diff --git a/.github/prompts/implement-task.prompt.md b/.github/prompts/implement-task.prompt.md
index 0b77d8e..a3decea 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.
@@ -73,16 +72,34 @@ 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)
-- Test approach (existing tests, new tests only if essential)
+- 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
+ 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 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.
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/CHANGELOG.md b/CHANGELOG.md
index 174030b..57fdbbe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,47 @@ 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]
### 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.
+- 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
+
+- 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".
+- 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.
+- 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.
+- 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
merge-base with a configured base branch (e.g. `main`).
- Hunk group matrix panel: a fragmap-style visualization showing which commits
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-COMPLETED.md b/TASKS-COMPLETED.md
index 2f7cff3..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,42 +391,119 @@
(`\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 — 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
+
+- [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
+- [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
+- [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
+
+## 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
+ 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
+- [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
+
+
+## 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
+ 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:
+
+
+## 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
+ 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 269f4e5..f8d10e5 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.
@@ -13,15 +12,65 @@ Guidelines:
## UNCATEGORIZED
-## Interactivity — Fragmap View (V5)
-- [ ] T108 P1 fix - Fix fragmap relations not following file renames: when a
+## Bug Fixes — Squash & Fixup
+- [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
+ 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`
+- [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
+ 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
+- [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 (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)
+- [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
+ 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 — Conflict Resolution
+- [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
+ (`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
the same logical content, but currently they are treated as separate files and
don't form clusters. Investigate the original fragmap Python implementation
(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
@@ -30,7 +79,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
@@ -40,64 +89,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)
-- [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)
+## CLI Output & Compatibility
-## Build & CI (V5)
-- [ ] T112 P3 feat - Set up cargo-deny with configuration to check dependency
+## Build & CI
+- [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,
AGPL), and add `cargo deny check` command to verify no license violations in
- the dependency tree (Flags: V5)
-- [ ] T113 P3 feat - Add cargo-deny to GitHub Actions CI: create or update
+ the dependency tree
+- [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
- 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
@@ -106,44 +117,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)
-- [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)
+ 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)
-
-## 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)
+ improvements
## Notes
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/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/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/main.rs b/src/main.rs
index 09c5c57..e690a3c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -34,10 +34,18 @@ 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).
- 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 +107,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 +128,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(());
@@ -293,11 +306,14 @@ 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. 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();
@@ -313,36 +329,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;
@@ -403,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,
@@ -448,11 +512,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 c10ed53..286e9b4 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