├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml ├── scripts │ └── update-tag.bash └── workflows │ ├── master-coverage.yml │ ├── pull-request.yml │ ├── release-latest.yml │ └── release.yml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── COPYING ├── Cargo.lock ├── Cargo.toml ├── Makefile.toml ├── README.md ├── about.toml ├── build.rs ├── docs ├── .gitignore ├── CNAME ├── Gemfile ├── LICENSE ├── _config.yml ├── _layouts │ └── default.html ├── _sass │ └── theme.scss ├── _scripts │ ├── demo.bash │ ├── generate-frames.bash │ └── generate-gif.bash ├── assets │ ├── css │ │ └── style.scss │ └── images │ │ ├── girt-break.gif │ │ ├── girt-commit-diff.gif │ │ ├── girt-commit-overview.gif │ │ ├── girt-demo.gif │ │ ├── girt-edit.gif │ │ ├── girt-emoji.png │ │ ├── girt-external-editor.gif │ │ ├── girt-reorder.gif │ │ ├── girt-set-actions.gif │ │ ├── girt-unicode.png │ │ └── girt-visual-mode.gif ├── docker-compose.yml ├── index.md ├── licenses.hbs └── licenses.html ├── readme ├── customization.md └── install.md ├── src ├── application.rs ├── application │ └── app_data.rs ├── arguments.rs ├── components.rs ├── components │ ├── choice.rs │ ├── choice │ │ └── tests.rs │ ├── confirm.rs │ ├── confirm │ │ ├── confirmed.rs │ │ └── tests.rs │ ├── edit.rs │ ├── edit │ │ └── tests.rs │ ├── help.rs │ ├── help │ │ └── tests.rs │ ├── search_bar.rs │ ├── search_bar │ │ ├── action.rs │ │ ├── state.rs │ │ └── tests.rs │ ├── shared.rs │ ├── shared │ │ └── editable_line.rs │ └── spin_indicator.rs ├── config.rs ├── config │ ├── color.rs │ ├── config_loader.rs │ ├── diff_ignore_whitespace_setting.rs │ ├── diff_show_whitespace_setting.rs │ ├── errors.rs │ ├── errors │ │ ├── config_error_cause.rs │ │ └── invalid_color.rs │ ├── git_config.rs │ ├── key_bindings.rs │ ├── theme.rs │ ├── utils.rs │ └── utils │ │ ├── get_bool.rs │ │ ├── get_diff_ignore_whitespace.rs │ │ ├── get_diff_rename.rs │ │ ├── get_diff_show_whitespace.rs │ │ ├── get_input.rs │ │ ├── get_string.rs │ │ └── get_unsigned_integer.rs ├── diff.rs ├── diff │ ├── commit.rs │ ├── commit_diff.rs │ ├── commit_diff_loader.rs │ ├── commit_diff_loader_options.rs │ ├── delta.rs │ ├── diff_line.rs │ ├── file_mode.rs │ ├── file_status.rs │ ├── file_status_builder.rs │ ├── origin.rs │ ├── reference.rs │ ├── reference_kind.rs │ ├── status.rs │ ├── thread.rs │ ├── thread │ │ ├── action.rs │ │ ├── load_status.rs │ │ ├── state.rs │ │ └── update_handler.rs │ └── user.rs ├── display.rs ├── display │ ├── color_mode.rs │ ├── crossterm.rs │ ├── display_color.rs │ ├── error.rs │ ├── size.rs │ ├── tui.rs │ └── utils.rs ├── editor.rs ├── exit.rs ├── git.rs ├── git │ └── errors.rs ├── help.rs ├── input.rs ├── input │ ├── event.rs │ ├── event_handler.rs │ ├── event_provider.rs │ ├── input_options.rs │ ├── key_bindings.rs │ ├── key_event.rs │ ├── map_keybindings.rs │ ├── standard_event.rs │ ├── thread.rs │ └── thread │ │ └── state.rs ├── interactive-rebase-tool.1 ├── license.rs ├── main.rs ├── module.rs ├── module │ ├── exit_status.rs │ ├── module_handler.rs │ ├── module_provider.rs │ ├── modules.rs │ ├── state.rs │ └── tests.rs ├── modules.rs ├── modules │ ├── confirm_abort.rs │ ├── confirm_rebase.rs │ ├── error.rs │ ├── external_editor.rs │ ├── external_editor │ │ ├── action.rs │ │ ├── argument_tokenizer.rs │ │ ├── external_editor_state.rs │ │ └── tests.rs │ ├── insert.rs │ ├── insert │ │ ├── insert_state.rs │ │ ├── line_type.rs │ │ └── tests.rs │ ├── list.rs │ ├── list │ │ ├── search.rs │ │ ├── search │ │ │ ├── line_match.rs │ │ │ └── state.rs │ │ ├── tests.rs │ │ ├── tests │ │ │ ├── abort_and_rebase.rs │ │ │ ├── activate.rs │ │ │ ├── change_action.rs │ │ │ ├── duplicate_line.rs │ │ │ ├── edit_mode.rs │ │ │ ├── external_editor.rs │ │ │ ├── help.rs │ │ │ ├── insert_line.rs │ │ │ ├── movement.rs │ │ │ ├── normal_mode.rs │ │ │ ├── read_event.rs │ │ │ ├── remove_lines.rs │ │ │ ├── render.rs │ │ │ ├── search.rs │ │ │ ├── show_commit.rs │ │ │ ├── swap_lines.rs │ │ │ ├── toggle_break.rs │ │ │ ├── toggle_option.rs │ │ │ ├── undo_redo.rs │ │ │ └── visual_mode.rs │ │ └── utils.rs │ ├── show_commit.rs │ ├── show_commit │ │ ├── show_commit_state.rs │ │ ├── tests.rs │ │ ├── util.rs │ │ └── view_builder.rs │ └── window_size_error.rs ├── process.rs ├── process │ ├── artifact.rs │ ├── results.rs │ ├── tests.rs │ └── thread.rs ├── runtime.rs ├── runtime │ ├── errors.rs │ ├── installer.rs │ ├── notifier.rs │ ├── runtime.rs │ ├── status.rs │ ├── thread_statuses.rs │ └── threadable.rs ├── search.rs ├── search │ ├── action.rs │ ├── interrupter.rs │ ├── search_result.rs │ ├── searchable.rs │ ├── state.rs │ ├── status.rs │ ├── thread.rs │ └── update_handler.rs ├── test_helpers.rs ├── test_helpers │ ├── assertions.rs │ ├── assertions │ │ ├── assert_empty.rs │ │ ├── assert_not_empty.rs │ │ ├── assert_rendered_output.rs │ │ ├── assert_rendered_output │ │ │ ├── patterns.rs │ │ │ ├── render_style.rs │ │ │ ├── render_view_data.rs │ │ │ └── render_view_line.rs │ │ └── assert_results.rs │ ├── builders.rs │ ├── builders │ │ ├── commit.rs │ │ ├── commit_diff.rs │ │ ├── file_status.rs │ │ └── reference.rs │ ├── create_commit.rs │ ├── create_config.rs │ ├── create_default_test_module_handler.rs │ ├── create_event_reader.rs │ ├── create_invalid_utf.rs │ ├── create_test_keybindings.rs │ ├── create_test_module_handler.rs │ ├── mocks.rs │ ├── mocks │ │ ├── crossterm.rs │ │ ├── notifier.rs │ │ └── searchable.rs │ ├── shared.rs │ ├── shared │ │ ├── git2.rs │ │ ├── module.rs │ │ ├── replace_invisibles.rs │ │ └── with_temporary_path.rs │ ├── testers.rs │ ├── testers │ │ ├── module.rs │ │ ├── process.rs │ │ ├── read_event.rs │ │ ├── searchable.rs │ │ └── threadable.rs │ ├── with_env_var.rs │ ├── with_event_handler.rs │ ├── with_git_config.rs │ ├── with_git_directory.rs │ ├── with_search.rs │ ├── with_temp_bare_repository.rs │ ├── with_temp_repository.rs │ ├── with_todo_file.rs │ └── with_view_state.rs ├── tests.rs ├── todo_file.rs ├── todo_file │ ├── action.rs │ ├── edit_content.rs │ ├── errors.rs │ ├── errors │ │ ├── io.rs │ │ └── parse.rs │ ├── history.rs │ ├── history │ │ ├── history_item.rs │ │ ├── operation.rs │ │ └── tests.rs │ ├── line.rs │ ├── line_parser.rs │ ├── todo_file_options.rs │ └── utils.rs ├── util.rs ├── version.rs ├── view.rs └── view │ ├── line_segment.rs │ ├── render_context.rs │ ├── render_slice.rs │ ├── render_slice │ ├── render_action.rs │ └── tests.rs │ ├── scroll_position.rs │ ├── tests.rs │ ├── thread.rs │ ├── thread │ ├── action.rs │ └── state.rs │ ├── view_data.rs │ ├── view_data_updater.rs │ ├── view_line.rs │ └── view_lines.rs └── test ├── fixtures ├── invalid-config │ ├── HEAD │ ├── config │ ├── objects │ │ └── ae │ │ │ └── d0fd1db3e73c0e568677ae8903a11c5fbc5659 │ └── refs │ │ └── heads │ │ └── master ├── not-a-repository │ └── .gitkeep └── simple │ ├── HEAD │ ├── config │ ├── objects │ ├── 18 │ │ ├── a2bd71d9c48b793fe60a390cdf08f48e795abb │ │ └── d82dcc4c36cade807d7cf79700b6bbad8080b9 │ ├── 22 │ │ └── 3b7836fb19fdf64ba2d3cd6173c6a283141f78 │ ├── 27 │ │ └── 4006cec98796695eb5fbc66336c09d06b7cc35 │ ├── 28 │ │ └── 36dcdcbd040f9157652dd3db0d584a44d4793d │ ├── 31 │ │ └── ca0c0283104a7c6532a8fce1df1b83a8063159 │ ├── 35 │ │ └── 6f526abb39f15fd9d3fea57cf3ff1d1a400a22 │ ├── 46 │ │ └── 79e4849c8d0578dd0801f5f5c1d5bfc65feb26 │ ├── 64 │ │ └── 99b1dcdbf3020be36ef51f27cb12c53ab779a8 │ ├── 66 │ │ └── 22be15a9bd68ae17baa125c6af09efd577053c │ ├── 01 │ │ └── 82075d5b79ff61177e6314a8e5bff640f99caa │ ├── 1c │ │ └── c0456637cb220155e957c641f483e60724c581 │ ├── 2b │ │ └── 33ed150ddc749651eead8f8ef45ae18760a64a │ ├── 2e │ │ └── b17981c49e604a4894b94ae3cd7ce4b3ca29a1 │ ├── 3b │ │ └── 03afff0ca32dad434d3703dd5c6b8216eccb9d │ ├── 3c │ │ └── c58df83752123644fef39faab2393af643b1d2 │ ├── 4b │ │ └── 825dc642cb6eb9a060e54bf8d69288fbee4904 │ ├── 7f │ │ └── 5eac44012ea33e5bdec0df72125c1bc2b2691d │ ├── ac │ │ └── 950e31a96660e55d8034948b5d9b985c97692d │ ├── ae │ │ └── d0fd1db3e73c0e568677ae8903a11c5fbc5659 │ ├── b4 │ │ └── f179909d96883b73eff159c293cf1b5320b8ae │ ├── ba │ │ └── e175bd8992c5c05b858fa2f9b63193ab92a1f0 │ ├── c0 │ │ └── 28f42bdb2a5a9f80adea23d95eb240b994a6c2 │ ├── c1 │ │ ├── 43f6d98cbd8e6e959439c41da3bb8127e23385 │ │ └── ac7f2c32f9e00012f409572d223c9457ae497b │ ├── d8 │ │ └── 5479638307e4db37e1f1f2c3c807f7ff36a0ff │ ├── d9 │ │ └── 05d9da82c97264ab6f4920e20242e088850ce9 │ ├── e1 │ │ └── 0b3f474644d8566947104c07acba4d6f4f4f9f │ ├── f5 │ │ └── b6d3334d82cb2f7cf7ecea806a86f06020b163 │ ├── f7 │ │ └── 0f10e4db19068f79bc43844b49f3eece45c4e8 │ └── fe │ │ └── d706611bd9077feb0268ce7ddcff2bbe5ed939 │ ├── rebase-todo │ ├── rebase-todo-empty │ ├── rebase-todo-noop │ └── refs │ └── heads │ └── master └── not-executable.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_size = 4 8 | 9 | [*.md] 10 | indent_style = space 11 | 12 | [*.yml] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mitmaro] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "08:30" 8 | open-pull-requests-limit: 0 9 | -------------------------------------------------------------------------------- /.github/scripts/update-tag.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -u 5 | set -o pipefail 6 | 7 | master_ref="$(git rev-parse "$DEFAULT_BRANCH")" 8 | master_ref_short="$(git rev-parse --short "$DEFAULT_BRANCH")" 9 | 10 | curl -X PATCH \ 11 | -H "accept: application/vnd.github.dorian-preview+json" \ 12 | -H "content-type: application/json" \ 13 | -H "authorization: token $GITHUB_ACCESS_TOKEN" \ 14 | -d "{\"sha\": \"$master_ref\", \"force\": true}" \ 15 | "https://api.github.com/repos/$REPOSITORY/git/refs/tags/latest" 16 | 17 | curl -X PATCH \ 18 | -H "accept: application/vnd.github.dorian-preview+json" \ 19 | -H "content-type: application/json" \ 20 | -H "authorization: token $GITHUB_ACCESS_TOKEN" \ 21 | -d "{\"name\": \"Latest Release ($master_ref_short)\"}" \ 22 | "https://api.github.com/repos/$REPOSITORY/releases/$TARGET_RELEASE_ID" 23 | -------------------------------------------------------------------------------- /.github/workflows/master-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Master Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | coverage: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: dtolnay/rust-toolchain@nightly 15 | with: 16 | toolchain: nightly 17 | - uses: Swatinem/rust-cache@v2 18 | - uses: baptiste0928/cargo-install@v2 19 | with: 20 | crate: cargo-tarpaulin 21 | - name: Run cargo-tarpaulin 22 | run: | 23 | cargo +nightly tarpaulin --ignore-tests --line --output-dir coverage --timeout 10 --out Lcov 24 | - name: Post to Coveralls 25 | uses: coverallsapp/github-action@v2 26 | with: 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | path-to-lcov: "coverage/lcov.info" 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | target 3 | 4 | test/git-rebase-todo-scratch 5 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Last Updated Version: 1.8.0 2 | # array_width - allow use_small_heuristics 3 | # attr_fn_like_width - allow use_small_heuristics 4 | binop_separator = "Front" 5 | blank_lines_lower_bound = 0 6 | blank_lines_upper_bound = 1 7 | brace_style = "SameLineWhere" 8 | # chain_width - allow use_small_heuristics 9 | color = "Auto" 10 | combine_control_expr = false 11 | comment_width = 120 12 | condense_wildcard_suffixes = true 13 | control_brace_style = "ClosingNextLine" 14 | disable_all_formatting = false 15 | # edition - allow default read from Cargo.toml 16 | empty_item_single_line = true 17 | enum_discrim_align_threshold = 0 18 | error_on_line_overflow = true 19 | error_on_unformatted = true 20 | # fn_args_layout = "Tall" - Deprecated, see fn_params_layout 21 | # fn_call_width - allow use_small_heuristics 22 | fn_params_layout = "Tall" 23 | fn_single_line = false 24 | force_explicit_abi = true 25 | force_multiline_blocks = true 26 | format_code_in_doc_comments = true 27 | doc_comment_code_block_width = 120 28 | format_generated_files = false 29 | # generated_marker_line_search_limit - Allow default 30 | format_macro_matchers = true 31 | format_macro_bodies = true 32 | skip_macro_invocations = [] 33 | format_strings = true 34 | hard_tabs = true 35 | hex_literal_case = "Upper" 36 | # hide_parse_errors - Deprecated, see show_parse_errors 37 | show_parse_errors = true 38 | ignore = [] 39 | imports_indent = "Block" 40 | imports_layout = "HorizontalVertical" 41 | indent_style = "block" 42 | inline_attribute_width = 0 43 | match_arm_blocks = true 44 | match_arm_leading_pipes = "Never" 45 | match_block_trailing_comma = true 46 | max_width = 120 47 | merge_derives = true 48 | imports_granularity = "Crate" 49 | # merge_imports - deprecated 50 | newline_style = "Unix" 51 | normalize_comments = true 52 | normalize_doc_attributes = true 53 | overflow_delimited_expr = true 54 | remove_nested_parens = true 55 | reorder_impl_items = true 56 | reorder_imports = true 57 | group_imports = "StdExternalCrate" 58 | reorder_modules = true 59 | # required_version - allow default 60 | short_array_element_width_threshold = 8 61 | skip_children = false 62 | # single_line_if_else_max_width - allow use_small_heuristics 63 | # single_line_let_else_max_width - allow use_small_heuristics 64 | space_after_colon = true 65 | space_before_colon = false 66 | spaces_around_ranges = false 67 | struct_field_align_threshold = 0 68 | struct_lit_single_line = true 69 | # struct_lit_width - allow use_small_heuristics 70 | # struct_variant_width - allow use_small_heuristics 71 | style_edition = "2024" 72 | tab_spaces = 4 73 | trailing_comma = "Vertical" 74 | trailing_semicolon = true 75 | type_punctuation_density = "Wide" 76 | unstable_features = true 77 | use_field_init_shorthand = true 78 | use_small_heuristics = "Default" 79 | use_try_shorthand = true 80 | # version = "Two" - Deprecated, see style_edition 81 | where_single_line = true 82 | wrap_comments = true 83 | -------------------------------------------------------------------------------- /about.toml: -------------------------------------------------------------------------------- 1 | accepted = [ 2 | "Apache-2.0", 3 | "BSD-2-Clause", 4 | "BSD-3-Clause", 5 | "GPL-3.0", 6 | "ISC", 7 | "MIT", 8 | ] 9 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, process}; 2 | 3 | use chrono::{TimeZone as _, Utc}; 4 | use rustc_version::{Channel, version_meta}; 5 | 6 | fn git_revision_hash() -> Option { 7 | let result = process::Command::new("git") 8 | .args(["rev-parse", "--short=10", "HEAD"]) 9 | .output(); 10 | let output = result.ok()?; 11 | let v = String::from(String::from_utf8_lossy(&output.stdout).trim()); 12 | if v.is_empty() { None } else { Some(v) } 13 | } 14 | 15 | fn main() { 16 | println!("cargo::rustc-check-cfg=cfg(allow_unknown_lints)"); 17 | println!("cargo::rustc-check-cfg=cfg(include_nightly_lints)"); 18 | // allow unknown lints in nightly builds 19 | if let Ok(meta) = version_meta() { 20 | if meta.channel == Channel::Nightly { 21 | println!("cargo:rustc-cfg=allow_unknown_lints"); 22 | println!("cargo:rustc-cfg=include_nightly_lints"); 23 | } 24 | } 25 | 26 | // Make the current git hash available to the build 27 | if let Some(rev) = git_revision_hash() { 28 | println!("cargo:rustc-env=GIRT_BUILD_GIT_HASH={rev}"); 29 | } 30 | 31 | // Use provided SOURCE_DATE_EPOCH to make builds reproducible 32 | let build_date = match env::var("SOURCE_DATE_EPOCH") { 33 | Ok(val) => Utc.timestamp_opt(val.parse::().unwrap(), 0).unwrap(), 34 | Err(_) => Utc::now(), 35 | }; 36 | println!("cargo:rustc-env=GIRT_BUILD_DATE={}", build_date.format("%Y-%m-%d")); 37 | } 38 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | vendor 3 | 4 | .sass-cache 5 | Gemfile.lock 6 | *.gem 7 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | gitrebasetool.mitmaro.ca 2 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'jekyll' 4 | 5 | group :jekyll_plugins do 6 | gem 'github-pages' 7 | end 8 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Tim Oram and all contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software 4 | for any purpose with or without fee is hereby granted, provided 5 | that the above copyright notice and this permission notice appear 6 | in all copies. 7 | 8 | The software is provided "as is" and the author disclaims all 9 | warranties with regard to this software including all implied 10 | warranties of merchantability and fitness. In no event shall the 11 | author be liable for any special, direct, indirect, or 12 | consequential damages or any damages whatsoever resulting from 13 | loss of use, data or profits, whether in an action of contract, 14 | negligence or other tortious action, arising out of or in connection 15 | with the use or performance of this software. 16 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: Git Interactive Rebase Tool 2 | description: An improved sequence editor for Git 3 | show_downloads: true 4 | repository: MitMaro/git-interactive-rebase-tool 5 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ site.title }} by MitMaro 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 |
17 |
18 |

{{ site.title }}

19 |

{{ site.description }}

20 |
21 | 30 |
31 |
32 | Git Interactive Rebase Tool Demo 33 |
34 |
35 | {{ content }} 36 |
37 | 44 |
45 | Fork me on GitHub 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/_scripts/demo.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### Instructions 4 | # This must be run from the mitmaro/demo branch 5 | # Create a rebase file with the contents 6 | : " 7 | pick f7e0f51 Add diff configuration settings 8 | fixup a24b8e4 fixup! Add diff configuration settings 9 | pick 30ca7cd Fix modified, should be change 10 | pick 5f14c38 Fix typo 11 | pick c695bff Add key bindings configuration 12 | pick 384a40a Refactor configuration 13 | pick ef303c7 Update variable names in git_config 14 | pick ad7042e Add theme configuration 15 | exec cargo build 16 | pick 5d89b27 Add config utilities 17 | pick 6ab2fc4 Major refactor of the configuration 18 | " 19 | # Window should be 11 lines tall for diff display (10 display lines + title) 20 | # Run `xdotool getactivewindow` to fet the window for running the follow script 21 | # Start screen recording over the window 22 | # Run with `./demo.bash ` 23 | # Wait until finish 24 | 25 | this_window="$(xdotool getactivewindow)" 26 | window_number="$1" 27 | _xdotool="xdotool" 28 | 29 | printf "Starting in 3\r"; 30 | for value in {5..1}; do 31 | printf "Starting in ${value}\r"; 32 | sleep 1 33 | done 34 | printf "\33[2KRunning...\r" 35 | 36 | # `xev` can be used to find key names 37 | commands=( 38 | # actions 39 | "key;Down;0.2;3" 40 | "key;r;0.3" 41 | "key;Down;0.1" 42 | "key;e;0.3" 43 | "key;Down;0.1" 44 | "key;s;0.3" 45 | "key;Down;0.1" 46 | "key;f;0.3" 47 | "key;Down;0.1" 48 | "key;d;0.3" 49 | "sleep;0.3" 50 | # visual mode set action 51 | "key;v;0.3" 52 | "key;Up;0.1;4" 53 | "key;p;0.3" 54 | "key;v;0.3;2" 55 | "key;Down;0.2;2" 56 | "key;f;0.1" 57 | "key;j;0.2;3" 58 | "sleep;0.2" 59 | "key;k;0.2;2" 60 | "key;v;0.3" 61 | "sleep;0.3" 62 | # break 63 | "key;Down;0.1" 64 | "key;b;0.4;3" 65 | # edit 66 | "key;Prior;0.1;3" 67 | "key;Down;0.1;2" 68 | "sleep;0.3" 69 | "key;E;0.4" 70 | "key;BackSpace;0.1;5" 71 | "type;make;150" 72 | "sleep;0.3" 73 | "key;Return;0.2" 74 | "sleep;0.3" 75 | # show commit 76 | "key;Up;0.1" 77 | "key;c;0.3" 78 | "key;Down;0.2;4" 79 | "sleep;0.5" 80 | "key;c;0.1" 81 | "key;Up;0.3" 82 | "key;c;0" 83 | "key;d;0.2" 84 | "key;Down;0.2;14" 85 | "key;d;0" 86 | "key;c;0" 87 | "sleep;0.5" 88 | # External editor 89 | "key;exclam;0.3" 90 | "sleep;0.5" 91 | "key;Down;0.1" 92 | "key;i;0.1" 93 | "key;Delete;0.1;5" 94 | "type;drop;150" 95 | "key;Escape" 96 | "type;:wq;0.1" 97 | "key;Return;0.1" 98 | ) 99 | 100 | 101 | $_xdotool windowactivate "$window_number" 102 | sleep 1 103 | 104 | for c in "${commands[@]}"; do 105 | IFS=';' read -ra p <<< "$c" 106 | case "${p[0]}" in 107 | "type") 108 | $_xdotool type --delay "${p[2]}" -- "${p[1]}" 109 | ;; 110 | "key") 111 | for i in $(seq 1 "${p[3]:-1}"); do 112 | $_xdotool key "${p[1]}" 113 | sleep "${p[2]}" 114 | done 115 | ;; 116 | "sleep") 117 | sleep "${p[1]}" 118 | ;; 119 | *) 120 | echo "Invalid sequence: '${c}'" 121 | ;; 122 | esac 123 | done 124 | 125 | 126 | xdotool windowactivate "$this_window" 127 | printf "\33[2KDone...\n``" 128 | -------------------------------------------------------------------------------- /docs/_scripts/generate-frames.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # usage generate-frames.bash file-without-extension 4 | 5 | name="$1" 6 | set -x 7 | rm -rf frames/ 8 | mkdir -p frames/ 9 | ffmpeg -i "${name}.mp4" -r 20 'frames/frame-%03d.png' 10 | -------------------------------------------------------------------------------- /docs/_scripts/generate-gif.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # usage generate-gif.bash name crop 4 | # crop value is (left,top-right,bottom) 5 | 6 | name="$1" 7 | crop="$2" 8 | shift 2 9 | set -x 10 | convert -monitor -delay 10 -loop 0 "$@" "${name}-raw.gif" 11 | gifsicle --crop "$crop" --colors 256 -O3 -o "${name}.gif" "${name}-raw.gif" 12 | -------------------------------------------------------------------------------- /docs/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | @import "theme"; 4 | -------------------------------------------------------------------------------- /docs/assets/images/girt-break.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-break.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-commit-diff.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-commit-diff.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-commit-overview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-commit-overview.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-demo.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-edit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-edit.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-emoji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-emoji.png -------------------------------------------------------------------------------- /docs/assets/images/girt-external-editor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-external-editor.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-reorder.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-reorder.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-set-actions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-set-actions.gif -------------------------------------------------------------------------------- /docs/assets/images/girt-unicode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-unicode.png -------------------------------------------------------------------------------- /docs/assets/images/girt-visual-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/docs/assets/images/girt-visual-mode.gif -------------------------------------------------------------------------------- /docs/docker-compose.yml: -------------------------------------------------------------------------------- 1 | jekyll: 2 | image: jekyll/jekyll 3 | command: jekyll serve --watch --config _config.yml 4 | ports: 5 | - 4000:4000 6 | volumes: 7 | - .:/srv/jekyll 8 | - ./vendor/bundle:/usr/local/bundle 9 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | -------------------------------------------------------------------------------- /docs/licenses.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 36 | 37 | 38 | 39 |
40 |
41 |

Third Party Licenses

42 |

This page lists the licenses of the projects used in Git Interactive Rebase Tool.

43 |
44 | 45 |

Overview of licenses:

