From 795f787209eecf3f901fe87ae53ea4217cd3f000 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Tue, 17 Mar 2026 18:23:45 +0000 Subject: [PATCH 01/19] fix: Allow operations when only submodule pointers are dirty check_no_dirty_state now filters out gitlink deltas (mode 0o160000) instead of relying on DiffOptions::ignore_submodules, which only works for entries declared in .gitmodules. This matches git-rebase behaviour: a staged or unstaged submodule pointer update no longer blocks drop, move, and squash. Add a stage_gitlink test helper in tests/common.rs and one new test per operation (drop_commit_allowed_with_staged_submodule, move_commit_allowed_with_staged_submodule, squash_commits_allowed_with_staged_submodule, squash_try_combine_allowed_with_staged_submodule) to verify the relaxed guard, alongside the existing tests that confirm ordinary staged/unstaged file changes are still rejected. --- CHANGELOG.md | 9 +++ deny.toml | 21 +++++++ src/repo/git2_impl.rs | 33 ++++++++++- tests/common.rs | 29 ++++++++++ tests/drop_commit.rs | 21 +++++++ tests/move_commit.rs | 24 ++++++++ ...p__rename_clusters_with_original_file.snap | 7 +++ tests/squash_commit.rs | 55 +++++++++++++++++++ 8 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 deny.toml create mode 100644 tests/snapshots/static_fragmap__rename_clusters_with_original_file.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index 174030b..ce076ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + ## [Unreleased] +### Fixed + +- Added possibility to perform drop, move and squash operations when there are + unstaged/staged changes in a submodule. + + +## [0.1.0] - 2026-03-15 + ### Added - Interactive TUI commit browser showing all commits between HEAD and the diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..8f3ca84 --- /dev/null +++ b/deny.toml @@ -0,0 +1,21 @@ +[advisories] +# Use git CLI for fetching the advisory DB (respects proxy / auth config). +git-fetch-with-cli = true + +[licenses] +allow = [ + "Apache-2.0", + "MIT", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.95 + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "warn" +unknown-git = "warn" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/src/repo/git2_impl.rs b/src/repo/git2_impl.rs index a923909..9e314a6 100644 --- a/src/repo/git2_impl.rs +++ b/src/repo/git2_impl.rs @@ -1482,13 +1482,42 @@ fn apply_selected_hunks_to_tree( } impl Git2Repo { - /// Refuse if the working tree or index has any staged or unstaged changes. + /// Refuse if the working tree or index has any staged or unstaged changes, + /// ignoring submodule pointer updates (consistent with `git rebase`). + /// + /// Gitlink entries (mode `0o160000`) are skipped because libgit2's + /// `checkout_head` does not recurse into submodule directories, so a dirty + /// submodule reference cannot be silently discarded. /// /// Called before operations that end with `checkout_head(force)`, which /// would silently discard any dirty state. The user should stash or /// commit their changes before running such operations. fn check_no_dirty_state(&self) -> Result<()> { - if self.staged_diff().is_some() || self.unstaged_diff().is_some() { + let mut opts = git2::DiffOptions::new(); + opts.context_lines(0); + opts.interhunk_lines(0); + + let head_tree = self.inner.head().ok().and_then(|h| h.peel_to_tree().ok()); + + // Returns true only when the delta is a real file change, not a gitlink. + let is_real = |delta: git2::DiffDelta| { + delta.old_file().mode() != git2::FileMode::Commit + && delta.new_file().mode() != git2::FileMode::Commit + }; + + let has_staged = self + .inner + .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)) + .map(|d| d.deltas().any(is_real)) + .unwrap_or(false); + + let has_unstaged = self + .inner + .diff_index_to_workdir(None, Some(&mut opts)) + .map(|d| d.deltas().any(is_real)) + .unwrap_or(false); + + if has_staged || has_unstaged { anyhow::bail!( "You have staged or unstaged changes. \ Stash or commit them before running this operation." diff --git a/tests/common.rs b/tests/common.rs index fc1e375..2ffb089 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -207,6 +207,35 @@ pub fn create_test_commit_diff( } } +/// Stage a gitlink (submodule pointer) entry in the repo's index at `path` +/// pointing at `target_oid`, without making a commit. +/// +/// This simulates the state produced by `git submodule update` or a manual +/// submodule bump — the working tree inside the submodule directory is +/// unaffected; only the gitlink entry in the index changes. +#[allow(dead_code)] +pub fn stage_gitlink(repo: &git2::Repository, path: &str, target_oid: git2::Oid) { + let mut index = repo.index().unwrap(); + index.read(true).unwrap(); + index + .add(&git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o160000, + uid: 0, + gid: 0, + file_size: 0, + id: target_oid, + flags: 0, + flags_extended: 0, + path: path.as_bytes().to_vec(), + }) + .unwrap(); + index.write().unwrap(); +} + /// Build a minimal `CommitInfo` for use in TUI snapshot tests. #[allow(dead_code)] pub fn create_test_commit(oid: &str, summary: &str) -> CommitInfo { diff --git a/tests/drop_commit.rs b/tests/drop_commit.rs index 0155cea..faecb04 100644 --- a/tests/drop_commit.rs +++ b/tests/drop_commit.rs @@ -541,3 +541,24 @@ fn drop_commit_blocked_with_unstaged_changes() { "error should mention staged/unstaged: {msg}" ); } + +#[test] +fn drop_commit_allowed_with_staged_submodule() { + let test = common::TestRepo::new(); + + let base = test.commit_file("a.txt", "v1\n", "base"); + let to_drop = test.commit_file("a.txt", "v2\n", "to drop"); + + // Stage a submodule pointer update — the only dirty state is a gitlink. + common::stage_gitlink(&test.repo, "libs/sub", base); + + let git_repo = test.git_repo(); + let result = git_repo + .drop_commit(&to_drop.to_string(), &to_drop.to_string()) + .unwrap(); + + assert!( + matches!(result, RebaseOutcome::Complete), + "drop should succeed when only a submodule pointer is staged; got {result:?}" + ); +} diff --git a/tests/move_commit.rs b/tests/move_commit.rs index 34bfcf8..cf7cb5b 100644 --- a/tests/move_commit.rs +++ b/tests/move_commit.rs @@ -321,3 +321,27 @@ fn move_commit_blocked_with_unstaged_changes() { ); let _ = a; } + +#[test] +fn move_commit_allowed_with_staged_submodule() { + let test = common::TestRepo::new(); + + // ref → A → B → C(source) → HEAD; move C to after A + let base = test.commit_file("a.txt", "base\n", "base"); + let a = test.commit_file("x.txt", "x\n", "A"); + let _b = test.commit_file("y.txt", "y\n", "B"); + let c = test.commit_file("z.txt", "z\n", "C"); + + // Stage a submodule pointer update — the only dirty state is a gitlink. + common::stage_gitlink(&test.repo, "libs/sub", base); + + let git_repo = test.git_repo(); + let result = git_repo + .move_commit(&c.to_string(), &a.to_string(), &c.to_string()) + .unwrap(); + + assert!( + matches!(result, RebaseOutcome::Complete), + "move should succeed when only a submodule pointer is staged; got {result:?}" + ); +} diff --git a/tests/snapshots/static_fragmap__rename_clusters_with_original_file.snap b/tests/snapshots/static_fragmap__rename_clusters_with_original_file.snap new file mode 100644 index 0000000..3106b7f --- /dev/null +++ b/tests/snapshots/static_fragmap__rename_clusters_with_original_file.snap @@ -0,0 +1,7 @@ +--- +source: tests/static_fragmap.rs +assertion_line: 319 +expression: output +--- +aaaa0000 Add old.rs ## +bbbb2222 Rename old to new .# diff --git a/tests/squash_commit.rs b/tests/squash_commit.rs index 3116c82..1c554e5 100644 --- a/tests/squash_commit.rs +++ b/tests/squash_commit.rs @@ -635,3 +635,58 @@ fn squash_try_combine_blocked_with_unstaged_changes() { "error should mention staged/unstaged: {msg}" ); } + +#[test] +fn squash_commits_allowed_with_staged_submodule() { + let test = common::TestRepo::new(); + + let base = test.commit_file("a.txt", "base\n", "base"); + let target = test.commit_file("b.txt", "b\n", "target commit"); + let source = test.commit_file("c.txt", "c\n", "source commit"); + + // Stage a submodule pointer update — the only dirty state is a gitlink. + common::stage_gitlink(&test.repo, "libs/sub", base); + + let git_repo = test.git_repo(); + let result = git_repo + .squash_commits( + &source.to_string(), + &target.to_string(), + "squashed", + &source.to_string(), + ) + .unwrap(); + + assert!( + matches!(result, RebaseOutcome::Complete), + "squash should succeed when only a submodule pointer is staged; got {result:?}" + ); +} + +#[test] +fn squash_try_combine_allowed_with_staged_submodule() { + let test = common::TestRepo::new(); + + let base = test.commit_file("a.txt", "base\n", "base"); + let target = test.commit_file("b.txt", "b\n", "target commit"); + let source = test.commit_file("c.txt", "c\n", "source commit"); + + // Stage a submodule pointer update — the only dirty state is a gitlink. + common::stage_gitlink(&test.repo, "libs/sub", base); + + let git_repo = test.git_repo(); + // Returns Ok(None) when there is no merge conflict — both files differ. + let result = git_repo + .squash_try_combine( + &source.to_string(), + &target.to_string(), + "squashed", + &source.to_string(), + ) + .unwrap(); + + assert!( + result.is_none(), + "squash_try_combine should succeed (no conflict) when only a submodule pointer is staged" + ); +} From 270b6ab47656cbe9c0cdbc0b35ab0dbb2ca3f7b7 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Tue, 17 Mar 2026 19:55:20 +0000 Subject: [PATCH 02/19] tasks: Add T130 auto-detect default branch when no BASE given --- TASKS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TASKS.md b/TASKS.md index 269f4e5..2659afe 100644 --- a/TASKS.md +++ b/TASKS.md @@ -12,6 +12,11 @@ Guidelines: ## UNCATEGORIZED +- [ ] T130 P2 feat - Auto-detect the repository default branch when no `` + is provided on the command line: resolve `origin/HEAD` via + `git rev-parse --abbrev-ref origin/HEAD` (libgit2: look up the symbolic target + of `refs/remotes/origin/HEAD`) and use the resulting branch as the base; fall + back to the current hard-coded default if `origin/HEAD` is not set. ## Interactivity — Fragmap View (V5) - [ ] T108 P1 fix - Fix fragmap relations not following file renames: when a From dbabdcf2f6304950205b25bf85e4a95b63a16f41 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Tue, 17 Mar 2026 20:14:42 +0000 Subject: [PATCH 03/19] feat: Auto-detect default branch when no BASE argument is given (T130) Make optional on the command line. When omitted, resolve refs/remotes/origin/HEAD symbolically to find the remote's default branch (e.g. origin/main) and pass it to find_reference_point. Falls back to 'main' when origin/HEAD is not configured. - Add GitRepo::default_branch() to the trait - Implement it in Git2Repo via refs/remotes/origin/HEAD - Make Cli::base Option with updated help text - Add three tests in tests/reference_point.rs covering set/absent/non-main - Add default_branch stub to TUI test mock impls --- CHANGELOG.md | 6 ++++ README.md | 7 ++++- TASKS.md | 2 +- src/main.rs | 15 ++++++++-- src/repo.rs | 13 +++++++++ src/repo/git2_impl.rs | 11 ++++++++ tests/reference_point.rs | 56 ++++++++++++++++++++++++++++++++++++++ tests/tui_commit_detail.rs | 8 ++++++ tests/tui_main_view.rs | 4 +++ 9 files changed, 117 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce076ba..2340eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on ## [Unreleased] +### Added + +- `` argument is now optional. When omitted, `gt` resolves `origin/HEAD` + to determine the repository's default upstream branch (e.g. `origin/main`). + Falls back to `main` if `origin/HEAD` is not configured. + ### Fixed - Added possibility to perform drop, move and squash operations when there are diff --git a/README.md b/README.md index c49159c..39181cd 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Requires Rust 1.85 or later. ## Usage ```sh -gt +gt [base] ``` `` identifies the branch or point you forked from — typically the target @@ -40,10 +40,15 @@ be a direct ancestor of `HEAD`: the **merge-base** (common ancestor) between `` and `HEAD` is used as the reference point. All commits between that merge-base and `HEAD` are shown. +When `` is omitted, `gt` automatically uses the repository's default +upstream branch by resolving `origin/HEAD`. If that is not configured it falls +back to `main`. + ```sh gt main # commits on top of main gt origin/main # commits not yet pushed gt v1.2.3 # commits since a tag +gt # auto-detect default branch (origin/HEAD or main) ``` **Flags:** diff --git a/TASKS.md b/TASKS.md index 2659afe..6648a59 100644 --- a/TASKS.md +++ b/TASKS.md @@ -12,7 +12,7 @@ Guidelines: ## UNCATEGORIZED -- [ ] T130 P2 feat - Auto-detect the repository default branch when no `` +- [X] T130 P2 feat - Auto-detect the repository default branch when no `` is provided on the command line: resolve `origin/HEAD` via `git rev-parse --abbrev-ref origin/HEAD` (libgit2: look up the symbolic target of `refs/remotes/origin/HEAD`) and use the resulting branch as the base; fall diff --git a/src/main.rs b/src/main.rs index 09c5c57..3f62b2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,11 @@ use std::io; #[command(name = "gt")] struct Cli { /// A commit-ish to use as the base reference (branch, tag, or hash). - base: String, + /// + /// When omitted, the tool resolves `origin/HEAD` to find the repository's + /// default upstream branch (e.g. `origin/main`). If `origin/HEAD` is not + /// configured it falls back to `main`. + base: Option, /// Display commits in reverse order (HEAD at top). #[arg(short, long)] @@ -99,7 +103,12 @@ fn main() -> Result<()> { let cli = Cli::parse(); let git_repo = Git2Repo::open(std::env::current_dir()?)?; - let reference_oid = git_repo.find_reference_point(&cli.base)?; + let base = cli.base.unwrap_or_else(|| { + git_repo + .default_branch() + .unwrap_or_else(|| "main".to_string()) + }); + let reference_oid = git_repo.find_reference_point(&base)?; let head_oid = git_repo.head_oid()?; let commits = git_repo.list_commits(&head_oid, &reference_oid)?; @@ -115,7 +124,7 @@ fn main() -> Result<()> { if commits.is_empty() { eprintln!( "No commits to display: HEAD is at the merge-base with '{}'", - cli.base + base ); eprintln!("The current branch has no commits beyond the common ancestor."); return Ok(()); diff --git a/src/repo.rs b/src/repo.rs index c10ed53..98a7fb4 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -333,6 +333,19 @@ pub trait GitRepo { /// the updated index to disk. Must be called after a merge tool resolves a /// conflict so that subsequent `index.has_conflicts()` checks return false. fn stage_file(&self, path: &str) -> Result<()>; + + /// Return the name of the repository's default upstream branch. + /// + /// Looks up the symbolic target of `refs/remotes/origin/HEAD` (the pointer + /// that `git remote set-head origin --auto` sets) and strips the + /// `refs/remotes/` prefix so the returned value can be passed directly to + /// `find_reference_point`. For example when `origin/HEAD` points to + /// `refs/remotes/origin/main` this returns `Some("origin/main")`. + /// + /// Returns `None` when the remote tracking ref is absent or has no symbolic + /// target (e.g. the repo has no remote configured, or `origin/HEAD` was + /// never set). + fn default_branch(&self) -> Option; } impl ConflictState { diff --git a/src/repo/git2_impl.rs b/src/repo/git2_impl.rs index 9e314a6..337b4a7 100644 --- a/src/repo/git2_impl.rs +++ b/src/repo/git2_impl.rs @@ -1035,6 +1035,17 @@ impl GitRepo for Git2Repo { Ok(()) } + fn default_branch(&self) -> Option { + let reference = self + .inner + .find_reference("refs/remotes/origin/HEAD") + .ok()?; + let target = reference.symbolic_target()?; + // Strip the "refs/remotes/" prefix so the caller can pass the result + // directly to find_reference_point (e.g. "origin/main"). + target.strip_prefix("refs/remotes/").map(str::to_string) + } + fn squash_try_combine( &self, source_oid: &str, diff --git a/tests/reference_point.rs b/tests/reference_point.rs index 954f7dd..6045613 100644 --- a/tests/reference_point.rs +++ b/tests/reference_point.rs @@ -14,6 +14,8 @@ mod common; +use git_tailor::repo::GitRepo; + #[test] fn test_merge_base_with_branch_name() { let test = common::TestRepo::new(); @@ -127,3 +129,57 @@ fn test_merge_base_with_diverged_branches() { let merge_base = test.repo.merge_base(a_oid, b_oid).unwrap(); assert_eq!(merge_base, base); } + +// --------------------------------------------------------------------------- +// default_branch tests +// --------------------------------------------------------------------------- + +/// Helper: register a fake remote and point refs/remotes/origin/HEAD at the +/// given branch tracking ref. No network activity; everything is local. +fn set_origin_head(repo: &git2::Repository, branch: &str) { + // Create the tracking branch ref so origin/HEAD has a valid target. + let tracking_refname = format!("refs/remotes/origin/{branch}"); + let head_oid = repo.head().unwrap().target().unwrap(); + repo.reference(&tracking_refname, head_oid, true, "test setup") + .unwrap(); + + // Create refs/remotes/origin/HEAD as a symbolic ref pointing at the + // tracking branch. + let symbolic_target = tracking_refname.clone(); + repo.reference_symbolic( + "refs/remotes/origin/HEAD", + &symbolic_target, + true, + "test setup", + ) + .unwrap(); +} + +#[test] +fn default_branch_returns_origin_head_when_set() { + let test = common::TestRepo::new(); + test.commit_file("a.txt", "content", "initial"); + set_origin_head(&test.repo, "main"); + + let git_repo = test.git_repo(); + assert_eq!(git_repo.default_branch(), Some("origin/main".to_string())); +} + +#[test] +fn default_branch_returns_none_when_origin_head_absent() { + let test = common::TestRepo::new(); + test.commit_file("a.txt", "content", "initial"); + // No remote configured at all. + let git_repo = test.git_repo(); + assert_eq!(git_repo.default_branch(), None); +} + +#[test] +fn default_branch_reflects_non_main_default() { + let test = common::TestRepo::new(); + test.commit_file("a.txt", "content", "initial"); + set_origin_head(&test.repo, "master"); + + let git_repo = test.git_repo(); + assert_eq!(git_repo.default_branch(), Some("origin/master".to_string())); +} diff --git a/tests/tui_commit_detail.rs b/tests/tui_commit_detail.rs index 8751daa..63b32f3 100644 --- a/tests/tui_commit_detail.rs +++ b/tests/tui_commit_detail.rs @@ -143,6 +143,10 @@ impl GitRepo for NoOpRepo { fn stage_file(&self, _path: &str) -> Result<()> { unimplemented!() } + + fn default_branch(&self) -> Option { + None + } } /// GitRepo stub that returns a fixed CommitDiff for the selected commit OID. @@ -259,6 +263,10 @@ impl GitRepo for FakeDiffRepo { fn stage_file(&self, _path: &str) -> Result<()> { unimplemented!() } + + fn default_branch(&self) -> Option { + None + } } /// Short message: all content lines fit within 80 columns — no horizontal scrollbar. diff --git a/tests/tui_main_view.rs b/tests/tui_main_view.rs index 805d028..098f1c2 100644 --- a/tests/tui_main_view.rs +++ b/tests/tui_main_view.rs @@ -139,6 +139,10 @@ impl GitRepo for NoOpRepo { fn stage_file(&self, _path: &str) -> Result<()> { unimplemented!() } + + fn default_branch(&self) -> Option { + None + } } fn app_with_commits() -> AppState { From f1e56c3d77723c0fae7fed2d3359c96c234d6fb3 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Tue, 17 Mar 2026 20:23:24 +0100 Subject: [PATCH 04/19] chore: Archive completed tasks from TASKS.md to TASKS-COMPLETED.md --- TASKS-COMPLETED.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++ TASKS.md | 71 ------------------------------------------ 2 files changed, 77 insertions(+), 71 deletions(-) diff --git a/TASKS-COMPLETED.md b/TASKS-COMPLETED.md index 2f7cff3..ce203d4 100644 --- a/TASKS-COMPLETED.md +++ b/TASKS-COMPLETED.md @@ -430,3 +430,80 @@ `if action == SeparatorLeft { ... continue; }`, handle separator_offset mutation inside the view handle_key (main_view or commit_list), returning AppAction::Handled (Flags: V6) + +## CLI Output & Compatibility (V5) — continued +- [X] T128 P2 feat - Adapt title column width to terminal width in `--static` + output: the original fragmap tool sets the title column width dynamically so + that the SHA + title + hunk-group matrix fills the available terminal width; + investigate the original Python implementation + (https://github.com/amollberg/fragmap) to understand the exact layout + algorithm (how many columns it reserves for SHA, separators, and the matrix, + and how it clamps the title width), then implement the same or equivalent + logic in `static_views::fragmap::render` — the title currently uses a fixed + 26-character truncation; instead, detect the terminal width (via + `crossterm::terminal::size()` or a passed-in width, falling back to 80), + compute `title_width = terminal_width − sha_width − separators − matrix_width` + clamped to a sensible minimum, and truncate/pad the title to that width + (Flags: V5) +- [X] T126 P2 feat - Add `--squashable-scope ` CLI argument + controlling what the squashable connector color/symbol means: `group` (default + in TUI) — a connector in a column is squashable when *that hunk-group pair + alone* has no intervening touches (current per-group behavior); `commit` + (default in `--static`) — a connector is squashable only when the *entire + lower commit* is fully squashable into the same single upper commit (i.e. + `fragmap.is_fully_squashable()` is true and `squash_target()` points to that + upper commit), matching the original fragmap tool's stricter rule; the + argument must be valid in both TUI and `--static` modes; store the choice in + `AppState` and thread it through the fragmap connector rendering logic in both + `static_views::fragmap::render` and the TUI fragmap widget (Flags: V5) +- [X] T127 P2 fix - Respect the `-r` / `--reverse` flag when `--static` is used: + currently `--static` always outputs commits in the order returned by + `list_commits` (newest-first); when `--reverse` is also passed the rows should + be printed oldest-first, matching the interactive TUI behavior (Flags: V5) +- [x] T111 P3 feat - Replace the current example application in `examples/` with + a compatibility tool that takes a commit-ish as its argument, uses it to find + the merge-base (same as `--static`), then builds a `Fragmap` object in the + normal way and also runs the original `fragmap` binary (if installed) on the + same repository/ref; the tool renders git-tailor's result through the static + view and compares the two outputs column-by-column (columns may be in any + order); if the same commit-cluster relationships are present in both it prints + "OK"; otherwise it prints the `fragmap` output, then git-tailor's static + output, plus a short summary explaining what differs (Flags: V5) + +## Build & CI (V5) — continued +- [X] T114 P2 feat - Write comprehensive README.md documentation: describe what + the tool does (interactive git commit browser with fragmap visualization and + rebase operations), installation instructions, basic usage guide with key + bindings, attribution to original fragmap tool (reference NOTICE file), note + that the entire tool is AI-generated, and include a prominent data safety + disclaimer warning users to push their changes before using the tool since any + bugs may cause permanent data loss — author takes no responsibility for data + loss under any circumstances, see Apache 2.0 license text (Flags: V5) +- [X] T115 P2 feat - Add CHANGELOG.md following keepachangelog.com format: + create initial changelog with sections for Unreleased, version entries (Added, + Changed, Deprecated, Removed, Fixed, Security), and update AGENTS.md to + instruct AI agents to ask users whether changes should be noted in the + changelog when completing tasks that add user-visible features or fix bugs + (Flags: V5) + +## Bug Fixes (V5) — continued +- [X] T129 P1 bug - Fix move/drop/fixup/squash/split losing working-tree and + index changes: currently these rebase operations discard any uncommitted + changes (both staged and unstaged) that exist in the working tree when the + operation is applied; `reword` already preserves them correctly, so audit how + `reword` saves and restores the working-tree and index state and apply the + same stash-and-restore (or equivalent) pattern to `move_commit`, + `drop_commit`, `squash_commit`, `fixup_commit`, and `split_commit` in the + rebase engine; add integration tests in the `tests/` directory covering all + five operations with both staged changes (files added to the index but not + committed) and unstaged changes (modified tracked files not yet staged), + asserting that after the operation completes the working tree and index + reflect the same content that was present before the operation started (Flags: + V5) + +## Interactivity — Auto-detection (V5) +- [X] T130 P2 feat - Auto-detect the repository default branch when no `` + is provided on the command line: resolve `origin/HEAD` via + `git rev-parse --abbrev-ref origin/HEAD` (libgit2: look up the symbolic target + of `refs/remotes/origin/HEAD`) and use the resulting branch as the base; fall + back to the current hard-coded default if `origin/HEAD` is not set. diff --git a/TASKS.md b/TASKS.md index 6648a59..1b0432c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -12,11 +12,6 @@ Guidelines: ## UNCATEGORIZED -- [X] T130 P2 feat - Auto-detect the repository default branch when no `` - is provided on the command line: resolve `origin/HEAD` via - `git rev-parse --abbrev-ref origin/HEAD` (libgit2: look up the symbolic target - of `refs/remotes/origin/HEAD`) and use the resulting branch as the base; fall - back to the current hard-coded default if `origin/HEAD` is not set. ## Interactivity — Fragmap View (V5) - [ ] T108 P1 fix - Fix fragmap relations not following file renames: when a @@ -53,43 +48,6 @@ Guidelines: V5) ## CLI Output & Compatibility (V5) -- [x] T128 P2 feat - Adapt title column width to terminal width in `--static` - output: the original fragmap tool sets the title column width dynamically so - that the SHA + title + hunk-group matrix fills the available terminal width; - investigate the original Python implementation - (https://github.com/amollberg/fragmap) to understand the exact layout - algorithm (how many columns it reserves for SHA, separators, and the matrix, - and how it clamps the title width), then implement the same or equivalent - logic in `static_views::fragmap::render` — the title currently uses a fixed - 26-character truncation; instead, detect the terminal width (via - `crossterm::terminal::size()` or a passed-in width, falling back to 80), - compute `title_width = terminal_width − sha_width − separators − matrix_width` - clamped to a sensible minimum, and truncate/pad the title to that width - (Flags: V5) -- [X] T126 P2 feat - Add `--squashable-scope ` CLI argument - controlling what the squashable connector color/symbol means: `group` (default - in TUI) — a connector in a column is squashable when *that hunk-group pair - alone* has no intervening touches (current per-group behavior); `commit` - (default in `--static`) — a connector is squashable only when the *entire - lower commit* is fully squashable into the same single upper commit (i.e. - `fragmap.is_fully_squashable()` is true and `squash_target()` points to that - upper commit), matching the original fragmap tool's stricter rule; the - argument must be valid in both TUI and `--static` modes; store the choice in - `AppState` and thread it through the fragmap connector rendering logic in both - `static_views::fragmap::render` and the TUI fragmap widget (Flags: V5) -- [X] T127 P2 fix - Respect the `-r` / `--reverse` flag when `--static` is used: - currently `--static` always outputs commits in the order returned by - `list_commits` (newest-first); when `--reverse` is also passed the rows should - be printed oldest-first, matching the interactive TUI behavior (Flags: V5) -- [x] T111 P3 feat - Replace the current example application in `examples/` with - a compatibility tool that takes a commit-ish as its argument, uses it to find - the merge-base (same as `--static`), then builds a `Fragmap` object in the - normal way and also runs the original `fragmap` binary (if installed) on the - same repository/ref; the tool renders git-tailor's result through the static - view and compares the two outputs column-by-column (columns may be in any - order); if the same commit-cluster relationships are present in both it prints - "OK"; otherwise it prints the `fragmap` output, then git-tailor's static - output, plus a short summary explaining what differs (Flags: V5) ## Build & CI (V5) - [ ] T112 P3 feat - Set up cargo-deny with configuration to check dependency @@ -112,20 +70,6 @@ Guidelines: the GitHub Release automatically; the musl target should produce a zero shared-library binary (add `RUSTFLAGS=-C target-feature=+crt-static` if needed) so no system libs beyond the kernel are required (Flags: V5) -- [X] T114 P2 feat - Write comprehensive README.md documentation: describe what - the tool does (interactive git commit browser with fragmap visualization and - rebase operations), installation instructions, basic usage guide with key - bindings, attribution to original fragmap tool (reference NOTICE file), note - that the entire tool is AI-generated, and include a prominent data safety - disclaimer warning users to push their changes before using the tool since any - bugs may cause permanent data loss — author takes no responsibility for data - loss under any circumstances, see Apache 2.0 license text (Flags: V5) -- [X] T115 P2 feat - Add CHANGELOG.md following keepachangelog.com format: - create initial changelog with sections for Unreleased, version entries (Added, - Changed, Deprecated, Removed, Fixed, Security), and update AGENTS.md to - instruct AI agents to ask users whether changes should be noted in the - changelog when completing tasks that add user-visible features or fix bugs - (Flags: V5) ## Refactoring — TUI Architecture (V5) - [ ] T116 P3 feat - Review codebase for refactoring opportunities: audit @@ -136,19 +80,4 @@ Guidelines: improving module boundaries; create follow-up tasks for the most impactful improvements (Flags: V5) -## Bug Fixes (V5) -- [X] T129 P1 bug - Fix move/drop/fixup/squash/split losing working-tree and - index changes: currently these rebase operations discard any uncommitted - changes (both staged and unstaged) that exist in the working tree when the - operation is applied; `reword` already preserves them correctly, so audit how - `reword` saves and restores the working-tree and index state and apply the - same stash-and-restore (or equivalent) pattern to `move_commit`, - `drop_commit`, `squash_commit`, `fixup_commit`, and `split_commit` in the - rebase engine; add integration tests in the `tests/` directory covering all - five operations with both staged changes (files added to the index but not - committed) and unstaged changes (modified tracked files not yet staged), - asserting that after the operation completes the working tree and index - reflect the same content that was present before the operation started (Flags: - V5) - ## Notes From f1397971567509277d4a59d5bb4708690b09dbb9 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Tue, 17 Mar 2026 20:45:03 +0100 Subject: [PATCH 05/19] fix: Track file renames in fragmap span clustering (T108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable rename detection via find_similar() in commit_diff_for_fragmap so git2 produces a single delta with old_path ≠ new_path instead of separate delete+add pairs. Build a rename map that traces each path back to its canonical (earliest) name and use it throughout the fragmap pipeline: extract_spans_propagated, build_fragmap, assign_hunk_groups, build_matrix, determine_touch_kind, and dump_per_file_spg_stats. Extract shared collect_file_commits() helper to replace four duplicated inline file-grouping loops. --- CHANGELOG.md | 3 + TASKS.md | 2 +- src/fragmap.rs | 238 ++++++++++++++++++++++------------------ src/fragmap/spg.rs | 32 +----- src/repo/git2_impl.rs | 9 +- tests/commit_diff.rs | 30 +++++ tests/common.rs | 45 ++++++++ tests/static_fragmap.rs | 43 ++++++++ 8 files changed, 259 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2340eb7..8356887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The format is based on ### Fixed +- Fragmap now tracks file renames across commits: when a file is renamed, + overlapping spans in the old and new paths are correctly clustered together + instead of being treated as unrelated files. - Added possibility to perform drop, move and squash operations when there are unstaged/staged changes in a submodule. diff --git a/TASKS.md b/TASKS.md index 1b0432c..2985325 100644 --- a/TASKS.md +++ b/TASKS.md @@ -14,7 +14,7 @@ Guidelines: ## UNCATEGORIZED ## Interactivity — Fragmap View (V5) -- [ ] T108 P1 fix - Fix fragmap relations not following file renames: when a +- [X] T108 P1 fix - Fix fragmap relations not following file renames: when a file is renamed across commits, spans should cluster together if they overlap the same logical content, but currently they are treated as separate files and don't form clusters. Investigate the original fragmap Python implementation diff --git a/src/fragmap.rs b/src/fragmap.rs index 550c670..17e2346 100644 --- a/src/fragmap.rs +++ b/src/fragmap.rs @@ -27,42 +27,49 @@ mod spg; pub use spg::dump_per_file_spg_stats; use spg::{build_file_clusters, build_file_clusters_and_assign_hunks, deduplicate_clusters}; -/// A span of line numbers within a specific file. +/// Build a map from every known file path to the canonical (earliest) name for +/// that file, following rename chains across commits. /// -/// Represents a contiguous range of lines that were touched by a commit, -/// propagated forward to the final file version so all spans share the -/// same reference frame. This allows overlap-based clustering to correctly -/// detect which commits touch related code regions. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileSpan { - /// The file path (from the new version of the file). - pub path: String, - /// First line number (1-indexed) in the range. - pub start_line: u32, - /// Last line number (1-indexed) in the range, inclusive. - pub end_line: u32, +/// The commit diffs must be in chronological order (oldest first). When a +/// `FileDiff` has `old_path ≠ new_path` the old name's canonical entry is +/// propagated to the new name. +pub(crate) fn build_rename_map(commit_diffs: &[CommitDiff]) -> HashMap { + let mut canonical: HashMap = HashMap::new(); + for diff in commit_diffs { + for file in &diff.files { + if let (Some(old), Some(new)) = (&file.old_path, &file.new_path) + && old != new + { + let root = canonical.get(old).cloned().unwrap_or_else(|| old.clone()); + canonical.insert(new.clone(), root); + } + } + } + canonical } -/// Extract FileSpans from all commit diffs with span propagation. +/// Resolve a file path to its canonical (earliest) name using the rename map. +fn canonical_path<'a>(path: &'a str, rename_map: &'a HashMap) -> &'a str { + rename_map.get(path).map(|s| s.as_str()).unwrap_or(path) +} + +/// Collect per-file hunk lists grouped by canonical path. /// -/// Each hunk produces a span using its full `[new_start, new_start + new_lines)` -/// range (the region of the file occupied after the commit). That span is then -/// propagated forward through every subsequent commit that modifies the same -/// file, adjusting line numbers to account for insertions and deletions. -/// The result: every span is expressed in the FINAL file version's coordinates, -/// making overlap-based clustering correct across commits. -pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec)> { - // Group hunks by file path across all commits. - // For each file we need the commit index + hunks in chronological order. +/// This is the shared grouping logic used by [`build_fragmap`], +/// [`assign_hunk_groups`], [`extract_spans_propagated`], and +/// [`dump_per_file_spg_stats`]. +pub(crate) fn collect_file_commits( + commit_diffs: &[CommitDiff], + rename_map: &HashMap, +) -> HashMap)>> { let mut file_commits: HashMap)>> = HashMap::new(); - for (commit_idx, diff) in commit_diffs.iter().enumerate() { for file in &diff.files { let path = match &file.new_path { Some(p) => p.clone(), None => continue, }; - + let key = canonical_path(&path, rename_map).to_owned(); let hunks: Vec = file .hunks .iter() @@ -73,15 +80,48 @@ pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec new_lines: h.new_lines, }) .collect(); - if !hunks.is_empty() { - file_commits - .entry(path) - .or_default() - .push((commit_idx, hunks)); + let entry = file_commits.entry(key).or_default(); + if let Some(last) = entry.last_mut() + && last.0 == commit_idx + { + last.1.extend(hunks); + continue; + } + entry.push((commit_idx, hunks)); } } } + file_commits +} + +/// A span of line numbers within a specific file. +/// +/// Represents a contiguous range of lines that were touched by a commit, +/// propagated forward to the final file version so all spans share the +/// same reference frame. This allows overlap-based clustering to correctly +/// detect which commits touch related code regions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileSpan { + /// The file path (from the new version of the file). + pub path: String, + /// First line number (1-indexed) in the range. + pub start_line: u32, + /// Last line number (1-indexed) in the range, inclusive. + pub end_line: u32, +} + +/// Extract FileSpans from all commit diffs with span propagation. +/// +/// Each hunk produces a span using its full `[new_start, new_start + new_lines)` +/// range (the region of the file occupied after the commit). That span is then +/// propagated forward through every subsequent commit that modifies the same +/// file, adjusting line numbers to account for insertions and deletions. +/// The result: every span is expressed in the FINAL file version's coordinates, +/// making overlap-based clustering correct across commits. +pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec)> { + let rename_map = build_rename_map(commit_diffs); + let file_commits = collect_file_commits(commit_diffs, &rename_map); // For each file, propagate every commit's spans forward to the final version. let mut all_spans: Vec<(usize, FileSpan)> = Vec::new(); @@ -138,7 +178,7 @@ pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec /// Lightweight copy of the hunk header fields needed for propagation. #[derive(Debug, Clone)] -struct HunkInfo { +pub(crate) struct HunkInfo { old_start: u32, old_lines: u32, new_start: u32, @@ -295,40 +335,8 @@ pub struct FragMap { /// `false` to keep every raw hunk cluster as its own column, which is useful /// for debugging the cluster layout. pub fn build_fragmap(commit_diffs: &[CommitDiff], deduplicate: bool) -> FragMap { - let mut file_commits: HashMap)>> = HashMap::new(); - - for (commit_idx, diff) in commit_diffs.iter().enumerate() { - for file in &diff.files { - let path = match &file.new_path { - Some(p) => p.clone(), - None => continue, - }; - - let hunks: Vec = file - .hunks - .iter() - .map(|h| HunkInfo { - old_start: h.old_start, - old_lines: h.old_lines, - new_start: h.new_start, - new_lines: h.new_lines, - }) - .collect(); - - if !hunks.is_empty() { - let entry = file_commits.entry(path).or_default(); - // Merge hunks from the same file and commit (can happen when - // a commit has multiple FileDiff entries for the same path) - if let Some(last) = entry.last_mut() - && last.0 == commit_idx - { - last.1.extend(hunks); - continue; - } - entry.push((commit_idx, hunks)); - } - } - } + let rename_map = build_rename_map(commit_diffs); + let file_commits = collect_file_commits(commit_diffs, &rename_map); let mut clusters: Vec = Vec::new(); @@ -345,7 +353,7 @@ pub fn build_fragmap(commit_diffs: &[CommitDiff], deduplicate: bool) -> FragMap } let commits: Vec = commit_diffs.iter().map(|d| d.commit.oid.clone()).collect(); - let matrix = build_matrix(&commits, &clusters, commit_diffs); + let matrix = build_matrix(&commits, &clusters, commit_diffs, &rename_map); FragMap { commits, @@ -375,36 +383,8 @@ pub fn assign_hunk_groups( .iter() .position(|d| d.commit.oid == commit_oid)?; - // Build file_commits the same way build_fragmap does. - let mut file_commits: HashMap)>> = HashMap::new(); - for (commit_idx, diff) in commit_diffs.iter().enumerate() { - for file in &diff.files { - let path = match &file.new_path { - Some(p) => p.clone(), - None => continue, - }; - let hunks: Vec = file - .hunks - .iter() - .map(|h| HunkInfo { - old_start: h.old_start, - old_lines: h.old_lines, - new_start: h.new_start, - new_lines: h.new_lines, - }) - .collect(); - if !hunks.is_empty() { - let entry = file_commits.entry(path).or_default(); - if let Some(last) = entry.last_mut() - && last.0 == commit_idx - { - last.1.extend(hunks); - continue; - } - entry.push((commit_idx, hunks)); - } - } - } + let rename_map = build_rename_map(commit_diffs); + let file_commits = collect_file_commits(commit_diffs, &rename_map); let mut sorted_paths: Vec<&String> = file_commits.keys().collect(); sorted_paths.sort(); @@ -704,6 +684,7 @@ fn build_matrix( commits: &[String], clusters: &[SpanCluster], commit_diffs: &[CommitDiff], + rename_map: &HashMap, ) -> Vec> { let mut matrix = vec![vec![TouchKind::None; clusters.len()]; commits.len()]; @@ -711,10 +692,9 @@ fn build_matrix( let commit_diff = &commit_diffs[commit_idx]; for (cluster_idx, cluster) in clusters.iter().enumerate() { - // Check if this commit touches this cluster if cluster.commit_oids.contains(commit_oid) { - // Determine the touch kind - matrix[commit_idx][cluster_idx] = determine_touch_kind(commit_diff, cluster); + matrix[commit_idx][cluster_idx] = + determine_touch_kind(commit_diff, cluster, rename_map); } } } @@ -725,14 +705,21 @@ fn build_matrix( /// Determine how a commit touches a cluster (Added/Modified/Deleted). /// /// Looks at the files in the commit that overlap with the cluster's spans -/// to classify the type of change. -fn determine_touch_kind(commit_diff: &CommitDiff, cluster: &SpanCluster) -> TouchKind { +/// to classify the type of change. Uses the rename map to match file paths +/// across renames. +fn determine_touch_kind( + commit_diff: &CommitDiff, + cluster: &SpanCluster, + rename_map: &HashMap, +) -> TouchKind { for cluster_span in &cluster.spans { + let cluster_canonical = canonical_path(&cluster_span.path, rename_map); for file in &commit_diff.files { - // Check if this file matches the cluster span let file_path = file.new_path.as_ref().or(file.old_path.as_ref()); - if file_path.map(|p| p == &cluster_span.path).unwrap_or(false) { - // Classify based on file paths + let matches = file_path + .map(|p| canonical_path(p, rename_map) == cluster_canonical) + .unwrap_or(false); + if matches { if file.old_path.is_none() && file.new_path.is_some() { return TouchKind::Added; } else if file.old_path.is_some() && file.new_path.is_none() { @@ -2247,8 +2234,9 @@ mod tests { } #[test] - fn build_fragmap_file_rename_cluster_uses_new_path() { - // A commit that renames foo.rs → bar.rs. The cluster should track bar.rs. + fn build_fragmap_file_rename_cluster_uses_canonical_path() { + // A commit that renames foo.rs → bar.rs. The cluster should track + // the canonical (earliest) path — foo.rs. let c1 = CommitDiff { commit: make_commit_info_with_oid("c1"), files: vec![FileDiff { @@ -2266,7 +2254,43 @@ mod tests { }; let fm = build_fragmap(&[c1], true); assert_eq!(fm.clusters.len(), 1); - assert_eq!(fm.clusters[0].spans[0].path, "bar.rs"); + assert_eq!(fm.clusters[0].spans[0].path, "foo.rs"); + } + + #[test] + fn build_fragmap_rename_groups_old_and_new_in_same_cluster() { + // Commit 0 touches foo.rs lines 1-10. + // Commit 1 renames foo.rs → bar.rs and modifies overlapping lines 5-12. + // Both should land in the same cluster because the rename map links + // bar.rs back to the canonical name foo.rs. + let c0 = make_commit_diff( + "c0", + vec![make_file_diff(Some("foo.rs"), Some("foo.rs"), 1, 0, 1, 10)], + ); + let c1 = CommitDiff { + commit: make_commit_info_with_oid("c1"), + files: vec![FileDiff { + old_path: Some("foo.rs".to_string()), + new_path: Some("bar.rs".to_string()), + status: crate::DeltaStatus::Modified, + hunks: vec![Hunk { + old_start: 5, + old_lines: 6, + new_start: 5, + new_lines: 8, + lines: vec![], + }], + }], + }; + let fm = build_fragmap(&[c0, c1], true); + // Both commits share a cluster — the overlapping region groups them. + assert!( + fm.clusters.iter().any(|cl| { + cl.commit_oids.contains(&"c0".to_string()) + && cl.commit_oids.contains(&"c1".to_string()) + }), + "expected c0 and c1 to share a cluster via rename tracking" + ); } #[test] diff --git a/src/fragmap/spg.rs b/src/fragmap/spg.rs index 24e08ac..ed16a65 100644 --- a/src/fragmap/spg.rs +++ b/src/fragmap/spg.rs @@ -666,36 +666,8 @@ pub(super) fn enumerate_file_spg_paths( /// Diagnostic: dump per-file SPG stats (for debugging, not used in production). #[doc(hidden)] pub fn dump_per_file_spg_stats(commit_diffs: &[CommitDiff]) { - let mut file_commits: HashMap)>> = HashMap::new(); - - for (commit_idx, diff) in commit_diffs.iter().enumerate() { - for file in &diff.files { - let path = match &file.new_path { - Some(p) => p.clone(), - None => continue, - }; - let hunks: Vec = file - .hunks - .iter() - .map(|h| HunkInfo { - old_start: h.old_start, - old_lines: h.old_lines, - new_start: h.new_start, - new_lines: h.new_lines, - }) - .collect(); - if !hunks.is_empty() { - let entry = file_commits.entry(path).or_default(); - if let Some(last) = entry.last_mut() - && last.0 == commit_idx - { - last.1.extend(hunks); - continue; - } - entry.push((commit_idx, hunks)); - } - } - } + let file_commits = + super::collect_file_commits(commit_diffs, &super::build_rename_map(commit_diffs)); let mut sorted_paths: Vec<&String> = file_commits.keys().collect(); sorted_paths.sort(); diff --git a/src/repo/git2_impl.rs b/src/repo/git2_impl.rs index 337b4a7..e92f872 100644 --- a/src/repo/git2_impl.rs +++ b/src/repo/git2_impl.rs @@ -148,10 +148,12 @@ impl GitRepo for Git2Repo { opts.context_lines(0); opts.interhunk_lines(0); - let diff = + let mut diff = self.inner .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))?; + diff.find_similar(None)?; + extract_commit_diff(&diff, &commit) } @@ -1036,10 +1038,7 @@ impl GitRepo for Git2Repo { } fn default_branch(&self) -> Option { - let reference = self - .inner - .find_reference("refs/remotes/origin/HEAD") - .ok()?; + let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?; let target = reference.symbolic_target()?; // Strip the "refs/remotes/" prefix so the caller can pass the result // directly to find_reference_point (e.g. "origin/main"). diff --git a/tests/commit_diff.rs b/tests/commit_diff.rs index 0f89f17..12097f6 100644 --- a/tests/commit_diff.rs +++ b/tests/commit_diff.rs @@ -182,3 +182,33 @@ fn test_commit_diff_metadata() { assert!(diff.commit.date.as_deref().is_some_and(|d| !d.is_empty())); assert_eq!(diff.commit.parent_oids.len(), 0); } + +/// `commit_diff_for_fragmap` should detect renames via `find_similar` +/// and produce a single `FileDiff` with `old_path ≠ new_path`. +#[test] +fn test_commit_diff_for_fragmap_detects_rename() { + let test = common::TestRepo::new(); + // Need an initial commit so the rename commit has a parent. + test.commit_file("old_name.rs", "line 1\nline 2\nline 3\n", "initial"); + let c2 = test.rename_file( + "old_name.rs", + "new_name.rs", + Some("line 1\nline 2 modified\nline 3\n"), + "rename file", + ); + + let diff = test + .git_repo() + .commit_diff_for_fragmap(&c2.to_string()) + .unwrap(); + + // find_similar should collapse the delete+add into a single renamed entry + assert_eq!( + diff.files.len(), + 1, + "expected single file delta after rename" + ); + let f = &diff.files[0]; + assert_eq!(f.old_path.as_deref(), Some("old_name.rs")); + assert_eq!(f.new_path.as_deref(), Some("new_name.rs")); +} diff --git a/tests/common.rs b/tests/common.rs index 2ffb089..a302efc 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -151,6 +151,51 @@ impl TestRepo { .unwrap() } + /// Rename a file and optionally modify its content in a single commit. + /// If `new_content` is `None`, the file keeps its current content. + #[allow(dead_code)] + pub fn rename_file( + &self, + old_path: &str, + new_path: &str, + new_content: Option<&str>, + message: &str, + ) -> git2::Oid { + let repo_path = self.repo.workdir().unwrap(); + let old_file = repo_path.join(old_path); + let new_file = repo_path.join(new_path); + + let content = match new_content { + Some(c) => c.to_string(), + None => fs::read_to_string(&old_file).unwrap(), + }; + + fs::remove_file(&old_file).unwrap(); + if let Some(parent) = new_file.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&new_file, content).unwrap(); + + let mut index = self.repo.index().unwrap(); + index.remove_path(std::path::Path::new(old_path)).unwrap(); + index.add_path(std::path::Path::new(new_path)).unwrap(); + index.write().unwrap(); + + let tree_oid = index.write_tree().unwrap(); + let tree = self.repo.find_tree(tree_oid).unwrap(); + + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let parent_commit = self.repo.head().unwrap(); + let parent = self + .repo + .find_commit(parent_commit.target().unwrap()) + .unwrap(); + + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]) + .unwrap() + } + #[allow(dead_code)] pub fn create_branch(&self, name: &str, target: git2::Oid) { let commit = self.repo.find_commit(target).unwrap(); diff --git a/tests/static_fragmap.rs b/tests/static_fragmap.rs index 21391ef..38e425e 100644 --- a/tests/static_fragmap.rs +++ b/tests/static_fragmap.rs @@ -290,3 +290,46 @@ fn test_squashable_scope_commit_stricter_than_group() { "Commit scope: B row should have conflicting connector (|), got: {row_b_commit:?}" ); } + +/// When a file is renamed between commits, overlapping spans should cluster +/// together — the rename should not break the relation. +#[test] +fn test_rename_clusters_with_original_file() { + // Commit 0: adds lines 1-10 in "src/old.rs" + let c0 = common::create_test_commit_diff("aaaa00001111", "Add old.rs", "src/old.rs", (1, 10)); + + // Commit 1: renames "src/old.rs" → "src/new.rs" and modifies overlapping lines 5-12 + let c1 = CommitDiff { + commit: common::create_test_commit("bbbb22223333", "Rename old to new"), + files: vec![FileDiff { + old_path: Some("src/old.rs".to_string()), + new_path: Some("src/new.rs".to_string()), + status: DeltaStatus::Modified, + hunks: vec![Hunk { + old_start: 5, + old_lines: 6, + new_start: 5, + new_lines: 8, + lines: vec![], + }], + }], + }; + + let output = plain(&[c0, c1]); + insta::assert_snapshot!(output); + + // The rename map links "src/new.rs" back to "src/old.rs", so the SPG + // processes both commits under the same file. The overlapping portion + // (lines 5-10) forms a shared cluster; the non-overlapping part of c0 + // (lines 1-4) forms a second cluster touched only by c0. + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 2, "expected exactly 2 rows"); + + // c1 (rename commit) must touch at least one cluster, proving it was + // grouped with c0's file instead of being isolated. + let c1_matrix = &lines[1][35..].trim(); + assert!( + c1_matrix.contains('#'), + "rename commit should touch a shared cluster: {c1_matrix:?}" + ); +} From 7f4702c9fe715822b797c84546c12dd38bdc2523 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Wed, 18 Mar 2026 17:30:41 +0000 Subject: [PATCH 06/19] tasks: Add T131-T133 fixup conflict bugs --- TASKS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/TASKS.md b/TASKS.md index 2985325..57f6de9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -13,6 +13,33 @@ Guidelines: ## UNCATEGORIZED +## Bug Fixes — Squash & Fixup +- [ ] T131 P1 bug - Fixup conflict resolution incorrectly opens commit message + editor: when a fixup operation causes a conflict in the squash tree itself and + the user resolves it, `RebaseContinue` in `main.rs` always opens the editor + for the commit message (via `squash_finalize`) regardless of whether the + operation was a squash or a fixup; the `SquashContext` needs an `is_fixup` + field (or equivalent) so that the editor is skipped and the target message is + used as-is when finalizing a fixup, mirroring the non-conflict path in + `PrepareSquash` +- [ ] T132 P1 bug - Fixup conflict falsely reported as still unresolved: after + the user resolves a conflict during a fixup (either manually or via mergetool) + and presses Enter to continue, `rebase_continue` in `git2_impl.rs` re-reads + the index with `index.read(true)` and calls `index.has_conflicts()`, which + returns true even though the working-tree file has been correctly resolved and + staged; investigate whether libgit2's in-memory index is not being refreshed + from disk before the `has_conflicts()` check, or whether deleted-file + conflicts leave behind phantom stage entries, and fix so that a genuinely + resolved index is not incorrectly treated as unresolved +- [ ] T133 P1 bug - Aborting a fixup after a conflict leaves dirty working tree: + `rebase_abort` in `git2_impl.rs` resets the branch ref and calls + `checkout_head()`, but this does not clean up untracked files or staged + deletions that were left behind by the failed cherry-pick (e.g. a file that + was deleted in the conflict appears as a staged deletion and also as an + untracked file after the abort); the abort should additionally clean untracked + files and reset the index so the working tree matches HEAD, similar to what + `git checkout -f HEAD` followed by `git clean -fd` would do + ## Interactivity — Fragmap View (V5) - [X] T108 P1 fix - Fix fragmap relations not following file renames: when a file is renamed across commits, spans should cluster together if they overlap From 045aa12c6e83b92bc35726f7adc1625ab82dded4 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Wed, 18 Mar 2026 17:44:56 +0000 Subject: [PATCH 07/19] fix: Skip commit message editor after fixup conflict resolution (T131) --- CHANGELOG.md | 3 ++ TASKS.md | 2 +- src/main.rs | 90 +++++++++++++++++++++++++------------- src/repo.rs | 11 ++++- src/repo/git2_impl.rs | 7 ++- tests/squash_commit.rs | 21 ++++++++- tests/tui_commit_detail.rs | 2 + tests/tui_main_view.rs | 1 + 8 files changed, 100 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8356887..37c4059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The format is based on ### Fixed +- Fixup operations that hit a squash-tree conflict no longer open the commit + message editor after the conflict is resolved — the target commit's message + is used as-is, matching the behavior of a conflict-free fixup. - Fragmap now tracks file renames across commits: when a file is renamed, overlapping spans in the old and new paths are correctly clustered together instead of being treated as unrelated files. diff --git a/TASKS.md b/TASKS.md index 57f6de9..0e7840b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -14,7 +14,7 @@ Guidelines: ## UNCATEGORIZED ## Bug Fixes — Squash & Fixup -- [ ] T131 P1 bug - Fixup conflict resolution incorrectly opens commit message +- [X] T131 P1 bug - Fixup conflict resolution incorrectly opens commit message editor: when a fixup operation causes a conflict in the squash tree itself and the user resolves it, `RebaseContinue` in `main.rs` always opens the editor for the commit message (via `squash_finalize`) regardless of whether the diff --git a/src/main.rs b/src/main.rs index 3f62b2c..ebe5378 100644 --- a/src/main.rs +++ b/src/main.rs @@ -303,10 +303,9 @@ fn main() -> Result<()> { } AppAction::RebaseContinue(state) => { // Squash-time tree conflict: the user has resolved the - // combined tree. Open the editor for the commit message, - // then finalize. + // combined tree. For squash, open the editor; for fixup, + // use the stored target message directly. if let Some(ref ctx) = state.squash_context { - let combined = ctx.combined_message.clone(); let original_oid = state.original_branch_oid.clone(); let ctx_clone = ctx.clone(); @@ -322,36 +321,52 @@ fn main() -> Result<()> { continue; } - let editor_result = editor::edit_message_in_editor(&git_repo, &combined); - terminal.clear()?; - match editor_result { - Err(e) => { - let _ = git_repo.rebase_abort(&state); - reload_commits(&git_repo, &mut app); - app.set_error_message(format!("Editor error: {e}")); + // Fixup: skip the editor and use the stored target message. + // Squash: open the editor with the combined message. + let final_msg = if ctx.is_fixup { + ctx.combined_message.clone() + } else { + let combined = ctx.combined_message.clone(); + let editor_result = editor::edit_message_in_editor(&git_repo, &combined); + terminal.clear()?; + match editor_result { + Err(e) => { + let _ = git_repo.rebase_abort(&state); + reload_commits(&git_repo, &mut app); + app.set_error_message(format!("Editor error: {e}")); + continue; + } + Ok(msg) if msg.trim().is_empty() => { + let _ = git_repo.rebase_abort(&state); + reload_commits(&git_repo, &mut app); + let label = &state.operation_label; + app.set_error_message(format!( + "{label} aborted: empty commit message" + )); + continue; + } + Ok(msg) => msg, } - Ok(msg) if msg.trim().is_empty() => { - let _ = git_repo.rebase_abort(&state); + }; + + let saved_index = app.selection_index; + match git_repo.squash_finalize(&ctx_clone, &final_msg, &original_oid) { + Ok(RebaseOutcome::Complete) => { reload_commits(&git_repo, &mut app); - let label = &state.operation_label; - app.set_error_message(format!("{label} aborted: empty commit message")); + app.selection_index = + saved_index.min(app.commits.len().saturating_sub(1)); + let success_msg = if ctx_clone.is_fixup { + "Commit fixed up" + } else { + "Commits squashed" + }; + app.set_success_message(success_msg); } - Ok(msg) => { - let saved_index = app.selection_index; - match git_repo.squash_finalize(&ctx_clone, &msg, &original_oid) { - Ok(RebaseOutcome::Complete) => { - reload_commits(&git_repo, &mut app); - app.selection_index = - saved_index.min(app.commits.len().saturating_sub(1)); - app.set_success_message("Commits squashed"); - } - Ok(RebaseOutcome::Conflict(new_state)) => { - app.enter_rebase_conflict(*new_state); - } - Err(e) => { - app.set_error_message(format!("Squash failed: {e}")); - } - } + Ok(RebaseOutcome::Conflict(new_state)) => { + app.enter_rebase_conflict(*new_state); + } + Err(e) => { + app.set_error_message(format!("Squash failed: {e}")); } } continue; @@ -457,11 +472,24 @@ fn main() -> Result<()> { }; let label = if is_fixup { "Fixup" } else { "Squash" }; + // For fixup, use only the target message; for squash use the + // combined message so it is shown in the editor. + let message_for_context = if is_fixup { + target_message.clone() + } else { + format!("{target_message}\n\n{source_message}") + }; let combined = format!("{target_message}\n\n{source_message}"); // Try the tree combination first. If it conflicts, let the // user resolve before opening the editor (T080). - match git_repo.squash_try_combine(&source_oid, &target_oid, &combined, &head_oid) { + match git_repo.squash_try_combine( + &source_oid, + &target_oid, + &message_for_context, + is_fixup, + &head_oid, + ) { Ok(Some(conflict_state)) => { app.enter_rebase_conflict(conflict_state); continue; diff --git a/src/repo.rs b/src/repo.rs index 98a7fb4..448aabb 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -80,10 +80,15 @@ pub struct SquashContext { pub source_oid: String, /// OID of the target commit (author/committer are taken from here). pub target_oid: String, - /// The combined default message (target + source), shown in the editor. + /// The message to use for the squash commit. For squash this is the + /// combined (target + source) message shown in the editor; for fixup + /// this is just the target message, used as-is without opening an editor. pub combined_message: String, /// OIDs of descendants to rebase after the squash commit is created. pub descendant_oids: Vec, + /// When true the operation is a fixup: the editor is skipped and + /// `combined_message` (the target message) is used directly. + pub is_fixup: bool, } /// Abstraction over git repository operations. @@ -305,12 +310,14 @@ pub trait GitRepo { /// Returns `Ok(Some(ConflictState))` when the cherry-pick conflicts. The /// conflict is written to the working tree and index. The `ConflictState` /// carries a `SquashContext` so the TUI can let the user resolve, then - /// open the editor, then call `squash_finalize`. + /// (for squash) open the editor and call `squash_finalize`, or (for fixup) + /// call `squash_finalize` directly without opening the editor. fn squash_try_combine( &self, source_oid: &str, target_oid: &str, combined_message: &str, + is_fixup: bool, head_oid: &str, ) -> Result>; diff --git a/src/repo/git2_impl.rs b/src/repo/git2_impl.rs index e92f872..9bcfbec 100644 --- a/src/repo/git2_impl.rs +++ b/src/repo/git2_impl.rs @@ -962,6 +962,7 @@ impl GitRepo for Git2Repo { target_oid: target_oid.to_string(), combined_message: message.to_string(), descendant_oids: descendants, + is_fixup: false, }), }, ))); @@ -1050,6 +1051,7 @@ impl GitRepo for Git2Repo { source_oid: &str, target_oid: &str, combined_message: &str, + is_fixup: bool, head_oid: &str, ) -> Result> { self.check_no_dirty_state()?; @@ -1086,8 +1088,10 @@ impl GitRepo for Git2Repo { self.write_conflicts_to_workdir(&cherry_index, &target_commit)?; + let operation_label = if is_fixup { "Fixup" } else { "Squash" }.to_string(); + Ok(Some(super::ConflictState { - operation_label: "Squash".to_string(), + operation_label, original_branch_oid, new_tip_oid: target_git_oid.to_string(), conflicting_commit_oid: source_git_oid.to_string(), @@ -1101,6 +1105,7 @@ impl GitRepo for Git2Repo { target_oid: target_oid.to_string(), combined_message: combined_message.to_string(), descendant_oids: descendants, + is_fixup, }), })) } diff --git a/tests/squash_commit.rs b/tests/squash_commit.rs index 1c554e5..131decb 100644 --- a/tests/squash_commit.rs +++ b/tests/squash_commit.rs @@ -402,7 +402,13 @@ fn squash_try_combine_returns_none_when_clean() { let head = git_repo.head_oid().unwrap(); let result = git_repo - .squash_try_combine(&source.to_string(), &target.to_string(), "combined", &head) + .squash_try_combine( + &source.to_string(), + &target.to_string(), + "combined", + false, + &head, + ) .unwrap(); assert!(result.is_none(), "clean merge should return None"); @@ -425,6 +431,7 @@ fn squash_try_combine_returns_conflict_state() { &source.to_string(), &target.to_string(), "combined msg", + false, &head, ) .unwrap() @@ -454,7 +461,13 @@ fn squash_finalize_after_conflict_resolution() { // Step 1: try combine -> conflict let state = git_repo - .squash_try_combine(&source.to_string(), &target.to_string(), "combined", &head) + .squash_try_combine( + &source.to_string(), + &target.to_string(), + "combined", + false, + &head, + ) .unwrap() .expect("should conflict"); @@ -473,6 +486,7 @@ fn squash_finalize_after_conflict_resolution() { target_oid: target.to_string(), combined_message: "combined".to_string(), descendant_oids: vec![], + is_fixup: false, }; let result = git_repo @@ -591,6 +605,7 @@ fn squash_try_combine_blocked_with_staged_changes() { &source.to_string(), &target.to_string(), "combined", + false, &source.to_string(), ); @@ -622,6 +637,7 @@ fn squash_try_combine_blocked_with_unstaged_changes() { &source.to_string(), &target.to_string(), "combined", + false, &source.to_string(), ); @@ -681,6 +697,7 @@ fn squash_try_combine_allowed_with_staged_submodule() { &source.to_string(), &target.to_string(), "squashed", + false, &source.to_string(), ) .unwrap(); diff --git a/tests/tui_commit_detail.rs b/tests/tui_commit_detail.rs index 63b32f3..302c50b 100644 --- a/tests/tui_commit_detail.rs +++ b/tests/tui_commit_detail.rs @@ -128,6 +128,7 @@ impl GitRepo for NoOpRepo { _source_oid: &str, _target_oid: &str, _combined_message: &str, + _is_fixup: bool, _head_oid: &str, ) -> Result> { unimplemented!() @@ -248,6 +249,7 @@ impl GitRepo for FakeDiffRepo { _source_oid: &str, _target_oid: &str, _combined_message: &str, + _is_fixup: bool, _head_oid: &str, ) -> Result> { unimplemented!() diff --git a/tests/tui_main_view.rs b/tests/tui_main_view.rs index 098f1c2..9d7e79f 100644 --- a/tests/tui_main_view.rs +++ b/tests/tui_main_view.rs @@ -124,6 +124,7 @@ impl GitRepo for NoOpRepo { _source_oid: &str, _target_oid: &str, _combined_message: &str, + _is_fixup: bool, _head_oid: &str, ) -> Result> { unimplemented!() From 78879931b05eff25a09fc0dd02b2593b23ba8003 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Wed, 18 Mar 2026 18:04:14 +0000 Subject: [PATCH 08/19] fix: stage_file handles deleted files in conflict resolution (T132) --- CHANGELOG.md | 4 +++ TASKS.md | 2 +- src/repo/git2_impl.rs | 23 ++++++++++++-- tests/mergetool.rs | 70 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c4059..9e18bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The format is based on - Fixup operations that hit a squash-tree conflict no longer open the commit message editor after the conflict is resolved — the target commit's message is used as-is, matching the behavior of a conflict-free fixup. +- Resolving a conflict that involves a deleted or renamed file (modify/delete + conflict) is no longer falsely reported as still unresolved: `stage_file` + now correctly stages the deletion when the file is absent from the working + tree instead of failing with "file not found". - Fragmap now tracks file renames across commits: when a file is renamed, overlapping spans in the old and new paths are correctly clustered together instead of being treated as unrelated files. diff --git a/TASKS.md b/TASKS.md index 0e7840b..ac544c6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -22,7 +22,7 @@ Guidelines: field (or equivalent) so that the editor is skipped and the target message is used as-is when finalizing a fixup, mirroring the non-conflict path in `PrepareSquash` -- [ ] T132 P1 bug - Fixup conflict falsely reported as still unresolved: after +- [X] T132 P1 bug - Fixup conflict falsely reported as still unresolved: after the user resolves a conflict during a fixup (either manually or via mergetool) and presses Enter to continue, `rebase_continue` in `git2_impl.rs` re-reads the index with `index.read(true)` and calls `index.has_conflicts()`, which diff --git a/src/repo/git2_impl.rs b/src/repo/git2_impl.rs index 9bcfbec..25a07ed 100644 --- a/src/repo/git2_impl.rs +++ b/src/repo/git2_impl.rs @@ -1029,9 +1029,26 @@ impl GitRepo for Git2Repo { index .read(true) .context("failed to refresh index from disk")?; - index - .add_path(std::path::Path::new(path)) - .with_context(|| format!("failed to stage '{path}'"))?; + + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?; + + if workdir.join(path).exists() { + // File is present — add it to clear conflict stages and create a + // normal stage-0 entry. + index + .add_path(std::path::Path::new(path)) + .with_context(|| format!("failed to stage '{path}'"))?; + } else { + // File was deleted — remove all index entries for this path + // (stages 0, 1, 2, 3) so the deletion is staged and no phantom + // conflict entries remain. + index + .remove_path(std::path::Path::new(path)) + .with_context(|| format!("failed to remove '{path}' from index"))?; + } + index .write() .context("failed to write index after staging")?; diff --git a/tests/mergetool.rs b/tests/mergetool.rs index bdf17ee..724f5e4 100644 --- a/tests/mergetool.rs +++ b/tests/mergetool.rs @@ -443,3 +443,73 @@ fn stage_file_and_check_content_matches_written_file() { "no conflicts should remain after staging" ); } + +/// When a conflict involves a deleted file (e.g. a fixup whose source commit +/// deletes a file that target also modified), the resolved state is that the +/// file is absent from the working tree. Calling `stage_file` on a path that +/// no longer exists must clear the conflict entries rather than failing or +/// leaving phantom stage entries. +#[test] +fn stage_file_clears_conflict_for_deleted_file() { + // Scenario for a genuine modify/delete conflict: + // + // _base: a.txt = "original\n" + // target: a.txt = "target-version\n" (parent = _base) + // _intermediate: a.txt = "intermediate\n" (parent = target) + // _source: a.txt deleted (parent = _intermediate) + // + // cherry-pick(_source, target) 3-way merge: + // ancestor = _intermediate (a.txt = "intermediate\n") + // ours = target (a.txt = "target-version\n") + // theirs = _source (a.txt absent) + // + // ancestor != ours → modify/delete conflict. + let test = common::TestRepo::new(); + let _base = test.commit_file("a.txt", "original\n", "base"); + let target = test.commit_file("a.txt", "target-version\n", "target modifies a"); + let _intermediate = test.commit_file("a.txt", "intermediate\n", "intermediate modifies a"); + let _source = test.delete_file("a.txt", "source deletes a"); + + let git_repo = test.git_repo(); + let head = git_repo.head_oid().unwrap(); + + // squash_try_combine should detect a conflict. + let state = git_repo + .squash_try_combine( + &_source.to_string(), + &target.to_string(), + "combined", + false, + &head, + ) + .unwrap() + .expect("delete/modify should conflict"); + + assert!( + !state.conflicting_files.is_empty(), + "should report conflicting files" + ); + let conflict_path = &state.conflicting_files[0]; + + // The user resolves by accepting the deletion: the file is absent from + // the working tree (checkout_index with allow_conflicts may or may not + // write the file — for a delete/modify conflict libgit2 may leave it + // with conflict markers or absent). Either way, remove it and stage. + let workdir = git_repo.workdir().unwrap(); + let abs_path = workdir.join(conflict_path); + if abs_path.exists() { + std::fs::remove_file(&abs_path).unwrap(); + } + + // This is the call under test. It must succeed and clear all conflict + // entries even though the file does not exist on disk. + git_repo + .stage_file(conflict_path) + .expect("stage_file should succeed for a deleted file"); + + let remaining = git_repo.read_conflicting_files(); + assert!( + remaining.is_empty(), + "no conflicts should remain after staging a deletion: {remaining:?}" + ); +} From 7029621106d8ea8d50843f483264fed051d5a477 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Wed, 18 Mar 2026 18:28:40 +0000 Subject: [PATCH 09/19] test: add regression test for clean workdir after squash/fixup abort (T131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T133 was diagnosed as fixed by the T132 fix: libgit2's checkout_head(force) already resets both the index and workdir to HEAD, including removing files written to the workdir during conflict checkout that are absent from HEAD's tree. The dirty-workdir symptom was a consequence of stage_file leaving the index in a corrupt partial state, which T132 resolved. Add squash_abort_leaves_clean_working_tree to confirm that after aborting a conflicted squash, no staged changes, unstaged changes, or untracked files remain — even when the source commit introduced a new file (b.txt) that was written to the workdir by the conflict checkout but is absent from the original HEAD. --- CHANGELOG.md | 14 +++-- TASKS.md | 8 ++- tests/drop_commit.rs | 75 +++++++++++++++++++++++++++ tests/squash_commit.rs | 115 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e18bf1..430a031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,16 @@ The format is based on ### Fixed - Fixup operations that hit a squash-tree conflict no longer open the commit - message editor after the conflict is resolved — the target commit's message - is used as-is, matching the behavior of a conflict-free fixup. + message editor after the conflict is resolved — the target commit's message is + used as-is, matching the behavior of a conflict-free fixup. - Resolving a conflict that involves a deleted or renamed file (modify/delete - conflict) is no longer falsely reported as still unresolved: `stage_file` - now correctly stages the deletion when the file is absent from the working - tree instead of failing with "file not found". + conflict) is no longer falsely reported as still unresolved: `stage_file` now + correctly stages the deletion when the file is absent from the working tree + instead of failing with "file not found". +- Aborting a squash or fixup after a conflict now correctly leaves a clean + working tree: `checkout_head` (force) already resets both the index and + workdir to HEAD, including removing any files written during conflict checkout + that are absent from HEAD's tree. - Fragmap now tracks file renames across commits: when a file is renamed, overlapping spans in the old and new paths are correctly clustered together instead of being treated as unrelated files. diff --git a/TASKS.md b/TASKS.md index ac544c6..4ae494d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -31,14 +31,18 @@ Guidelines: from disk before the `has_conflicts()` check, or whether deleted-file conflicts leave behind phantom stage entries, and fix so that a genuinely resolved index is not incorrectly treated as unresolved -- [ ] T133 P1 bug - Aborting a fixup after a conflict leaves dirty working tree: +- [X] T133 P1 bug - Aborting a fixup after a conflict leaves dirty working tree: `rebase_abort` in `git2_impl.rs` resets the branch ref and calls `checkout_head()`, but this does not clean up untracked files or staged deletions that were left behind by the failed cherry-pick (e.g. a file that was deleted in the conflict appears as a staged deletion and also as an untracked file after the abort); the abort should additionally clean untracked files and reset the index so the working tree matches HEAD, similar to what - `git checkout -f HEAD` followed by `git clean -fd` would do + `git checkout -f HEAD` followed by `git clean -fd` would do (fixed by T130: + libgit2's `checkout_head(force)` already resets both the index and workdir to + HEAD, including files absent from HEAD's tree; the dirty-workdir symptom was a + consequence of T130's `stage_file` bug leaving the index in a corrupt state; + integration test added to confirm) ## Interactivity — Fragmap View (V5) - [X] T108 P1 fix - Fix fragmap relations not following file renames: when a diff --git a/tests/drop_commit.rs b/tests/drop_commit.rs index faecb04..d237acc 100644 --- a/tests/drop_commit.rs +++ b/tests/drop_commit.rs @@ -562,3 +562,78 @@ fn drop_commit_allowed_with_staged_submodule() { "drop should succeed when only a submodule pointer is staged; got {result:?}" ); } + +/// After aborting a conflicted operation, the working tree must be completely +/// clean — no staged changes, no unstaged changes, and no untracked files left +/// behind by the conflict checkout. +#[test] +fn rebase_abort_leaves_clean_working_tree() { + // Scenario: drop a commit whose descendant modifies a file that the + // dropped commit also touched. This creates a conflict where the + // working tree is dirtied (conflict markers written). + let test = common::TestRepo::new(); + + let _base = test.commit_file("a.txt", "base\n", "base"); + let to_drop = test.commit_file("a.txt", "base\ndropped\n", "add dropped line"); + let head = test.commit_file("a.txt", "base\ndropped\nhead\n", "add head line"); + + let git_repo = test.git_repo(); + let result = git_repo + .drop_commit(&to_drop.to_string(), &head.to_string()) + .unwrap(); + + let state = match result { + RebaseOutcome::Conflict(s) => s, + RebaseOutcome::Complete => panic!("expected Conflict"), + }; + + // Abort — must restore branch and leave a clean working tree. + git_repo.rebase_abort(&state).unwrap(); + + // Branch ref is restored. + let current_head = test.repo.head().unwrap().target().unwrap(); + assert_eq!(current_head, head, "HEAD should be restored after abort"); + + // No staged changes. + let head_tree = test.repo.head().unwrap().peel_to_tree().unwrap(); + let staged_diff = test + .repo + .diff_tree_to_index(Some(&head_tree), None, None) + .unwrap(); + assert_eq!( + staged_diff.deltas().len(), + 0, + "no staged changes should remain after abort: {:?}", + staged_diff + .deltas() + .map(|d| d.new_file().path().unwrap().display().to_string()) + .collect::>() + ); + + // No unstaged changes. + let unstaged_diff = test.repo.diff_index_to_workdir(None, None).unwrap(); + assert_eq!( + unstaged_diff.deltas().len(), + 0, + "no unstaged changes should remain after abort: {:?}", + unstaged_diff + .deltas() + .map(|d| d.new_file().path().unwrap().display().to_string()) + .collect::>() + ); + + // No untracked files left behind by the conflict checkout. + let mut status_opts = git2::StatusOptions::new(); + status_opts.include_untracked(true); + status_opts.recurse_untracked_dirs(true); + let statuses = test.repo.statuses(Some(&mut status_opts)).unwrap(); + let untracked: Vec<_> = statuses + .iter() + .filter(|e| e.status().contains(git2::Status::WT_NEW)) + .map(|e| e.path().unwrap_or("?").to_string()) + .collect(); + assert!( + untracked.is_empty(), + "no untracked files should remain after abort: {untracked:?}" + ); +} diff --git a/tests/squash_commit.rs b/tests/squash_commit.rs index 131decb..be040d2 100644 --- a/tests/squash_commit.rs +++ b/tests/squash_commit.rs @@ -707,3 +707,118 @@ fn squash_try_combine_allowed_with_staged_submodule() { "squash_try_combine should succeed (no conflict) when only a submodule pointer is staged" ); } + +/// After aborting a conflicted squash, the working tree must be completely +/// clean — no staged changes, no unstaged changes, and no untracked files +/// (e.g. files written to the workdir during conflict checkout that are not +/// present in HEAD). +#[test] +fn squash_abort_leaves_clean_working_tree() { + // Scenario: squash a source commit (which adds an extra file b.txt) onto + // a target commit, producing a modify/modify conflict on a.txt. The + // conflict checkout writes b.txt to the workdir. The original HEAD + // (_base) has no b.txt, so after abort checkout_head must clean it up. + // + // _base: a.txt = "base" ← original HEAD (head_oid) + // target: a.txt = "target" ← squash target / onto_commit + // _mid: a.txt = "mid" ← source's parent + // source: a.txt = "source", b.txt = "new" ← squash source + // + // cherry-pick(source, target): ancestor=_mid, ours=target, theirs=source + // a.txt: both changed from "mid" → modify/modify conflict (stages 1,2,3) + // b.txt: only theirs added → stage 0, written to workdir by checkout_index + // + // After abort restoring HEAD to _base (no b.txt), checkout_head must + // remove b.txt from the workdir (requires remove_untracked in the + // checkout options — the bug is that it was missing). + let test = common::TestRepo::new(); + let _base = test.commit_file("a.txt", "base\n", "base — will be the original HEAD"); + let target = test.commit_file("a.txt", "target\n", "target modifies a"); + let _mid = test.commit_file("a.txt", "mid\n", "mid — parent of source"); + let source = test.commit_files( + &[("a.txt", "source\n"), ("b.txt", "new file from source\n")], + "source modifies a and adds b", + ); + + let git_repo = test.git_repo(); + + // Pass _base as head_oid so rebase_abort restores the branch there. + // _base has only a.txt; b.txt is absent from it. + let state = git_repo + .squash_try_combine( + &source.to_string(), + &target.to_string(), + "combined", + false, + &_base.to_string(), + ) + .unwrap() + .expect("a.txt conflict (modify/modify) should be detected"); + + assert!( + !state.conflicting_files.is_empty(), + "should report conflicting files" + ); + + // Verify b.txt was actually written to workdir during conflict checkout + // (this confirms the scenario exercises the code path where a file not + // in _base ends up in the workdir, so cleanup on abort is necessary). + let workdir = git_repo.workdir().unwrap(); + assert!( + workdir.join("b.txt").exists(), + "b.txt should be present in workdir after conflict checkout (pre-abort)" + ); + + // Abort without resolving anything. + git_repo.rebase_abort(&state).unwrap(); + + // Branch ref is restored to _base. + let current_head = test.repo.head().unwrap().target().unwrap(); + assert_eq!( + current_head, _base, + "HEAD should be restored to _base after abort" + ); + + // No staged changes (index matches HEAD). + let head_tree = test.repo.head().unwrap().peel_to_tree().unwrap(); + let staged_diff = test + .repo + .diff_tree_to_index(Some(&head_tree), None, None) + .unwrap(); + assert_eq!( + staged_diff.deltas().len(), + 0, + "no staged changes should remain after abort: {:?}", + staged_diff + .deltas() + .map(|d| d.new_file().path().unwrap().display().to_string()) + .collect::>() + ); + + // No unstaged changes. + let unstaged_diff = test.repo.diff_index_to_workdir(None, None).unwrap(); + assert_eq!( + unstaged_diff.deltas().len(), + 0, + "no unstaged changes should remain after abort: {:?}", + unstaged_diff + .deltas() + .map(|d| d.new_file().path().unwrap().display().to_string()) + .collect::>() + ); + + // No untracked files (e.g. b.txt written to workdir by conflict checkout). + let mut status_opts = git2::StatusOptions::new(); + status_opts.include_untracked(true); + status_opts.recurse_untracked_dirs(true); + let statuses = test.repo.statuses(Some(&mut status_opts)).unwrap(); + let untracked: Vec<_> = statuses + .iter() + .filter(|e| e.status().contains(git2::Status::WT_NEW)) + .map(|e| e.path().unwrap_or("?").to_string()) + .collect(); + assert!( + untracked.is_empty(), + "no untracked files should remain after abort: {untracked:?}" + ); +} From a0774f240665c286c96417fa6673104ea1eac6cd Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Thu, 19 Mar 2026 08:42:50 +0100 Subject: [PATCH 10/19] feat: Add -V to show version number and show version in --help --- src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index ebe5378..3c0df55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,11 @@ use std::io; /// An interactive terminal tool for tidying up Git commits on a branch. #[derive(Parser)] -#[command(name = "gt")] +#[command( + //name = "gt", + version, + help_template = "{name} {version}\n{about-with-newline}\n{usage-heading} {usage}\n\n{all-args}{after-help}" +)] struct Cli { /// A commit-ish to use as the base reference (branch, tag, or hash). /// From 25dff8804c4c52e0d7fdec003a0e55fc407134ff Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 17:23:28 +0000 Subject: [PATCH 11/19] task: Add T134 external editor conflict resolution not detected during squash/fixup --- TASKS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TASKS.md b/TASKS.md index 4ae494d..5532727 100644 --- a/TASKS.md +++ b/TASKS.md @@ -43,6 +43,15 @@ Guidelines: HEAD, including files absent from HEAD's tree; the dirty-workdir symptom was a consequence of T130's `stage_file` bug leaving the index in a corrupt state; integration test added to confirm) +- [ ] T134 P1 bug - External editor conflict resolution not detected during + squash/fixup: when a conflict occurs during squash or fixup and the user + resolves it by editing the conflicted file in an external editor (e.g. VS + Code) and saving, git-tailor does not detect the resolution; opening the + built-in mergetool afterward still shows the original conflict markers as if + the external edits were ignored; resolving via the built-in mergetool works + correctly; the likely cause is that git-tailor reads the file content from + git2's in-memory state or a cached copy rather than re-reading from the + working tree on disk when checking conflict status or launching the mergetool ## Interactivity — Fragmap View (V5) - [X] T108 P1 fix - Fix fragmap relations not following file renames: when a From 3cbd5ba0feb11b848a9f70c7917d37ff09db2669 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 17:53:43 +0000 Subject: [PATCH 12/19] fix: Auto-stage externally resolved conflicts during rebase continue (T134) --- CHANGELOG.md | 4 ++ TASKS.md | 2 +- src/main.rs | 4 ++ src/repo.rs | 10 ++++ src/repo/git2_impl.rs | 22 +++++++++ tests/drop_commit.rs | 95 ++++++++++++++++++++++++++++++++++++++ tests/squash_commit.rs | 78 +++++++++++++++++++++++++++++++ tests/tui_commit_detail.rs | 6 +++ tests/tui_main_view.rs | 3 ++ 9 files changed, 223 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 430a031..d57529b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ The format is based on working tree: `checkout_head` (force) already resets both the index and workdir to HEAD, including removing any files written during conflict checkout that are absent from HEAD's tree. +- Conflicts resolved in an external editor (e.g. VS Code) are now detected + when pressing Enter to continue: the app auto-stages working-tree files + whose conflict markers have been removed, so the index reflects the actual + resolution state. Previously only the built-in mergetool path worked. - Fragmap now tracks file renames across commits: when a file is renamed, overlapping spans in the old and new paths are correctly clustered together instead of being treated as unrelated files. diff --git a/TASKS.md b/TASKS.md index 5532727..d957146 100644 --- a/TASKS.md +++ b/TASKS.md @@ -43,7 +43,7 @@ Guidelines: HEAD, including files absent from HEAD's tree; the dirty-workdir symptom was a consequence of T130's `stage_file` bug leaving the index in a corrupt state; integration test added to confirm) -- [ ] T134 P1 bug - External editor conflict resolution not detected during +- [X] T134 P1 bug - External editor conflict resolution not detected during squash/fixup: when a conflict occurs during squash or fixup and the user resolves it by editing the conflicted file in an external editor (e.g. VS Code) and saving, git-tailor does not detect the resolution; opening the diff --git a/src/main.rs b/src/main.rs index 3c0df55..273f020 100644 --- a/src/main.rs +++ b/src/main.rs @@ -306,6 +306,10 @@ fn main() -> Result<()> { } } AppAction::RebaseContinue(state) => { + // Auto-stage files the user resolved in an external editor + // so that the index reflects the working-tree state. + let _ = git_repo.auto_stage_resolved_conflicts(&state.conflicting_files); + // Squash-time tree conflict: the user has resolved the // combined tree. For squash, open the editor; for fixup, // use the stored target message directly. diff --git a/src/repo.rs b/src/repo.rs index 448aabb..286e9b4 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -341,6 +341,16 @@ pub trait GitRepo { /// conflict so that subsequent `index.has_conflicts()` checks return false. fn stage_file(&self, path: &str) -> Result<()>; + /// Auto-stage conflicting files whose working-tree content no longer + /// contains conflict markers. + /// + /// When a user resolves conflicts in an external editor (instead of the + /// built-in mergetool), the index still carries stage 1/2/3 entries. + /// This method reads each file from disk and stages it if the standard + /// `<<<<<<<` marker is absent, so that `index.has_conflicts()` reflects + /// the actual resolution state. + fn auto_stage_resolved_conflicts(&self, files: &[String]) -> Result<()>; + /// Return the name of the repository's default upstream branch. /// /// Looks up the symbolic target of `refs/remotes/origin/HEAD` (the pointer diff --git a/src/repo/git2_impl.rs b/src/repo/git2_impl.rs index 25a07ed..785ae9d 100644 --- a/src/repo/git2_impl.rs +++ b/src/repo/git2_impl.rs @@ -1055,6 +1055,28 @@ impl GitRepo for Git2Repo { Ok(()) } + fn auto_stage_resolved_conflicts(&self, files: &[String]) -> Result<()> { + let workdir = self + .inner + .workdir() + .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?; + + for path in files { + let full_path = workdir.join(path); + if !full_path.exists() { + // File was deleted — stage the deletion to clear conflict entries. + self.stage_file(path)?; + continue; + } + let content = std::fs::read(&full_path) + .with_context(|| format!("failed to read '{path}' from working tree"))?; + if !content.windows(b"<<<<<<<".len()).any(|w| w == b"<<<<<<<") { + self.stage_file(path)?; + } + } + Ok(()) + } + fn default_branch(&self) -> Option { let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?; let target = reference.symbolic_target()?; diff --git a/tests/drop_commit.rs b/tests/drop_commit.rs index d237acc..2a16693 100644 --- a/tests/drop_commit.rs +++ b/tests/drop_commit.rs @@ -637,3 +637,98 @@ fn rebase_abort_leaves_clean_working_tree() { "no untracked files should remain after abort: {untracked:?}" ); } + +// --------------------------------------------------------------------------- +// Auto-stage resolved conflicts (T132) +// --------------------------------------------------------------------------- + +#[test] +fn auto_stage_resolved_conflicts_stages_externally_edited_file() { + // Simulates the user resolving a conflict in an external editor (e.g. + // VS Code) and saving the file without explicitly staging it. The + // auto_stage_resolved_conflicts method should detect the resolution + // and stage the file so that rebase_continue succeeds. + let test = common::TestRepo::new(); + + let base = test.commit_file("a.txt", "line1\n", "base"); + let to_drop = test.commit_file("a.txt", "line1\nline2\n", "add line2"); + let head = test.commit_file("a.txt", "line1\nline2\nline3\n", "add line3"); + + let git_repo = test.git_repo(); + let result = git_repo + .drop_commit(&to_drop.to_string(), &head.to_string()) + .unwrap(); + + let state = match result { + RebaseOutcome::Conflict(s) => *s, + RebaseOutcome::Complete => panic!("expected Conflict"), + }; + + // Simulate external editor: write resolved content but do NOT stage. + let workdir = test.repo.workdir().unwrap(); + std::fs::write(workdir.join("a.txt"), "line1\nline3\n").unwrap(); + + // Index still has conflict entries at this point. + assert!( + !git_repo.read_conflicting_files().is_empty(), + "conflict entries should still be in the index before auto-staging" + ); + + // Auto-stage should detect the file no longer has conflict markers. + git_repo + .auto_stage_resolved_conflicts(&state.conflicting_files) + .unwrap(); + + // Conflict entries should be cleared now. + assert!( + git_repo.read_conflicting_files().is_empty(), + "conflict entries should be cleared after auto-staging" + ); + + // rebase_continue should succeed. + let result = git_repo.rebase_continue(&state).unwrap(); + assert!( + matches!(result, RebaseOutcome::Complete), + "expected Complete after auto-staging, got {result:?}" + ); + + let commits = commits_from_head(&test.repo, base); + assert_eq!(commits.len(), 1); + let head_oid = test.repo.head().unwrap().target().unwrap(); + assert_eq!( + file_content_at(&test.repo, head_oid, "a.txt"), + "line1\nline3\n" + ); +} + +#[test] +fn auto_stage_does_not_stage_file_with_conflict_markers() { + // If the working-tree file still contains conflict markers, + // auto_stage_resolved_conflicts must leave it alone. + let test = common::TestRepo::new(); + + let _base = test.commit_file("a.txt", "line1\n", "base"); + let to_drop = test.commit_file("a.txt", "line1\nline2\n", "add line2"); + let head = test.commit_file("a.txt", "line1\nline2\nline3\n", "add line3"); + + let git_repo = test.git_repo(); + let result = git_repo + .drop_commit(&to_drop.to_string(), &head.to_string()) + .unwrap(); + + let state = match result { + RebaseOutcome::Conflict(s) => *s, + RebaseOutcome::Complete => panic!("expected Conflict"), + }; + + // Working tree already has conflict markers from write_conflicts_to_workdir. + // auto_stage should NOT clear them. + git_repo + .auto_stage_resolved_conflicts(&state.conflicting_files) + .unwrap(); + + assert!( + !git_repo.read_conflicting_files().is_empty(), + "conflict entries should still be present when markers remain" + ); +} diff --git a/tests/squash_commit.rs b/tests/squash_commit.rs index be040d2..8d1cdcd 100644 --- a/tests/squash_commit.rs +++ b/tests/squash_commit.rs @@ -822,3 +822,81 @@ fn squash_abort_leaves_clean_working_tree() { "no untracked files should remain after abort: {untracked:?}" ); } + +// --------------------------------------------------------------------------- +// Auto-stage resolved conflicts (T132) +// --------------------------------------------------------------------------- + +#[test] +fn squash_finalize_after_external_conflict_resolution_without_staging() { + // Simulates the user resolving a squash-time conflict in an external + // editor (e.g. VS Code) without explicitly staging the file. After + // calling auto_stage_resolved_conflicts, squash_finalize should succeed. + use git_tailor::repo::SquashContext; + + let test = common::TestRepo::new(); + + let _base = test.commit_file("a.txt", "original\n", "base"); + let target = test.commit_file("a.txt", "target\n", "target changes a"); + let _mid = test.commit_file("a.txt", "mid\n", "mid changes a"); + let source = test.commit_file("a.txt", "source\n", "source changes a"); + + let git_repo = test.git_repo(); + let head = git_repo.head_oid().unwrap(); + + let state = git_repo + .squash_try_combine( + &source.to_string(), + &target.to_string(), + "combined", + false, + &head, + ) + .unwrap() + .expect("should conflict"); + + // Simulate external editor: write resolved content but do NOT stage. + let workdir = git_repo.workdir().unwrap(); + std::fs::write(workdir.join("a.txt"), "resolved\n").unwrap(); + + // Index still has conflict entries. + assert!( + !git_repo.read_conflicting_files().is_empty(), + "conflict entries should still be in index before auto-staging" + ); + + // Auto-stage should detect the resolution. + git_repo + .auto_stage_resolved_conflicts(&state.conflicting_files) + .unwrap(); + + assert!( + git_repo.read_conflicting_files().is_empty(), + "conflict entries should be cleared after auto-staging" + ); + + let ctx = SquashContext { + base_oid: state.squash_context.as_ref().unwrap().base_oid.clone(), + source_oid: source.to_string(), + target_oid: target.to_string(), + combined_message: "combined".to_string(), + descendant_oids: vec![], + is_fixup: false, + }; + + let result = git_repo + .squash_finalize(&ctx, "resolved squash", &state.original_branch_oid) + .unwrap(); + + assert!( + matches!(result, RebaseOutcome::Complete), + "should complete after auto-staging: {result:?}" + ); + + let head_oid = test.repo.head().unwrap().target().unwrap(); + assert_eq!( + file_content_at(&test.repo, head_oid, "a.txt"), + "resolved\n", + "resolved content should be in HEAD" + ); +} diff --git a/tests/tui_commit_detail.rs b/tests/tui_commit_detail.rs index 302c50b..f955f19 100644 --- a/tests/tui_commit_detail.rs +++ b/tests/tui_commit_detail.rs @@ -144,6 +144,9 @@ impl GitRepo for NoOpRepo { fn stage_file(&self, _path: &str) -> Result<()> { unimplemented!() } + fn auto_stage_resolved_conflicts(&self, _files: &[String]) -> Result<()> { + unimplemented!() + } fn default_branch(&self) -> Option { None @@ -265,6 +268,9 @@ impl GitRepo for FakeDiffRepo { fn stage_file(&self, _path: &str) -> Result<()> { unimplemented!() } + fn auto_stage_resolved_conflicts(&self, _files: &[String]) -> Result<()> { + unimplemented!() + } fn default_branch(&self) -> Option { None diff --git a/tests/tui_main_view.rs b/tests/tui_main_view.rs index 9d7e79f..e6de1c6 100644 --- a/tests/tui_main_view.rs +++ b/tests/tui_main_view.rs @@ -140,6 +140,9 @@ impl GitRepo for NoOpRepo { fn stage_file(&self, _path: &str) -> Result<()> { unimplemented!() } + fn auto_stage_resolved_conflicts(&self, _files: &[String]) -> Result<()> { + unimplemented!() + } fn default_branch(&self) -> Option { None From 8465868eccea0b5b72d370b6c798dae12067de87 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 18:31:46 +0000 Subject: [PATCH 13/19] chore: Remove version flags from TASKS.md, TASKS-COMPLETED.md, and implement-task prompt --- .github/prompts/implement-task.prompt.md | 3 +- TASKS-COMPLETED.md | 300 +++++++++++------------ TASKS.md | 26 +- 3 files changed, 163 insertions(+), 166 deletions(-) diff --git a/.github/prompts/implement-task.prompt.md b/.github/prompts/implement-task.prompt.md index 0b77d8e..57e7f5d 100644 --- a/.github/prompts/implement-task.prompt.md +++ b/.github/prompts/implement-task.prompt.md @@ -28,12 +28,11 @@ Guidelines: - Priorities: P0 (urgent) → P3 (low). - Categories: bug | feat | fix | idea | human. - Flags (optional): CLARIFICATION, HUMAN INPUT, HUMAN TASK, DUPLICATE. -- Version flags (optional): V1, V2 etc. (used to group versions/releases). - Mark completion by [ ] → [X]. Keep changes atomic (one commit per task). - Mark won't-do tasks by [ ] → [-] and add `WONT DO` to Flags. ## Example -- [ ] T001 P1 feat - Initial placeholder task (Flags: CLARIFICATION, V1) +- [ ] T001 P1 feat - Initial placeholder task (Flags: CLARIFICATION) ## UNCATEGORIZED - Add brand new tasks here before categorization (human or agent). The agent MUST first review this section before selecting any other task. For each uncategorized task: assign an ID (T###), priority, category, and flags; then move it into the proper thematic section; finally REMOVE it from UNCATEGORIZED. diff --git a/TASKS-COMPLETED.md b/TASKS-COMPLETED.md index ce203d4..9c3435e 100644 --- a/TASKS-COMPLETED.md +++ b/TASKS-COMPLETED.md @@ -1,125 +1,125 @@ # Completed Tasks -## CLI Reference Point (V1) -- [X] T002 P0 feat - Add git2 dependency to Cargo.toml (Flags: V1) -- [X] T003 P0 feat - Parse single CLI argument (commit-ish string) (Flags: V1) -- [X] T004 P0 feat - Open repo from current directory with git2 (Flags: V1) -- [X] T005 P0 feat - Resolve CLI arg to Oid using revparse_single (Flags: V1) -- [X] T006 P0 feat - Get HEAD as Oid (Flags: V1) -- [X] T007 P0 feat - Call merge_base to find common ancestor (Flags: V1) -- [X] T008 P0 feat - Print reference commit hash to stdout (Flags: V1) -- [X] T009 P1 feat - Add integration test with TempDir fixture repo (Flags: V1) -- [X] T010 P1 feat - Test resolving branch name to ref point (Flags: V1) -- [X] T011 P1 feat - Test resolving tag to ref point (Flags: V1) -- [X] T012 P1 feat - Test resolving short hash to ref point (Flags: V1) -- [X] T013 P1 feat - Test resolving long hash to ref point (Flags: V1) - -## TUI Commit List View (V2) +## CLI Reference Point +- [X] T002 P0 feat - Add git2 dependency to Cargo.toml +- [X] T003 P0 feat - Parse single CLI argument (commit-ish string) +- [X] T004 P0 feat - Open repo from current directory with git2 +- [X] T005 P0 feat - Resolve CLI arg to Oid using revparse_single +- [X] T006 P0 feat - Get HEAD as Oid +- [X] T007 P0 feat - Call merge_base to find common ancestor +- [X] T008 P0 feat - Print reference commit hash to stdout +- [X] T009 P1 feat - Add integration test with TempDir fixture repo +- [X] T010 P1 feat - Test resolving branch name to ref point +- [X] T011 P1 feat - Test resolving tag to ref point +- [X] T012 P1 feat - Test resolving short hash to ref point +- [X] T013 P1 feat - Test resolving long hash to ref point + +## TUI Commit List View - [X] T014 P0 feat - Add ratatui and crossterm dependencies to Cargo.toml - (Flags: V2) + - [X] T015 P0 feat - Create CommitInfo domain type (oid, summary, author, date) - in lib.rs (Flags: V2) + in lib.rs - [X] T016 P0 feat - Implement list_commits(from_oid, to_oid) in library to get - commits in range (Flags: V2) + commits in range - [X] T017 P0 feat - Create app module (src/app.rs) with AppState struct (Flags: - V2) -- [X] T018 P0 feat - Add commit list and selection index to AppState (Flags: V2) + +- [X] T018 P0 feat - Add commit list and selection index to AppState - [X] T019 P0 feat - Implement methods for moving selection up/down in AppState - (Flags: V2) + - [X] T020 P0 feat - Create event module (src/event.rs) for input handling - (Flags: V2) -- [X] T021 P0 feat - Parse arrow keys and 'q' key in event module (Flags: V2) + +- [X] T021 P0 feat - Parse arrow keys and 'q' key in event module - [X] T022 P0 feat - Create views module (src/views.rs) declaring commit_list - submodule (Flags: V2) + submodule - [X] T023 P0 feat - Create commit_list view (src/views/commit_list.rs) with - render function (Flags: V2) + render function - [X] T024 P0 feat - Render table with "SHA" and "Title" column headers (Flags: - V2) + - [X] T025 P0 feat - Render commits oldest-to-newest with short SHA (7 chars) - and summary (Flags: V2) + and summary - [X] T026 P0 feat - Highlight selected row with different color/style (Flags: - V2) + - [X] T027 P0 feat - Update main.rs to initialize terminal with crossterm - backend (Flags: V2) + backend - [X] T028 P0 feat - Implement main event loop: draw, handle input, update state - (Flags: V2) + - [X] T029 P0 feat - Call list_commits with HEAD and reference point from CLI - arg (Flags: V2) -- [X] T030 P0 feat - Handle 'q' key to exit and restore terminal (Flags: V2) + arg +- [X] T030 P0 feat - Handle 'q' key to exit and restore terminal - [X] T031 P1 feat - Add integration test for list_commits returning correct - order (Flags: V2) -- [X] T032 P1 feat - Add unit test for AppState selection movement (Flags: V2) + order +- [X] T032 P1 feat - Add unit test for AppState selection movement - [X] T033 P2 feat - Add TUI snapshot test with TestBackend for commit_list view - (Flags: V2) + -## TUI Enhancements (V2) +## TUI Enhancements - [X] T035 P1 feat - Start application with HEAD commit selected instead of - first commit (Flags: V2) + first commit - [X] T036 P2 feat - Highlight table column headers with background color or - style (Flags: V2) + style - [X] T037 P1 feat - Make commit list scrollable when commits exceed screen - height (Flags: V2) + height - [X] T038 P1 feat - Render scrollbar for commit list when content exceeds - visible area (Flags: V2) + visible area - [X] T039 P1 feat - Add footer showing selected commit info (long SHA, commit - position) (Flags: V2) -- [X] T040 P1 feat - Add clap dependency for CLI argument parsing (Flags: V2) + position) +- [X] T040 P1 feat - Add clap dependency for CLI argument parsing - [X] T041 P1 feat - Add --reverse flag to display commits in reverse order - (Flags: V2) -- [X] T043 P2 feat - Remove Commits border from commit list table (Flags: V2) + +- [X] T043 P2 feat - Remove Commits border from commit list table -## Fragmap — Diff Extraction (V3) +## Fragmap — Diff Extraction - [X] T044 P0 feat - Add diff domain types: FileDiff, Hunk, DiffLine, CommitDiff - (Flags: V3) + - [X] T045 P0 feat - Add commit_diff(oid) function in repo.rs using git2 to - extract CommitDiff for a single commit (Flags: V3) + extract CommitDiff for a single commit - [X] T046 P1 feat - Add integration tests for commit_diff using fixture repos - (Flags: V3) + -## Fragmap — Span Extraction (V3) +## Fragmap — Span Extraction - [X] T047 P0 feat - Add FileSpan type and extract_spans function in fragmap - module (Flags: V3) -- [X] T048 P1 feat - Add unit tests for span extraction (Flags: V3) + module +- [X] T048 P1 feat - Add unit tests for span extraction -## Fragmap — Matrix Generation (V3) +## Fragmap — Matrix Generation - [X] T049 P0 feat - Build fragmap matrix: commits x chunks with TouchKind - cells, one column per hunk (Flags: V3) + cells, one column per hunk - [X] T050 P1 feat - Add unit tests for matrix generation with fabricated - CommitDiff data (Flags: V3) + CommitDiff data -## Fragmap — Conflict & Squashability Analysis (V3) +## Fragmap — Conflict & Squashability Analysis - [X] T051 P0 feat - Determine squashability between commit pairs sharing a - column: yellow if trivial, red if conflicting (Flags: V3) -- [X] T052 P1 feat - Add unit tests for squashability logic (Flags: V3) + column: yellow if trivial, red if conflicting +- [X] T052 P1 feat - Add unit tests for squashability logic -## Fragmap — TUI Rendering (V3) +## Fragmap — TUI Rendering - [X] T053 P0 feat - Compute fragmap data in main.rs and store in AppState - (Flags: V3) + - [X] T054 P0 feat - Render fragmap grid right of commit title: white squares - for touched chunks, colored lines between related commits (Flags: V3) -- [X] T055 P1 feat - Add snapshot tests for fragmap grid rendering (Flags: V3) + for touched chunks, colored lines between related commits +- [X] T055 P1 feat - Add snapshot tests for fragmap grid rendering - [X] T056 P2 feat - Horizontal scrolling for fragmap columns exceeding - available width (Flags: V3) + available width - [X] T057 P2 feat - Add horizontal scrollbar indicator for fragmap matrix - (Flags: V3) + - [X] T058 P1 feat - Align fragmap matrix to the left, adjacent to title column - (Flags: V3) + - [X] T059 P1 feat - Colorize SHA and title of commits where all touched - clusters are squashable into the same single other commit (Flags: V3) + clusters are squashable into the same single other commit - [X] T060 P1 feat - Highlight related commits when a commit is selected: color SHA and title of squashable targets in yellow (COLOR_SQUASHABLE) and conflicting commits in red (COLOR_CONFLICTING), matching the vertical - connector line colors (Flags: V3) + connector line colors ## Bugs - [X] T042 P0 bug - Commit list shows commits from repo start to reference point - instead of from HEAD to reference point (Flags: V2) + instead of from HEAD to reference point ## Code Organization & Refactoring - [X] T034 P2 feat - Move find_reference_point and list_commits from lib.rs to - repo module (Flags: V2) + repo module -## Interactivity — Basic UI (V5) +## Interactivity — Basic UI - [X] T120 P2 fix - "Hunk groups" header label is truncated when the fragmap matrix has fewer columns than the label is wide: in `build_constraints` the third column uses `Constraint::Length(layout.fragmap_col_width)`, which clips @@ -127,20 +127,20 @@ by using `Constraint::Min(layout.fragmap_col_width)` (or `Constraint::Length(layout.fragmap_col_width.max(MIN_HEADER_WIDTH))`) for the fragmap column so the header always has enough room to display the full label - (Flags: V5) + - [X] T121 P2 fix - Help dialog wraps long key-binding lines mid-text, splitting a single entry across two rows without indentation — making it hard to read; find the help text rendering in `views/help.rs` and ensure each entry either fits on one line or wraps with a hanging indent (e.g. align continuation lines under the description column) so no entry appears to be two separate bindings - (Flags: V5) + - [X] T122 P2 fix - Dialogs that show multi-line body text (e.g. the "some conflicts are still unresolved" conflict dialog and similar) wrap long lines without preserving indentation: continuation lines start at column 0 inside the dialog instead of aligning with the start of the text on the first line; update `render_centered_dialog` (or the individual dialog callers) to apply a hanging indent when wrapping body lines, so wrapped text is visually grouped - under its first line (Flags: V5) + under its first line - [X] T119 P1 fix - Handle Ctrl+C gracefully: always quit the application immediately regardless of the current mode; if the app is in `RebaseConflict` mode (i.e. a rebase is in progress with a half-applied working tree), call @@ -151,7 +151,7 @@ `KeyCode::Char('c')` with `KeyModifiers::CONTROL` in `AppMode::parse_key`, map it to a new `KeyCommand::ForceQuit`, and handle it in `main.rs` outside the per-mode dispatch so it cannot be shadowed; ensure raw mode and the alternate - screen are properly restored before exit (Flags: V5) + screen are properly restored before exit - [X] T117 P2 feat - Allow the user to move the vertical separator bar between the commit list and the right panel (fragmap / commit detail) using Ctrl+Left and Ctrl+Right arrow keys; store the offset as a signed integer in `AppState` @@ -161,99 +161,99 @@ the `split_x` constant in `render_main_view` -## Core Behavior & Constraints (V4) +## Core Behavior & Constraints - [X] T081 P0 feat - Exclude the reference point (merge-base) commit from the commit list and all operations — it is shared with the target branch and must - not be squashed, moved, or split (Flags: V4) + not be squashed, moved, or split -## Interactivity — Basic UI (V4) -- [X] T061 P0 feat - Change exit key from 'q' to Esc (Flags: V4) +## Interactivity — Basic UI +- [X] T061 P0 feat - Change exit key from 'q' to Esc - [X] T062 P1 feat - Add vertical separator line between title column and hunk - groups column (Flags: V4) + groups column - [X] T063 P1 feat - Add help dialog on 'h' key showing all interactive - keybindings (q=quit, i=info, s=split, m=move, h=help) (Flags: V4) + keybindings (q=quit, i=info, s=split, m=move, h=help) - [X] T085 P2 feat - Add 'r' key to reload: re-read the commit list from HEAD down to the originally calculated reference point (merge-base), refreshing - after external git operations without restarting the tool (Flags: V4) + after external git operations without restarting the tool - [X] T086 P2 feat - Show staged and unstaged working-tree changes as synthetic rows at the top of the commit list (above HEAD), displayed with distinct labels ("staged" / "unstaged") and included in the fragmap matrix so their - hunk overlap with commits is visible (Flags: V4) + hunk overlap with commits is visible -## Interactivity — Fragmap View (V4) +## Interactivity — Fragmap View - [X] T082 P1 feat - Improve selected row highlighting in the hunk group matrix; the current inverse-color style is hard to read — use a subtler approach such as a bold/bright foreground, a dim background tint, or a side marker (Flags: - V4) + - [X] T083 P2 feat - Add CLI flag `--no-dedup-columns` (or similar) to disable deduplication of identical hunk-group columns in the fragmap view, useful for - debugging and understanding the raw cluster layout (Flags: V4) + debugging and understanding the raw cluster layout -## Interactivity — Commit Detail View (V4) +## Interactivity — Commit Detail View - [X] T064a P0 feat - Add DetailView app mode and 'i' key toggle, create basic - commit_detail view module with placeholder rendering (Flags: V4) + commit_detail view module with placeholder rendering - [X] T064b P0 feat - Display commit metadata in detail view: full message, - author name, author date, commit date (Flags: V4) + author name, author date, commit date - [X] T064c P0 feat - Add file list showing changed/added/removed files with - status indicators (Flags: V4) + status indicators - [X] T064d P0 feat - Add complete diff rendering with +/- lines (plain text, no - colors) (Flags: V4) + colors) - [X] T065 P1 feat - Color diff output in commit detail view similar to tig: - green for additions, red for deletions, cyan for hunk headers (Flags: V4) + green for additions, red for deletions, cyan for hunk headers - [X] T066 P1 feat - Support scrolling in commit detail view for long diffs - (Flags: V4) + - [X] T067 P1 feat - Pressing 'i' again or Esc in detail view returns to the - commit list with hunk groups (Flags: V4) + commit list with hunk groups -## Interactivity — Split Commit (V4) +## Interactivity — Split Commit - [X] T068 P0 feat - Add split mode on 's' key: prompt user to choose split - strategy — one commit per file, per hunk, or per hunk cluster (Flags: V4) + strategy — one commit per file, per hunk, or per hunk cluster - [X] T069 P0 feat - Implement per-file split: create N commits each applying one file's changes, using git2 cherry-pick/tree manipulation; refuse if staged/unstaged changes overlap (share file paths) with the commit being - split, and report the conflicting file(s) to the user (Flags: V4) + split, and report the conflicting file(s) to the user - [X] T070 P1 feat - Implement per-hunk split: create one commit per hunk using - git2 diff apply with filtered patches (Flags: V4) + git2 diff apply with filtered patches - [X] T071 P1 feat - Implement per-hunk-cluster split: create one commit per - fragmap cluster column (Flags: V4) + fragmap cluster column - [X] T072 P1 feat - Add numbering n/total to split commit messages in the - subject line (Flags: V4) + subject line - [X] T087 P2 feat - Before executing a split that would produce more than 5 new commits, show a yes/no confirmation dialog displaying the count and asking the - user to confirm before proceeding (Flags: V4) + user to confirm before proceeding -## Interactivity — Drop Commit (V4) +## Interactivity — Drop Commit - [X] T084a P1 feat - Implement `drop_commit` on `GitRepo` trait: remove the selected commit by cherry-picking its descendants onto its parent. Return a `RebaseOutcome` that is either `Complete` on success or `Conflict` with enough state to resume or abort. Each cherry-pick step can conflict, so conflicts - must be detected at every stage of the rebase. (Flags: V4) + must be detected at every stage of the rebase. - [X] T084b P1 feat - Implement `drop_commit_continue` and `drop_commit_abort` on `GitRepo` trait: after the user resolves conflicts in the working tree, `continue` stages the resolution and resumes cherry-picking the remaining - descendants; `abort` restores the branch to its original state. (Flags: V4) + descendants; `abort` restores the branch to its original state. - [X] T084c P1 feat - Wire drop to 'd' key in the TUI: always prompt the user for confirmation before executing (Enter to confirm, Esc to cancel). (Flags: - V4) + - [X] T084d P1 feat - Handle conflict during drop: when `drop_commit` returns a conflict, prompt the user to resolve it in their working tree (Enter to - continue as resolved, Esc to abort the drop). (Flags: V4) + continue as resolved, Esc to abort the drop). - [X] T092 P2 fix - Wrap long commit summaries in the drop confirm and drop conflict dialogs so the title is never truncated when it exceeds the dialog - width (Flags: V4) + width - [X] T093 P2 feat - Show conflicting file paths in the drop conflict dialog: query the index for entries with conflict stage > 0 and list them inside the - dialog so the user can see which files need to be resolved (Flags: V4) + dialog so the user can see which files need to be resolved - [X] T094 P1 fix - When `drop_commit_continue` is called with partially unresolved conflicts (some files still have conflict markers), detect the remaining conflicts, show them to the user inside the dialog, and keep the `DropConflict` mode active instead of returning an error and leaving the repo - in a broken state (Flags: V4) + in a broken state - [X] T095 P2 feat - When a merge conflict occurs during drop, offer to launch the user's configured merge tool (from `merge.tool` / `mergetool..cmd` git config) on each conflicted file. Suspend the TUI (disable raw mode, leave @@ -261,10 +261,10 @@ files, invoke the tool and wait for it to exit (same contract as the commit message editor), then restore the TUI and re-read the index to refresh `conflicting_files`. If no merge tool is configured, leave the current - behaviour unchanged. (Flags: V4) + behaviour unchanged. -## Interactivity — Move Commit (V4) +## Interactivity — Move Commit - [X] T073 P0 feat - Add move mode on 'm' key: highlight selected commit and show a "move here" insertion row navigable with arrow keys. Design: move `KeyCommand` enum and key parsing into `app.rs`, implement @@ -275,17 +275,17 @@ `build_rows` injects a styled separator row (e.g. `▶ move here`) at the insertion point — same pattern as the existing squash source highlight. A thin line between rows is not feasible with ratatui's Table widget without - reimplementing layout. (Flags: V4) + reimplementing layout. - [-] T074 P1 feat - Color the insertion row red with "move here - likely conflict" when moving to a position that would cause a conflict (Flags: - V4, WONT DO) + WONT DO) - [X] T075 P0 feat - Execute the move via git2 cherry-pick rebase onto the new - position, abort and notify user on conflict (Flags: V4) + position, abort and notify user on conflict - [X] T076 P2 feat - On conflict, tell the user whether the conflict is in the - moved commit or in a commit rebased on top of it (Flags: V4) + moved commit or in a commit rebased on top of it -## Interactivity — Squash Commit (V4) +## Interactivity — Squash Commit - [X] T099 P1 feat - Generalize conflict handling for reuse by squash and future operations: rename `drop_commit_continue`/`drop_commit_abort` → `rebase_continue`/`rebase_abort` on the `GitRepo` trait and `Git2Repo` impl, @@ -296,20 +296,20 @@ extract conflict dialog code (`handle_conflict_key`, `render_drop_conflict`) from `views/drop.rs` into a new `views/conflict.rs`, and update all references in `main.rs`, `app.rs`, `AppMode::background()`, tests, and help text (Flags: - V4) + - [X] T101 P1 feat - Remap split key from 's' to 'p' (sPlit) in the commit list view and help dialog, freeing 's' for squash which matches git's interactive - rebase keybindings (Flags: V4) + rebase keybindings - [X] T077 P0 feat - Add squash mode on 's' key: enter a `SquashSelect` app mode where the selected commit is the "source" and the user navigates with arrow keys to pick a squash target; the source is squashed *into* the target (target keeps its position, source is removed, their changes are combined); pressing Enter confirms the target, Esc cancels back to CommitList; block the key when - the selected row is a staged/unstaged synthetic entry (Flags: V4) + the selected row is a staged/unstaged synthetic entry - [X] T078 P1 feat - Color squash target candidates in SquashSelect mode: yellow if squashable without conflict, red if the squash would likely conflict (overlapping fragmap clusters), white/dim if unrelated (no shared hunks and no - conflict) (Flags: V4) + conflict) - [X] T079 P0 feat - Implement `squash_commits` on the `GitRepo` trait: given source and target OIDs plus `head_oid`, create a combined tree by cherry-picking the target then the source onto the target's parent, then @@ -317,7 +317,7 @@ exclusive, plus commits after source) onto the result using `cherry_pick_chain` — return `RebaseOutcome` so conflicts during the descendant rebase are handled by the generalized conflict infrastructure - (Flags: V4) + - [X] T100 P0 feat - Wire squash execution in the TUI: after the user picks a target in SquashSelect, open the editor (reuse `edit_message_in_editor`) with both commit messages concatenated — target message first, then a blank line, @@ -325,26 +325,26 @@ user saves an unchanged or non-empty message, call `squash_commits`; on `RebaseOutcome::Conflict` enter `RebaseConflict` mode (reusing the generalized conflict dialog, continue, abort, and mergetool flows from T099); on success - reload commits and show a confirmation message (Flags: V4) + reload commits and show a confirmation message - [x] T080 P2 feat - Handle squash-time conflict (source changes conflict with target changes): when creating the combined tree itself fails due to overlapping edits in the source and target commits, write the conflict to the working tree and enter `RebaseConflict` mode so the user can resolve, continue, abort, or launch the mergetool — same flow as descendant rebase - conflicts (Flags: V4) + conflicts - [X] T102 P1 feat - Replace the SquashSelect overlay dialog with a footer-based context line: remove `squash_select::render()` and its centered dialog, and instead show a footer message in `render_footer` when in SquashSelect mode — e.g. `Squash: select target for "" · Enter confirm · Esc cancel` — so the commit list is never obscured while picking a squash target; the source commit's magenta highlight and candidate coloring already provide - sufficient visual context (Flags: V4) + sufficient visual context - [X] T103 P1 feat - Restrict SquashSelect cursor to earlier commits only: in `squash_select::handle_key`, clamp navigation so the cursor cannot move to commits later than (above) the source commit — squashing into a later commit is not supported; also dim the rows above the source in the commit list when in SquashSelect mode to visually indicate they are unreachable targets (Flags: - V4) + - [X] T104 P1 feat - Add fixup mode on 'f' key: works identically to squash ('s') — enters `SquashSelect`, uses the same target-picking UI, candidate coloring, and conflict handling — but instead of opening the editor with both @@ -352,13 +352,13 @@ (the source commit's message is discarded); reuse `squash_try_combine`, `squash_commits`, and `squash_finalize` with the target's message passed directly, skipping `edit_message_in_editor`; update the footer context line to - say "Fixup" instead of "Squash" and add 'f' to the help dialog (Flags: V4) + say "Fixup" instead of "Squash" and add 'f' to the help dialog -## Interactivity — Reword Commit (V4) +## Interactivity — Reword Commit - [X] T088 P1 feat - Implement `resolve_editor()` helper: walk GIT_EDITOR env var → core.editor git config → VISUAL env var → EDITOR env var → "vi" - fallback, matching git's own editor resolution order (Flags: V4) + fallback, matching git's own editor resolution order - [X] T089 P1 feat - Implement general `edit_message_in_editor(repo, message)` utility: write message to a tempfile, suspend TUI (disable raw mode, leave alternate screen), spawn the resolved editor with inherited stdio and the @@ -366,9 +366,9 @@ alternate screen), read and return the edited message; works for both terminal-UI editors (e.g. `vim`, `emacs -nw`) and GUI editors that open their own window (e.g. `code --wait`) — this function is intentionally general so it - can be reused when editing commit messages during squash (Flags: V4) + can be reused when editing commit messages during squash - [X] T090 P1 feat - Change reload key from 'r' to 'u' (update) in commit list - view and help dialog, to free 'r' for reword (Flags: V4) + view and help dialog, to free 'r' for reword - [X] T091 P1 feat - Add 'r' reword key in commit list view: invoke `edit_message_in_editor` with the selected commit's message, then use git2 to recreate the commit with the same tree and parents but the new message; if the @@ -377,10 +377,10 @@ the tree content is identical at every step, so staged/unstaged working-tree changes are unaffected and do not need to block this operation; block the key (show an error) only when the selected row is a staged or unstaged synthetic - entry (Flags: V4) + entry -## CLI Output & Compatibility (V5) +## CLI Output & Compatibility - [X] T109 P2 feat - Add `--static` CLI flag to output the commit SHA/title list and fragmap matrix to stdout without launching the interactive TUI, mimicking the behavior of the original fragmap tool; format each row as: short SHA in @@ -391,47 +391,47 @@ (`\x1b[43m \x1b[0m`) for a squashable connector between two touching commits, and a red-background space (`\x1b[41m \x1b[0m`) for a conflicting connector; skip staged/unstaged synthetic rows (not present in original fragmap output); - then exit (Flags: V5) + then exit - [X] T110 P3 feat - Add `--no-color` CLI flag to disable all color output when used with `--static` from T109, producing plain text output suitable for piping or automated processing; ensure this works correctly with the fragmap - symbols and commit list formatting (Flags: V5) + symbols and commit list formatting -## Refactoring — TUI Architecture (V5) +## Refactoring — TUI Architecture - [X] T096 P1 feat - Refactor event loop to mode-first dispatch: flip the main match from action-first to mode-first so there is one small match on `AppMode` delegating to a `handle_action(action, app)` function in each view module (co-located with `render()`). Each handler returns an `ActionResult` enum (Handled, ExecuteSplit, ExecuteDrop, Quit, etc.) so view modules stay free of - git/terminal dependencies and `main.rs` only interprets the result (Flags: V5) + git/terminal dependencies and `main.rs` only interprets the result - [X] T097 P2 feat - Extract shared dialog rendering helper: create `views/dialog.rs` with a `render_centered_dialog(frame, config)` utility that handles centering, clearing, bordering and wrapping — then refactor drop confirm, drop conflict, split select, split confirm and help dialogs to use - it, eliminating the duplicated layout/clear/border code (Flags: V5) + it, eliminating the duplicated layout/clear/border code - [X] T098 P2 feat - Formalize the overlay concept: add an `AppMode::background()` method that returns the underlying mode to render first for overlay modes (SplitSelect, SplitConfirm, DropConfirm, DropConflict, Help), then simplify the render dispatch in `main.rs` to call `render_mode(background)` then `render_mode(foreground)` instead of - hand-coding the layering for each overlay variant (Flags: V5) + hand-coding the layering for each overlay variant - [X] T123 P2 feat - Extract render_main_view from main.rs into views/main_view.rs: move the split-panel orchestrator (separator clamping, left/right area computation, fragmap hide/restore, commit_list + commit_detail - coordination) out of main.rs into a proper view module (Flags: V6) + coordination) out of main.rs into a proper view module - [X] T124 P2 feat - Extract fragmap rendering helpers into views/hunk_groups.rs: move build_fragmap_cell, fragmap_cell_content, fragmap_connector_content, cluster_relation, commit_text_style, fragmap color constants, and render_horizontal_scrollbar out of commit_list.rs into a dedicated module. commit_list.rs calls into hunk_groups for the third table - column (Flags: V6) + column - [X] T125 P3 feat - Move SeparatorLeft/Right handling out of main event loop: instead of the event loop doing `if action == SeparatorLeft { ... continue; }`, handle separator_offset mutation inside the view handle_key (main_view or commit_list), returning - AppAction::Handled (Flags: V6) + AppAction::Handled -## CLI Output & Compatibility (V5) — continued +## CLI Output & Compatibility — continued - [X] T128 P2 feat - Adapt title column width to terminal width in `--static` output: the original fragmap tool sets the title column width dynamically so that the SHA + title + hunk-group matrix fills the available terminal width; @@ -444,7 +444,7 @@ `crossterm::terminal::size()` or a passed-in width, falling back to 80), compute `title_width = terminal_width − sha_width − separators − matrix_width` clamped to a sensible minimum, and truncate/pad the title to that width - (Flags: V5) + - [X] T126 P2 feat - Add `--squashable-scope ` CLI argument controlling what the squashable connector color/symbol means: `group` (default in TUI) — a connector in a column is squashable when *that hunk-group pair @@ -455,11 +455,11 @@ upper commit), matching the original fragmap tool's stricter rule; the argument must be valid in both TUI and `--static` modes; store the choice in `AppState` and thread it through the fragmap connector rendering logic in both - `static_views::fragmap::render` and the TUI fragmap widget (Flags: V5) + `static_views::fragmap::render` and the TUI fragmap widget - [X] T127 P2 fix - Respect the `-r` / `--reverse` flag when `--static` is used: currently `--static` always outputs commits in the order returned by `list_commits` (newest-first); when `--reverse` is also passed the rows should - be printed oldest-first, matching the interactive TUI behavior (Flags: V5) + be printed oldest-first, matching the interactive TUI behavior - [x] T111 P3 feat - Replace the current example application in `examples/` with a compatibility tool that takes a commit-ish as its argument, uses it to find the merge-base (same as `--static`), then builds a `Fragmap` object in the @@ -468,9 +468,9 @@ view and compares the two outputs column-by-column (columns may be in any order); if the same commit-cluster relationships are present in both it prints "OK"; otherwise it prints the `fragmap` output, then git-tailor's static - output, plus a short summary explaining what differs (Flags: V5) + output, plus a short summary explaining what differs -## Build & CI (V5) — continued +## Build & CI — continued - [X] T114 P2 feat - Write comprehensive README.md documentation: describe what the tool does (interactive git commit browser with fragmap visualization and rebase operations), installation instructions, basic usage guide with key @@ -478,15 +478,15 @@ that the entire tool is AI-generated, and include a prominent data safety disclaimer warning users to push their changes before using the tool since any bugs may cause permanent data loss — author takes no responsibility for data - loss under any circumstances, see Apache 2.0 license text (Flags: V5) + loss under any circumstances, see Apache 2.0 license text - [X] T115 P2 feat - Add CHANGELOG.md following keepachangelog.com format: create initial changelog with sections for Unreleased, version entries (Added, Changed, Deprecated, Removed, Fixed, Security), and update AGENTS.md to instruct AI agents to ask users whether changes should be noted in the changelog when completing tasks that add user-visible features or fix bugs - (Flags: V5) + -## Bug Fixes (V5) — continued +## Bug Fixes — continued - [X] T129 P1 bug - Fix move/drop/fixup/squash/split losing working-tree and index changes: currently these rebase operations discard any uncommitted changes (both staged and unstaged) that exist in the working tree when the @@ -499,9 +499,9 @@ committed) and unstaged changes (modified tracked files not yet staged), asserting that after the operation completes the working tree and index reflect the same content that was present before the operation started (Flags: - V5) -## Interactivity — Auto-detection (V5) + +## Interactivity — Auto-detection - [X] T130 P2 feat - Auto-detect the repository default branch when no `` is provided on the command line: resolve `origin/HEAD` via `git rev-parse --abbrev-ref origin/HEAD` (libgit2: look up the symbolic target diff --git a/TASKS.md b/TASKS.md index d957146..95a9d56 100644 --- a/TASKS.md +++ b/TASKS.md @@ -5,7 +5,6 @@ Guidelines: - Priorities: P0 (urgent) → P3 (low). - Categories: bug | feat | fix | idea | human. - Flags (optional): CLARIFICATION, HUMAN INPUT, HUMAN TASK, DUPLICATE. -- Version flags (optional): V1, V2 etc. (used to group versions/releases). - Mark completion by [ ] → [X]. Keep changes atomic (one commit per task). - Mark won't-do tasks by [ ] → [-] and add `WONT DO` to Flags. - Completed tasks are archived in TASKS-COMPLETED.md. @@ -53,7 +52,7 @@ Guidelines: git2's in-memory state or a cached copy rather than re-reading from the working tree on disk when checking conflict status or launching the mergetool -## Interactivity — Fragmap View (V5) +## Interactivity — Fragmap View - [X] T108 P1 fix - Fix fragmap relations not following file renames: when a file is renamed across commits, spans should cluster together if they overlap the same logical content, but currently they are treated as separate files and @@ -61,7 +60,7 @@ Guidelines: (https://github.com/amollberg/fragmap) to see how rename detection is handled in span clustering, and adapt the SPG logic in `src/fragmap/spg.rs` to properly track renamed files so that overlapping spans across renames are - correctly clustered together (Flags: V5) + correctly clustered together - [ ] T106 P2 feat - Refactor fragmap cell rendering into a `FragmapTheme` trait with methods like `touched_symbol()`, `connector_symbol()`, `touched_style()`, `connector_style()` that accept context (relation type, whether the cluster is @@ -70,7 +69,7 @@ Guidelines: constant lookups in `fragmap_cell_content`, `fragmap_connector_content`, and `build_fragmap_cell` with calls through the trait so that adding new rendering modes (T105) doesn't require scattering conditionals throughout the rendering - functions (Flags: V5) + functions - [ ] T105 P2 feat - Add glyph-weight focus highlighting to the fragmap matrix: clusters related to the focus commit (selected commit in CommitList, source commit in SquashSelect/MoveSelect) use heavy glyphs — `█` for touched squares @@ -80,27 +79,26 @@ Guidelines: This makes it immediately scannable which hunk groups the focus commit participates in without introducing new colors. "Related" means the cluster column contains a touch from the focus commit. Implement as a `FocusTheme` - behind the `FragmapTheme` trait from T106. (Flags: V5) + behind the `FragmapTheme` trait from T106. - [ ] T107 P3 feat - Add CLI flag `--no-focus-glyphs` (or similar) to disable the glyph-weight focus highlighting from T105 and fall back to the uniform heavy-glyph rendering (DefaultTheme from T106); store the choice in `AppState` - and select the appropriate `FragmapTheme` implementation at startup (Flags: - V5) + and select the appropriate `FragmapTheme` implementation at startup -## CLI Output & Compatibility (V5) +## CLI Output & Compatibility -## Build & CI (V5) +## Build & CI - [ ] T112 P3 feat - Set up cargo-deny with configuration to check dependency licenses are compatible with Apache 2.0: install cargo-deny, create `deny.toml` config allowing Apache-compatible licenses (Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, etc.), deny copyleft licenses (GPL, LGPL, AGPL), and add `cargo deny check` command to verify no license violations in - the dependency tree (Flags: V5) + the dependency tree - [ ] T113 P3 feat - Add cargo-deny to GitHub Actions CI: create or update `.github/workflows/ci.yml` to run `cargo deny check licenses` alongside existing format/clippy/test checks, failing the build if any dependency license conflicts are detected; ensure this runs on pull requests and main - branch pushes (Flags: V5) + branch pushes - [ ] T118 P2 feat - Set up GitHub Releases with pre-built binaries: create `.github/workflows/release.yml` that triggers on version tags (`v*`), builds the `gt` binary for `x86_64-unknown-linux-musl` (fully static, covers WSL2 and @@ -109,15 +107,15 @@ Guidelines: `taiki-e/upload-rust-binary-action` to strip, archive, and attach binaries to the GitHub Release automatically; the musl target should produce a zero shared-library binary (add `RUSTFLAGS=-C target-feature=+crt-static` if - needed) so no system libs beyond the kernel are required (Flags: V5) + needed) so no system libs beyond the kernel are required -## Refactoring — TUI Architecture (V5) +## Refactoring — TUI Architecture - [ ] T116 P3 feat - Review codebase for refactoring opportunities: audit existing code for duplication, overly complex functions, inconsistent patterns, and areas where abstractions could simplify implementation; identify specific refactoring targets like extracting common dialog patterns, consolidating similar error handling, reducing parameter passing, and improving module boundaries; create follow-up tasks for the most impactful - improvements (Flags: V5) + improvements ## Notes From 0b0f475dd60aa78fcb028af19f965f15cd631ae8 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 18:35:31 +0000 Subject: [PATCH 14/19] chore: Prefer TDD for bug tasks: failing test commit before fix --- .github/prompts/implement-task.prompt.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/prompts/implement-task.prompt.md b/.github/prompts/implement-task.prompt.md index 57e7f5d..dbb1ac5 100644 --- a/.github/prompts/implement-task.prompt.md +++ b/.github/prompts/implement-task.prompt.md @@ -73,12 +73,20 @@ Produce a concise bullet plan: - Constitution / architectural principle alignment (reference sections if available) - Files to change (relative paths) - Minimal diff strategy (why changes are smallest possible) -- Test approach (existing tests, new tests only if essential) +- Test approach: + - **Bug tasks**: prefer TDD — write a failing test first that demonstrates the + bug, commit it separately, then implement the fix. The test commit message + should be prefixed with `test:` and reference the task title. Only skip + the failing-test commit if the bug cannot be exercised by an automated test. + - **Feat/fix tasks**: new tests only if essential. - Risks & rollback steps Wait for user APPROVAL. Stop if not approved. ### 5. Implement After approval: +- **Bug tasks (TDD)**: before writing the fix, write a failing test that + reproduces the bug and commit it alone (prefix: `test:`). Then implement the + fix in a subsequent commit and confirm the test now passes. - Apply smallest possible, atomic changes (optimize for a single concise commit per task). - If task inherently requires multiple steps, propose splitting before proceeding. - Avoid unrelated refactors. From 1d3f22dbab82667652dc27d614b9124868442a0a Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 18:40:22 +0000 Subject: [PATCH 15/19] chore: Suggest refactoring tasks before proceeding when design is a poor fit --- .github/prompts/implement-task.prompt.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/prompts/implement-task.prompt.md b/.github/prompts/implement-task.prompt.md index dbb1ac5..a3decea 100644 --- a/.github/prompts/implement-task.prompt.md +++ b/.github/prompts/implement-task.prompt.md @@ -72,7 +72,15 @@ Produce a concise bullet plan: - Goal & acceptance criteria (derived + user clarifications) - Constitution / architectural principle alignment (reference sections if available) - Files to change (relative paths) -- Minimal diff strategy (why changes are smallest possible) +- Design quality check: assess whether the existing code structure is a good + fit for this change. If the area is fragile, duplicated, or poorly abstracted, + identify the specific problem and propose one or more preparatory refactoring + tasks (with suggested category and title). Ask the user whether to add them to + TASKS.md and tackle them first, or proceed with the current task as-is. Do NOT + add tasks to TASKS.md without explicit user approval. +- Scope: implement the smallest change that is also correct and does not make + the design worse. A prior refactor commit that makes the actual change cleaner + is in scope; unrelated cleanup is not. - Test approach: - **Bug tasks**: prefer TDD — write a failing test first that demonstrates the bug, commit it separately, then implement the fix. The test commit message @@ -88,8 +96,10 @@ After approval: reproduces the bug and commit it alone (prefix: `test:`). Then implement the fix in a subsequent commit and confirm the test now passes. - Apply smallest possible, atomic changes (optimize for a single concise commit per task). +- If a preparatory refactor was identified in the plan, commit it separately + before the main change. - If task inherently requires multiple steps, propose splitting before proceeding. -- Avoid unrelated refactors. +- Avoid unrelated cleanup (out-of-scope refactors belong in their own task). - Keep shared/domain logic host-neutral (follow project conventions, e.g. packages/, libs/, src/domain/). - No new dependencies unless explicitly approved. - No secrets or credentials added. From fe045fcad2622109138b65e97e22d7e551f62a5f Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 19:41:44 +0000 Subject: [PATCH 16/19] tasks: Add T135 open editor during conflict resolution --- TASKS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TASKS.md b/TASKS.md index 95a9d56..8294402 100644 --- a/TASKS.md +++ b/TASKS.md @@ -52,6 +52,16 @@ Guidelines: git2's in-memory state or a cached copy rather than re-reading from the working tree on disk when checking conflict status or launching the mergetool +## Interactivity — Conflict Resolution +- [ ] T135 P2 feat - Add option to open the configured editor when resolving a + conflict: the conflict view currently offers a key binding to launch the + mergetool (`core.mergetool` / `merge.tool`); add a second key binding (e.g. + `e`) that instead opens the conflicted file in the user's configured editor + (`core.editor`, falling back to `$VISUAL`, then `$EDITOR`, then a sensible + default such as `vi`); after the editor exits, re-check the file for conflict + markers and update the conflict view state accordingly, the same way the + mergetool path does + ## Interactivity — Fragmap View - [X] T108 P1 fix - Fix fragmap relations not following file renames: when a file is renamed across commits, spans should cluster together if they overlap From e940c81960de03c9eb67e1cacd4979e23b3fa479 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 20:02:43 +0000 Subject: [PATCH 17/19] feat: Add 'e' key to open editor during conflict resolution (T135) --- CHANGELOG.md | 10 ++-- TASKS.md | 2 +- src/app.rs | 10 ++++ src/editor.rs | 47 ++++++++++------ src/main.rs | 32 +++++++++++ src/views/commit_list.rs | 5 +- src/views/conflict.rs | 18 +++++-- ...rm__drop_conflict_dialog_long_summary.snap | 38 ++++++------- ..._drop_conflict_dialog_narrow_terminal.snap | 18 ++++--- ...rm__drop_conflict_dialog_no_remaining.snap | 38 ++++++------- ...flict_dialog_still_unresolved_warning.snap | 54 +++++++++---------- ...firm__drop_conflict_dialog_with_files.snap | 50 ++++++++--------- ...__drop_conflict_dialog_with_remaining.snap | 38 ++++++------- 13 files changed, 202 insertions(+), 158 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d57529b..57fdbbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ The format is based on - `` argument is now optional. When omitted, `gt` resolves `origin/HEAD` to determine the repository's default upstream branch (e.g. `origin/main`). Falls back to `main` if `origin/HEAD` is not configured. +- Press `e` in the conflict dialog to open each conflicting file directly in the + configured editor (`GIT_EDITOR` → `core.editor` → `$VISUAL` → `$EDITOR` → + `vi`). After the editor exits the conflict state is refreshed, the same way + the mergetool (`m`) path works. ### Fixed @@ -27,9 +31,9 @@ The format is based on working tree: `checkout_head` (force) already resets both the index and workdir to HEAD, including removing any files written during conflict checkout that are absent from HEAD's tree. -- Conflicts resolved in an external editor (e.g. VS Code) are now detected - when pressing Enter to continue: the app auto-stages working-tree files - whose conflict markers have been removed, so the index reflects the actual +- Conflicts resolved in an external editor (e.g. VS Code) are now detected when + pressing Enter to continue: the app auto-stages working-tree files whose + conflict markers have been removed, so the index reflects the actual resolution state. Previously only the built-in mergetool path worked. - Fragmap now tracks file renames across commits: when a file is renamed, overlapping spans in the old and new paths are correctly clustered together diff --git a/TASKS.md b/TASKS.md index 8294402..44bc428 100644 --- a/TASKS.md +++ b/TASKS.md @@ -53,7 +53,7 @@ Guidelines: working tree on disk when checking conflict status or launching the mergetool ## Interactivity — Conflict Resolution -- [ ] T135 P2 feat - Add option to open the configured editor when resolving a +- [X] T135 P2 feat - Add option to open the configured editor when resolving a conflict: the conflict view currently offers a key binding to launch the mergetool (`core.mergetool` / `merge.tool`); add a second key binding (e.g. `e`) that instead opens the conflicted file in the user's configured editor diff --git a/src/app.rs b/src/app.rs index b26ca1a..7bbb8cc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -41,6 +41,7 @@ pub enum KeyCommand { Drop, Move, Mergetool, + OpenEditor, Update, Quit, Confirm, @@ -102,6 +103,11 @@ pub enum AppAction { files: Vec, conflict_state: ConflictState, }, + /// Open conflicting files in the configured editor. + RunEditor { + files: Vec, + conflict_state: ConflictState, + }, /// Start the reword flow: get head_oid, launch editor, rewrite commit. PrepareReword { commit_oid: String, @@ -244,6 +250,10 @@ impl AppMode { AppMode::RebaseConflict(_) => KeyCommand::Mergetool, _ => KeyCommand::Move, }, + KeyCode::Char('e') => match self { + AppMode::RebaseConflict(_) => KeyCommand::OpenEditor, + _ => KeyCommand::None, + }, KeyCode::Char('u') => KeyCommand::Update, KeyCode::Esc | KeyCode::Char('q') => KeyCommand::Quit, _ => KeyCommand::None, diff --git a/src/editor.rs b/src/editor.rs index 0fac545..5b4011a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -14,6 +14,9 @@ use crate::repo::GitRepo; +use anyhow::Context as _; +use crossterm::{execute, terminal}; + /// Resolve the editor command to use for editing commit messages. /// /// Walks git's canonical editor lookup chain: @@ -40,24 +43,14 @@ fn resolve_editor(repo: &impl GitRepo) -> String { "vi".to_string() } -/// Open `message` in the configured editor and return the edited result. -/// -/// Suspends the TUI (disables raw mode, leaves the alternate screen) before -/// launching the editor, then restores it unconditionally before returning. -/// Works for both terminal-UI editors (e.g. `vim`, `emacs -nw`) and GUI -/// editors that manage their own window (e.g. `code --wait`). +/// Suspend the TUI, open `path` in the configured editor, then restore the TUI. /// /// The editor command may include arguments (e.g. `"emacs -nw"`) — they are -/// split on whitespace and forwarded before the temp-file path. -pub fn edit_message_in_editor(repo: &impl GitRepo, message: &str) -> anyhow::Result { - use anyhow::Context; - use crossterm::{execute, terminal}; - use std::io::Write as _; - - let mut tmpfile = - tempfile::NamedTempFile::new().context("failed to create temp file for commit message")?; - write!(tmpfile, "{message}").context("failed to write commit message to temp file")?; - +/// split on whitespace and forwarded before the file path. Works for both +/// terminal editors (e.g. `vim`) and GUI editors that manage their own window +/// (e.g. `code --wait`). The TUI is restored unconditionally so the app is +/// never left in a broken state. +fn launch_editor(repo: &impl GitRepo, path: &std::path::Path) -> anyhow::Result<()> { let editor_cmd = resolve_editor(repo); let mut parts = editor_cmd.split_whitespace(); let prog = parts @@ -72,7 +65,7 @@ pub fn edit_message_in_editor(repo: &impl GitRepo, message: &str) -> anyhow::Res let status = std::process::Command::new(prog) .args(&args) - .arg(tmpfile.path()) + .arg(path) .status(); // Restore TUI unconditionally so the app is never left in a broken state. @@ -83,8 +76,28 @@ pub fn edit_message_in_editor(repo: &impl GitRepo, message: &str) -> anyhow::Res if !status.success() { anyhow::bail!("editor exited with {status}"); } + Ok(()) +} + +/// Open `message` in the configured editor and return the edited result. +pub fn edit_message_in_editor(repo: &impl GitRepo, message: &str) -> anyhow::Result { + use std::io::Write as _; + + let mut tmpfile = + tempfile::NamedTempFile::new().context("failed to create temp file for commit message")?; + write!(tmpfile, "{message}").context("failed to write commit message to temp file")?; + + launch_editor(repo, tmpfile.path())?; let edited = std::fs::read_to_string(tmpfile.path()).context("failed to read edited commit message")?; Ok(edited.trim().to_string() + "\n") } + +/// Open an existing working-tree file in the configured editor. +/// +/// `path` should be the absolute path to the file. Returns when the editor +/// process exits. +pub fn open_file_in_editor(repo: &impl GitRepo, path: &std::path::Path) -> anyhow::Result<()> { + launch_editor(repo, path) +} diff --git a/src/main.rs b/src/main.rs index 273f020..e690a3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -435,6 +435,38 @@ fn main() -> Result<()> { } } } + AppAction::RunEditor { + files, + conflict_state, + } => { + let workdir = git_repo.workdir(); + let result: anyhow::Result<()> = (|| { + let workdir = workdir + .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?; + for file_path in &files { + editor::open_file_in_editor(&git_repo, &workdir.join(file_path))?; + } + Ok(()) + })(); + terminal.clear()?; + match result { + Ok(()) => { + let new_files = git_repo.read_conflicting_files(); + app.mode = + AppMode::RebaseConflict(Box::new(git_tailor::repo::ConflictState { + conflicting_files: new_files, + still_unresolved: false, + ..conflict_state + })); + app.set_success_message( + "Editor finished — press Enter when done or Esc to abort", + ); + } + Err(e) => { + app.set_error_message(format!("Editor failed: {e}")); + } + } + } AppAction::PrepareReword { commit_oid, current_message, diff --git a/src/views/commit_list.rs b/src/views/commit_list.rs index 4312b11..79aeed6 100644 --- a/src/views/commit_list.rs +++ b/src/views/commit_list.rs @@ -120,7 +120,10 @@ pub fn handle_key(action: KeyCommand, app: &mut AppState) -> AppAction { app.enter_move_select(); AppAction::Handled } - KeyCommand::Mergetool | KeyCommand::None | KeyCommand::ForceQuit => AppAction::Handled, + KeyCommand::Mergetool + | KeyCommand::OpenEditor + | KeyCommand::None + | KeyCommand::ForceQuit => AppAction::Handled, KeyCommand::SeparatorLeft => { app.separator_offset = app.separator_offset.saturating_sub(4); AppAction::Handled diff --git a/src/views/conflict.rs b/src/views/conflict.rs index 12ff547..742a866 100644 --- a/src/views/conflict.rs +++ b/src/views/conflict.rs @@ -45,6 +45,16 @@ pub fn handle_conflict_key(action: KeyCommand, app: &mut AppState) -> AppAction AppAction::Handled } } + KeyCommand::OpenEditor => { + if let AppMode::RebaseConflict(ref state) = app.mode { + AppAction::RunEditor { + files: state.conflicting_files.clone(), + conflict_state: state.as_ref().clone(), + } + } else { + AppAction::Handled + } + } KeyCommand::ShowHelp => { app.toggle_help(); AppAction::Handled @@ -191,18 +201,16 @@ pub fn render_conflict(app: &AppState, frame: &mut Frame) { } lines.push(Line::from("")); } - lines.push(Line::from(Span::raw( - " Resolve conflicts in your working tree, then:", - ))); - lines.push(Line::from("")); lines.push( Line::from(vec![ Span::styled("Enter ", Style::default().fg(Color::Green)), Span::raw("Continue "), Span::styled("m ", Style::default().fg(Color::Cyan)), Span::raw("Mergetool "), + Span::styled("e ", Style::default().fg(Color::Cyan)), + Span::raw("Editor "), Span::styled("Esc ", Style::default().fg(Color::Red)), - Span::raw(format!("Abort entire {label_lower}")), + Span::raw("Abort"), ]) .alignment(Alignment::Center), ); diff --git a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_long_summary.snap b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_long_summary.snap index 234b7c6..258d21d 100644 --- a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_long_summary.snap +++ b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_long_summary.snap @@ -10,6 +10,7 @@ Buffer { "def456gh Add feature X ", " ", " ", + " ", " ┌ Drop Conflict ─────────────────────────────────────────────┐ ", " │ │ ", " │ Merge conflict during drop │ ", @@ -19,15 +20,14 @@ Buffer { " │ dispatching for better extensibility │ ", " │ (2 commit(s) still to rebase after this) │ ", " │ │ ", - " │ Resolve conflicts in your working tree, then: │ ", - " │ │ ", - " │ Enter Continue m Mergetool Esc Abort entire drop │ ", + " │ Enter Continue m Mergetool e Editor Esc Abort │ ", " │ │ ", " └────────────────────────────────────────────────────────────┘ ", " ", " ", " ", " ", + " ", " abc123def456 2/2 ", ], styles: [ @@ -36,29 +36,25 @@ Buffer { x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 7, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 37, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 8, fg: Red, bg: Black, underline: Reset, modifier: BOLD, + x: 37, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 9, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 9, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 9, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 23, y: 10, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 33, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -79,24 +75,22 @@ Buffer { x: 71, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 15, y: 15, fg: Green, bg: Black, underline: Reset, modifier: NONE, + x: 21, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 32, y: 15, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 34, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 46, y: 15, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 48, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 57, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, + x: 61, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 14, y: 16, fg: Green, bg: Black, underline: Reset, modifier: NONE, - x: 20, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 31, y: 16, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 45, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 18, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 18, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: Blue, underline: Reset, modifier: NONE, ] } diff --git a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_narrow_terminal.snap b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_narrow_terminal.snap index 1e947af..e224091 100644 --- a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_narrow_terminal.snap +++ b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_narrow_terminal.snap @@ -15,10 +15,10 @@ Buffer { " │ (1 commit(s) still to rebase │ ", " │ after this) │ ", " │ │ ", - " │ Resolve conflicts in your working│ ", - " │tree, then: │ ", - " │ │ ", + " │ Enter Continue m Mergetool e │ ", + " │ Editor Esc Abort │ ", " └──────────────────────────────────┘ ", + " ", " abc123def456 2/2 ", ], styles: [ @@ -63,18 +63,22 @@ Buffer { x: 38, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 3, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 4, y: 10, fg: Green, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 21, y: 10, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 23, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 35, y: 10, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 36, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 37, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 38, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 3, y: 11, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 20, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, + x: 24, y: 11, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 37, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 38, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 3, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 37, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 38, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 38, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 14, fg: White, bg: Blue, underline: Reset, modifier: NONE, ] } diff --git a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_no_remaining.snap b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_no_remaining.snap index 0190bab..0f7ce49 100644 --- a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_no_remaining.snap +++ b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_no_remaining.snap @@ -9,6 +9,7 @@ Buffer { "abc123de Refactor parser module ", "def456gh Add feature X ", " ", + " ", " ┌ Drop Conflict ─────────────────────────────────────────────┐ ", " │ │ ", " │ Merge conflict during drop │ ", @@ -16,14 +17,13 @@ Buffer { " │ Conflict in abc123def4 │ ", " │ Refactor parser module │ ", " │ │ ", - " │ Resolve conflicts in your working tree, then: │ ", - " │ │ ", - " │ Enter Continue m Mergetool Esc Abort entire drop │ ", + " │ Enter Continue m Mergetool e Editor Esc Abort │ ", " │ │ ", " └────────────────────────────────────────────────────────────┘ ", " ", " ", " ", + " ", " abc123def456 2/2 ", ], styles: [ @@ -32,29 +32,25 @@ Buffer { x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, x: 33, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 6, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 37, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 7, fg: Red, bg: Black, underline: Reset, modifier: BOLD, + x: 37, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 8, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 9, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 23, y: 9, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 33, y: 9, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -67,24 +63,22 @@ Buffer { x: 71, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 15, y: 12, fg: Green, bg: Black, underline: Reset, modifier: NONE, + x: 21, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 32, y: 12, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 34, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 46, y: 12, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 48, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 57, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, + x: 61, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 14, y: 13, fg: Green, bg: Black, underline: Reset, modifier: NONE, - x: 20, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 31, y: 13, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 45, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 14, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 19, fg: White, bg: Blue, underline: Reset, modifier: NONE, ] } diff --git a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_still_unresolved_warning.snap b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_still_unresolved_warning.snap index 204f7f6..f61eca0 100644 --- a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_still_unresolved_warning.snap +++ b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_still_unresolved_warning.snap @@ -8,6 +8,7 @@ Buffer { "SHA Title ", "abc123de Refactor parser module ", "def456gh Add feature X ", + " ", " ┌ Drop Conflict ─────────────────────────────────────────────┐ ", " │ │ ", " │ Merge conflict during drop │ ", @@ -21,13 +22,12 @@ Buffer { " │ ! Still unresolved — fix all conflicts above before │ ", " │ continuing │ ", " │ │ ", - " │ Resolve conflicts in your working tree, then: │ ", - " │ │ ", - " │ Enter Continue m Mergetool Esc Abort entire drop │ ", + " │ Enter Continue m Mergetool e Editor Esc Abort │ ", " │ │ ", " └────────────────────────────────────────────────────────────┘ ", " ", " ", + " ", " abc123def456 2/2 ", ], styles: [ @@ -36,29 +36,25 @@ Buffer { x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, x: 33, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 3, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 5, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 37, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 6, fg: Red, bg: Black, underline: Reset, modifier: BOLD, + x: 37, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 7, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 23, y: 8, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 33, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -66,30 +62,30 @@ Buffer { x: 70, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 10, fg: Yellow, bg: Black, underline: Reset, modifier: NONE, - x: 29, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 28, y: 11, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 11, fg: Yellow, bg: Black, underline: Reset, modifier: NONE, + x: 29, y: 11, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 28, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 13, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 62, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 14, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 21, y: 14, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 62, y: 14, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 15, fg: Red, bg: Black, underline: Reset, modifier: BOLD, + x: 21, y: 15, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -98,24 +94,22 @@ Buffer { x: 71, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 15, y: 17, fg: Green, bg: Black, underline: Reset, modifier: NONE, + x: 21, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 32, y: 17, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 34, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 46, y: 17, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 48, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 57, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, + x: 61, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 18, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 18, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 14, y: 18, fg: Green, bg: Black, underline: Reset, modifier: NONE, - x: 20, y: 18, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 31, y: 18, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 18, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 45, y: 18, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 18, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 18, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 18, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 19, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 19, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 19, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 20, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 20, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: Blue, underline: Reset, modifier: NONE, ] } diff --git a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_files.snap b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_files.snap index 217f434..105f372 100644 --- a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_files.snap +++ b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_files.snap @@ -8,6 +8,7 @@ Buffer { "SHA Title ", "abc123de Refactor parser module ", "def456gh Add feature X ", + " ", " ┌ Drop Conflict ─────────────────────────────────────────────┐ ", " │ │ ", " │ Merge conflict during drop │ ", @@ -20,14 +21,13 @@ Buffer { " │ src/parser/expr.rs │ ", " │ tests/integration.rs │ ", " │ │ ", - " │ Resolve conflicts in your working tree, then: │ ", - " │ │ ", - " │ Enter Continue m Mergetool Esc Abort entire drop │ ", + " │ Enter Continue m Mergetool e Editor Esc Abort │ ", " │ │ ", " └────────────────────────────────────────────────────────────┘ ", " ", " ", " ", + " ", " abc123def456 2/2 ", ], styles: [ @@ -36,29 +36,25 @@ Buffer { x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, x: 33, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 3, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 5, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 37, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 6, fg: Red, bg: Black, underline: Reset, modifier: BOLD, + x: 37, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 7, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 23, y: 8, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 33, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -66,24 +62,24 @@ Buffer { x: 70, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 10, fg: Yellow, bg: Black, underline: Reset, modifier: NONE, - x: 29, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 10, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 10, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 28, y: 11, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 11, fg: Yellow, bg: Black, underline: Reset, modifier: NONE, + x: 29, y: 11, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 11, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 29, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 28, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 31, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 29, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 14, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 31, y: 14, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -92,24 +88,22 @@ Buffer { x: 71, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 15, y: 16, fg: Green, bg: Black, underline: Reset, modifier: NONE, + x: 21, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 32, y: 16, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 34, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 46, y: 16, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 48, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 57, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, + x: 61, y: 16, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 16, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 16, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 14, y: 17, fg: Green, bg: Black, underline: Reset, modifier: NONE, - x: 20, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 31, y: 17, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 45, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 17, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 17, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 18, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 18, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 18, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 18, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 19, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: Blue, underline: Reset, modifier: NONE, ] } diff --git a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_remaining.snap b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_remaining.snap index e56ea6e..9593490 100644 --- a/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_remaining.snap +++ b/tests/snapshots/tui_drop_confirm__drop_conflict_dialog_with_remaining.snap @@ -8,6 +8,7 @@ Buffer { "SHA Title ", "abc123de Refactor parser module ", "def456gh Add feature X ", + " ", " ┌ Drop Conflict ─────────────────────────────────────────────┐ ", " │ │ ", " │ Merge conflict during drop │ ", @@ -16,14 +17,13 @@ Buffer { " │ Refactor parser module │ ", " │ (3 commit(s) still to rebase after this) │ ", " │ │ ", - " │ Resolve conflicts in your working tree, then: │ ", - " │ │ ", - " │ Enter Continue m Mergetool Esc Abort entire drop │ ", + " │ Enter Continue m Mergetool e Editor Esc Abort │ ", " │ │ ", " └────────────────────────────────────────────────────────────┘ ", " ", " ", " ", + " ", " abc123def456 2/2 ", ], styles: [ @@ -32,29 +32,25 @@ Buffer { x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, x: 33, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 3, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 4, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 5, fg: Red, bg: Black, underline: Reset, modifier: BOLD, - x: 37, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 5, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 5, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 10, y: 6, fg: Red, bg: Black, underline: Reset, modifier: BOLD, + x: 37, y: 6, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 6, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 7, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 7, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 7, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 23, y: 8, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 33, y: 8, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 8, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 9, fg: Red, bg: Black, underline: Reset, modifier: NONE, @@ -71,24 +67,22 @@ Buffer { x: 71, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 15, y: 12, fg: Green, bg: Black, underline: Reset, modifier: NONE, + x: 21, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 32, y: 12, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 34, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 46, y: 12, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, + x: 48, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 57, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, + x: 61, y: 12, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 12, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 10, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 14, y: 13, fg: Green, bg: Black, underline: Reset, modifier: NONE, - x: 20, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 31, y: 13, fg: Cyan, bg: Black, underline: Reset, modifier: NONE, - x: 33, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 45, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 13, fg: Reset, bg: Black, underline: Reset, modifier: NONE, x: 70, y: 13, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 9, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 10, y: 14, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - x: 70, y: 14, fg: Red, bg: Black, underline: Reset, modifier: NONE, x: 71, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 9, y: 15, fg: Red, bg: Black, underline: Reset, modifier: NONE, - x: 71, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 19, fg: White, bg: Blue, underline: Reset, modifier: NONE, ] } From bdd90ffddd055570a52e7b034aedfd72043cfdba Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 20:41:43 +0000 Subject: [PATCH 18/19] feat: Add cargo-deny with license check configuration (T112) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 44bc428..7b32fec 100644 --- a/TASKS.md +++ b/TASKS.md @@ -98,7 +98,7 @@ Guidelines: ## CLI Output & Compatibility ## Build & CI -- [ ] T112 P3 feat - Set up cargo-deny with configuration to check dependency +- [X] T112 P3 feat - Set up cargo-deny with configuration to check dependency licenses are compatible with Apache 2.0: install cargo-deny, create `deny.toml` config allowing Apache-compatible licenses (Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, etc.), deny copyleft licenses (GPL, LGPL, From f36c1336ce252e60a1226bb7644c1c8d2105f1d9 Mon Sep 17 00:00:00 2001 From: Thomas Johannesson Date: Fri, 20 Mar 2026 20:44:59 +0000 Subject: [PATCH 19/19] ci: add cargo-deny license check to GitHub Actions (T113) --- .github/workflows/rust.yml | 4 ++++ TASKS.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 67bb5c4..c48e2d9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -20,6 +20,10 @@ jobs: run: cargo fmt --all -- --check --verbose - name: Clippy run: cargo clippy --all-targets -- -D warnings --verbose + - name: Licenses + uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check licenses - name: Build run: cargo build --all-targets --verbose - name: Run tests diff --git a/TASKS.md b/TASKS.md index 7b32fec..f8d10e5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -104,7 +104,7 @@ Guidelines: BSD-2-Clause, BSD-3-Clause, ISC, etc.), deny copyleft licenses (GPL, LGPL, AGPL), and add `cargo deny check` command to verify no license violations in the dependency tree -- [ ] T113 P3 feat - Add cargo-deny to GitHub Actions CI: create or update +- [X] T113 P3 feat - Add cargo-deny to GitHub Actions CI: create or update `.github/workflows/ci.yml` to run `cargo deny check licenses` alongside existing format/clippy/test checks, failing the build if any dependency license conflicts are detected; ensure this runs on pull requests and main