46 |
    47 | {{#each overview}} 48 |
  • {{name}} ({{count}})
  • 49 | {{/each}} 50 |
51 | 52 |

All license text:

53 |
    54 | {{#each licenses}} 55 |
  • 56 |

    {{name}}

    57 |

    Used by:

    58 | 63 |
    {{text}}
    64 |
  • 65 | {{/each}} 66 |
67 |
68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/application/app_data.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use parking_lot::Mutex; 4 | 5 | use crate::{config::Config, diff, input, module, search, todo_file::TodoFile, view}; 6 | 7 | #[derive(Clone, Debug)] 8 | pub(crate) struct AppData { 9 | config: Arc, 10 | active_module: Arc>, 11 | todo_file: Arc>, 12 | diff_state: diff::thread::State, 13 | view_state: view::State, 14 | input_state: input::State, 15 | search_state: search::State, 16 | } 17 | 18 | impl AppData { 19 | pub(crate) fn new( 20 | config: Config, 21 | active_module: module::State, 22 | todo_file: Arc>, 23 | diff_state: diff::thread::State, 24 | view_state: view::State, 25 | input_state: input::State, 26 | search_state: search::State, 27 | ) -> Self { 28 | Self { 29 | config: Arc::new(config), 30 | active_module: Arc::new(Mutex::new(active_module)), 31 | todo_file, 32 | diff_state, 33 | view_state, 34 | input_state, 35 | search_state, 36 | } 37 | } 38 | 39 | pub(crate) fn config(&self) -> Arc { 40 | Arc::clone(&self.config) 41 | } 42 | 43 | pub(crate) fn active_module(&self) -> Arc> { 44 | Arc::clone(&self.active_module) 45 | } 46 | 47 | pub(crate) fn todo_file(&self) -> Arc> { 48 | Arc::clone(&self.todo_file) 49 | } 50 | 51 | pub(crate) fn diff_state(&self) -> diff::thread::State { 52 | self.diff_state.clone() 53 | } 54 | 55 | pub(crate) fn view_state(&self) -> view::State { 56 | self.view_state.clone() 57 | } 58 | 59 | pub(crate) fn input_state(&self) -> input::State { 60 | self.input_state.clone() 61 | } 62 | 63 | pub(crate) fn search_state(&self) -> search::State { 64 | self.search_state.clone() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/arguments.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | 3 | use pico_args::Arguments; 4 | 5 | use crate::{exit::Exit, module::ExitStatus}; 6 | 7 | #[derive(Debug, Eq, PartialEq)] 8 | pub(crate) enum Mode { 9 | Editor, 10 | Help, 11 | Version, 12 | License, 13 | } 14 | 15 | #[derive(Debug)] 16 | pub(crate) struct Args { 17 | mode: Mode, 18 | todo_file_path: Option, 19 | } 20 | 21 | impl Args { 22 | pub(crate) const fn mode(&self) -> &Mode { 23 | &self.mode 24 | } 25 | 26 | pub(crate) fn todo_file_path(&self) -> Option<&str> { 27 | self.todo_file_path.as_deref() 28 | } 29 | } 30 | 31 | impl TryFrom> for Args { 32 | type Error = Exit; 33 | 34 | fn try_from(args: Vec) -> Result { 35 | let mut pargs = Arguments::from_vec(args); 36 | 37 | let mode = if pargs.contains(["-h", "--help"]) { 38 | Mode::Help 39 | } 40 | else if pargs.contains(["-v", "--version"]) { 41 | Mode::Version 42 | } 43 | else if pargs.contains("--license") { 44 | Mode::License 45 | } 46 | else { 47 | Mode::Editor 48 | }; 49 | 50 | let todo_file_path = pargs 51 | .opt_free_from_str() 52 | .map_err(|err| Exit::new(ExitStatus::StateError, err.to_string().as_str()))?; 53 | 54 | Ok(Self { mode, todo_file_path }) 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | 62 | fn create_args(args: &[&str]) -> Vec { 63 | args.iter().map(OsString::from).collect() 64 | } 65 | 66 | #[test] 67 | fn mode_help() { 68 | assert_eq!(Args::try_from(create_args(&["-h"])).unwrap().mode(), &Mode::Help); 69 | assert_eq!(Args::try_from(create_args(&["--help"])).unwrap().mode(), &Mode::Help); 70 | } 71 | 72 | #[test] 73 | fn mode_version() { 74 | assert_eq!(Args::try_from(create_args(&["-v"])).unwrap().mode(), &Mode::Version); 75 | assert_eq!( 76 | Args::try_from(create_args(&["--version"])).unwrap().mode(), 77 | &Mode::Version 78 | ); 79 | } 80 | 81 | #[test] 82 | fn mode_license() { 83 | assert_eq!( 84 | Args::try_from(create_args(&["--license"])).unwrap().mode(), 85 | &Mode::License 86 | ); 87 | } 88 | 89 | #[test] 90 | fn todo_file_ok() { 91 | let args = Args::try_from(create_args(&["todofile"])).unwrap(); 92 | assert_eq!(args.mode(), &Mode::Editor); 93 | assert_eq!(args.todo_file_path(), Some("todofile")); 94 | } 95 | 96 | #[test] 97 | fn todo_file_missing() { 98 | let args = Args::try_from(create_args(&[])).unwrap(); 99 | assert_eq!(args.mode(), &Mode::Editor); 100 | assert!(args.todo_file_path().is_none()); 101 | } 102 | 103 | #[cfg(unix)] 104 | #[test] 105 | #[expect(unsafe_code)] 106 | fn todo_file_invalid() { 107 | let args = unsafe { vec![OsString::from(String::from_utf8_unchecked(vec![0xC3, 0x28]))] }; 108 | _ = Args::try_from(args).unwrap_err(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/components.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod choice; 2 | pub(crate) mod confirm; 3 | pub(crate) mod edit; 4 | pub(crate) mod help; 5 | pub(crate) mod search_bar; 6 | mod shared; 7 | pub(crate) mod spin_indicator; 8 | -------------------------------------------------------------------------------- /src/components/choice.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests; 3 | 4 | use std::{collections::HashMap, sync::LazyLock}; 5 | 6 | use crate::{ 7 | display::DisplayColor, 8 | input::{Event, InputOptions, KeyCode}, 9 | util::handle_view_data_scroll, 10 | view::{self, LineSegment, ViewData, ViewLine, ViewLines}, 11 | }; 12 | 13 | pub(crate) static INPUT_OPTIONS: LazyLock = 14 | LazyLock::new(|| InputOptions::RESIZE | InputOptions::MOVEMENT); 15 | 16 | pub(crate) struct Choice { 17 | map: HashMap, 18 | view_data: ViewData, 19 | options: Vec<(T, char, String)>, 20 | invalid_selection: bool, 21 | } 22 | 23 | impl Choice 24 | where T: Clone 25 | { 26 | #[expect(clippy::pattern_type_mismatch, reason = "Legacy, needs refactor.")] 27 | pub(crate) fn new(options: Vec<(T, char, String)>) -> Self { 28 | let map = options 29 | .iter() 30 | .map(|(v, k, _)| { 31 | let c = *k; 32 | let t = v.clone(); 33 | (c, t) 34 | }) 35 | .collect::>(); 36 | Self { 37 | map, 38 | options, 39 | view_data: ViewData::new(|updater| { 40 | updater.set_show_title(true); 41 | updater.set_retain_scroll_position(false); 42 | }), 43 | invalid_selection: false, 44 | } 45 | } 46 | 47 | pub(crate) fn set_prompt(&mut self, prompt_lines: ViewLines) { 48 | self.view_data.update_view_data(|updater| { 49 | updater.clear(); 50 | for line in prompt_lines { 51 | updater.push_leading_line(line); 52 | } 53 | updater.push_leading_line(ViewLine::new_empty_line()); 54 | }); 55 | } 56 | 57 | #[expect(clippy::pattern_type_mismatch, reason = "Legacy, needs refactor.")] 58 | pub(crate) fn get_view_data(&mut self) -> &ViewData { 59 | let options = &self.options; 60 | let invalid_selection = self.invalid_selection; 61 | self.view_data.update_view_data(|updater| { 62 | updater.clear_body(); 63 | for (_, key, description) in options { 64 | updater.push_line(ViewLine::from(format!("{key}) {description}"))); 65 | } 66 | updater.push_line(ViewLine::new_empty_line()); 67 | if invalid_selection { 68 | updater.push_line(ViewLine::from(LineSegment::new_with_color( 69 | "Invalid option selected. Please choose an option.", 70 | DisplayColor::IndicatorColor, 71 | ))); 72 | } 73 | else { 74 | updater.push_line(ViewLine::from(LineSegment::new_with_color( 75 | "Please choose an option.", 76 | DisplayColor::IndicatorColor, 77 | ))); 78 | } 79 | }); 80 | &self.view_data 81 | } 82 | 83 | pub(crate) fn handle_event(&mut self, event: Event, view_state: &view::State) -> Option<&T> { 84 | if handle_view_data_scroll(event, view_state).is_none() { 85 | if let Event::Key(key_event) = event { 86 | if let KeyCode::Char(c) = key_event.code { 87 | if let Some(v) = self.map.get(&c) { 88 | self.invalid_selection = false; 89 | return Some(v); 90 | } 91 | } 92 | self.invalid_selection = true; 93 | } 94 | } 95 | None 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/components/confirm.rs: -------------------------------------------------------------------------------- 1 | mod confirmed; 2 | #[cfg(test)] 3 | mod tests; 4 | 5 | use std::sync::LazyLock; 6 | 7 | use captur::capture; 8 | 9 | pub(crate) use self::confirmed::Confirmed; 10 | use crate::{ 11 | input::{Event, InputOptions, KeyBindings, KeyCode, KeyEvent, StandardEvent}, 12 | view::{ViewData, ViewLine}, 13 | }; 14 | 15 | pub(crate) static INPUT_OPTIONS: LazyLock = 16 | LazyLock::new(|| InputOptions::RESIZE | InputOptions::MOVEMENT); 17 | 18 | pub(crate) struct Confirm { 19 | view_data: ViewData, 20 | } 21 | 22 | impl Confirm { 23 | pub(crate) fn new(prompt: &str, confirm_yes: &[String], confirm_no: &[String]) -> Self { 24 | let view_data = ViewData::new(|updater| { 25 | capture!(confirm_yes, confirm_no); 26 | updater.set_show_title(true); 27 | updater.set_retain_scroll_position(false); 28 | updater.push_line(ViewLine::from(format!( 29 | "{prompt} ({}/{})? ", 30 | confirm_yes.join(","), 31 | confirm_no.join(",") 32 | ))); 33 | }); 34 | Self { view_data } 35 | } 36 | 37 | pub(crate) fn get_view_data(&mut self) -> &ViewData { 38 | &self.view_data 39 | } 40 | 41 | pub(crate) fn read_event(event: Event, key_bindings: &KeyBindings) -> Event { 42 | if let Event::Key(key) = event { 43 | if let KeyCode::Char(c) = key.code { 44 | let event_lower_modifiers = key.modifiers; 45 | let event_lower = Event::Key(KeyEvent::new( 46 | KeyCode::Char(c.to_ascii_lowercase()), 47 | event_lower_modifiers, 48 | )); 49 | let event_upper = Event::Key(KeyEvent::new(KeyCode::Char(c.to_ascii_uppercase()), key.modifiers)); 50 | 51 | return if key_bindings.confirm_yes.contains(&event_lower) 52 | || key_bindings.confirm_yes.contains(&event_upper) 53 | { 54 | Event::from(StandardEvent::Yes) 55 | } 56 | else { 57 | Event::from(StandardEvent::No) 58 | }; 59 | } 60 | } 61 | event 62 | } 63 | 64 | pub(crate) const fn handle_event(&self, event: Event) -> Confirmed { 65 | if let Event::Standard(standard_event) = event { 66 | match standard_event { 67 | StandardEvent::Yes => Confirmed::Yes, 68 | StandardEvent::No => Confirmed::No, 69 | _ => Confirmed::Other, 70 | } 71 | } 72 | else { 73 | Confirmed::Other 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/confirm/confirmed.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Eq)] 2 | pub(crate) enum Confirmed { 3 | Yes, 4 | No, 5 | Other, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/confirm/tests.rs: -------------------------------------------------------------------------------- 1 | use rstest::rstest; 2 | 3 | use super::*; 4 | use crate::{ 5 | assert_rendered_output, 6 | input::StandardEvent, 7 | test_helpers::{assertions::assert_rendered_output::AssertRenderOptions, create_test_keybindings}, 8 | }; 9 | 10 | #[test] 11 | fn render() { 12 | let mut module = Confirm::new("Prompt message", &[String::from("y"), String::from("Z")], &[ 13 | String::from("n"), 14 | String::from("X"), 15 | ]); 16 | assert_rendered_output!( 17 | Options AssertRenderOptions::INCLUDE_TRAILING_WHITESPACE | AssertRenderOptions::BODY_ONLY, 18 | module.get_view_data(), 19 | "Prompt message (y,Z/n,X)? " 20 | ); 21 | } 22 | 23 | #[test] 24 | fn read_event_yes_uppercase() { 25 | assert_eq!( 26 | Confirm::read_event(Event::from('Y'), &create_test_keybindings()), 27 | Event::from(StandardEvent::Yes) 28 | ); 29 | } 30 | 31 | #[test] 32 | fn read_event_yes_lowercase() { 33 | assert_eq!( 34 | Confirm::read_event(Event::from('y'), &create_test_keybindings()), 35 | Event::from(StandardEvent::Yes) 36 | ); 37 | } 38 | 39 | #[test] 40 | fn read_event_no_lowercase() { 41 | assert_eq!( 42 | Confirm::read_event(Event::from('n'), &create_test_keybindings()), 43 | Event::from(StandardEvent::No) 44 | ); 45 | } 46 | 47 | #[test] 48 | fn read_event_no_uppercase() { 49 | assert_eq!( 50 | Confirm::read_event(Event::from('N'), &create_test_keybindings()), 51 | Event::from(StandardEvent::No) 52 | ); 53 | } 54 | 55 | #[test] 56 | fn read_event_not_key_event() { 57 | assert_eq!( 58 | Confirm::read_event(Event::None, &create_test_keybindings()), 59 | Event::None 60 | ); 61 | } 62 | 63 | #[test] 64 | fn read_event_not_char_event() { 65 | assert_eq!( 66 | Confirm::read_event(Event::from(KeyCode::Backspace), &create_test_keybindings()), 67 | Event::from(KeyCode::Backspace) 68 | ); 69 | } 70 | 71 | #[test] 72 | fn handle_event_yes() { 73 | let module = Confirm::new("Prompt message", &[], &[]); 74 | let confirmed = module.handle_event(Event::from(StandardEvent::Yes)); 75 | assert_eq!(confirmed, Confirmed::Yes); 76 | } 77 | 78 | #[test] 79 | fn handle_event_no() { 80 | let module = Confirm::new("Prompt message", &[], &[]); 81 | let confirmed = module.handle_event(Event::from(StandardEvent::No)); 82 | assert_eq!(confirmed, Confirmed::No); 83 | } 84 | 85 | #[rstest] 86 | #[case::resize(Event::Resize(100, 100))] 87 | #[case::scroll_left(Event::from(StandardEvent::ScrollLeft))] 88 | #[case::scroll_right(Event::from(StandardEvent::ScrollRight))] 89 | #[case::scroll_down(Event::from(StandardEvent::ScrollDown))] 90 | #[case::scroll_up(Event::from(StandardEvent::ScrollUp))] 91 | #[case::scroll_jump_down(Event::from(StandardEvent::ScrollJumpDown))] 92 | #[case::scroll_jump_up(Event::from(StandardEvent::ScrollJumpUp))] 93 | fn input_standard(#[case] event: Event) { 94 | let module = Confirm::new("Prompt message", &[], &[]); 95 | let confirmed = module.handle_event(event); 96 | assert_eq!(confirmed, Confirmed::Other); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/edit.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests; 3 | 4 | use std::sync::LazyLock; 5 | 6 | use crate::{ 7 | components::shared::EditableLine, 8 | display::DisplayColor, 9 | input::{Event, InputOptions, KeyCode, KeyEvent, KeyModifiers}, 10 | view::{LineSegment, LineSegmentOptions, ViewData, ViewDataUpdater, ViewLine}, 11 | }; 12 | 13 | pub(crate) static INPUT_OPTIONS: LazyLock = LazyLock::new(|| InputOptions::RESIZE); 14 | 15 | const FINISH_EVENT: Event = Event::Key(KeyEvent { 16 | code: KeyCode::Enter, 17 | modifiers: KeyModifiers::NONE, 18 | }); 19 | 20 | pub(crate) struct Edit { 21 | editable_line: EditableLine, 22 | finished: bool, 23 | view_data: ViewData, 24 | } 25 | 26 | impl Edit { 27 | pub(crate) fn new() -> Self { 28 | let view_data = ViewData::new(|updater| { 29 | updater.set_show_title(true); 30 | }); 31 | Self { 32 | editable_line: EditableLine::new(), 33 | finished: false, 34 | view_data, 35 | } 36 | } 37 | 38 | pub(crate) fn build_view_data(&mut self, before_build: F, after_build: G) -> &ViewData 39 | where 40 | F: FnOnce(&mut ViewDataUpdater<'_>), 41 | G: FnOnce(&mut ViewDataUpdater<'_>), 42 | { 43 | self.view_data.update_view_data(|updater| { 44 | updater.clear(); 45 | before_build(updater); 46 | updater.push_line(ViewLine::from(self.editable_line.line_segments())); 47 | updater.push_trailing_line(ViewLine::new_pinned(vec![LineSegment::new_with_color( 48 | "Enter to finish", 49 | DisplayColor::IndicatorColor, 50 | )])); 51 | updater.ensure_column_visible(self.editable_line.cursor_position()); 52 | updater.ensure_line_visible(0); 53 | after_build(updater); 54 | }); 55 | &self.view_data 56 | } 57 | 58 | pub(crate) fn get_view_data(&mut self) -> &ViewData { 59 | self.build_view_data(|_| {}, |_| {}) 60 | } 61 | 62 | pub(crate) fn handle_event(&mut self, event: Event) { 63 | if event == FINISH_EVENT { 64 | self.finished = true; 65 | } 66 | else { 67 | _ = self.editable_line.handle_event(event); 68 | } 69 | } 70 | 71 | pub(crate) fn set_label(&mut self, label: &str) { 72 | self.editable_line.set_label(LineSegment::new_with_color_and_style( 73 | label, 74 | DisplayColor::Normal, 75 | LineSegmentOptions::DIMMED, 76 | )); 77 | } 78 | 79 | pub(crate) fn set_content(&mut self, content: &str) { 80 | self.editable_line.set_content(content); 81 | } 82 | 83 | pub(crate) fn reset(&mut self) { 84 | self.editable_line.clear(); 85 | self.editable_line.set_read_only(false); 86 | self.finished = false; 87 | } 88 | 89 | pub(crate) fn input_options(&self) -> &InputOptions { 90 | &INPUT_OPTIONS 91 | } 92 | 93 | pub(crate) const fn is_finished(&self) -> bool { 94 | self.finished 95 | } 96 | 97 | pub(crate) fn get_content(&self) -> &str { 98 | self.editable_line.get_content() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/edit/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{assert_rendered_output, test_helpers::assertions::assert_rendered_output::AssertRenderOptions}; 3 | 4 | #[test] 5 | fn with_before_and_after_build() { 6 | let mut module = Edit::new(); 7 | module.set_content("foobar"); 8 | let view_data = module.build_view_data( 9 | |updater| { 10 | updater.push_line(ViewLine::from("Before")); 11 | }, 12 | |updater| { 13 | updater.push_line(ViewLine::from("After")); 14 | }, 15 | ); 16 | assert_rendered_output!( 17 | Options AssertRenderOptions::INCLUDE_TRAILING_WHITESPACE | AssertRenderOptions::INCLUDE_STYLE, 18 | view_data, 19 | "{TITLE}", 20 | "{BODY}", 21 | "{Normal}Before", 22 | "{Normal}foobar{Normal,Underline} ", 23 | "{Normal}After", 24 | "{TRAILING}", 25 | "{IndicatorColor}Enter to finish" 26 | ); 27 | } 28 | 29 | #[test] 30 | fn edit_event() { 31 | let mut module = Edit::new(); 32 | module.set_content("foobar"); 33 | module.handle_event(Event::from(KeyCode::Left)); 34 | let view_data = module.get_view_data(); 35 | 36 | assert_rendered_output!( 37 | Style view_data, 38 | "{TITLE}", 39 | "{BODY}", 40 | "{Normal}fooba{Normal,Underline}r", 41 | "{TRAILING}", 42 | "{IndicatorColor}Enter to finish" 43 | ); 44 | } 45 | 46 | #[test] 47 | fn finish_event() { 48 | let mut module = Edit::new(); 49 | module.set_content("foobar"); 50 | module.handle_event(Event::from(KeyCode::Enter)); 51 | assert!(module.is_finished()); 52 | } 53 | 54 | #[test] 55 | fn set_get_content() { 56 | let mut module = Edit::new(); 57 | module.set_content("abcd"); 58 | assert_eq!(module.get_content(), "abcd"); 59 | } 60 | 61 | #[test] 62 | fn reset() { 63 | let mut module = Edit::new(); 64 | module.set_content("abcd"); 65 | module.reset(); 66 | assert_eq!(module.get_content(), ""); 67 | assert!(!module.is_finished()); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/help/tests.rs: -------------------------------------------------------------------------------- 1 | use rstest::rstest; 2 | 3 | use super::*; 4 | use crate::{ 5 | assert_rendered_output, 6 | input::{KeyModifiers, MouseEvent, MouseEventKind, StandardEvent}, 7 | test_helpers::with_view_state, 8 | }; 9 | 10 | fn handle_event(help: &mut Help, event: Event) { 11 | let evt = help.read_event(event).unwrap_or(event); 12 | with_view_state(|context| { 13 | let _result = help.handle_event(evt, &context.state); 14 | }); 15 | } 16 | 17 | #[test] 18 | fn empty() { 19 | let mut module = Help::new_from_keybindings(&[]); 20 | assert_rendered_output!( 21 | Style module.get_view_data(), 22 | "{TITLE}", 23 | "{LEADING}", 24 | "{Normal,Underline} Key Action{Pad( )}", 25 | "{TRAILING}", 26 | "{IndicatorColor}Press any key to close" 27 | ); 28 | } 29 | 30 | #[test] 31 | fn from_key_bindings() { 32 | let mut module = Help::new_from_keybindings(&[ 33 | (vec![String::from("a")], String::from("Description A")), 34 | (vec![String::from("b")], String::from("Description B")), 35 | ]); 36 | assert_rendered_output!( 37 | Style module.get_view_data(), 38 | "{TITLE}", 39 | "{LEADING}", 40 | "{Normal,Underline} Key Action{Pad( )}", 41 | "{BODY}", 42 | "{IndicatorColor} a{Normal,Dimmed}|{Normal}Description A", 43 | "{IndicatorColor} b{Normal,Dimmed}|{Normal}Description B", 44 | "{TRAILING}", 45 | "{IndicatorColor}Press any key to close" 46 | ); 47 | } 48 | 49 | #[rstest] 50 | #[case::resize(Event::Resize(100, 100))] 51 | #[case::scroll_left(Event::from(StandardEvent::ScrollLeft))] 52 | #[case::scroll_right(Event::from(StandardEvent::ScrollRight))] 53 | #[case::scroll_down(Event::from(StandardEvent::ScrollDown))] 54 | #[case::scroll_up(Event::from(StandardEvent::ScrollUp))] 55 | #[case::scroll_jump_down(Event::from(StandardEvent::ScrollJumpDown))] 56 | #[case::scroll_jump_up(Event::from(StandardEvent::ScrollJumpUp))] 57 | #[case::mouse_event(Event::Mouse(MouseEvent { 58 | kind: MouseEventKind::ScrollUp, 59 | column: 0, 60 | row: 0, 61 | modifiers: KeyModifiers::empty(), 62 | }))] 63 | fn handle_standard_events(#[case] event: Event) { 64 | let mut module = Help::new_from_keybindings(&[]); 65 | module.set_active(); 66 | handle_event(&mut module, event); 67 | assert!(module.is_active()); 68 | } 69 | 70 | #[test] 71 | fn handle_other_key_event() { 72 | let mut module = Help::new_from_keybindings(&[]); 73 | module.set_active(); 74 | handle_event(&mut module, Event::from('a')); 75 | assert!(!module.is_active()); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/search_bar/action.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq)] 2 | pub(crate) enum Action { 3 | Update(String), 4 | Start(String), 5 | Next(String), 6 | Previous(String), 7 | Cancel, 8 | None, 9 | } 10 | -------------------------------------------------------------------------------- /src/components/search_bar/state.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, PartialEq)] 2 | pub(crate) enum State { 3 | Deactivated, 4 | Editing, 5 | Searching, 6 | } 7 | 8 | impl State { 9 | pub(crate) const fn is_active(self) -> bool { 10 | match self { 11 | Self::Deactivated => false, 12 | Self::Editing | Self::Searching => true, 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/shared.rs: -------------------------------------------------------------------------------- 1 | mod editable_line; 2 | 3 | pub(crate) use self::editable_line::{EditAction, EditableLine}; 4 | -------------------------------------------------------------------------------- /src/components/spin_indicator.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | const INDICATOR_CHARACTERS: [&str; 4] = ["-", "\\", "|", "/"]; 4 | const ANIMATE_SPEED: Duration = Duration::from_millis(100); 5 | 6 | pub(crate) struct SpinIndicator { 7 | index: u8, 8 | last_refreshed_at: Instant, 9 | } 10 | 11 | impl SpinIndicator { 12 | pub(crate) fn new() -> Self { 13 | Self { 14 | index: 0, 15 | last_refreshed_at: Instant::now(), 16 | } 17 | } 18 | 19 | pub(crate) fn refresh(&mut self) { 20 | if self.last_refreshed_at.elapsed() >= ANIMATE_SPEED { 21 | self.last_refreshed_at = Instant::now(); 22 | self.index = if self.index == 3 { 0 } else { self.index + 1 } 23 | } 24 | } 25 | 26 | pub(crate) fn indicator(&self) -> String { 27 | format!("({})", INDICATOR_CHARACTERS[self.index as usize]) 28 | } 29 | } 30 | #[cfg(test)] 31 | mod tests { 32 | use std::ops::{Add as _, Sub as _}; 33 | 34 | const SAFE_TEST_DURATION: Duration = Duration::from_secs(60); 35 | 36 | use super::*; 37 | 38 | #[test] 39 | fn does_not_advance_if_duration_has_not_elapsed() { 40 | let mut indicator = SpinIndicator::new(); 41 | // this test is unlikely to finish before this elapsed time 42 | indicator.last_refreshed_at = Instant::now().add(SAFE_TEST_DURATION); 43 | assert_eq!(indicator.indicator(), "(-)"); 44 | indicator.refresh(); 45 | assert_eq!(indicator.indicator(), "(-)"); 46 | } 47 | 48 | #[test] 49 | fn does_not_advance_if_duration_has_elapsed() { 50 | let mut indicator = SpinIndicator::new(); 51 | indicator.last_refreshed_at = Instant::now().sub(SAFE_TEST_DURATION); 52 | assert_eq!(indicator.indicator(), "(-)"); 53 | indicator.refresh(); 54 | assert_eq!(indicator.indicator(), "(\\)"); 55 | } 56 | 57 | #[test] 58 | fn full_animation() { 59 | let mut indicator = SpinIndicator::new(); 60 | indicator.last_refreshed_at = Instant::now().sub(SAFE_TEST_DURATION); 61 | assert_eq!(indicator.indicator(), "(-)"); 62 | indicator.refresh(); 63 | indicator.last_refreshed_at = indicator.last_refreshed_at.sub(SAFE_TEST_DURATION); 64 | assert_eq!(indicator.indicator(), "(\\)"); 65 | indicator.refresh(); 66 | indicator.last_refreshed_at = indicator.last_refreshed_at.sub(SAFE_TEST_DURATION); 67 | assert_eq!(indicator.indicator(), "(|)"); 68 | indicator.refresh(); 69 | indicator.last_refreshed_at = indicator.last_refreshed_at.sub(SAFE_TEST_DURATION); 70 | assert_eq!(indicator.indicator(), "(/)"); 71 | indicator.refresh(); 72 | indicator.last_refreshed_at = indicator.last_refreshed_at.sub(SAFE_TEST_DURATION); 73 | assert_eq!(indicator.indicator(), "(-)"); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/config/config_loader.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Debug, Formatter}; 2 | 3 | use git2::Repository; 4 | 5 | use crate::git::{Config, GitError}; 6 | 7 | pub(crate) struct ConfigLoader { 8 | repository: Repository, 9 | } 10 | 11 | impl ConfigLoader { 12 | /// Load the git configuration for the repository. 13 | /// 14 | /// # Errors 15 | /// Will result in an error if the configuration is invalid. 16 | pub(crate) fn load_config(&self) -> Result { 17 | self.repository.config().map_err(|e| GitError::ConfigLoad { cause: e }) 18 | } 19 | 20 | pub(crate) fn eject_repository(self) -> Repository { 21 | self.repository 22 | } 23 | } 24 | 25 | impl From for ConfigLoader { 26 | fn from(repository: Repository) -> Self { 27 | Self { repository } 28 | } 29 | } 30 | 31 | impl Debug for ConfigLoader { 32 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { 33 | f.debug_struct("ConfigLoader") 34 | .field("[path]", &self.repository.path()) 35 | .finish() 36 | } 37 | } 38 | 39 | // Paths in Windows make these tests difficult, so disable 40 | #[cfg(all(unix, test))] 41 | mod unix_tests { 42 | use claims::assert_ok; 43 | 44 | use crate::{ 45 | config::ConfigLoader, 46 | test_helpers::{with_temp_bare_repository, with_temp_repository}, 47 | }; 48 | 49 | #[test] 50 | fn load_config() { 51 | with_temp_bare_repository(|repository| { 52 | let config = ConfigLoader::from(repository); 53 | assert_ok!(config.load_config()); 54 | }); 55 | } 56 | 57 | #[test] 58 | fn fmt() { 59 | with_temp_repository(|repository| { 60 | let path = repository.path().canonicalize().unwrap(); 61 | let loader = ConfigLoader::from(repository); 62 | let formatted = format!("{loader:?}"); 63 | assert_eq!( 64 | formatted, 65 | format!("ConfigLoader {{ [path]: \"{}/\" }}", path.to_str().unwrap()) 66 | ); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/config/diff_ignore_whitespace_setting.rs: -------------------------------------------------------------------------------- 1 | /// Configuration option for how to ignore whitespace during diff calculation. 2 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 3 | #[non_exhaustive] 4 | pub(crate) enum DiffIgnoreWhitespaceSetting { 5 | /// Do not ignore whitespace when calculating diffs. 6 | None, 7 | /// Ignore all whitespace in diffs, same as the [`--ignore-all-space`]( 8 | /// https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---ignore-all-space 9 | /// ) flag. 10 | All, 11 | /// Ignore changed whitespace in diffs, same as the [`--ignore-space-change`]( 12 | /// https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---ignore-space-change 13 | /// ) flag. 14 | Change, 15 | } 16 | -------------------------------------------------------------------------------- /src/config/diff_show_whitespace_setting.rs: -------------------------------------------------------------------------------- 1 | /// Configuration option for how to show whitespace when displaying diffs. 2 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 3 | #[non_exhaustive] 4 | pub(crate) enum DiffShowWhitespaceSetting { 5 | /// Do not show whitespace characters. 6 | None, 7 | /// Show only trailing whitespace characters. 8 | Trailing, 9 | /// Show only leading whitespace characters. 10 | Leading, 11 | /// Show both leading and trailing whitespace characters. 12 | Both, 13 | } 14 | -------------------------------------------------------------------------------- /src/config/errors/config_error_cause.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use crate::{config::InvalidColorError, git::GitError}; 4 | 5 | /// The kind of config error that occurred. 6 | #[derive(Error, Debug, PartialEq)] 7 | #[non_exhaustive] 8 | pub(crate) enum ConfigErrorCause { 9 | /// The input provided is not a valid color 10 | #[error(transparent)] 11 | InvalidColor(InvalidColorError), 12 | /// An error occurred reading the git config files. 13 | #[error(transparent)] 14 | GitError(GitError), 15 | /// The input provided is not a valid value for the show whitespace value. 16 | #[error("Must match one of 'true', 'on', 'both', 'trailing', 'leading', 'false', 'off' or 'none'.")] 17 | InvalidShowWhitespace, 18 | /// The input provided is not a valid value for the ignore whitespace value. 19 | #[error("Must match one of 'true', 'on', 'all', 'change', 'false', 'off' or 'none'")] 20 | InvalidDiffIgnoreWhitespace, 21 | /// The input provided is not a valid value for the diff renames. 22 | #[error("Must match one of 'true', 'false', 'copy', or 'copies'")] 23 | InvalidDiffRenames, 24 | /// The input provided is not a valid boolean value. 25 | #[error("The input provided is not a valid boolean value")] 26 | InvalidBoolean, 27 | /// The input provided is outside of valid range for an unsigned 32-bit integer. 28 | #[error("The input provided is outside of valid range for an unsigned 32-bit integer")] 29 | InvalidUnsignedInteger, 30 | /// The input provided is not a valid input keybinding. 31 | #[error("The input provided is not a valid input keybinding.")] 32 | InvalidKeyBinding, 33 | /// The input provided is not valid UTF. 34 | #[error("The input provided is not valid UTF")] 35 | InvalidUtf, 36 | /// The input provided resulted in an unknown error variant: {0}. 37 | #[error("The input provided resulted in an unknown error variant")] 38 | UnknownError(String), 39 | } 40 | -------------------------------------------------------------------------------- /src/config/errors/invalid_color.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | /// A invalid color error. 4 | #[derive(Error, Copy, Clone, Debug, PartialEq, Eq)] 5 | #[non_exhaustive] 6 | pub(crate) enum InvalidColorError { 7 | /// The indexed color is invalid. 8 | #[error("Index must be between 0-255")] 9 | Indexed, 10 | /// The red color is invalid. 11 | #[error("Red color value must be between 0-255")] 12 | Red, 13 | /// The green color is invalid. 14 | #[error("Green color value must be between 0-255")] 15 | Green, 16 | /// The blue color is invalid. 17 | #[error("Blue color value must be between 0-255")] 18 | Blue, 19 | /// An unknown color was used. 20 | #[error("Unknown color value")] 21 | Invalid, 22 | } 23 | -------------------------------------------------------------------------------- /src/config/utils.rs: -------------------------------------------------------------------------------- 1 | mod get_bool; 2 | mod get_diff_ignore_whitespace; 3 | mod get_diff_rename; 4 | mod get_diff_show_whitespace; 5 | mod get_input; 6 | mod get_string; 7 | mod get_unsigned_integer; 8 | 9 | pub(crate) use self::{ 10 | get_bool::get_bool, 11 | get_diff_ignore_whitespace::get_diff_ignore_whitespace, 12 | get_diff_rename::git_diff_renames, 13 | get_diff_show_whitespace::get_diff_show_whitespace, 14 | get_input::get_input, 15 | get_string::{get_optional_string, get_string}, 16 | get_unsigned_integer::get_unsigned_integer, 17 | }; 18 | -------------------------------------------------------------------------------- /src/config/utils/get_bool.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{ConfigError, ConfigErrorCause, utils::get_optional_string}, 3 | git::{Config, ErrorCode}, 4 | }; 5 | 6 | pub(crate) fn get_bool(config: Option<&Config>, name: &str, default: bool) -> Result { 7 | if let Some(cfg) = config { 8 | match cfg.get_bool(name) { 9 | Ok(v) => Ok(v), 10 | Err(e) if e.code() == ErrorCode::NotFound => Ok(default), 11 | Err(e) if e.message().contains("failed to parse") => { 12 | Err(ConfigError::new_with_optional_input( 13 | name, 14 | get_optional_string(config, name).ok().flatten(), 15 | ConfigErrorCause::InvalidBoolean, 16 | )) 17 | }, 18 | Err(e) => { 19 | Err(ConfigError::new_with_optional_input( 20 | name, 21 | get_optional_string(config, name).ok().flatten(), 22 | ConfigErrorCause::UnknownError(String::from(e.message())), 23 | )) 24 | }, 25 | } 26 | } 27 | else { 28 | Ok(default) 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use claims::{assert_err_eq, assert_ok_eq}; 35 | 36 | use super::*; 37 | use crate::test_helpers::{invalid_utf, with_git_config}; 38 | 39 | #[test] 40 | fn read_true() { 41 | with_git_config(&["[test]", "bool = true"], |git_config| { 42 | assert_ok_eq!(get_bool(Some(&git_config), "test.bool", false), true); 43 | }); 44 | } 45 | 46 | #[test] 47 | fn read_false() { 48 | with_git_config(&["[test]", "bool = false"], |git_config| { 49 | assert_ok_eq!(get_bool(Some(&git_config), "test.bool", true), false); 50 | }); 51 | } 52 | 53 | #[test] 54 | fn read_default() { 55 | with_git_config(&[], |git_config| { 56 | assert_ok_eq!(get_bool(Some(&git_config), "test.bool", true), true); 57 | }); 58 | } 59 | 60 | #[test] 61 | fn read_invalid_value() { 62 | with_git_config(&["[test]", "bool = invalid"], |git_config| { 63 | assert_err_eq!( 64 | get_bool(Some(&git_config), "test.bool", true), 65 | ConfigError::new("test.bool", "invalid", ConfigErrorCause::InvalidBoolean) 66 | ); 67 | }); 68 | } 69 | 70 | #[test] 71 | fn read_unexpected_error() { 72 | with_git_config(&["[test]", "bool = invalid"], |git_config| { 73 | assert_err_eq!( 74 | get_bool(Some(&git_config), "test", true), 75 | ConfigError::new_read_error( 76 | "test", 77 | ConfigErrorCause::UnknownError(String::from("invalid config item name 'test'")) 78 | ) 79 | ); 80 | }); 81 | } 82 | 83 | #[test] 84 | fn read_invalid_non_utf() { 85 | with_git_config( 86 | &["[test]", format!("bool = {}", invalid_utf()).as_str()], 87 | |git_config| { 88 | assert_err_eq!( 89 | get_bool(Some(&git_config), "test.bool", true), 90 | ConfigError::new_read_error("test.bool", ConfigErrorCause::InvalidBoolean) 91 | ); 92 | }, 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/config/utils/get_diff_ignore_whitespace.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{ConfigError, ConfigErrorCause, DiffIgnoreWhitespaceSetting, utils::get_string}, 3 | git::Config, 4 | }; 5 | 6 | pub(crate) fn get_diff_ignore_whitespace( 7 | git_config: Option<&Config>, 8 | name: &str, 9 | ) -> Result { 10 | match get_string(git_config, name, "none")?.to_lowercase().as_str() { 11 | "true" | "on" | "all" => Ok(DiffIgnoreWhitespaceSetting::All), 12 | "change" => Ok(DiffIgnoreWhitespaceSetting::Change), 13 | "false" | "off" | "none" => Ok(DiffIgnoreWhitespaceSetting::None), 14 | input => { 15 | Err(ConfigError::new( 16 | name, 17 | input, 18 | ConfigErrorCause::InvalidDiffIgnoreWhitespace, 19 | )) 20 | }, 21 | } 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use claims::{assert_err_eq, assert_ok_eq}; 27 | use rstest::rstest; 28 | 29 | use super::*; 30 | use crate::test_helpers::{invalid_utf, with_git_config}; 31 | 32 | #[rstest] 33 | #[case::true_str("true", DiffIgnoreWhitespaceSetting::All)] 34 | #[case::on("on", DiffIgnoreWhitespaceSetting::All)] 35 | #[case::all("all", DiffIgnoreWhitespaceSetting::All)] 36 | #[case::change("change", DiffIgnoreWhitespaceSetting::Change)] 37 | #[case::false_str("false", DiffIgnoreWhitespaceSetting::None)] 38 | #[case::off("off", DiffIgnoreWhitespaceSetting::None)] 39 | #[case::none("none", DiffIgnoreWhitespaceSetting::None)] 40 | #[case::mixed_case("ChAnGe", DiffIgnoreWhitespaceSetting::Change)] 41 | fn read_ok(#[case] value: &str, #[case] expected: DiffIgnoreWhitespaceSetting) { 42 | with_git_config(&["[test]", format!("value = \"{value}\"").as_str()], |git_config| { 43 | assert_ok_eq!(get_diff_ignore_whitespace(Some(&git_config), "test.value"), expected); 44 | }); 45 | } 46 | 47 | #[test] 48 | fn read_default() { 49 | with_git_config(&[], |git_config| { 50 | assert_ok_eq!( 51 | get_diff_ignore_whitespace(Some(&git_config), "test.value"), 52 | DiffIgnoreWhitespaceSetting::None 53 | ); 54 | }); 55 | } 56 | 57 | #[test] 58 | fn read_invalid_value() { 59 | with_git_config(&["[test]", "value = invalid"], |git_config| { 60 | assert_err_eq!( 61 | get_diff_ignore_whitespace(Some(&git_config), "test.value"), 62 | ConfigError::new("test.value", "invalid", ConfigErrorCause::InvalidDiffIgnoreWhitespace) 63 | ); 64 | }); 65 | } 66 | 67 | #[test] 68 | fn read_invalid_non_utf() { 69 | with_git_config( 70 | &["[test]", format!("value = {}", invalid_utf()).as_str()], 71 | |git_config| { 72 | assert_err_eq!( 73 | get_diff_ignore_whitespace(Some(&git_config), "test.value"), 74 | ConfigError::new_read_error("test.value", ConfigErrorCause::InvalidUtf) 75 | ); 76 | }, 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/config/utils/get_diff_rename.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{ConfigError, ConfigErrorCause, get_string}, 3 | git::Config, 4 | }; 5 | 6 | pub(crate) fn git_diff_renames(git_config: Option<&Config>, name: &str) -> Result<(bool, bool), ConfigError> { 7 | match get_string(git_config, name, "true")?.to_lowercase().as_str() { 8 | "true" => Ok((true, false)), 9 | "false" => Ok((false, false)), 10 | "copy" | "copies" => Ok((true, true)), 11 | input => Err(ConfigError::new(name, input, ConfigErrorCause::InvalidDiffRenames)), 12 | } 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use claims::{assert_err_eq, assert_ok_eq}; 18 | use rstest::rstest; 19 | 20 | use super::*; 21 | use crate::test_helpers::{invalid_utf, with_git_config}; 22 | 23 | #[rstest] 24 | #[case::true_str("true", (true, false))] 25 | #[case::false_str("false", (false, false))] 26 | #[case::off("copy", (true, true))] 27 | #[case::none("copies", (true, true))] 28 | #[case::mixed_case("CoPiEs", (true, true))] 29 | fn read_ok(#[case] value: &str, #[case] expected: (bool, bool)) { 30 | with_git_config(&["[test]", format!("value = \"{value}\"").as_str()], |git_config| { 31 | assert_ok_eq!(git_diff_renames(Some(&git_config), "test.value"), expected); 32 | }); 33 | } 34 | 35 | #[test] 36 | fn read_default() { 37 | with_git_config(&[], |git_config| { 38 | assert_ok_eq!(git_diff_renames(Some(&git_config), "test.value"), (true, false)); 39 | }); 40 | } 41 | 42 | #[test] 43 | fn read_invalid_value() { 44 | with_git_config(&["[test]", "value = invalid"], |git_config| { 45 | assert_err_eq!( 46 | git_diff_renames(Some(&git_config), "test.value"), 47 | ConfigError::new("test.value", "invalid", ConfigErrorCause::InvalidDiffRenames) 48 | ); 49 | }); 50 | } 51 | 52 | #[test] 53 | fn read_invalid_non_utf() { 54 | with_git_config( 55 | &["[test]", format!("value = {}", invalid_utf()).as_str()], 56 | |git_config| { 57 | assert_err_eq!( 58 | git_diff_renames(Some(&git_config), "test.value"), 59 | ConfigError::new_read_error("test.value", ConfigErrorCause::InvalidUtf) 60 | ); 61 | }, 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/config/utils/get_diff_show_whitespace.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{ConfigError, ConfigErrorCause, DiffShowWhitespaceSetting, get_string}, 3 | git::Config, 4 | }; 5 | 6 | pub(crate) fn get_diff_show_whitespace( 7 | git_config: Option<&Config>, 8 | name: &str, 9 | ) -> Result { 10 | match get_string(git_config, name, "both")?.to_lowercase().as_str() { 11 | "true" | "on" | "both" => Ok(DiffShowWhitespaceSetting::Both), 12 | "trailing" => Ok(DiffShowWhitespaceSetting::Trailing), 13 | "leading" => Ok(DiffShowWhitespaceSetting::Leading), 14 | "false" | "off" | "none" => Ok(DiffShowWhitespaceSetting::None), 15 | input => Err(ConfigError::new(name, input, ConfigErrorCause::InvalidShowWhitespace)), 16 | } 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use claims::{assert_err_eq, assert_ok_eq}; 22 | use rstest::rstest; 23 | 24 | use super::*; 25 | use crate::test_helpers::{invalid_utf, with_git_config}; 26 | 27 | #[rstest] 28 | #[case::true_str("true", DiffShowWhitespaceSetting::Both)] 29 | #[case::on("on", DiffShowWhitespaceSetting::Both)] 30 | #[case::both("both", DiffShowWhitespaceSetting::Both)] 31 | #[case::trailing("trailing", DiffShowWhitespaceSetting::Trailing)] 32 | #[case::leading("leading", DiffShowWhitespaceSetting::Leading)] 33 | #[case::false_str("false", DiffShowWhitespaceSetting::None)] 34 | #[case::off("off", DiffShowWhitespaceSetting::None)] 35 | #[case::none("none", DiffShowWhitespaceSetting::None)] 36 | #[case::mixed_case("lEaDiNg", DiffShowWhitespaceSetting::Leading)] 37 | fn read_ok(#[case] value: &str, #[case] expected: DiffShowWhitespaceSetting) { 38 | with_git_config(&["[test]", format!("value = \"{value}\"").as_str()], |git_config| { 39 | assert_ok_eq!(get_diff_show_whitespace(Some(&git_config), "test.value"), expected); 40 | }); 41 | } 42 | 43 | #[test] 44 | fn read_default() { 45 | with_git_config(&[], |git_config| { 46 | assert_ok_eq!( 47 | get_diff_show_whitespace(Some(&git_config), "test.value"), 48 | DiffShowWhitespaceSetting::Both 49 | ); 50 | }); 51 | } 52 | 53 | #[test] 54 | fn read_invalid_value() { 55 | with_git_config(&["[test]", "value = invalid"], |git_config| { 56 | assert_err_eq!( 57 | get_diff_show_whitespace(Some(&git_config), "test.value"), 58 | ConfigError::new("test.value", "invalid", ConfigErrorCause::InvalidShowWhitespace) 59 | ); 60 | }); 61 | } 62 | 63 | #[test] 64 | fn read_invalid_non_utf() { 65 | with_git_config( 66 | &["[test]", format!("value = {}", invalid_utf()).as_str()], 67 | |git_config| { 68 | assert_err_eq!( 69 | get_diff_show_whitespace(Some(&git_config), "test.value"), 70 | ConfigError::new_read_error("test.value", ConfigErrorCause::InvalidUtf) 71 | ); 72 | }, 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/config/utils/get_string.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::{ConfigError, ConfigErrorCause}, 3 | git::{Config, ErrorCode}, 4 | }; 5 | 6 | pub(crate) fn get_optional_string(config: Option<&Config>, name: &str) -> Result, ConfigError> { 7 | let Some(cfg) = config 8 | else { 9 | return Ok(None); 10 | }; 11 | match cfg.get_string(name) { 12 | Ok(v) => Ok(Some(v)), 13 | Err(e) if e.code() == ErrorCode::NotFound => Ok(None), 14 | // detecting a UTF-8 error is tricky 15 | Err(e) if e.message() == "configuration value is not valid utf8" => { 16 | Err(ConfigError::new_read_error(name, ConfigErrorCause::InvalidUtf)) 17 | }, 18 | Err(e) => { 19 | Err(ConfigError::new_read_error( 20 | name, 21 | ConfigErrorCause::UnknownError(String::from(e.message())), 22 | )) 23 | }, 24 | } 25 | } 26 | 27 | pub(crate) fn get_string(config: Option<&Config>, name: &str, default: &str) -> Result { 28 | Ok(get_optional_string(config, name)?.unwrap_or_else(|| String::from(default))) 29 | } 30 | 31 | #[cfg(test)] 32 | mod tests { 33 | use claims::{assert_err_eq, assert_ok_eq}; 34 | 35 | use super::*; 36 | use crate::test_helpers::{invalid_utf, with_git_config}; 37 | 38 | #[test] 39 | fn read_value() { 40 | with_git_config(&["[test]", "value = foo"], |git_config| { 41 | assert_ok_eq!( 42 | get_string(Some(&git_config), "test.value", "default"), 43 | String::from("foo") 44 | ); 45 | }); 46 | } 47 | 48 | #[test] 49 | fn read_default() { 50 | with_git_config(&[], |git_config| { 51 | assert_ok_eq!( 52 | get_string(Some(&git_config), "test.value", "default"), 53 | String::from("default") 54 | ); 55 | }); 56 | } 57 | 58 | #[test] 59 | fn read_unexpected_error() { 60 | with_git_config(&["[test]", "value = invalid"], |git_config| { 61 | assert_err_eq!( 62 | get_string(Some(&git_config), "test", "default"), 63 | ConfigError::new_read_error( 64 | "test", 65 | ConfigErrorCause::UnknownError(String::from("invalid config item name 'test'")) 66 | ) 67 | ); 68 | }); 69 | } 70 | 71 | #[test] 72 | fn read_invalid_non_utf() { 73 | with_git_config( 74 | &["[test]", format!("value = {}", invalid_utf()).as_str()], 75 | |git_config| { 76 | assert_err_eq!( 77 | get_string(Some(&git_config), "test.value", "default"), 78 | ConfigError::new_read_error("test.value", ConfigErrorCause::InvalidUtf) 79 | ); 80 | }, 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/diff.rs: -------------------------------------------------------------------------------- 1 | mod commit; 2 | mod commit_diff; 3 | mod commit_diff_loader; 4 | mod commit_diff_loader_options; 5 | mod delta; 6 | mod diff_line; 7 | mod file_mode; 8 | mod file_status; 9 | mod file_status_builder; 10 | mod origin; 11 | mod reference; 12 | mod reference_kind; 13 | mod status; 14 | mod user; 15 | 16 | pub(crate) mod thread; 17 | 18 | pub(crate) use self::{ 19 | commit::Commit, 20 | commit_diff::CommitDiff, 21 | commit_diff_loader::CommitDiffLoader, 22 | commit_diff_loader_options::CommitDiffLoaderOptions, 23 | delta::Delta, 24 | diff_line::DiffLine, 25 | file_mode::FileMode, 26 | file_status::FileStatus, 27 | file_status_builder::FileStatusBuilder, 28 | origin::Origin, 29 | reference::Reference, 30 | reference_kind::ReferenceKind, 31 | status::Status, 32 | user::User, 33 | }; 34 | -------------------------------------------------------------------------------- /src/diff/file_mode.rs: -------------------------------------------------------------------------------- 1 | /// Represents the mode of a file 2 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 3 | pub(crate) enum FileMode { 4 | /// A normal type of file 5 | Normal, 6 | /// A file that is executable 7 | Executable, 8 | /// A file that is a link 9 | Link, 10 | /// Any other file types 11 | Other, 12 | } 13 | 14 | impl FileMode { 15 | pub(crate) const fn from(file_mode: git2::FileMode) -> Self { 16 | match file_mode { 17 | git2::FileMode::Commit | git2::FileMode::Tree | git2::FileMode::Unreadable => Self::Other, 18 | git2::FileMode::Blob | git2::FileMode::BlobGroupWritable => Self::Normal, 19 | git2::FileMode::BlobExecutable => Self::Executable, 20 | git2::FileMode::Link => Self::Link, 21 | } 22 | } 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use rstest::rstest; 28 | 29 | use super::*; 30 | 31 | #[rstest] 32 | #[case::commit(git2::FileMode::Commit, FileMode::Other)] 33 | #[case::commit(git2::FileMode::Tree, FileMode::Other)] 34 | #[case::commit(git2::FileMode::Unreadable, FileMode::Other)] 35 | #[case::commit(git2::FileMode::Blob, FileMode::Normal)] 36 | #[case::commit(git2::FileMode::BlobExecutable, FileMode::Executable)] 37 | #[case::commit(git2::FileMode::Link, FileMode::Link)] 38 | fn from(#[case] git2_file_mode: git2::FileMode, #[case] expected_file_mode: FileMode) { 39 | assert_eq!(FileMode::from(git2_file_mode), expected_file_mode); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/diff/origin.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 2 | /// The origin of a diff line 3 | pub(crate) enum Origin { 4 | /// A diff line that has been added 5 | Addition, 6 | /// A binary file line 7 | Binary, 8 | /// A diff line that provides context 9 | Context, 10 | /// A diff line that has been deleted 11 | Deletion, 12 | /// Diff line header content 13 | Header, 14 | } 15 | 16 | impl From for Origin { 17 | fn from(diff_line_type: git2::DiffLineType) -> Self { 18 | match diff_line_type { 19 | git2::DiffLineType::Context | git2::DiffLineType::ContextEOFNL => Self::Context, 20 | git2::DiffLineType::Addition | git2::DiffLineType::AddEOFNL => Self::Addition, 21 | git2::DiffLineType::Deletion | git2::DiffLineType::DeleteEOFNL => Self::Deletion, 22 | git2::DiffLineType::FileHeader | git2::DiffLineType::HunkHeader => Self::Header, 23 | git2::DiffLineType::Binary => Self::Binary, 24 | } 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use rstest::rstest; 31 | 32 | use super::*; 33 | 34 | #[rstest] 35 | #[case::context(git2::DiffLineType::Context, Origin::Context)] 36 | #[case::context_eof(git2::DiffLineType::ContextEOFNL, Origin::Context)] 37 | #[case::addition(git2::DiffLineType::Addition, Origin::Addition)] 38 | #[case::addition_eof(git2::DiffLineType::AddEOFNL, Origin::Addition)] 39 | #[case::deletion(git2::DiffLineType::Deletion, Origin::Deletion)] 40 | #[case::deletion_eof(git2::DiffLineType::DeleteEOFNL, Origin::Deletion)] 41 | #[case::file_header(git2::DiffLineType::FileHeader, Origin::Header)] 42 | #[case::hunk_header(git2::DiffLineType::HunkHeader, Origin::Header)] 43 | #[case::binary(git2::DiffLineType::Binary, Origin::Binary)] 44 | fn from_char(#[case] input: git2::DiffLineType, #[case] expected: Origin) { 45 | assert_eq!(Origin::from(input), expected); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/diff/reference.rs: -------------------------------------------------------------------------------- 1 | use crate::diff::ReferenceKind; 2 | 3 | /// Represents a pointer to an object in Git. 4 | #[derive(Debug, Clone, PartialEq, Eq)] 5 | pub(crate) struct Reference { 6 | /// The object id 7 | hash: String, 8 | /// The reference full name 9 | name: String, 10 | /// The reference shorthand name 11 | shorthand: String, 12 | /// The kind of reference 13 | kind: ReferenceKind, 14 | } 15 | 16 | impl Reference { 17 | pub(crate) fn new(hash: String, name: String, shorthand: String, kind: ReferenceKind) -> Self { 18 | Self { 19 | hash, 20 | name, 21 | shorthand, 22 | kind, 23 | } 24 | } 25 | 26 | /// Get the oid of the reference 27 | #[must_use] 28 | #[expect(unused, reason = "Available for future use")] 29 | pub(crate) fn hash(&self) -> &str { 30 | self.hash.as_str() 31 | } 32 | 33 | /// Get the name of the reference 34 | #[must_use] 35 | #[expect(unused, reason = "Available for future use")] 36 | pub(crate) fn name(&self) -> &str { 37 | self.name.as_str() 38 | } 39 | 40 | /// Get the shorthand name of the reference 41 | #[must_use] 42 | #[expect(unused, reason = "Available for future use")] 43 | pub(crate) fn shortname(&self) -> &str { 44 | self.shorthand.as_str() 45 | } 46 | 47 | /// Get the kind of the reference 48 | #[must_use] 49 | #[expect(unused, reason = "Available for future use")] 50 | pub(crate) const fn kind(&self) -> ReferenceKind { 51 | self.kind 52 | } 53 | } 54 | 55 | impl From<&git2::Reference<'_>> for Reference { 56 | fn from(reference: &git2::Reference<'_>) -> Self { 57 | let oid = reference 58 | .peel(git2::ObjectType::Any) 59 | .expect("Reference peel failed") 60 | .id(); 61 | let kind = ReferenceKind::from(reference); 62 | let name = String::from(reference.name().unwrap_or("InvalidRef")); 63 | let shorthand = String::from(reference.shorthand().unwrap_or("InvalidRef")); 64 | 65 | Self::new(format!("{oid}"), name, shorthand, kind) 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | use crate::test_helpers::with_temp_repository; 73 | 74 | #[test] 75 | fn test() { 76 | with_temp_repository(|repository| { 77 | let revision = repository.revparse_single("refs/heads/main").unwrap(); 78 | let oid = revision.id().to_string(); 79 | let reference = repository 80 | .find_reference("refs/heads/main") 81 | .map(|r| Reference::from(&r)) 82 | .unwrap(); 83 | 84 | assert_eq!(reference.hash(), format!("{oid}")); 85 | assert_eq!(reference.name(), "refs/heads/main"); 86 | assert_eq!(reference.shortname(), "main"); 87 | assert_eq!(reference.kind(), ReferenceKind::Branch); 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/diff/reference_kind.rs: -------------------------------------------------------------------------------- 1 | /// Represents the kind of a reference 2 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 3 | pub(crate) enum ReferenceKind { 4 | /// Reference is a branch. 5 | Branch, 6 | /// Reference is a note. 7 | Note, 8 | /// Reference is a remote. 9 | Remote, 10 | /// Reference is a tag. 11 | Tag, 12 | /// Reference is another kind. 13 | Other, 14 | } 15 | 16 | impl ReferenceKind { 17 | pub(crate) fn from(reference: &git2::Reference<'_>) -> Self { 18 | if reference.is_branch() { 19 | Self::Branch 20 | } 21 | else if reference.is_note() { 22 | Self::Note 23 | } 24 | else if reference.is_remote() { 25 | Self::Remote 26 | } 27 | else if reference.is_tag() { 28 | Self::Tag 29 | } 30 | else { 31 | Self::Other 32 | } 33 | } 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use super::*; 39 | use crate::test_helpers::{JAN_2021_EPOCH, with_temp_repository}; 40 | 41 | #[test] 42 | fn from_git2_reference_branch() { 43 | with_temp_repository(|repository| { 44 | assert_eq!( 45 | ReferenceKind::from(&repository.find_reference("refs/heads/main").unwrap()), 46 | ReferenceKind::Branch 47 | ); 48 | }); 49 | } 50 | 51 | #[test] 52 | fn from_git2_reference_note() { 53 | with_temp_repository(|repository| { 54 | let sig = git2::Signature::new("name", "name@example.com", &git2::Time::new(JAN_2021_EPOCH, 0)).unwrap(); 55 | let head_id = repository.refname_to_id("HEAD").unwrap(); 56 | _ = repository.note(&sig, &sig, None, head_id, "note", false).unwrap(); 57 | assert_eq!( 58 | ReferenceKind::from(&repository.find_reference("refs/notes/commits").unwrap()), 59 | ReferenceKind::Note 60 | ); 61 | }); 62 | } 63 | 64 | #[test] 65 | fn from_git2_reference_remote() { 66 | with_temp_repository(|repository| { 67 | let mut remote = repository 68 | .remote("origin", repository.path().to_str().unwrap()) 69 | .unwrap(); 70 | remote.fetch(&["main"], None, None).unwrap(); 71 | assert_eq!( 72 | ReferenceKind::from(&repository.find_reference("refs/remotes/origin/main").unwrap()), 73 | ReferenceKind::Remote 74 | ); 75 | }); 76 | } 77 | 78 | #[test] 79 | fn from_git2_reference_tag() { 80 | with_temp_repository(|repository| { 81 | let sig = git2::Signature::new("name", "name@example.com", &git2::Time::new(JAN_2021_EPOCH, 0)).unwrap(); 82 | let head_id = repository.revparse_single("HEAD").unwrap(); 83 | _ = repository.tag("tag", &head_id, &sig, "note", false).unwrap(); 84 | assert_eq!( 85 | ReferenceKind::from(&repository.find_reference("refs/tags/tag").unwrap()), 86 | ReferenceKind::Tag 87 | ); 88 | }); 89 | } 90 | 91 | #[test] 92 | fn from_git2_reference_other() { 93 | with_temp_repository(|repository| { 94 | let blob = repository.blob(b"foo").unwrap(); 95 | _ = repository.reference("refs/blob", blob, false, "blob").unwrap(); 96 | assert_eq!( 97 | ReferenceKind::from(&repository.find_reference("refs/blob").unwrap()), 98 | ReferenceKind::Other 99 | ); 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/diff/status.rs: -------------------------------------------------------------------------------- 1 | /// Represents the type of change of a diff entry 2 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 3 | pub(crate) enum Status { 4 | /// Entry does not exist in old version 5 | Added, 6 | /// Entry does not exist in new version 7 | Deleted, 8 | /// Entry content changed between old and new 9 | Modified, 10 | /// Entry was renamed between old and new 11 | Renamed, 12 | /// Entry was copied from another old entry 13 | Copied, 14 | /// Type of entry changed between old and new 15 | Typechange, 16 | /// Other type of change not normally found in a rebase 17 | Other, 18 | } 19 | 20 | impl Status { 21 | /// Create a new status for a `git2::Delta`. 22 | #[must_use] 23 | pub(crate) const fn from(delta: git2::Delta) -> Self { 24 | match delta { 25 | git2::Delta::Added => Self::Added, 26 | git2::Delta::Copied => Self::Copied, 27 | git2::Delta::Deleted => Self::Deleted, 28 | git2::Delta::Modified => Self::Modified, 29 | git2::Delta::Renamed => Self::Renamed, 30 | git2::Delta::Typechange => Self::Typechange, 31 | git2::Delta::Ignored 32 | | git2::Delta::Conflicted 33 | | git2::Delta::Unmodified 34 | | git2::Delta::Unreadable 35 | | git2::Delta::Untracked => Self::Other, 36 | } 37 | } 38 | } 39 | 40 | #[cfg(test)] 41 | mod tests { 42 | use git2::Delta; 43 | use rstest::rstest; 44 | 45 | use super::*; 46 | 47 | #[rstest] 48 | #[case::added(Delta::Added, Status::Added)] 49 | #[case::copied(Delta::Copied, Status::Copied)] 50 | #[case::deleted(Delta::Deleted, Status::Deleted)] 51 | #[case::modified(Delta::Modified, Status::Modified)] 52 | #[case::renamed(Delta::Renamed, Status::Renamed)] 53 | #[case::typechange(Delta::Typechange, Status::Typechange)] 54 | #[case::ignored(Delta::Ignored, Status::Other)] 55 | #[case::conflicted(Delta::Conflicted, Status::Other)] 56 | #[case::unmodified(Delta::Unmodified, Status::Other)] 57 | #[case::unreadable(Delta::Unreadable, Status::Other)] 58 | #[case::untracked(Delta::Untracked, Status::Other)] 59 | fn from_delta(#[case] input: Delta, #[case] expected: Status) { 60 | assert_eq!(Status::from(input), expected); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/diff/thread/action.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Debug, Formatter}; 2 | 3 | #[derive(PartialEq)] 4 | pub(crate) enum Action { 5 | StatusChange, 6 | Load(String), 7 | } 8 | 9 | impl Debug for Action { 10 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 11 | match *self { 12 | Self::StatusChange => write!(f, "StatusChange"), 13 | Self::Load(ref hash) => write!(f, "Load({hash})"), 14 | } 15 | } 16 | } 17 | #[cfg(test)] 18 | mod tests { 19 | use rstest::rstest; 20 | 21 | use super::*; 22 | 23 | #[rstest] 24 | #[case::status_change(Action::StatusChange, "StatusChange")] 25 | #[case::cont(Action::Load(String::from("abc123")), "Load(abc123)")] 26 | fn debug(#[case] action: Action, #[case] expected: &str) { 27 | assert_eq!(format!("{action:?}"), expected); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/diff/thread/load_status.rs: -------------------------------------------------------------------------------- 1 | use git2::ErrorCode; 2 | 3 | #[derive(Clone, Debug, PartialEq, Eq)] 4 | pub(crate) enum LoadStatus { 5 | New, 6 | QuickDiff(usize, usize), 7 | CompleteQuickDiff, 8 | Diff(usize, usize), 9 | DiffComplete, 10 | Error { msg: String, code: ErrorCode }, 11 | } 12 | -------------------------------------------------------------------------------- /src/diff/thread/update_handler.rs: -------------------------------------------------------------------------------- 1 | pub(crate) trait UpdateHandlerFn: Fn() + Sync + Send {} 2 | 3 | impl UpdateHandlerFn for FN {} 4 | -------------------------------------------------------------------------------- /src/diff/user.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | /// Represents a user within a commit with a name and email address 4 | #[derive(Debug, Clone, Eq, PartialEq)] 5 | pub(crate) struct User { 6 | name: Option, 7 | email: Option, 8 | } 9 | 10 | impl User { 11 | /// Creates a new user 12 | #[must_use] 13 | pub(crate) fn new(name: Option<&str>, email: Option<&str>) -> Self { 14 | Self { 15 | email: email.map(String::from), 16 | name: name.map(String::from), 17 | } 18 | } 19 | 20 | /// Get the optional name of the user 21 | #[must_use] 22 | pub(crate) fn name(&self) -> Option<&str> { 23 | self.name.as_deref() 24 | } 25 | 26 | /// Get the optional email of the user 27 | #[must_use] 28 | pub(crate) fn email(&self) -> Option<&str> { 29 | self.email.as_deref() 30 | } 31 | 32 | /// Returns `true` if one of name or email is a `Some` value. 33 | #[must_use] 34 | pub(crate) const fn is_some(&self) -> bool { 35 | self.name.is_some() || self.email.is_some() 36 | } 37 | } 38 | 39 | impl Display for User { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | if let Some(name) = self.name() { 42 | if let Some(email) = self.email() { 43 | write!(f, "{name} <{email}>") 44 | } 45 | else { 46 | write!(f, "{name}") 47 | } 48 | } 49 | else if let Some(email) = self.email() { 50 | write!(f, "<{email}>") 51 | } 52 | else { 53 | write!(f, "") 54 | } 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use claims::assert_some_eq; 61 | use rstest::rstest; 62 | 63 | use super::*; 64 | 65 | #[test] 66 | fn name() { 67 | let user = User::new(Some("name"), None); 68 | assert_some_eq!(user.name(), "name"); 69 | } 70 | 71 | #[test] 72 | fn email() { 73 | let user = User::new(None, Some("email")); 74 | assert_some_eq!(user.email(), "email"); 75 | } 76 | 77 | #[rstest] 78 | #[case(Some("name"), None)] 79 | #[case(None, Some("email"))] 80 | #[case(Some("email"), Some("email"))] 81 | fn is_some_none_when_some(#[case] name: Option<&str>, #[case] email: Option<&str>) { 82 | let user = User::new(name, email); 83 | assert!(user.is_some()); 84 | } 85 | 86 | #[test] 87 | fn is_some_none_when_none() { 88 | let user = User::new(None, None); 89 | assert!(!user.is_some()); 90 | } 91 | 92 | #[test] 93 | fn to_string_with_none_name_and_none_email() { 94 | let user = User::new(None, None); 95 | assert_eq!(user.to_string(), ""); 96 | } 97 | 98 | #[test] 99 | fn to_string_with_none_name_and_some_email() { 100 | let user = User::new(None, Some("me@example.com")); 101 | assert_eq!(user.to_string(), ""); 102 | } 103 | 104 | #[test] 105 | fn to_string_with_some_name_and_none_email() { 106 | let user = User::new(Some("Tim Oram"), None); 107 | assert_eq!(user.to_string(), "Tim Oram"); 108 | } 109 | 110 | #[test] 111 | fn to_string_with_some_name_and_some_email() { 112 | let user = User::new(Some("Tim Oram"), Some("me@example.com")); 113 | assert_eq!(user.to_string(), "Tim Oram "); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/display/color_mode.rs: -------------------------------------------------------------------------------- 1 | /// Represents the color mode of a terminal interface. 2 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 3 | pub(crate) enum ColorMode { 4 | /// Supports 2 colors. 5 | TwoTone, 6 | /// Supports 8 colors. 7 | ThreeBit, 8 | /// Supports 16 colors. 9 | FourBit, 10 | /// Supports 256 colors. 11 | EightBit, 12 | /// Supports 24 bits of color. 13 | TrueColor, 14 | } 15 | 16 | impl ColorMode { 17 | /// Supports 4 bit or more of color. 18 | #[must_use] 19 | pub(crate) fn has_minimum_four_bit_color(self) -> bool { 20 | self == Self::FourBit || self == Self::EightBit || self == Self::TrueColor 21 | } 22 | 23 | /// Has true color support. 24 | #[must_use] 25 | pub(crate) fn has_true_color(self) -> bool { 26 | self == Self::TrueColor 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | 34 | #[test] 35 | fn color_mode_has_minimum_four_bit_color_two_tone() { 36 | assert!(!ColorMode::TwoTone.has_minimum_four_bit_color()); 37 | } 38 | 39 | #[test] 40 | fn color_mode_has_minimum_four_bit_color_three_bit() { 41 | assert!(!ColorMode::ThreeBit.has_minimum_four_bit_color()); 42 | } 43 | 44 | #[test] 45 | fn color_mode_has_minimum_four_bit_color_four_bit() { 46 | assert!(ColorMode::FourBit.has_minimum_four_bit_color()); 47 | } 48 | 49 | #[test] 50 | fn color_mode_has_minimum_four_bit_color_eight_bit() { 51 | assert!(ColorMode::EightBit.has_minimum_four_bit_color()); 52 | } 53 | 54 | #[test] 55 | fn color_mode_has_minimum_four_bit_color_true_color() { 56 | assert!(ColorMode::TrueColor.has_minimum_four_bit_color()); 57 | } 58 | 59 | #[test] 60 | fn color_mode_has_true_color_two_tone() { 61 | assert!(!ColorMode::TwoTone.has_true_color()); 62 | } 63 | 64 | #[test] 65 | fn color_mode_has_true_color_three_bit() { 66 | assert!(!ColorMode::ThreeBit.has_true_color()); 67 | } 68 | 69 | #[test] 70 | fn color_mode_has_true_color_four_bit() { 71 | assert!(!ColorMode::FourBit.has_true_color()); 72 | } 73 | 74 | #[test] 75 | fn color_mode_has_true_color_eight_bit() { 76 | assert!(!ColorMode::EightBit.has_true_color()); 77 | } 78 | 79 | #[test] 80 | fn color_mode_has_true_color_true_color() { 81 | assert!(ColorMode::TrueColor.has_true_color()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/display/display_color.rs: -------------------------------------------------------------------------------- 1 | /// An abstraction of colors to display. 2 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 3 | pub(crate) enum DisplayColor { 4 | /// The color for the break action. 5 | ActionBreak, 6 | /// The color for the drop action. 7 | ActionDrop, 8 | /// The color for the edit action. 9 | ActionEdit, 10 | /// The color for the exec action. 11 | ActionExec, 12 | /// The color for the fixup action. 13 | ActionFixup, 14 | /// The color for the pick action. 15 | ActionPick, 16 | /// The color for the reword action. 17 | ActionReword, 18 | /// The color for the squash action. 19 | ActionSquash, 20 | /// The color for the label action. 21 | ActionLabel, 22 | /// The color for the reset action. 23 | ActionReset, 24 | /// The color for the merge action. 25 | ActionMerge, 26 | /// The color for the merge action. 27 | ActionUpdateRef, 28 | /// The color for added lines in a diff. 29 | DiffAddColor, 30 | /// The color for changed lines in a diff. 31 | DiffChangeColor, 32 | /// The color for removed lines in a diff. 33 | DiffRemoveColor, 34 | /// The color for context lines in a diff. 35 | DiffContextColor, 36 | /// The color for whitespace characters in a diff. 37 | DiffWhitespaceColor, 38 | /// The color for indicator text. 39 | IndicatorColor, 40 | /// The color for the standard text. 41 | Normal, 42 | } 43 | -------------------------------------------------------------------------------- /src/display/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use thiserror::Error; 4 | 5 | /// A display error. 6 | #[derive(Error, Debug)] 7 | #[non_exhaustive] 8 | pub(crate) enum DisplayError { 9 | /// An unexpected error occurred. 10 | #[error("Unexpected error")] 11 | Unexpected(io::Error), 12 | } 13 | 14 | impl PartialEq for DisplayError { 15 | #[expect(clippy::pattern_type_mismatch, reason = "Legacy, needs refactor")] 16 | fn eq(&self, other: &Self) -> bool { 17 | match (self, other) { 18 | (Self::Unexpected(self_io_error), Self::Unexpected(other_io_error)) => { 19 | self_io_error.kind() == other_io_error.kind() 20 | }, 21 | } 22 | } 23 | } 24 | 25 | #[cfg(test)] 26 | mod test { 27 | use super::*; 28 | 29 | #[test] 30 | fn partial_eq_io_error_same() { 31 | assert_eq!( 32 | DisplayError::Unexpected(io::Error::from(io::ErrorKind::Other)), 33 | DisplayError::Unexpected(io::Error::from(io::ErrorKind::Other)) 34 | ); 35 | } 36 | 37 | #[test] 38 | fn partial_eq_io_error_different() { 39 | assert_ne!( 40 | DisplayError::Unexpected(io::Error::from(io::ErrorKind::Other)), 41 | DisplayError::Unexpected(io::Error::from(io::ErrorKind::NotFound)) 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/display/size.rs: -------------------------------------------------------------------------------- 1 | /// Represents a terminal window size. 2 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 3 | pub(crate) struct Size { 4 | width: usize, 5 | height: usize, 6 | } 7 | 8 | impl Size { 9 | /// Create a new instance with a width and height. 10 | #[must_use] 11 | pub(crate) const fn new(width: usize, height: usize) -> Self { 12 | Self { width, height } 13 | } 14 | 15 | /// Get the width. 16 | #[must_use] 17 | pub(crate) const fn width(&self) -> usize { 18 | self.width 19 | } 20 | 21 | /// Get the height. 22 | #[must_use] 23 | pub(crate) const fn height(&self) -> usize { 24 | self.height 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/editor.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(test))] 2 | use crate::display::CrossTerm; 3 | #[cfg(test)] 4 | use crate::test_helpers::mocks::CrossTerm; 5 | use crate::{ 6 | application::Application, 7 | arguments::Args, 8 | exit::Exit, 9 | input::read_event, 10 | module::{ExitStatus, Modules}, 11 | }; 12 | 13 | #[cfg(not(tarpaulin_include))] 14 | pub(crate) fn run(args: &Args) -> Exit { 15 | let mut application: Application = match Application::new(args, read_event, CrossTerm::new()) { 16 | Ok(app) => app, 17 | Err(exit) => return exit, 18 | }; 19 | 20 | match application.run_until_finished() { 21 | Ok(..) => Exit::from(ExitStatus::Good), 22 | Err(exit) => exit, 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use std::{ffi::OsString, path::Path}; 29 | 30 | use super::*; 31 | use crate::test_helpers::with_git_directory; 32 | 33 | fn args(args: &[&str]) -> Args { 34 | Args::try_from(args.iter().map(OsString::from).collect::>()).unwrap() 35 | } 36 | 37 | #[test] 38 | fn successful_run() { 39 | with_git_directory("fixtures/simple", |path| { 40 | let todo_file = Path::new(path).join("rebase-todo-empty"); 41 | assert_eq!( 42 | run(&args(&[todo_file.to_str().unwrap()])).get_status(), 43 | &ExitStatus::Good 44 | ); 45 | }); 46 | } 47 | 48 | #[test] 49 | fn error_on_application_create() { 50 | with_git_directory("fixtures/simple", |path| { 51 | let todo_file = Path::new(path).join("does-not-exist"); 52 | assert_eq!( 53 | run(&args(&[todo_file.to_str().unwrap()])).get_status(), 54 | &ExitStatus::FileReadError 55 | ); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/exit.rs: -------------------------------------------------------------------------------- 1 | use crate::module::ExitStatus; 2 | 3 | #[derive(Debug, PartialEq, Eq)] 4 | pub(crate) struct Exit { 5 | message: Option, 6 | status: ExitStatus, 7 | } 8 | 9 | impl Exit { 10 | pub(crate) fn new(status: ExitStatus, message: &str) -> Self { 11 | Self { 12 | message: Some(String::from(message)), 13 | status, 14 | } 15 | } 16 | 17 | pub(crate) fn get_message(&self) -> Option<&str> { 18 | self.message.as_deref() 19 | } 20 | 21 | pub(crate) const fn get_status(&self) -> &ExitStatus { 22 | &self.status 23 | } 24 | } 25 | 26 | impl From for Exit { 27 | fn from(status: ExitStatus) -> Self { 28 | Self { message: None, status } 29 | } 30 | } 31 | 32 | impl From for Exit { 33 | fn from(msg: String) -> Self { 34 | Self { 35 | message: Some(msg), 36 | status: ExitStatus::Good, 37 | } 38 | } 39 | } 40 | 41 | impl From<&str> for Exit { 42 | fn from(msg: &str) -> Self { 43 | Self::from(String::from(msg)) 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | 51 | #[test] 52 | fn exit_new() { 53 | let exit = Exit::new(ExitStatus::StateError, "This is an error"); 54 | assert_eq!(exit.get_message(), Some("This is an error")); 55 | assert_eq!(exit.get_status(), &ExitStatus::StateError); 56 | } 57 | 58 | #[test] 59 | fn exit_from_exit_status() { 60 | let exit = Exit::from(ExitStatus::Kill); 61 | assert_eq!(exit.get_message(), None); 62 | assert_eq!(exit.get_status(), &ExitStatus::Kill); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/git.rs: -------------------------------------------------------------------------------- 1 | //! Git Interactive Rebase Tool - Git Module 2 | //! 3 | //! # Description 4 | //! This module is used to handle working with external Git systems. 5 | //! 6 | //! ## Test Utilities 7 | //! To facilitate testing the usages of this crate, a set of testing utilities are provided. Since 8 | //! these utilities are not tested, and often are optimized for developer experience than 9 | //! performance, they should only be used in test code. 10 | mod errors; 11 | 12 | use git2::Repository; 13 | pub(crate) use git2::{Config, ErrorCode}; 14 | 15 | pub(crate) use self::errors::{GitError, RepositoryLoadKind}; 16 | 17 | /// Find and open an existing repository, respecting git environment variables. This will check 18 | /// for and use `$GIT_DIR`, and if unset will search for a repository starting in the current 19 | /// directory, walking to the root. 20 | /// 21 | /// # Errors 22 | /// Will result in an error if the repository cannot be opened. 23 | pub(crate) fn open_repository_from_env() -> Result { 24 | Repository::open_from_env().map_err(|e| { 25 | GitError::RepositoryLoad { 26 | kind: RepositoryLoadKind::Environment, 27 | cause: e, 28 | } 29 | }) 30 | } 31 | 32 | // Paths in Windows make these tests difficult, so disable 33 | #[cfg(all(unix, test))] 34 | mod tests { 35 | use claims::assert_ok; 36 | use git2::ErrorClass; 37 | 38 | use super::*; 39 | use crate::test_helpers::with_git_directory; 40 | 41 | #[test] 42 | fn open_repository_from_env_success() { 43 | with_git_directory("fixtures/simple", |_| { 44 | assert_ok!(open_repository_from_env()); 45 | }); 46 | } 47 | 48 | #[test] 49 | fn open_repository_from_env_error() { 50 | with_git_directory("fixtures/does-not-exist", |path| { 51 | match open_repository_from_env() { 52 | Ok(_) => { 53 | panic!("open_repository_from_env should return error") 54 | }, 55 | Err(err) => { 56 | assert_eq!(err, GitError::RepositoryLoad { 57 | kind: RepositoryLoadKind::Environment, 58 | cause: git2::Error::new( 59 | ErrorCode::NotFound, 60 | ErrorClass::Os, 61 | format!("failed to resolve path '{path}': No such file or directory") 62 | ), 63 | }); 64 | }, 65 | } 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/help.rs: -------------------------------------------------------------------------------- 1 | use crate::{exit::Exit, version::VERSION}; 2 | 3 | const HELP_MESSAGE: &str = r#" 4 | Git Interactive Rebase Editor ({{VERSION}}) 5 | Full feature terminal based sequence editor for git interactive rebase. 6 | 7 | USAGE: 8 | interactive-rebase-tool [FLAGS] [REBASE-TODO-FILE] 9 | 10 | FLAGS: 11 | -v, --version Prints versioning information 12 | -h, --help Prints help information 13 | --license Prints Open Source Software licensing 14 | 15 | ARGS: 16 | The path to the Git rebase todo file 17 | "#; 18 | 19 | pub(crate) fn build_help(message: Option) -> String { 20 | let help = HELP_MESSAGE.replace("{{VERSION}}", VERSION); 21 | if let Some(msg) = message { 22 | format!("{msg}\n\n{help}") 23 | } 24 | else { 25 | help 26 | } 27 | } 28 | 29 | pub(crate) fn run() -> Exit { 30 | Exit::from(build_help(None)) 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use super::*; 36 | 37 | #[test] 38 | fn run_success() { 39 | assert!( 40 | run() 41 | .get_message() 42 | .unwrap() 43 | .contains("Full feature terminal based sequence editor for git interactive rebase.") 44 | ); 45 | } 46 | 47 | #[test] 48 | fn build_help_no_message() { 49 | assert!(build_help(None).contains("Full feature terminal based sequence editor for git interactive rebase.")); 50 | } 51 | 52 | #[test] 53 | fn build_help_message() { 54 | assert!(build_help(Some(String::from("Custom Message"))).contains("Custom Message")); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | //! Git Interactive Rebase Tool - Input Module 2 | //! 3 | //! # Description 4 | //! This module is used to handle working with input events. 5 | //! 6 | //! ## Test Utilities 7 | //! To facilitate testing the usages of this crate, a set of testing utilities are provided. Since 8 | //! these utilities are not tested, and often are optimized for developer experience than 9 | //! performance should only be used in test code. 10 | 11 | mod event; 12 | mod event_handler; 13 | mod event_provider; 14 | mod input_options; 15 | mod key_bindings; 16 | mod key_event; 17 | mod map_keybindings; 18 | mod standard_event; 19 | mod thread; 20 | 21 | pub(crate) use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind}; 22 | 23 | pub(crate) use self::{ 24 | event::Event, 25 | event_handler::EventHandler, 26 | event_provider::{EventReaderFn, read_event}, 27 | input_options::InputOptions, 28 | key_bindings::KeyBindings, 29 | key_event::KeyEvent, 30 | map_keybindings::map_keybindings, 31 | standard_event::StandardEvent, 32 | thread::{State, THREAD_NAME, Thread}, 33 | }; 34 | -------------------------------------------------------------------------------- /src/input/input_options.rs: -------------------------------------------------------------------------------- 1 | use bitflags::bitflags; 2 | 3 | bitflags! { 4 | /// Represents options for parsing input events. 5 | #[derive(Default, PartialEq, Eq, Debug, Clone, Copy)] 6 | pub(crate) struct InputOptions: u8 { 7 | /// Enable movement input handling 8 | const MOVEMENT = 0b0000_0001; 9 | /// Enable terminal resize input handling 10 | const RESIZE = 0b0000_0010; 11 | /// Enable undo and redo input handling 12 | const UNDO_REDO = 0b0000_0100; 13 | /// Search start 14 | const SEARCH_START = 0b0000_1000; 15 | /// Search handling 16 | const SEARCH = 0b0001_1000; 17 | /// Help input handling 18 | const HELP = 0b0010_0000; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/input/key_event.rs: -------------------------------------------------------------------------------- 1 | use crate::input::{KeyCode, KeyModifiers}; 2 | 3 | /// Represents a key event. 4 | #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy)] 5 | pub(crate) struct KeyEvent { 6 | /// The key itself. 7 | pub(crate) code: KeyCode, 8 | /// Additional key modifiers. 9 | pub(crate) modifiers: KeyModifiers, 10 | } 11 | 12 | impl KeyEvent { 13 | /// Creates a new `KeyEvent` with `code` and `modifiers`. 14 | #[must_use] 15 | pub(crate) fn new(mut code: KeyCode, mut modifiers: KeyModifiers) -> Self { 16 | // normalize keys with the SHIFT modifier 17 | if let KeyCode::Char(c) = code { 18 | if modifiers.contains(KeyModifiers::SHIFT) { 19 | code = KeyCode::Char(c.to_ascii_uppercase()); 20 | modifiers.remove(KeyModifiers::SHIFT); 21 | } 22 | } 23 | Self { code, modifiers } 24 | } 25 | } 26 | 27 | impl From for KeyEvent { 28 | fn from(key_event: crossterm::event::KeyEvent) -> Self { 29 | Self::new(key_event.code, key_event.modifiers) 30 | } 31 | } 32 | 33 | impl From for KeyEvent { 34 | fn from(code: KeyCode) -> Self { 35 | Self::new(code, KeyModifiers::empty()) 36 | } 37 | } 38 | 39 | #[cfg(test)] 40 | mod tests { 41 | use super::*; 42 | 43 | #[test] 44 | fn new_non_character() { 45 | assert_eq!(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), KeyEvent { 46 | code: KeyCode::Backspace, 47 | modifiers: KeyModifiers::ALT 48 | }); 49 | } 50 | 51 | #[test] 52 | fn new_lowercase_character_without_shift() { 53 | assert_eq!(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), KeyEvent { 54 | code: KeyCode::Char('a'), 55 | modifiers: KeyModifiers::NONE 56 | }); 57 | } 58 | 59 | #[test] 60 | fn new_uppercase_character_without_shift() { 61 | assert_eq!(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE), KeyEvent { 62 | code: KeyCode::Char('A'), 63 | modifiers: KeyModifiers::NONE 64 | }); 65 | } 66 | 67 | #[test] 68 | fn new_lowercase_character_with_shift() { 69 | assert_eq!(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT), KeyEvent { 70 | code: KeyCode::Char('A'), 71 | modifiers: KeyModifiers::NONE 72 | }); 73 | } 74 | 75 | #[test] 76 | fn new_uppercase_character_with_shift() { 77 | assert_eq!(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT), KeyEvent { 78 | code: KeyCode::Char('A'), 79 | modifiers: KeyModifiers::NONE 80 | }); 81 | } 82 | 83 | #[test] 84 | fn from_crossterm_key_event() { 85 | assert_eq!( 86 | KeyEvent::from(crossterm::event::KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)), 87 | KeyEvent { 88 | code: KeyCode::Char('a'), 89 | modifiers: KeyModifiers::ALT 90 | } 91 | ); 92 | } 93 | 94 | #[test] 95 | fn from_keycode() { 96 | assert_eq!(KeyEvent::from(KeyCode::Char('a')), KeyEvent { 97 | code: KeyCode::Char('a'), 98 | modifiers: KeyModifiers::NONE 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/input/map_keybindings.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyModifiers}; 2 | 3 | use crate::input::{Event, KeyEvent}; 4 | 5 | /// Map a keybinding to a list of events. 6 | #[must_use] 7 | #[expect(clippy::string_slice, reason = "Slice usage is guarded.")] 8 | pub(crate) fn map_keybindings(bindings: &[String]) -> Vec { 9 | bindings 10 | .iter() 11 | .map(|b| { 12 | let mut key = String::from(b); 13 | let mut modifiers = KeyModifiers::empty(); 14 | if key.contains("Control") { 15 | key = key.replace("Control", ""); 16 | modifiers.insert(KeyModifiers::CONTROL); 17 | } 18 | if key.contains("Alt") { 19 | key = key.replace("Alt", ""); 20 | modifiers.insert(KeyModifiers::ALT); 21 | } 22 | if key.contains("Shift") { 23 | key = key.replace("Shift", ""); 24 | modifiers.insert(KeyModifiers::SHIFT); 25 | } 26 | 27 | let code = match key.as_str() { 28 | "Backspace" => KeyCode::Backspace, 29 | "BackTab" => KeyCode::BackTab, 30 | "Delete" => KeyCode::Delete, 31 | "Down" => KeyCode::Down, 32 | "End" => KeyCode::End, 33 | "Enter" => KeyCode::Enter, 34 | "Esc" => KeyCode::Esc, 35 | "Home" => KeyCode::Home, 36 | "Insert" => KeyCode::Insert, 37 | "Left" => KeyCode::Left, 38 | "PageDown" => KeyCode::PageDown, 39 | "PageUp" => KeyCode::PageUp, 40 | "Right" => KeyCode::Right, 41 | "Tab" => KeyCode::Tab, 42 | "Up" => KeyCode::Up, 43 | // assume that this is an F key 44 | k if k.len() > 1 && k.to_ascii_lowercase().starts_with('f') => { 45 | let key_number = k[1..].parse::().unwrap_or(1); 46 | KeyCode::F(key_number) 47 | }, 48 | k => KeyCode::Char(k.chars().next().expect("Expected only one character from Char KeyCode")), 49 | }; 50 | Event::Key(KeyEvent::new(code, modifiers)) 51 | }) 52 | .collect() 53 | } 54 | -------------------------------------------------------------------------------- /src/interactive-rebase-tool.1: -------------------------------------------------------------------------------- 1 | .\" Manpage for interactive-rebase-tool. 2 | 3 | .TH INTERACTIVE\-REBASE\-TOOL 1 "" "" "" 4 | 5 | .SH NAME 6 | interactive-rebase-tool \- full featured sequence editor for git 7 | 8 | .SH SYNOPSIS 9 | 10 | .B interactive-rebase-tool 11 | <\fIrebase-todo-filepath\fR> 12 | .br 13 | .B interactive-rebase-tool 14 | [\fIoptions\fR] 15 | 16 | .SH DESCRIPTION 17 | Native cross platform full feature terminal based sequence editor for git interactive rebase. 18 | 19 | To configure git to use it by default for interactive rebasing (i.e. with \fBgit rebase \-\-interactive\fR), use: 20 | .sp 21 | .nf 22 | .in 16 23 | git config --global sequence.editor interactive-rebase-tool 24 | .fi 25 | .in 8 26 | .sp 27 | 28 | .SH OPTIONS 29 | .TP 30 | \fB\-\-help\fR 31 | Prints help information 32 | .TP 33 | \fB\-\-version\fR 34 | Prints version information 35 | 36 | .SH ON-LINE HELP 37 | 38 | Press \fB?\fR during use for a summary of commands. 39 | 40 | .SH SEE ALSO 41 | git(1) 42 | 43 | .SH BUGS 44 | See: 45 | 46 | .SH AUTHOR 47 | Tim Oram (dev@mitmaro.ca) 48 | -------------------------------------------------------------------------------- /src/license.rs: -------------------------------------------------------------------------------- 1 | use crate::exit::Exit; 2 | 3 | const LICENSE_MESSAGE: &str = r#" 4 | Sequence Editor for Git Interactive Rebase 5 | 6 | Copyright (C) 2017-2020 Tim Oram and Contributors 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with this program. If not, see . 20 | 21 | A list of open source software and the license terms can be found at 22 | 23 | "#; 24 | 25 | pub(crate) fn run() -> Exit { 26 | Exit::from(LICENSE_MESSAGE) 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | 33 | #[test] 34 | fn run_success() { 35 | assert!( 36 | run() 37 | .get_message() 38 | .unwrap() 39 | .contains("Sequence Editor for Git Interactive Rebase") 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | allow_unknown_lints, 3 | allow( 4 | unknown_lints, 5 | renamed_and_removed_lints, 6 | reason = "Nightly sometimes removes/renames lints." 7 | ) 8 | )] 9 | #![cfg_attr( 10 | test, 11 | allow( 12 | clippy::allow_attributes_without_reason, 13 | clippy::arbitrary_source_item_ordering, 14 | clippy::as_conversions, 15 | clippy::cast_possible_truncation, 16 | clippy::cognitive_complexity, 17 | clippy::let_underscore_must_use, 18 | clippy::let_underscore_untyped, 19 | clippy::missing_const_for_fn, 20 | clippy::missing_errors_doc, 21 | clippy::multiple_inherent_impl, 22 | clippy::needless_pass_by_value, 23 | clippy::panic, 24 | clippy::shadow_reuse, 25 | clippy::shadow_unrelated, 26 | clippy::struct_field_names, 27 | clippy::undocumented_unsafe_blocks, 28 | clippy::unimplemented, 29 | clippy::unreachable, 30 | let_underscore_drop, 31 | missing_docs, 32 | unfulfilled_lint_expectations, 33 | reason = "Relaxed for tests" 34 | ) 35 | )] 36 | // #![cfg_attr( 37 | // include_nightly_lints, 38 | // expect(.., reason = "Upcoming lints, only in nightly") 39 | // )] 40 | 41 | mod application; 42 | mod arguments; 43 | mod components; 44 | mod config; 45 | mod diff; 46 | mod display; 47 | mod editor; 48 | mod exit; 49 | mod git; 50 | mod help; 51 | mod input; 52 | mod license; 53 | mod module; 54 | mod modules; 55 | mod process; 56 | mod runtime; 57 | mod search; 58 | #[cfg(test)] 59 | mod test_helpers; 60 | #[cfg(test)] 61 | mod tests; 62 | mod todo_file; 63 | mod util; 64 | mod version; 65 | mod view; 66 | 67 | use std::{env, ffi::OsString, process::Termination}; 68 | 69 | use crate::{ 70 | arguments::{Args, Mode}, 71 | exit::Exit, 72 | }; 73 | 74 | #[must_use] 75 | fn run(os_args: Vec) -> Exit { 76 | match Args::try_from(os_args) { 77 | Err(err) => err, 78 | Ok(args) => { 79 | match *args.mode() { 80 | Mode::Help => help::run(), 81 | Mode::Version => version::run(), 82 | Mode::License => license::run(), 83 | Mode::Editor => editor::run(&args), 84 | } 85 | }, 86 | } 87 | } 88 | 89 | #[expect(clippy::print_stderr, reason = "Required to print error message.")] 90 | #[cfg(not(tarpaulin_include))] 91 | fn main() -> impl Termination { 92 | let exit = run(env::args_os().skip(1).collect()); 93 | if let Some(message) = exit.get_message() { 94 | eprintln!("{message}"); 95 | } 96 | *exit.get_status() 97 | } 98 | -------------------------------------------------------------------------------- /src/module.rs: -------------------------------------------------------------------------------- 1 | mod exit_status; 2 | mod module_handler; 3 | mod module_provider; 4 | mod modules; 5 | mod state; 6 | #[cfg(test)] 7 | mod tests; 8 | 9 | use std::sync::LazyLock; 10 | 11 | use anyhow::Error; 12 | 13 | pub(crate) use self::{ 14 | exit_status::ExitStatus, 15 | module_handler::ModuleHandler, 16 | module_provider::ModuleProvider, 17 | modules::Modules, 18 | state::State, 19 | }; 20 | use crate::{ 21 | input::{Event, InputOptions, KeyBindings}, 22 | process::Results, 23 | view::{RenderContext, ViewData}, 24 | }; 25 | 26 | pub(crate) static DEFAULT_INPUT_OPTIONS: LazyLock = LazyLock::new(|| InputOptions::RESIZE); 27 | pub(crate) static DEFAULT_VIEW_DATA: LazyLock = LazyLock::new(|| ViewData::new(|_| {})); 28 | 29 | pub(crate) trait Module: Send { 30 | fn activate(&mut self, _previous_state: State) -> Results { 31 | Results::new() 32 | } 33 | 34 | fn deactivate(&mut self) -> Results { 35 | Results::new() 36 | } 37 | 38 | fn build_view_data(&mut self, _render_context: &RenderContext) -> &ViewData { 39 | &DEFAULT_VIEW_DATA 40 | } 41 | 42 | fn input_options(&self) -> &InputOptions { 43 | &DEFAULT_INPUT_OPTIONS 44 | } 45 | 46 | fn read_event(&self, event: Event, _key_bindings: &KeyBindings) -> Event { 47 | event 48 | } 49 | 50 | fn handle_event(&mut self, _event: Event) -> Results { 51 | Results::new() 52 | } 53 | 54 | fn handle_error(&mut self, _error: &Error) -> Results { 55 | Results::new() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/module/exit_status.rs: -------------------------------------------------------------------------------- 1 | use std::process::{ExitCode, Termination}; 2 | 3 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 4 | pub(crate) enum ExitStatus { 5 | None, 6 | Abort, 7 | ConfigError, 8 | FileReadError, 9 | FileWriteError, 10 | Good, 11 | StateError, 12 | Kill, 13 | } 14 | 15 | impl ExitStatus { 16 | pub(crate) const fn to_code(self) -> u8 { 17 | match self { 18 | Self::Abort => 5, 19 | Self::ConfigError => 1, 20 | Self::FileReadError => 2, 21 | Self::FileWriteError => 3, 22 | Self::None | Self::Good => 0, 23 | Self::StateError => 4, 24 | Self::Kill => 6, 25 | } 26 | } 27 | } 28 | 29 | impl Termination for ExitStatus { 30 | fn report(self) -> ExitCode { 31 | ExitCode::from(self.to_code()) 32 | } 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use rstest::rstest; 38 | 39 | use super::*; 40 | 41 | #[rstest] 42 | #[case::abort(ExitStatus::None, 0)] 43 | #[case::abort(ExitStatus::Abort, 5)] 44 | #[case::config_error(ExitStatus::ConfigError, 1)] 45 | #[case::file_read_error(ExitStatus::FileReadError, 2)] 46 | #[case::file_write_error(ExitStatus::FileWriteError, 3)] 47 | #[case::good(ExitStatus::Good, 0)] 48 | #[case::state_error(ExitStatus::StateError, 4)] 49 | #[case::kill(ExitStatus::Kill, 6)] 50 | fn to_code(#[case] input: ExitStatus, #[case] expected: u8) { 51 | assert_eq!(ExitStatus::to_code(input), expected); 52 | } 53 | 54 | #[test] 55 | fn termination() { 56 | assert_eq!(ExitStatus::ConfigError.report(), ExitCode::from(1)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/module/module_provider.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | application::AppData, 3 | module::{Module, State}, 4 | }; 5 | 6 | pub(crate) trait ModuleProvider { 7 | fn new(app_data: &AppData) -> Self; 8 | 9 | fn get_mut_module(&mut self, _state: State) -> &mut dyn Module; 10 | 11 | fn get_module(&self, _state: State) -> &dyn Module; 12 | } 13 | -------------------------------------------------------------------------------- /src/module/state.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 2 | pub(crate) enum State { 3 | ConfirmAbort, 4 | ConfirmRebase, 5 | Error, 6 | ExternalEditor, 7 | List, 8 | Insert, 9 | ShowCommit, 10 | WindowSizeError, 11 | } 12 | -------------------------------------------------------------------------------- /src/module/tests.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | 3 | use crate::{ 4 | module::{Event, InputOptions, Module, State}, 5 | test_helpers::{create_test_keybindings, testers}, 6 | }; 7 | 8 | struct TestModule; 9 | 10 | impl Module for TestModule {} 11 | 12 | #[test] 13 | fn default_trait_method_activate() { 14 | let mut module = TestModule {}; 15 | assert!(module.activate(State::List).artifact().is_none()); 16 | } 17 | 18 | #[test] 19 | fn default_trait_method_deactivate() { 20 | let mut module = TestModule {}; 21 | assert!(module.deactivate().artifact().is_none()); 22 | } 23 | 24 | #[test] 25 | fn default_trait_method_build_view_data() { 26 | testers::module(&[], &[], None, |context| { 27 | let mut module = TestModule {}; 28 | let view_data = module.build_view_data(&context.render_context); 29 | assert!(!view_data.get_name().is_empty()); 30 | }); 31 | } 32 | 33 | #[test] 34 | fn default_trait_method_input_options() { 35 | let module = TestModule {}; 36 | assert_eq!(module.input_options(), &InputOptions::RESIZE); 37 | } 38 | 39 | #[test] 40 | fn default_trait_method_read_event() { 41 | let key_bindings = create_test_keybindings(); 42 | let module = TestModule {}; 43 | assert_eq!(module.read_event(Event::from('a'), &key_bindings), Event::from('a')); 44 | } 45 | 46 | #[test] 47 | fn default_trait_method_handle_event() { 48 | let mut module = TestModule {}; 49 | let mut result = module.handle_event(Event::from('a')); 50 | assert!(result.artifact().is_none()); 51 | } 52 | 53 | #[test] 54 | fn default_trait_method_handle_error() { 55 | let mut module = TestModule {}; 56 | assert!(module.handle_error(&anyhow!("Error")).artifact().is_none()); 57 | } 58 | -------------------------------------------------------------------------------- /src/modules.rs: -------------------------------------------------------------------------------- 1 | mod confirm_abort; 2 | mod confirm_rebase; 3 | mod error; 4 | mod external_editor; 5 | mod insert; 6 | mod list; 7 | mod show_commit; 8 | mod window_size_error; 9 | 10 | pub(crate) use self::{ 11 | confirm_abort::ConfirmAbort, 12 | confirm_rebase::ConfirmRebase, 13 | error::Error, 14 | external_editor::ExternalEditor, 15 | insert::Insert, 16 | list::List, 17 | show_commit::ShowCommit, 18 | window_size_error::WindowSizeError, 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/external_editor/action.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug, PartialEq, Eq)] 2 | pub(crate) enum Action { 3 | AbortRebase, 4 | EditRebase, 5 | RestoreAndAbortEdit, 6 | UndoAndEdit, 7 | } 8 | -------------------------------------------------------------------------------- /src/modules/external_editor/external_editor_state.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | 3 | #[derive(Debug)] 4 | pub(crate) enum ExternalEditorState { 5 | Active, 6 | Empty, 7 | Error(Error), 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/insert/insert_state.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Eq)] 2 | pub(crate) enum InsertState { 3 | Prompt, 4 | Edit, 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/insert/line_type.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | #[derive(Clone, Debug, PartialEq, Eq)] 4 | pub(crate) enum LineType { 5 | Cancel, 6 | Pick, 7 | Exec, 8 | Label, 9 | Merge, 10 | Reset, 11 | UpdateRef, 12 | } 13 | 14 | impl Display for LineType { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | match *self { 17 | Self::Cancel => write!(f, ""), 18 | Self::Pick => write!(f, "pick"), 19 | Self::Exec => write!(f, "exec"), 20 | Self::Label => write!(f, "label"), 21 | Self::Merge => write!(f, "merge"), 22 | Self::Reset => write!(f, "reset"), 23 | Self::UpdateRef => write!(f, "update-ref"), 24 | } 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use rstest::rstest; 31 | 32 | use super::*; 33 | 34 | #[rstest] 35 | #[case::cancel(&LineType::Cancel, "")] 36 | #[case::pick(&LineType::Pick, "pick")] 37 | #[case::exec(&LineType::Exec, "exec")] 38 | #[case::label(&LineType::Label, "label")] 39 | #[case::merge(&LineType::Merge, "merge")] 40 | #[case::reset(&LineType::Reset, "reset")] 41 | #[case::update_ref(&LineType::UpdateRef, "update-ref")] 42 | fn to_string(#[case] line_type: &LineType, #[case] expected: &str) { 43 | assert_eq!(line_type.to_string(), String::from(expected)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/list/search/line_match.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 2 | pub(crate) struct LineMatch { 3 | index: usize, 4 | hash: bool, 5 | content: bool, 6 | } 7 | 8 | impl LineMatch { 9 | pub(crate) const fn new(index: usize, hash: bool, content: bool) -> Self { 10 | Self { index, hash, content } 11 | } 12 | 13 | pub(crate) const fn index(&self) -> usize { 14 | self.index 15 | } 16 | 17 | pub(crate) const fn hash(&self) -> bool { 18 | self.hash 19 | } 20 | 21 | pub(crate) const fn content(&self) -> bool { 22 | self.content 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/list/tests.rs: -------------------------------------------------------------------------------- 1 | mod abort_and_rebase; 2 | mod activate; 3 | mod change_action; 4 | mod duplicate_line; 5 | mod edit_mode; 6 | mod external_editor; 7 | mod help; 8 | mod insert_line; 9 | mod movement; 10 | mod normal_mode; 11 | mod read_event; 12 | mod remove_lines; 13 | mod render; 14 | mod search; 15 | mod show_commit; 16 | mod swap_lines; 17 | mod toggle_break; 18 | mod toggle_option; 19 | mod undo_redo; 20 | mod visual_mode; 21 | 22 | use super::*; 23 | use crate::test_helpers::{create_config, testers}; 24 | 25 | #[test] 26 | fn resize() { 27 | testers::module( 28 | &["pick aaa c1"], 29 | &[Event::Resize(100, 200)], 30 | None, 31 | |mut test_context| { 32 | let mut module = List::new(&test_context.app_data()); 33 | _ = test_context.handle_all_events(&mut module); 34 | assert_eq!(module.height, 200); 35 | }, 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/list/tests/activate.rs: -------------------------------------------------------------------------------- 1 | use claims::{assert_none, assert_some_eq}; 2 | 3 | use super::*; 4 | use crate::{ 5 | assert_results, 6 | process::Artifact, 7 | search::{Interrupter, SearchResult}, 8 | }; 9 | 10 | #[derive(Clone)] 11 | struct MockedSearchable; 12 | 13 | impl Searchable for MockedSearchable { 14 | fn reset(&mut self) {} 15 | 16 | fn search(&mut self, _: Interrupter, _: &str) -> SearchResult { 17 | SearchResult::None 18 | } 19 | } 20 | 21 | #[test] 22 | fn sets_selected_line_action() { 23 | testers::module(&["pick aaa c1"], &[], None, |test_context| { 24 | let mut module = List::new(&test_context.app_data()); 25 | _ = test_context.activate(&mut module, State::List); 26 | assert_some_eq!(module.selected_line_action, Action::Pick); 27 | }); 28 | } 29 | 30 | #[test] 31 | fn sets_selected_line_action_none_selected() { 32 | testers::module(&["pick aaa c1", "pick bbb c2"], &[], None, |test_context| { 33 | let app_data = test_context.app_data(); 34 | 35 | let todo_file = app_data.todo_file(); 36 | todo_file.lock().set_lines(vec![]); 37 | 38 | let mut module = List::new(&app_data); 39 | _ = test_context.activate(&mut module, State::List); 40 | assert_none!(module.selected_line_action); 41 | }); 42 | } 43 | 44 | #[test] 45 | fn result() { 46 | testers::module(&["pick aaa c1", "pick bbb c2"], &[], None, |test_context| { 47 | let mut module = List::new(&test_context.app_data()); 48 | assert_results!( 49 | test_context.activate(&mut module, State::List), 50 | Artifact::Searchable(Box::new(MockedSearchable {})) 51 | ); 52 | }); 53 | } 54 | 55 | #[test] 56 | fn result_with_serach_term() { 57 | testers::module(&["pick aaa c1", "pick bbb c2"], &[], None, |test_context| { 58 | let mut module = List::new(&test_context.app_data()); 59 | module.search_bar.start_search(Some("foo")); 60 | assert_results!( 61 | test_context.activate(&mut module, State::List), 62 | Artifact::Searchable(Box::new(MockedSearchable {})), 63 | Artifact::SearchTerm(String::from("foo")) 64 | ); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/modules/list/tests/duplicate_line.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{action_line, assert_rendered_output, assert_results, process::Artifact}; 3 | 4 | #[test] 5 | fn duplicate_line_duplicatable() { 6 | testers::module( 7 | &["pick aaa c1"], 8 | &[Event::from(StandardEvent::DuplicateLine)], 9 | None, 10 | |mut test_context| { 11 | let mut module = List::new(&test_context.app_data()); 12 | assert_results!( 13 | test_context.handle_event(&mut module), 14 | Artifact::Event(Event::from(StandardEvent::DuplicateLine)) 15 | ); 16 | assert_rendered_output!( 17 | Body test_context.build_view_data(&mut module), 18 | action_line!(Selected Pick "aaa", "c1"), 19 | action_line!(Pick "aaa", "c1") 20 | ); 21 | }, 22 | ); 23 | } 24 | 25 | #[test] 26 | fn duplicate_line_not_duplicatable() { 27 | testers::module( 28 | &["break"], 29 | &[Event::from(StandardEvent::DuplicateLine)], 30 | None, 31 | |mut test_context| { 32 | let mut module = List::new(&test_context.app_data()); 33 | assert_results!( 34 | test_context.handle_event(&mut module), 35 | Artifact::Event(Event::from(StandardEvent::DuplicateLine)) 36 | ); 37 | assert_rendered_output!( 38 | Body test_context.build_view_data(&mut module), 39 | action_line!(Selected Break) 40 | ); 41 | }, 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/list/tests/edit_mode.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{assert_rendered_output, assert_results, input::KeyCode, process::Artifact}; 3 | 4 | #[test] 5 | fn edit_with_edit_content() { 6 | testers::module( 7 | &["exec echo foo"], 8 | &[Event::from(StandardEvent::Edit)], 9 | None, 10 | |mut test_context| { 11 | let mut module = List::new(&test_context.app_data()); 12 | assert_results!( 13 | test_context.handle_event(&mut module), 14 | Artifact::Event(Event::from(StandardEvent::Edit)) 15 | ); 16 | assert_eq!(module.state, ListState::Edit); 17 | }, 18 | ); 19 | } 20 | 21 | #[test] 22 | fn edit_without_edit_content() { 23 | testers::module( 24 | &["pick aaa c1"], 25 | &[Event::from(StandardEvent::Edit)], 26 | None, 27 | |mut test_context| { 28 | let mut module = List::new(&test_context.app_data()); 29 | assert_results!( 30 | test_context.handle_event(&mut module), 31 | Artifact::Event(Event::from(StandardEvent::Edit)) 32 | ); 33 | assert_eq!(module.state, ListState::Normal); 34 | }, 35 | ); 36 | } 37 | 38 | #[test] 39 | fn edit_without_selected_line() { 40 | testers::module(&[], &[Event::from(StandardEvent::Edit)], None, |mut test_context| { 41 | let mut module = List::new(&test_context.app_data()); 42 | assert_results!( 43 | test_context.handle_event(&mut module), 44 | Artifact::Event(Event::from(StandardEvent::Edit)) 45 | ); 46 | assert_eq!(module.state, ListState::Normal); 47 | }); 48 | } 49 | 50 | #[test] 51 | fn handle_event() { 52 | testers::module( 53 | &["exec foo"], 54 | &[ 55 | Event::from(StandardEvent::Edit), 56 | Event::from(KeyCode::Backspace), 57 | Event::from(KeyCode::Enter), 58 | ], 59 | None, 60 | |mut test_context| { 61 | let mut module = List::new(&test_context.app_data()); 62 | _ = test_context.build_view_data(&mut module); 63 | _ = test_context.handle_all_events(&mut module); 64 | assert_eq!(module.todo_file.lock().get_line(0).unwrap().get_content(), "fo"); 65 | assert_eq!(module.state, ListState::Normal); 66 | }, 67 | ); 68 | } 69 | 70 | #[test] 71 | fn render() { 72 | testers::module( 73 | &["exec foo"], 74 | &[Event::from(StandardEvent::Edit)], 75 | None, 76 | |mut test_context| { 77 | let mut module = List::new(&test_context.app_data()); 78 | _ = test_context.handle_all_events(&mut module); 79 | let view_data = test_context.build_view_data(&mut module); 80 | assert_rendered_output!( 81 | Style view_data, 82 | "{TITLE}", 83 | "{LEADING}", 84 | "{IndicatorColor}Modifying line: exec foo", 85 | "", 86 | "{BODY}", 87 | "{Normal,Dimmed}exec {Normal}foo{Normal,Underline}", 88 | "{TRAILING}", 89 | "{IndicatorColor}Enter to finish" 90 | ); 91 | }, 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/modules/list/tests/external_editor.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{assert_results, process::Artifact}; 3 | 4 | #[test] 5 | fn normal_mode_open_external_editor() { 6 | testers::module( 7 | &["pick aaa c1"], 8 | &[Event::from(StandardEvent::OpenInEditor)], 9 | None, 10 | |mut test_context| { 11 | let mut module = List::new(&test_context.app_data()); 12 | assert_results!( 13 | test_context.handle_event(&mut module), 14 | Artifact::Event(Event::from(StandardEvent::OpenInEditor)), 15 | Artifact::SearchCancel, 16 | Artifact::ChangeState(State::ExternalEditor) 17 | ); 18 | }, 19 | ); 20 | } 21 | 22 | #[test] 23 | fn visual_mode_open_external_editor() { 24 | testers::module( 25 | &["pick aaa c1"], 26 | &[ 27 | Event::from(StandardEvent::ToggleVisualMode), 28 | Event::from(StandardEvent::OpenInEditor), 29 | ], 30 | None, 31 | |mut test_context| { 32 | let mut module = List::new(&test_context.app_data()); 33 | _ = test_context.handle_event(&mut module); 34 | assert_results!( 35 | test_context.handle_event(&mut module), 36 | Artifact::Event(Event::from(StandardEvent::OpenInEditor)), 37 | Artifact::SearchCancel, 38 | Artifact::ChangeState(State::ExternalEditor) 39 | ); 40 | }, 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/list/tests/insert_line.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{assert_results, process::Artifact}; 3 | 4 | #[test] 5 | fn insert_line() { 6 | testers::module( 7 | &[], 8 | &[Event::from(StandardEvent::InsertLine)], 9 | None, 10 | |mut test_context| { 11 | let mut module = List::new(&test_context.app_data()); 12 | assert_results!( 13 | test_context.handle_event(&mut module), 14 | Artifact::Event(Event::from(StandardEvent::InsertLine)), 15 | Artifact::ChangeState(State::Insert) 16 | ); 17 | }, 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/list/tests/normal_mode.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{action_line, assert_rendered_output, assert_results, input::KeyCode, process::Artifact}; 3 | 4 | #[test] 5 | fn change_auto_select_next_with_next_line() { 6 | let mut config = create_config(); 7 | config.auto_select_next = true; 8 | testers::module( 9 | &["pick aaa c1", "pick aaa c2"], 10 | &[Event::from(StandardEvent::ActionSquash)], 11 | Some(config), 12 | |mut test_context| { 13 | let mut module = List::new(&test_context.app_data()); 14 | _ = test_context.handle_all_events(&mut module); 15 | let view_data = test_context.build_view_data(&mut module); 16 | assert_rendered_output!( 17 | Body view_data, 18 | action_line!(Squash "aaa", "c1"), 19 | action_line!(Selected Pick "aaa", "c2") 20 | ); 21 | }, 22 | ); 23 | } 24 | 25 | #[test] 26 | fn toggle_visual_mode() { 27 | testers::module( 28 | &["pick aaa c1"], 29 | &[Event::from(StandardEvent::ToggleVisualMode)], 30 | None, 31 | |mut test_context| { 32 | let mut module = List::new(&test_context.app_data()); 33 | assert_results!( 34 | test_context.handle_event(&mut module), 35 | Artifact::Event(Event::from(StandardEvent::ToggleVisualMode)) 36 | ); 37 | assert_eq!(module.visual_index_start, Some(0)); 38 | assert_eq!(module.state, ListState::Visual); 39 | }, 40 | ); 41 | } 42 | 43 | #[test] 44 | fn other_event() { 45 | testers::module( 46 | &["pick aaa c1"], 47 | &[Event::from(KeyCode::Null)], 48 | None, 49 | |mut test_context| { 50 | let mut module = List::new(&test_context.app_data()); 51 | assert_results!( 52 | test_context.handle_event(&mut module), 53 | Artifact::Event(Event::from(KeyCode::Null)) 54 | ); 55 | }, 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/modules/list/tests/show_commit.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{assert_results, process::Artifact}; 3 | 4 | #[test] 5 | fn when_hash_available() { 6 | testers::module( 7 | &["pick aaa c1"], 8 | &[Event::from(StandardEvent::ShowCommit)], 9 | None, 10 | |mut test_context| { 11 | let mut module = List::new(&test_context.app_data()); 12 | assert_results!( 13 | test_context.handle_event(&mut module), 14 | Artifact::Event(Event::from(StandardEvent::ShowCommit)), 15 | Artifact::ChangeState(State::ShowCommit) 16 | ); 17 | }, 18 | ); 19 | } 20 | 21 | #[test] 22 | fn when_no_selected_line() { 23 | testers::module( 24 | &[], 25 | &[Event::from(StandardEvent::ShowCommit)], 26 | None, 27 | |mut test_context| { 28 | let mut module = List::new(&test_context.app_data()); 29 | assert_results!( 30 | test_context.handle_event(&mut module), 31 | Artifact::Event(Event::from(StandardEvent::ShowCommit)) 32 | ); 33 | }, 34 | ); 35 | } 36 | 37 | #[test] 38 | fn do_not_when_hash_not_available() { 39 | testers::module( 40 | &["exec echo foo"], 41 | &[Event::from(StandardEvent::ShowCommit)], 42 | None, 43 | |mut test_context| { 44 | let mut module = List::new(&test_context.app_data()); 45 | assert_results!( 46 | test_context.handle_event(&mut module), 47 | Artifact::Event(Event::from(StandardEvent::ShowCommit)) 48 | ); 49 | }, 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/list/tests/toggle_break.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::{action_line, assert_rendered_output}; 3 | 4 | #[test] 5 | fn change_toggle_break_add() { 6 | testers::module( 7 | &["pick aaa c1"], 8 | &[Event::from(StandardEvent::ActionBreak)], 9 | None, 10 | |mut test_context| { 11 | let mut module = List::new(&test_context.app_data()); 12 | _ = test_context.handle_all_events(&mut module); 13 | let view_data = test_context.build_view_data(&mut module); 14 | assert_rendered_output!( 15 | Body view_data, 16 | action_line!(Pick "aaa", "c1"), 17 | action_line!(Selected Break) 18 | ); 19 | }, 20 | ); 21 | } 22 | 23 | #[test] 24 | fn change_toggle_break_remove() { 25 | testers::module( 26 | &["pick aaa c1", "break"], 27 | &[ 28 | Event::from(StandardEvent::MoveCursorDown), 29 | Event::from(StandardEvent::ActionBreak), 30 | ], 31 | None, 32 | |mut test_context| { 33 | let mut module = List::new(&test_context.app_data()); 34 | _ = test_context.handle_all_events(&mut module); 35 | let view_data = test_context.build_view_data(&mut module); 36 | assert_rendered_output!( 37 | Body view_data, 38 | action_line!(Selected Pick "aaa", "c1") 39 | ); 40 | }, 41 | ); 42 | } 43 | 44 | #[test] 45 | fn change_toggle_break_above_existing() { 46 | testers::module( 47 | &["pick aaa c1", "break"], 48 | &[Event::from(StandardEvent::ActionBreak)], 49 | None, 50 | |mut test_context| { 51 | let mut module = List::new(&test_context.app_data()); 52 | _ = test_context.handle_all_events(&mut module); 53 | let view_data = test_context.build_view_data(&mut module); 54 | assert_rendered_output!( 55 | Body view_data, 56 | action_line!(Selected Pick "aaa", "c1"), 57 | action_line!(Break) 58 | ); 59 | }, 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/list/tests/toggle_option.rs: -------------------------------------------------------------------------------- 1 | use claims::{assert_none, assert_some, assert_some_eq}; 2 | 3 | use super::*; 4 | 5 | #[test] 6 | fn on_fixup_keep_message() { 7 | testers::module( 8 | &["fixup aaa c1"], 9 | &[Event::from(StandardEvent::FixupKeepMessage)], 10 | None, 11 | |mut test_context| { 12 | let mut module = List::new(&test_context.app_data()); 13 | _ = test_context.activate(&mut module, State::List); 14 | _ = test_context.handle_all_events(&mut module); 15 | let todo_file = module.todo_file.lock(); 16 | let line = todo_file.get_line(0).unwrap(); 17 | assert_some_eq!(line.option(), "-C"); 18 | }, 19 | ); 20 | } 21 | 22 | #[test] 23 | fn on_fixup_keep_message_with_editor() { 24 | testers::module( 25 | &["fixup aaa c1"], 26 | &[Event::from(StandardEvent::FixupKeepMessageWithEditor)], 27 | None, 28 | |mut test_context| { 29 | let mut module = List::new(&test_context.app_data()); 30 | _ = test_context.activate(&mut module, State::List); 31 | _ = test_context.handle_all_events(&mut module); 32 | let todo_file = module.todo_file.lock(); 33 | let line = todo_file.get_line(0).unwrap(); 34 | assert_some_eq!(line.option(), "-c"); 35 | }, 36 | ); 37 | } 38 | 39 | #[test] 40 | fn on_existing_option_remove_option() { 41 | testers::module( 42 | &["fixup -c aaa c1"], 43 | &[Event::from(StandardEvent::FixupKeepMessageWithEditor)], 44 | None, 45 | |mut test_context| { 46 | let mut module = List::new(&test_context.app_data()); 47 | _ = test_context.activate(&mut module, State::List); 48 | _ = test_context.handle_all_events(&mut module); 49 | let todo_file = module.todo_file.lock(); 50 | let line = todo_file.get_line(0).unwrap(); 51 | assert_none!(line.option()); 52 | }, 53 | ); 54 | } 55 | 56 | #[test] 57 | fn after_select_line() { 58 | testers::module( 59 | &["fixup aaa c1", "fixup aaa c2", "fixup aaa c3"], 60 | &[Event::from(StandardEvent::MoveCursorDown), Event::from('u')], 61 | None, 62 | |mut test_context| { 63 | let mut module = List::new(&test_context.app_data()); 64 | _ = test_context.activate(&mut module, State::List); 65 | _ = test_context.handle_all_events(&mut module); 66 | assert_none!(module.todo_file.lock().get_line(0).unwrap().option()); 67 | assert_some!(module.todo_file.lock().get_line(1).unwrap().option()); 68 | assert_none!(module.todo_file.lock().get_line(2).unwrap().option()); 69 | }, 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/modules/show_commit/show_commit_state.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug, PartialEq, Eq)] 2 | pub(super) enum ShowCommitState { 3 | Overview, 4 | Diff, 5 | } 6 | -------------------------------------------------------------------------------- /src/process/artifact.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Debug, Formatter}; 2 | 3 | use anyhow::Error; 4 | 5 | use crate::{ 6 | input::Event, 7 | module::{ExitStatus, State}, 8 | search::Searchable, 9 | }; 10 | 11 | pub(crate) enum Artifact { 12 | ChangeState(State), 13 | EnqueueResize, 14 | Error(Error, Option), 15 | Event(Event), 16 | ExitStatus(ExitStatus), 17 | ExternalCommand((String, Vec)), 18 | SearchCancel, 19 | SearchTerm(String), 20 | Searchable(Box), 21 | LoadDiff(String), 22 | CancelDiff, 23 | } 24 | 25 | impl Debug for Artifact { 26 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 27 | match *self { 28 | Self::ChangeState(state) => write!(f, "ChangeState({state:?})"), 29 | Self::EnqueueResize => write!(f, "EnqueueResize"), 30 | Self::Error(ref err, state) => write!(f, "Error({err}, {state:?})"), 31 | Self::Event(event) => write!(f, "Event({event:?})"), 32 | Self::ExitStatus(status) => write!(f, "ExitStatus({status:?})"), 33 | Self::ExternalCommand((ref command, ref args)) => write!(f, "ExternalCommand({command:?}, {args:?})"), 34 | Self::SearchCancel => write!(f, "SearchCancel"), 35 | Self::SearchTerm(ref term) => write!(f, "SearchTerm({term:?})"), 36 | Self::Searchable(_) => write!(f, "Searchable(dyn Searchable)"), 37 | Self::LoadDiff(ref hash) => write!(f, "LoadDiff({hash:?})"), 38 | Self::CancelDiff => write!(f, "CancelDiff"), 39 | } 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use anyhow::anyhow; 46 | use rstest::rstest; 47 | 48 | use super::*; 49 | use crate::test_helpers::mocks; 50 | 51 | #[rstest] 52 | #[case::change_state(Artifact::ChangeState(State::List), "ChangeState(List)")] 53 | #[case::enqueue_resize(Artifact::EnqueueResize, "EnqueueResize")] 54 | #[case::error(Artifact::Error(anyhow!("Error"), Some(State::List)), "Error(Error, Some(List))")] 55 | #[case::event(Artifact::Event(Event::None), "Event(None)")] 56 | #[case::exit_status(Artifact::ExitStatus(ExitStatus::Abort), "ExitStatus(Abort)")] 57 | #[case::external_command(Artifact::ExternalCommand((String::from("foo"), vec![])), "ExternalCommand(\"foo\", [])")] 58 | #[case::search_cancel(Artifact::SearchCancel, "SearchCancel")] 59 | #[case::search_term(Artifact::SearchTerm(String::from("foo")), "SearchTerm(\"foo\")")] 60 | #[case::searchable( 61 | Artifact::Searchable(Box::new(mocks::Searchable::new())), 62 | "Searchable(dyn Searchable)" 63 | )] 64 | #[case::diff_load(Artifact::LoadDiff(String::from("hash")), "LoadDiff(\"hash\")")] 65 | #[case::diff_cancel(Artifact::CancelDiff, "CancelDiff")] 66 | fn debug(#[case] artifact: Artifact, #[case] expected: &str) { 67 | assert_eq!(format!("{artifact:?}"), expected); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/runtime.rs: -------------------------------------------------------------------------------- 1 | //! Git Interactive Rebase Tool - Runtime 2 | //! 3 | //! # Description 4 | //! This module is used to handle the application lifecycles and management of threads. 5 | //! 6 | //! ## Test Utilities 7 | //! To facilitate testing the usages of this crate, a set of testing utilities are provided. Since 8 | //! these utilities are not tested, and often are optimized for developer experience than 9 | //! performance should only be used in test code. 10 | 11 | mod errors; 12 | mod installer; 13 | mod notifier; 14 | #[expect( 15 | clippy::module_inception, 16 | reason = "This is from a past refactor and should be updated." 17 | )] 18 | mod runtime; 19 | mod status; 20 | 21 | mod thread_statuses; 22 | mod threadable; 23 | 24 | pub(crate) use self::{ 25 | errors::RuntimeError, 26 | installer::Installer, 27 | notifier::Notifier, 28 | runtime::Runtime, 29 | status::Status, 30 | thread_statuses::ThreadStatuses, 31 | threadable::Threadable, 32 | }; 33 | -------------------------------------------------------------------------------- /src/runtime/errors.rs: -------------------------------------------------------------------------------- 1 | //! Git Interactive Rebase Tool - Git crate errors 2 | //! 3 | //! # Description 4 | //! This module contains error types used in the Git crate. 5 | 6 | use thiserror::Error; 7 | 8 | /// The kind of config error that occurred. 9 | #[derive(Error, Debug, PartialEq, Eq)] 10 | #[non_exhaustive] 11 | pub(crate) enum RuntimeError { 12 | /// An error occurred while attempting to spawn a thread 13 | #[error("An error occurred while attempting to spawn thread: {0}")] 14 | ThreadSpawnError(String), 15 | /// No thread with the given name is registered 16 | #[error("No thread with name '{0}' is registered")] 17 | ThreadNotRegistered(String), 18 | /// A timeout occurred while attempting to wait for a thread 19 | #[error("A timeout occurred while waiting for thread: '{0}'")] 20 | ThreadWaitTimeout(String), 21 | /// An error occurred while sending a message 22 | #[error("Failed to send message")] 23 | SendError, 24 | /// The thread resulted in an error. 25 | #[error("An error occurred during ")] 26 | ThreadError(String), 27 | } 28 | -------------------------------------------------------------------------------- /src/runtime/status.rs: -------------------------------------------------------------------------------- 1 | use crate::runtime::RuntimeError; 2 | 3 | /// The threads status. 4 | #[derive(Debug, PartialEq, Eq)] 5 | pub(crate) enum Status { 6 | /// Thread is new, and hasn't yet started. This is the initial status of all threads. 7 | New, 8 | /// The thread is busy processing. 9 | Busy, 10 | /// The thread is waiting for more work to complete. 11 | Waiting, 12 | /// The thread is finished. This is a final state. 13 | Ended, 14 | /// The thread has requested all threads pause. 15 | RequestPause, 16 | /// The thread has requested all threads resume. 17 | RequestResume, 18 | /// The thread has requested all threads end. 19 | RequestEnd, 20 | /// The thread has errored with provided `RuntimeError`. This is a final state. 21 | Error(RuntimeError), 22 | } 23 | -------------------------------------------------------------------------------- /src/runtime/threadable.rs: -------------------------------------------------------------------------------- 1 | use crate::runtime::Installer; 2 | 3 | /// An interface for a entity that has threads managed by the `Runtime`. 4 | pub(crate) trait Threadable: Send { 5 | /// Method that installs the threads that the `Threadable` is responsible for. 6 | fn install(&self, installer: &Installer); 7 | 8 | /// Called when threads are requested to pause. 9 | /// 10 | /// # Errors 11 | /// Returns an error is that thread cannot be paused for any reason. 12 | fn pause(&self) {} 13 | 14 | /// Called when threads are requested to resume. 15 | /// 16 | /// # Errors 17 | /// Returns an error is that thread cannot be resumed for any reason. 18 | fn resume(&self) {} 19 | 20 | /// Called when threads are requested to finish. 21 | /// 22 | /// # Errors 23 | /// Returns an error is that thread cannot be ended for any reason. 24 | fn end(&self) {} 25 | } 26 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod interrupter; 3 | mod search_result; 4 | mod searchable; 5 | mod state; 6 | mod status; 7 | mod thread; 8 | mod update_handler; 9 | 10 | pub(crate) use self::{ 11 | action::Action, 12 | interrupter::Interrupter, 13 | search_result::SearchResult, 14 | searchable::Searchable, 15 | state::State, 16 | status::Status, 17 | thread::Thread, 18 | update_handler::UpdateHandlerFn, 19 | }; 20 | -------------------------------------------------------------------------------- /src/search/action.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Debug, Formatter}; 2 | 3 | use crate::search::Searchable; 4 | 5 | pub(crate) enum Action { 6 | Cancel, 7 | Continue, 8 | End, 9 | SetSearchable(Box), 10 | Start(String), 11 | } 12 | 13 | impl Debug for Action { 14 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 15 | match *self { 16 | Self::Cancel => write!(f, "Cancel"), 17 | Self::Continue => write!(f, "Continue"), 18 | Self::End => write!(f, "End"), 19 | Self::SetSearchable(_) => write!(f, "SetSearchable(_)"), 20 | Self::Start(ref term) => write!(f, "Start({term})"), 21 | } 22 | } 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use rstest::rstest; 28 | 29 | use super::*; 30 | use crate::search::{Interrupter, SearchResult}; 31 | 32 | struct TestSearchable; 33 | 34 | impl Searchable for TestSearchable { 35 | fn reset(&mut self) {} 36 | 37 | fn search(&mut self, _: Interrupter, _: &str) -> SearchResult { 38 | SearchResult::None 39 | } 40 | } 41 | 42 | #[rstest] 43 | #[case::cancel(Action::Cancel, "Cancel")] 44 | #[case::cont(Action::Continue, "Continue")] 45 | #[case::end(Action::End, "End")] 46 | #[case::set_searchable(Action::SetSearchable(Box::new(TestSearchable {})), "SetSearchable(_)")] 47 | #[case::start(Action::Start(String::from("foo")), "Start(foo)")] 48 | fn debug(#[case] action: Action, #[case] expected: &str) { 49 | assert_eq!(format!("{action:?}"), expected); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/search/interrupter.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ops::Add as _, 3 | time::{Duration, Instant}, 4 | }; 5 | 6 | pub(crate) struct Interrupter { 7 | finish: Instant, 8 | } 9 | 10 | impl Interrupter { 11 | pub(crate) fn new(duration: Duration) -> Self { 12 | Self { 13 | finish: Instant::now().add(duration), 14 | } 15 | } 16 | 17 | pub(crate) fn should_continue(&self) -> bool { 18 | Instant::now() < self.finish 19 | } 20 | } 21 | 22 | #[cfg(test)] 23 | mod test { 24 | use std::ops::Sub as _; 25 | 26 | use super::*; 27 | 28 | #[test] 29 | fn should_continue_before_finish() { 30 | let interrupter = Interrupter::new(Duration::from_secs(60)); 31 | assert!(interrupter.should_continue()); 32 | } 33 | 34 | #[test] 35 | fn should_continue_after_finish() { 36 | let interrupter = Interrupter { 37 | finish: Instant::now().sub(Duration::from_secs(60)), 38 | }; 39 | assert!(!interrupter.should_continue()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/search/search_result.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2 | pub(crate) enum SearchResult { 3 | None, 4 | Complete, 5 | Updated, 6 | } 7 | -------------------------------------------------------------------------------- /src/search/searchable.rs: -------------------------------------------------------------------------------- 1 | use crate::search::{Interrupter, SearchResult}; 2 | 3 | pub(crate) trait Searchable: Send { 4 | fn reset(&mut self); 5 | 6 | fn search(&mut self, interrupter: Interrupter, term: &str) -> SearchResult; 7 | } 8 | -------------------------------------------------------------------------------- /src/search/state.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{ 3 | Arc, 4 | atomic::{AtomicBool, Ordering}, 5 | }, 6 | time::Duration, 7 | }; 8 | 9 | use crossbeam_channel::RecvTimeoutError; 10 | 11 | use crate::search::Action; 12 | 13 | const RECEIVE_TIMEOUT: Duration = Duration::from_millis(500); 14 | 15 | #[derive(Clone, Debug)] 16 | pub(crate) struct State { 17 | ended: Arc, 18 | paused: Arc, 19 | update_receiver: crossbeam_channel::Receiver, 20 | update_sender: crossbeam_channel::Sender, 21 | } 22 | 23 | impl State { 24 | pub(crate) fn new() -> Self { 25 | let (update_sender, update_receiver) = crossbeam_channel::unbounded(); 26 | Self { 27 | ended: Arc::new(AtomicBool::from(false)), 28 | paused: Arc::new(AtomicBool::from(false)), 29 | update_receiver, 30 | update_sender, 31 | } 32 | } 33 | 34 | pub(crate) fn receive_update(&self) -> Action { 35 | self.update_receiver 36 | .recv_timeout(RECEIVE_TIMEOUT) 37 | .unwrap_or_else(|e: RecvTimeoutError| { 38 | match e { 39 | RecvTimeoutError::Timeout => Action::Continue, 40 | RecvTimeoutError::Disconnected => Action::End, 41 | } 42 | }) 43 | } 44 | 45 | pub(crate) fn send_update(&self, action: Action) { 46 | let _result = self.update_sender.send(action); 47 | } 48 | 49 | pub(crate) fn is_paused(&self) -> bool { 50 | self.paused.load(Ordering::Acquire) 51 | } 52 | 53 | pub(crate) fn is_ended(&self) -> bool { 54 | self.ended.load(Ordering::Acquire) 55 | } 56 | 57 | pub(crate) fn pause(&self) { 58 | self.paused.store(true, Ordering::Release); 59 | } 60 | 61 | pub(crate) fn resume(&self) { 62 | self.paused.store(false, Ordering::Release); 63 | } 64 | 65 | pub(crate) fn end(&self) { 66 | self.ended.store(true, Ordering::Release); 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | 73 | use super::*; 74 | 75 | #[test] 76 | fn send_recv_update() { 77 | let state = State::new(); 78 | state.send_update(Action::Start(String::from("test"))); 79 | assert!(matches!(state.receive_update(), Action::Start(_))); 80 | } 81 | 82 | #[test] 83 | fn send_recv_update_timeout() { 84 | let state = State::new(); 85 | assert!(matches!(state.receive_update(), Action::Continue)); 86 | } 87 | 88 | #[test] 89 | fn send_recv_disconnect() { 90 | let (update_sender, _update_receiver) = crossbeam_channel::unbounded(); 91 | let mut state = State::new(); 92 | state.update_sender = update_sender; // replace last reference to sender, to force a disconnect 93 | assert!(matches!(state.receive_update(), Action::End)); 94 | } 95 | 96 | #[test] 97 | fn paused() { 98 | let state = State::new(); 99 | state.pause(); 100 | assert!(state.is_paused()); 101 | } 102 | 103 | #[test] 104 | fn resumed() { 105 | let state = State::new(); 106 | state.resume(); 107 | assert!(!state.is_paused()); 108 | } 109 | 110 | #[test] 111 | fn ended() { 112 | let state = State::new(); 113 | state.end(); 114 | assert!(state.is_ended()); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/search/status.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 2 | pub(crate) enum Status { 3 | Inactive, 4 | Active, 5 | Complete, 6 | } 7 | -------------------------------------------------------------------------------- /src/search/update_handler.rs: -------------------------------------------------------------------------------- 1 | /// Function for handling 2 | pub(crate) trait UpdateHandlerFn: Fn() + Sync + Send {} 3 | 4 | impl UpdateHandlerFn for FN {} 5 | -------------------------------------------------------------------------------- /src/test_helpers.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | pub(crate) mod assertions; 3 | pub(crate) mod builders; 4 | mod create_commit; 5 | mod create_config; 6 | mod create_default_test_module_handler; 7 | mod create_event_reader; 8 | mod create_invalid_utf; 9 | mod create_test_keybindings; 10 | mod create_test_module_handler; 11 | pub(crate) mod mocks; 12 | mod shared; 13 | pub(crate) mod testers; 14 | mod with_env_var; 15 | mod with_event_handler; 16 | mod with_git_config; 17 | mod with_git_directory; 18 | mod with_search; 19 | mod with_temp_bare_repository; 20 | mod with_temp_repository; 21 | mod with_todo_file; 22 | mod with_view_state; 23 | 24 | pub(crate) static JAN_2021_EPOCH: i64 = 1_609_459_200; 25 | 26 | pub(crate) use self::{ 27 | create_commit::{CreateCommitOptions, create_commit}, 28 | create_config::create_config, 29 | create_default_test_module_handler::{DefaultTestModule, create_default_test_module_handler}, 30 | create_event_reader::create_event_reader, 31 | create_invalid_utf::invalid_utf, 32 | create_test_keybindings::create_test_keybindings, 33 | create_test_module_handler::create_test_module_handler, 34 | shared::TestModuleProvider, 35 | with_env_var::{EnvVarAction, with_env_var}, 36 | with_event_handler::{EventHandlerTestContext, with_event_handler}, 37 | with_git_config::with_git_config, 38 | with_git_directory::with_git_directory, 39 | with_search::with_search, 40 | with_temp_bare_repository::with_temp_bare_repository, 41 | with_temp_repository::with_temp_repository, 42 | with_todo_file::with_todo_file, 43 | with_view_state::{ViewStateTestContext, with_view_state}, 44 | }; 45 | -------------------------------------------------------------------------------- /src/test_helpers/assertions.rs: -------------------------------------------------------------------------------- 1 | mod assert_empty; 2 | mod assert_not_empty; 3 | pub(crate) mod assert_rendered_output; 4 | mod assert_results; 5 | 6 | pub(crate) use assert_results::{_assert_results, AnyArtifact, ArtifactCompareWrapper}; 7 | -------------------------------------------------------------------------------- /src/test_helpers/assertions/assert_empty.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! assert_empty { 3 | ($expression:expr) => { 4 | assert!($expression.is_empty(), "assertion failed, expected {:?} to be empty", $expression) 5 | }; 6 | ($expression:expr, $($arg:tt)+) => { 7 | assert!( 8 | $expression.is_empty(), 9 | "assertion failed, expected {:?} to be empty: {}", 10 | $expression, 11 | format_args!($($arg)+) 12 | ) 13 | }; 14 | } 15 | 16 | #[macro_export] 17 | macro_rules! debug_assert_empty { 18 | ($($arg:tt)*) => (if cfg!(debug_assertions) { $crate::assert_empty!($($arg)*); }) 19 | } 20 | -------------------------------------------------------------------------------- /src/test_helpers/assertions/assert_not_empty.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! assert_not_empty { 3 | ($expression:expr) => { 4 | assert!(!$expression.is_empty(), "assertion failed, expected {:?} to not be empty", $expression) 5 | }; 6 | ($expression:expr, $($arg:tt)+) => { 7 | assert!( 8 | !$expression.is_empty(), 9 | "assertion failed, expected {:?} to not be empty: {}", 10 | $expression, 11 | format_args!($($arg)+) 12 | ) 13 | }; 14 | } 15 | 16 | #[macro_export] 17 | macro_rules! debug_assert_not_empty { 18 | ($($arg:tt)*) => (if cfg!(debug_assertions) { $crate::assert_not_empty!($($arg)*); }) 19 | } 20 | -------------------------------------------------------------------------------- /src/test_helpers/assertions/assert_rendered_output/render_style.rs: -------------------------------------------------------------------------------- 1 | use crate::{display::DisplayColor, view::LineSegment}; 2 | 3 | pub(crate) fn render_style(line_segment: &LineSegment) -> String { 4 | let color_string = match line_segment.get_color() { 5 | DisplayColor::ActionBreak => String::from("ActionBreak"), 6 | DisplayColor::ActionDrop => String::from("ActionDrop"), 7 | DisplayColor::ActionEdit => String::from("ActionEdit"), 8 | DisplayColor::ActionExec => String::from("ActionExec"), 9 | DisplayColor::ActionFixup => String::from("ActionFixup"), 10 | DisplayColor::ActionPick => String::from("ActionPick"), 11 | DisplayColor::ActionReword => String::from("ActionReword"), 12 | DisplayColor::ActionSquash => String::from("ActionSquash"), 13 | DisplayColor::DiffAddColor => String::from("DiffAddColor"), 14 | DisplayColor::DiffChangeColor => String::from("DiffChangeColor"), 15 | DisplayColor::DiffRemoveColor => String::from("DiffRemoveColor"), 16 | DisplayColor::DiffContextColor => String::from("DiffContextColor"), 17 | DisplayColor::DiffWhitespaceColor => String::from("DiffWhitespaceColor"), 18 | DisplayColor::IndicatorColor => String::from("IndicatorColor"), 19 | DisplayColor::Normal => String::from("Normal"), 20 | DisplayColor::ActionLabel => String::from("ActionLabel"), 21 | DisplayColor::ActionReset => String::from("ActionReset"), 22 | DisplayColor::ActionMerge => String::from("ActionMerge"), 23 | DisplayColor::ActionUpdateRef => String::from("ActionUpdateRef"), 24 | }; 25 | 26 | let mut style = vec![]; 27 | if line_segment.is_dimmed() { 28 | style.push("Dimmed"); 29 | } 30 | if line_segment.is_underlined() { 31 | style.push("Underline"); 32 | } 33 | if line_segment.is_reversed() { 34 | style.push("Reversed"); 35 | } 36 | 37 | if style.is_empty() { 38 | format!("{{{color_string}}}") 39 | } 40 | else { 41 | format!("{{{color_string},{}}}", style.join(",")) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test_helpers/assertions/assert_rendered_output/render_view_data.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | test_helpers::assertions::assert_rendered_output::{AssertRenderOptions, render_view_line}, 3 | view::ViewData, 4 | }; 5 | 6 | pub(crate) fn render_view_data(view_data: &ViewData, options: AssertRenderOptions) -> Vec { 7 | let mut lines = vec![]; 8 | let body_only = options.contains(AssertRenderOptions::BODY_ONLY); 9 | if !body_only && view_data.show_title() { 10 | if view_data.show_help() { 11 | lines.push(String::from("{TITLE}{HELP}")); 12 | } 13 | else { 14 | lines.push(String::from("{TITLE}")); 15 | } 16 | } 17 | 18 | let leading_lines = view_data.leading_lines(); 19 | let body_lines = view_data.lines(); 20 | let trailing_lines = view_data.trailing_lines(); 21 | 22 | if leading_lines.count() == 0 && body_lines.count() == 0 && trailing_lines.count() == 0 { 23 | lines.push(String::from("{EMPTY}")); 24 | } 25 | 26 | if !body_only && leading_lines.count() != 0 { 27 | lines.push(String::from("{LEADING}")); 28 | for line in leading_lines.iter() { 29 | lines.push(render_view_line(line, Some(options))); 30 | } 31 | } 32 | 33 | if body_lines.count() != 0 { 34 | if !body_only { 35 | lines.push(String::from("{BODY}")); 36 | } 37 | for line in body_lines.iter() { 38 | lines.push(render_view_line(line, Some(options))); 39 | } 40 | } 41 | 42 | if !body_only && trailing_lines.count() != 0 { 43 | lines.push(String::from("{TRAILING}")); 44 | for line in trailing_lines.iter() { 45 | lines.push(render_view_line(line, Some(options))); 46 | } 47 | } 48 | lines 49 | } 50 | -------------------------------------------------------------------------------- /src/test_helpers/assertions/assert_rendered_output/render_view_line.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | test_helpers::assertions::assert_rendered_output::{AssertRenderOptions, render_style}, 3 | view::ViewLine, 4 | }; 5 | 6 | /// Render a `ViewLine` to a `String` using similar logic that is used in the `View`. 7 | #[must_use] 8 | pub(crate) fn render_view_line(view_line: &ViewLine, options: Option) -> String { 9 | let mut line = String::new(); 10 | 11 | let opts = options.unwrap_or_default(); 12 | 13 | if opts.contains(AssertRenderOptions::INCLUDE_PINNED) { 14 | let pinned = view_line.get_number_of_pinned_segment(); 15 | if pinned > 0 { 16 | line.push_str(format!("{{Pin({pinned})}}").as_str()); 17 | } 18 | } 19 | 20 | if view_line.get_selected() { 21 | line.push_str("{Selected}"); 22 | } 23 | 24 | let mut last_style = String::new(); 25 | for segment in view_line.get_segments() { 26 | if opts.contains(AssertRenderOptions::INCLUDE_STYLE) { 27 | let style = render_style(segment); 28 | if style != last_style { 29 | line.push_str(style.as_str()); 30 | last_style = style; 31 | } 32 | } 33 | line.push_str(segment.get_content()); 34 | } 35 | if let Some(padding) = view_line.get_padding() { 36 | if opts.contains(AssertRenderOptions::INCLUDE_STYLE) { 37 | let style = render_style(padding); 38 | if style != last_style { 39 | line.push_str(style.as_str()); 40 | } 41 | } 42 | line.push_str(format!("{{Pad({})}}", padding.get_content()).as_str()); 43 | } 44 | line 45 | } 46 | -------------------------------------------------------------------------------- /src/test_helpers/builders.rs: -------------------------------------------------------------------------------- 1 | mod commit; 2 | mod commit_diff; 3 | mod file_status; 4 | mod reference; 5 | 6 | pub(crate) use self::{ 7 | commit::CommitBuilder, 8 | commit_diff::CommitDiffBuilder, 9 | file_status::FileStatusBuilder, 10 | reference::ReferenceBuilder, 11 | }; 12 | -------------------------------------------------------------------------------- /src/test_helpers/builders/commit.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Local, TimeZone as _}; 2 | 3 | use crate::{ 4 | diff::{Commit, Reference, User}, 5 | test_helpers::JAN_2021_EPOCH, 6 | }; 7 | 8 | /// Builder for creating a new commit. 9 | #[derive(Debug)] 10 | pub(crate) struct CommitBuilder { 11 | commit: Commit, 12 | } 13 | 14 | impl CommitBuilder { 15 | /// Create a new instance of the builder with the provided hash. The new instance will default 16 | /// to a committed date of Jan 1, 2021 UTC. All other fields are `None`. 17 | #[must_use] 18 | pub(crate) fn new(hash: &str) -> Self { 19 | Self { 20 | commit: Commit { 21 | hash: String::from(hash), 22 | reference: None, 23 | author: User::new(None, None), 24 | authored_date: None, 25 | committed_date: Local.timestamp_opt(JAN_2021_EPOCH, 0).unwrap(), 26 | committer: None, 27 | message: None, 28 | summary: None, 29 | }, 30 | } 31 | } 32 | 33 | /// Set the hash. 34 | #[must_use] 35 | pub(crate) fn hash(mut self, hash: &str) -> Self { 36 | self.commit.hash = String::from(hash); 37 | self 38 | } 39 | 40 | /// Set the reference, use `create::testutil::ReferenceBuilder` to build a `Reference`. 41 | #[must_use] 42 | pub(crate) fn reference(mut self, reference: Reference) -> Self { 43 | self.commit.reference = Some(reference); 44 | self 45 | } 46 | 47 | /// Set the author name and related email address. 48 | #[must_use] 49 | pub(crate) fn author(mut self, author: User) -> Self { 50 | self.commit.author = author; 51 | self 52 | } 53 | 54 | /// Set the authored commit time from number of seconds since unix epoch. 55 | #[must_use] 56 | pub(crate) fn authored_time(mut self, time: i64) -> Self { 57 | self.commit.authored_date = Some(Local.timestamp_opt(time, 0).unwrap()); 58 | self 59 | } 60 | 61 | /// Set the committer name and related email address. 62 | #[must_use] 63 | pub(crate) fn committer(mut self, committer: User) -> Self { 64 | self.commit.committer = Some(committer); 65 | self 66 | } 67 | 68 | /// Set the committed commit time from number of seconds since unix epoch. 69 | #[must_use] 70 | pub(crate) fn commit_time(mut self, time: i64) -> Self { 71 | self.commit.committed_date = Local.timestamp_opt(time, 0).unwrap(); 72 | self 73 | } 74 | 75 | /// Set the commit summary. 76 | #[must_use] 77 | pub(crate) fn summary(mut self, summary: &str) -> Self { 78 | self.commit.summary = Some(String::from(summary)); 79 | self 80 | } 81 | 82 | /// Set the commit message. 83 | #[must_use] 84 | pub(crate) fn message(mut self, message: &str) -> Self { 85 | self.commit.message = Some(String::from(message)); 86 | self 87 | } 88 | 89 | /// Build the `Commit`. 90 | #[must_use] 91 | pub(crate) fn build(self) -> Commit { 92 | self.commit 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/test_helpers/builders/commit_diff.rs: -------------------------------------------------------------------------------- 1 | use crate::diff::{Commit, CommitDiff, FileStatus}; 2 | 3 | /// Builder for creating a new commit diff. 4 | #[derive(Debug)] 5 | pub(crate) struct CommitDiffBuilder { 6 | commit: Commit, 7 | parent: Option, 8 | file_statuses: Vec, 9 | number_files_changed: usize, 10 | number_insertions: usize, 11 | number_deletions: usize, 12 | } 13 | 14 | impl CommitDiffBuilder { 15 | /// Create a new instance. 16 | #[must_use] 17 | pub(crate) const fn new(commit: Commit) -> Self { 18 | Self { 19 | commit, 20 | parent: None, 21 | file_statuses: vec![], 22 | number_files_changed: 0, 23 | number_insertions: 0, 24 | number_deletions: 0, 25 | } 26 | } 27 | 28 | /// Set the commit. 29 | #[must_use] 30 | pub(crate) fn commit(mut self, commit: Commit) -> Self { 31 | self.commit = commit; 32 | self 33 | } 34 | 35 | /// Set the parent commit. 36 | #[must_use] 37 | pub(crate) fn parent(mut self, parent: Commit) -> Self { 38 | self.parent = Some(parent); 39 | self 40 | } 41 | 42 | /// Set the `FileStatus`es. 43 | #[must_use] 44 | pub(crate) fn file_statuses(mut self, statuses: Vec) -> Self { 45 | self.file_statuses = statuses; 46 | self 47 | } 48 | 49 | /// Set the number of files changed. 50 | #[must_use] 51 | pub(crate) const fn number_files_changed(mut self, count: usize) -> Self { 52 | self.number_files_changed = count; 53 | self 54 | } 55 | 56 | /// Set the number of line insertions. 57 | #[must_use] 58 | pub(crate) const fn number_insertions(mut self, count: usize) -> Self { 59 | self.number_insertions = count; 60 | self 61 | } 62 | 63 | /// Set the number of line deletions. 64 | #[must_use] 65 | pub(crate) const fn number_deletions(mut self, count: usize) -> Self { 66 | self.number_deletions = count; 67 | self 68 | } 69 | 70 | /// Return the built `CommitDiff` 71 | #[must_use] 72 | pub(crate) fn build(self) -> CommitDiff { 73 | let mut diff = CommitDiff::new(); 74 | diff.reset(self.commit, self.parent); 75 | diff.update( 76 | self.file_statuses, 77 | self.number_files_changed, 78 | self.number_insertions, 79 | self.number_deletions, 80 | ); 81 | 82 | diff 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test_helpers/builders/file_status.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use crate::diff::{Delta, FileMode, FileStatus, Status}; 4 | 5 | /// Builder for creating a new reference. 6 | #[derive(Debug)] 7 | pub(crate) struct FileStatusBuilder { 8 | deltas: Vec, 9 | destination_is_binary: bool, 10 | destination_mode: FileMode, 11 | destination_path: PathBuf, 12 | source_is_binary: bool, 13 | source_mode: FileMode, 14 | source_path: PathBuf, 15 | status: Status, 16 | } 17 | 18 | impl FileStatusBuilder { 19 | /// Create a new instance of the builder. The new instance will default to an empty file status. 20 | #[must_use] 21 | pub(crate) fn new() -> Self { 22 | Self { 23 | deltas: vec![], 24 | destination_is_binary: false, 25 | destination_mode: FileMode::Normal, 26 | destination_path: PathBuf::default(), 27 | source_is_binary: false, 28 | source_mode: FileMode::Normal, 29 | source_path: PathBuf::default(), 30 | status: Status::Added, 31 | } 32 | } 33 | 34 | /// Push a `Delta`. 35 | #[must_use] 36 | pub(crate) fn push_delta(mut self, delta: Delta) -> Self { 37 | self.deltas.push(delta); 38 | self 39 | } 40 | 41 | /// Set if the destination is binary. 42 | #[must_use] 43 | pub(crate) const fn destination_is_binary(mut self, binary: bool) -> Self { 44 | self.destination_is_binary = binary; 45 | self 46 | } 47 | 48 | /// Set the destination file mode. 49 | #[must_use] 50 | pub(crate) const fn destination_mode(mut self, mode: FileMode) -> Self { 51 | self.destination_mode = mode; 52 | self 53 | } 54 | 55 | /// Set the destination file path. 56 | #[must_use] 57 | pub(crate) fn destination_path>(mut self, path: F) -> Self { 58 | self.destination_path = PathBuf::from(path.as_ref()); 59 | self 60 | } 61 | 62 | /// Set if the source is binary. 63 | #[must_use] 64 | pub(crate) const fn source_is_binary(mut self, binary: bool) -> Self { 65 | self.source_is_binary = binary; 66 | self 67 | } 68 | 69 | /// Set if the source file mode. 70 | #[must_use] 71 | pub(crate) const fn source_mode(mut self, mode: FileMode) -> Self { 72 | self.source_mode = mode; 73 | self 74 | } 75 | 76 | /// Set the destination file path. 77 | #[must_use] 78 | pub(crate) fn source_path>(mut self, path: F) -> Self { 79 | self.source_path = PathBuf::from(path.as_ref()); 80 | self 81 | } 82 | 83 | /// Set the status. 84 | #[must_use] 85 | pub(crate) const fn status(mut self, status: Status) -> Self { 86 | self.status = status; 87 | self 88 | } 89 | 90 | /// Build the `FileStatus` 91 | #[must_use] 92 | pub(crate) fn build(self) -> FileStatus { 93 | let mut file_status = FileStatus::new( 94 | self.source_path, 95 | self.source_mode, 96 | self.source_is_binary, 97 | self.destination_path, 98 | self.destination_mode, 99 | self.destination_is_binary, 100 | self.status, 101 | ); 102 | for delta in self.deltas { 103 | file_status.add_delta(delta); 104 | } 105 | 106 | file_status 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test_helpers/builders/reference.rs: -------------------------------------------------------------------------------- 1 | use crate::diff::{Reference, ReferenceKind}; 2 | 3 | /// Builder for creating a new reference. 4 | #[derive(Debug)] 5 | pub(crate) struct ReferenceBuilder { 6 | hash: String, 7 | name: String, 8 | shorthand: String, 9 | kind: ReferenceKind, 10 | } 11 | 12 | impl ReferenceBuilder { 13 | /// Create a new instance of the builder with the provided hash. The new instance will default 14 | /// to a branch kind and a name of "main". 15 | #[must_use] 16 | pub(crate) fn new(hash: &str) -> Self { 17 | Self { 18 | hash: String::from(hash), 19 | name: String::from("refs/heads/main"), 20 | shorthand: String::from("main"), 21 | kind: ReferenceKind::Branch, 22 | } 23 | } 24 | 25 | /// Set the hash. 26 | pub(crate) fn hash(&mut self, hash: &str) -> &mut Self { 27 | self.hash = String::from(hash); 28 | self 29 | } 30 | 31 | /// Set the name. 32 | pub(crate) fn name(&mut self, name: &str) -> &mut Self { 33 | self.name = String::from(name); 34 | self 35 | } 36 | 37 | /// Set the shortname. 38 | pub(crate) fn shorthand(&mut self, shorthand: &str) -> &mut Self { 39 | self.shorthand = String::from(shorthand); 40 | self 41 | } 42 | 43 | /// Set the kind. 44 | pub(crate) fn kind(&mut self, kind: ReferenceKind) -> &mut Self { 45 | self.kind = kind; 46 | self 47 | } 48 | 49 | /// Build the `Reference`. 50 | #[must_use] 51 | pub(crate) fn build(self) -> Reference { 52 | Reference::new(self.hash, self.name, self.shorthand, self.kind) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test_helpers/create_config.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | 3 | pub(crate) fn create_config() -> Config { 4 | Config::new_with_config(None).unwrap() 5 | } 6 | -------------------------------------------------------------------------------- /src/test_helpers/create_default_test_module_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | input::EventHandler, 3 | module::{Module, ModuleHandler}, 4 | test_helpers::{TestModuleProvider, create_test_keybindings}, 5 | }; 6 | 7 | pub(crate) struct DefaultTestModule; 8 | 9 | impl Module for DefaultTestModule {} 10 | 11 | pub(crate) fn create_default_test_module_handler() -> ModuleHandler> { 12 | ModuleHandler::new( 13 | EventHandler::new(create_test_keybindings()), 14 | TestModuleProvider::::from(DefaultTestModule {}), 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/test_helpers/create_event_reader.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use crossterm::event as c_event; 3 | 4 | use crate::input::{Event, EventReaderFn}; 5 | 6 | /// Create an event reader that will map the provided events to the internal representation of the 7 | /// events. This allows for mocking of event input when testing at the highest level of the application. 8 | /// 9 | /// This function does not accept any `Event::MetaEvent` or `Event::StandardEvent` event types, instead 10 | /// use other event types that will map to the expected value using the keybindings. 11 | /// 12 | /// This function should be used sparingly, and instead `with_event_handler` should be used where possible. 13 | /// 14 | /// # Panics 15 | /// If provided an event generator that returns a `Event::MetaEvent` or `Event::StandardEvent` event type. 16 | pub(crate) fn create_event_reader( 17 | event_generator: EventGeneratorFunction, 18 | ) -> impl EventReaderFn 19 | where EventGeneratorFunction: Fn() -> Result> + Sync + Send + 'static { 20 | move || { 21 | match event_generator()? { 22 | None => Ok(None), 23 | Some(event) => { 24 | match event { 25 | Event::Key(key) => { 26 | Ok(Some(c_event::Event::Key(c_event::KeyEvent::new( 27 | key.code, 28 | key.modifiers, 29 | )))) 30 | }, 31 | Event::Mouse(mouse_event) => Ok(Some(c_event::Event::Mouse(mouse_event))), 32 | Event::None => Ok(None), 33 | Event::Resize(width, height) => Ok(Some(c_event::Event::Resize(width, height))), 34 | Event::Standard(_) => { 35 | panic!("MetaEvent and Standard are not supported, please use other event types") 36 | }, 37 | } 38 | }, 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test_helpers/create_invalid_utf.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | static INVALID_UTF_STRING: LazyLock = LazyLock::new(create_invalid_utf); 4 | 5 | #[expect(unsafe_code, reason = "Used for testing invalid UTF8 strings")] 6 | fn create_invalid_utf() -> String { 7 | // used in tests to create an invalid value in a Git config file, while this is unsafe, it is 8 | // only ever used in tests to test the handling of invalid input data 9 | unsafe { String::from_utf8_unchecked(vec![0xC3, 0x28]) } 10 | } 11 | 12 | pub(crate) fn invalid_utf() -> &'static str { 13 | INVALID_UTF_STRING.as_str() 14 | } 15 | -------------------------------------------------------------------------------- /src/test_helpers/create_test_keybindings.rs: -------------------------------------------------------------------------------- 1 | use crate::input::{KeyBindings, map_keybindings}; 2 | 3 | /// Create a mocked version of `KeyBindings`. 4 | #[must_use] 5 | pub(crate) fn create_test_keybindings() -> KeyBindings { 6 | KeyBindings { 7 | redo: map_keybindings(&[String::from("Controly")]), 8 | undo: map_keybindings(&[String::from("Controlz")]), 9 | scroll_down: map_keybindings(&[String::from("Down")]), 10 | scroll_end: map_keybindings(&[String::from("End")]), 11 | scroll_home: map_keybindings(&[String::from("Home")]), 12 | scroll_left: map_keybindings(&[String::from("Left")]), 13 | scroll_right: map_keybindings(&[String::from("Right")]), 14 | scroll_up: map_keybindings(&[String::from("Up")]), 15 | scroll_step_down: map_keybindings(&[String::from("PageDown")]), 16 | scroll_step_up: map_keybindings(&[String::from("PageUp")]), 17 | help: map_keybindings(&[String::from("?")]), 18 | search_start: map_keybindings(&[String::from("/")]), 19 | search_next: map_keybindings(&[String::from("n")]), 20 | search_previous: map_keybindings(&[String::from("N")]), 21 | abort: map_keybindings(&[String::from("q")]), 22 | action_break: map_keybindings(&[String::from("b")]), 23 | action_drop: map_keybindings(&[String::from("d")]), 24 | action_edit: map_keybindings(&[String::from("e")]), 25 | action_fixup: map_keybindings(&[String::from("f")]), 26 | action_pick: map_keybindings(&[String::from("p")]), 27 | action_reword: map_keybindings(&[String::from("r")]), 28 | action_squash: map_keybindings(&[String::from("s")]), 29 | confirm_yes: map_keybindings(&[String::from("y")]), 30 | edit: map_keybindings(&[String::from("E")]), 31 | force_abort: map_keybindings(&[String::from("Q")]), 32 | force_rebase: map_keybindings(&[String::from("W")]), 33 | insert_line: map_keybindings(&[String::from("I")]), 34 | duplicate_line: map_keybindings(&[String::from("ControlD")]), 35 | move_down: map_keybindings(&[String::from("Down")]), 36 | move_down_step: map_keybindings(&[String::from("PageDown")]), 37 | move_end: map_keybindings(&[String::from("End")]), 38 | move_home: map_keybindings(&[String::from("Home")]), 39 | move_left: map_keybindings(&[String::from("Left")]), 40 | move_right: map_keybindings(&[String::from("Right")]), 41 | move_selection_down: map_keybindings(&[String::from("j")]), 42 | move_selection_up: map_keybindings(&[String::from("k")]), 43 | move_up: map_keybindings(&[String::from("Up")]), 44 | move_up_step: map_keybindings(&[String::from("PageUp")]), 45 | open_in_external_editor: map_keybindings(&[String::from('!')]), 46 | rebase: map_keybindings(&[String::from('w')]), 47 | remove_line: map_keybindings(&[String::from("Delete")]), 48 | show_commit: map_keybindings(&[String::from("c")]), 49 | show_diff: map_keybindings(&[String::from("d")]), 50 | toggle_visual_mode: map_keybindings(&[String::from("v")]), 51 | fixup_keep_message: map_keybindings(&[String::from("u")]), 52 | fixup_keep_message_with_editor: map_keybindings(&[String::from("U")]), 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test_helpers/create_test_module_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | input::EventHandler, 3 | module::{Module, ModuleHandler}, 4 | test_helpers::{create_test_keybindings, shared::TestModuleProvider}, 5 | }; 6 | 7 | pub(crate) fn create_test_module_handler(module: M) -> ModuleHandler> { 8 | ModuleHandler::new( 9 | EventHandler::new(create_test_keybindings()), 10 | TestModuleProvider::from(module), 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/test_helpers/mocks.rs: -------------------------------------------------------------------------------- 1 | mod crossterm; 2 | mod notifier; 3 | mod searchable; 4 | 5 | pub(crate) use self::{ 6 | crossterm::{CrossTerm, CrosstermMockState, MockableTui}, 7 | notifier::Notifier, 8 | searchable::Searchable, 9 | }; 10 | -------------------------------------------------------------------------------- /src/test_helpers/mocks/notifier.rs: -------------------------------------------------------------------------------- 1 | use crate::runtime::{Status, ThreadStatuses}; 2 | 3 | /// A mocked version of the `Notifier`, that will interact directly with a `ThreadStatuses` without the use of a thread 4 | /// or the `Runtime`. 5 | #[derive(Debug)] 6 | pub(crate) struct Notifier<'notifier> { 7 | threadable_statuses: &'notifier ThreadStatuses, 8 | } 9 | 10 | impl<'notifier> Notifier<'notifier> { 11 | /// Create a new instance of a `MockNotifier`. 12 | #[must_use] 13 | pub(crate) const fn new(threadable_statuses: &'notifier ThreadStatuses) -> Self { 14 | Self { threadable_statuses } 15 | } 16 | 17 | /// Register a thread by name and status. This does not create a thread. 18 | pub(crate) fn register_thread(&mut self, thread_name: &str, status: Status) { 19 | self.threadable_statuses.register_thread(thread_name, status); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test_helpers/mocks/searchable.rs: -------------------------------------------------------------------------------- 1 | use crate::search::{Interrupter, SearchResult}; 2 | 3 | pub(crate) struct Searchable; 4 | 5 | impl Searchable { 6 | pub(crate) const fn new() -> Self { 7 | Self {} 8 | } 9 | } 10 | 11 | impl crate::search::Searchable for Searchable { 12 | fn reset(&mut self) {} 13 | 14 | fn search(&mut self, _: Interrupter, _: &str) -> SearchResult { 15 | SearchResult::None 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test_helpers/shared.rs: -------------------------------------------------------------------------------- 1 | mod git2; 2 | mod module; 3 | mod replace_invisibles; 4 | mod with_temporary_path; 5 | 6 | pub(crate) use self::{ 7 | git2::create_repository, 8 | module::TestModuleProvider, 9 | replace_invisibles::replace_invisibles, 10 | with_temporary_path::with_temporary_path, 11 | }; 12 | -------------------------------------------------------------------------------- /src/test_helpers/shared/git2.rs: -------------------------------------------------------------------------------- 1 | use git2::{Repository, Signature, Time}; 2 | 3 | use crate::test_helpers::JAN_2021_EPOCH; 4 | 5 | pub(crate) fn create_repository(repo: &Repository) { 6 | let id = repo.index().unwrap().write_tree().unwrap(); 7 | let tree = repo.find_tree(id).unwrap(); 8 | let sig = Signature::new("name", "name@example.com", &Time::new(JAN_2021_EPOCH, 0)).unwrap(); 9 | _ = repo 10 | .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[]) 11 | .unwrap(); 12 | } 13 | -------------------------------------------------------------------------------- /src/test_helpers/shared/module.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | application::AppData, 3 | module::{Module, ModuleProvider, State}, 4 | }; 5 | 6 | pub(crate) struct TestModuleProvider { 7 | module: M, 8 | } 9 | 10 | impl From for TestModuleProvider { 11 | fn from(module: M) -> Self { 12 | Self { module } 13 | } 14 | } 15 | 16 | impl ModuleProvider for TestModuleProvider { 17 | fn new(_: &AppData) -> Self { 18 | unimplemented!("Not implemented for the TestModuleProvider"); 19 | } 20 | 21 | fn get_mut_module(&mut self, _state: State) -> &mut dyn Module { 22 | &mut self.module 23 | } 24 | 25 | fn get_module(&self, _state: State) -> &dyn Module { 26 | &self.module 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test_helpers/shared/replace_invisibles.rs: -------------------------------------------------------------------------------- 1 | const VISIBLE_SPACE_REPLACEMENT: &str = "\u{b7}"; // "·" 2 | const VISIBLE_TAB_REPLACEMENT: &str = " \u{2192}"; // " →" 3 | 4 | /// Replace invisible characters with visible counterparts 5 | #[must_use] 6 | pub(crate) fn replace_invisibles(line: &str) -> String { 7 | line.replace(' ', VISIBLE_SPACE_REPLACEMENT) 8 | .replace('\t', VISIBLE_TAB_REPLACEMENT) 9 | } 10 | -------------------------------------------------------------------------------- /src/test_helpers/shared/with_temporary_path.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use tempfile::Builder; 4 | 5 | pub(crate) fn with_temporary_path(callback: F) 6 | where F: FnOnce(&Path) { 7 | let temp_repository_directory = Builder::new().prefix("interactive-rebase-tool").tempdir().unwrap(); 8 | let path = temp_repository_directory.path(); 9 | callback(path); 10 | temp_repository_directory.close().unwrap(); 11 | } 12 | -------------------------------------------------------------------------------- /src/test_helpers/testers.rs: -------------------------------------------------------------------------------- 1 | mod module; 2 | mod process; 3 | mod read_event; 4 | mod searchable; 5 | mod threadable; 6 | 7 | pub(crate) use self::{ 8 | module::{ModuleTestContext, module_test as module}, 9 | process::{ProcessTestContext, process}, 10 | read_event::read_event, 11 | searchable::SearchableRunner, 12 | threadable::Threadable, 13 | }; 14 | -------------------------------------------------------------------------------- /src/test_helpers/testers/process.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use parking_lot::{Mutex, lock_api::RwLock}; 4 | 5 | use crate::{ 6 | application::AppData, 7 | diff, 8 | diff::CommitDiff, 9 | display::Size, 10 | input::Event, 11 | module::{self, ModuleHandler, State}, 12 | process::Process, 13 | runtime::ThreadStatuses, 14 | test_helpers::{ 15 | ViewStateTestContext, 16 | create_config, 17 | with_event_handler, 18 | with_search, 19 | with_todo_file, 20 | with_view_state, 21 | }, 22 | }; 23 | 24 | pub(crate) struct ProcessTestContext { 25 | pub(crate) process: Process, 26 | pub(crate) view_context: ViewStateTestContext, 27 | pub(crate) app_data: AppData, 28 | } 29 | 30 | pub(crate) fn process( 31 | module_handler: ModuleHandler, 32 | callback: C, 33 | ) where 34 | C: FnOnce(ProcessTestContext), 35 | { 36 | with_event_handler(&[Event::from('a')], |event_handler_context| { 37 | with_view_state(|view_context| { 38 | with_todo_file(&[], |todo_file_context| { 39 | with_search(|search_context| { 40 | let commit_diff = CommitDiff::new(); 41 | let (_todo_file_tmp_path, todo_file) = todo_file_context.to_owned(); 42 | let view_state = view_context.state.clone(); 43 | let input_state = event_handler_context.state.clone(); 44 | let app_data = AppData::new( 45 | create_config(), 46 | State::WindowSizeError, 47 | Arc::new(Mutex::new(todo_file)), 48 | diff::thread::State::new(Arc::new(RwLock::new(commit_diff))), 49 | view_state, 50 | input_state, 51 | search_context.state.clone(), 52 | ); 53 | 54 | callback(ProcessTestContext { 55 | process: Process::new(&app_data, Size::new(300, 120), module_handler, ThreadStatuses::new()), 56 | view_context, 57 | app_data, 58 | }); 59 | }); 60 | }); 61 | }); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /src/test_helpers/testers/read_event.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::Config, input::Event, test_helpers::testers}; 2 | 3 | pub(crate) fn read_event(event: Event, config: Option, callback: C) 4 | where C: FnOnce(testers::ModuleTestContext) { 5 | testers::module(&[], &[event], config, callback); 6 | } 7 | -------------------------------------------------------------------------------- /src/test_helpers/testers/searchable.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crate::search::{Interrupter, SearchResult, Searchable}; 4 | 5 | const SEARCH_INTERRUPT_TIME: Duration = Duration::from_secs(1); 6 | 7 | pub(crate) struct SearchableRunner { 8 | searchable: S, 9 | } 10 | 11 | impl SearchableRunner { 12 | pub(crate) fn new(searchable: &S) -> Self { 13 | Self { 14 | searchable: searchable.clone(), 15 | } 16 | } 17 | 18 | pub(crate) fn run_search(&mut self, search_term: &str) -> SearchResult { 19 | self.searchable 20 | .search(Interrupter::new(SEARCH_INTERRUPT_TIME), search_term) 21 | } 22 | 23 | pub(crate) fn run_search_with_time(&mut self, search_term: &str, millis: u64) -> SearchResult { 24 | self.searchable 25 | .search(Interrupter::new(Duration::from_millis(millis)), search_term) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test_helpers/with_env_var.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::{remove_var, set_var, var}, 3 | sync::LazyLock, 4 | }; 5 | 6 | use parking_lot::Mutex; 7 | 8 | static ENV_CHANGE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); 9 | 10 | #[derive(Debug, Clone)] 11 | pub(crate) enum EnvVarAction<'var> { 12 | Remove(&'var str), 13 | Set(&'var str, String), 14 | } 15 | 16 | // This wraps the unsafe modification on environment variables in a lock, that ensures that only one test thread is 17 | // trying to modify environment variables at a time. This does not provide any guarantee that a value was not changed 18 | // outside the scope of tests using this function. So in that regard, this could be considered unsafe, however within 19 | // the confines of the tests for this project, this is safe enough. 20 | // 21 | // The wrapper will attempt to restore all values back to their previous value, cleaning up any changes made. 22 | #[expect(unsafe_code, reason = "See comment.")] // unused unsafe until Rust 2024 23 | pub(crate) fn with_env_var(actions: &[EnvVarAction<'_>], callback: C) 24 | where C: FnOnce() { 25 | let lock = ENV_CHANGE_LOCK.lock(); 26 | let mut undo_actions = vec![]; 27 | 28 | for action in actions { 29 | let name = match action { 30 | EnvVarAction::Set(name, _) | EnvVarAction::Remove(name) => *name, 31 | }; 32 | if let Ok(v) = var(name) { 33 | undo_actions.push(EnvVarAction::Set(name, v)); 34 | } 35 | else { 36 | undo_actions.push(EnvVarAction::Remove(name)); 37 | } 38 | match action { 39 | EnvVarAction::Remove(name) => unsafe { 40 | remove_var(*name); 41 | }, 42 | EnvVarAction::Set(name, value) => unsafe { 43 | set_var(*name, value.as_str()); 44 | }, 45 | } 46 | } 47 | callback(); 48 | for action in undo_actions { 49 | match action { 50 | EnvVarAction::Remove(name) => unsafe { 51 | remove_var(name); 52 | }, 53 | EnvVarAction::Set(name, value) => unsafe { 54 | set_var(name, value); 55 | }, 56 | } 57 | } 58 | drop(lock); 59 | } 60 | -------------------------------------------------------------------------------- /src/test_helpers/with_event_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | input::{Event, EventHandler, State}, 3 | test_helpers::create_test_keybindings, 4 | }; 5 | 6 | /// Context for a `EventHandler` based test. 7 | #[derive(Debug)] 8 | #[non_exhaustive] 9 | pub(crate) struct EventHandlerTestContext { 10 | /// The `EventHandler` instance. 11 | pub(crate) event_handler: EventHandler, 12 | /// The sender instance. 13 | pub(crate) state: State, 14 | /// The number of known available events. 15 | pub(crate) number_events: usize, 16 | } 17 | 18 | /// Provide an `EventHandler` instance for use within a test. 19 | pub(crate) fn with_event_handler(events: &[Event], callback: C) 20 | where C: FnOnce(EventHandlerTestContext) { 21 | let event_handler = EventHandler::new(create_test_keybindings()); 22 | let state = State::new(); 23 | 24 | for event in events { 25 | state.enqueue_event(*event); 26 | } 27 | 28 | callback(EventHandlerTestContext { 29 | event_handler, 30 | state, 31 | number_events: events.len(), 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/test_helpers/with_git_config.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write as _; 2 | 3 | use tempfile::NamedTempFile; 4 | 5 | use crate::git::Config; 6 | 7 | pub(crate) fn with_git_config(lines: &[&str], callback: F) 8 | where F: FnOnce(Config) { 9 | let tmp_file = NamedTempFile::new().unwrap(); 10 | writeln!(tmp_file.as_file(), "{}", lines.join("\n")).unwrap(); 11 | callback(Config::open(tmp_file.path()).unwrap()); 12 | } 13 | -------------------------------------------------------------------------------- /src/test_helpers/with_git_directory.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use crate::test_helpers::{EnvVarAction, with_env_var}; 4 | 5 | pub(crate) fn with_git_directory(repo: &str, callback: C) 6 | where C: FnOnce(&str) { 7 | let path = Path::new(env!("CARGO_MANIFEST_DIR")) 8 | .join("test") 9 | .join(repo) 10 | .canonicalize() 11 | .unwrap_or(PathBuf::from("does-not-exist")); 12 | with_env_var( 13 | &[EnvVarAction::Set("GIT_DIR", String::from(path.to_str().unwrap()))], 14 | || callback(path.to_str().unwrap()), 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/test_helpers/with_search.rs: -------------------------------------------------------------------------------- 1 | use crate::search::State; 2 | 3 | pub(crate) struct SearchTestContext { 4 | pub(crate) state: State, 5 | } 6 | 7 | pub(crate) fn with_search(callback: C) 8 | where C: FnOnce(SearchTestContext) { 9 | callback(SearchTestContext { state: State::new() }); 10 | } 11 | -------------------------------------------------------------------------------- /src/test_helpers/with_temp_bare_repository.rs: -------------------------------------------------------------------------------- 1 | use git2::Repository; 2 | 3 | use crate::test_helpers::shared::{create_repository, with_temporary_path}; 4 | 5 | /// Provide a bare repository for testing in a temporary directory. 6 | /// 7 | /// # Panics 8 | /// 9 | /// If the repository cannot be created for any reason, this function will panic. 10 | pub(crate) fn with_temp_bare_repository(callback: F) 11 | where F: FnOnce(Repository) { 12 | with_temporary_path(|path| { 13 | let repo = Repository::init_bare(path).unwrap(); 14 | create_repository(&repo); 15 | callback(repo); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/test_helpers/with_temp_repository.rs: -------------------------------------------------------------------------------- 1 | use git2::Repository; 2 | 3 | use crate::test_helpers::shared::{create_repository, with_temporary_path}; 4 | 5 | /// Provides a new repository instance in a temporary directory for testing that contains an initial 6 | /// empty commit. 7 | /// 8 | /// # Panics 9 | /// 10 | /// If the repository cannot be created for any reason, this function will panic. 11 | pub(crate) fn with_temp_repository(callback: F) 12 | where F: FnOnce(Repository) { 13 | with_temporary_path(|path| { 14 | let mut opts = git2::RepositoryInitOptions::new(); 15 | _ = opts.initial_head("main"); 16 | let repo = Repository::init_opts(path, &opts).unwrap(); 17 | create_repository(&repo); 18 | callback(repo); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use super::*; 4 | use crate::{module::ExitStatus, test_helpers::with_git_directory}; 5 | 6 | fn args(args: &[&str]) -> Vec { 7 | args.iter().map(OsString::from).collect::>() 8 | } 9 | 10 | #[test] 11 | #[serial_test::serial] 12 | fn successful_run_help() { 13 | let exit = run(args(&["--help"])); 14 | assert!(exit.get_message().unwrap().contains("USAGE:")); 15 | assert_eq!(exit.get_status(), &ExitStatus::Good); 16 | } 17 | 18 | #[test] 19 | #[serial_test::serial] 20 | fn successful_run_version() { 21 | let exit = run(args(&["--version"])); 22 | assert!(exit.get_message().unwrap().starts_with("interactive-rebase-tool")); 23 | assert_eq!(exit.get_status(), &ExitStatus::Good); 24 | } 25 | 26 | #[test] 27 | #[serial_test::serial] 28 | fn successful_run_license() { 29 | let exit = run(args(&["--license"])); 30 | assert!( 31 | exit.get_message() 32 | .unwrap() 33 | .contains("Sequence Editor for Git Interactive Rebase") 34 | ); 35 | assert_eq!(exit.get_status(), &ExitStatus::Good); 36 | } 37 | 38 | #[test] 39 | fn successful_run_editor() { 40 | with_git_directory("fixtures/simple", |path| { 41 | let todo_file = Path::new(path).join("rebase-todo-empty"); 42 | assert_eq!( 43 | run(args(&[todo_file.to_str().unwrap()])).get_status(), 44 | &ExitStatus::Good 45 | ); 46 | }); 47 | } 48 | 49 | #[cfg(unix)] 50 | #[test] 51 | #[serial_test::serial] 52 | #[expect(unsafe_code)] 53 | fn error() { 54 | let args = unsafe { vec![OsString::from(String::from_utf8_unchecked(vec![0xC3, 0x28]))] }; 55 | assert_eq!(run(args).get_status(), &ExitStatus::StateError); 56 | } 57 | -------------------------------------------------------------------------------- /src/todo_file/edit_content.rs: -------------------------------------------------------------------------------- 1 | use crate::todo_file::Action; 2 | 3 | /// Describes a edit context for modifying a line. 4 | #[derive(Debug)] 5 | pub(crate) struct EditContext { 6 | action: Option, 7 | content: Option, 8 | option: Option, 9 | } 10 | 11 | impl EditContext { 12 | /// Create a new empty instance. 13 | #[must_use] 14 | pub(crate) const fn new() -> Self { 15 | Self { 16 | action: None, 17 | content: None, 18 | option: None, 19 | } 20 | } 21 | 22 | /// Set the action. 23 | #[must_use] 24 | pub(crate) const fn action(mut self, action: Action) -> Self { 25 | self.action = Some(action); 26 | self 27 | } 28 | 29 | /// Set the content. 30 | #[must_use] 31 | pub(crate) fn content(mut self, content: &str) -> Self { 32 | self.content = Some(String::from(content)); 33 | self 34 | } 35 | 36 | /// Set the option. 37 | #[must_use] 38 | pub(crate) fn option(mut self, option: &str) -> Self { 39 | self.option = Some(String::from(option)); 40 | self 41 | } 42 | 43 | /// Get the action. 44 | #[must_use] 45 | pub(crate) const fn get_action(&self) -> Option { 46 | self.action 47 | } 48 | 49 | /// Get the content. 50 | #[must_use] 51 | pub(crate) fn get_content(&self) -> Option<&str> { 52 | self.content.as_deref() 53 | } 54 | 55 | /// Get the option. 56 | #[must_use] 57 | pub(crate) fn get_option(&self) -> Option<&str> { 58 | self.option.as_deref() 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests { 64 | use claims::{assert_none, assert_some_eq}; 65 | 66 | use super::*; 67 | 68 | #[test] 69 | fn empty() { 70 | let edit_context = EditContext::new(); 71 | assert_none!(edit_context.get_action()); 72 | assert_none!(edit_context.get_content()); 73 | assert_none!(edit_context.get_option()); 74 | } 75 | 76 | #[test] 77 | fn with_action() { 78 | let edit_context = EditContext::new().action(Action::Break); 79 | assert_some_eq!(edit_context.get_action(), Action::Break); 80 | assert_none!(edit_context.get_content()); 81 | assert_none!(edit_context.get_option()); 82 | } 83 | 84 | #[test] 85 | fn with_content() { 86 | let edit_context = EditContext::new().content("test content"); 87 | assert_none!(edit_context.get_action()); 88 | assert_some_eq!(edit_context.get_content(), "test content"); 89 | assert_none!(edit_context.get_option()); 90 | } 91 | 92 | #[test] 93 | fn with_option() { 94 | let edit_context = EditContext::new().option("-C"); 95 | assert_none!(edit_context.get_action()); 96 | assert_none!(edit_context.get_content()); 97 | assert_some_eq!(edit_context.get_option(), "-C"); 98 | } 99 | 100 | #[test] 101 | fn with_all() { 102 | let edit_context = EditContext::new() 103 | .action(Action::Edit) 104 | .content("test content") 105 | .option("-C"); 106 | assert_some_eq!(edit_context.get_action(), Action::Edit); 107 | assert_some_eq!(edit_context.get_content(), "test content"); 108 | assert_some_eq!(edit_context.get_option(), "-C"); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/todo_file/errors.rs: -------------------------------------------------------------------------------- 1 | //! Git Interactive Rebase Tool - Todo File Module Errors 2 | //! 3 | //! # Description 4 | //! This module contains error types used in the Todo File Module. 5 | 6 | mod io; 7 | mod parse; 8 | 9 | pub(crate) use self::{ 10 | io::{FileReadErrorCause, IoError}, 11 | parse::ParseError, 12 | }; 13 | -------------------------------------------------------------------------------- /src/todo_file/errors/io.rs: -------------------------------------------------------------------------------- 1 | use std::{io, path::PathBuf}; 2 | 3 | use thiserror::Error; 4 | 5 | use crate::todo_file::ParseError; 6 | 7 | /// The cause of a `FileRead` error 8 | #[derive(Error, Debug)] 9 | #[non_exhaustive] 10 | pub(crate) enum FileReadErrorCause { 11 | /// Caused by an io error 12 | #[error(transparent)] 13 | IoError(#[from] io::Error), 14 | /// Caused by a parse error 15 | #[error(transparent)] 16 | ParseError(#[from] ParseError), 17 | } 18 | 19 | impl PartialEq for FileReadErrorCause { 20 | #[expect(clippy::pattern_type_mismatch, reason = "Legacy, needs update")] 21 | fn eq(&self, other: &Self) -> bool { 22 | match (self, other) { 23 | (Self::IoError(self_err), Self::IoError(other_err)) => self_err.kind() == other_err.kind(), 24 | (Self::ParseError(self_err), Self::ParseError(other_err)) => self_err == other_err, 25 | _ => false, 26 | } 27 | } 28 | } 29 | 30 | /// IO baser errors 31 | #[derive(Error, Debug, PartialEq)] 32 | #[non_exhaustive] 33 | pub(crate) enum IoError { 34 | /// The file could not be read 35 | #[error("Unable to read file `{file}`")] 36 | FileRead { 37 | /// The file path that failed to read 38 | file: PathBuf, 39 | /// The reason for the read error 40 | cause: FileReadErrorCause, 41 | }, 42 | } 43 | 44 | #[cfg(test)] 45 | mod test { 46 | use super::*; 47 | 48 | #[test] 49 | fn partial_eq_file_read_error_cause_different_cause() { 50 | assert_ne!( 51 | FileReadErrorCause::IoError(io::Error::from(io::ErrorKind::Other)), 52 | FileReadErrorCause::ParseError(ParseError::InvalidAction(String::from("action"))) 53 | ); 54 | } 55 | 56 | #[test] 57 | fn partial_eq_file_read_error_cause_io_error_same_kind() { 58 | assert_eq!( 59 | FileReadErrorCause::IoError(io::Error::from(io::ErrorKind::Other)), 60 | FileReadErrorCause::IoError(io::Error::from(io::ErrorKind::Other)) 61 | ); 62 | } 63 | 64 | #[test] 65 | fn partial_eq_file_read_error_cause_io_error_different_kind() { 66 | assert_ne!( 67 | FileReadErrorCause::IoError(io::Error::from(io::ErrorKind::Other)), 68 | FileReadErrorCause::IoError(io::Error::from(io::ErrorKind::NotFound)) 69 | ); 70 | } 71 | 72 | #[test] 73 | fn partial_eq_file_read_error_cause_different_parse_error() { 74 | assert_ne!( 75 | FileReadErrorCause::ParseError(ParseError::InvalidAction(String::from("action"))), 76 | FileReadErrorCause::ParseError(ParseError::InvalidLine(String::from("line"))), 77 | ); 78 | } 79 | 80 | #[test] 81 | fn partial_eq_file_read_error_cause_same_parse_error() { 82 | assert_eq!( 83 | FileReadErrorCause::ParseError(ParseError::InvalidAction(String::from("action"))), 84 | FileReadErrorCause::ParseError(ParseError::InvalidAction(String::from("action"))), 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/todo_file/errors/parse.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | /// Parsing errors 4 | #[derive(Error, Debug, PartialEq, Eq)] 5 | #[non_exhaustive] 6 | pub(crate) enum ParseError { 7 | /// The provided action string is not one of the allowed values 8 | #[error("The action `{0}` is not valid")] 9 | InvalidAction(String), 10 | /// The provided line is not valid 11 | #[error("The line `{0}` is not valid")] 12 | InvalidLine(String), 13 | } 14 | -------------------------------------------------------------------------------- /src/todo_file/history/history_item.rs: -------------------------------------------------------------------------------- 1 | use crate::todo_file::{Line, Operation}; 2 | 3 | #[derive(Debug, PartialEq, Eq)] 4 | pub(crate) struct HistoryItem { 5 | pub(crate) start_index: usize, 6 | pub(crate) end_index: usize, 7 | pub(crate) operation: Operation, 8 | pub(crate) lines: Vec, 9 | } 10 | 11 | impl HistoryItem { 12 | pub(crate) const fn new_load() -> Self { 13 | Self { 14 | operation: Operation::Load, 15 | start_index: 0, 16 | end_index: 0, 17 | lines: vec![], 18 | } 19 | } 20 | 21 | pub(crate) fn new_modify(start_index: usize, end_index: usize, lines: Vec) -> Self { 22 | Self { 23 | operation: Operation::Modify, 24 | start_index, 25 | end_index, 26 | lines, 27 | } 28 | } 29 | 30 | pub(crate) const fn new_add(start_index: usize, end_index: usize) -> Self { 31 | Self { 32 | operation: Operation::Add, 33 | start_index, 34 | end_index, 35 | lines: vec![], 36 | } 37 | } 38 | 39 | pub(crate) fn new_remove(start_index: usize, end_index: usize, lines: Vec) -> Self { 40 | Self { 41 | operation: Operation::Remove, 42 | start_index, 43 | end_index, 44 | lines, 45 | } 46 | } 47 | 48 | pub(crate) const fn new_swap_up(start_index: usize, end_index: usize) -> Self { 49 | Self { 50 | operation: Operation::SwapUp, 51 | start_index, 52 | end_index, 53 | lines: vec![], 54 | } 55 | } 56 | 57 | pub(crate) const fn new_swap_down(start_index: usize, end_index: usize) -> Self { 58 | Self { 59 | operation: Operation::SwapDown, 60 | start_index, 61 | end_index, 62 | lines: vec![], 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/todo_file/history/operation.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Eq)] 2 | pub(crate) enum Operation { 3 | Load, 4 | Modify, 5 | SwapUp, 6 | SwapDown, 7 | Add, 8 | Remove, 9 | } 10 | -------------------------------------------------------------------------------- /src/todo_file/todo_file_options.rs: -------------------------------------------------------------------------------- 1 | /// Options for `TodoFile` 2 | #[derive(Debug, Clone, PartialEq, Eq)] 3 | pub(crate) struct TodoFileOptions { 4 | pub(crate) comment_prefix: String, 5 | pub(crate) line_changed_command: Option, 6 | pub(crate) undo_limit: u32, 7 | } 8 | 9 | impl TodoFileOptions { 10 | /// Create a new instance of `TodoFileOptions` 11 | #[must_use] 12 | pub(crate) fn new(undo_limit: u32, comment_prefix: &str) -> Self { 13 | Self { 14 | comment_prefix: String::from(comment_prefix), 15 | line_changed_command: None, 16 | undo_limit, 17 | } 18 | } 19 | 20 | /// Set a command to be added after each changed line 21 | pub(crate) fn line_changed_command(&mut self, command: &str) { 22 | self.line_changed_command = Some(String::from(command)); 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use claims::{assert_none, assert_some_eq}; 29 | 30 | use super::*; 31 | 32 | #[test] 33 | fn new() { 34 | let options = TodoFileOptions::new(10, "#"); 35 | 36 | assert_eq!(options.undo_limit, 10); 37 | assert_eq!(options.comment_prefix, "#"); 38 | assert_none!(options.line_changed_command); 39 | } 40 | 41 | #[test] 42 | fn line_changed_command() { 43 | let mut options = TodoFileOptions::new(10, "#"); 44 | 45 | options.line_changed_command("command"); 46 | 47 | assert_some_eq!(options.line_changed_command, "command"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/todo_file/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::todo_file::Line; 2 | 3 | pub(crate) fn swap_range_up(lines: &mut [Line], start_index: usize, end_index: usize) { 4 | let range = if end_index <= start_index { 5 | (end_index - 1)..start_index 6 | } 7 | else { 8 | (start_index - 1)..end_index 9 | }; 10 | for index in range { 11 | lines.swap(index, index + 1); 12 | } 13 | } 14 | 15 | pub(crate) fn swap_range_down(lines: &mut [Line], start_index: usize, end_index: usize) { 16 | let range = if end_index <= start_index { 17 | end_index..=start_index 18 | } 19 | else { 20 | start_index..=end_index 21 | }; 22 | 23 | for index in range.rev() { 24 | lines.swap(index, index + 1); 25 | } 26 | } 27 | 28 | pub(crate) fn remove_range(lines: &mut Vec, start_index: usize, end_index: usize) -> Vec { 29 | let mut removed_lines = vec![]; 30 | if end_index <= start_index { 31 | for _ in end_index..=start_index { 32 | removed_lines.push(lines.remove(end_index)); 33 | } 34 | } 35 | else { 36 | for _ in start_index..=end_index { 37 | removed_lines.push(lines.remove(start_index)); 38 | } 39 | } 40 | 41 | removed_lines 42 | } 43 | 44 | pub(crate) fn add_range(lines: &mut Vec, new_lines: &[Line], start_index: usize, end_index: usize) { 45 | let range = if end_index <= start_index { 46 | end_index..=start_index 47 | } 48 | else { 49 | start_index..=end_index 50 | }; 51 | 52 | for (add_index, index) in range.enumerate() { 53 | lines.insert(index, new_lines[add_index].clone()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | input::{Event, StandardEvent}, 3 | process::Results, 4 | view, 5 | }; 6 | 7 | #[macro_export] 8 | macro_rules! select { 9 | (default $default: expr, $first: expr) => { 10 | if let Some(value) = $first { 11 | value 12 | } 13 | else { 14 | $default 15 | } 16 | }; 17 | (default $default: expr, $first: expr, $($arg:expr),*) => { 18 | if let Some(value) = $first { 19 | value 20 | } 21 | $(else if let Some(value) = $arg { 22 | value 23 | })* 24 | else { 25 | $default 26 | } 27 | }; 28 | } 29 | 30 | /// Utility function to handle scroll events. 31 | #[must_use] 32 | pub(crate) fn handle_view_data_scroll(event: Event, view_state: &view::State) -> Option { 33 | match event { 34 | Event::Standard(StandardEvent::ScrollLeft) => view_state.scroll_left(), 35 | Event::Standard(StandardEvent::ScrollRight) => view_state.scroll_right(), 36 | Event::Standard(StandardEvent::ScrollDown) => view_state.scroll_down(), 37 | Event::Standard(StandardEvent::ScrollUp) => view_state.scroll_up(), 38 | Event::Standard(StandardEvent::ScrollTop) => view_state.scroll_top(), 39 | Event::Standard(StandardEvent::ScrollBottom) => view_state.scroll_bottom(), 40 | Event::Standard(StandardEvent::ScrollJumpDown) => view_state.scroll_page_down(), 41 | Event::Standard(StandardEvent::ScrollJumpUp) => view_state.scroll_page_up(), 42 | _ => return None, 43 | } 44 | Some(Results::new()) 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use captur::capture; 50 | use claims::{assert_none, assert_some}; 51 | use rstest::rstest; 52 | 53 | use super::*; 54 | use crate::test_helpers::with_view_state; 55 | 56 | #[rstest] 57 | #[case::scroll_left(StandardEvent::ScrollLeft, "ScrollLeft")] 58 | #[case::scroll_right(StandardEvent::ScrollRight, "ScrollRight")] 59 | #[case::scroll_down(StandardEvent::ScrollDown, "ScrollDown")] 60 | #[case::scroll_up(StandardEvent::ScrollUp, "ScrollUp")] 61 | #[case::scroll_top(StandardEvent::ScrollTop, "ScrollTop")] 62 | #[case::scroll_bottom(StandardEvent::ScrollBottom, "ScrollBottom")] 63 | #[case::jump_down(StandardEvent::ScrollJumpDown, "PageDown")] 64 | #[case::jump_up(StandardEvent::ScrollJumpUp, "PageUp")] 65 | fn handle_view_data_scroll_event(#[case] meta_event: StandardEvent, #[case] action: &str) { 66 | with_view_state(|context| { 67 | capture!(action); 68 | let event = Event::from(meta_event); 69 | assert_some!(handle_view_data_scroll(event, &context.state)); 70 | context.assert_render_action(&[action]); 71 | }); 72 | } 73 | 74 | #[test] 75 | fn handle_view_data_scroll_event_other() { 76 | with_view_state(|context| { 77 | let event = Event::from('a'); 78 | assert_none!(handle_view_data_scroll(event, &context.state)); 79 | context.assert_render_action(&[]); 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | use crate::exit::Exit; 2 | 3 | #[cfg(not(feature = "dev"))] 4 | pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); 5 | #[cfg(feature = "dev")] 6 | pub(crate) const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-dev"); 7 | 8 | fn build_version() -> String { 9 | let mut parts = vec![]; 10 | 11 | if let Some(hash) = option_env!("GIRT_BUILD_GIT_HASH") { 12 | parts.push(String::from(hash)); 13 | } 14 | 15 | parts.push(String::from(env!("GIRT_BUILD_DATE"))); 16 | 17 | format!("interactive-rebase-tool {VERSION} ({})", parts.join(" ")) 18 | } 19 | 20 | pub(crate) fn run() -> Exit { 21 | Exit::from(build_version()) 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use super::*; 27 | 28 | #[test] 29 | #[serial_test::serial] 30 | fn run_success() { 31 | assert!(run().get_message().unwrap().starts_with("interactive-rebase-tool")); 32 | } 33 | 34 | #[test] 35 | #[serial_test::serial] 36 | fn build_version_default() { 37 | let version = build_version(); 38 | assert!(version.starts_with("interactive-rebase-tool")); 39 | } 40 | 41 | #[test] 42 | #[serial_test::serial] 43 | fn build_version_with_env() { 44 | let maybe_git_hash = option_env!("GIRT_BUILD_GIT_HASH"); 45 | assert_eq!( 46 | std::process::Command::new("git") 47 | .args(["rev-parse", "--is-inside-work-tree"]) 48 | .output() 49 | .map(|out| out.status.success()) 50 | .unwrap_or(false), 51 | maybe_git_hash.is_some() 52 | ); 53 | 54 | let version = build_version(); 55 | let expected_meta = if let Some(git_hash) = maybe_git_hash { 56 | format!("({} {})", git_hash, env!("GIRT_BUILD_DATE")) 57 | } 58 | else { 59 | format!("({})", env!("GIRT_BUILD_DATE")) 60 | }; 61 | assert!(version.starts_with("interactive-rebase-tool")); 62 | assert!(version.ends_with(expected_meta.as_str())); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/view/render_slice/render_action.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub(crate) enum RenderAction { 3 | ScrollDown, 4 | ScrollUp, 5 | ScrollRight, 6 | ScrollLeft, 7 | ScrollTop, 8 | ScrollBottom, 9 | PageUp, 10 | PageDown, 11 | Resize(usize, usize), 12 | } 13 | -------------------------------------------------------------------------------- /src/view/thread/action.rs: -------------------------------------------------------------------------------- 1 | /// An action to send to the thread handling updates to the view. 2 | #[derive(Debug, Copy, Clone)] 3 | pub(crate) enum ViewAction { 4 | /// Stop processing actions. 5 | Stop, 6 | /// Force a refresh of the view. 7 | Refresh, 8 | /// Render the latest `ViewData`. 9 | Render, 10 | /// Start processing actions. 11 | Start, 12 | /// End the thread and the processing of actions. 13 | End, 14 | } 15 | -------------------------------------------------------------------------------- /src/view/view_lines.rs: -------------------------------------------------------------------------------- 1 | use std::{slice::Iter, vec}; 2 | 3 | use crate::view::ViewLine; 4 | 5 | /// Represents a line in the view. 6 | #[derive(Debug)] 7 | pub(crate) struct ViewLines { 8 | lines: Vec, 9 | } 10 | 11 | impl ViewLines { 12 | pub(crate) fn new() -> Self { 13 | Self { lines: vec![] } 14 | } 15 | 16 | #[expect( 17 | clippy::cast_possible_truncation, 18 | reason = "Number of lines will be below maximum of 16-bit." 19 | )] 20 | pub(crate) fn count(&self) -> u16 { 21 | self.lines.len() as u16 22 | } 23 | 24 | pub(crate) fn iter(&self) -> Iter<'_, ViewLine> { 25 | self.lines.iter() 26 | } 27 | 28 | pub(crate) fn clear(&mut self) { 29 | self.lines.clear(); 30 | } 31 | 32 | pub(crate) fn push(&mut self, view_line: ViewLine) { 33 | self.lines.push(view_line); 34 | } 35 | } 36 | 37 | impl From<[ViewLine; N]> for ViewLines { 38 | fn from(values: [ViewLine; N]) -> Self { 39 | Self { 40 | lines: Vec::from(values), 41 | } 42 | } 43 | } 44 | 45 | impl IntoIterator for ViewLines { 46 | type IntoIter = vec::IntoIter; 47 | type Item = ViewLine; 48 | 49 | fn into_iter(self) -> Self::IntoIter { 50 | self.lines.into_iter() 51 | } 52 | } 53 | 54 | impl FromIterator for ViewLines { 55 | fn from_iter>(iter: T) -> Self { 56 | Self { 57 | lines: Vec::from_iter(iter), 58 | } 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests { 64 | use super::*; 65 | 66 | #[test] 67 | fn new() { 68 | assert!(ViewLines::new().lines.is_empty()); 69 | } 70 | 71 | #[test] 72 | fn push() { 73 | let mut view_lines = ViewLines::new(); 74 | view_lines.push(ViewLine::new_empty_line()); 75 | assert_eq!(view_lines.count(), 1); 76 | } 77 | 78 | #[test] 79 | fn clear() { 80 | let mut view_lines = ViewLines::new(); 81 | view_lines.push(ViewLine::new_empty_line()); 82 | view_lines.clear(); 83 | assert!(view_lines.lines.is_empty()); 84 | } 85 | 86 | #[test] 87 | fn iter() { 88 | let mut view_lines = ViewLines::new(); 89 | view_lines.push(ViewLine::new_empty_line()); 90 | view_lines.push(ViewLine::new_empty_line()); 91 | assert_eq!(view_lines.iter().len(), 2); 92 | } 93 | 94 | #[test] 95 | fn into_iter() { 96 | let mut view_lines = ViewLines::new(); 97 | view_lines.push(ViewLine::new_empty_line()); 98 | view_lines.push(ViewLine::new_empty_line()); 99 | assert_eq!(view_lines.into_iter().len(), 2); 100 | } 101 | 102 | #[test] 103 | fn from_slice() { 104 | let view_lines = ViewLines::from([ViewLine::new_empty_line()]); 105 | assert_eq!(view_lines.iter().len(), 1); 106 | } 107 | 108 | #[test] 109 | fn from_iter() { 110 | let view_lines = ViewLines::from_iter([ViewLine::new_empty_line()]); 111 | assert_eq!(view_lines.iter().len(), 1); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /test/fixtures/invalid-config/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/master 2 | -------------------------------------------------------------------------------- /test/fixtures/invalid-config/config: -------------------------------------------------------------------------------- 1 | [interactive-rebase-tool] 2 | autoSelectNext = invalid 3 | -------------------------------------------------------------------------------- /test/fixtures/invalid-config/objects/ae/d0fd1db3e73c0e568677ae8903a11c5fbc5659: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/invalid-config/objects/ae/d0fd1db3e73c0e568677ae8903a11c5fbc5659 -------------------------------------------------------------------------------- /test/fixtures/invalid-config/refs/heads/master: -------------------------------------------------------------------------------- 1 | aed0fd1db3e73c0e568677ae8903a11c5fbc5659 2 | -------------------------------------------------------------------------------- /test/fixtures/not-a-repository/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/not-a-repository/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/simple/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/master 2 | -------------------------------------------------------------------------------- /test/fixtures/simple/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = true 5 | -------------------------------------------------------------------------------- /test/fixtures/simple/objects/01/82075d5b79ff61177e6314a8e5bff640f99caa: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/01/82075d5b79ff61177e6314a8e5bff640f99caa -------------------------------------------------------------------------------- /test/fixtures/simple/objects/18/a2bd71d9c48b793fe60a390cdf08f48e795abb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/18/a2bd71d9c48b793fe60a390cdf08f48e795abb -------------------------------------------------------------------------------- /test/fixtures/simple/objects/18/d82dcc4c36cade807d7cf79700b6bbad8080b9: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/18/d82dcc4c36cade807d7cf79700b6bbad8080b9 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/1c/c0456637cb220155e957c641f483e60724c581: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/1c/c0456637cb220155e957c641f483e60724c581 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/22/3b7836fb19fdf64ba2d3cd6173c6a283141f78: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/22/3b7836fb19fdf64ba2d3cd6173c6a283141f78 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/27/4006cec98796695eb5fbc66336c09d06b7cc35: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/27/4006cec98796695eb5fbc66336c09d06b7cc35 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/28/36dcdcbd040f9157652dd3db0d584a44d4793d: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/28/36dcdcbd040f9157652dd3db0d584a44d4793d -------------------------------------------------------------------------------- /test/fixtures/simple/objects/2b/33ed150ddc749651eead8f8ef45ae18760a64a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/2b/33ed150ddc749651eead8f8ef45ae18760a64a -------------------------------------------------------------------------------- /test/fixtures/simple/objects/2e/b17981c49e604a4894b94ae3cd7ce4b3ca29a1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/2e/b17981c49e604a4894b94ae3cd7ce4b3ca29a1 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/31/ca0c0283104a7c6532a8fce1df1b83a8063159: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/31/ca0c0283104a7c6532a8fce1df1b83a8063159 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/35/6f526abb39f15fd9d3fea57cf3ff1d1a400a22: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/35/6f526abb39f15fd9d3fea57cf3ff1d1a400a22 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/3b/03afff0ca32dad434d3703dd5c6b8216eccb9d: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/3b/03afff0ca32dad434d3703dd5c6b8216eccb9d -------------------------------------------------------------------------------- /test/fixtures/simple/objects/3c/c58df83752123644fef39faab2393af643b1d2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/3c/c58df83752123644fef39faab2393af643b1d2 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/46/79e4849c8d0578dd0801f5f5c1d5bfc65feb26: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/46/79e4849c8d0578dd0801f5f5c1d5bfc65feb26 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904: -------------------------------------------------------------------------------- 1 | x+)JMU0` 2 | , -------------------------------------------------------------------------------- /test/fixtures/simple/objects/64/99b1dcdbf3020be36ef51f27cb12c53ab779a8: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/64/99b1dcdbf3020be36ef51f27cb12c53ab779a8 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/66/22be15a9bd68ae17baa125c6af09efd577053c: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/66/22be15a9bd68ae17baa125c6af09efd577053c -------------------------------------------------------------------------------- /test/fixtures/simple/objects/7f/5eac44012ea33e5bdec0df72125c1bc2b2691d: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/7f/5eac44012ea33e5bdec0df72125c1bc2b2691d -------------------------------------------------------------------------------- /test/fixtures/simple/objects/ac/950e31a96660e55d8034948b5d9b985c97692d: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/ac/950e31a96660e55d8034948b5d9b985c97692d -------------------------------------------------------------------------------- /test/fixtures/simple/objects/ae/d0fd1db3e73c0e568677ae8903a11c5fbc5659: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/ae/d0fd1db3e73c0e568677ae8903a11c5fbc5659 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/b4/f179909d96883b73eff159c293cf1b5320b8ae: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/b4/f179909d96883b73eff159c293cf1b5320b8ae -------------------------------------------------------------------------------- /test/fixtures/simple/objects/ba/e175bd8992c5c05b858fa2f9b63193ab92a1f0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/ba/e175bd8992c5c05b858fa2f9b63193ab92a1f0 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/c0/28f42bdb2a5a9f80adea23d95eb240b994a6c2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/c0/28f42bdb2a5a9f80adea23d95eb240b994a6c2 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/c1/43f6d98cbd8e6e959439c41da3bb8127e23385: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/c1/43f6d98cbd8e6e959439c41da3bb8127e23385 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/c1/ac7f2c32f9e00012f409572d223c9457ae497b: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/c1/ac7f2c32f9e00012f409572d223c9457ae497b -------------------------------------------------------------------------------- /test/fixtures/simple/objects/d8/5479638307e4db37e1f1f2c3c807f7ff36a0ff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/d8/5479638307e4db37e1f1f2c3c807f7ff36a0ff -------------------------------------------------------------------------------- /test/fixtures/simple/objects/d9/05d9da82c97264ab6f4920e20242e088850ce9: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/d9/05d9da82c97264ab6f4920e20242e088850ce9 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/e1/0b3f474644d8566947104c07acba4d6f4f4f9f: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/e1/0b3f474644d8566947104c07acba4d6f4f4f9f -------------------------------------------------------------------------------- /test/fixtures/simple/objects/f5/b6d3334d82cb2f7cf7ecea806a86f06020b163: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/f5/b6d3334d82cb2f7cf7ecea806a86f06020b163 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/f7/0f10e4db19068f79bc43844b49f3eece45c4e8: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/f7/0f10e4db19068f79bc43844b49f3eece45c4e8 -------------------------------------------------------------------------------- /test/fixtures/simple/objects/fe/d706611bd9077feb0268ce7ddcff2bbe5ed939: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitMaro/git-interactive-rebase-tool/b5887b137fa71833209417471aefe256ce08b32b/test/fixtures/simple/objects/fe/d706611bd9077feb0268ce7ddcff2bbe5ed939 -------------------------------------------------------------------------------- /test/fixtures/simple/rebase-todo: -------------------------------------------------------------------------------- 1 | pick abc comment 2 | -------------------------------------------------------------------------------- /test/fixtures/simple/rebase-todo-empty: -------------------------------------------------------------------------------- 1 | # This is an empty rebase file 2 | -------------------------------------------------------------------------------- /test/fixtures/simple/rebase-todo-noop: -------------------------------------------------------------------------------- 1 | noop 2 | -------------------------------------------------------------------------------- /test/fixtures/simple/refs/heads/master: -------------------------------------------------------------------------------- 1 | aed0fd1db3e73c0e568677ae8903a11c5fbc5659 2 | -------------------------------------------------------------------------------- /test/not-executable.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This file should not have it's executable bit set 4 | 5 | exit 1 6 | --------------------------------------------------------------------------------