diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81e1ce7dae9..c26e828a474 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,7 +133,7 @@ jobs: continue-on-error: true - name: Check for unrecognized *-sys dependencies run: | - ! grep -qP '(?`. Give it the purposeful subject the completed change + is expected to use so an interrupted session remains identifiable; do not use + a `WIP:` prefix. +- When the task is complete, stage the intended changes and amend that + provisional commit. Replace the WIP author with the responsible agent's own + name and email, and update the subject and descriptive body to match what was + actually completed. The final commit must contain the complete task and leave + the worktree clean; never leave the provisional author in finished history. + ### Code Style - Follow existing patterns in the codebase diff --git a/Cargo.lock b/Cargo.lock index 67787fcc43d..0cae2981541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,6 +1078,27 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -1340,6 +1361,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1409,6 +1439,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1435,6 +1471,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2568,10 +2613,19 @@ name = "gix-tix" version = "0.2.0" dependencies = [ "anyhow", + "clap", "crossterm", + "directories", "gix", "gix-testtools", + "insta", + "notify", "ratatui", + "terminal-colorsaurus", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tui-markdown", ] [[package]] @@ -3190,6 +3244,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.48.0" @@ -3319,6 +3393,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3473,6 +3556,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -3506,6 +3609,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -3720,6 +3832,33 @@ dependencies = [ "serde", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3862,6 +4001,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "page_size" version = "0.6.0" @@ -4068,6 +4213,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -4131,6 +4285,25 @@ dependencies = [ "nix", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quinn" version = "0.11.11" @@ -4355,6 +4528,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.4" @@ -4384,6 +4568,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.13.4" @@ -4459,6 +4649,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.118", + "unicode-ident", +] + [[package]] name = "rusqlite" version = "0.40.1" @@ -4966,6 +5185,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -5088,6 +5313,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal-colorsaurus" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" +dependencies = [ + "cfg-if", + "libc", + "memchr", + "mio", + "terminal-trx", + "windows-sys 0.61.2", + "xterm-color", +] + +[[package]] +name = "terminal-trx" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -5166,6 +5417,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -5174,6 +5426,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5292,6 +5554,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -5363,6 +5637,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -5456,6 +5743,20 @@ dependencies = [ "toml", ] +[[package]] +name = "tui-markdown" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c95afc7b66008823dea2614a800d944e18990690db47517efc66cd01ff9cee" +dependencies = [ + "itertools 0.15.0", + "pretty_assertions", + "pulldown-cmark", + "ratatui-core", + "rstest", + "tracing", +] + [[package]] name = "tui-react" version = "0.24.0" @@ -5483,6 +5784,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -6131,6 +6438,9 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -6164,6 +6474,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xterm-color" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7008a9d8ba97a7e47d9b2df63fcdb8dade303010c5a7cd5bf2469d4da6eba673" + [[package]] name = "xz2" version = "0.1.7" diff --git a/deny.toml b/deny.toml index 25f11002035..cd6740261fe 100644 --- a/deny.toml +++ b/deny.toml @@ -28,6 +28,7 @@ allow = [ "Apache-2.0", "BSD-3-Clause", "BSL-1.0", + "CC0-1.0", "CDLA-Permissive-2.0", "MIT", "MIT-0", diff --git a/gix-tix/AGENTS.md b/gix-tix/AGENTS.md new file mode 100644 index 00000000000..07037ab964d --- /dev/null +++ b/gix-tix/AGENTS.md @@ -0,0 +1,60 @@ +# gix-tix invariants + +## Compatibility + +- Tix is experimental. Implement requested command-line and behavioral changes + without compatibility aliases or migration shims unless compatibility is + explicitly requested. + +## Behavioral specification + +- Keep `spec.md` synchronized with every user-visible, lifecycle, performance, + or resource-ownership change to tix. Update the specification and its + regression coverage in the same semantic patch as the implementation. + +## Command output + +- Write warnings, diagnostics, and informational messages to stderr. Reserve + stdout for a command's primary data output so it remains safe to redirect. + +## Test repositories + +- Open fixture repositories through `crate::test_repository::open()` or + `open_with()`. They isolate configuration and provide deterministic author, + committer, signing, editor, and date defaults; pass only behavior-specific + overrides to `open_with()`, which take precedence over those defaults. + +## Commit authorship + +- Create one commit per semantically distinct change. Do not combine independent + behavior changes merely because they were requested together. +- Write a descriptive commit body for every commit, not only a subject line. + Explain the motivation and relevant prior behavior, the context needed to + review the change, and any important constraints or decisions. The message + should let a reviewer understand why the change exists without reconstructing + the conversation that produced it. +- Keep each change's implementation, tests, snapshots, and corresponding + `spec.md` updates in that change's commit. +- A commit created or materially rewritten by an AI agent must use that agent's + own name and email as its author. Set it explicitly when necessary, for example + with `tix reword --author "Agent Name "`; do not silently + inherit the repository owner's configured identity. +- Never impersonate the repository owner, user, or another person when authoring + an agent-created or materially agent-rewritten commit. +- Preserve the author of an existing commit when the agent is not responsible for its contents. +- Keep this provenance in commit metadata so reviewers can distinguish agent-authored changes without relying on commit-message trailers. +- If follow-up work semantically belongs to an existing local commit, amend it + into that commit instead of adding a corrective commit at the tip. Use + `tix travel ` when suitable to edit it while retaining its descendants, + then return through the saved branch after the descendants are rebased. + +## Repository lifetime + +- Treat the event-loop head as a repository-lifecycle boundary. After any wait, + the original worktree directory and process CWD may no longer exist; recover + to the normalized common repository before processing watchers, loading view + data, or drawing. +- Do not retain a `gix::Repository`, or a platform/object that owns one, in application or event-loop state while tix is idle. Exceptions are an unresolved in-memory rebase result awaiting the user's immediate accept-or-cancel key, and line-diff worker repositories during their bounded ten-second reuse window. The event loop must wake at the line-diff deadline and join the entire pool; workers must rebuild diff platforms per batch, and dropping either exception must leave no observable repository state. +- Open a fresh, non-isolated repository for bounded view population so configuration such as mailmap and diff filters is honored, then retain only detached display data. +- The fill repository may be reused only while continuous navigation is active and must be dropped when its idle timer expires. +- Filesystem watchers retain paths and native watcher handles, never repositories. diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index c8272c17329..c005496c5f7 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -24,12 +24,21 @@ sha256 = ["gix/sha256"] [dependencies] anyhow = "1.0.98" +clap = { version = "4.5.42", features = ["derive"] } crossterm = { version = "0.29.0", features = ["osc52"] } -gix = { version = "^0.87.0", path = "../gix", default-features = false, features = ["mailmap", "parallel", "revision"] } +directories = "6.0.0" +gix = { version = "^0.87.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "merge", "notes", "parallel", "revision", "command", "status"] } +notify = "8.2.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } +terminal-colorsaurus = "1.0.3" +tracing = "0.1.37" +tracing-appender = "0.2.4" +tracing-subscriber = "0.3.17" +tui-markdown = { version = "0.3.9", default-features = false } [dev-dependencies] -gix-testtools = { path = "../tests/tools" } +gix-testtools = { path = "../tests/tools", features = ["sha1"] } +insta = "1.46.3" [package.metadata.docs.rs] features = ["sha1"] diff --git a/gix-tix/spec.md b/gix-tix/spec.md new file mode 100644 index 00000000000..6de71778e17 --- /dev/null +++ b/gix-tix/spec.md @@ -0,0 +1,1334 @@ +# gix-tix specification + +This document describes the intended behavior of `tix` on this branch. It is the +behavioral contract for future changes; implementation details belong here only +when they preserve responsiveness, bounded memory, Git compatibility, or resource +lifetime. + +## Purpose and invocation + +`tix` is a minimal, `tig`-inspired commit-history browser optimized for large +repositories. It must remain useful on histories as large as the Linux kernel +without trading responsiveness for metadata that is not visible. + +- `tix [REVISION]...` shows commits reachable from the supplied revisions, or + from `HEAD` when none are supplied. +- `tix show [-x HIDDEN...] [--no-auto-hide] [TIP...]`, also available through + the visible `tix status` alias, prints the complete + history view without opening a terminal UI. Tips default to `HEAD`, and + applicable pins participate exactly as they do in the history view. Output + uses the history view's graph lanes and default metadata, without colors, + selection, clipping, or a footer. Each visible root replaces its ordinary row + with a centered `──── base ────` separator; distinct roots therefore + delineate their trees while retaining the commit's markers and metadata. Each + seven-character commit hash is followed by its seven-character reverse-hex + change ID. Colliding or duplicated prefixes remain visible and receive a `💥` + gutter marker. +- `tix travel [--materialize-conflicts] (REVSPEC | --to first|parent|child|tip)` + performs the same detached checkout, pending-rebase replay, stash handling, + and pin reconciliation as TUI time travel. Its target may also be an + unambiguous reverse-hex change-ID prefix from the default Tix view. `parent` + and `child` move one edge from `HEAD`; `first` selects its oldest reachable + root and `tip` its reachable leaf, considering only commits visible in the + default view. Multiple direct or terminal candidates are reported with their + commit and change IDs and must be selected with a direct `tix travel REVSPEC`. + Travelling to the current `HEAD` is a no-op. + A detached source may travel to a descendant without a pin, but travelling to + an ancestor or unrelated commit requires an existing current-worktree pin at + `HEAD` or a descendant. An attached source is preserved through the singleton + HEAD-pin rules. Replay conflicts change nothing unless explicitly + materialized; an accepted conflict writes the checkout and unmerged index, + then exits with an error so resolution cannot be mistaken for completion. +- `tix stash` saves the index and worktree state in a gix stash associated with + the `HEAD` commit through the same commit-stash operation as the TUI. +- `tix copy-insert [--materialize-conflicts [CONTINUE]] C I` exposes the TUI + copy-insert action without opening it. + Both operands accept Git revisions or unambiguous reverse-hex change-ID + prefixes from the default Tix view; `C` is the commit to copy and `I` is the + commit above which the copy is inserted. A conflict aborts without changing + repository state unless explicitly materialized into the ordinary rebase + continuation workflow. +- `tix admin clear-undo` atomically and idempotently deletes the current + worktree's undo and redo queue. It does not apply or reverse queued operations, + change their recorded references, or affect another worktree's queue. +- `tix enrich commit todo [--clear] [REVSPEC]`, `tix enrich commit note + [REVSPEC]`, `tix enrich commit git-note [REVSPEC]`, and `tix enrich tree + checks-pass [--clear] [REVSPEC]` expose the TUI's enrichment actions without + opening it. Targets default to `HEAD` and accept Git revisions or unambiguous + reverse-hex change-ID prefixes from the default Tix view. Boolean commands + idempotently set their marker, or clear it with `--clear`; note commands use + Git's editor, remove empty notes, and leave unchanged notes alone. Output + starts with the target's abbreviated commit and change IDs before its status. +- `tix new [--index | --worktree | --worktree-untracked] [--allow-empty] [--todo] + [--author "Name "] [-m MESSAGE ... | -f FILE]` creates a child of `HEAD`, or a root commit for + unborn `HEAD`, with the same signing, editor, enrichment, lazy-rebase, and + worktree-safety rules as `a w`. By default a changed index wins and tracked + worktree changes are used only when the index is unchanged. `--index` uses + only the index delta; `--worktree` applies only unstaged tracked-worktree + changes to the `HEAD` tree and omits staged-only changes. `--worktree-untracked` + additionally includes non-ignored untracked files. An unchanged selected tree + is rejected unless `--allow-empty` is given. Message files, + repeated messages, standard input, editor bypass, and `--author` follow + `tix reword`; the author uses the prepared new-commit date. `--todo` enables + the new commit's editable Todo header. +- `tix reword REVSPEC [--author "Name "] [-m MESSAGE ... | -f FILE]` + applies the same signing, + lazy-rebase, mutable-ref, and worktree-safety rules as the TUI. Without either + message option it opens the standard Markdown reword document. Repeated + `-m/--message` values form paragraphs; `-f/--file` reads the complete message + from a file, or from standard input when given `-`. Explicit sources bypass + the editor and do not add suggested trailers. `--author` replaces the author + actor while preserving its date. Without an explicit message it prefills the + normal editor document; with one it is applied non-interactively. An attached + `HEAD` may reword itself without a pin. Every other target requires an existing + current-worktree tix pin at that commit or a descendant. As with `tix travel`, + an unambiguous default-view change-ID prefix may replace the Git revspec; + every such covering pin participates so retained forks are rewritten together. + Eligibility is checked before the editor opens, and an unchanged document is + an explicit no-op. Editor documents also contain commented `Todo` and + `Message:` enrichment headers. An uncommented bare `Todo` enables the flag; + commenting or deleting it disables the flag. `Message:` accepts one title + line. Editing that title preserves an existing message body byte-for-byte, + while commenting, deleting, or emptying the header removes the whole message. + Explicit `-m` and `-f` messages preserve enrichments. +- Primary command output follows every displayed abbreviated commit hash with + its reverse-hex change ID. The two abbreviations have equal widths. This + applies to mutation results, rewritten-ref mappings, pins, stash and travel + notices, raw commit labels in `ref-tree`, and visible commit identifiers in + rebase todos. Diagnostics on stderr and the full object IDs in the hidden + `tix-rebase-state-v2` block remain unchanged. +- `-x/--hide REVSPEC` excludes the revision and its reachable ancestry. The + option may be repeated. +- `-h/--help` prints Clap's standard help for `tix` and every subcommand. +- `--quit-on-finish[=INPUTS]` exits after traversal, lane computation, and one + completed frame, for measurement and non-interactive inspection. Optional + characters are replayed as read-only keyboard input before the retained final + frame, allowing navigation such as `--quit-on-finish=jjjl`. Inputs that would + mutate the repository, launch another program, or copy data are ignored. The + frame is drawn on the normal screen and remains visible after exit. +- `--no-alt-screen` runs the interactive UI in a full-height inline viewport on + the normal screen, retaining its frame and panic output for diagnostics. Input + handling otherwise matches the default interactive mode. +- `tix rebase todo [-x HIDDEN...] [--no-auto-hide] + [--onto REV | --update-base] [TIP...]` + writes a self-contained Markdown history-rebase plan to stdout. Visible tips + default to `HEAD`, and an ambiguous derived fork point is an error. With + `--update-base`, the uniquely derived fork point is rebased onto the same newer + hidden local branch tip offered by TUI `rebase-update`; absence of such a tip + is an error. The resulting `(updated-base)` plan remains actionable when saved + unchanged. `--update-base` and an explicit `--onto` are mutually exclusive. + `--edit-and-apply` opens the same plan with Git's configured editor and applies + it when the editor exits. It also accepts `--materialize-conflicts [CONTINUE]` + to opt into the same conflict checkout and continuation-document workflow as + `tix rebase apply`; the option requires `--edit-and-apply`. +- `tix show`, `tix ref-tree`, and `tix rebase todo` automatically inspect symbolic + `refs/remotes//HEAD` references. Their targets are reverse-mapped + through each remote's fetch refspec, and existing local commit branches are + added to the explicit hidden revisions. Multiple remote defaults are + deduplicated; stale, direct, ambiguous, unmappable, missing, and non-commit + results are ignored. At least one explicit or inferred hidden revision is + required by commands that need a hidden boundary. `--no-auto-hide` disables + inference. The interactive history retains its explicit-only behavior. +- `tix rebase apply [FILE]` applies such a plan from a file, or from standard + input when `FILE` is omitted or `-`. Removing its state comment or emptying the + document cancels successfully; malformed or unsupported state is an error. +- By default, a todo conflict changes nothing. Explicit + `--materialize-conflicts [CONTINUE]` accepts the partial result, checks out the + conflicting commit with an unmerged index, and writes a fresh editable + continuation todo to `CONTINUE`, or stdout when `-` is used. A terminal stdout + is refused. Materialization exits unsuccessfully so scripts cannot mistake the + incomplete rebase for completion. +- Editor-launching commands honor Git's normal editor selection and + `GIT_EDITOR` overrides it. +- Revisions must resolve and peel to commits. Invalid or non-commit visible + revisions are errors. An unavailable hidden revision emits a warning and is + ignored when another hidden revision resolves; if none resolve, startup fails. +- The interactive UI owns the alternate screen by default. `--no-alt-screen` + instead draws interactively on the normal screen. Raw mode, focus reporting, + mouse capture, and enhanced keyboard reporting are restored on every exit path. + Shutdown leaves the alternate screen without clearing it or writing afterward. + `--quit-on-finish` draws without input reporting on the normal screen. +- `Ctrl-C` exits immediately from any normal tix focus without recovery + bookkeeping. `q` always quits from history, including while a conflict or + rebase continuation is suspended. Before that normal exit, tix journals + already-materialized reference progress and drops only in-memory candidates; + it never rolls repository state back. `q` or `Escape` in a focused changes + block still returns focus to history. + +## History model + +### Traversal and projection + +- Traversal streams commits before graph-lane computation finishes. The footer + reports the number received while loading and switches to the selected row + number after completion. Every visible root starts at `#0`; descendants use + their on-screen row distance from that root. A merge reachable from multiple + visible roots uses the visually closest root, so only one count is shown. +- Commit topology, commit time, and generation are loaded through the same + commit-graph-or-ODB lookup model as `gix-traverse`. A small object cache avoids + repeated ODB decoding during a walk. +- Metadata already decoded from ODB is retained. Metadata omitted because a + commit came from the commit-graph is populated lazily for visible rows. +- The persistent graph is append-only and index-addressed, with one compact copy + of each commit and flat parent edges. View refreshes project rows from this + cache and stop walking when complete cached ancestry is reached. +- Local branch targets are reverse-indexed. Configured upstream targets are added + as internal traversal tips so ahead/behind calculations have complete ancestry + without a second repository walk. +- Shallow boundaries are honored. Parent topology needed by future projections, + hidden expansion, and ahead/behind calculations must not be pruned with the + currently visible lane graph. + +### Hidden history + +- Hidden ancestry is removed from the selectable view by default. Direct parents + that connect visible history to hidden history remain as boundary rows. +- Boundary rows retain graph styling but use terminal-default colors, are dimmed, + and can be selected, paged to, restored as a selection, copied, and inspected. + They cannot be reworded, forgotten, or signature-verified. During review-base + selection, only an eligible base boundary remains selectable among hidden rows. + They may be used for time travel or as the parent of an independent fork commit. + A boundary whose visible descendants contain no merge commit offers the + history-rebase editor, including when those descendants fork into multiple + linear stacks. +- If a boundary has exactly one leaf among its visible descendants, selecting it + uses the boundary-to-leaf tree comparison for the changes block and selection + diff-stat. Forks which merge back into one leaf qualify; multiple surviving + leaves retain the boundary commit's ordinary parent diff. Enter opens the same + complete branch diff, labelled `..`. +- Hidden revisions do not change the default reference display mode. +- `v`, then `h`, toggles the full hidden projection. Toggling preserves the + selected commit when it still exists and otherwise selects the newest + selectable row. +- When a hidden revspec names a local branch, its best common base with the + visible tips permanently shows `⇣N` after the commit title when that branch has + `N` commits not reachable from the view. The terminal edge pushes the marker + left over a clipped title when necessary. A blank margin remains on each side. + The cached history graph supplies the base and count; unrelated refs and + zero-count relations add no marker. If multiple hidden branches share a base, + the largest count is shown and its tip is retained as the update target; equal + counts choose a deterministic object ID. + +### Row content and visual states + +- A row contains graph lanes, a seven-character object ID, optional references, + author date by default, author and attribution information, markers, and title. + Simple lane turns use rounded corners in both the TUI and `tix show`; merge + tees and crossings remain orthogonal so every commit still occupies one row. +- The commit marker is blue when unsigned, orange when signed but unverified or + being verified, green when verified, and bright red when verification fails. +- The current `HEAD` commit, including a review commit, uses `@` instead of the + normal commit disc. The marker is italic when HEAD is attached to a branch and + keeps the same signature and selection coloring. It remains visible when textual + reference labels are hidden, and textual `HEAD` is never rendered alongside it. +- At startup, the current worktree's `@` row becomes selected as soon as it is + loaded, unless the user navigates first. Once the viewport is known, the row + is centered with normal history-boundary clamping so surrounding commits are + visible. While the row is unselected, its title is shown in reverse video and + `@` is bold; the selected row's normal inversion replaces that title emphasis + while keeping `@` bold. +- Local branches checked out in other worktrees are displayed as `short-name@` + in light blue instead of their plain branch decoration. The current worktree's + symbolic branch is displayed as `@short-name` in the local-reference color. + A detached foreign worktree is shown as `directory@` at its actual `HEAD`, + without a pin marker. Its symbolic HEAD pin is shown separately as `★branch` + at that branch's actual tip. The worktree administration name is used when no + directory basename is available. A detached current worktree is identified by + the graph `@` alone and has no redundant `@directory` label. Identical labels + are deduplicated. +- An unselected commit checked out by any foreign worktree gives only its title + a dark-gray background, whether that worktree is attached or detached and even + when reference labels are hidden. The current worktree's reverse-video title + takes precedence when both point to the same commit; selection clears either + title emphasis. +- When reference labels are hidden, worktree labels are visible only on the + selected row. Stale, malformed, unborn, and otherwise unreadable worktree + entries are skipped without failing history loading. +- The selected row uses `>` at the left. If the displayed worktree block is dirty, + `🫟` is shown at the `HEAD` row instead; a separately selected row retains `>`. +- A selected row at the current worktree is inverted from its left edge through + the final displayed title character. Any other selected row is inverted through + its non-title metadata, leaving the final space and title uninverted. The graph + background is derived from the commit-marker color. The selected row's + right-hand tail and contextual information remain separate, have blank margins, + and never invert an adjacent character. +- A compared merge parent is cyan, including its commit marker, and its hash is + inverted. +- Rows outside active review-base reachability are dimmed. When a changes block has + focus, history is dimmed but its contextual selection information and main + status line remain prominent. + +### Metadata and attribution + +- Mailmap resolution is enabled by default and is obtained from a non-isolated + repository. +- Recognized attribution trailers are `Co-authored-by`, `Assisted-by`, + `Reviewed-by`, `Acked-by`, `Tested-by`, and `Signed-off-by`. +- Every displayed `Assisted-by` value is classified as an agent. Agent names are + bracketed and agent emails are never displayed. +- Attribution keys with identical displayed actor lists are grouped, for example + `Co, A: [GPT 5.6]`. +- Actors whose email ends in `@users.noreply.github.com` are italicized. +- Full-actor mode shows author emails and attribution actors but hides the commit + title. Classified agent emails remain hidden. +- A commit message containing `--- agent` or `` receives a bright + purple `[A]` before its title. +- A commit with notes in the configured notes ref receives a matching `[N]`. + Notes are loaded lazily for visible commits. +- `n g` edits the selected commit's note in `core.notesRef`, or + `refs/notes/commits` when it is unset. The action is available on every + selected commit, including immutable hidden boundaries. Empty content removes + the note and unchanged content is a no-op. +- Every operation that actually rewrites a commit copies its default-ref Git + note to the successor while retaining the predecessor note. Notes that converge + through a squash are concatenated in source order with a blank line. A split + copies the source note only to its rewritten lower identity; inserted and + dropped commits do not propagate notes. The notes ref changes atomically with + the other rebase refs and participates in rollback. +- Tix enrichments are stored separately as Git notes headed at the worktree-local + `refs/worktree/tix/enrich` ref. Enrichments are keyed by the commit's effective + change ID and use human-readable Git config. Independent `[commit]` keys store + `todo = true` and an optional multiline `note` value. + Consequently, rewritten commits retain their metadata and commits sharing a + change ID share it as well. Malformed enrichments are ignored for display and + diagnosed, while mutation refuses to overwrite them. +- Tree enrichments use the same human-readable Git config format in notes headed + at `refs/worktree/tix/enrich-tree`. They are keyed directly by tree object ID; + `[tree] checks-pass = true` therefore applies to every commit with that exact + tree and naturally disappears when a rewrite changes the tree. +- Todo, note, and checks-pass enrichments receive a leading `🚧`, `📝`, and `✔️` + respectively before + the graph, with no gap between them or the following status field. The dedicated + field remains visible alongside selection, dirty-worktree, and conflict markers. + `tix show` emits the same field and aligns unmarked rows when any displayed + commit has either enrichment. +- Only the selected history row prefixes its commit title with its note title, + using black text on a yellow background followed by one unstyled space. + Unselected rows and `tix show` retain only the commit title. +- Commit and selected-note titles render Markdown styling. Block-shaped title + output is flattened onto the single history row; plain command output retains + the rendered text without terminal styling. + +### Selection context + +- When tree changes are displayed, non-zero insertion and deletion counts for + the selected commit appear immediately before the right selection tail. +- When a selected commit is pointed to by local refs, display at most one + deterministic relationship. Prefer a configured-upstream relation as + `⇡ahead⇣behind`; otherwise, when hidden ancestry exists, show the visible-only + count as `⇡N`. +- Relationship walks use the in-memory graph, stop once no further distinction + can be made, and cache completed results. They must never reopen a repository + merely because selection moved. + +### Ref-tree overviews + +- After traversal completes, `t` toggles history and a rounded rail ref-tree. + `Escape` also returns directly to history. The ref-tree cursor and viewport are + independent of the history selection and panes. The direct ref-tree action + appears in the `?` information group. + “Tree” without the `ref-` prefix refers to Git tree objects and tree diffs. +- Entering the overview expands its completed graph with every successfully + resolved main and linked worktree `HEAD` plus every valid symbolic + `refs/worktree/tix/pins/HEAD` target. This does not add those commits to the + history view. Special refs are excluded. First-parent paths form a + forest whose referenced commits, forks, roots, shallow boundaries, and raw + tips remain as nodes while linear runs are contracted. `Shift-T` toggles + tags; when hidden, tag labels and tag-only anchors are removed before this + projection. +- The component containing `HEAD` sorts first. Children sort by their smallest + reference label and then object ID. Initial selection is `HEAD`, then a raw + tip, then the first node; refresh and re-entry preserve the ref-tree cursor when + its commit remains available. +- The selected node shows the exact number of commits reachable + through all parents. Other reference and raw-tip nodes show the number reachable + from them but not from the selection as `N•`; multiple labels at one commit + share one count. Space fixes this count anchor at the selected node or clears it + when pressed there again, so cursor navigation can reuse reachability and layout + caches. Selected first-parent ancestry is emphasized, other reachable history is + dimmed, and exclusive history remains normal. A non-selectable `●` splits a + contracted edge where it becomes reachable. Exact exclusive counts are computed + and cached only for reference rows visible in the viewport. +- The ref-tree orders tips above roots and renders one retained or boundary + node per row. Rounded ancestry lanes precede aligned counts and labels; their + `●` disk is the node marker, while the smaller `•` is the commit-count unit. + Referenced or raw-tip nodes whose commits are present in history use the + current-history cyan; other linked-worktree nodes use dark green. + Selection inverts both the node disk and its label, including synthetic nodes + whose disk is otherwise unlabelled. +- Rendering clips lanes and node labels to the viewport. +- Plain directions choose the nearest node in the requested screen direction. + Shift-directions instead navigate topologically: Up moves toward leaves, Down + toward roots, and Left/Right chooses a remembered child; `i/n` and an + emphasized edge always show the choice. +- `g` selects the top ref-tree node, and `Shift-G` selects the root of the current + component. Unshifted mouse pans the viewport, while Shift-mouse moves to the + nearest node. Unshifted full- or half-page Ctrl/Page input moves the cursor by + the corresponding viewport distance and keeps it visible; shifted Ctrl/Page + input pans the viewport without moving the cursor. +- `e` opens node-level reference editing. `d` deletes every eligible local branch + immediately. `e r` is offered only when selected remote-tracking references + map uniquely through a named remote's fetch refspecs; it deletes every resolved + remote reference, grouped into one Git push per remote. Pushes continue after + individual failures and run with the terminal suspended for output and authentication. +- `` on a node with visible references creates or reuses symbolic + current-worktree pins for every displayed local branch, tag, remote-tracking + reference, or review reference at that commit. It returns to history and + selects the pinned commit in the first refreshed frame, with its cached + ancestry and hidden merge-base boundary already projected. Synthetic nodes, raw tips, detached-worktree labels, + and stash associations have no Enter action. +- Worktree branch labels keep the history view's `@branch`, `branch@`, and + `★branch` forms at the branch's actual tip. A detached current worktree is + additionally shown with one `📌`; a detached foreign worktree instead uses + `directory@` at its actual `HEAD`. Ordinary tix pins neither anchor nor + decorate the ref-tree. +- After deleting selected local or remote references, refresh keeps the commit + when it remains a ref-tree node, otherwise selects the next surviving node row or + the nearest previous row when nothing below survives. + +### Ref-tree diagnostics + +- `tix ref-tree` prints the non-hidden reference projection to standard output without + terminal colors, selection state, counts, or viewport clipping. A detached + current worktree renders as `[pin]` in ASCII output and `📌` with `--unicode`. + It traverses all normal references by default; positional revisions scope + traversal. Hidden reference labels and traversal tips are omitted, including + local defaults inferred from remote HEADs unless `--no-auto-hide` is given. +- Worktree traversal is always enabled. `--no-tags` and repeatable + `-x/--hide ` match the corresponding ref-tree inputs. Output uses + ASCII lines and `o` nodes by default; `--unicode` uses the interactive + ref-tree's rounded line and node glyphs. + +## Interaction + +### Navigation and display controls + +| Key | Behavior | +| --- | --- | +| `j`/Down, `k`/Up | Move one selectable row or changed path. Shift follows the first parent or chosen child. | +| Mouse/trackpad vertical scroll | Pan history by the coalesced scroll distance without moving its cursor; Shift moves the cursor instead. Mouse input continues to move paths when a changes block is focused. | +| `h`/`l` | Pan history or the focused changes block horizontally. Shift chooses a remembered topological child. Shift-horizontal mouse input does the same. | +| `Ctrl-u`/`Ctrl-d` | Move the cursor half a page; Shift pans the viewport half a page. | +| `Ctrl-b`/`Ctrl-f`, `PageUp`/`PageDown` | Move the cursor a page; Shift pans the viewport a page. Both forms scroll an overflowing commit message when applicable. | +| `g`/Home, `G`/End | Select the newest/top or oldest/bottom selectable item. | +| `?` | Toggle the information key group. | +| `t` | Toggle the rounded ref-tree overview. | +| `[` | Cycle viewport-local title alignment, full-column alignment, no alignment, and compressed history. | +| `v` | Toggle the history-display key group. Pressing `v` again closes it. | +| `v d` | Cycle author dates, committer dates, and no dates. | +| `v i` | Cycle commit IDs, change IDs, and no explicit IDs. | +| `v s` | Toggle full actors/emails and titles. | +| `v e` | Cycle all attribution, author only, and no names, skipping inert states. | +| `v t` | Toggle attribution trailers. | +| `v m` | Toggle mailmap resolution. | +| `v r` | Cycle all, normal, and no reference labels. | +| `v h` | Show or hide configured hidden ancestry. | +| `r` | Hide reference labels or restore the mode visible when they were hidden. | +| `m`/`]` | Toggle the commit-message view. | +| `p` | Open the command menu from history or a focused changes block. | +| `Shift-P` | Cycle the comparison parent while Tree has focus. | +| `? e` | Cycle the tree/worktree changes display. | +| `Shift-R` | Explicitly refresh the revision view and visible worktree status. | +| `y` | Copy the selected commit ID, or the selected raw path when a changes block is focused. | +| `Shift-y`/`Y` | Copy the selected author as `Name `. | +| `s` | Verify signed, unverified commits currently visible on screen. | +| `@` | Time-travel to the selected commit, or return through its tix pin. Terminals reporting the base key as `Shift-2` are also accepted. | +| `x` | Select the next visible commit with the same change ID, wrapping at the end. | + +Alignment uses only rows in the current viewport to determine widths and starts +in title mode. Horizontal navigation pans the complete padded row in title and +full-column alignment so clipped fields can be reached. + +Topological navigation treats the displayed history as a first-parent forest. +Down moves toward roots, Up moves toward leaves, and Left/Right or `h`/`l` +select among a fork's children in display order. The choice is remembered per +fork and shown as `i/n` beside the selected row without extending its inverted +selection style. Movement stops at missing roots or leaves and never follows a +merge's secondary parents. A viewport panned away with the mouse or page keys +stays detached until the next cursor movement makes its destination visible. + +Compressed history keeps the visible reference, pin, and worktree tips, the +commit selected when compression begins, every graph endpoint or junction, and +every hidden boundary as full, selectable commit rows. Each remaining maximal +linear segment of at least two commits is represented by a selectable hollow +node followed by its exact commit count, such as `○ [12]`; a singleton remains +a full commit row. Pressing Enter on a summary expands that one segment in place; +expansions accumulate until the display is the ordinary title-aligned history. +Topological navigation peels one connected commit from a segment per step. +Moving toward a summary exposes and selects its connected boundary commit. +Starting on a summary instead exposes the boundary in the requested direction +and keeps the remaining summary selected, so repeated steps progressively open +it; when only one member remains, that ordinary commit inherits the selection. +Filtered target selection still exposes ineligible boundary commits one at a +time without selecting them. + +Modal review and rewrite-target pickers retain the +compressed projection so its points of interest remain available as targets, +while conflict selection continues to show the full history. Leaving and +re-entering compressed mode through the `[` cycle, or performing a full history +reload, discards accumulated expansions. + +The display group remains open for consecutive display changes and closes on +navigation or another recognized command. The `?` group similarly remains open +for signature verification, alignment, message, and changes actions. The +footer keeps every prefix compact. Opening one reverses its label and shows its available +items in a reversed popout immediately above and connected to that label. The +actions popout has commit operations in its first logical section and general +actions in its second. The `?` popout likewise has information actions first, +then the command-menu shortcut, pane switching, and keyboard navigation through +` diff`; other groups use one logical section. The popout has horizontal +padding and shifts left at the terminal edge. Complete items that would cross +the edge spill into additional rows while preserving the declared row order; an +individual item wider than the terminal is clipped. The whole popout is omitted +when its label, the required rows above the footer, or space needed to preserve +a protected message is not visible. It does not reserve history rows and may +cover history, but message and changes panes, their status lines, and transient +notices shift upward to reserve all of its rows and are never occluded. Closing +behavior and shortcut availability are unchanged, and direct status actions and quit remain in the +footer. The history status starts with the history position, then the `p` +command entry and the `v` and `a` prefixes when they are addressable. Remaining history-level +actions end at the information prefix while it is closed. An available direct +time-travel action follows the shortcut groups, and duplicate cycling follows it when +the selected commit has duplicates, and copy follows these actions; the reference toggle immediately precedes +the `?` group; quit is always last. +All status lines embed and underline a shortcut character in its action label when +possible; keys that cannot be expressed naturally in the label remain explicit. +The Enter key is written as `` throughout. + +### Command menu + +- Bare `p` opens a centered command menu from the main history UI, including + while a changes block has focus. The ref-tree retains `p` for Pin, and `a p` + remains Split. +- The menu contains the currently available executable entries from the Actions, + View, Enrich, and Information groups. Each entry retains its exact contextual + identity, so Stash and Unstash, Review and Finish Review, and Pin and Unpin are + distinct commands rather than interchangeable labels for one action. +- A single-line input filters entries by a case-insensitive ordered-subsequence + match against the command label or displayed prefix-group name. Up and Down move the selection, + `` executes it, Escape closes the menu, and pasted text edits the query + instead of invoking history paste behavior. +- A displayed prefix key followed by an ASCII space scopes the menu to that + group: `v ` selects View, `a ` Actions, `n ` Enrich, and `? ` Information. + With no suffix every available entry in the group matches; further text + fuzzy-filters command labels within that group. The literal query remains in + the input, and an invalid or unavailable scope has no matches. +- The unfiltered catalog interleaves View, Actions, Enrich, and Information + entries while preserving each prefix group's own order, so the first screen + represents every available group instead of being filled by one prefix. +- At most nine matching entries are visible and numbered `1` through `9`; + pressing a displayed number executes that entry. The first opening has no + default selection, so `` alone does nothing. On later openings, the + last exact command submitted through this menu is preselected when it is still + available; an available contextual opposite is not substituted. Typing a query + replaces that recalled selection with the first matching entry. + +### Time-travel + +- On a completed, focused history in a worktree repository, `@` on a non-`HEAD` + row runs `git checkout --detach ` without forcing local changes. +- `a h` is available while `HEAD` is detached with a valid symbolic HEAD pin. + It atomically moves the remembered local branch to the current `HEAD` commit + and attaches `HEAD` without changing the index or worktree. The symbolic HEAD + pin remains and follows the moved branch. The branch's previous tip receives + an ordinary pin only when no other normal view tip still reaches it; an + ordinary destination pin is consumed normally. +- Attach refuses a remembered branch checked out by another worktree. It is + unavailable during conflicts or incomplete history, but otherwise permits + dirty index and worktree state because the operation changes only refs. +- When tix detaches an attached local branch, it records that branch symbolically + in the singleton `refs/worktree/tix/pins/HEAD` ref. Git stores this HEAD pin + privately for the current worktree, and later branch advances move its tip. + Further detached travel preserves the singleton. Landing on an available + remembered branch tip uses that branch as the checkout target directly, + reattaches `HEAD` in one checkout, and removes the HEAD pin; explicitly + attaching another branch also removes it. + Attach is the exception: it deliberately retains the symbolic HEAD pin + after attaching its remembered branch. + A failed automatic reattachment leaves both detached `HEAD` and the HEAD pin + intact and reports a warning. External Git checkouts do not reconcile it. +- Other departures are provisionally retained with ordinary + `refs/worktree/tix/pins/` refs. An already detached `HEAD` receives a + direct pin. After a successful checkout tix removes that pin when the old + `HEAD` remains reachable from another view tip, and retains it otherwise. Pins + use at least four alphanumeric characters; generated pins start with eight + hexadecimal characters from the saved commit. +- When a rebase rewrites a detached departure, checkout applies the rebase mapping + before deciding whether to pin it. A departure rewritten into the selected `@` + successor is not pinned; a distinct departure preserves its rewritten identity. +- While `HEAD` is detached, every valid pin, including the HEAD pin, from the + current worktree augments implicit and explicit revision tips. While it is + attached, every ordinary pin away from `HEAD` does so while the HEAD pin is + inactive. This lets explicitly pinned references and unrelated retained trees + remain in history. Pins from other worktrees, dangling, + malformed, and non-commit pins do not enter the view or its decorations. + Normal hidden-revision exclusions still apply. +- One or more worktree pins at a commit are shown as a single blue `📌` + resource marker immediately after the hash and outside ordinary reference + decorations. It remains visible when references are hidden, and internal pin + names are omitted from history rows. `@` on a pinned + tip checks out its underlying branch, or its direct commit in detached mode, + then removes that one pin. Multiple matching pins prefer symbolic targets and + then lexical ref-name order. +- The HEAD pin instead marks its target branch as `★branch` in the local-branch + style. It has no `📌`, is never selected as a return destination, and does not + offer `unpin`; its branch keeps normal tracking-relation behavior. +- The edit menu offers `pin` on an unpinned row and `unpin` on a pinned row, + both on `a i`. Pin creates or reuses a direct current-worktree pin for the + selected commit. Unpin atomically removes every non-HEAD pin for that commit; + both operations retain that row's selection. +- `tix pin ...` resolves every argument before writing and deduplicates + pin targets in argument order. A direct reference name creates or reuses a + symbolic current-worktree pin so it follows later reference updates; derived + revisions and object IDs remain fixed direct pins. Each unique target prints + as `pin: `, and targets at the same commit remain distinct. +- Checkout failures retain the original `HEAD`, remove only a newly created + source or HEAD pin, and leave destination pins intact. Successful travel + consumes a destination pin and applies the same source-pin reconciliation for + ancestor, descendant, and sideways moves. Conflict acceptance, history-rebase + checkout, and automatic fork travel use this same primitive. Successful travel + preserves the selected row, refreshes history directly, and invalidates + worktree status. +- Active review commits define review trees containing all of their descendants. + Time travel within one review tree keeps ordinary checkout behavior and never + creates or restores a stash. Crossing out of a dirty review tree saves tracked, + staged, unstaged, and untracked state with Git under + `refs/worktree/tix/review/stashes/N`; ignored files remain untouched. Crossing + into any commit in that review tree restores the state with `git stash apply + --index` and always removes the companion ref after Git returns. Apply conflicts + remain in the ordinary index/worktree conflict workflow. Leaving a review tree + retains its leaf with the normal direct departure pin even after returning to + attached history; returning through that pin consumes it. Nested trees use the + nearest review-root ancestor. +- When loaded worktree status shows staged, unstaged, or untracked changes without + conflicts, the actions menu offers `z stash` at the selected `@` entry. Missing or + stale worktree status hides the action instead of performing another status + query. Saving uses Git with `--include-untracked`, leaves ignored files alone, + preserves the ordinary stash stack, and records the stash commit at + `refs/tix/stash/`. A commit can retain only one such stash. + `tix stash` performs this operation directly at `HEAD` with the same checks. +- A commit stash is shown as a bright `🎁` beside any `📌`, directly after the + hash and outside reference visibility. Time travel back to that exact commit + restores it with `git stash apply --index` and consumes its companion ref after + Git returns, including when application leaves conflicts to resolve. Manual + commit stashes use the same plumbing during reviews, while automatic review + stashes retain their review-tree identity and namespace. An active automatic + review stash likewise shows `🎁` on the review leaf whose worktree state it + saved, without exposing its internal reference or stash commit to traversal. +- At a selected `@` with a commit stash, the actions menu offers `z unstash` even + when other worktree changes are present. It applies and consumes the stash in + place through the same path used when time travel returns to that commit. +- Rewriting a commit atomically renames its commit-stash association alongside + other reference updates. Dropping a stashed commit, converging multiple stashes + onto one result, or overwriting an existing destination stash is rejected before + prepared objects or references are persisted. + +## Overlay views + +Overlay views paint over history without changing metadata alignment. Selection +is bounded above the top-most changes block: moving down at that boundary scrolls +history so the selected row stays visible. Shrinking a changes block does not +pull history back into the freed rows. The commit view reserves right-side +space first; changes blocks adapt within the remaining history width. + +### Commit message + +- `m` or `]` toggles the commit view on the right. It uses at most half the + terminal and reserves 80 content columns when space permits. +- Its history-status action says `message`, avoiding confusion with the edit + group's commit-creation action. +- The panel has a minimally shaded background derived from the detected terminal + background, with the default background as fallback. Its content has two + columns and one row of margin; an overflow status uses the bottom margin. +- A note renders its bold Markdown title and body first without a separate + background, followed by a horizontal rule and the commit's bold Markdown title + and body. Standard Git notes retain their bold purple `Notes` + prefix and render their content as Markdown. Heading markers and code fences + are hidden, fenced code uses generic styling without syntax highlighting, and + commit trailers remain plain and aligned last. +- Overflow is page-scrollable and gets a distinct pane status line only when + scrolling is possible. + +### Tree and worktree changes + +- Changes start enabled as `Tree + Worktree`. `? e` cycles `Tree + Worktree` → + `Tree` → hidden. Bare repositories omit the worktree mode. +- Each block has a top border carrying its compact summary. Tree summaries show + the selected short hash; worktree summaries distinguish staged and unstaged + counts. Kind totals, total files when non-redundant, and non-zero line totals + are color-coded. Empty enabled Tree and Worktree blocks remain visible, say + `empty` and `clean` respectively in green, and are not focusable. +- Tree paths preserve tree-diff order. Worktree paths show staged entries first in + green and unstaged/untracked/conflicted entries second in bright red, sorted by + raw path within each group. When both groups exist, a non-selectable `↑ index ↑` + divider scrolls between them; its dimmed label aligns with the path-kind letters + and a green horizontal rail fills the inset content width to its right. +- Path kinds are `A`, `M`, `D`, `R`, `C`, `T`, and `U`. The selected path is + subtly inverted and appends its already-computed non-zero line counts. +- Blocks are side by side when both condensed titles fit, otherwise Worktree is + stacked above Tree. A shared vertical divider joins side-by-side blocks. Blocks + size to content but together use no more than half the terminal. +- If paths overflow, the final row reports the remaining line count and updates + while scrolling. A single path is never replaced by overflow text. +- `Tab` cycles focus in visual order through visible changes blocks and history. + Inactive blocks, including paths and borders, are dimmed. Only the focused + block shows its distinct status line. +- `Shift-P` cycles the comparison parent while Tree has focus. Merge commits are + compared to one parent at a time; root commits compare against an empty tree. +- Repeated history keys, including printable `j`/`k` reported through enhanced + keyboard input, and vertical mouse bursts temporarily hide changes + overlays. They return after 75 ms of navigation idle, with the same path + selection and viewport where possible. +- Tree diff results, detached diff resources, and line counts use a bounded MRU + while changes remain enabled. Worktree results are cached separately and + invalidated by relevant filesystem events. +- Per-file line information is computed once in a lazily activated + `available_parallelism` worker pool. One repository is opened per activation + and cheaply cloned into thread-local worker handles; per-batch diff platforms + are discarded after use. Ten seconds without a completed line-count batch + joins the workers and releases their repositories. The next uncached diff + reactivates the pool, while hiding changes drops it immediately. + +### Diffs + +- `Enter` in history opens the whole selected commit against the active parent. + `Enter` in a focused changes block opens only its selected path. +- A whole-commit diff starts with commit identity and a Git-style per-path + diffstat in diff order. Each textual path retains Git's churn count and bar, + followed by an aligned signed net `additions - deletions` count. Parent/root, + kind totals, and aggregate line totals follow before the internal patch and any + per-path external diff drivers. +- Diff preparation honors Git attributes, text conversion, binary detection, + external diff commands, and the configured `core.pager` pipeline. +- Binary, submodule, conflicted, and otherwise unavailable file diffs do not + launch an inappropriate pager; the changes status line reports the reason. +- The built-in viewer takes over the alternate screen and supports the same + vertical and horizontal navigation keys. `Enter` advances from a whole-commit + internal diff to external drivers; `q` or `Escape` returns to tix. +- External programs run with the terminal suspended and restored afterward. + Broken-pipe writes are accepted. If a pager exits within 250 ms, its already + displayed output is retained until a keypress so short output remains readable. + +## Signature verification and editing + +### Signatures + +- Presence of `gpgsig` or `gpgsig-sha256` marks a commit as signed but + unverified; history loading does not validate signatures eagerly. +- The `s` hint appears only while the viewport has work to verify and disappears + after success. Verification uses Git-compatible repository configuration. +- Failures show their count with a bright-red marker. Moving the history + selection resets failed visible states to unverified so verification can be + retried. + +### Reword + +- `e`, then `r`, is available after history completion when no known descendant + of the selected commit is a merge commit. +- The configured Git editor receives a document containing `Author`, + `AuthorDate`, `Committer`, `CommitterDate`, `CommentChar`, and the complete + message in a temporary `.md` file for syntax highlighting. Author identity and + time are retained; the committer fields show the repository's configured + current committer. +- `CommentChar` is a non-empty single-line byte prefix, defaults to `;`, and is + recognized only at column zero. Parsing removes those lines and applies + Git-style whitespace cleanup. +- Missing `Assisted-by: GPT 5.6` and + `Co-authored-by: GPT 5.6 ` trailers are offered as commented + opt-ins. A case-insensitive existing trailer key suppresses its suggestion, + regardless of value. +- An unchanged editor document is a no-op. Otherwise tix recreates the commit, + signs it when commit-signing configuration is enabled, and rewrites every + linear descendant with unchanged trees and corrected parentage. Descendants + whose parent changed retain that original parent for cherry-pick replay during + time travel; the edited commit itself needs no replay marker. + Mutable refs follow every rewritten commit; tags and remote-tracking refs remain + unchanged. +- Every commit object actually rewritten by an edit receives the repository's + current committer identity and date immediately before signing and writing. + Edited committer fields cannot override it; untouched commit objects retain + their existing identity and object ID. +- Command-line message inputs replace only the message, retain + editor-comment-looking lines as content, and are a no-op when their cleaned + message already matches the commit. +- Editor, signing, parsing, writing, or reference-update failures are shown in + the main status line and do not leave a repository retained by the UI. + +### New commits + +- If an unborn `HEAD` leaves the ordinary history empty, each configured hidden + branch tip is shown as a selectable boundary without exposing its ancestry. + Creating a commit there creates the unborn branch above that base and leaves + the hidden branch unchanged. +- `a w` creates a child of the selected commit from tracked changes, or a root + commit for an unborn `HEAD`. A changed index wins; otherwise, tracked worktree + changes are used. Untracked files never enter an implicit new commit and remain + untracked. It is available only with a live worktree, after history completion, + and when the selected parent has no known merge descendant. +- `a n` creates an explicit empty commit which reuses the selected parent's tree, + or the empty tree for an unborn history. Existing index and worktree state is + preserved exactly. Both forms reject unresolved index conflicts. +- A current worktree-changes cache controls which actions are advertised without + opening a repository: tracked changes offer both `new` and `new-empty`, while a + clean or untracked-only worktree offers only `new-empty`. If no current cache is + available, both are shown and `new` validates its candidate before opening the + editor, directing an empty candidate to `new-empty`. +- Before launching the editor, tix resolves identities, signing configuration, + index conflicts, filters, + candidate tree, per-path diffstat, and a provisional commit entirely through an + in-memory object database. Cancellation and preflight failure write no object, + reference, index, or worktree state. +- A changed index supplies the complete commit tree and wins over unstaged + changes. Otherwise, when the worktree `HEAD` is the selected parent, tracked + worktree changes are filtered into a tree. A normal `new` rejects a tree equal + to its parent; `new-empty` deliberately reuses it. +- The Markdown editor buffer contains editable identities and dates, a `what` + title, a `why` body, optional attribution trailers, and a commented Git-style + per-path diffstat with signed net line counts. Commit hooks are not run. +- After editing, tix revalidates the destination, applies configured signing, + marks linear descendants for lazy replay, persists the prepared objects, and atomically + advances mutable refs throughout the rewritten stack. This includes local + branches, custom refs, direct tix pins, and a detached `HEAD`, while excluding + tags and remote-tracking refs. Checked-out affected worktrees are preflighted; + inaccessible or conflicting affected worktrees abort safely. + +### Fork commits + +- `a f` creates an independent child of any selected commit, including a hidden + boundary or merge commit. It requires completed history and a live, + conflict-free worktree, but unlike `a w` it is not restricted by descendants + because it rewrites none of them. It is unavailable for unborn history. +- Fork preparation reuses the new-commit editor, candidate-tree, identity, + enrichment, and signing rules. Empty-delta children are allowed so historical + commits can be forked without borrowing the current worktree's changes. +- Saving writes only the new commit and a temporary direct + `refs/worktree/tix/pins/*` ref; existing refs, descendants, indexes, and + worktrees do not move during creation. +- Tix immediately time-travels to the new fork. A successful checkout consumes + its temporary pin and reconciles the departed `HEAD` through the standard pin + primitive. If checkout fails, the fork remains pinned and visible. + +### Amend, spill, and split + +- `a e` amends the current worktree's `@` commit with the changed index, or + worktree changes when the index already matches `HEAD`. `a l` spills that + commit's tree delta into the worktree by replacing its tree with its first + parent's tree, or the empty tree for a root commit. Clean operations are + unavailable and report a no-op through `tix amend|spill`. +- Command-line `tix amend --index` disables the worktree fallback. It amends + staged index content when present and reports `nothing to amend` when the + index matches `HEAD`, even if tracked worktree changes exist. This option does + not alter the history-view amend action. +- Command-line edits use the same default HEAD, applicable pin, and review tips + as the history view. Unrelated refs do not broaden their descendant rewrite + scope, while mutable refs pointing into that scope are still retargeted. +- After any command-line amend, spill, split, reword, new, rebase, or pending + time-travel replay, successfully retargeted commit refs are printed after the + command's existing result as sorted `full/ref/name: old-id -> new-id` lines. + IDs use the same seven-character display as other command results. Ref + creations, deletions, unchanged refs, and unreferenced replayed commits add no + mapping line. +- With a path selected in the focused tree-changes block, the main `a` prefix + offers `spill` and `a l` spills only that path against the displayed parent. + `tix spill PATH...` atomically spills the named paths against the first + parent; omitting paths keeps the whole-commit behavior. +- With a path selected in the focused worktree-changes block, the main `a` + prefix offers `amend` and `a e` amends only that path. A staged row uses its + index version; an unstaged row uses its filtered worktree version. If both + rows exist for one path, the selected row determines the version. Review + commits accept only staged rows, and unresolved indexes cannot be amended. + Unrelated staged entries retain their index state. The CLI intentionally + supports only whole-commit amending. +- `a p` is offered at `@` only when both staged and unstaged changes exist. It + amends the unstaged changes into the source commit, then creates a new upper + commit from the staged delta using the standard Markdown editor buffer. Both + deltas are three-way applied in memory before the editor opens, so overlapping + changes abort without writing objects or changing refs, the index, or files. +- `tix split [--todo]` performs the same split at `HEAD`: worktree changes are amended + into the source commit and staged index changes become the new commit on top. + Its upper-commit editor uses the same enrichment headers as the new-commit + editor; `--todo` enables its Todo header. Existing source enrichments stay + with the rewritten lower commit. +- A successful split leaves the worktree bytes untouched and resets the index to + the new upper commit. The rewritten source retains its message and ancestry; + the upper commit receives the edited message. Their final trees and ancestry + need no replay marker; rewritten descendants use the same lazy rebase as amend + and spill. +- All three operations leave worktree files untouched and cheaply rewrite linear + descendants. Whole-commit edits reset the affected + worktree's index to the rewritten commit; selected-path amend synchronizes only + its destination and renamed source. A directly amended commit already has its + final tree and unchanged parent, so it is signed immediately when configured + and is never pending. A zero-delta commit immediately adopts and is signed + against its rewritten parent tree whenever that parent is final; it remains + lazy only behind a pending parent. Other reparented descendants carry + `tix-rebase-parent`, retaining the original parent needed for later replay. + Pending forms use a grey commit marker so they remain distinct from unsigned + blue. A final descendant whose effective parents did not change retains its + exact commit instead of being replayed merely because it is checked out. +- Edit graph discovery follows refs that point to commits and ignores refs whose + targets are trees, blobs, or other non-commit objects. +- Time travel toward a pending destination cherry-picks and signs only the pending + ancestry through that destination. Later non-empty descendants become or + remain lazy and unsigned; zero-delta descendants finalize immediately while + their parent is final and remain lazy behind a pending parent. Traveling toward + a non-pending ancestor leaves the entire pending region untouched. A completed + final replay does not reload history; + another pass loads only the rewritten path and never unrelated references. + A conflict retains the ours tree, exact merge-result + tree, conflict stages, prepared commits, and in-memory objects without changing + the repository. The actual conflicting row is selected and centered with normal + history-boundary clamping and shows a steady red conflict marker; `` persists + the prepared rebase, leaves later descendants lazy, checks out the conflicting + commit at the ours tree, then checks out the merge result and derives the + unmerged index from it. `Esc` discards the suspended operation; navigation and + other read-only actions leave the choice armed, while repository-changing actions + and refresh are blocked. Key-release events are not actions and leave it armed. + Diagnostics warn when a conflict suspends the rebase and record whether it is + accepted, discarded, or fails during checkout. +- A checked-out unresolved index keeps `C` at `@`, overrides dirty `🫟`, and + disables time travel until all conflict stages are resolved. The worktree + changes block is shown for resolution. +- Accepting a conflict remembers the materialized commit, HEAD attachment, + parents, and accumulated reference changes. If the conflict is resolved and + amended outside tix, refresh recognizes completion only when HEAD remains + attached the same way, moves to a same-parent replacement, and the + conflict-free index exactly matches that replacement's tree. Tix then removes + any pending-rebase marker preserved by Git's amend, appends both reference + transitions to the same undo operation, and clears the mandatory prompt. + Staging a resolution without amending remains incomplete. An unrelated HEAD + move stays blocked with a diagnostic and can still be left with normal `q`. + Tix's own `` amend also completes an identical-tree resolution so no + pending marker can survive merely because the tree did not change. +- A materialized todo conflict keeps a high-contrast `REBASE PAUSED` attention notice until + its in-memory continuation is consumed. The notice changes when the index is + resolved but always advertises `` to continue and `Esc` to stop. History, + changes-pane navigation, display toggles, copying, and path-diff inspection stay + available; repository-changing actions and refresh are blocked. Pane-local + ``, `Esc`, and `q` retain their inspection and focus behavior. Stopping + forgets only the in-memory continuation and leaves the partially applied + repository untouched; Ctrl-C still exits immediately. + +### Reviews + +- `a r` starts a review from any non-boundary commit without merge descendants. + If exactly one selectable strict ancestor can be the review base, review starts + with it immediately. Otherwise tix limits navigation to the selected commit's + ancestry; the connected hidden base remains selectable, `` confirms it, + and Escape cancels before any repository change. +- Starting requires a completely clean index and worktree, including no untracked + files, and non-pending reviewed-tip and base commits. Only after confirmation, + tix creates the first unused direct `refs/worktree/tix/review/N` ref at the + reviewed tip and an unsigned ordinary `review` commit at the base with + `tix-rebase: onto refs/worktree/tix/review/N`. Starting always creates a + dedicated worktree-local tix pin for the departure, symbolic for an attached + branch and direct for a detached checkout, and names it in the + `tix-review-return-to` header. HEAD is detached at the review commit, + its base tree fills the index, and the reviewed tip tree remains in the worktree + as unstaged changes. The pin keeps the departure and its ancestry visible. + Reviews never share return pins, even when they depart from the same ref or + commit, so finishing one cannot consume another review's return path. + Finishing maps the recorded return target through the rewrite and uses normal + time-travel checkout semantics to restore attached or detached HEAD and consume + its pin. Existing symbolic review refs remain readable. +- Review refs are resources, not traversal tips; pins alone retain history. They + remain visible in every ref mode: one active ref is shown as `review`, while + multiple refs are shown as `review:N`. Review + commits show a filled diamond as the first resource marker, before pin and stash + markers, while retaining the normal signature disc or `@` at `HEAD`. Ordinary + edits preserve the review header and otherwise keep + their normal signing and lazy-rebase behavior. +- At a checked-out review commit, amend is offered only for staged changes and + consumes only the index tree. It leaves worktree bytes and the review header + intact, removes signatures, and marks only affected descendants for lazy replay. +- `a r` finishes a selected review when status is completely clean and the current + worktree HEAD is the review commit or one of its successors. The + review commit is inserted after its reviewed tip with its exact tree, review + header removed, updated committer, and configured signature. Review-side + descendants retain exact trees and are signed without pending markers. With one + review-side leaf, the reviewed tip's prior descendants are lazily reparented + after it; with multiple leaves they branch directly after the finished review. + The review ref is deleted in the same atomic ref/worktree transaction. +- If the recorded review return ref is missing, finishing leaves the repository + untouched and limits navigation to visible non-review commits descended from + the reviewed tip. The reviewed tip is selected initially when visible; + otherwise the nearest eligible row is selected. `` finishes the review, + maps the chosen commit through that rewrite, and checks it out detached, while + Escape cancels recovery. + Hidden, unrelated, and review commits are not selectable return targets. +- Forget is unavailable for a review commit with descendants. Forgetting a review + leaf cancels the review: tracked review changes are discarded, its recorded + return checkout is restored, and the departure pin is consumed. Finishing a + review or dropping one through a rebase todo also deletes its review ref and + optional saved-worktree ref atomically; reordering or rewriting it preserves the + headers and resources. Review stash refs are internal: they are not traversal + tips or named decorations, but their saved review leaf carries a `🎁` marker. + +### Forget commits + +- `e`, then `d`, is available after history completion for a selected non-merge + commit with no known merge descendant. The first `d` arms a + yellow notice asking for `d` again; the second performs it. Navigation, refresh, + cancellation, selection changes, and other commands disarm confirmation. +- Forgetting does not require a worktree. Linear descendants are reparented with + unchanged trees and marked for lazy replay; mutable refs throughout the + rewritten stack move atomically. Tags and remote-tracking refs remain unchanged. +- When the selected commit is the current worktree `HEAD`, Git preflights and + applies a two-tree index/worktree transition which discards only that commit's + tracked delta. Conflicting staged, tracked, or untracked state refuses the + operation; unrelated untracked content survives. When `HEAD` is unrelated, only + refs move and the worktree is untouched. +- Forgetting an attached root deletes the branch and leaves symbolic `HEAD` + unborn. A selected detached root is rejected because it cannot produce a valid + unborn `HEAD`. Success refreshes history and selects the parent when present. + +### Transactional rebases + +- All edits share one in-memory rebase primitive. + Forks are preserved, descendant merges are rejected, and all commit/tree + preparation—including cherry-pick conflict detection—finishes before objects + become reachable through refs. +- `Tree::LeaveAsIs` rewrites parentage without changing trees; + `LeaveAsIsAndMark` writes the original first parent to `tix-rebase-parent` only + when later replay needs it; and `CherryPick` transplants each tree delta. + Any edit that rewrites the current worktree's checked-out ancestry eagerly + cherry-picks that affected path before committing the operation. The edited + root of a direct amend or spill already has its final tree and does not receive + a redundant worktree transition. Descendants on unrelated branches and in + other worktrees remain lazy unless their delta is empty and their parent is + final. A successful repeated rebase clears the marker + through its checkout destination. + On conflict, `tix-rebase-parent` identifies the original base and later descendants + remain marked instead of being cherry-picked. +- `Signature::RedoIfNeeded` signs every rewritten commit when signing is + configured and otherwise removes stale signature headers. + `InvalidateExisting` empties existing signature values when signing is + configured, making the empty field a pending-signature signal, or removes them + when it is not. A pending-rebase commit can only use the invalidation policy, + so it never carries a usable signature. Automatically rebased descendants + retain their author and receive one configured current committer identity and + timestamp for the operation. +- Ordinary edits retarget mutable local refs pointing into the rewritten set. + History todos instead use their explicit reference lines. Ref changes use + compare-and-swap transactions; a checkout failure rolls back already-applied + worktree transitions and the ref transaction, except that deleting the branch + being departed necessarily follows the successful checkout. Newly written + unreachable objects may remain for normal Git garbage collection. +- A suspended conflict temporarily owns a cloned repository with object memory + while awaiting an explicit `` or `Esc` choice. Dropping it writes nothing; + accepting it consumes the repository immediately after persisting the commit at + the ours tree and materializing the retained merge result in the worktree and index. + Forget, reword, commit insertion, review finishing, and other shared-rebase + callers propagate this same suspended result instead of completing their ref + transaction first. Thus a checkout-path conflict is reported by the initiating + edit itself, and `Esc` leaves its repository snapshot unchanged. + +### History rebase editor + +- Selecting an eligible hidden boundary and pressing `a b` opens a Markdown + `.md` todo. Its editable plan is read bottom-to-top like the history view: + newest commands and refs are highest, and each stack ends in a centered + `──── fork ────` separator below its oldest command. + IDs are shortened through repository configuration; metadata is loaded across + the complete todo scope, repeats the full information visible in history, and + always includes the subject. Base-level + stacks end with `fork (base) ` in the separator, using the title + exactly as displayed in history without Markdown escaping. Fork points within the editable tree + remain plain `fork <id>` separators. Every separator is centered with at least + four `─` characters per side, and all span the widest editable line. +- When that boundary shows `⇣N`, `a u` opens the same editor with each base-level + stack rooted at the corresponding hidden branch tip. Its otherwise unfamiliar + separator is `fork <id> (updated-base) <title>`, with the raw title exactly as + shown in history, including `[A]` and `[N]`. The hidden branch + itself is not moved. +- Pick lines may be reordered or removed. `squash <id>` folds an existing + non-merge commit into the following `pick` or `empty` below it in the same + fork; it may carry `@`, and fork separators naming any folded ID resolve to + the combined result. A fork cannot begin with `squash` when read bottom-to-top. + Fork separators may otherwise target a pick below or any existing commit, so + adding and removing separators creates + and joins branches. `empty <title>` inserts an empty commit. Markdown code + spans and equivalent plain commands are accepted; display text after an ID is + informational and emitted verbatim without Markdown escaping. +- Squash groups are materialized eagerly on every fork by applying their source + deltas in bottom-to-top todo order. The result retains the first member's author, author + time, encoding, extra headers, and message, receives the operation's committer, + and is signed once. Before every later full message, a permanent + `# <short-id> <subject>` line identifies its source. Distinct raw authors of + later commits are appended in first-seen order as `Co-authored-by` trailers, + excluding the first author and identities already named by a valid such + trailer in any source message. Name and email pairs are compared without + mailmap. All folded IDs and mutable refs map to the one resulting commit; + resources owned by a later folded review commit are removed. +- The first line points to complete self-documenting help after the editable + todo. All instructions are enclosed in Markdown comments so only separators, + reference lines, and command lines participate in the editable plan. +- A versioned Markdown state comment makes the document independently + applicable in a later process. It records full base, target, scope and tip IDs, + checkout requirements, and compare-and-swap state for mutable refs. Ref names + use Git-compatible C-style quoting so arbitrary ref bytes round-trip. Missing + state cancels; present invalid state never reaches repository mutation. The + state comment follows the complete help at the end of the document. Bottom-up + todos use `tix-rebase-state-v2`; older state versions are rejected rather than + interpreted with the opposite command order. +- Standalone `(ref, ref)` lines place direct mutable refs at the following fork + separator or command result below them. Multiple consecutive lines share that + destination. + Commit command metadata omits ref decorations because these lines are their + sole editable representation. + Existing displayed names may be moved or removed, and new unqualified names + create local branches; explicit editable `refs/...` names are also accepted. + Existing editable direct refs outside the generated todo are imported with + their current target as compare-and-swap state and may be placed the same way. + Short names follow the history display, ambiguous names expand to full names, + and Git quoting preserves arbitrary bytes. Tags, remote-tracking refs, general + symbolic refs, tix pins, stashes, and review resources remain hidden and + unchanged. +- Pick lines use display-only state symbols documented in the footer: `↻` for a + lazy rebase, `◌` for an invalidated signature awaiting signing, `◐` for an + unverified signature, and `○` for an unsigned commit. Applicable states may be + combined without changing plan semantics. Applicable `🚧`, `📝`, and `✔️` + enrichment gutter symbols appear before the signature-state disk as metadata. +- `@pick`, `@squash`, or `@empty` chooses the post-rebase commit. A generated + todo keeps this marker even when `HEAD` is attached, but shows its branch as an + ordinary ref. Versioned state remembers that attachment while the ref stays + at the marked result. Moving it elsewhere detaches `HEAD`; adding `@` to one + editable ref explicitly attaches it and is valid only at the marked result. + The ref may be imported from outside the generated todo and is moved before + `HEAD` is made symbolic to it. Removing the name deletes the ref. Checkout + markers are invalid without a worktree. Todo generation and application + reject an unborn `HEAD`. +- Within the ancestry ending at `@`, unchanged picks whose original parent is + still their planned parent retain their IDs. Eager cherry-picking and re-signing + starts at the first pending or structurally changed commit. Any descendants + above `@` and other resulting stacks retain their trees, receive pending-rebase + markers, and invalidate old signatures for later time travel. With no explicit + `@`, the current attached branch's resulting destination is inferred and its + ancestry is replayed eagerly; a detached checkout is not inferred. Other + ordinary steps remain lazy while squash groups are still materialized. + Any conflict while applying a history todo first remains entirely in memory. + The TUI projects the partial result, selects and centers the actual conflicting + result with normal history-boundary clamping, and marks it with a steady red + conflict marker; predicted ref decorations remain at their + repository positions. Repository-backed overlay content is hidden while these + candidate objects exist only in memory. History and pane navigation and other + read-only actions leave the preview armed; repository-changing actions and + refresh are blocked. `<enter>` accepts the partial result, + moves already-final refs, records the ours tree in the conflicting commit, and + checks out the retained merge result with an unmerged index, + and retains an in-memory continuation plan. Only `Esc` discards the preview + without writes. On continuation, `<enter>` stages paths that still have unresolved + index entries, refuses to proceed if any unresolved stages remain, and amends the + current conflicting commit from the complete staged index, including any additional + staged changes. Unrelated unstaged changes remain untouched. Another conflict + repeats the same explicit choice. +- Command-line apply, including todo `--edit-and-apply`, reports a conflict + without changes unless `--materialize-conflicts` was explicitly supplied. Its + continuation document uses the full null object ID for the command whose tree + must come from the resolved index. Already produced commits use their new IDs, + completed drops and squash sources disappear, unapplied squash sources remain, + and the remaining + todo stays editable. Applying it + requires only that `HEAD` names a commit and the index has no unresolved stages; + the index tree, including additional staged changes, becomes the resolved tree. + There is no hidden sequencer state or separate continue/abort command. +- Every interactive operation that rewrites the stack below `HEAD`, including + todo application, runs on a scoped worker and shows its modal gauge after + 300 ms. TUI time travel also runs on a scoped worker and follows completed + pending rebases on the destination ancestry. The history selection traverses + each fixed viewport from bottom to top; crossing its top jumps the viewport + by one page and places the selection at the bottom again. Compressed history + temporarily uses its canonical rows for these frames and is restored afterward. + The first and latest rows are drawn even for fast operations, with intermediate + rows coalesced to at most 60 fps. Command-line time travel does not animate. +- Displayed mutable refs follow their explicit locations in the edited todo; + omission deletes them and newly named refs require nonexistence. Refs checked + out by linked worktrees are displayed normally and may move, with their index + and worktree updated through the same preflighted transition as other rebases, + but may not be deleted. The current worktree's branch may be deleted only when + the todo also moves or detaches `HEAD`; deletion is deferred until checkout + succeeds. All remaining moves use one compare-and-swap transaction. Every + other resulting leaf gets a direct + `refs/worktree/tix/pins/*` ref, except the checked-out leaf. When `@` moves below + a referenced leaf, the existing time-travel checkout detaches `HEAD` there while + the ref stays at the leaf. Concurrent ref edits win by making the transaction + fail; the editor result is not rebuilt against a later graph snapshot. Leaving + the document unchanged is a no-op unless the ancestry ending at `@` contains + pending commits or rebase-update selected a newer base; pending commits on + other forks remain lazy and do not replay a clean checkout ancestry. Explicit + `tix rebase apply` always + applies a valid plan, even when its editable commands are unchanged. The first + Markdown comment states which of these modes applies and explains that emptying + the file or removing the `tix-rebase-state-v2` comment cancels. Continuation + todos likewise state that saving unchanged continues the materialized rebase. + +### Commit and action shortcuts + +- `a` toggles a two-line shortcut group with commit operations above general + actions. `a o` rewords, `a w` creates a rebased child, `a n` creates an empty + child, `a e` amends `@`, `a l` spills `@`, `a p` splits staged from unstaged + changes, and `a d` forgets a top commit when each action is + available. `a b` rebases an eligible hidden base, + `a u` rebases it onto the newer hidden branch tip when available, `a r` starts + or finishes a review, `a s` squashes the selected commit, `a z` stashes or + restores changes at `@`, `a y` copy-inserts + current `HEAD` above the selected commit, `a m` move-inserts it, `a t` starts + stack-insert for the linear ancestry from the selected commit through `HEAD`, + `a f` creates and travels to a standalone child of the selected commit, and + `a h` attaches the remembered branch at detached `HEAD` when available. +- Squash accepts any visible strict ancestor whose affected descendants contain no merges. With one eligible + target it applies immediately; otherwise navigation is limited to eligible ancestors, `<enter>` confirms, + and Escape cancels. A non-adjacent source is folded next to the target while intervening commits and sibling + forks remain above the combined result. Squash uses the history-todo rebase, conflict, and continuation rules. +- Copy-insert requires a non-root, single-parent source that is not an active + review commit. `a y` copies current `HEAD`; `tix copy-insert C I` accepts any + resolvable source `C`. It inserts another occurrence of its change above the + target without removing the source occurrence, including when the target is + the source's current parent. The new copy becomes detached `HEAD`; the branch + checked out before the operation remains visible through the ordinary HEAD + pin. If the target is an ancestor of the source, that source occurrence is + retained in its original logical position while its branch follows the + necessary rewrite. Git notes are copied to the new occurrence. Copy-insert + uses the history-todo conflict and continuation rules. +- Bracketed paste in the history view trims surrounding whitespace and accepts + one uniquely resolvable hexadecimal object-ID prefix. If that object is a + commit, its change is copy-inserted above the commit at the cursor using the + same progress, conflict, checkout, and undo behavior as `a y`. Other text, + ambiguous or missing IDs, non-commit objects, and unavailable targets produce + an attention message without changing the repository. +- Move-insert requires a non-root, single-parent `HEAD`. It removes `HEAD` from + its old position, reconnects its former children to its parent, inserts its + rewritten change above the selected target, and reparents every former direct + child of the target above it. The target may be an ancestor, descendant, or in + unrelated history; selecting `HEAD` or its current parent is a no-op. An + unchanged merge target is permitted, but any move that would rewrite a merge + is unavailable. Mutable refs, pins, Git notes, enrichments, review resources, + and attached or detached checkout state follow their rewritten commits. + Move-insert uses the history-todo conflict and continuation rules. +- Stack-insert requires the selected commit to be an inclusive base in the linear + ancestry of `HEAD`. It then limits navigation to eligible insertion targets; + `<enter>` moves the complete inclusive base-through-`HEAD` stack as a unit above the selected + target, and Escape cancels. The stack follows the same eligibility, rewrite, + no-op, metadata, checkout, and conflict rules as move-insert. +- `@` invokes time travel directly, outside the group. Invoking it leaves an + already expanded actions group open. +- Commit and action shortcuts keep the actions group open. Navigation or + another recognized command closes it, matching the `v` display shortcut group. + Plain `r` does not mutate the repository, and plain `t` has no action. +- The footer underlines `a` in `actions`; its expanded commit and action lines + contain only the operations available for the current selection. An empty + line says `no actions`. +- The top-level `v`, `a`, `n`, and `?` keys are reserved for their groups. + Pressing one while another group is open switches directly to that group; + `? e` cycles the changes panes. +- While the `v` group is open, `d`, `i`, `s`, `e`, `m`, `t`, `r`, and `h` control + dates, IDs, emails, names, mailmap, trailers, references, and hidden commits. +- The `n` in `enrich` toggles its shortcut group. On any commit eligible for rewording, + `n t` toggles `[commit] todo`, preserving a saved note, and `n o` opens + `[commit] note` in Git's editor as Markdown. Saving or removing a note preserves + the todo flag, and toggling todo preserves the note. `n e` toggles + `[tree] checks-pass` for any selected commit, including immutable boundaries. + `n g` edits the real Git note and remains available when the commit-specific + Tix actions are not. The group is mutually + exclusive with the view, commit, actions, and information groups and otherwise follows + their closing behavior. + +## Refresh, focus, and diagnostics + +- Native reference watchers observe `HEAD`, loose and packed refs, linked-worktree + HEAD and membership changes, and the direct or symbolic refs used by view and + hide revspecs. Linked indexes, logs, locks, and unrelated metadata do not + trigger history refreshes. Missing refs during an atomic update are transient; + malformed or inaccessible ordinary refs remain errors. +- Ref changes that affect view or hidden tips trigger an incremental history + refresh. Decoration-only changes avoid traversal. Filesystem-driven traversal + changes, manual refresh, and display toggles preserve selection by commit ID. + Edits retain the selection on the successor ID returned by the rewrite. A + selected worktree HEAD or other moving reference follows its changed target, + covering external branch and StGit patch rewrites. If none remains visible, + selection falls back to the first selectable row. +- The worktree watcher exists only while the combined worktree block is enabled. + It observes the index and ignore-aware directories that Git status would walk, + using non-recursive registrations so ignored build trees do not generate work. +- Access-only and incomplete `.lock` activity are ignored. Completed atomic + renames, index/HEAD updates, relevant worktree paths, and backend rescan requests + invalidate the appropriate cache. +- Worktree updates retain the history selection and restore changed-path + selection by raw path and relative viewport position. They never select the + newest commit merely because status changed. +- Event batches are bounded and coalesced. Worktree status waits 75 ms of quiet; + reference transactions wait for their final update. Watchers retry after + failure while still needed. +- Refresh status remains hidden for 500 ms so quick background work does not + flicker the footer. +- A filesystem history refresh is presented immediately as one complete frame, + without animating or retaining intermediate history layouts. +- While the terminal is unfocused, filesystem-attributed redraws replace footer + separators with persistent orange discs. Focus restores normal separators. +- Filesystem responses receive correlated IDs in daily tracing logs, including + semantic trigger, coalesced paths, phases, presentation count, elapsed time, + and outcome. Logs use the platform application-log directory, retain seven + days, and are best-effort. Failure to create or open the log is silently + ignored and never prevents either command-line or interactive operation. +- After every event-loop wait, tix assumes that the original worktree and process + working directory may have disappeared. Before processing filesystem events or + redrawing, it lexically normalizes and enters the common repository, reopens it + as bare, drops worktree state, keeps tree/history views live, and reports recovery + in the attention notice. If recovery fails, terminal state is restored and the + contextual error is returned. + +## Resource and responsiveness invariants + +- No `gix::Repository`, commit-graph, object platform, notes platform, or other + repository-owning value may remain in idle application/event-loop state, + except line-diff worker repositories during their bounded ten-second reuse + window. +- Hidden-revision startup validation returns only detached revision and warning + data; its temporary repository is dropped before terminal initialization and + the event loop. +- View population opens a fresh non-isolated repository so mailmap, notes, diff + drivers, pagers, signing, and other Git configuration are current. It starts + without an object cache; bounded diff operations may enable one temporarily and + disable it again before any navigation reuse. Detached display data is retained. +- One fill repository may be shared by commit, tree, worktree, and metadata loads + during continuous key-repeat or mouse navigation. It is dropped after the + 75 ms idle boundary. +- Traversal and incremental refresh workers may use a bounded object cache and + must drop their repository when finished. Lane and verification workers exist + only for active work. Line-diff workers may remain for ten seconds after their + latest batch, then are joined together and release their shared repository + resources. +- Change IDs are scanned only while configured hidden tips are actively excluded. + Unrestricted and explicitly expanded views perform no scan. A refresh keeps + the current projection's IDs until it has synchronously scanned the replacement, + then publishes rows, IDs, duplicate markers, and gutter width together. +- Redraw is reactive and capped at approximately 60 frames per second while + streaming. Mouse events are drained and coalesced in bounded batches so input + storms cannot starve the main loop. +- Main status remains readable regardless of pane focus. Errors are surfaced in + the nearest relevant status line; diagnostics never replace user-visible + errors. +- Global command and recovery feedback uses one transient notice channel in the + history and ref-tree views. It reserves a wrapped, content-height block above + worktree changes until the next recognized user action, inset by two columns + within that pane when visible and otherwise within the main view. It never + covers the main or pane-local status lines. Green indicates success, yellow + indicates attention, no-op, recovery, partial success, or an armed prompt, and + red indicates failure. While undo or redo feedback retains its queue position, + the notice becomes a two-tone progress bar: the applied share is bright on the + left and the redo share is dim on the right. A fully applied queue is entirely + bright, while its start and an empty queue are entirely dim; attention and + failure notices retain the same progress in their respective hues. Forget, + review selection and recovery, suspended + conflicts, and paused rebases retain their notice until resolved; pane-specific + errors remain in their pane status line. +- Closing the new-commit editor without changing its prepared buffer leaves the + repository untouched and reports `no commit created: no input was provided`. + +## Regression coverage + +- Unit tests cover navigation, projections, pane layout, status summaries, + selection restoration, watcher classification, cached graph walks, diff + preparation, signatures, rewording, and terminal rendering. +- Behavior changes to this specification require corresponding tests and an + update to this document in the same semantic patch. diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 4e3ba4f1051..03030ea23b7 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -1,12 +1,14 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, ops::Range, + sync::Arc, time::{Duration, Instant}, }; use gix::{ ObjectId, bstr::{BStr, BString, ByteSlice}, + hash::ChangeId, traverse::commit::ParentIds, }; @@ -15,18 +17,211 @@ pub(crate) struct Commit<T> { pub id: ObjectId, pub parent_ids: ParentIds, pub committer_time: gix::date::Time, + pub author_time: gix::date::Time, pub author: &'static Author, pub attributions: Range<usize>, pub title: T, pub metadata_loaded: bool, + pub has_agent_marker: bool, + pub is_review: bool, + pub signature: SignatureState, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct Metadata<T> { pub committer_time: gix::date::Time, + pub author_time: gix::date::Time, pub author: &'static Author, pub attributions: Range<usize>, pub title: T, + pub has_agent_marker: bool, + pub is_review: bool, + pub signature: SignatureState, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum SignatureState { + #[default] + Unsigned, + Unverified, + Verifying, + Verified, + Failed, + PendingRebase, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum NoticeKind { + Success, + Attention, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Notice { + pub kind: NoticeKind, + pub text: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct UndoPosition { + applied: usize, + total: usize, + title: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ChangeKind { + Added, + Modified, + Deleted, + Renamed, + Copied, + TypeChanged, + Unmerged, +} + +impl ChangeKind { + pub(crate) fn letter(self) -> char { + match self { + ChangeKind::Added => 'A', + ChangeKind::Modified => 'M', + ChangeKind::Deleted => 'D', + ChangeKind::Renamed => 'R', + ChangeKind::Copied => 'C', + ChangeKind::TypeChanged => 'T', + ChangeKind::Unmerged => 'U', + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ChangesMode { + #[default] + Tree, + Both, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ChangePane { + Tree, + Worktree, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ChangesLayout { + #[default] + SideBySide, + Stacked, +} + +#[derive(Debug)] +pub(crate) struct ChangesView { + pub selected: usize, + pub offset: usize, + pub horizontal_offset: usize, + pub error: Option<String>, + page: usize, + len: usize, + max: usize, + separator: Option<usize>, + horizontal_page: usize, + horizontal_max: usize, +} + +impl Default for ChangesView { + fn default() -> Self { + Self { + selected: 0, + offset: 0, + horizontal_offset: 0, + error: None, + page: 1, + len: 0, + max: 0, + separator: None, + horizontal_page: 1, + horizontal_max: 0, + } + } +} + +impl ChangesView { + fn display_index(&self, path_index: usize) -> usize { + path_index + usize::from(self.separator.is_some_and(|separator| path_index >= separator)) + } + + fn ensure_visible(&mut self) { + let selected = self.display_index(self.selected); + if selected < self.offset { + self.offset = selected; + } else if selected >= self.offset.saturating_add(self.page) { + self.offset = selected + 1 - self.page; + } + let display_len = self.len + usize::from(self.separator.is_some()); + self.offset = self.offset.min(display_len.saturating_sub(self.page)); + } + + fn visible_paths(&self) -> usize { + let end = self.offset.saturating_add(self.page); + self.page.saturating_sub(usize::from( + self.separator + .is_some_and(|separator| separator >= self.offset && separator < end), + )) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ChangeGroup { + #[default] + Tree, + Staged, + Unstaged, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PathChange { + pub kind: ChangeKind, + pub group: ChangeGroup, + pub source: Option<BString>, + pub path: BString, + pub lines: Option<(u32, u32)>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Changes { + pub parent: Option<ComparedParent>, + pub range: Option<ComparedRange>, + pub paths: Vec<PathChange>, + pub diffs: Vec<crate::FileChange>, + pub lines_added: u64, + pub lines_removed: u64, + pub has_tracked_changes: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ComparedRange { + pub base: ObjectId, + pub tip: ObjectId, +} + +impl Changes { + pub(crate) fn is_visible(&self) -> bool { + self.parent.is_some() || self.range.is_some() || !self.paths.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ComparedParent { + pub index: usize, + pub total: usize, + pub id: ObjectId, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SelectionRelation { + Tracking { ahead: usize, behind: usize }, + Visible(usize), } #[derive(Debug, Eq, Hash, PartialEq)] @@ -41,6 +236,13 @@ impl Author { .iter() .any(|candidate| self.email.eq_ignore_ascii_case(candidate)) } + + pub fn is_github_noreply(&self) -> bool { + let suffix = b"@users.noreply.github.com"; + self.email + .get(self.email.len().saturating_sub(suffix.len())..) + .is_some_and(|email| email.eq_ignore_ascii_case(suffix)) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -51,15 +253,7 @@ pub(crate) struct Attribution { impl Attribution { pub fn is_agent(&self) -> bool { - self.author.is_bot() - || self.kind == AttributionKind::Assisted - && [b"opus".as_slice(), b"gpt".as_slice()].iter().any(|name| { - self.author - .name - .get(..name.len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case(name)) - && self.author.name.get(name.len()).is_none_or(u8::is_ascii_whitespace) - }) + self.author.is_bot() || self.kind == AttributionKind::Assisted } } @@ -75,6 +269,7 @@ pub(crate) enum AttributionKind { pub(crate) type LoadedCommit = Commit<BString>; pub(crate) type CommitRow = Commit<Range<usize>>; +pub(crate) type SharedCommitRow = Arc<CommitRow>; #[derive(Debug)] pub(crate) struct LoadedCommits { @@ -114,17 +309,44 @@ pub(crate) enum NameMode { None, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum DateMode { + #[default] + Author, + Committer, + None, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum IdMode { + Commit, + Change, + #[default] + Off, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CopyKind { Id, Author, } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum Action { Cancelled, + Undo, + Redo, MoveUp, MoveDown, + MoveUpBy(usize), + MoveDownBy(usize), + TopologicalUp, + TopologicalDown, + PanUpBy(usize), + PanDownBy(usize), + PreviousChild, + NextChild, + CycleDuplicate, ScrollLeft, ScrollRight, HalfPageUp, @@ -134,128 +356,700 @@ pub(crate) enum Action { First, Last, ToggleDate, + CycleIds, ToggleName, + ToggleEmail, ToggleTrailers, ToggleMailmap, + CycleRefs, ToggleRefs, + Refresh, ToggleHidden, + ToggleHistoryDisplay, + ToggleRefTree, + ToggleActions, + ToggleEnrich, + ToggleTodo, + ToggleChecksPass, + EditNote, + EditGitNote, + ToggleInformation, ToggleAlign, ToggleCommit, + ToggleChanges, + ToggleChangesFocus, + CycleChangesParent, + OpenDiff, + Reword, + NewCommit, + NewEmptyCommit, + Amend, + Stash, + Spill, + Split, + Forget, + Rebase, + RebaseUpdate, + Squash, + CopyInsert, + PasteInsert { source: ObjectId, target: ObjectId }, + MoveInsert, + StackInsert, + Review, + ForkCommit, + Attach, + TimeTravel, + TogglePin, + VerifySignatures, Cancel, Copy, + CopyPath(BString), CopyAuthor, - PreviewAuthorCopy(bool), + ForceQuit, Quit, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum Effect { Cancel, + Undo, + Redo, CopyId(ObjectId), + CopyPath(BString), CopyAuthor(&'static Author), Reload(bool), + OpenDiff(ChangePane, usize), + OpenCommitDiff(TreeDiffTarget), + Reword(ObjectId), + NewCommit { + parent: Option<ObjectId>, + empty: bool, + }, + Amend(ObjectId), + Stash(ObjectId), + Unstash(ObjectId), + Spill(ObjectId), + Split(ObjectId), + Forget(ObjectId), + Rebase { + base: ObjectId, + onto: ObjectId, + commits: Vec<ObjectId>, + }, + Squash { + source: ObjectId, + target: ObjectId, + }, + Insert { + source: ObjectId, + base: ObjectId, + target: ObjectId, + copy: bool, + }, + PasteInsert { + source: ObjectId, + target: ObjectId, + }, + StartReview { + tip: ObjectId, + base: ObjectId, + }, + FinishReview { + review: ObjectId, + return_to: Option<ObjectId>, + }, + ForkCommit(ObjectId), + Attach, + TimeTravel(ObjectId), + TogglePin(ObjectId), + ToggleTodo(ObjectId), + ToggleChecksPass(ObjectId), + EditNote(ObjectId), + EditGitNote(ObjectId), + VerifySignatures(Vec<ObjectId>), Quit, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Alignment { + None, + Title, + Columns, + Compressed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistoryEntry { + Commit(usize), + Segment { representative: usize, count: usize }, +} + +#[derive(Debug)] +struct CompressedHistory { + entries: Vec<HistoryEntry>, + display_indices: HashMap<usize, usize>, + member_indices: HashMap<ObjectId, usize>, + members: Vec<Vec<usize>>, + rows: Vec<SharedCommitRow>, + graph: Graph, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TreeDiffTarget { + Commit { id: ObjectId, parent: usize }, + Branch { base: ObjectId, tip: ObjectId }, +} + +impl TreeDiffTarget { + pub(crate) fn selected(self) -> ObjectId { + match self { + TreeDiffTarget::Commit { id, .. } => id, + TreeDiffTarget::Branch { base, .. } => base, + } + } +} + #[derive(Debug)] pub(crate) struct App { - pub rows: Vec<CommitRow>, + pub rows: Vec<SharedCommitRow>, + all_rows: HashMap<ObjectId, SharedCommitRow>, + all_order: Vec<ObjectId>, + hidden_rows: HashSet<ObjectId>, + hidden_branch_targets: HashMap<ObjectId, ObjectId>, + hidden_rebase_bases: HashSet<ObjectId>, + pending_hidden_rows: Option<HashSet<ObjectId>>, titles: Vec<u8>, + notes: HashMap<ObjectId, Vec<BString>>, + enrichments: HashMap<ObjectId, crate::enrich::Enrichment>, + tree_enrichments: HashMap<ObjectId, crate::enrich::TreeEnrichment>, graph: Option<Graph>, + view_tips: HashSet<ObjectId>, + compressed_history: Option<CompressedHistory>, + compressed_anchor: Option<ObjectId>, + compressed_segment: Option<ObjectId>, + compressed_expanded: HashSet<ObjectId>, + time_travel_animation: Option<(ObjectId, usize)>, attributions: Vec<Attribution>, #[cfg(test)] test_lanes: Vec<String>, pub selected: Option<usize>, pub offset: usize, + viewport_panned: bool, pub state: State, + pub(crate) deferred_history_state: Option<State>, pub viewport_rows: usize, pub lane_time: Option<Duration>, - pub show_committer_date: bool, + pub date_mode: DateMode, + pub id_mode: IdMode, pub name_mode: NameMode, + pub show_emails: bool, pub show_trailers: bool, pub use_mailmap: bool, pub ref_mode: RefMode, + visible_ref_mode: RefMode, pub has_hidden_filter: bool, pub show_hidden: bool, - pub align_metadata: bool, + pub(crate) alignment: Alignment, pub show_commit: bool, - pub(crate) show_selection_tail: bool, - pub inline: bool, - pub preview_author_copy: bool, + pub changes_mode: Option<ChangesMode>, + worktree_changes_available: bool, + pub(crate) changes_suppressed: bool, + pub(crate) changes_focus: Option<ChangePane>, + pub(crate) changes_layout: ChangesLayout, + pub(crate) tree_changes_visible: bool, + pub(crate) worktree_changes_visible: bool, + pub(crate) tree_changes: ChangesView, + pub(crate) worktree_changes: ChangesView, + pub(crate) changes_parent: usize, + pub(crate) commit_offset: usize, + pub(crate) commit_pane_background: Option<(u8, u8, u8)>, + commit_page: usize, + commit_max: usize, + reachability_anchor: Option<ObjectId>, + review_tip: Option<ObjectId>, + review_return: Option<(ObjectId, ObjectId)>, + squash_source: Option<ObjectId>, + stack_insert_base: Option<ObjectId>, + reachable_rows: Option<Vec<bool>>, pub copy_feedback: Option<CopyKind>, + pub(crate) focus_feedback: Option<&'static str>, + notice: Option<Notice>, + undo_position: Option<UndoPosition>, + pub(crate) unseen_filesystem_redraw: bool, + pub(crate) history_display_expanded: bool, + pub(crate) actions_expanded: bool, + pub(crate) enrich_expanded: bool, + pub(crate) information_expanded: bool, + topo_children: HashMap<ObjectId, ObjectId>, pub estimated_lane_width: usize, pub horizontal_offset: usize, horizontal_page: usize, horizontal_max: usize, follow_tail: bool, + reload_selection: Option<ObjectId>, + pending_initial_selection: Option<ObjectId>, + selection_after_refresh: Option<ObjectId>, + worktree_head: Option<ObjectId>, + worktree_branch: Option<(ObjectId, bool)>, + worktree_head_has_descendants: bool, + worktree_head_unborn: bool, + pending_rebase_conflict: Option<ObjectId>, + rebase_continuation_pending: bool, + worktree_conflicted: bool, + amend_available: bool, + stash_available: bool, + unstash_available: bool, + worktree_path_amend_available: bool, + finish_review_available: bool, + spill_available: bool, + split_available: bool, + new_commit_available: bool, + new_empty_commit_available: bool, + known_descendants: HashSet<ObjectId>, + known_merge_descendants: HashSet<ObjectId>, + select_top_after_refresh: bool, + pub(crate) signature_failures: usize, + signature_verification_running: bool, + pub(crate) selection_relation: Option<SelectionRelation>, + hidden_branch_updates: HashMap<ObjectId, (usize, ObjectId)>, + change_ids: HashMap<ObjectId, ChangeId>, + duplicate_change_ids: HashSet<ObjectId>, } impl App { pub fn new(viewport_rows: usize) -> Self { App { rows: Vec::new(), + all_rows: HashMap::new(), + all_order: Vec::new(), + hidden_rows: HashSet::new(), + hidden_branch_targets: HashMap::new(), + hidden_rebase_bases: HashSet::new(), + pending_hidden_rows: None, titles: Vec::new(), + notes: HashMap::new(), + enrichments: HashMap::new(), + tree_enrichments: HashMap::new(), graph: None, + view_tips: HashSet::new(), + compressed_history: None, + compressed_anchor: None, + compressed_segment: None, + compressed_expanded: HashSet::new(), + time_travel_animation: None, attributions: Vec::new(), #[cfg(test)] test_lanes: Vec::new(), selected: None, offset: 0, + viewport_panned: false, state: State::Loading, + deferred_history_state: None, viewport_rows, lane_time: None, - show_committer_date: true, + date_mode: DateMode::default(), + id_mode: IdMode::default(), name_mode: NameMode::All, + show_emails: false, show_trailers: true, use_mailmap: true, ref_mode: RefMode::Default, + visible_ref_mode: RefMode::Default, has_hidden_filter: false, show_hidden: false, - align_metadata: true, + alignment: Alignment::Title, show_commit: false, - show_selection_tail: true, - inline: false, - preview_author_copy: false, + changes_mode: Some(ChangesMode::Both), + worktree_changes_available: true, + changes_suppressed: false, + changes_focus: None, + changes_layout: ChangesLayout::SideBySide, + tree_changes_visible: false, + worktree_changes_visible: false, + tree_changes: ChangesView::default(), + worktree_changes: ChangesView::default(), + changes_parent: 0, + commit_offset: 0, + commit_pane_background: None, + commit_page: 1, + commit_max: 0, + reachability_anchor: None, + review_tip: None, + review_return: None, + squash_source: None, + stack_insert_base: None, + reachable_rows: None, copy_feedback: None, + focus_feedback: None, + notice: None, + undo_position: None, + unseen_filesystem_redraw: false, + history_display_expanded: false, + actions_expanded: false, + enrich_expanded: false, + information_expanded: false, + topo_children: HashMap::new(), estimated_lane_width: 0, horizontal_offset: 0, horizontal_page: 1, horizontal_max: 0, follow_tail: false, + reload_selection: None, + pending_initial_selection: None, + selection_after_refresh: None, + worktree_head: None, + worktree_branch: None, + worktree_head_has_descendants: false, + worktree_head_unborn: false, + pending_rebase_conflict: None, + rebase_continuation_pending: false, + worktree_conflicted: false, + amend_available: false, + stash_available: false, + unstash_available: false, + worktree_path_amend_available: false, + finish_review_available: false, + spill_available: false, + split_available: false, + new_commit_available: true, + new_empty_commit_available: true, + known_descendants: HashSet::new(), + known_merge_descendants: HashSet::new(), + select_top_after_refresh: false, + signature_failures: 0, + signature_verification_running: false, + selection_relation: None, + hidden_branch_updates: HashMap::new(), + change_ids: HashMap::new(), + duplicate_change_ids: HashSet::new(), + } + } + + pub(crate) fn effective_id_mode(&self) -> IdMode { + match self.id_mode { + IdMode::Off if !self.duplicate_change_ids.is_empty() => IdMode::Change, + mode => mode, + } + } + + pub(crate) fn change_id(&self, id: ObjectId) -> ChangeId { + self.change_ids.get(&id).copied().unwrap_or_else(|| id.into()) + } + + pub(crate) fn has_duplicate_change_id(&self, id: ObjectId) -> bool { + self.duplicate_change_ids.contains(&id) + } + + pub(crate) fn has_duplicate_change_ids(&self) -> bool { + !self.duplicate_change_ids.is_empty() + } + + pub(crate) fn can_cycle_duplicate(&self) -> bool { + self.changes_focus.is_none() && self.reachable_rows.is_none() && self.next_duplicate().is_some() + } + + pub(crate) fn set_change_ids(&mut self, change_ids: HashMap<ObjectId, ChangeId>, duplicates: HashSet<ObjectId>) { + self.change_ids = change_ids; + self.duplicate_change_ids = duplicates; + } + + #[cfg(test)] + fn clear_change_ids(&mut self) { + self.change_ids.clear(); + self.duplicate_change_ids.clear(); + } + + pub(crate) fn leave_attention(&mut self, message: impl Into<String>) { + self.leave_notice(NoticeKind::Attention, message); + } + + pub(crate) fn leave_success(&mut self, message: impl Into<String>) { + self.leave_notice(NoticeKind::Success, message); + } + + pub(crate) fn leave_error(&mut self, message: impl Into<String>) { + self.leave_notice(NoticeKind::Error, message); + } + + pub(crate) fn close_shortcut_groups(&mut self) { + self.history_display_expanded = false; + self.actions_expanded = false; + self.enrich_expanded = false; + self.information_expanded = false; + } + + fn leave_notice(&mut self, kind: NoticeKind, message: impl Into<String>) { + self.notice = Some(Notice { + kind, + text: message.into(), + }); + } + + pub(crate) fn notice(&self) -> Option<Notice> { + let prompt = if self.rebase_continuation_pending() { + Some(if self.rebase_continuation_conflicted() { + "REBASE PAUSED · resolve conflicts, then <enter> continue · Esc stop" + } else { + "REBASE PAUSED · <enter> continue · Esc stop" + }) + } else if self.pending_rebase_conflict.is_some() { + Some("rebase conflict · <enter> checkout for resolution · Esc cancel") + } else if self.selected_is_segment() && self.reachable_rows.is_some() { + Some("compressed segment · <enter> expand · Esc cancel") + } else if self.review_return_selection_active() { + Some("review return is missing · j/k select commit · <enter> finish detached · Esc cancel") + } else if self.review_selection_active() { + Some("review base · j/k select ancestor · <enter> start · Esc cancel") + } else if self.squash_selection_active() { + Some("squash target · j/k select ancestor · <enter> squash · Esc cancel") + } else if self.stack_insert_base.is_some() { + Some("stack-insert target · j/k select insertion point · <enter> insert · Esc cancel") + } else { + None + }; + match (prompt, self.notice.as_ref()) { + (Some(prompt), Some(notice)) if notice.text != prompt => Some(Notice { + kind: notice.kind.max(NoticeKind::Attention), + text: format!("{prompt} · {}", notice.text), + }), + (Some(prompt), _) => Some(Notice { + kind: NoticeKind::Attention, + text: prompt.into(), + }), + (None, notice) => notice.cloned(), + } + } + + pub(crate) fn show_undo_position(&mut self, applied: usize, total: usize, title: impl Into<String>) { + let applied = applied.min(total); + let title = if applied == 0 { + "start of undo history".into() + } else { + title.into() + }; + self.leave_success(format!("{title} · {applied} undo · {} redo", total - applied)); + self.undo_position = Some(UndoPosition { applied, total, title }); + } + + pub(crate) fn undo_position(&self) -> Option<(usize, usize, &str)> { + self.undo_position + .as_ref() + .map(|position| (position.applied, position.total, position.title.as_str())) + } + + pub(crate) fn dismiss_undo_position(&mut self) { + self.undo_position = None; + } + + pub(crate) fn configure_hidden_filter(&mut self, present: bool) { + self.has_hidden_filter = present; + } + + pub(crate) fn set_worktree_head(&mut self, head: Option<ObjectId>, select_on_load: bool) { + if !select_on_load + && self.worktree_head != head + && self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) == self.worktree_head + { + self.selection_after_refresh = head; + } + self.worktree_head = head; + self.pending_initial_selection = select_on_load.then_some(head).flatten(); + self.update_worktree_head_descendants(); + } + + pub(crate) fn set_worktree_branch(&mut self, branch: Option<(ObjectId, bool)>) { + self.worktree_branch = branch; + } + + pub(crate) fn set_worktree_head_unborn(&mut self, unborn: bool) { + self.worktree_head_unborn = unborn; + } + + pub(crate) fn set_view_tips(&mut self, tips: &[ObjectId]) { + self.view_tips.clear(); + self.view_tips.extend(tips.iter().copied()); + if self.alignment == Alignment::Compressed { + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + self.compressed_anchor = Some(id); + } + self.rebuild_compressed_history(); + } + } + + pub(crate) fn arm_rebase_conflict(&mut self, id: ObjectId) { + self.materialize_compressed_selection(); + tracing::warn!( + commit_id = %id, + "rebase suspended on conflict; press <enter> to checkout for resolution" + ); + self.pending_rebase_conflict = Some(id); + self.rebase_continuation_pending = false; + if let Some(index) = self.rows.iter().position(|row| row.id == id) { + self.select(index); + self.center_selection(); + } else { + self.selection_after_refresh = Some(id); + } + } + + pub(crate) fn has_rebase_conflict(&self) -> bool { + self.pending_rebase_conflict.is_some() + } + + pub(crate) fn has_conflict_marker(&self) -> bool { + self.pending_rebase_conflict.is_some() || self.worktree_conflicted + } + + pub(crate) fn clear_rebase_conflict(&mut self) { + self.pending_rebase_conflict = None; + self.notice = None; + self.restore_compressed_history_around_selection(); + } + + pub(crate) fn begin_conflict_resolution(&mut self) { + self.pending_rebase_conflict = None; + self.worktree_conflicted = true; + self.changes_mode = Some(ChangesMode::Both); + self.changes_focus = None; + self.restore_compressed_history_around_selection(); + } + + pub(crate) fn arm_rebase_continuation(&mut self) { + self.materialize_compressed_selection(); + self.rebase_continuation_pending = true; + self.ensure_visible(); + } + + pub(crate) fn clear_rebase_continuation(&mut self) { + self.rebase_continuation_pending = false; + self.restore_compressed_history_around_selection(); + } + + pub(crate) fn rebase_continuation_pending(&self) -> bool { + self.rebase_continuation_pending + } + + pub(crate) fn rebase_continuation_conflicted(&self) -> bool { + self.rebase_continuation_pending && self.worktree_conflicted + } + + pub(crate) fn set_worktree_conflicted(&mut self, conflicted: bool) { + if self.worktree_conflicted == conflicted { + return; + } + if conflicted { + self.materialize_compressed_selection(); + } + self.worktree_conflicted = conflicted; + if conflicted { + self.ensure_visible(); + } else { + self.restore_compressed_history_around_selection(); } } + pub(crate) fn conflict_marker(&self, id: ObjectId, head: bool) -> bool { + self.pending_rebase_conflict == Some(id) || (head && self.worktree_conflicted) + } + + pub(crate) fn set_known_descendants(&mut self, ids: HashSet<ObjectId>) { + self.known_descendants = ids; + self.update_worktree_head_descendants(); + } + + pub(crate) fn set_known_merge_descendants(&mut self, ids: HashSet<ObjectId>) { + self.known_merge_descendants = ids; + } + + pub(crate) fn worktree_head_has_descendants(&self, id: ObjectId) -> bool { + self.worktree_head == Some(id) && self.worktree_head_has_descendants + } + pub(crate) fn extend_commits(&mut self, commits: impl Into<LoadedCommits>) { - let LoadedCommits { rows, attributions } = commits.into(); - if self.state != State::Loading || rows.is_empty() { + let commits = commits.into(); + if self.state != State::Loading || commits.rows.is_empty() { return; } + let rows = self.store_commits(commits); let was_empty = self.rows.is_empty(); - self.titles.reserve(rows.iter().map(|row| row.title.len()).sum()); - let attribution_base = self.attributions.len(); - self.attributions.extend(attributions); self.rows.reserve(rows.len()); for row in rows { - let start = self.titles.len(); - self.titles.extend_from_slice(&row.title); - self.rows.push(Commit { - id: row.id, - parent_ids: row.parent_ids, - committer_time: row.committer_time, - author: row.author, - attributions: attribution_base + row.attributions.start..attribution_base + row.attributions.end, - title: start..self.titles.len(), - metadata_loaded: row.metadata_loaded, - }); + self.rows.push(row); } if was_empty { self.estimated_lane_width = estimate_lane_width(&self.rows[..self.viewport_rows.min(self.rows.len())]); - self.selected = Some(0); + self.selected = self.first_selectable(); self.ensure_visible(); } else if self.follow_tail { - self.selected = Some(self.rows.len() - 1); + self.selected = self.last_selectable(); + self.ensure_visible(); + } + if let Some(index) = self + .reload_selection + .and_then(|id| self.rows.iter().position(|row| row.id == id)) + { + self.selected = Some(index); + self.reload_selection = None; + self.ensure_visible(); + } + if let Some(index) = self + .pending_initial_selection + .and_then(|id| self.rows.iter().position(|row| row.id == id)) + { + self.selected = Some(index); self.ensure_visible(); } + if self.reachability_anchor.is_some() { + self.compute_reachable_rows(); + } + } + + fn store_commits(&mut self, commits: LoadedCommits) -> Vec<SharedCommitRow> { + let LoadedCommits { rows, attributions } = commits; + if !self.worktree_head_has_descendants + && let Some(head) = self.worktree_head + { + self.worktree_head_has_descendants = rows.iter().any(|row| row.parent_ids.contains(&head)); + } + self.titles.reserve(rows.iter().map(|row| row.title.len()).sum()); + let attribution_base = self.attributions.len(); + self.attributions.extend(attributions); + rows.into_iter() + .map(|row| { + let start = self.titles.len(); + self.titles.extend_from_slice(&row.title); + let row = Commit { + id: row.id, + parent_ids: row.parent_ids, + committer_time: row.committer_time, + author_time: row.author_time, + author: row.author, + attributions: attribution_base + row.attributions.start..attribution_base + row.attributions.end, + title: start..self.titles.len(), + metadata_loaded: row.metadata_loaded, + has_agent_marker: row.has_agent_marker, + is_review: row.is_review, + signature: row.signature, + }; + let row = Arc::new(row); + if self.all_rows.insert(row.id, Arc::clone(&row)).is_none() { + self.all_order.push(row.id); + } + row + }) + .collect() + } + + pub(crate) fn extend_hidden_commits(&mut self, commits: impl Into<LoadedCommits>) { + let commits = commits.into(); + self.hidden_rows.extend(commits.rows.iter().map(|row| row.id)); + self.extend_commits(commits); + } + + pub(crate) fn is_row_hidden(&self, index: usize) -> bool { + self.rows + .get(index) + .is_some_and(|row| self.hidden_rows.contains(&row.id)) } pub(crate) fn set_metadata( @@ -268,21 +1062,31 @@ impl App { if row.metadata_loaded { return; } + let row = Arc::make_mut(row); let Metadata { committer_time, + author_time, author, attributions, title, + has_agent_marker, + is_review, + signature, } = metadata; let title_start = self.titles.len(); self.titles.extend_from_slice(&title); let attribution_start = self.attributions.len(); self.attributions.extend(new_attributions); row.committer_time = committer_time; + row.author_time = author_time; row.author = author; row.attributions = attribution_start + attributions.start..attribution_start + attributions.end; row.title = title_start..self.titles.len(); row.metadata_loaded = true; + row.has_agent_marker = has_agent_marker; + row.is_review = is_review; + row.signature = signature; + self.all_rows.insert(row.id, Arc::clone(&self.rows[index])); } pub(crate) fn title(&self, row: &CommitRow) -> &BStr { @@ -290,771 +1094,5315 @@ impl App { self.titles[row.title.clone()].as_bstr() } - pub(crate) fn render_lanes(&self, range: Range<usize>) -> RenderedLanes { - #[cfg(test)] - if !self.test_lanes.is_empty() { - return RenderedLanes::from_lanes( - self.test_lanes[range.start.min(self.test_lanes.len())..range.end.min(self.test_lanes.len())].iter(), - ); - } - match &self.graph { - Some(graph) => graph.render(&self.rows, range), - None => RenderedLanes::empty(range.len()), - } + pub(crate) fn notes_loaded(&self, id: ObjectId) -> bool { + self.notes.contains_key(&id) } - pub(crate) fn attributions(&self, row: &CommitRow) -> &[Attribution] { - debug_assert!(row.metadata_loaded, "visible rows have metadata"); - &self.attributions[row.attributions.clone()] + pub(crate) fn set_notes(&mut self, id: ObjectId, notes: Vec<BString>) { + self.notes.insert(id, notes); } - pub fn update(&mut self, action: Action) -> Vec<Effect> { - match action { - Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, - Action::MoveUp => self.move_selection(1, false), - Action::MoveDown => self.move_selection(1, true), - Action::ScrollLeft => { - self.horizontal_offset = self.horizontal_offset.saturating_sub(self.horizontal_page); - } - Action::ScrollRight => { - self.horizontal_offset = self - .horizontal_offset - .saturating_add(self.horizontal_page) - .min(self.horizontal_max); - } - Action::HalfPageUp => self.move_selection((self.viewport_rows / 2).max(1), false), - Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), - Action::PageUp => self.move_selection(self.viewport_rows.max(1), false), - Action::PageDown => self.move_selection(self.viewport_rows.max(1), true), - Action::First => self.select(0), - Action::Last if !self.rows.is_empty() => { - self.selected = Some(self.rows.len() - 1); - self.follow_tail = self.state == State::Loading; - self.ensure_visible(); - } - Action::ToggleDate => self.show_committer_date = !self.show_committer_date, - Action::ToggleName => { - let start = self.offset.min(self.rows.len()); - let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); - let has_visible_attributions = self.rows[start..end] - .iter() - .any(|row| row.metadata_loaded && !row.attributions.is_empty()); - self.name_mode = match self.name_mode { - NameMode::All if has_visible_attributions => NameMode::Author, - NameMode::All => NameMode::None, - NameMode::Author => NameMode::None, - NameMode::None => NameMode::All, - }; - } - Action::ToggleTrailers => self.show_trailers = !self.show_trailers, - Action::ToggleMailmap => self.use_mailmap = !self.use_mailmap, - Action::ToggleRefs => { - self.ref_mode = match self.ref_mode { - RefMode::All => RefMode::Default, - RefMode::Default => RefMode::None, - RefMode::None => RefMode::All, - }; - } - Action::ToggleHidden - if self.has_hidden_filter && matches!(self.state, State::Complete | State::Cancelled) => - { - return vec![Effect::Reload(!self.show_hidden)]; - } - Action::ToggleAlign => self.align_metadata = !self.align_metadata, - Action::ToggleCommit => self.show_commit = !self.show_commit, - Action::PreviewAuthorCopy(value) => self.preview_author_copy = value, - Action::Cancel if self.state == State::Loading => { - self.state = State::Cancelling; - return vec![Effect::Cancel]; - } - Action::Copy => { - if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { - self.copy_feedback = Some(CopyKind::Id); - return vec![Effect::CopyId(id)]; - } - } - Action::CopyAuthor => { - if let Some(author) = self - .selected - .and_then(|index| self.rows.get(index)) - .filter(|row| row.metadata_loaded) - .map(|row| row.author) - { - self.copy_feedback = Some(CopyKind::Author); - return vec![Effect::CopyAuthor(author)]; - } - } - Action::Quit => { - return if matches!(self.state, State::Loading | State::Cancelling) { - vec![Effect::Cancel, Effect::Quit] - } else { - vec![Effect::Quit] - }; - } - _ => {} - } - Vec::new() + pub(crate) fn clear_notes(&mut self, id: ObjectId) { + self.notes.remove(&id); } - pub(crate) fn start_lane_computation(&mut self) -> Option<Vec<CommitRow>> { - match self.state { - State::Loading => { - self.state = State::Computing; - self.follow_tail = false; - Some(self.rows.clone()) - } - State::Cancelling => { - self.state = State::Cancelled; - self.follow_tail = false; - None - } - _ => None, - } + pub(crate) fn notes(&self, id: ObjectId) -> &[BString] { + self.notes.get(&id).map(Vec::as_slice).unwrap_or_default() } - pub(crate) fn finish_lane_computation(&mut self, rows: Vec<CommitRow>, graph: Graph, lane_time: Duration) { - if self.state != State::Computing { - return; - } - let selected = self.selected.map(|index| self.rows[index].id); - let metadata: HashMap<_, _> = if rows.iter().any(|row| !row.metadata_loaded) { - self.rows - .iter() - .filter(|row| row.metadata_loaded) - .map(|row| { - ( - row.id, - Metadata { - committer_time: row.committer_time, - author: row.author, - attributions: row.attributions.clone(), - title: row.title.clone(), - }, - ) - }) - .collect() - } else { - HashMap::new() - }; - self.rows = rows; - for row in &mut self.rows { - if let Some(metadata) = metadata.get(&row.id) { - row.committer_time = metadata.committer_time; - row.author = metadata.author; - row.attributions = metadata.attributions.clone(); - row.title = metadata.title.clone(); - row.metadata_loaded = true; - } - } - self.graph = Some(graph); - self.lane_time = Some(lane_time); - self.selected = selected.and_then(|id| self.rows.iter().position(|row| row.id == id)); - self.state = State::Complete; - self.ensure_visible(); + pub(crate) fn enrichment_loaded(&self, id: ObjectId) -> bool { + self.enrichments.contains_key(&id) } - pub(crate) fn reload(&mut self, show_hidden: bool) { - self.rows = Vec::new(); - self.titles = Vec::new(); - self.graph = None; - self.attributions = Vec::new(); - #[cfg(test)] - self.test_lanes.clear(); - self.selected = None; - self.offset = 0; - self.state = State::Loading; - self.lane_time = None; - self.estimated_lane_width = 0; - self.show_hidden = show_hidden; - self.horizontal_offset = 0; - self.follow_tail = false; + pub(crate) fn set_enrichment(&mut self, id: ObjectId, enrichment: crate::enrich::Enrichment) { + self.enrichments.insert(id, enrichment); } - fn move_selection(&mut self, distance: usize, down: bool) { - let Some(selected) = self.selected else { return }; - self.selected = Some(if down { - selected.saturating_add(distance).min(self.rows.len() - 1) - } else { - selected.saturating_sub(distance) - }); - self.follow_tail = false; - self.ensure_visible(); + pub(crate) fn todo(&self, id: ObjectId) -> bool { + self.enrichments.get(&id).is_some_and(|enrichment| enrichment.todo) } - fn select(&mut self, selected: usize) { - if !self.rows.is_empty() { - self.selected = Some(selected.min(self.rows.len() - 1)); - self.follow_tail = false; - self.ensure_visible(); - } + pub(crate) fn note(&self, id: ObjectId) -> Option<&BStr> { + self.enrichments + .get(&id) + .and_then(|enrichment| enrichment.note.as_ref().map(|note| note.as_bstr())) } - pub(crate) fn ensure_visible(&mut self) { - let Some(selected) = self.selected else { return }; - let height = self.viewport_rows.max(1); - if selected < self.offset { - self.offset = selected; - } else if selected >= self.offset.saturating_add(height) { - self.offset = selected + 1 - height; - } + pub(crate) fn tree_enrichment_loaded(&self, id: ObjectId) -> bool { + self.tree_enrichments.contains_key(&id) } - pub(crate) fn set_horizontal_bounds(&mut self, page: usize, max: usize) { - self.horizontal_page = page.max(1); - self.horizontal_max = max; - self.horizontal_offset = self.horizontal_offset.min(max); + pub(crate) fn set_tree_enrichment(&mut self, id: ObjectId, enrichment: crate::enrich::TreeEnrichment) { + self.tree_enrichments.insert(id, enrichment); } - #[cfg(test)] - pub(crate) fn set_lane(&mut self, index: usize, lane: &str) { - self.test_lanes.resize(self.rows.len(), String::new()); - self.test_lanes[index] = lane.into(); + pub(crate) fn checks_pass(&self, id: ObjectId) -> bool { + self.tree_enrichments + .get(&id) + .is_some_and(|enrichment| enrichment.checks_pass) } -} -fn estimate_lane_width(rows: &[CommitRow]) -> usize { - let mut rows = rows.to_vec(); - let known: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); - for row in &mut rows { - row.parent_ids.retain(|id| known.contains_key(id)); + pub(crate) fn clear_enrichments(&mut self) { + self.enrichments.clear(); + self.tree_enrichments.clear(); } - let graph = Graph::new(&rows); - graph - .render(&rows, 0..rows.len()) - .iter() - .map(|lane| lane.trim_end().chars().count().saturating_add(1)) - .max() - .unwrap_or_default() -} -pub(crate) fn compute_lanes(mut rows: Vec<CommitRow>) -> (Vec<CommitRow>, Graph, Duration) { - let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); - for row in &mut rows { - row.parent_ids.retain(|id| positions.contains_key(id)); + pub(crate) fn history_len(&self) -> usize { + self.active_compressed_history() + .map_or(self.rows.len(), |history| history.entries.len()) } - let mut children = vec![0usize; rows.len()]; - for row in rows.iter() { - for parent in &row.parent_ids { - if let Some(index) = positions.get(parent) { - children[*index] += 1; - } - } + + pub(crate) fn history_entry(&self, index: usize) -> Option<HistoryEntry> { + self.active_compressed_history().map_or_else( + || (index < self.rows.len()).then_some(HistoryEntry::Commit(index)), + |history| history.entries.get(index).copied(), + ) } - let mut ready: Vec<_> = children - .iter() - .enumerate() - .rev() - .filter_map(|(index, count)| (*count == 0).then_some(index)) - .collect(); - let mut ordered = 0; - while let Some(index) = ready.pop() { - for parent in rows[index].parent_ids.iter().rev() { - if let Some(parent_index) = positions.get(parent) { - children[*parent_index] -= 1; - if children[*parent_index] == 0 { - ready.push(*parent_index); - } - } - } - // A ready row's child count is dead, so reuse it as the row's destination. - children[index] = ordered; - ordered += 1; + fn history_row(&self, index: usize) -> Option<&CommitRow> { + self.active_compressed_history().map_or_else( + || self.rows.get(index).map(Arc::as_ref), + |history| history.rows.get(index).map(Arc::as_ref), + ) } - if ordered == rows.len() { - for index in 0..rows.len() { - while children[index] != index { - let destination = children[index]; - rows.swap(index, destination); - children.swap(index, destination); - } - } + + pub(crate) fn history_index(&self, canonical_index: usize) -> Option<usize> { + self.active_compressed_history().map_or_else( + || (canonical_index < self.rows.len()).then_some(canonical_index), + |history| history.display_indices.get(&canonical_index).copied(), + ) } - let start = Instant::now(); - let graph = Graph::new(&rows); - (rows, graph, start.elapsed()) -} -const CHECKPOINT_INTERVAL: usize = 256; + pub(crate) fn selected_history_index(&self) -> Option<usize> { + self.selected + .and_then(|selected| self.history_index(selected)) + .or_else(|| { + let history = self.active_compressed_history()?; + let selected = self.compressed_segment?; + let index = *history.member_indices.get(&selected)?; + matches!(history.entries[index], HistoryEntry::Segment { .. }).then_some(index) + }) + } -#[derive(Debug)] -pub(crate) struct Graph { - offsets: Vec<usize>, - columns: Vec<ObjectId>, -} + pub(crate) fn selected_is_segment(&self) -> bool { + self.compressed_segment.is_some() && self.selected_history_index().is_some() + } -impl Graph { - fn new(rows: &[CommitRow]) -> Self { - let mut state = LaneState::default(); - let mut graph = Graph { - offsets: Vec::with_capacity(rows.len().div_ceil(CHECKPOINT_INTERVAL) + 1), - columns: Vec::new(), - }; - for (index, row) in rows.iter().enumerate() { - if index % CHECKPOINT_INTERVAL == 0 { - graph.offsets.push(graph.columns.len()); - graph.columns.extend_from_slice(&state.columns); + pub(crate) fn visible_history_indices(&self, range: Range<usize>) -> Vec<usize> { + let start = range.start.min(self.history_len()); + let end = range.end.min(self.history_len()); + (start..end) + .filter_map(|index| match self.history_entry(index) { + Some(HistoryEntry::Commit(index)) => Some(index), + Some(HistoryEntry::Segment { .. }) | None => None, + }) + .collect() + } + + pub(crate) fn has_verifiable_signatures(&self) -> bool { + self.visible_history_indices(self.offset..self.offset.saturating_add(self.viewport_rows)) + .into_iter() + .any(|index| { + !self.hidden_rows.contains(&self.rows[index].id) + && matches!( + self.rows[index].signature, + SignatureState::Unverified | SignatureState::Verifying + ) + }) + } + + fn active_compressed_history(&self) -> Option<&CompressedHistory> { + (self.alignment == Alignment::Compressed && !self.compressed_history_suspended()) + .then_some(self.compressed_history.as_ref()) + .flatten() + } + + fn compressed_history_suspended(&self) -> bool { + self.time_travel_animation.is_some() + || self.pending_rebase_conflict.is_some() + || self.rebase_continuation_pending + || self.worktree_conflicted + } + + fn rebuild_compressed_history(&mut self) { + let previous_display = self.selected_history_index(); + self.compressed_expanded.retain(|id| self.all_rows.contains_key(id)); + let history = (self.graph.is_some() && !self.rows.is_empty()).then(|| { + CompressedHistory::new( + &self.rows, + &self.view_tips, + &self.hidden_rows, + &self.compressed_expanded, + self.compressed_anchor, + ) + }); + let selection = self.compressed_segment.and_then(|id| { + let history = history.as_ref()?; + let display = *history.member_indices.get(&id)?; + history.entries.get(display).copied() + }); + let fallback = previous_display.and_then(|display| { + let history = history.as_ref()?; + history + .entries + .get(display.min(history.entries.len().saturating_sub(1))) + .copied() + }); + self.compressed_history = history; + if self.compressed_segment.is_some() { + match selection.or(fallback) { + Some(HistoryEntry::Commit(index)) => { + self.selected = Some(index); + self.compressed_segment = None; + } + Some(HistoryEntry::Segment { representative, .. }) => { + self.selected = None; + self.compressed_segment = self.rows.get(representative).map(|row| row.id); + } + None => { + self.selected = (!self.rows.is_empty()).then_some(0); + self.compressed_segment = None; + } } - state.advance(row, None); } - graph.offsets.push(graph.columns.len()); - graph } - fn render(&self, rows: &[CommitRow], range: Range<usize>) -> RenderedLanes { - let start = range.start.min(rows.len()); - let end = range.end.min(rows.len()); - if start >= end { - return RenderedLanes::default(); - } - let checkpoint = start / CHECKPOINT_INTERVAL; - let mut state = LaneState { - columns: self.columns[self.offsets[checkpoint]..self.offsets[checkpoint + 1]].to_vec(), - ..LaneState::default() - }; - let mut rendered = RenderedLanes { - data: String::with_capacity((end - start).saturating_mul(4)), - ranges: Vec::with_capacity(end - start), + fn materialize_compressed_selection(&mut self) { + let Some(id) = self.compressed_segment.take() else { + return; }; - for (index, row) in rows[checkpoint * CHECKPOINT_INTERVAL..end].iter().enumerate() { - let index = checkpoint * CHECKPOINT_INTERVAL + index; - if let Some(range) = state.advance(row, (index >= start).then_some(&mut rendered.data)) { - rendered.ranges.push(range); - } + self.selected = self.rows.iter().position(|row| row.id == id); + } + + pub(crate) fn history_entry_selectable(&self, display: usize) -> bool { + match self.history_entry(display) { + Some(HistoryEntry::Commit(index)) => self.history_row_selectable(index), + Some(HistoryEntry::Segment { .. }) => self + .active_compressed_history() + .and_then(|history| history.members.get(display)) + .is_some_and(|members| members.iter().copied().any(|index| self.history_row_selectable(index))), + None => false, } - rendered } -} -#[derive(Debug, Default)] -pub(crate) struct RenderedLanes { - data: String, - ranges: Vec<Range<usize>>, -} + fn history_row_selectable(&self, index: usize) -> bool { + self.reachable_rows.as_ref().is_none() || (self.is_row_reachable(index) && self.reachable_row_selectable(index)) + } -impl RenderedLanes { - pub(crate) fn lane(&self, index: usize) -> &str { - &self.data[self.ranges[index].clone()] + fn topological_positions(&self) -> HashMap<ObjectId, usize> { + (0..self.history_len()) + .filter_map(|index| self.history_row(index).map(|row| (row.id, index))) + .collect() } - pub(crate) fn iter(&self) -> impl Iterator<Item = &str> { - self.ranges.iter().map(|range| &self.data[range.clone()]) + fn topological_graph(&self) -> Option<&Graph> { + self.active_compressed_history() + .map(|history| &history.graph) + .or(self.graph.as_ref()) } - fn empty(len: usize) -> Self { - RenderedLanes { - data: String::new(), - ranges: vec![0..0; len], + fn topological_parent_from_rows( + &self, + index: usize, + positions: &HashMap<ObjectId, usize>, + stop: Option<usize>, + ) -> Option<usize> { + let mut parent = self.history_row(index)?.parent_ids.first().copied(); + while let Some(id) = parent { + let index = *positions.get(&id)?; + if stop == Some(index) || self.history_entry_selectable(index) { + return Some(index); + } + parent = self.history_row(index)?.parent_ids.first().copied(); } + None } - #[cfg(test)] - fn from_lanes<'a>(lanes: impl IntoIterator<Item = &'a String>) -> Self { - let mut rendered = RenderedLanes::default(); - for lane in lanes { - let start = rendered.data.len(); - rendered.data.push_str(lane); - rendered.ranges.push(start..rendered.data.len()); + fn topological_parent(&self, index: usize, stop: Option<usize>) -> Option<usize> { + if let Some(graph) = self.topological_graph() { + let mut parent = graph.first_parents.get(index).copied().flatten(); + while let Some(index) = parent { + if stop == Some(index) || self.history_entry_selectable(index) { + return Some(index); + } + parent = graph.first_parents.get(index).copied().flatten(); + } + return None; } - rendered + self.topological_parent_from_rows(index, &self.topological_positions(), stop) } -} -#[derive(Default)] -struct LaneState { - columns: Vec<ObjectId>, - next: Vec<ObjectId>, - parents: Vec<(ObjectId, Option<usize>, usize)>, - edges: Vec<(usize, usize)>, - cells: Vec<u8>, -} + fn topological_children(&self, parent: usize) -> Vec<usize> { + if let Some(graph) = self.topological_graph() { + let mut pending = graph + .first_parent_children + .get(parent) + .into_iter() + .flatten() + .rev() + .copied() + .collect::<Vec<_>>(); + let mut children = Vec::new(); + while let Some(child) = pending.pop() { + if self.history_entry_selectable(child) { + children.push(child); + } else if let Some(descendants) = graph.first_parent_children.get(child) { + pending.extend(descendants.iter().rev().copied()); + } + } + children.sort_unstable(); + return children; + } + let positions = self.topological_positions(); + (0..self.history_len()) + .filter(|index| { + self.history_entry_selectable(*index) + && self.topological_parent_from_rows(*index, &positions, Some(parent)) == Some(parent) + }) + .collect() + } -impl LaneState { - fn advance(&mut self, row: &CommitRow, out: Option<&mut String>) -> Option<Range<usize>> { - let render = out.is_some(); - let current = self.columns.iter().position(|id| *id == row.id).unwrap_or_else(|| { - self.columns.push(row.id); - self.columns.len() - 1 - }); + fn topological_child_index(&self, parent: usize, children: &[usize]) -> usize { + self.history_row(parent) + .and_then(|row| self.topo_children.get(&row.id)) + .and_then(|chosen| { + children + .iter() + .position(|child| self.history_row(*child).is_some_and(|row| row.id == *chosen)) + }) + .unwrap_or_default() + } - self.parents.clear(); - for parent in row.parent_ids.iter().copied() { - if !self.parents.iter().any(|(id, _, _)| *id == parent) { - self.parents - .push((parent, self.columns.iter().position(|id| *id == parent), 0)); - } + fn move_topologically(&mut self, down: bool) -> bool { + let Some(selected) = self.selected_history_index() else { + return false; + }; + if matches!(self.history_entry(selected), Some(HistoryEntry::Segment { .. })) { + return self.peel_compressed_segment(selected, !down, false).is_some(); } - self.next.clear(); - self.edges.clear(); - for (index, id) in self.columns[..current].iter().copied().enumerate() { - let destination = self.next.len(); - self.next.push(id); - if render { - self.edges.push((index, destination)); + let next = if down { + self.topological_parent(selected, None) + } else { + let children = self.topological_children(selected); + children.get(self.topological_child_index(selected, &children)).copied() + }; + let Some(next) = next else { return false }; + if matches!(self.history_entry(next), Some(HistoryEntry::Segment { .. })) { + let source = self.history_row(selected).map(|row| row.id); + let peeled = self.peel_compressed_segment(next, down, true); + if !down && let Some((source, peeled)) = source.zip(peeled) { + self.topo_children.insert(source, self.rows[peeled].id); } + return peeled.is_some(); } - for (parent, old_position, destination) in &mut self.parents { - *destination = match old_position { - Some(position) if *position < current => *position, - _ => { - let destination = self.next.len(); - self.next.push(*parent); - if render && old_position.is_some_and(|position| position != current) { - self.edges - .push((old_position.expect("checked as present"), destination)); - } - destination - } - }; + self.select_history_index(next); + true + } + + fn peel_compressed_segment(&mut self, display: usize, newest: bool, select_peeled: bool) -> Option<usize> { + let members = self + .active_compressed_history() + .filter(|history| matches!(history.entries.get(display), Some(HistoryEntry::Segment { .. })))? + .members + .get(display)? + .clone(); + let peeled = if newest { *members.first()? } else { *members.last()? }; + let remaining = if newest { + *members.get(1)? + } else { + *members.get(members.len().checked_sub(2)?)? + }; + let peeled_selectable = self.history_row_selectable(peeled); + let remaining_selectable = members + .iter() + .copied() + .filter(|index| *index != peeled) + .any(|index| self.history_row_selectable(index)); + if !peeled_selectable && !remaining_selectable { + return None; } - for (index, id) in self.columns.iter().copied().enumerate().skip(current + 1) { - if self.parents.iter().any(|(_, position, _)| *position == Some(index)) { - continue; - } - let destination = self.next.len(); - self.next.push(id); - if render { - self.edges.push((index, destination)); + + self.compressed_expanded.insert(self.rows[peeled].id); + self.compressed_segment = None; + if select_peeled { + self.rebuild_compressed_history(); + if peeled_selectable { + self.select(peeled); + } else { + self.ensure_visible(); } + } else if members.len() == 2 || !remaining_selectable { + self.rebuild_compressed_history(); + self.select(if remaining_selectable { remaining } else { peeled }); + } else { + self.selected = None; + self.compressed_segment = Some(self.rows[remaining].id); + self.rebuild_compressed_history(); + self.ensure_visible(); } - if render { - for (_, _, destination) in &self.parents { - self.edges.push((current, *destination)); + Some(peeled) + } + + fn adjust_topological_child(&mut self, right: bool) { + let Some(selected) = self.selected_history_index() else { + return; + }; + let children = self.topological_children(selected); + let Some(last) = children.len().checked_sub(1) else { + return; + }; + let current = self.topological_child_index(selected, &children); + let next = if right { + current.saturating_add(1).min(last) + } else { + current.saturating_sub(1) + }; + let Some((parent, child)) = self + .history_row(selected) + .zip(self.history_row(children[next])) + .map(|(parent, child)| (parent.id, child.id)) + else { + return; + }; + self.topo_children.insert(parent, child); + } + + pub(crate) fn topological_choice(&self) -> Option<(usize, usize)> { + self.topological_graph()?; + let selected = self.selected_history_index()?; + let children = self.topological_children(selected); + (children.len() > 1).then(|| (self.topological_child_index(selected, &children) + 1, children.len())) + } + + fn select_history_index(&mut self, display: usize) { + if !self.history_entry_selectable(display) { + return; + } + match self.history_entry(display) { + Some(HistoryEntry::Commit(index)) => self.select(index), + Some(HistoryEntry::Segment { representative, .. }) => { + let id = self.rows[representative].id; + let changed = self.selected.is_some() || self.compressed_segment != Some(id); + self.selected = None; + self.compressed_segment = Some(id); + self.pending_initial_selection = None; + self.actions_expanded = false; + self.enrich_expanded = false; + if changed { + self.retry_failed_signatures(); + } + self.follow_tail = false; + self.ensure_visible(); } + None => {} } - let range = out.map(|out| { - transition( - self.columns.len(), - self.next.len(), - current, - &self.edges, - &mut self.cells, - out, - ) - }); - std::mem::swap(&mut self.columns, &mut self.next); - range } -} -fn transition( - before: usize, - after: usize, - current: usize, - edges: &[(usize, usize)], - cells: &mut Vec<u8>, - out: &mut String, -) -> Range<usize> { - const UP: u8 = 1; - const DOWN: u8 = 2; - const LEFT: u8 = 4; - const RIGHT: u8 = 8; - const VERTICAL: u8 = UP | DOWN; - const HORIZONTAL: u8 = LEFT | RIGHT; - const CROSS: u8 = VERTICAL | HORIZONTAL; - const VERTICAL_RIGHT: u8 = VERTICAL | RIGHT; - const VERTICAL_LEFT: u8 = VERTICAL | LEFT; - const DOWN_HORIZONTAL: u8 = DOWN | HORIZONTAL; - const UP_HORIZONTAL: u8 = UP | HORIZONTAL; - const DOWN_RIGHT: u8 = DOWN | RIGHT; - const DOWN_LEFT: u8 = DOWN | LEFT; - const UP_RIGHT: u8 = UP | RIGHT; - const UP_LEFT: u8 = UP | LEFT; + fn expand_selected_segment(&mut self) -> bool { + let Some(display) = self.selected_history_index() else { + return false; + }; + let Some(HistoryEntry::Segment { .. }) = self.history_entry(display) else { + return false; + }; + let Some(members) = self + .active_compressed_history() + .and_then(|history| history.members.get(display)) + .cloned() + else { + return false; + }; + let Some(selected) = members + .iter() + .copied() + .find(|index| self.history_row_selectable(*index)) + else { + return false; + }; + self.compressed_expanded + .extend(members.iter().map(|index| self.rows[*index].id)); + self.compressed_segment = None; + self.rebuild_compressed_history(); + self.select(selected); + true + } - let width = before.max(after).max(current + 1) * 2 - 1; - cells.clear(); - cells.resize(width, 0); - for &(from, to) in edges { - let from = from * 2; - let to = to * 2; - cells[from] |= UP; - cells[to] |= DOWN; - if from < to { - cells[from] |= RIGHT; - cells[to] |= LEFT; - for cell in &mut cells[from + 1..to] { - *cell |= LEFT | RIGHT; - } - } else if to < from { - cells[from] |= LEFT; - cells[to] |= RIGHT; - for cell in &mut cells[to + 1..from] { - *cell |= LEFT | RIGHT; - } + fn restore_compressed_history_around_selection(&mut self) { + if self.alignment != Alignment::Compressed { + return; } + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + self.compressed_anchor = Some(id); + } + self.rebuild_compressed_history(); + self.ensure_visible(); } - let start = out.len(); - for (index, cell) in cells.iter().copied().enumerate() { - out.push(if index == current * 2 { - '●' - } else { - match cell { - 0 => ' ', - CROSS => '┼', - VERTICAL_RIGHT => '├', - VERTICAL_LEFT => '┤', - DOWN_HORIZONTAL => '┬', - UP_HORIZONTAL => '┴', - DOWN_RIGHT => '┌', - DOWN_LEFT => '┐', - UP_RIGHT => '└', - UP_LEFT => '┘', - HORIZONTAL => '─', - _ => '│', - } - }); + pub(crate) fn render_lanes(&self, range: Range<usize>) -> RenderedLanes { + #[cfg(test)] + if self.active_compressed_history().is_none() && !self.test_lanes.is_empty() { + return RenderedLanes::from_lanes( + self.test_lanes[range.start.min(self.test_lanes.len())..range.end.min(self.test_lanes.len())].iter(), + ); + } + if let Some(history) = self.active_compressed_history() { + return history.graph.render_with_markers(&history.rows, range, |index| { + if matches!(history.entries[index], HistoryEntry::Segment { .. }) { + '○' + } else { + '●' + } + }); + } + match &self.graph { + Some(graph) => graph.render(&self.rows, range), + None => RenderedLanes::empty(range.len()), + } } - out.push(' '); - start..out.len() -} -#[cfg(test)] -mod tests { - use super::*; + pub(crate) fn visual_count(&self, index: usize) -> Option<usize> { + self.graph + .as_ref() + .and_then(|graph| graph.visual_counts.get(index)) + .copied() + } - fn id(n: u16) -> ObjectId { - let mut bytes = [0; 20]; - bytes[18..].copy_from_slice(&n.to_be_bytes()); - ObjectId::Sha1(bytes) + pub(crate) fn attributions(&self, row: &CommitRow) -> &[Attribution] { + debug_assert!(row.metadata_loaded, "visible rows have metadata"); + &self.attributions[row.attributions.clone()] } - fn row(n: u8) -> LoadedCommit { - Commit { - id: id(n.into()), - parent_ids: ParentIds::new(), - committer_time: gix::date::Time::default(), - author: Box::leak(Box::new(Author { - name: b"author".as_bstr(), - email: b"author@example.com".as_bstr(), - })), - attributions: 0..0, - title: format!("commit {n}").into(), - metadata_loaded: true, + pub fn update(&mut self, action: Action) -> Vec<Effect> { + self.notice = None; + if !matches!(&action, Action::Undo | Action::Redo) { + self.undo_position = None; + } + if !matches!( + &action, + Action::ToggleHistoryDisplay + | Action::ToggleDate + | Action::CycleIds + | Action::ToggleEmail + | Action::ToggleName + | Action::ToggleTrailers + | Action::ToggleMailmap + | Action::CycleRefs + | Action::ToggleHidden + ) { + self.history_display_expanded = false; + } + if !matches!( + &action, + Action::ToggleActions + | Action::Reword + | Action::NewCommit + | Action::NewEmptyCommit + | Action::Amend + | Action::Spill + | Action::Split + | Action::Forget + | Action::TimeTravel + | Action::TogglePin + | Action::Rebase + | Action::RebaseUpdate + | Action::Squash + | Action::CopyInsert + | Action::MoveInsert + | Action::StackInsert + | Action::Stash + | Action::Review + | Action::ForkCommit + | Action::Attach + ) { + self.actions_expanded = false; + } + if !matches!( + &action, + Action::ToggleEnrich + | Action::ToggleTodo + | Action::ToggleChecksPass + | Action::EditNote + | Action::EditGitNote + ) { + self.enrich_expanded = false; + } + if !matches!( + &action, + Action::ToggleInformation + | Action::VerifySignatures + | Action::ToggleAlign + | Action::ToggleCommit + | Action::ToggleChanges + | Action::ToggleRefTree + ) { + self.information_expanded = false; + } + match action { + Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, + Action::Undo if self.undo_redo_allowed() => return vec![Effect::Undo], + Action::Redo if self.undo_redo_allowed() => return vec![Effect::Redo], + Action::MoveUp if self.changes_focus.is_some() => self.move_changes(1, false), + Action::MoveDown if self.changes_focus.is_some() => self.move_changes(1, true), + Action::MoveUpBy(distance) if self.changes_focus.is_some() => self.move_changes(distance, false), + Action::MoveDownBy(distance) if self.changes_focus.is_some() => self.move_changes(distance, true), + Action::MoveUp => self.move_selection(1, false), + Action::MoveDown => self.move_selection(1, true), + Action::MoveUpBy(distance) => self.move_selection(distance, false), + Action::MoveDownBy(distance) => self.move_selection(distance, true), + Action::TopologicalUp => { + self.pending_initial_selection = None; + self.move_topologically(false); + } + Action::TopologicalDown => { + self.pending_initial_selection = None; + self.move_topologically(true); + } + Action::PanUpBy(distance) => self.pan_history(distance, false), + Action::PanDownBy(distance) => self.pan_history(distance, true), + Action::PreviousChild if self.changes_focus.is_some() => self.pan_horizontal(false), + Action::NextChild if self.changes_focus.is_some() => self.pan_horizontal(true), + Action::PreviousChild => self.adjust_topological_child(false), + Action::NextChild => self.adjust_topological_child(true), + Action::CycleDuplicate => { + if self.changes_focus.is_none() + && self.reachable_rows.is_none() + && let Some(selected) = self.next_duplicate() + { + self.select(selected); + } + } + Action::ScrollLeft => self.pan_horizontal(false), + Action::ScrollRight => self.pan_horizontal(true), + Action::HalfPageUp if self.changes_focus.is_some() => { + self.move_changes((self.focused_changes().visible_paths() / 2).max(1), false); + } + Action::HalfPageDown if self.changes_focus.is_some() => { + self.move_changes((self.focused_changes().visible_paths() / 2).max(1), true); + } + Action::PageUp if self.changes_focus.is_some() => { + self.move_changes(self.focused_changes().visible_paths().max(1), false); + } + Action::PageDown if self.changes_focus.is_some() => { + self.move_changes(self.focused_changes().visible_paths().max(1), true); + } + Action::PageUp if self.show_commit && self.commit_max > 0 => { + self.commit_offset = self.commit_offset.saturating_sub(self.commit_page); + } + Action::PageDown if self.show_commit && self.commit_max > 0 => { + self.commit_offset = self.commit_offset.saturating_add(self.commit_page).min(self.commit_max); + } + Action::HalfPageUp => self.move_selection((self.viewport_rows / 2).max(1), false), + Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), + Action::PageUp => self.move_selection(self.viewport_rows.max(1), false), + Action::PageDown => self.move_selection(self.viewport_rows.max(1), true), + Action::First if self.changes_focus.is_some() => { + let changes = self.focused_changes_mut(); + changes.selected = 0; + changes.error = None; + self.ensure_changes_visible(); + } + Action::First => { + if let Some(index) = (0..self.history_len()).find(|index| self.history_entry_selectable(*index)) { + self.select_history_index(index); + } + } + Action::Last if self.changes_focus.is_some() => { + let changes = self.focused_changes_mut(); + changes.selected = changes.max; + changes.error = None; + self.ensure_changes_visible(); + } + Action::Last => { + if let Some(index) = (0..self.history_len()) + .rev() + .find(|index| self.history_entry_selectable(*index)) + { + self.pending_initial_selection = None; + self.select_history_index(index); + self.follow_tail = self.state == State::Loading; + self.ensure_visible(); + } + } + Action::ToggleDate => { + self.date_mode = match self.date_mode { + DateMode::Author => DateMode::Committer, + DateMode::Committer => DateMode::None, + DateMode::None => DateMode::Author, + }; + } + Action::CycleIds => { + self.id_mode = match self.id_mode { + IdMode::Commit => IdMode::Change, + IdMode::Change => IdMode::Off, + IdMode::Off => IdMode::Commit, + }; + } + Action::ToggleEmail => self.show_emails = !self.show_emails, + Action::ToggleName => { + let visible = self.visible_history_indices(self.offset..self.offset.saturating_add(self.viewport_rows)); + let has_visible_attributions = visible + .into_iter() + .filter_map(|index| self.rows.get(index)) + .any(|row| row.metadata_loaded && !row.attributions.is_empty()); + self.name_mode = match self.name_mode { + NameMode::All if has_visible_attributions => NameMode::Author, + NameMode::All => NameMode::None, + NameMode::Author => NameMode::None, + NameMode::None => NameMode::All, + }; + } + Action::ToggleTrailers => self.show_trailers = !self.show_trailers, + Action::ToggleMailmap => self.use_mailmap = !self.use_mailmap, + Action::ToggleHistoryDisplay => self.history_display_expanded = !self.history_display_expanded, + Action::ToggleActions if !self.selected_is_segment() => self.actions_expanded = !self.actions_expanded, + Action::ToggleEnrich if !self.selected_is_segment() => self.enrich_expanded = !self.enrich_expanded, + Action::ToggleInformation => self.information_expanded = !self.information_expanded, + Action::CycleRefs => { + self.ref_mode = match self.ref_mode { + RefMode::All => RefMode::Default, + RefMode::Default => RefMode::None, + RefMode::None => RefMode::All, + }; + } + Action::ToggleRefs => match self.ref_mode { + RefMode::None => self.ref_mode = self.visible_ref_mode, + visible => { + self.visible_ref_mode = visible; + self.ref_mode = RefMode::None; + } + }, + Action::Refresh if matches!(self.state, State::Complete | State::Cancelled) => { + self.materialize_compressed_selection(); + self.compressed_expanded.clear(); + self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + if self.alignment == Alignment::Compressed { + self.rebuild_compressed_history(); + } + return vec![Effect::Reload(self.show_hidden)]; + } + Action::ToggleHidden + if self.has_hidden_filter && matches!(self.state, State::Complete | State::Cancelled) => + { + self.materialize_compressed_selection(); + self.compressed_expanded.clear(); + self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + if self.alignment == Alignment::Compressed { + self.rebuild_compressed_history(); + } + return vec![Effect::Reload(!self.show_hidden)]; + } + Action::ToggleAlign => { + let viewport_row = self + .selected_history_index() + .map(|selected| selected.saturating_sub(self.offset)); + let leaving_compressed = self.alignment == Alignment::Compressed; + if leaving_compressed { + self.materialize_compressed_selection(); + } + self.alignment = match self.alignment { + Alignment::Title => Alignment::Columns, + Alignment::Columns => Alignment::None, + Alignment::None => Alignment::Compressed, + Alignment::Compressed => Alignment::Title, + }; + if self.alignment == Alignment::Compressed { + self.compressed_expanded.clear(); + self.compressed_segment = None; + self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + self.rebuild_compressed_history(); + } else if leaving_compressed { + self.compressed_history = None; + self.compressed_anchor = None; + self.compressed_expanded.clear(); + } + if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) { + self.offset = selected.saturating_sub(viewport_row); + } + self.ensure_visible(); + } + Action::ToggleCommit => { + self.show_commit = !self.show_commit; + self.reset_commit_view(); + } + Action::ToggleChanges => { + self.focus_feedback = None; + self.changes_mode = match self.changes_mode { + Some(ChangesMode::Both) => Some(ChangesMode::Tree), + Some(ChangesMode::Tree) => None, + None if self.worktree_changes_available => Some(ChangesMode::Both), + None => Some(ChangesMode::Tree), + }; + self.reset_changes_view(); + self.changes_parent = 0; + if self.changes_mode.is_none() { + self.changes_suppressed = false; + self.changes_focus = None; + } + } + Action::ToggleChangesFocus if self.changes_mode.is_some() => { + self.cycle_changes_focus(); + self.focus_feedback = Some(match self.changes_focus { + Some(ChangePane::Tree) => "tree changes", + Some(ChangePane::Worktree) => "worktree changes", + None => "history", + }); + } + Action::CycleChangesParent => { + if self.changes_focus == Some(ChangePane::Tree) { + self.changes_parent = self.changes_parent.saturating_add(1); + self.tree_changes.error = None; + } + } + Action::OpenDiff if self.changes_focus.is_some() => { + let pane = self.changes_focus.expect("focus was checked"); + let changes = self.focused_changes_mut(); + changes.error = None; + return vec![Effect::OpenDiff(pane, changes.selected)]; + } + Action::OpenDiff if self.expand_selected_segment() => {} + Action::OpenDiff if self.review_return.is_some() => { + let (review, _) = self.review_return.expect("review return selection is active"); + let Some(return_to) = self + .selected + .filter(|index| self.is_row_reachable(*index) && self.reachable_row_selectable(*index)) + .and_then(|index| self.rows.get(index)) + .map(|row| row.id) + else { + return Vec::new(); + }; + self.clear_reachability_selection(); + return vec![Effect::FinishReview { + review, + return_to: Some(return_to), + }]; + } + Action::OpenDiff if self.review_tip.is_some() => { + let tip = self.review_tip.expect("review selection has a tip"); + let Some(base) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) else { + return Vec::new(); + }; + if base != tip && self.selected.is_some_and(|index| self.is_row_reachable(index)) { + self.clear_reachability_selection(); + return vec![Effect::StartReview { tip, base }]; + } + } + Action::OpenDiff if self.squash_source.is_some() => { + let source = self.squash_source.expect("squash selection has a source"); + let Some(target) = self + .selected + .filter(|index| self.is_row_reachable(*index) && self.reachable_row_selectable(*index)) + .and_then(|index| self.rows.get(index)) + .map(|row| row.id) + else { + return Vec::new(); + }; + self.clear_reachability_selection(); + return vec![Effect::Squash { source, target }]; + } + Action::OpenDiff if self.stack_insert_base.is_some() => { + let source = self.worktree_head.expect("stack insertion requires HEAD"); + let base = self.stack_insert_base.expect("stack insertion has a base"); + let Some(target) = self + .selected + .filter(|index| self.is_row_reachable(*index) && self.reachable_row_selectable(*index)) + .and_then(|index| self.rows.get(index)) + .map(|row| row.id) + else { + return Vec::new(); + }; + self.clear_reachability_selection(); + return vec![Effect::Insert { + source, + base, + target, + copy: false, + }]; + } + Action::OpenDiff => { + if let Some(target) = self.selected_tree_diff_target() { + return vec![Effect::OpenCommitDiff(target)]; + } + } + Action::Reword if self.can_reword() => { + return vec![Effect::Reword( + self.rows[self.selected.expect("reword requires a selection")].id, + )]; + } + Action::NewCommit if self.can_create_commit() => { + return vec![Effect::NewCommit { + parent: self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id), + empty: false, + }]; + } + Action::NewEmptyCommit if self.can_create_empty_commit() => { + return vec![Effect::NewCommit { + parent: self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id), + empty: true, + }]; + } + Action::Amend if self.can_amend() => { + return vec![Effect::Amend( + self.rows[self.selected.expect("amend requires a selection")].id, + )]; + } + Action::Stash if self.can_unstash() => { + return vec![Effect::Unstash( + self.rows[self.selected.expect("unstash requires a selection")].id, + )]; + } + Action::Stash if self.can_stash() => { + return vec![Effect::Stash( + self.rows[self.selected.expect("stash requires a selection")].id, + )]; + } + Action::Spill if self.can_spill() => { + return vec![Effect::Spill( + self.rows[self.selected.expect("spill requires a selection")].id, + )]; + } + Action::Split if self.can_split() => { + return vec![Effect::Split( + self.rows[self.selected.expect("split requires a selection")].id, + )]; + } + Action::Forget if self.can_forget() => { + let id = self.rows[self.selected.expect("forget requires a selection")].id; + return vec![Effect::Forget(id)]; + } + Action::Rebase if self.can_rebase() => { + let base = self.rows[self.selected.expect("rebase requires a selection")].id; + return vec![Effect::Rebase { + base, + onto: base, + commits: self.hidden_descendants(base), + }]; + } + Action::RebaseUpdate if self.can_rebase_update() => { + let base = self.rows[self.selected.expect("rebase-update requires a selection")].id; + return vec![Effect::Rebase { + base, + onto: self.hidden_branch_updates[&base].1, + commits: self.hidden_descendants(base), + }]; + } + Action::Squash if self.can_squash() => { + let source = self.rows[self.selected.expect("squash requires a selection")].id; + self.squash_source = Some(source); + self.reachability_anchor = Some(source); + self.compute_reachable_rows(); + let mut targets = self + .rows + .iter() + .enumerate() + .filter(|(index, row)| { + row.id != source && self.is_row_reachable(*index) && self.reachable_row_selectable(*index) + }) + .map(|(_, row)| row.id); + if let Some(target) = targets.next().filter(|_| targets.next().is_none()) { + self.clear_reachability_selection(); + return vec![Effect::Squash { source, target }]; + } + } + Action::CopyInsert if self.can_copy_insert() => { + let source = self.worktree_head.expect("copy-insert availability requires HEAD"); + return vec![Effect::Insert { + source, + base: source, + target: self.rows[self.selected.expect("copy-insert requires a selection")].id, + copy: true, + }]; + } + Action::PasteInsert { source, target } => { + return vec![Effect::PasteInsert { source, target }]; + } + Action::MoveInsert if self.can_move_insert() => { + let source = self.worktree_head.expect("move-insert availability requires HEAD"); + return vec![Effect::Insert { + source, + base: source, + target: self.rows[self.selected.expect("move-insert requires a selection")].id, + copy: false, + }]; + } + Action::StackInsert => { + if let Some((source, base, base_parent, stack)) = self.stack_insert() { + let reachable: Vec<_> = self + .rows + .iter() + .enumerate() + .map(|(index, row)| self.is_stack_insert_target(index, row.id, base, base_parent, &stack)) + .collect(); + if reachable.iter().any(|reachable| *reachable) { + debug_assert_eq!(source, self.worktree_head.expect("a stack insertion has HEAD")); + self.stack_insert_base = Some(base); + self.reachable_rows = Some(reachable); + self.ensure_visible(); + } + } + } + Action::Review if self.can_finish_review() => { + return vec![Effect::FinishReview { + review: self.rows[self.selected.expect("finishing review requires a selection")].id, + return_to: None, + }]; + } + Action::Review if self.can_review() => { + let tip = self.rows[self.selected.expect("review requires a selection")].id; + self.review_tip = Some(tip); + self.reachability_anchor = Some(tip); + self.compute_reachable_rows(); + let mut bases = self + .rows + .iter() + .enumerate() + .filter(|(index, row)| { + row.id != tip && self.is_row_reachable(*index) && self.reachable_row_selectable(*index) + }) + .map(|(_, row)| row.id); + if let Some(base) = bases.next().filter(|_| bases.next().is_none()) { + self.clear_reachability_selection(); + return vec![Effect::StartReview { tip, base }]; + } + } + Action::ForkCommit if self.can_fork_commit() => { + return vec![Effect::ForkCommit( + self.rows[self.selected.expect("fork requires a selection")].id, + )]; + } + Action::Attach if self.can_attach() => return vec![Effect::Attach], + Action::TimeTravel if self.can_time_travel() => { + return vec![Effect::TimeTravel( + self.rows[self.selected.expect("time-travel requires a selection")].id, + )]; + } + Action::TogglePin => { + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + return vec![Effect::TogglePin(id)]; + } + } + Action::ToggleTodo if self.can_reword() => { + return vec![Effect::ToggleTodo( + self.rows[self.selected.expect("todo requires a selection")].id, + )]; + } + Action::ToggleChecksPass => { + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + return vec![Effect::ToggleChecksPass(id)]; + } + } + Action::EditNote if self.can_reword() => { + return vec![Effect::EditNote( + self.rows[self.selected.expect("note requires a selection")].id, + )]; + } + Action::EditGitNote => { + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + return vec![Effect::EditGitNote(id)]; + } + } + Action::VerifySignatures if !self.signature_verification_running => { + let visible = self.visible_history_indices(self.offset..self.offset.saturating_add(self.viewport_rows)); + let mut changed = Vec::new(); + for index in visible { + let row = &mut self.rows[index]; + if !self.hidden_rows.contains(&row.id) && row.signature == SignatureState::Unverified { + Arc::make_mut(row).signature = SignatureState::Verifying; + changed.push((row.id, Arc::clone(row))); + } + } + for (id, row) in &changed { + self.all_rows.insert(*id, Arc::clone(row)); + } + let ids: Vec<_> = changed.into_iter().map(|(id, _)| id).collect(); + if !ids.is_empty() { + self.signature_verification_running = true; + return vec![Effect::VerifySignatures(ids)]; + } + } + Action::ForceQuit => return vec![Effect::Quit], + Action::Cancel + if self.review_tip.is_some() + || self.review_return.is_some() + || self.squash_source.is_some() + || self.stack_insert_base.is_some() => + { + self.clear_reachability_selection(); + } + Action::Cancel | Action::Quit if self.changes_focus.is_some() => self.focus_history(), + Action::Cancel if self.state == State::Loading => { + self.state = State::Cancelling; + return vec![Effect::Cancel]; + } + Action::Copy => { + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + self.copy_feedback = Some(CopyKind::Id); + return vec![Effect::CopyId(id)]; + } + } + Action::CopyPath(path) => return vec![Effect::CopyPath(path)], + Action::CopyAuthor => { + if let Some(author) = self + .selected + .and_then(|index| self.rows.get(index)) + .filter(|row| row.metadata_loaded) + .map(|row| row.author) + { + self.copy_feedback = Some(CopyKind::Author); + return vec![Effect::CopyAuthor(author)]; + } + } + Action::Quit => { + return if matches!(self.state, State::Loading | State::Cancelling) { + vec![Effect::Cancel, Effect::Quit] + } else { + vec![Effect::Quit] + }; + } + _ => {} + } + Vec::new() + } + + fn undo_redo_allowed(&self) -> bool { + self.pending_rebase_conflict.is_none() + && !self.rebase_continuation_pending + && !self.worktree_conflicted + && self.reachable_rows.is_none() + } + + pub(crate) fn start_lane_computation(&mut self) -> Option<Vec<SharedCommitRow>> { + match self.state { + State::Loading => { + self.state = State::Computing; + self.follow_tail = false; + self.reload_selection = None; + Some(self.rows.clone()) + } + State::Cancelling => { + self.state = State::Cancelled; + self.follow_tail = false; + None + } + _ => None, + } + } + + pub(crate) fn hidden_ids(&self) -> HashSet<ObjectId> { + self.hidden_rows.clone() + } + + pub(crate) fn set_hidden_branch_updates(&mut self, updates: HashMap<ObjectId, (usize, ObjectId)>) { + self.hidden_branch_updates = updates; + } + + pub(crate) fn hidden_branch_behind(&self, id: ObjectId) -> Option<usize> { + self.hidden_branch_updates.get(&id).map(|(behind, _)| *behind) + } + + pub(crate) fn hidden_branch_update(&self, id: ObjectId) -> Option<ObjectId> { + self.hidden_branch_updates.get(&id).map(|(_, target)| *target) + } + + pub(crate) fn selected_tree_diff_target(&self) -> Option<TreeDiffTarget> { + let id = self.selected.and_then(|index| self.rows.get(index))?.id; + Some(match self.hidden_branch_targets.get(&id) { + Some(&tip) => TreeDiffTarget::Branch { base: id, tip }, + None => TreeDiffTarget::Commit { + id, + parent: self.changes_parent, + }, + }) + } + + fn update_hidden_branch_targets(&mut self) { + #[derive(Clone, Copy)] + struct State { + leaf: Option<ObjectId>, + has_merge: bool, + } + + let visible_parents: HashSet<_> = self + .rows + .iter() + .filter(|row| !self.hidden_rows.contains(&row.id)) + .flat_map(|row| row.parent_ids.iter().copied()) + .collect(); + let mut states = HashMap::<ObjectId, State>::new(); + let mut targets = HashMap::new(); + let mut rebase_bases = HashSet::new(); + for row in &self.rows { + let state = if self.hidden_rows.contains(&row.id) { + states.get(&row.id).copied() + } else if !visible_parents.contains(&row.id) { + Some(State { + leaf: Some(row.id), + has_merge: row.parent_ids.len() > 1, + }) + } else { + states.get(&row.id).copied().map(|mut state| { + state.has_merge |= row.parent_ids.len() > 1; + state + }) + }; + let Some(state) = state else { continue }; + if self.hidden_rows.contains(&row.id) { + if let Some(tip) = state.leaf { + targets.insert(row.id, tip); + } + if !state.has_merge { + rebase_bases.insert(row.id); + } + continue; + } + for parent in &row.parent_ids { + states + .entry(*parent) + .and_modify(|existing| { + if existing.leaf != state.leaf { + existing.leaf = None; + } + existing.has_merge |= state.has_merge; + }) + .or_insert(state); + } + } + self.hidden_branch_targets = targets; + self.hidden_rebase_bases = rebase_bases; + } + + fn hidden_descendants(&self, base: ObjectId) -> Vec<ObjectId> { + let mut reachable = HashSet::from([base]); + let mut out = Vec::new(); + for row in self.rows.iter().rev() { + if self.hidden_rows.contains(&row.id) || !row.parent_ids.iter().any(|parent| reachable.contains(parent)) { + continue; + } + reachable.insert(row.id); + out.push(row.id); + } + out.reverse(); + out + } + + pub(crate) fn hidden_rebase_candidates(&mut self) -> Vec<(ObjectId, Vec<ObjectId>)> { + self.update_hidden_branch_targets(); + self.rows + .iter() + .filter(|row| self.hidden_rebase_bases.contains(&row.id)) + .map(|row| (row.id, self.hidden_descendants(row.id))) + .collect() + } + + pub(crate) fn start_refresh( + &mut self, + commits: LoadedCommits, + view_tips: &[ObjectId], + hidden_tips: &[ObjectId], + select_top: bool, + ) -> Option<Vec<SharedCommitRow>> { + self.set_view_tips(view_tips); + if self.stack_insert_base.is_some() { + self.clear_reachability_selection(); + } + let previous_order: HashMap<_, _> = self + .rows + .iter() + .enumerate() + .map(|(index, row)| (row.id, index)) + .collect(); + drop(self.store_commits(commits)); + + let visible = self.reachable_from(view_tips); + let hidden = self.reachable_from(hidden_tips); + let visible: HashSet<_> = visible.difference(&hidden).copied().collect(); + let boundary: HashSet<_> = if view_tips.is_empty() { + hidden_tips.iter().copied().collect() + } else if hidden_tips.is_empty() { + HashSet::new() + } else { + visible + .iter() + .filter_map(|id| self.all_rows.get(id)) + .flat_map(|row| row.parent_ids.iter().copied()) + .filter(|id| !visible.contains(id)) + .collect() + }; + let rows: Vec<_> = self + .all_order + .iter() + .filter(|id| visible.contains(*id) || boundary.contains(*id)) + .filter_map(|id| self.all_rows.get(id).map(Arc::clone)) + .collect(); + let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); + let mut children = vec![Vec::new(); rows.len()]; + let mut ranks = vec![None; rows.len()]; + let mut pending = Vec::new(); + for (index, row) in rows.iter().enumerate() { + if let Some(rank) = previous_order.get(&row.id) { + ranks[index] = Some(*rank); + pending.push(index); + continue; + } + if let Some(parent) = row.parent_ids.iter().next().and_then(|parent| positions.get(parent)) { + children[*parent].push(index); + } else { + pending.push(index); + } + } + while let Some(parent) = pending.pop() { + for child in &children[parent] { + // First-parent ancestry identifies the old lane. + ranks[*child] = ranks[parent]; + pending.push(*child); + } + } + let mut rows: Vec<_> = rows.into_iter().zip(ranks).collect(); + rows.sort_by_key(|(_, rank)| (rank.is_none(), *rank)); + let rows = rows.into_iter().map(|(row, _)| row).collect(); + self.pending_hidden_rows = Some(boundary); + self.select_top_after_refresh = select_top; + self.state = State::Computing; + self.follow_tail = false; + Some(rows) + } + + fn reachable_from(&self, tips: &[ObjectId]) -> HashSet<ObjectId> { + let mut reachable = HashSet::new(); + let mut pending = tips.to_vec(); + while let Some(id) = pending.pop() { + if !reachable.insert(id) { + continue; + } + if let Some(row) = self.all_rows.get(&id) { + pending.extend(row.parent_ids.iter().copied()); + } + } + reachable + } + + fn is_known_ancestor(&self, ancestor: ObjectId, descendant: ObjectId) -> bool { + let mut seen = HashSet::new(); + let mut pending = vec![descendant]; + while let Some(id) = pending.pop() { + if id == ancestor { + return true; + } + if seen.insert(id) + && let Some(row) = self.all_rows.get(&id) + { + pending.extend(row.parent_ids.iter().copied()); + } + } + false + } + + pub(crate) fn finish_lane_computation(&mut self, rows: Vec<SharedCommitRow>, graph: Graph, lane_time: Duration) { + if self.state != State::Computing { + return; + } + let select_top = std::mem::take(&mut self.select_top_after_refresh); + let previous_selection = if select_top { None } else { self.selected }; + let previous_visual_position = previous_selection.and_then(|index| { + let count = self.visual_count(index)?; + let base = self.rows.get(index.checked_add(count)?)?.id; + Some((base, count, index)) + }); + let segment = (!select_top && self.selection_after_refresh.is_none()) + .then_some(self.compressed_segment) + .flatten(); + let selected = if select_top { + self.selection_after_refresh = None; + None + } else { + self.selection_after_refresh + .take() + .or_else(|| self.selected.map(|index| self.rows[index].id)) + }; + let metadata: HashMap<_, _> = if rows.iter().any(|row| !row.metadata_loaded) { + self.rows + .iter() + .filter(|row| row.metadata_loaded) + .map(|row| { + ( + row.id, + Metadata { + committer_time: row.committer_time, + author_time: row.author_time, + author: row.author, + attributions: row.attributions.clone(), + title: row.title.clone(), + has_agent_marker: row.has_agent_marker, + is_review: row.is_review, + signature: row.signature, + }, + ) + }) + .collect() + } else { + HashMap::new() + }; + self.rows = rows; + if let Some(hidden) = self.pending_hidden_rows.take() { + self.hidden_rows = hidden; + } + self.update_hidden_branch_targets(); + for row in &mut self.rows { + if let Some(metadata) = metadata.get(&row.id) { + let row = Arc::make_mut(row); + row.committer_time = metadata.committer_time; + row.author_time = metadata.author_time; + row.author = metadata.author; + row.attributions = metadata.attributions.clone(); + row.title = metadata.title.clone(); + row.metadata_loaded = true; + row.has_agent_marker = metadata.has_agent_marker; + row.is_review = metadata.is_review; + row.signature = metadata.signature; + } + } + self.graph = Some(graph); + self.lane_time = Some(lane_time); + self.update_worktree_head_descendants(); + let visual_counts = &self + .graph + .as_ref() + .expect("the completed graph was just stored") + .visual_counts; + let visual_selection = previous_visual_position.and_then(|(base, count, previous)| { + self.rows + .iter() + .enumerate() + .filter(|(index, _)| { + visual_counts.get(*index) == Some(&count) + && index + .checked_add(count) + .and_then(|base_index| self.rows.get(base_index)) + .is_some_and(|row| row.id == base) + }) + .min_by_key(|(index, _)| index.abs_diff(previous)) + .map(|(index, _)| index) + }); + self.selected = selected + .and_then(|id| self.rows.iter().position(|row| row.id == id)) + .or(visual_selection) + .or_else(|| { + previous_selection + .filter(|_| !self.rows.is_empty()) + .map(|index| index.min(self.rows.len() - 1)) + }) + .or_else(|| (!self.rows.is_empty()).then_some(0)); + self.compressed_segment = segment; + if self.compressed_segment.is_some() { + self.selected = None; + } + if self.alignment == Alignment::Compressed { + if self.compressed_segment.is_none() { + self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + } + self.rebuild_compressed_history(); + } + self.state = State::Complete; + if self.reachability_anchor.is_some() { + self.compute_reachable_rows(); + } + if self + .pending_rebase_conflict + .is_some_and(|id| self.selected.is_some_and(|index| self.rows[index].id == id)) + { + self.center_selection(); + } else { + self.prepare_history_viewport(); + } + } + + #[cfg(test)] + pub(crate) fn reload(&mut self, show_hidden: bool) { + self.materialize_compressed_selection(); + self.reload_selection = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + self.select_top_after_refresh = false; + self.rows = Vec::new(); + self.all_rows.clear(); + self.all_order.clear(); + self.hidden_rows.clear(); + self.hidden_branch_targets.clear(); + self.hidden_rebase_bases.clear(); + self.hidden_branch_updates.clear(); + self.clear_change_ids(); + self.pending_hidden_rows = None; + self.titles = Vec::new(); + self.notes.clear(); + self.clear_enrichments(); + self.graph = None; + self.view_tips.clear(); + self.compressed_history = None; + self.compressed_anchor = None; + self.compressed_segment = None; + self.compressed_expanded.clear(); + self.attributions = Vec::new(); + #[cfg(test)] + self.test_lanes.clear(); + self.selected = None; + self.offset = 0; + self.viewport_panned = false; + self.state = State::Loading; + self.lane_time = None; + self.estimated_lane_width = 0; + self.show_hidden = show_hidden; + self.changes_suppressed = false; + self.horizontal_offset = 0; + self.focus_history(); + self.reset_commit_view(); + self.reset_changes_view(); + self.follow_tail = false; + self.pending_initial_selection = None; + self.selection_after_refresh = None; + self.update_worktree_head_descendants(); + self.clear_reachability_selection(); + self.signature_failures = 0; + self.signature_verification_running = false; + } + + pub(crate) fn finish_signature_verification(&mut self, results: Vec<(ObjectId, bool)>) { + let mut failed = 0; + for (id, valid) in results { + let Some(index) = self.rows.iter().position(|row| row.id == id) else { + continue; + }; + let row = Arc::make_mut(&mut self.rows[index]); + row.signature = if valid { + SignatureState::Verified + } else { + failed += 1; + SignatureState::Failed + }; + self.all_rows.insert(id, Arc::clone(&self.rows[index])); + } + self.signature_verification_running = false; + self.signature_failures = failed; + } + + fn move_changes(&mut self, distance: usize, down: bool) { + let changes = self.focused_changes_mut(); + changes.error = None; + changes.selected = if down { + changes.selected.saturating_add(distance).min(changes.max) + } else { + changes.selected.saturating_sub(distance) + }; + self.ensure_changes_visible(); + } + + fn clear_reachability_selection(&mut self) { + self.review_tip = None; + self.review_return = None; + self.squash_source = None; + self.stack_insert_base = None; + self.reachability_anchor = None; + self.reachable_rows = None; + self.restore_compressed_history_around_selection(); + } + + pub(crate) fn select_review_return(&mut self, review: ObjectId, tip: ObjectId) -> bool { + let reachable: Vec<_> = self + .rows + .iter() + .enumerate() + .map(|(index, row)| !row.is_review && !self.is_row_hidden(index) && self.is_known_ancestor(tip, row.id)) + .collect(); + let current = self.selected.unwrap_or_default(); + let selected = self + .rows + .iter() + .position(|row| row.id == tip) + .filter(|index| reachable[*index]) + .or_else(|| { + reachable + .iter() + .enumerate() + .filter_map(|(index, reachable)| reachable.then_some(index)) + .min_by_key(|index| index.abs_diff(current)) + }); + let Some(selected) = selected else { return false }; + self.review_tip = None; + self.review_return = Some((review, tip)); + self.reachability_anchor = None; + self.reachable_rows = Some(reachable); + self.select(selected); + true + } + + pub(crate) fn focus_history(&mut self) { + self.changes_focus = None; + self.focus_feedback = None; + } + + pub(crate) fn set_worktree_changes_available(&mut self, available: bool) { + self.worktree_changes_available = available; + if !available { + if self.changes_mode == Some(ChangesMode::Both) { + self.changes_mode = Some(ChangesMode::Tree); + } + if self.changes_focus == Some(ChangePane::Worktree) { + self.focus_history(); + } + } + } + + pub(crate) fn changes_visible(&self) -> bool { + self.changes_mode.is_some() && !self.changes_suppressed && self.pending_rebase_conflict.is_none() + } + + fn ensure_changes_visible(&mut self) { + self.focused_changes_mut().ensure_visible(); + } + + fn pan_changes(&mut self, right: bool) { + let changes = self.focused_changes_mut(); + changes.horizontal_offset = if right { + changes + .horizontal_offset + .saturating_add(changes.horizontal_page) + .min(changes.horizontal_max) + } else { + changes.horizontal_offset.saturating_sub(changes.horizontal_page) + }; + } + + fn pan_horizontal(&mut self, right: bool) { + if self.changes_focus.is_some() { + self.pan_changes(right); + } else if right { + self.horizontal_offset = self + .horizontal_offset + .saturating_add(self.horizontal_page) + .min(self.horizontal_max); + } else { + self.horizontal_offset = self.horizontal_offset.saturating_sub(self.horizontal_page); + } + } + + fn pan_history(&mut self, distance: usize, down: bool) { + self.pending_initial_selection = None; + self.follow_tail = false; + self.viewport_panned = true; + let max = self.history_len().saturating_sub(self.viewport_rows.max(1)); + self.offset = if down { + self.offset.saturating_add(distance.max(1)).min(max) + } else { + self.offset.saturating_sub(distance.max(1)) + }; + } + + fn move_selection(&mut self, distance: usize, down: bool) { + self.pending_initial_selection = None; + let Some(display_selected) = self.selected_history_index() else { + return; + }; + let distance = distance.max(1); + let target = if down { + display_selected + .saturating_add(distance) + .min(self.history_len().saturating_sub(1)) + } else { + display_selected.saturating_sub(distance) + }; + if self.reachable_rows.is_some() { + let next = if down { + (display_selected + 1..self.history_len()) + .filter(|index| self.history_entry_selectable(*index)) + .min_by_key(|index| index.abs_diff(target)) + } else { + (0..display_selected) + .filter(|index| self.history_entry_selectable(*index)) + .min_by_key(|index| index.abs_diff(target)) + }; + if let Some(next) = next { + self.select_history_index(next); + } + return; + } + self.select_history_index(target); + } + + fn next_duplicate(&self) -> Option<usize> { + let selected = self.selected?; + let id = self.rows.get(selected)?.id; + if !self.has_duplicate_change_id(id) { + return None; + } + let change_id = self.change_id(id); + ((selected + 1)..self.rows.len()) + .chain(0..selected) + .find(|index| self.change_id(self.rows[*index].id) == change_id) + } + + fn compute_reachable_rows(&mut self) { + if self.state != State::Complete { + self.reachable_rows = None; + return; + } + let Some(anchor) = self.reachability_anchor else { + self.reachable_rows = None; + return; + }; + if !self.rows.iter().any(|row| row.id == anchor) { + self.reachable_rows = Some(vec![false; self.rows.len()]); + return; + } + let mut pending = HashSet::from([anchor]); + let reachable: Vec<_> = self + .rows + .iter() + .map(|row| { + let reachable = pending.remove(&row.id); + if reachable { + pending.extend(row.parent_ids.iter().copied()); + } + reachable + }) + .collect(); + self.reachable_rows = Some(reachable); + if self.alignment == Alignment::Compressed { + self.ensure_visible(); + } + } + + pub(crate) fn is_row_reachable(&self, index: usize) -> bool { + self.reachable_rows + .as_ref() + .is_none_or(|reachable| reachable.get(index).copied().unwrap_or(false)) + } + + fn reachable_row_selectable(&self, index: usize) -> bool { + if self.squash_source.is_some() { + return self + .rows + .get(index) + .is_some_and(|row| Some(row.id) != self.squash_source) + && self.is_squash_target(index); + } + !self.is_row_hidden(index) + || (self.review_tip.is_some() + && self + .rows + .get(index) + .is_some_and(|row| self.hidden_rebase_bases.contains(&row.id))) + } + + fn is_squash_target(&self, index: usize) -> bool { + self.rows.get(index).is_some_and(|row| { + !self.is_row_hidden(index) && row.parent_ids.len() == 1 && !self.known_merge_descendants.contains(&row.id) + }) + } + + fn select(&mut self, selected: usize) { + self.pending_initial_selection = None; + self.compressed_segment = None; + if !self.rows.is_empty() { + let selected = selected.min(self.rows.len() - 1); + if self.alignment == Alignment::Compressed + && !self.compressed_history_suspended() + && self.history_index(selected).is_none() + { + self.compressed_anchor = Some(self.rows[selected].id); + self.rebuild_compressed_history(); + } + let previous = self.selected; + self.selected = Some(selected); + if self.selected != previous { + self.retry_failed_signatures(); + } + self.follow_tail = false; + self.ensure_visible(); + } + } + + fn first_selectable(&self) -> Option<usize> { + (0..self.history_len()).find_map(|index| match self.history_entry(index) { + Some(HistoryEntry::Commit(index)) => Some(index), + Some(HistoryEntry::Segment { .. }) | None => None, + }) + } + + fn update_worktree_head_descendants(&mut self) { + self.worktree_head_has_descendants = self.worktree_head.is_some_and(|head| self.has_known_descendant(head)); + } + + fn has_known_descendant(&self, id: ObjectId) -> bool { + self.known_descendants.contains(&id) || self.rows.iter().any(|row| row.parent_ids.contains(&id)) + } + + pub(crate) fn can_reword(&self) -> bool { + self.state == State::Complete && self.reword_shortcut_visible() + } + + pub(crate) fn reword_shortcut_visible(&self) -> bool { + self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { + !self.hidden_rows.contains(&row.id) && !self.known_merge_descendants.contains(&row.id) + }) + } + + pub(crate) fn can_create_commit(&self) -> bool { + self.new_commit_available && self.can_create_any_commit() + } + + pub(crate) fn can_create_empty_commit(&self) -> bool { + self.new_empty_commit_available && self.can_create_any_commit() + } + + fn can_create_any_commit(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && self.changes_focus.is_none() + && !self.selected_is_segment() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && match self.selected.and_then(|index| self.rows.get(index)) { + Some(row) => { + (self.worktree_head_unborn || !self.hidden_rows.contains(&row.id)) + && !self.known_merge_descendants.contains(&row.id) + } + None => self.worktree_head_unborn, + } + } + + pub(crate) fn set_new_commit_availability(&mut self, changes: Option<&Changes>) { + let selected_is_head = + self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) == self.worktree_head; + let selected_is_head = selected_is_head + || self.worktree_head_unborn && self.selected.is_none_or(|index| self.is_row_hidden(index)); + match changes { + Some(changes) if changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged) => { + self.new_commit_available = false; + self.new_empty_commit_available = false; + } + Some(changes) => { + self.new_commit_available = selected_is_head && changes.has_tracked_changes; + self.new_empty_commit_available = true; + } + None => { + self.new_commit_available = true; + self.new_empty_commit_available = true; + } + } + } + + pub(crate) fn can_forget(&self) -> bool { + self.state == State::Complete + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { + !self.hidden_rows.contains(&row.id) + && row.parent_ids.len() <= 1 + && !self.known_merge_descendants.contains(&row.id) + && (!row.is_review || !self.has_known_descendant(row.id)) + }) + } + + pub(crate) fn can_rebase(&self) -> bool { + self.state == State::Complete + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| self.hidden_rebase_bases.contains(&row.id)) + } + + pub(crate) fn can_rebase_update(&self) -> bool { + self.can_rebase() + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| self.hidden_branch_updates.contains_key(&row.id)) + } + + pub(crate) fn can_squash(&self) -> bool { + self.state == State::Complete + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|source| { + !self.hidden_rows.contains(&source.id) + && source.parent_ids.len() == 1 + && !self.known_merge_descendants.contains(&source.id) + && self.rows.iter().enumerate().any(|(index, target)| { + target.id != source.id + && self.is_squash_target(index) + && self.is_known_ancestor(target.id, source.id) + }) + }) + } + + pub(crate) fn can_copy_insert(&self) -> bool { + self.can_insert(true) + } + + pub(crate) fn paste_insert_target(&self) -> Option<ObjectId> { + if self.state != State::Complete + || self.deferred_history_state.unwrap_or(self.state) != State::Complete + || self.changes_focus.is_some() + || self.has_conflict_marker() + { + return None; + } + self.selected + .filter(|index| !self.is_row_hidden(*index)) + .and_then(|index| self.rows.get(index)) + .filter(|row| !self.known_merge_descendants.contains(&row.id)) + .map(|row| row.id) + } + + pub(crate) fn can_move_insert(&self) -> bool { + self.can_insert(false) + } + + fn can_insert(&self, copy: bool) -> bool { + if self.state != State::Complete + || self.changes_focus.is_some() + || self.deferred_history_state.unwrap_or(self.state) != State::Complete + { + return false; + } + let Some(source) = self + .worktree_head + .and_then(|head| self.rows.iter().find(|row| row.id == head)) + else { + return false; + }; + let Some((target_index, target)) = self + .selected + .and_then(|index| self.rows.get(index).map(|row| (index, row))) + else { + return false; + }; + source.parent_ids.len() == 1 + && source.id != target.id + && (copy || source.parent_ids.first().copied() != Some(target.id)) + && (!copy || !source.is_review) + && !self.is_row_hidden(target_index) + && (copy || !self.known_merge_descendants.contains(&source.id)) + && !self.known_merge_descendants.contains(&target.id) + } + + pub(crate) fn can_stack_insert(&self) -> bool { + self.stack_insert().is_some_and(|(_, base, base_parent, stack)| { + self.rows + .iter() + .enumerate() + .any(|(index, row)| self.is_stack_insert_target(index, row.id, base, base_parent, &stack)) + }) + } + + fn stack_insert(&self) -> Option<(ObjectId, ObjectId, ObjectId, HashSet<ObjectId>)> { + if self.state != State::Complete + || self.changes_focus.is_some() + || self.deferred_history_state.unwrap_or(self.state) != State::Complete + { + return None; + } + let source = self.worktree_head?; + let base = self.selected.and_then(|index| self.rows.get(index))?.id; + if self.hidden_rows.contains(&base) || self.known_merge_descendants.contains(&base) { + return None; + } + + let mut current = source; + let mut stack = HashSet::new(); + loop { + if !stack.insert(current) { + return None; + } + let row = self.all_rows.get(¤t)?; + let [parent] = row.parent_ids.as_slice() else { + return None; + }; + if current == base { + return Some((source, base, *parent, stack)); + } + current = *parent; + } + } + + fn is_stack_insert_target( + &self, + index: usize, + id: ObjectId, + base: ObjectId, + base_parent: ObjectId, + stack: &HashSet<ObjectId>, + ) -> bool { + !self.is_row_hidden(index) + && !stack.contains(&id) + && id != base_parent + && !self.known_merge_descendants.contains(&id) + && !stack + .iter() + .any(|ancestor| *ancestor != base && self.is_known_ancestor(*ancestor, id)) + } + + pub(crate) fn can_review(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && !self.worktree_conflicted + && self.pending_rebase_conflict.is_none() + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { + !self.hidden_rows.contains(&row.id) + && !row.parent_ids.is_empty() + && !self.known_merge_descendants.contains(&row.id) + && !row.is_review + }) + } + + pub(crate) fn can_finish_review(&self) -> bool { + self.state == State::Complete + && self.finish_review_available + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { + row.is_review + && self + .worktree_head + .is_some_and(|head| self.is_known_ancestor(row.id, head)) + }) + } + + pub(crate) fn can_fork_commit(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && !self.worktree_conflicted + && self.pending_rebase_conflict.is_none() + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.worktree_head.is_some() + && self.selected.and_then(|index| self.rows.get(index)).is_some() + } + + pub(crate) fn can_attach(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && !self.worktree_conflicted + && self.pending_rebase_conflict.is_none() + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.worktree_head.is_some() + && self.worktree_branch.is_some_and(|(_, detached)| detached) + } + + pub(crate) fn review_selection_active(&self) -> bool { + self.review_tip.is_some() + } + + pub(crate) fn squash_selection_active(&self) -> bool { + self.squash_source.is_some() + } + + pub(crate) fn review_return_selection_active(&self) -> bool { + self.review_return.is_some() + } + + fn can_edit_head(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { + !self.hidden_rows.contains(&row.id) + && Some(row.id) == self.worktree_head + && !self.known_merge_descendants.contains(&row.id) + }) + } + + pub(crate) fn can_amend(&self) -> bool { + self.can_edit_head() + && self.amend_available + && match self.changes_focus { + None => true, + Some(ChangePane::Worktree) => self.worktree_path_amend_available, + Some(ChangePane::Tree) => false, + } + } + + pub(crate) fn can_stash(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && !self.worktree_conflicted + && self.pending_rebase_conflict.is_none() + && self.changes_focus.is_none() + && self.stash_available + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| !self.hidden_rows.contains(&row.id) && Some(row.id) == self.worktree_head) + } + + pub(crate) fn can_unstash(&self) -> bool { + self.state == State::Complete + && self.pending_rebase_conflict.is_none() + && self.changes_focus.is_none() + && self.unstash_available + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| !self.hidden_rows.contains(&row.id) && Some(row.id) == self.worktree_head) + } + + pub(crate) fn can_spill(&self) -> bool { + self.can_edit_head() && matches!(self.changes_focus, None | Some(ChangePane::Tree)) && self.spill_available + } + + pub(crate) fn can_split(&self) -> bool { + self.can_edit_head() && self.changes_focus.is_none() && self.split_available + } + + #[expect(clippy::too_many_arguments)] + pub(crate) fn set_head_edit_availability( + &mut self, + amend: bool, + stash: bool, + unstash: bool, + worktree_path_amend: bool, + finish_review: bool, + spill: bool, + split: bool, + ) { + self.amend_available = amend; + self.stash_available = stash; + self.unstash_available = unstash; + self.worktree_path_amend_available = worktree_path_amend; + self.finish_review_available = finish_review; + self.spill_available = spill; + self.split_available = split; + } + + pub(crate) fn select_commit(&mut self, id: ObjectId) { + if let Some(index) = self.rows.iter().position(|row| row.id == id) { + self.select(index); + } + } + + pub(crate) fn select_commit_for_time_travel(&mut self, id: ObjectId) { + let Some(index) = self.rows.iter().position(|row| row.id == id) else { + return; + }; + let previous_offset = self.offset; + self.select(index); + if let Some(selected) = self + .selected_history_index() + .filter(|selected| *selected < previous_offset) + { + self.offset = selected.saturating_sub(self.viewport_rows.max(1) - 1); + } + } + + pub(crate) fn begin_time_travel_animation(&mut self) { + let viewport_row = self + .selected_history_index() + .map(|selected| selected.saturating_sub(self.offset)); + self.time_travel_animation = self + .selected + .and_then(|index| self.rows.get(index)) + .map(|row| (row.id, viewport_row.unwrap_or_default())); + if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) { + self.offset = selected.saturating_sub(viewport_row); + } + self.ensure_visible(); + } + + pub(crate) fn finish_time_travel_animation(&mut self) { + let Some((origin, viewport_row)) = self.time_travel_animation else { + return; + }; + self.select_commit(origin); + self.time_travel_animation = None; + if self.alignment == Alignment::Compressed { + self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + self.rebuild_compressed_history(); + } + if let Some(selected) = self.selected_history_index() { + self.offset = selected.saturating_sub(viewport_row); + } + self.ensure_visible(); + } + + pub(crate) fn time_travel_animation_origin(&self) -> Option<ObjectId> { + self.time_travel_animation.map(|(origin, _)| origin) + } + + pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) { + self.selection_after_refresh = Some(id); + } + + pub(crate) fn time_travel_shortcut_visible(&self) -> bool { + self.worktree_changes_available + && self.pending_rebase_conflict.is_none() + && !self.worktree_conflicted + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some() + } + + fn can_time_travel(&self) -> bool { + self.state == State::Complete && self.time_travel_shortcut_visible() + } + + fn last_selectable(&self) -> Option<usize> { + (0..self.history_len()) + .rev() + .find_map(|index| match self.history_entry(index) { + Some(HistoryEntry::Commit(index)) => Some(index), + Some(HistoryEntry::Segment { .. }) | None => None, + }) + } + + fn retry_failed_signatures(&mut self) { + if self.signature_failures == 0 { + return; + } + let mut changed = Vec::new(); + for row in &mut self.rows { + if row.signature == SignatureState::Failed { + Arc::make_mut(row).signature = SignatureState::Unverified; + changed.push((row.id, Arc::clone(row))); + } + } + for (id, row) in changed { + self.all_rows.insert(id, row); + } + self.signature_failures = 0; + } + + pub(crate) fn ensure_visible(&mut self) { + self.viewport_panned = false; + let Some(selected) = self.selected_history_index() else { + return; + }; + let height = self.viewport_rows.max(1); + if selected < self.offset { + self.offset = selected; + } else if selected >= self.offset.saturating_add(height) { + self.offset = selected + 1 - height; + } + } + + pub(crate) fn prepare_history_viewport(&mut self) { + if self.viewport_panned { + self.offset = self + .offset + .min(self.history_len().saturating_sub(self.viewport_rows.max(1))); + } else { + self.ensure_visible(); + } + } + + pub(crate) fn center_initial_selection(&mut self) { + let Some(id) = self.pending_initial_selection else { + return; + }; + let Some(selected) = self.rows.iter().position(|row| row.id == id) else { + if matches!(self.state, State::Complete | State::Cancelled) { + self.pending_initial_selection = None; + } + return; + }; + self.compressed_segment = None; + self.selected = Some(selected); + if self.alignment == Alignment::Compressed + && !self.compressed_history_suspended() + && self.history_index(selected).is_none() + { + self.compressed_anchor = Some(self.rows[selected].id); + self.rebuild_compressed_history(); + } + self.center_selection(); + if matches!(self.state, State::Complete | State::Cancelled) { + self.pending_initial_selection = None; + } + } + + fn center_selection(&mut self) { + let Some(selected) = self.selected_history_index() else { + return; + }; + let height = self.viewport_rows.max(1); + self.offset = selected + .saturating_sub(height / 2) + .min(self.history_len().saturating_sub(height)); + } + + pub(crate) fn set_horizontal_bounds(&mut self, page: usize, max: usize) { + self.horizontal_page = page.max(1); + self.horizontal_max = max; + self.horizontal_offset = self.horizontal_offset.min(max); + } + + pub(crate) fn set_commit_bounds(&mut self, page: usize, max: usize) { + self.commit_page = page.max(1); + self.commit_max = max; + self.commit_offset = self.commit_offset.min(max); + } + + pub(crate) fn commit_paging_active(&self) -> bool { + self.show_commit && self.commit_max > 0 + } + + pub(crate) fn reset_commit_view(&mut self) { + self.commit_offset = 0; + self.commit_max = 0; + } + + pub(crate) fn set_changes_bounds( + &mut self, + pane: ChangePane, + page: usize, + len: usize, + separator: Option<usize>, + horizontal_page: usize, + horizontal_max: usize, + ) { + let changes = self.changes_mut(pane); + changes.page = page.max(1); + changes.len = len; + changes.max = len.saturating_sub(1); + changes.separator = separator.filter(|separator| *separator > 0 && *separator < len); + if len == 0 { + changes.selected = 0; + changes.offset = 0; + } else { + changes.selected = changes.selected.min(changes.max); + changes.ensure_visible(); + } + changes.horizontal_page = horizontal_page.max(1); + changes.horizontal_max = horizontal_max; + changes.horizontal_offset = changes.horizontal_offset.min(horizontal_max); + } + + pub(crate) fn reset_changes_view(&mut self) { + self.tree_changes = ChangesView::default(); + self.worktree_changes = ChangesView::default(); + } + + pub(crate) fn changes(&self, pane: ChangePane) -> &ChangesView { + match pane { + ChangePane::Tree => &self.tree_changes, + ChangePane::Worktree => &self.worktree_changes, + } + } + + pub(crate) fn changes_mut(&mut self, pane: ChangePane) -> &mut ChangesView { + match pane { + ChangePane::Tree => &mut self.tree_changes, + ChangePane::Worktree => &mut self.worktree_changes, + } + } + + fn focused_changes(&self) -> &ChangesView { + self.changes(self.changes_focus.expect("changes are focused")) + } + + fn focused_changes_mut(&mut self) -> &mut ChangesView { + self.changes_mut(self.changes_focus.expect("changes are focused")) + } + + fn cycle_changes_focus(&mut self) { + let (first, second) = match self.changes_layout { + ChangesLayout::SideBySide => (ChangePane::Tree, ChangePane::Worktree), + ChangesLayout::Stacked => (ChangePane::Worktree, ChangePane::Tree), + }; + let visible = |pane| match pane { + ChangePane::Tree => self.tree_changes_visible, + ChangePane::Worktree => self.worktree_changes_visible, + }; + self.changes_focus = match self.changes_focus { + None if visible(first) => Some(first), + None if visible(second) => Some(second), + Some(current) if current == first && visible(second) => Some(second), + Some(_) | None => None, + }; + } + + pub(crate) fn set_changes_layout(&mut self, layout: ChangesLayout, tree_visible: bool, worktree_visible: bool) { + self.changes_layout = layout; + self.tree_changes_visible = tree_visible; + self.worktree_changes_visible = worktree_visible; + if self.changes_focus == Some(ChangePane::Tree) && !tree_visible { + self.changes_focus = worktree_visible.then_some(ChangePane::Worktree); + } else if self.changes_focus == Some(ChangePane::Worktree) && !worktree_visible { + self.changes_focus = tree_visible.then_some(ChangePane::Tree); + } + } + + #[cfg(test)] + pub(crate) fn set_lane(&mut self, index: usize, lane: &str) { + self.test_lanes.resize(self.rows.len(), String::new()); + self.test_lanes[index] = lane.into(); + } +} + +impl CompressedHistory { + fn new( + rows: &[SharedCommitRow], + view_tips: &HashSet<ObjectId>, + hidden_rows: &HashSet<ObjectId>, + expanded: &HashSet<ObjectId>, + selected: Option<ObjectId>, + ) -> Self { + let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); + let mut children = vec![(0usize, 0usize); rows.len()]; + for (child, row) in rows.iter().enumerate() { + for parent in &row.parent_ids { + if let Some(parent) = positions.get(parent) { + children[*parent] = (children[*parent].0 + 1, child); + } + } + } + let is_anchor = |index: usize| { + let row = &rows[index]; + view_tips.contains(&row.id) + || hidden_rows.contains(&row.id) + || expanded.contains(&row.id) + || selected == Some(row.id) + || row.parent_ids.len() != 1 + || children[index].0 != 1 + }; + let mut representatives = Vec::new(); + let mut members = Vec::<Vec<usize>>::new(); + let mut group_of = vec![usize::MAX; rows.len()]; + for (index, row) in rows.iter().enumerate() { + let joined = (!is_anchor(index)) + .then_some(children[index]) + .and_then(|(count, child)| match count { + 1 if !is_anchor(child) + && rows[child].parent_ids.len() == 1 + && rows[child].parent_ids[0] == row.id + && group_of[child] != usize::MAX => + { + Some(group_of[child]) + } + _ => None, + }); + let group = joined.unwrap_or_else(|| { + representatives.push(index); + members.push(Vec::new()); + representatives.len() - 1 + }); + group_of[index] = group; + members[group].push(index); + } + + let representative_ids: Vec<_> = representatives.iter().map(|index| rows[*index].id).collect(); + let mut parents: Vec<ParentIds> = representatives.iter().map(|_| ParentIds::new()).collect(); + for (member, row) in rows.iter().enumerate() { + let group = group_of[member]; + for parent in &row.parent_ids { + let Some(parent_index) = positions.get(parent) else { + continue; + }; + let parent_group = group_of[*parent_index]; + if parent_group != group { + parents[group].push(representative_ids[parent_group]); + } + } + } + + let mut compact_rows = Vec::with_capacity(representatives.len()); + let mut entries = Vec::with_capacity(representatives.len()); + for (group, representative) in representatives.into_iter().enumerate() { + let mut row = rows[representative].as_ref().clone(); + row.parent_ids = std::mem::take(&mut parents[group]); + compact_rows.push(Arc::new(row)); + entries.push(if is_anchor(representative) || members[group].len() == 1 { + HistoryEntry::Commit(representative) + } else { + HistoryEntry::Segment { + representative, + count: members[group].len(), + } + }); + } + let graph = Graph::new(&compact_rows); + let display_indices = entries + .iter() + .enumerate() + .filter_map(|(display, entry)| match entry { + HistoryEntry::Commit(canonical) => Some((*canonical, display)), + HistoryEntry::Segment { .. } => None, + }) + .collect(); + let member_indices = members + .iter() + .enumerate() + .flat_map(|(display, members)| members.iter().map(move |index| (rows[*index].id, display))) + .collect(); + CompressedHistory { + entries, + display_indices, + member_indices, + members, + rows: compact_rows, + graph, + } + } +} + +fn estimate_lane_width(rows: &[SharedCommitRow]) -> usize { + let mut rows = rows.to_vec(); + let known: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); + for row in &mut rows { + if row.parent_ids.iter().any(|id| !known.contains_key(id)) { + Arc::make_mut(row).parent_ids.retain(|id| known.contains_key(id)); + } + } + let graph = Graph::new(&rows); + graph + .render(&rows, 0..rows.len()) + .iter() + .map(|lane| lane.trim_end().chars().count().saturating_add(1)) + .max() + .unwrap_or_default() +} + +pub(crate) fn compute_lanes(mut rows: Vec<SharedCommitRow>) -> (Vec<SharedCommitRow>, Graph, Duration) { + let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); + for row in &mut rows { + if row.parent_ids.iter().any(|id| !positions.contains_key(id)) { + Arc::make_mut(row).parent_ids.retain(|id| positions.contains_key(id)); + } + } + let mut children = vec![0usize; rows.len()]; + for row in rows.iter() { + for parent in &row.parent_ids { + if let Some(index) = positions.get(parent) { + children[*index] += 1; + } + } + } + + let mut ready: Vec<_> = children + .iter() + .enumerate() + .rev() + .filter_map(|(index, count)| (*count == 0).then_some(index)) + .collect(); + let mut ordered = 0; + while let Some(index) = ready.pop() { + for parent in rows[index].parent_ids.iter().rev() { + if let Some(parent_index) = positions.get(parent) { + children[*parent_index] -= 1; + if children[*parent_index] == 0 { + ready.push(*parent_index); + } + } + } + // A ready row's child count is dead, so reuse it as the row's destination. + children[index] = ordered; + ordered += 1; + } + if ordered == rows.len() { + for index in 0..rows.len() { + while children[index] != index { + let destination = children[index]; + rows.swap(index, destination); + children.swap(index, destination); + } + } + } + let start = Instant::now(); + let graph = Graph::new(&rows); + (rows, graph, start.elapsed()) +} + +const CHECKPOINT_INTERVAL: usize = 256; + +#[derive(Debug)] +pub(crate) struct Graph { + offsets: Vec<usize>, + columns: Vec<ObjectId>, + visual_counts: Vec<usize>, + first_parents: Vec<Option<usize>>, + first_parent_children: Vec<Vec<usize>>, +} + +impl Graph { + fn new(rows: &[SharedCommitRow]) -> Self { + let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); + let mut first_parent_children = vec![Vec::new(); rows.len()]; + let first_parents = rows + .iter() + .enumerate() + .map(|(child, row)| { + row.parent_ids + .first() + .and_then(|parent| positions.get(parent)) + .copied() + .inspect(|parent| first_parent_children[*parent].push(child)) + }) + .collect(); + let mut state = LaneState::default(); + let mut graph = Graph { + offsets: Vec::with_capacity(rows.len().div_ceil(CHECKPOINT_INTERVAL) + 1), + columns: Vec::new(), + visual_counts: visual_counts(rows), + first_parents, + first_parent_children, + }; + for (index, row) in rows.iter().enumerate() { + if index % CHECKPOINT_INTERVAL == 0 { + graph.offsets.push(graph.columns.len()); + graph.columns.extend_from_slice(&state.columns); + } + state.advance(row, None); + } + graph.offsets.push(graph.columns.len()); + graph + } + + fn render(&self, rows: &[SharedCommitRow], range: Range<usize>) -> RenderedLanes { + self.render_with_markers(rows, range, |_| '●') + } + + fn render_with_markers( + &self, + rows: &[SharedCommitRow], + range: Range<usize>, + marker: impl Fn(usize) -> char, + ) -> RenderedLanes { + let start = range.start.min(rows.len()); + let end = range.end.min(rows.len()); + if start >= end { + return RenderedLanes::default(); + } + let checkpoint = start / CHECKPOINT_INTERVAL; + let mut state = LaneState { + columns: self.columns[self.offsets[checkpoint]..self.offsets[checkpoint + 1]].to_vec(), + ..LaneState::default() + }; + let mut rendered = RenderedLanes { + data: String::with_capacity((end - start).saturating_mul(4)), + ranges: Vec::with_capacity(end - start), + }; + for (index, row) in rows[checkpoint * CHECKPOINT_INTERVAL..end].iter().enumerate() { + let index = checkpoint * CHECKPOINT_INTERVAL + index; + if let Some(range) = state.advance_ids( + row.id, + row.parent_ids.iter().copied(), + (index >= start).then_some(&mut rendered.data), + marker(index), + ) { + rendered.ranges.push(range); + } + } + rendered + } +} + +fn visual_counts(rows: &[SharedCommitRow]) -> Vec<usize> { + let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); + let mut counts = vec![0; rows.len()]; + for (index, row) in rows.iter().enumerate().rev() { + let base = row + .parent_ids + .iter() + .filter_map(|parent| positions.get(parent).copied()) + .map(|parent| parent.saturating_add(counts[parent])) + .min() + .unwrap_or(index); + counts[index] = base.saturating_sub(index); + } + counts +} + +#[derive(Debug, Default)] +pub(crate) struct RenderedLanes { + data: String, + ranges: Vec<Range<usize>>, +} + +impl RenderedLanes { + pub(crate) fn lane(&self, index: usize) -> &str { + &self.data[self.ranges[index].clone()] + } + + pub(crate) fn iter(&self) -> impl Iterator<Item = &str> { + self.ranges.iter().map(|range| &self.data[range.clone()]) + } + + fn empty(len: usize) -> Self { + RenderedLanes { + data: String::new(), + ranges: vec![0..0; len], + } + } + + #[cfg(test)] + fn from_lanes<'a>(lanes: impl IntoIterator<Item = &'a String>) -> Self { + let mut rendered = RenderedLanes::default(); + for lane in lanes { + let start = rendered.data.len(); + rendered.data.push_str(lane); + rendered.ranges.push(start..rendered.data.len()); + } + rendered + } +} + +#[derive(Default)] +pub(crate) struct LaneState { + columns: Vec<ObjectId>, + next: Vec<ObjectId>, + parents: Vec<(ObjectId, Option<usize>, usize)>, + edges: Vec<(usize, usize)>, + cells: Vec<u8>, +} + +impl LaneState { + fn advance(&mut self, row: &CommitRow, out: Option<&mut String>) -> Option<Range<usize>> { + self.advance_ids(row.id, row.parent_ids.iter().copied(), out, '●') + } + + pub(crate) fn advance_ids( + &mut self, + id: ObjectId, + parent_ids: impl IntoIterator<Item = ObjectId>, + out: Option<&mut String>, + marker: char, + ) -> Option<Range<usize>> { + let render = out.is_some(); + let current = self + .columns + .iter() + .position(|candidate| *candidate == id) + .unwrap_or_else(|| { + self.columns.push(id); + self.columns.len() - 1 + }); + + self.parents.clear(); + for parent in parent_ids { + if !self.parents.iter().any(|(id, _, _)| *id == parent) { + self.parents + .push((parent, self.columns.iter().position(|id| *id == parent), 0)); + } + } + self.next.clear(); + self.edges.clear(); + for (index, id) in self.columns[..current].iter().copied().enumerate() { + let destination = self.next.len(); + self.next.push(id); + if render { + self.edges.push((index, destination)); + } + } + for (parent, old_position, destination) in &mut self.parents { + *destination = match old_position { + Some(position) if *position < current => *position, + _ => { + let destination = self.next.len(); + self.next.push(*parent); + if render && old_position.is_some_and(|position| position != current) { + self.edges + .push((old_position.expect("checked as present"), destination)); + } + destination + } + }; + } + for (index, id) in self.columns.iter().copied().enumerate().skip(current + 1) { + if self.parents.iter().any(|(_, position, _)| *position == Some(index)) { + continue; + } + let destination = self.next.len(); + self.next.push(id); + if render { + self.edges.push((index, destination)); + } + } + if render { + for (_, _, destination) in &self.parents { + self.edges.push((current, *destination)); + } + } + let range = out.map(|out| { + transition( + self.columns.len(), + self.next.len(), + current, + &self.edges, + &mut self.cells, + out, + marker, + ) + }); + std::mem::swap(&mut self.columns, &mut self.next); + range + } + + pub(crate) fn node_line(&self, id: ObjectId, marker: char) -> String { + let current = self + .columns + .iter() + .position(|candidate| *candidate == id) + .unwrap_or(self.columns.len()); + let mut cells = vec![' '; self.columns.len().max(current + 1) * 2 - 1]; + for column in 0..self.columns.len() { + cells[column * 2] = '│'; + } + cells[current * 2] = marker; + cells.push(' '); + cells.into_iter().collect() + } +} + +fn transition( + before: usize, + after: usize, + current: usize, + edges: &[(usize, usize)], + cells: &mut Vec<u8>, + out: &mut String, + marker: char, +) -> Range<usize> { + const UP: u8 = 1; + const DOWN: u8 = 2; + const LEFT: u8 = 4; + const RIGHT: u8 = 8; + const VERTICAL: u8 = UP | DOWN; + const HORIZONTAL: u8 = LEFT | RIGHT; + const CROSS: u8 = VERTICAL | HORIZONTAL; + const VERTICAL_RIGHT: u8 = VERTICAL | RIGHT; + const VERTICAL_LEFT: u8 = VERTICAL | LEFT; + const DOWN_HORIZONTAL: u8 = DOWN | HORIZONTAL; + const UP_HORIZONTAL: u8 = UP | HORIZONTAL; + const DOWN_RIGHT: u8 = DOWN | RIGHT; + const DOWN_LEFT: u8 = DOWN | LEFT; + const UP_RIGHT: u8 = UP | RIGHT; + const UP_LEFT: u8 = UP | LEFT; + + let width = before.max(after).max(current + 1) * 2 - 1; + cells.clear(); + cells.resize(width, 0); + for &(from, to) in edges { + let from = from * 2; + let to = to * 2; + cells[from] |= UP; + cells[to] |= DOWN; + if from < to { + cells[from] |= RIGHT; + cells[to] |= LEFT; + for cell in &mut cells[from + 1..to] { + *cell |= LEFT | RIGHT; + } + } else if to < from { + cells[from] |= LEFT; + cells[to] |= RIGHT; + for cell in &mut cells[to + 1..from] { + *cell |= LEFT | RIGHT; + } + } + } + + let start = out.len(); + for (index, cell) in cells.iter().copied().enumerate() { + out.push(if index == current * 2 { + marker + } else { + match cell { + 0 => ' ', + CROSS => '┼', + VERTICAL_RIGHT => '├', + VERTICAL_LEFT => '┤', + DOWN_HORIZONTAL => '┬', + UP_HORIZONTAL => '┴', + DOWN_RIGHT => '╭', + DOWN_LEFT => '╮', + UP_RIGHT => '╰', + UP_LEFT => '╯', + HORIZONTAL => '─', + _ => '│', + } + }); + } + out.push(' '); + start..out.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(n: u16) -> ObjectId { + let mut bytes = [0; 20]; + bytes[18..].copy_from_slice(&n.to_be_bytes()); + ObjectId::Sha1(bytes) + } + + fn row(n: u8) -> LoadedCommit { + Commit { + id: id(n.into()), + parent_ids: ParentIds::new(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: Box::leak(Box::new(Author { + name: b"author".as_bstr(), + email: b"author@example.com".as_bstr(), + })), + attributions: 0..0, + title: format!("commit {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + } + } + + fn row_with_parents(n: u8, parents: &[u8]) -> LoadedCommit { + let mut commit = row(n); + commit.parent_ids = parents.iter().map(|n| row(*n).id).collect(); + commit + } + + #[test] + fn recognizes_all_assistants_as_agents() { + let assistant = Box::leak(Box::new(Author { + name: b"Anything".as_bstr(), + email: b"".as_bstr(), + })); + + assert!( + Attribution { + kind: AttributionKind::Assisted, + author: assistant, + } + .is_agent() + ); + assert!( + !Attribution { + kind: AttributionKind::Reviewed, + author: assistant, + } + .is_agent() + ); + } + + fn numbered_row(n: u16, parent: Option<u16>) -> LoadedCommit { + let mut commit = row(0); + commit.id = id(n); + commit.parent_ids = parent.map(id).into_iter().collect(); + commit.title = format!("commit {n}").into(); + commit + } + + fn complete(app: &mut App) { + let rows = app + .start_lane_computation() + .expect("a loading app starts lane computation"); + let (rows, graph, lane_time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, lane_time); + } + + fn compressed_linear_app() -> App { + let mut app = App::new(3); + app.extend_commits(vec![ + row_with_parents(6, &[5]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.set_view_tips(&[id(6)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + app + } + + #[test] + fn undo_and_redo_retain_their_position_until_another_action() { + let mut app = App::new(2); + app.show_undo_position(9, 4, "reword commit"); + assert_eq!(app.undo_position(), Some((4, 4, "reword commit"))); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("reword commit · 4 undo · 0 redo".into()) + ); + + assert_eq!(app.update(Action::Undo), vec![Effect::Undo]); + assert_eq!(app.undo_position(), Some((4, 4, "reword commit"))); + app.leave_error("undo failed"); + assert_eq!(app.update(Action::Redo), vec![Effect::Redo]); + assert_eq!(app.undo_position(), Some((4, 4, "reword commit"))); + + app.show_undo_position(0, 4, "ignored at the sentinel"); + assert_eq!(app.undo_position(), Some((0, 4, "start of undo history"))); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("start of undo history · 0 undo · 4 redo".into()) + ); + assert!(app.update(Action::MoveDown).is_empty()); + assert_eq!(app.undo_position(), None, "ordinary movement dismisses undo progress"); + } + + #[test] + fn mandatory_prompts_block_undo_without_dismissing_its_position() { + let mut app = App::new(2); + app.show_undo_position(1, 2, "reword commit"); + app.arm_rebase_continuation(); + + assert!(app.update(Action::Undo).is_empty()); + assert_eq!(app.undo_position(), Some((1, 2, "reword commit"))); + } + + #[test] + fn cycles_duplicate_change_ids_and_wraps() { + let mut app = App::new(2); + app.extend_commits(vec![row(1), row(2), row(3), row(4), row(5)]); + complete(&mut app); + let duplicate = ChangeId::from(id(9)); + app.set_change_ids( + HashMap::from([(id(1), duplicate), (id(3), duplicate), (id(5), duplicate)]), + HashSet::from([id(1), id(3), id(5)]), + ); + + assert!(app.can_cycle_duplicate()); + for expected in [id(3), id(5), id(1)] { + app.update(Action::CycleDuplicate); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(expected), + "duplicate cycling skips unrelated rows and wraps" + ); + assert!( + app.selected + .is_some_and(|selected| selected >= app.offset && selected < app.offset + 2), + "duplicate cycling keeps the new selection visible" + ); + } + + app.changes_focus = Some(ChangePane::Tree); + assert!(!app.can_cycle_duplicate()); + app.update(Action::CycleDuplicate); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(1)), + "duplicate cycling does not move history while a changes pane is focused" + ); + app.changes_focus = None; + app.select_commit(id(2)); + assert!(!app.can_cycle_duplicate()); + app.update(Action::CycleDuplicate); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(2))); + } + + fn show_tree_changes(app: &mut App) { + app.set_changes_layout(ChangesLayout::SideBySide, true, false); + } + + #[test] + fn completion_orders_and_draws_merge_lanes() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(4, &[3, 2]), + row_with_parents(3, &[1]), + row(1), + row_with_parents(2, &[1]), + ]); + + assert_eq!( + app.estimated_lane_width, 4, + "the provisional and rendered graph widths use the same trailing separator" + ); + + complete(&mut app); + + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [row(4).id, row(3).id, row(2).id, row(1).id] + ); + assert_eq!( + app.render_lanes(0..app.rows.len()).iter().collect::<Vec<_>>(), + ["●─╮ ", "● │ ", "├─● ", "● "] + ); + } + + #[test] + fn refresh_projects_from_an_append_only_commit_cache() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + + let rows = app + .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4)], &[], false) + .expect("a refresh computes lanes"); + assert_eq!( + app.rows.len(), + 3, + "the current frame stays intact while lanes are computed" + ); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(4), id(3), id(2), id(1)] + ); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(3)), + "ordinary refreshes preserve the selected commit" + ); + + let rows = app + .start_refresh(Vec::<LoadedCommit>::new().into(), &[id(2)], &[], false) + .expect("a rewind reprojects cached topology"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!(app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), [id(2), id(1)]); + + let rows = app + .start_refresh(Vec::<LoadedCommit>::new().into(), &[id(4)], &[], false) + .expect("a fast-forward to retained commits needs no new objects"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(4), id(3), id(2), id(1)] + ); + assert!( + app.rows + .iter() + .all(|row| Arc::ptr_eq(row, app.all_rows.get(&row.id).expect("visible rows remain cached"))), + "the active projection shares its immutable rows with the append-only cache" + ); + } + + #[test] + fn refresh_keeps_hidden_tips_as_the_unborn_view() { + let mut app = App::new(10); + app.extend_hidden_commits(vec![row(1)]); + complete(&mut app); + + let rows = app + .start_refresh(vec![row_with_parents(2, &[1])].into(), &[], &[id(2)], false) + .expect("a hidden-only refresh computes lanes"); + assert_eq!( + rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(2)], + "only the current hidden tip remains in the view" + ); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert!(app.is_row_hidden(0), "the fallback remains a hidden boundary"); + } + + #[test] + fn refresh_keeps_an_advanced_tip_on_its_existing_side() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[1]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + + let rows = app + .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4), id(2)], &[], false) + .expect("a refresh computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(4), id(3), id(2), id(1)], + "advancing a tip keeps its history ahead of the independent tip" + ); + assert_eq!( + app.render_lanes(0..app.rows.len()).iter().collect::<Vec<_>>(), + ["● ", "● ", "├─● ", "● "], + "the advanced tip stays in its existing left lane" + ); + + let rows = app + .start_refresh(vec![row_with_parents(5, &[4])].into(), &[id(5), id(2)], &[], false) + .expect("a second refresh computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(5), id(4), id(3), id(2), id(1)], + "successive commits keep inheriting the current lane order" + ); + + app.select_commit(id(5)); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + assert_eq!( + app.render_lanes(0..app.history_len()).iter().collect::<Vec<_>>(), + ["● ", "○ ", "├─● ", "● "], + "compressed history inherits the stable canonical lanes" + ); + } + + #[test] + fn refresh_keeps_change_ids_until_the_replacement_projection_is_ready() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + let change_id = ChangeId::from(id(9)); + app.set_change_ids(HashMap::from([(id(2), change_id)]), HashSet::from([id(1), id(2)])); + + let _rows = app + .start_refresh(vec![row_with_parents(3, &[2])].into(), &[id(3)], &[], false) + .expect("the refresh starts lane computation"); + + assert_eq!( + app.change_id(id(2)), + change_id, + "the current frame retains its change ID during refresh" + ); + assert!( + app.has_duplicate_change_id(id(1)) && app.has_duplicate_change_id(id(2)), + "the duplicate gutter remains stable until rows and IDs are replaced together" + ); + } + + #[test] + fn ref_tree_pin_refresh_adds_cached_branch_ancestry_and_selects_its_tip() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + + let rows = app + .start_refresh( + vec![row_with_parents(5, &[4]), row_with_parents(4, &[2])].into(), + &[id(3)], + &[], + false, + ) + .expect("ref-tree expansion caches its branch while retaining history tips"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(3), id(2), id(1)], + "worktree expansion does not broaden history" + ); + + app.select_commit_after_refresh(id(5)); + let rows = app + .start_refresh(Vec::<LoadedCommit>::new().into(), &[id(3), id(5)], &[], false) + .expect("the new pin reprojects already cached commits"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(3), id(5), id(4), id(2), id(1)], + "the pinned branch and its shared base appear without restarting" + ); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(5)), + "the first frame of the refreshed history selects the pinned entry" + ); + } + + #[test] + fn lane_computation_keeps_cached_parents_outside_the_current_view() { + let mut app = App::new(3); + app.extend_commits(vec![row_with_parents(2, &[1])]); + app.extend_hidden_commits(vec![row_with_parents(1, &[0])]); + let rows = app + .start_lane_computation() + .expect("loading rows starts lane computation"); + let (rows, graph, elapsed) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, elapsed); + + let rows = app + .start_refresh(vec![row(0)].into(), &[id(2)], &[], false) + .expect("refresh projects the extended ancestry"); + assert_eq!( + rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [id(2), id(1), id(0)], + "lane pruning does not disconnect cached ancestry needed by a later expansion" + ); + } + + #[test] + fn filesystem_refresh_retains_selection_or_uses_the_first_selectable_row() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + app.update(Action::MoveDown); + app.update(Action::MoveDown); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(1))); + + let rows = app + .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4)], &[], false) + .expect("a filesystem refresh computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(1)), + "a still-visible selection survives new commits" + ); + + app.update(Action::First); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + let rows = app + .start_refresh(Vec::<LoadedCommit>::new().into(), &[id(3)], &[], false) + .expect("a filesystem rewind computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!(app.selected, app.first_selectable()); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(3)), + "a removed selection falls back to the first selectable row" + ); + } + + #[test] + fn refresh_selects_the_rewritten_successor() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + app.update(Action::MoveDown); + app.select_commit_after_refresh(id(4)); + + let rows = app + .start_refresh( + vec![row_with_parents(5, &[4]), row_with_parents(4, &[1])].into(), + &[id(5)], + &[], + false, + ) + .expect("a rewritten stack computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(4)), + "selection follows the successor supplied by the edit" + ); + } + + #[test] + fn refresh_restores_a_rewritten_selection_by_its_visual_position() { + let mut app = App::new(3); + app.extend_commits(vec![ + row_with_parents(6, &[5]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.select_commit(id(4)); + assert_eq!(app.visual_count(app.selected.expect("commit 4 is selected")), Some(3)); + assert_eq!(app.offset, 0, "the original selection is at the bottom of the viewport"); + + let rows = app.store_commits( + vec![ + numbered_row(17, Some(16)), + numbered_row(16, Some(15)), + numbered_row(15, Some(14)), + numbered_row(14, Some(13)), + numbered_row(13, Some(2)), + numbered_row(2, Some(1)), + numbered_row(1, None), + ] + .into(), + ); + app.state = State::Computing; + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(14)), + "the same base and displayed entry number identify the rewritten commit" + ); + assert_eq!( + app.offset, 1, + "the restored selection stays at the bottom of the viewport" + ); + + let rows = app.store_commits(vec![numbered_row(9, Some(8)), numbered_row(8, None)].into()); + app.state = State::Computing; + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(8)), + "a missing visual position clamps the previous row before falling back to the top" + ); + } + + #[test] + fn selected_head_follows_an_external_rewrite() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + app.set_worktree_head(Some(id(3)), false); + complete(&mut app); + app.set_worktree_head(Some(id(5)), false); + + let rows = app + .start_refresh( + vec![row_with_parents(5, &[4]), row_with_parents(4, &[1])].into(), + &[id(5)], + &[], + false, + ) + .expect("an externally rewritten stack computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(5)), + "the selected HEAD follows its new target" + ); + } + + #[test] + fn completed_non_merge_stacks_can_be_reworded_from_any_row() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + assert!(!app.can_reword(), "loading history cannot be reworded"); + complete(&mut app); + assert_eq!(app.update(Action::Reword), vec![Effect::Reword(id(2))]); + + app.update(Action::MoveDown); + assert!(app.can_reword(), "linear descendants can be rebased after rewording"); + assert_eq!(app.update(Action::Reword), vec![Effect::Reword(id(1))]); + } + + #[test] + fn head_edits_are_limited_to_the_current_worktree_head_and_available_changes() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + app.set_worktree_head(Some(id(2)), false); + app.set_head_edit_availability(true, true, false, true, false, true, false); + complete(&mut app); + assert_eq!(app.update(Action::Amend), vec![Effect::Amend(id(2))]); + assert_eq!(app.update(Action::Stash), vec![Effect::Stash(id(2))]); + assert_eq!(app.update(Action::Spill), vec![Effect::Spill(id(2))]); + assert!( + app.update(Action::Split).is_empty(), + "split needs both kinds of changes" + ); + app.set_head_edit_availability(true, true, false, true, false, true, true); + assert_eq!(app.update(Action::Split), vec![Effect::Split(id(2))]); + app.set_head_edit_availability(true, false, true, true, false, true, true); + assert_eq!( + app.update(Action::Stash), + vec![Effect::Unstash(id(2))], + "the stash key restores existing state instead of saving another stash" + ); + app.changes_focus = Some(ChangePane::Tree); + assert!(!app.can_amend(), "a tree path cannot be amended"); + assert!(!app.can_split(), "a tree path cannot be split"); + assert_eq!( + app.update(Action::Spill), + vec![Effect::Spill(id(2))], + "tree focus scopes spill to its selected path" + ); + app.changes_focus = Some(ChangePane::Worktree); + assert!(!app.can_spill(), "worktree paths cannot be spilled from a commit"); + assert_eq!( + app.update(Action::Amend), + vec![Effect::Amend(id(2))], + "worktree focus scopes amend to its selected path" + ); + app.changes_focus = None; + app.update(Action::MoveDown); + assert!(!app.can_amend()); + assert!(!app.can_stash()); + assert!(app.update(Action::Amend).is_empty()); + } + + #[test] + fn a_clean_review_can_finish_while_head_is_on_its_successor() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row_with_parents(4, &[1]), + row(1), + ]); + std::sync::Arc::make_mut(&mut app.rows[1]).is_review = true; + app.selected = Some(1); + app.set_worktree_head(Some(id(3)), false); + app.set_head_edit_availability(false, false, false, false, true, false, false); + complete(&mut app); + + assert_eq!( + app.update(Action::Review), + vec![Effect::FinishReview { + review: id(2), + return_to: None, + }] + ); + + app.set_worktree_head(Some(id(4)), false); + assert!( + app.update(Action::Review).is_empty(), + "an unrelated checkout cannot finish the selected review" + ); + } + + #[test] + fn a_missing_review_return_can_select_a_detached_checkout() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(6, &[4]), + row_with_parents(5, &[4]), + row_with_parents(4, &[1]), + row_with_parents(2, &[1]), + row_with_parents(3, &[1]), + row(1), + ]); + std::sync::Arc::make_mut(&mut app.rows[3]).is_review = true; + app.hidden_rows.insert(id(6)); + app.selected = Some(3); + complete(&mut app); + + assert!(app.select_review_return(id(2), id(4))); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + assert!(!app.is_row_reachable(0), "hidden descendants are not return candidates"); + assert!(!app.is_row_reachable(3), "review commits are not return candidates"); + assert!(!app.is_row_reachable(4), "unrelated commits are not return candidates"); + app.update(Action::MoveUp); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("review return is missing · j/k select commit · <enter> finish detached · Esc cancel".into()), + "navigation retains the return-selection prompt" + ); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(5)), + "navigation skips ineligible rows" + ); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::FinishReview { + review: id(2), + return_to: Some(id(5)), + }] + ); + assert!(!app.review_return_selection_active()); + + assert!(app.select_review_return(id(2), id(4))); + assert!(app.update(Action::Cancel).is_empty()); + assert!(!app.review_return_selection_active()); + } + + #[test] + fn forgetting_a_non_merge_tip_is_immediate() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + assert!(!app.can_forget(), "loading history cannot forget commits"); + complete(&mut app); + assert!(app.can_forget()); + assert_eq!(app.update(Action::Forget), vec![Effect::Forget(id(2))]); + + let mut merge = App::new(10); + merge.extend_commits(vec![row_with_parents(3, &[2, 1]), row(2), row(1)]); + complete(&mut merge); + assert!(!merge.can_forget(), "merge commits are not forgettable"); + } + + #[test] + fn editing_rejects_merge_descendants_and_new_commits_support_unborn_head() { + let mut app = App::new(10); + app.extend_commits(vec![row(2)]); + app.set_worktree_head(Some(id(2)), false); + complete(&mut app); + app.set_known_descendants(HashSet::from([id(2)])); + app.set_known_merge_descendants(HashSet::from([id(2)])); + assert!( + !app.can_reword(), + "a merge descendant outside the visible projection prevents rewording" + ); + assert!( + !app.can_create_commit(), + "a merge descendant outside the visible projection prevents a child" + ); + assert!(app.can_fork_commit(), "a fork does not rewrite merge descendants"); + assert_eq!(app.update(Action::ForkCommit), vec![Effect::ForkCommit(id(2))]); + + let mut unborn = App::new(10); + unborn.set_worktree_head_unborn(true); + complete(&mut unborn); + assert!( + unborn.can_create_commit(), + "an unborn worktree can create its root commit" + ); + assert_eq!( + unborn.update(Action::NewCommit), + vec![Effect::NewCommit { + parent: None, + empty: false, + }] + ); + assert!(!unborn.can_fork_commit(), "a fork requires a selected parent"); + + let mut unborn_with_history = App::new(10); + unborn_with_history.set_worktree_head_unborn(true); + unborn_with_history.extend_hidden_commits(vec![row(1)]); + complete(&mut unborn_with_history); + unborn_with_history.set_new_commit_availability(Some(&Changes { + has_tracked_changes: true, + ..Changes::default() + })); + assert_eq!( + unborn_with_history.update(Action::NewCommit), + vec![Effect::NewCommit { + parent: Some(id(1)), + empty: false, + }], + "the hidden tip can parent the unborn branch's first commit" + ); + assert!( + !unborn_with_history.can_fork_commit(), + "a fork requires an existing worktree HEAD even when another ref is selected" + ); + } + + #[test] + fn cached_worktree_changes_choose_new_or_new_empty_without_io() { + let mut app = App::new(10); + app.extend_commits(vec![row(1)]); + app.set_worktree_head(Some(id(1)), false); + complete(&mut app); + + app.set_new_commit_availability(Some(&Changes::default())); + assert!( + !app.can_create_commit(), + "a known-clean worktree cannot create an implicit empty commit" + ); + assert!( + app.can_create_empty_commit(), + "an explicit empty commit remains available" + ); + + let tracked = Changes { + has_tracked_changes: true, + ..Changes::default() + }; + app.set_new_commit_availability(Some(&tracked)); + assert!(app.can_create_commit()); + assert!(app.can_create_empty_commit()); + + let conflicted = Changes { + paths: vec![PathChange { + kind: ChangeKind::Unmerged, + group: ChangeGroup::Unstaged, + source: None, + path: "conflict".into(), + lines: None, + }], + has_tracked_changes: true, + ..Changes::default() + }; + app.set_new_commit_availability(Some(&conflicted)); + assert!(!app.can_create_commit()); + assert!(!app.can_create_empty_commit()); + + app.set_new_commit_availability(None); + assert!(app.can_create_commit(), "an absent cache leaves both choices available"); + assert!(app.can_create_empty_commit()); + } + + #[test] + fn time_travel_requires_completed_history_and_a_worktree() { + let mut app = App::new(10); + app.extend_commits(vec![row(1)]); + assert!(app.update(Action::TimeTravel).is_empty()); + complete(&mut app); + assert_eq!(app.update(Action::TimeTravel), vec![Effect::TimeTravel(id(1))]); + app.deferred_history_state = Some(State::Complete); + app.state = State::Computing; + assert!( + app.time_travel_shortcut_visible(), + "the shortcut remains stable during deferred lane computation" + ); + assert!( + app.update(Action::TimeTravel).is_empty(), + "time travel waits for the current lane generation" + ); + app.deferred_history_state = None; + app.state = State::Complete; + app.set_worktree_branch(Some((id(1), true))); + app.set_worktree_head(Some(id(1)), false); + assert!(app.can_attach()); + assert_eq!(app.update(Action::Attach), vec![Effect::Attach]); + app.set_worktree_changes_available(false); + assert!(app.update(Action::TimeTravel).is_empty()); + assert!(app.update(Action::Attach).is_empty()); + assert_eq!(app.update(Action::TogglePin), vec![Effect::TogglePin(id(1))]); + } + + #[test] + fn unresolved_conflicts_disable_time_travel() { + let mut app = App::new(2); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + app.set_worktree_head(Some(id(2)), false); + complete(&mut app); + app.update(Action::MoveDown); + assert!(app.time_travel_shortcut_visible()); + assert!(app.can_fork_commit()); + + app.set_worktree_conflicted(true); + assert!(!app.time_travel_shortcut_visible()); + assert!(!app.can_fork_commit()); + assert!(app.update(Action::TimeTravel).is_empty()); + app.set_worktree_conflicted(false); + assert!(app.changes_visible(), "changes are normally shown while enabled"); + app.arm_rebase_conflict(id(1)); + assert!(!app.time_travel_shortcut_visible()); + assert!(!app.can_fork_commit()); + assert!( + !app.changes_visible(), + "an in-memory conflict preview cannot be loaded by an on-disk changes view" + ); + app.clear_rebase_conflict(); + assert!(app.time_travel_shortcut_visible()); + assert!(app.changes_visible(), "clearing the preview restores the changes view"); + } + + #[test] + fn rebase_conflicts_are_selected_and_centered() { + let mut app = App::new(3); + app.extend_commits((1..=7).rev().map(row).collect::<Vec<_>>()); + complete(&mut app); + + app.arm_rebase_conflict(id(4)); + + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + assert_eq!(app.offset, 2, "the conflict is centered in the three-row viewport"); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("rebase conflict · <enter> checkout for resolution · Esc cancel".into()) + ); + } + + #[test] + fn an_unloaded_rebase_conflict_is_centered_after_refresh() { + let mut app = App::new(3); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + app.arm_rebase_conflict(id(4)); + + let rows = app + .start_refresh( + vec![row_with_parents(5, &[4]), row_with_parents(4, &[2])].into(), + &[id(3), id(5)], + &[], + false, + ) + .expect("the conflict refresh computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + assert_eq!(app.offset, 1, "the newly loaded conflict is centered"); + } + + #[test] + fn lane_computation_keeps_provisional_rows_interactive() { + let mut app = App::new(2); + app.extend_commits(vec![row(1), row(2)]); + + let rows = app + .start_lane_computation() + .expect("history completion starts lane computation"); + assert_eq!(app.state, State::Computing); + assert_eq!(app.rows.len(), 2, "provisional rows remain available to render"); + + app.update(Action::MoveDown); + let selected = app.rows[app.selected.expect("selection remains active")].id; + let (rows, graph, lane_time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, lane_time); + + assert_eq!(app.state, State::Complete); + assert_eq!( + app.rows[app.selected.expect("selection survives final ordering")].id, + selected + ); + } + + #[test] + fn lane_computation_preserves_metadata_loaded_while_it_runs() { + let mut deferred = row(1); + deferred.metadata_loaded = false; + deferred.title.clear(); + let mut app = App::new(1); + app.extend_commits(vec![deferred]); + let rows = app + .start_lane_computation() + .expect("history completion starts lane computation"); + + app.set_metadata( + 0, + Metadata { + author_time: gix::date::Time::new(123, 60), + committer_time: gix::date::Time::new(456, 120), + author: row(1).author, + attributions: 0..0, + title: "loaded".into(), + has_agent_marker: true, + is_review: true, + signature: SignatureState::Verified, + }, + Vec::new(), + ); + let (rows, graph, lane_time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, lane_time); + + assert!(app.rows[0].metadata_loaded); + assert_eq!(app.title(&app.rows[0]), "loaded"); + assert_eq!(app.rows[0].author_time, gix::date::Time::new(123, 60)); + assert_eq!(app.rows[0].committer_time, gix::date::Time::new(456, 120)); + assert!(app.rows[0].has_agent_marker); + assert!(app.rows[0].is_review); + assert_eq!(app.rows[0].signature, SignatureState::Verified); + } + + #[test] + fn verifies_only_visible_unchecked_signatures() { + let mut app = App::new(2); + app.extend_commits(vec![row(1), row(2), row(3)]); + for row in &mut app.rows { + Arc::make_mut(row).signature = SignatureState::Unverified; + } + app.offset = 1; + + assert_eq!( + app.update(Action::VerifySignatures), + vec![Effect::VerifySignatures(vec![id(2), id(3)])] + ); + assert_eq!(app.rows[0].signature, SignatureState::Unverified); + assert_eq!(app.rows[1].signature, SignatureState::Verifying); + app.finish_signature_verification(vec![(id(2), true), (id(3), false)]); + assert_eq!(app.rows[1].signature, SignatureState::Verified); + assert_eq!(app.rows[2].signature, SignatureState::Failed); + assert_eq!(app.signature_failures, 1); + + app.update(Action::MoveDown); + assert_eq!(app.rows[2].signature, SignatureState::Unverified); + assert_eq!(app.signature_failures, 0); + } + + #[test] + fn lane_reuses_a_parent_that_is_already_to_the_right() { + let mut app = App::new(10); + for row in [row_with_parents(4, &[2, 3]), row_with_parents(2, &[3]), row(3)] { + app.extend_commits(vec![row]); + } + + complete(&mut app); + + assert_eq!( + app.render_lanes(0..app.rows.len()).iter().collect::<Vec<_>>(), + ["●─╮ ", "●─╯ ", "● "] + ); + } + + #[test] + fn lanes_render_identically_after_a_checkpoint() { + let mut app = App::new(10); + app.extend_commits( + (0..=300) + .rev() + .map(|n| numbered_row(n, n.checked_sub(1))) + .collect::<Vec<_>>(), + ); + complete(&mut app); + + let all = app.render_lanes(0..app.rows.len()); + let window = app.render_lanes(257..300); + assert_eq!( + window.iter().collect::<Vec<_>>(), + all.iter().skip(257).take(43).collect::<Vec<_>>(), + "restoring a checkpoint produces the same graph as replaying from the beginning" + ); + } + + #[test] + fn completion_keeps_independent_lines_of_history_together() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(5, &[3]), + row_with_parents(4, &[2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + row(1), + ]); + + complete(&mut app); + + assert_eq!( + app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [row(5).id, row(3).id, row(4).id, row(2).id, row(1).id], + "topological order finishes one line before showing another" + ); + } + + #[test] + fn review_selects_only_a_strict_ancestor_before_starting() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(5, &[3]), + row_with_parents(4, &[2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(5)); + app.hidden_rows.insert(id(1)); + app.hidden_rebase_bases.insert(id(1)); + + assert!(app.can_review()); + assert!(app.update(Action::Review).is_empty()); + let tip_index = app.rows.iter().position(|row| row.id == id(5)).expect("tip is present"); + let unrelated = app + .rows + .iter() + .position(|row| row.id == id(4)) + .expect("unrelated row is present"); + assert!(app.is_row_reachable(tip_index)); + assert!(!app.is_row_reachable(unrelated)); + app.update(Action::MoveDown); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("review base · j/k select ancestor · <enter> start · Esc cancel".into()), + "navigation retains the review-base prompt" + ); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(3))); + app.update(Action::MoveDown); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(1))); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::StartReview { + tip: id(5), + base: id(1), + }] + ); + assert!( + app.is_row_reachable(unrelated), + "confirming restores ordinary navigation" + ); + } + + #[test] + fn review_starts_immediately_with_only_one_possible_base() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + app.selected = Some(0); + + assert_eq!( + app.update(Action::Review), + vec![Effect::StartReview { + tip: id(2), + base: id(1), + }], + "the sole strict ancestor needs no separate selection step" + ); + assert!(!app.review_selection_active()); + } + + #[test] + fn squash_selects_a_visible_strict_ancestor_and_can_be_cancelled() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(6, &[1]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(5)); + + assert!(app.can_squash()); + assert!(app.update(Action::Squash).is_empty()); + assert!(app.squash_selection_active()); + app.update(Action::MoveDown); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + app.update(Action::MoveDown); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(3))); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("squash target · j/k select ancestor · <enter> squash · Esc cancel".into()) + ); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::Squash { + source: id(5), + target: id(3), + }] + ); + assert!(!app.squash_selection_active()); + + app.selected = app.rows.iter().position(|row| row.id == id(5)); + assert!(app.update(Action::Squash).is_empty()); + assert!(app.update(Action::Cancel).is_empty()); + assert!(!app.squash_selection_active()); + } + + #[test] + fn squash_starts_immediately_with_one_eligible_target() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(3)); + + assert_eq!( + app.update(Action::Squash), + vec![Effect::Squash { + source: id(3), + target: id(2), + }] + ); + assert!(!app.squash_selection_active()); + + app.hidden_rows.insert(id(2)); + assert!(!app.can_squash(), "hidden commits cannot be squash targets"); + } + + #[test] + fn single_commit_inserts_use_current_head_and_the_selected_target() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + app.set_worktree_head(Some(id(5)), false); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(3)); + + assert!(app.can_copy_insert()); + assert!(app.can_move_insert()); + assert_eq!( + app.update(Action::CopyInsert), + vec![Effect::Insert { + source: id(5), + base: id(5), + target: id(3), + copy: true, + }] + ); + assert_eq!( + app.update(Action::MoveInsert), + vec![Effect::Insert { + source: id(5), + base: id(5), + target: id(3), + copy: false, + }] + ); + + app.selected = app.rows.iter().position(|row| row.id == id(4)); + assert!( + app.can_copy_insert(), + "copying directly above the current parent adds another occurrence" + ); + assert!( + !app.can_move_insert(), + "the current parent is already directly below HEAD" + ); + app.set_known_merge_descendants(HashSet::from([id(3)])); + app.selected = app.rows.iter().position(|row| row.id == id(3)); + assert!(!app.can_move_insert(), "a target with an affected merge is rejected"); + + let mut review_head = row_with_parents(5, &[4]); + review_head.is_review = true; + let mut review_app = App::new(5); + review_app.extend_commits(vec![ + review_head, + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + review_app.set_worktree_head(Some(id(5)), false); + complete(&mut review_app); + review_app.selected = review_app.rows.iter().position(|row| row.id == id(3)); + assert!(!review_app.can_copy_insert(), "review commits cannot be duplicated"); + assert!(review_app.can_move_insert(), "review commits can still be moved"); + + let mut merge_target = App::new(10); + merge_target.extend_commits(vec![ + row_with_parents(5, &[4]), + row(4), + row_with_parents(3, &[2, 1]), + row(2), + row(1), + ]); + merge_target.set_worktree_head(Some(id(5)), false); + complete(&mut merge_target); + merge_target.selected = merge_target.rows.iter().position(|row| row.id == id(3)); + assert!(merge_target.can_move_insert(), "an unchanged merge target is allowed"); + } + + #[test] + fn paste_insert_uses_only_an_editable_history_selection() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + + assert_eq!(app.paste_insert_target(), Some(id(2))); + app.hidden_rows.insert(id(2)); + assert_eq!(app.paste_insert_target(), None, "a hidden boundary is not editable"); + } + + #[test] + fn stack_insert_selects_only_valid_insertion_targets() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(7, &[6]), + row_with_parents(6, &[1]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + app.set_worktree_head(Some(id(5)), false); + app.set_known_merge_descendants(HashSet::from([id(6)])); + complete(&mut app); + app.select_commit(id(3)); + + assert!(app.can_stack_insert()); + assert!(app.update(Action::StackInsert).is_empty()); + assert_eq!( + app.notice().map(|notice| notice.text), + Some("stack-insert target · j/k select insertion point · <enter> insert · Esc cancel".into()) + ); + for (n, reachable) in [ + (7, true), + (6, false), + (5, false), + (4, false), + (3, false), + (2, false), + (1, true), + ] { + let index = app + .rows + .iter() + .position(|row| row.id == id(n)) + .expect("the fixture commit is visible"); + assert_eq!(app.is_row_reachable(index), reachable, "commit {n} target eligibility"); + } + + app.update(Action::MoveDown); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(1))); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::Insert { + source: id(5), + base: id(3), + target: id(1), + copy: false, + }] + ); + + app.select_commit(id(3)); + app.update(Action::StackInsert); + app.update(Action::Cancel); + assert!(app.notice().is_none(), "Escape cancels insertion-target selection"); + assert!( + app.rows + .iter() + .enumerate() + .all(|(index, _)| app.is_row_reachable(index)) + ); + } + + #[test] + fn stack_insert_requires_a_linear_head_ancestry() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(5, &[4]), + row_with_parents(4, &[3, 2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + row(1), + ]); + app.set_worktree_head(Some(id(5)), false); + complete(&mut app); + app.select_commit(id(3)); + + assert!(!app.can_stack_insert()); + assert!(app.update(Action::StackInsert).is_empty()); + assert!(app.notice().is_none()); + } + + #[test] + fn stack_insert_hides_targets_that_would_cycle_through_an_internal_commit() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(8, &[4]), + row_with_parents(7, &[3]), + row_with_parents(6, &[1]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + app.set_worktree_head(Some(id(5)), false); + complete(&mut app); + app.select_commit(id(3)); + + assert!(app.update(Action::StackInsert).is_empty()); + for (commit, expected) in [(8, false), (7, true), (6, true)] { + let index = app + .rows + .iter() + .position(|row| row.id == id(commit)) + .expect("the candidate is visible"); + assert_eq!( + app.is_row_reachable(index), + expected, + "candidate {commit} has the expected cycle safety" + ); + } + } + + #[test] + fn refresh_cancels_stack_insert_selection_before_rows_change() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + app.set_worktree_head(Some(id(5)), false); + complete(&mut app); + app.select_commit(id(3)); + app.update(Action::StackInsert); + assert!(app.stack_insert_base.is_some(), "target selection is active"); + + let rows = app + .start_refresh(vec![row_with_parents(6, &[5])].into(), &[id(6)], &[], false) + .expect("the changed history starts lane computation"); + assert!( + app.stack_insert_base.is_none(), + "refresh cancels the index-based selection" + ); + assert!( + app.reachable_rows.is_none(), + "the stale row mask is discarded immediately" + ); + assert!(app.notice().is_none(), "the cancelled selection no longer prompts"); + + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!(app.rows.first().map(|row| row.id), Some(id(6))); + assert!( + app.rows + .iter() + .enumerate() + .all(|(index, _)| app.is_row_reachable(index)), + "the replacement projection has no stale eligibility mask" + ); + } + + #[test] + fn selection_follows_the_oldest_commit_until_the_user_moves() { + let mut app = App::new(2); + app.extend_commits(vec![row(1), row(2), row(3)]); + + app.update(Action::Last); + assert_eq!(app.selected, Some(2), "Last selects the oldest loaded commit"); + assert_eq!(app.offset, 1, "the selection remains visible"); + + app.extend_commits(vec![row(4)]); + assert_eq!(app.selected, Some(3), "new commits extend the followed tail"); + assert_eq!(app.offset, 2, "the viewport follows the tail"); + + app.update(Action::MoveUp); + app.extend_commits(vec![row(5)]); + assert_eq!(app.selected, Some(2), "manual navigation stops following the tail"); + } + + #[test] + fn startup_selection_follows_the_worktree_head_until_the_user_moves() { + let mut app = App::new(2); + app.set_worktree_head(Some(id(2)), true); + app.extend_commits(vec![row_with_parents(3, &[2])]); + assert_eq!(app.selected, Some(0), "the newest row is selected provisionally"); + assert!( + app.worktree_head_has_descendants(id(2)), + "a streamed child marks HEAD as having visible descendants" + ); + + app.extend_commits(vec![row(2)]); + assert_eq!(app.selected, Some(1), "selection moves to HEAD when its row arrives"); + complete(&mut app); + assert_eq!(app.selected, Some(1), "lane computation retains the HEAD selection"); + + let mut moved = App::new(2); + moved.set_worktree_head(Some(id(2)), true); + moved.extend_commits(vec![row_with_parents(3, &[2])]); + moved.update(Action::MoveDown); + moved.extend_commits(vec![row(2)]); + assert_eq!(moved.selected, Some(0), "navigation cancels the pending jump to HEAD"); + } + + #[test] + fn startup_selection_centers_the_worktree_head_once_the_viewport_is_known() { + let mut app = App::new(1); + app.set_worktree_head(Some(id(4)), true); + app.extend_commits((1..=7).rev().map(row).collect::<Vec<_>>()); + assert_eq!( + app.offset, 3, + "the provisional one-row viewport only keeps HEAD visible" + ); + + app.viewport_rows = 5; + app.center_initial_selection(); + assert_eq!(app.selected, Some(3), "HEAD remains selected"); + assert_eq!(app.offset, 1, "HEAD is centered once the real viewport height is known"); + } + + #[test] + fn startup_head_selection_falls_back_when_head_is_unavailable() { + let mut absent = App::new(2); + absent.set_worktree_head(Some(id(9)), true); + absent.extend_commits(vec![row(3), row(2)]); + complete(&mut absent); + assert_eq!(absent.selected, Some(0), "an absent HEAD retains the newest selection"); + + let mut hidden = App::new(2); + hidden.set_worktree_head(Some(id(2)), true); + hidden.extend_commits(vec![row(3)]); + hidden.extend_hidden_commits(vec![row(2)]); + complete(&mut hidden); + assert_eq!( + hidden.rows[hidden.selected.expect("the boundary HEAD is selected")].id, + id(2), + "a selectable boundary retains the normal startup HEAD selection" + ); + } + + #[test] + fn navigation_is_clamped_and_uses_the_viewport_for_pages() { + let mut app = App::new(2); + app.extend_commits((1..=5).map(row).collect::<Vec<_>>()); + + app.update(Action::PageDown); + assert_eq!(app.selected, Some(2), "page-down advances by the viewport height"); + app.update(Action::PageDown); + assert_eq!(app.selected, Some(4), "page-down clamps at the last row"); + app.update(Action::MoveDown); + assert_eq!(app.selected, Some(4), "moving past the last row is a no-op"); + app.update(Action::First); + assert_eq!(app.selected, Some(0), "First selects the newest commit"); + assert_eq!(app.offset, 0, "the newest commit is visible"); + app.update(Action::MoveDownBy(3)); + assert_eq!( + app.selected, + Some(3), + "batched mouse navigation moves once by its full distance" + ); + app.update(Action::MoveUpBy(2)); + assert_eq!(app.selected, Some(1)); + } + + #[test] + fn topological_navigation_follows_first_parents_and_remembers_children() { + let mut app = App::new(5); + app.extend_commits(vec![ + row_with_parents(5, &[3]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2, 1]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + + app.update(Action::TopologicalDown); + assert_eq!(app.rows[app.selected.expect("the fork is selected")].id, id(3)); + assert_eq!(app.topological_choice(), Some((1, 2))); + app.update(Action::TopologicalUp); + assert_eq!(app.rows[app.selected.expect("the first child is selected")].id, id(5)); + app.update(Action::TopologicalDown); + + app.update(Action::NextChild); + app.update(Action::NextChild); + assert_eq!(app.topological_choice(), Some((2, 2)), "branch choices saturate"); + app.update(Action::TopologicalUp); + assert_eq!(app.rows[app.selected.expect("the second child is selected")].id, id(4)); + app.update(Action::TopologicalDown); + assert_eq!( + app.topological_choice(), + Some((2, 2)), + "returning to a fork restores its choice" + ); + + app.update(Action::PreviousChild); + app.update(Action::TopologicalDown); + assert_eq!( + app.rows[app.selected.expect("the first parent is selected")].id, + id(2), + "secondary merge parents are not topo-walk edges" + ); + app.update(Action::TopologicalDown); + app.update(Action::TopologicalDown); + assert_eq!(app.rows[app.selected.expect("the root remains selected")].id, id(1)); + + app.update(Action::TopologicalUp); + app.update(Action::TopologicalUp); + assert_eq!(app.rows[app.selected.expect("the fork is selected again")].id, id(3)); + app.update(Action::MoveUp); + assert_eq!( + app.rows[app.selected.expect("ordinary navigation is restored")].id, + id(4), + "ordinary movement remains display ordered after topological movement" + ); + } + + #[test] + fn topological_navigation_contracts_unselectable_rows() { + let mut app = App::new(4); + app.extend_commits(vec![ + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + app.reachable_rows = Some(vec![true, false, true, true]); + + app.update(Action::TopologicalDown); + assert_eq!(app.rows[app.selected.expect("the ancestor is selected")].id, id(2)); + app.update(Action::TopologicalUp); + assert_eq!( + app.rows[app.selected.expect("the descendant is selected")].id, + id(4), + "the contracted edge works in both directions" + ); + + app.selected = Some(1); + app.update(Action::TopologicalUp); + assert_eq!( + app.rows[app.selected.expect("the selectable child is selected")].id, + id(4), + "an ineligible current row still anchors its child walk" + ); + } + + #[test] + fn viewport_navigation_pans_without_moving_the_cursor() { + let mut app = App::new(2); + app.extend_commits( + (1..=6) + .rev() + .map(|commit| numbered_row(commit, (commit > 1).then_some(commit - 1))) + .collect::<Vec<_>>(), + ); + + app.update(Action::PanDownBy(3)); + app.prepare_history_viewport(); + assert_eq!((app.selected, app.offset), (Some(0), 3)); + app.update(Action::PanDownBy(app.viewport_rows)); + assert_eq!( + (app.selected, app.offset), + (Some(0), 4), + "page movement clamps the viewport" + ); + app.update(Action::TopologicalDown); + assert_eq!( + (app.selected, app.offset), + (Some(1), 1), + "a topo step brings its destination back into view" + ); + app.update(Action::PanDownBy(app.viewport_rows)); + app.update(Action::PanUpBy((app.viewport_rows / 2).max(1))); + assert_eq!((app.selected, app.offset), (Some(1), 2)); + complete(&mut app); + assert_eq!( + (app.selected, app.offset), + (Some(1), 2), + "lane completion preserves a detached viewport" + ); + } + + #[test] + fn time_travel_selection_crosses_each_viewport_before_paging() { + let mut app = App::new(4); + app.extend_commits((1..=10).map(row).collect::<Vec<_>>()); + complete(&mut app); + + let mut positions = Vec::new(); + for commit in (1u16..=10).rev() { + app.select_commit_for_time_travel(id(commit)); + positions.push((app.selected, app.offset)); + } + + assert_eq!( + positions, + [ + (Some(9), 6), + (Some(8), 6), + (Some(7), 6), + (Some(6), 6), + (Some(5), 2), + (Some(4), 2), + (Some(3), 2), + (Some(2), 2), + (Some(1), 0), + (Some(0), 0), + ], + "selection crosses a full viewport before the next page is bottom-aligned" + ); + } + + #[test] + fn time_travel_animation_returns_to_its_origin() { + let mut app = App::new(4); + app.extend_commits( + (1u16..=10) + .rev() + .map(|commit| numbered_row(commit, (commit > 1).then_some(commit - 1))) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.select_commit(id(6)); + assert_eq!((app.selected, app.offset), (Some(4), 1)); + + app.begin_time_travel_animation(); + app.select_commit_for_time_travel(id(10)); + app.finish_time_travel_animation(); + + assert_eq!( + (app.selected.map(|index| app.rows[index].id), app.offset), + (Some(id(6)), 1), + "the animated cursor returns to the requested entry and viewport row" + ); + + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + let viewport_row = app + .selected_history_index() + .expect("the origin is displayed in compressed history") + .saturating_sub(app.offset); + app.begin_time_travel_animation(); + app.select_commit_for_time_travel(id(10)); + app.finish_time_travel_animation(); + + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(6))); + assert_eq!( + app.selected_history_index() + .expect("the origin remains displayed") + .saturating_sub(app.offset), + viewport_row, + "compressed history restores the origin to its former viewport row" + ); + } + + #[test] + fn time_travel_animation_temporarily_expands_compressed_history() { + let mut app = App::new(4); + app.extend_commits( + (1u16..=10) + .rev() + .map(|commit| numbered_row(commit, (commit > 1).then_some(commit - 1))) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.set_view_tips(&[id(10)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + assert!(app.history_len() < app.rows.len(), "the linear history is compressed"); + + app.begin_time_travel_animation(); + assert_eq!(app.history_len(), app.rows.len(), "animation uses canonical rows"); + let mut offsets = Vec::new(); + for commit in 1..=10 { + app.select_commit_for_time_travel(id(commit)); + offsets.push(app.offset); + } + assert_eq!(offsets, [6, 6, 6, 6, 2, 2, 2, 2, 0, 0]); + app.finish_time_travel_animation(); + + assert_eq!(app.alignment, Alignment::Compressed); + assert!(app.history_len() < app.rows.len(), "compression is restored"); + } + + #[test] + fn compressed_history_retains_tips_and_selection_and_selects_segments() { + let mut app = App::new(2); + app.extend_commits(vec![ + row_with_parents(6, &[5]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.set_view_tips(&[id(6)]); + app.select_commit(id(3)); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + + assert_eq!( + (0..app.history_len()) + .filter_map(|index| app.history_entry(index)) + .collect::<Vec<_>>(), + [ + HistoryEntry::Commit(0), + HistoryEntry::Segment { + representative: 1, + count: 2, + }, + HistoryEntry::Commit(3), + HistoryEntry::Commit(4), + HistoryEntry::Commit(5), + ], + "linear runs collapse only when their summary saves vertical space" + ); + assert_eq!( + app.render_lanes(0..app.history_len()).iter().collect::<Vec<_>>(), + ["● ", "○ ", "● ", "● ", "● "], + "only segments use the quieter ring marker" + ); + + app.update(Action::MoveUp); + assert!(app.selected_is_segment(), "navigation stops on a segment summary"); + app.update(Action::MoveDown); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(3)), + "navigation returns from a summary to its adjacent retained commit" + ); + + app.select_commit(id(4)); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + assert!( + app.selected_history_index().is_some(), + "a programmatic jump rebuilds compression around the destination" + ); + } + + #[test] + fn topological_navigation_peels_and_selects_commits_towards_segments() { + let mut app = compressed_linear_app(); + let compressed_len = app.history_len(); + assert!(compressed_len < app.rows.len()); + + app.update(Action::TopologicalDown); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(5)), + "moving rootward exposes and selects the segment's connected commit" + ); + assert_eq!( + app.history_len(), + compressed_len + 1, + "one topological step exposes exactly one commit" + ); + app.update(Action::TopologicalDown); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(4)), + "repeating the movement peels the next connected commit" + ); + let selected = app.selected_history_index().expect("the peeled commit is displayed"); + assert!( + (app.offset..app.offset + app.viewport_rows).contains(&selected), + "the peeled selection remains inside the viewport" + ); + + let mut app = compressed_linear_app(); + app.update(Action::Last); + app.update(Action::TopologicalUp); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(2)), + "moving leafward exposes and selects the opposite segment boundary" + ); + } + + #[test] + fn topological_navigation_peels_while_retaining_a_segment_selection() { + let mut app = compressed_linear_app(); + app.update(Action::MoveDown); + assert!(app.selected_is_segment(), "ordinary movement selects the summary"); + + for peeled in [2, 3] { + app.update(Action::TopologicalDown); + assert!(app.selected_is_segment(), "the remaining summary stays selected"); + let canonical = app + .rows + .iter() + .position(|row| row.id == id(peeled)) + .expect("the peeled commit exists"); + assert!( + app.history_index(canonical).is_some(), + "the rootward boundary commit is exposed" + ); + } + app.update(Action::TopologicalDown); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(5)), + "the remaining singleton replaces the vanished summary selection" + ); + + let mut app = compressed_linear_app(); + app.update(Action::MoveDown); + app.update(Action::TopologicalUp); + assert!( + app.selected_is_segment(), + "the summary remains selected after a leafward peel" + ); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + None, + "the exposed leafward commit is not selected" + ); + let peeled = app + .rows + .iter() + .position(|row| row.id == id(5)) + .expect("the peeled commit exists"); + assert!( + app.history_index(peeled).is_some(), + "the leafward boundary commit is exposed" + ); + } + + #[test] + fn topological_peeling_does_not_select_ineligible_segment_members() { + let mut app = compressed_linear_app(); + app.reachable_rows = Some(vec![true, false, false, true, false, true]); + + for peeled in [5, 4] { + app.update(Action::TopologicalDown); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(6)), + "an ineligible boundary leaves the current eligible commit selected" + ); + let canonical = app + .rows + .iter() + .position(|row| row.id == id(peeled)) + .expect("the peeled commit exists"); + assert!( + app.history_index(canonical).is_some(), + "the ineligible boundary is still exposed one commit at a time" + ); } + app.update(Action::TopologicalDown); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(3)), + "the first eligible peeled commit becomes selected" + ); } - fn row_with_parents(n: u8, parents: &[u8]) -> LoadedCommit { - let mut commit = row(n); - commit.parent_ids = parents.iter().map(|n| row(*n).id).collect(); - commit + #[test] + fn compressed_mode_transitions_keep_the_selected_viewport_row() { + let mut app = App::new(4); + app.extend_commits( + (0..=11) + .rev() + .map(|n| numbered_row(n, n.checked_sub(1))) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.set_view_tips(&[id(11)]); + app.select_commit(id(5)); + app.offset = 4; + let viewport_row = app.selected_history_index().expect("the commit is selected") - app.offset; + + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + assert_eq!( + app.selected_history_index().map(|selected| selected - app.offset), + Some(viewport_row), + "entering compressed mode keeps the selected commit on screen" + ); + + app.update(Action::MoveDown); + assert!(app.selected_is_segment(), "the lower compressed segment is selected"); + app.offset = 1; + let viewport_row = app.selected_history_index().expect("the segment is selected") - app.offset; + app.update(Action::ToggleAlign); + assert_eq!( + app.selected_history_index().map(|selected| selected - app.offset), + Some(viewport_row), + "leaving compressed mode keeps its representative on the segment's screen row" + ); } #[test] - fn recognizes_named_agents_only_when_assisting() { - let opus = Box::leak(Box::new(Author { - name: b"Opus 4.7".as_bstr(), - email: b"".as_bstr(), - })); - let gpt = Box::leak(Box::new(Author { - name: b"GPT 5.6".as_bstr(), - email: b"".as_bstr(), - })); + fn compressed_history_keeps_ancestor_tips_and_merge_topology() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(7, &[6, 5]), + row_with_parents(6, &[4]), + row_with_parents(5, &[3]), + row_with_parents(4, &[2]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.set_view_tips(&[id(7), id(2)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + let entries: Vec<_> = (0..app.history_len()) + .filter_map(|index| app.history_entry(index)) + .collect(); + assert_eq!(entries.first(), Some(&HistoryEntry::Commit(0))); assert!( - Attribution { - kind: AttributionKind::Assisted, - author: opus, - } - .is_agent() + entries.contains(&HistoryEntry::Commit(5)), + "an explicit ancestor tip remains visible even though it is not a graph leaf" ); - assert!( - Attribution { - kind: AttributionKind::Assisted, - author: gpt, - } - .is_agent() + assert_eq!( + entries + .iter() + .filter_map(|entry| match entry { + HistoryEntry::Segment { count, .. } => Some(*count), + HistoryEntry::Commit(_) => None, + }) + .sum::<usize>(), + 4, + "every non-anchor commit is represented exactly once" ); + let lanes = app.render_lanes(0..app.history_len()); assert!( - !Attribution { - kind: AttributionKind::Reviewed, - author: opus, - } - .is_agent() + lanes + .iter() + .any(|lane| lane.contains('╮') || lane.contains('╯') || lane.contains('─')), + "the quotient graph preserves merge branches" ); } - fn numbered_row(n: u16, parent: Option<u16>) -> LoadedCommit { - let mut commit = row(0); - commit.id = id(n); - commit.parent_ids = parent.map(id).into_iter().collect(); - commit.title = format!("commit {n}").into(); - commit - } + #[test] + fn compressed_history_stays_active_for_target_selection() { + let mut app = App::new(2); + app.extend_commits(vec![ + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.set_view_tips(&[id(4)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + assert_eq!(app.history_len(), 3); - fn complete(app: &mut App) { - let rows = app - .start_lane_computation() - .expect("a loading app starts lane computation"); - let (rows, graph, lane_time) = compute_lanes(rows); - app.finish_lane_computation(rows, graph, lane_time); + app.reachability_anchor = Some(id(4)); + app.compute_reachable_rows(); + assert_eq!( + app.history_len(), + 3, + "target selection retains the compressed projection" + ); + app.update(Action::MoveDown); + assert!(app.selected_is_segment(), "an eligible target segment is selectable"); + app.update(Action::OpenDiff); + assert_eq!(app.history_len(), app.rows.len(), "Enter expands the eligible segment"); + app.clear_reachability_selection(); + assert_eq!( + app.history_len(), + app.rows.len(), + "expanded segments remain open for the compressed cycle" + ); } #[test] - fn completion_orders_and_draws_merge_lanes() { + fn compressed_history_keeps_graph_junctions_and_endpoints_as_commits() { let mut app = App::new(10); app.extend_commits(vec![ - row_with_parents(4, &[3, 2]), - row_with_parents(3, &[1]), - row(1), + row_with_parents(6, &[4]), + row_with_parents(5, &[3]), + row_with_parents(4, &[2]), + row_with_parents(3, &[2]), row_with_parents(2, &[1]), + row(1), ]); + app.set_worktree_head(Some(id(6)), false); + complete(&mut app); + app.set_view_tips(&[id(6), id(5)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + for (n, description) in [ + (6, "the current graph tip"), + (5, "the other graph tip"), + (2, "the shared merge base"), + (1, "the graph root"), + ] { + let canonical = app + .rows + .iter() + .position(|row| row.id == id(n)) + .expect("the structural commit is present"); + let display = app + .history_index(canonical) + .expect("structural commits have their own display row"); + assert_eq!( + app.history_entry(display), + Some(HistoryEntry::Commit(canonical)), + "{description} remains a full commit" + ); + } + + app.select_commit(id(2)); + assert!( + app.can_copy_insert(), + "a retained merge base is an ordinary insertion target" + ); assert_eq!( - app.estimated_lane_width, 4, - "the provisional and rendered graph widths use the same trailing separator" + app.update(Action::CopyInsert), + vec![Effect::Insert { + source: id(6), + base: id(6), + target: id(2), + copy: true, + }] ); + } + #[test] + fn compressed_history_keeps_the_first_hidden_boundary_as_a_commit() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1])]); + app.extend_hidden_commits(vec![row_with_parents(1, &[0])]); complete(&mut app); + app.set_view_tips(&[id(3)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); - assert_eq!( - app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), - [row(4).id, row(3).id, row(2).id, row(1).id] + let boundary = app + .rows + .iter() + .position(|row| row.id == id(1)) + .expect("the hidden boundary is present"); + assert!(app.is_row_hidden(boundary)); + let display = app + .history_index(boundary) + .expect("the hidden boundary has its own display row"); + assert_eq!(app.history_entry(display), Some(HistoryEntry::Commit(boundary))); + + app.select_commit(id(1)); + assert!( + app.can_rebase(), + "the retained boundary keeps its existing base actions" ); assert_eq!( - app.render_lanes(0..app.rows.len()).iter().collect::<Vec<_>>(), - ["●─┐ ", "● │ ", "├─● ", "● "] + app.update(Action::Rebase), + vec![Effect::Rebase { + base: id(1), + onto: id(1), + commits: vec![id(3), id(2)], + }] ); } #[test] - fn lane_computation_keeps_provisional_rows_interactive() { - let mut app = App::new(2); - app.extend_commits(vec![row(1), row(2)]); - - let rows = app - .start_lane_computation() - .expect("history completion starts lane computation"); - assert_eq!(app.state, State::Computing); - assert_eq!(app.rows.len(), 2, "provisional rows remain available to render"); + fn compressed_segments_are_selectable_and_expand_in_place() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(7, &[6]), + row_with_parents(6, &[5]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + let ordinary_lanes = app + .render_lanes(0..app.rows.len()) + .iter() + .map(str::to_owned) + .collect::<Vec<_>>(); + app.set_view_tips(&[id(7)]); + app.select_commit(id(4)); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); - app.update(Action::MoveDown); - let selected = app.rows[app.selected.expect("selection remains active")].id; - let (rows, graph, lane_time) = compute_lanes(rows); - app.finish_lane_computation(rows, graph, lane_time); + app.update(Action::MoveUp); + assert!(app.selected_is_segment(), "navigation stops on the upper summary"); + assert_eq!(app.selected_history_index(), Some(1)); + app.set_worktree_head_unborn(true); + for action in [Action::ToggleActions, Action::ToggleEnrich] { + app.update(action); + } + assert!(!app.actions_expanded && !app.enrich_expanded); + assert!( + app.update(Action::NewEmptyCommit).is_empty(), + "a synthetic selection is not mistaken for an unborn HEAD" + ); + assert!( + app.update(Action::OpenDiff).is_empty(), + "Enter expands instead of diffing a summary" + ); + assert_eq!( + app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id), + Some(id(6)), + "expansion selects the newest commit represented by the summary" + ); + assert!( + (0..app.history_len()).any(|index| matches!(app.history_entry(index), Some(HistoryEntry::Segment { .. }))), + "expanding one summary leaves the other summary compressed" + ); - assert_eq!(app.state, State::Complete); + app.update(Action::Last); + app.update(Action::MoveUp); + assert!(app.selected_is_segment(), "navigation stops on the remaining summary"); + assert!(app.update(Action::OpenDiff).is_empty()); assert_eq!( - app.rows[app.selected.expect("selection survives final ordering")].id, - selected + (0..app.history_len()) + .filter_map(|index| app.history_entry(index)) + .collect::<Vec<_>>(), + (0..app.rows.len()).map(HistoryEntry::Commit).collect::<Vec<_>>(), + "expanding every summary produces the ordinary row sequence" + ); + assert_eq!( + app.render_lanes(0..app.history_len()) + .iter() + .map(str::to_owned) + .collect::<Vec<_>>(), + ordinary_lanes, + "fully expanded compression uses the ordinary graph" + ); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenCommitDiff(TreeDiffTarget::Commit { id: id(3), parent: 0 })], + "Enter resumes its normal commit behavior after expansion" ); } #[test] - fn lane_computation_preserves_metadata_loaded_while_it_runs() { - let mut deferred = row(1); - deferred.metadata_loaded = false; - deferred.title.clear(); - let mut app = App::new(1); - app.extend_commits(vec![deferred]); - let rows = app - .start_lane_computation() - .expect("history completion starts lane computation"); + fn compressed_modal_navigation_only_selects_segments_with_eligible_members() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(6, &[5]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.set_view_tips(&[id(6)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); - app.set_metadata( - 0, - Metadata { - committer_time: gix::date::Time::default(), - author: row(1).author, - attributions: 0..0, - title: "loaded".into(), - }, - Vec::new(), + app.viewport_rows = 10; + app.reachable_rows = Some(vec![false, false, false, true, false, false]); + app.update(Action::PageDown); + assert!( + app.selected_is_segment(), + "paged navigation reaches a nearby eligible segment" + ); + app.reachable_rows = Some(vec![false; 6]); + app.update(Action::OpenDiff); + assert!( + app.selected_is_segment(), + "an ineligible segment keeps its selection instead of expanding without a target" ); - let (rows, graph, lane_time) = compute_lanes(rows); - app.finish_lane_computation(rows, graph, lane_time); - assert!(app.rows[0].metadata_loaded); - assert_eq!(app.title(&app.rows[0]), "loaded"); + app.select_commit(id(6)); + app.reachable_rows = Some(vec![false, false, false, false, false, true]); + app.update(Action::MoveDown); + assert_eq!( + app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id), + Some(id(1)), + "navigation skips a segment without an eligible target" + ); + + app.select_commit(id(6)); + app.reachable_rows = Some(vec![false, false, false, true, false, false]); + app.update(Action::MoveDown); + assert!( + app.selected_is_segment(), + "a segment containing an eligible target is selectable" + ); + app.update(Action::OpenDiff); + assert_eq!( + app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id), + Some(id(3)), + "expansion selects the newest eligible member" + ); } #[test] - fn lane_reuses_a_parent_that_is_already_to_the_right() { + fn compressed_selection_and_expansions_survive_in_place_refreshes() { + let commits = || { + vec![ + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ] + }; + let refresh = |app: &mut App| { + let rows = app + .start_refresh(commits().into(), &[id(5)], &[], false) + .expect("the in-place refresh computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + }; let mut app = App::new(10); - for row in [row_with_parents(4, &[2, 3]), row_with_parents(2, &[3]), row(3)] { - app.extend_commits(vec![row]); - } + app.extend_commits(commits()); + complete(&mut app); + app.set_view_tips(&[id(5)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + app.update(Action::MoveDown); + refresh(&mut app); + assert!(app.selected_is_segment(), "a synthetic selection survives by object ID"); + app.update(Action::OpenDiff); + refresh(&mut app); + assert_eq!( + (0..app.history_len()) + .filter_map(|index| app.history_entry(index)) + .collect::<Vec<_>>(), + (0..app.rows.len()).map(HistoryEntry::Commit).collect::<Vec<_>>(), + "expanded members survive an in-place refresh" + ); + } + + #[test] + fn suspending_compression_materializes_a_segment_selection() { + let mut app = App::new(2); + app.extend_commits(vec![ + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ]); complete(&mut app); + app.set_view_tips(&[id(5)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + app.update(Action::MoveDown); + assert!(app.selected_is_segment()); + app.set_worktree_conflicted(true); assert_eq!( - app.render_lanes(0..app.rows.len()).iter().collect::<Vec<_>>(), - ["●─┐ ", "●─┘ ", "● "] + app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id), + Some(id(4)), + "the segment representative becomes the full-history selection" + ); + app.update(Action::MoveDown); + assert_eq!( + app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id), + Some(id(3)) ); } #[test] - fn lanes_render_identically_after_a_checkpoint() { + fn compressed_expansions_reset_on_mode_exit_and_full_reload() { + let commits = || { + vec![ + row_with_parents(6, &[5]), + row_with_parents(5, &[4]), + row_with_parents(4, &[3]), + row_with_parents(3, &[2]), + row_with_parents(2, &[1]), + row(1), + ] + }; + let has_segment = |app: &App| { + (0..app.history_len()).any(|index| matches!(app.history_entry(index), Some(HistoryEntry::Segment { .. }))) + }; + let mut app = App::new(10); - app.extend_commits( - (0..=300) - .rev() - .map(|n| numbered_row(n, n.checked_sub(1))) - .collect::<Vec<_>>(), + app.extend_commits(commits()); + complete(&mut app); + app.set_view_tips(&[id(6)]); + app.alignment = Alignment::None; + app.update(Action::ToggleAlign); + app.update(Action::MoveDown); + assert!(app.selected_is_segment()); + app.update(Action::OpenDiff); + assert!(!has_segment(&app), "the only linear summary is expanded"); + + app.update(Action::ToggleAlign); + app.update(Action::ToggleAlign); + app.update(Action::ToggleAlign); + app.update(Action::ToggleAlign); + assert_eq!(app.alignment, Alignment::Compressed); + assert!(has_segment(&app), "leaving compressed mode discards expansions"); + + app.update(Action::MoveDown); + if !app.selected_is_segment() { + app.update(Action::MoveDown); + } + assert!(app.selected_is_segment(), "a summary is available to expand again"); + app.update(Action::OpenDiff); + app.reload(false); + app.set_view_tips(&[id(6)]); + app.extend_commits(commits()); + complete(&mut app); + assert_eq!(app.alignment, Alignment::Compressed); + assert!( + has_segment(&app), + "a full reload discards expansions even for unchanged commit IDs" ); + } + + #[test] + fn hidden_boundary_rows_are_selectable_for_inspection_and_independent_edits() { + let mut app = App::new(4); + app.extend_commits(vec![row(1), row(2), row(3)]); + app.extend_hidden_commits(vec![row(4)]); + app.set_worktree_head(Some(id(1)), false); complete(&mut app); + Arc::make_mut(&mut app.rows[3]).signature = SignatureState::Unverified; - let all = app.render_lanes(0..app.rows.len()); - let window = app.render_lanes(257..300); - assert_eq!( - window.iter().collect::<Vec<_>>(), - all.iter().skip(257).take(43).collect::<Vec<_>>(), - "restoring a checkpoint produces the same graph as replaying from the beginning" + app.update(Action::First); + app.update(Action::PageDown); + assert_eq!(app.selected, Some(3), "paging can select the hidden boundary"); + assert_eq!(app.update(Action::Copy), vec![Effect::CopyId(id(4))]); + assert!(!app.can_reword()); + assert!(!app.can_forget()); + assert!(app.can_fork_commit()); + assert_eq!(app.update(Action::ForkCommit), vec![Effect::ForkCommit(id(4))]); + assert_eq!(app.update(Action::TimeTravel), vec![Effect::TimeTravel(id(4))]); + assert!( + app.update(Action::VerifySignatures).is_empty(), + "hidden signatures are not actionable" ); } #[test] - fn completion_keeps_independent_lines_of_history_together() { - let mut app = App::new(10); - app.extend_commits(vec![ - row_with_parents(5, &[3]), - row_with_parents(4, &[2]), - row_with_parents(3, &[1]), - row_with_parents(2, &[1]), - row(1), - ]); - - complete(&mut app); + fn hidden_boundary_branch_diffs_require_one_descendant_leaf() { + let target = |visible: Vec<LoadedCommit>| { + let mut app = App::new(10); + app.extend_commits(visible); + app.extend_hidden_commits(vec![row(1)]); + complete(&mut app); + app.select_commit(id(1)); + app.selected_tree_diff_target() + }; assert_eq!( - app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), - [row(5).id, row(3).id, row(4).id, row(2).id, row(1).id], - "topological order finishes one line before showing another" + target(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1])]), + Some(TreeDiffTarget::Branch { + base: id(1), + tip: id(3), + }), + "a linear branch compares its boundary to its only leaf" + ); + assert_eq!( + target(vec![ + row_with_parents(4, &[2, 3]), + row_with_parents(2, &[1]), + row_with_parents(3, &[1]), + ]), + Some(TreeDiffTarget::Branch { + base: id(1), + tip: id(4), + }), + "forks which merge again still have one leaf" + ); + assert_eq!( + target(vec![row_with_parents(2, &[1]), row_with_parents(3, &[1])]), + Some(TreeDiffTarget::Commit { id: id(1), parent: 0 }), + "multiple leaves retain the boundary commit's ordinary diff" ); } #[test] - fn selection_follows_the_oldest_commit_until_the_user_moves() { - let mut app = App::new(2); - app.extend_commits(vec![row(1), row(2), row(3)]); - - app.update(Action::Last); - assert_eq!(app.selected, Some(2), "Last selects the oldest loaded commit"); - assert_eq!(app.offset, 1, "the selection remains visible"); + fn hidden_boundary_rebase_accepts_forks_but_rejects_descendant_merges() { + let eligible = |visible: Vec<LoadedCommit>| { + let mut app = App::new(10); + app.extend_commits(visible); + app.extend_hidden_commits(vec![row(1)]); + complete(&mut app); + app.select_commit(id(1)); + app + }; - app.extend_commits(vec![row(4)]); - assert_eq!(app.selected, Some(3), "new commits extend the followed tail"); - assert_eq!(app.offset, 2, "the viewport follows the tail"); + let mut fork = eligible(vec![row_with_parents(3, &[1]), row_with_parents(2, &[1])]); + assert!(fork.can_rebase(), "forked linear stacks can be edited together"); + assert_eq!( + fork.update(Action::Rebase), + vec![Effect::Rebase { + base: id(1), + onto: id(1), + commits: vec![id(3), id(2)], + }], + "the todo receives all visible descendants" + ); + assert!(!fork.can_rebase_update(), "there is no newer hidden branch tip yet"); + fork.set_hidden_branch_updates(HashMap::from([(id(1), (2, id(4)))])); + assert!( + fork.can_rebase_update(), + "a hidden branch ahead of the base can be used" + ); + assert_eq!( + fork.update(Action::RebaseUpdate), + vec![Effect::Rebase { + base: id(1), + onto: id(4), + commits: vec![id(3), id(2)], + }], + "rebase-update retains the editable scope but changes its root" + ); - app.update(Action::MoveUp); - app.extend_commits(vec![row(5)]); - assert_eq!(app.selected, Some(2), "manual navigation stops following the tail"); + let merged = eligible(vec![ + row_with_parents(4, &[3, 2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + ]); + assert!(!merged.can_rebase(), "a merge across editable descendants is rejected"); } #[test] - fn navigation_is_clamped_and_uses_the_viewport_for_pages() { + fn full_pages_target_changes_then_commit_messages_then_history() { let mut app = App::new(2); app.extend_commits((1..=5).map(row).collect::<Vec<_>>()); + app.show_commit = true; + app.set_commit_bounds(3, 7); app.update(Action::PageDown); - assert_eq!(app.selected, Some(2), "page-down advances by the viewport height"); + assert_eq!(app.commit_offset, 3); + assert_eq!(app.selected, Some(0), "commit paging leaves history selection alone"); app.update(Action::PageDown); - assert_eq!(app.selected, Some(4), "page-down clamps at the last row"); - app.update(Action::MoveDown); - assert_eq!(app.selected, Some(4), "moving past the last row is a no-op"); - app.update(Action::First); - assert_eq!(app.selected, Some(0), "First selects the newest commit"); - assert_eq!(app.offset, 0, "the newest commit is visible"); + assert_eq!(app.commit_offset, 6); + + app.changes_focus = Some(ChangePane::Tree); + app.set_changes_bounds(ChangePane::Tree, 2, 5, None, 1, 0); + app.update(Action::PageDown); + assert_eq!(app.tree_changes.selected, 2, "focused changes retain paging priority"); + assert_eq!(app.commit_offset, 6); + + app.changes_focus = None; + app.set_commit_bounds(3, 0); + app.update(Action::PageDown); + assert_eq!(app.selected, Some(2), "history paging resumes when the commit fits"); } #[test] @@ -1085,32 +6433,383 @@ mod tests { assert_eq!(app.horizontal_offset, 0, "scrolling is disabled when content fits"); } + #[test] + fn focused_changes_redirect_navigation_to_the_path_viewport() { + let mut app = App::new(2); + app.extend_commits((1..=3).map(row).collect::<Vec<_>>()); + app.set_changes_bounds(ChangePane::Tree, 4, 10, None, 20, 45); + show_tree_changes(&mut app); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Tree)); + assert_eq!(app.focus_feedback.take(), Some("tree changes")); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, None); + assert_eq!(app.focus_feedback.take(), Some("history")); + app.update(Action::ToggleChangesFocus); + + app.update(Action::MoveDown); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (1, 0)); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenDiff(ChangePane::Tree, 1)] + ); + assert_eq!( + app.selected, + Some(0), + "path selection leaves commit selection untouched" + ); + app.update(Action::PageDown); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (5, 2)); + app.update(Action::HalfPageDown); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (7, 4)); + app.update(Action::Last); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (9, 6)); + app.update(Action::First); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (0, 0)); + + app.update(Action::ScrollRight); + app.update(Action::ScrollRight); + app.update(Action::ScrollRight); + assert_eq!(app.tree_changes.horizontal_offset, 45); + assert_eq!(app.horizontal_offset, 0, "path panning leaves the graph untouched"); + app.update(Action::ScrollLeft); + assert_eq!(app.tree_changes.horizontal_offset, 25); + + app.update(Action::ToggleChanges); + app.update(Action::ToggleChanges); + assert_eq!(app.changes_focus, None, "closing the panel returns focus to history"); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenCommitDiff(TreeDiffTarget::Commit { id: id(1), parent: 0 })] + ); + assert_eq!(app.tree_changes.selected, 0); + assert_eq!(app.tree_changes.offset, 0); + assert_eq!(app.tree_changes.horizontal_offset, 0); + } + #[test] fn toggles_metadata_columns() { let mut app = App::new(1); assert!(app.show_trailers, "trailer attribution is visible by default"); + assert_eq!( + app.changes_mode, + Some(ChangesMode::Both), + "tree and worktree changes are visible by default" + ); app.update(Action::ToggleDate); + app.update(Action::ToggleEmail); app.update(Action::ToggleName); app.update(Action::ToggleTrailers); app.update(Action::ToggleMailmap); - app.update(Action::ToggleRefs); + app.update(Action::CycleRefs); app.update(Action::ToggleAlign); app.update(Action::ToggleCommit); + app.update(Action::CycleChangesParent); + app.update(Action::ToggleChanges); - assert!(!app.show_committer_date); + assert_eq!(app.date_mode, DateMode::Committer); + app.update(Action::ToggleDate); + assert_eq!(app.date_mode, DateMode::None); + app.update(Action::ToggleDate); + assert_eq!(app.date_mode, DateMode::Author); + assert_eq!(app.id_mode, IdMode::Off); + app.update(Action::CycleIds); + assert_eq!(app.id_mode, IdMode::Commit); + app.update(Action::CycleIds); + assert_eq!(app.id_mode, IdMode::Change); + app.update(Action::CycleIds); + assert_eq!(app.id_mode, IdMode::Off); + app.set_change_ids(HashMap::new(), HashSet::from([id(1), id(2)])); + assert_eq!( + app.effective_id_mode(), + IdMode::Change, + "off automatically reveals duplicate change IDs" + ); + app.update(Action::CycleIds); + assert_eq!( + app.effective_id_mode(), + IdMode::Commit, + "an explicit commit-ID mode overrides automatic change IDs" + ); + assert!(app.show_emails); assert_eq!(app.name_mode, NameMode::None); assert!(!app.show_trailers); assert!(!app.use_mailmap); assert_eq!(app.ref_mode, RefMode::None); + app.update(Action::CycleRefs); + assert_eq!(app.ref_mode, RefMode::All); + app.update(Action::CycleRefs); + assert_eq!(app.ref_mode, RefMode::Default); + assert_eq!(app.alignment, Alignment::Columns); + assert!(app.show_commit); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + assert_eq!(app.changes_parent, 0); + app.update(Action::ToggleAlign); + assert_eq!(app.alignment, Alignment::None); + app.update(Action::ToggleAlign); + assert_eq!(app.alignment, Alignment::Compressed); + app.update(Action::ToggleAlign); + assert_eq!(app.alignment, Alignment::Title); + } + + #[test] + fn reference_visibility_toggle_restores_the_mode_it_hid() { + let mut app = App::new(1); + app.update(Action::CycleRefs); + app.update(Action::CycleRefs); + assert_eq!(app.ref_mode, RefMode::All); + app.update(Action::ToggleRefs); + assert_eq!(app.ref_mode, RefMode::None); app.update(Action::ToggleRefs); assert_eq!(app.ref_mode, RefMode::All); + app.update(Action::CycleRefs); + assert_eq!(app.ref_mode, RefMode::Default); + app.update(Action::ToggleRefs); app.update(Action::ToggleRefs); assert_eq!(app.ref_mode, RefMode::Default); - assert!(!app.align_metadata); - assert!(app.show_commit); + } + + #[test] + fn history_display_group_stays_open_only_for_grouped_actions() { + let mut app = App::new(1); + + app.update(Action::ToggleHistoryDisplay); + assert!(app.history_display_expanded); + app.update(Action::ToggleDate); + app.update(Action::ToggleEmail); + assert!( + app.history_display_expanded, + "grouped display changes keep the group open" + ); + + app.update(Action::MoveDown); + assert!(!app.history_display_expanded, "navigation collapses the group"); + + app.update(Action::ToggleHistoryDisplay); app.update(Action::ToggleAlign); - assert!(app.align_metadata); + assert!( + !app.history_display_expanded, + "direct display commands also collapse the group" + ); + + app.update(Action::ToggleHistoryDisplay); + app.update(Action::ToggleHistoryDisplay); + assert!(!app.history_display_expanded, "the prefix key toggles the group"); + } + + #[test] + fn actions_group_stays_open_only_for_its_actions() { + let mut app = App::new(1); + + app.update(Action::ToggleActions); + assert!(app.actions_expanded); + app.update(Action::Reword); + app.update(Action::NewCommit); + app.update(Action::Forget); + app.update(Action::TimeTravel); + app.update(Action::Rebase); + app.update(Action::RebaseUpdate); + app.update(Action::Review); + app.update(Action::Squash); + app.update(Action::CopyInsert); + app.update(Action::MoveInsert); + app.update(Action::StackInsert); + app.update(Action::Stash); + app.update(Action::ForkCommit); + app.update(Action::Attach); + assert!(app.actions_expanded, "all grouped actions keep the group open"); + + app.update(Action::MoveDown); + assert!(!app.actions_expanded, "navigation collapses the group"); + + app.update(Action::ToggleActions); + app.update(Action::ToggleHistoryDisplay); + assert!(!app.actions_expanded, "opening the view group closes actions"); + assert!(app.history_display_expanded); + + app.update(Action::ToggleActions); + assert!(app.actions_expanded); + assert!(!app.history_display_expanded, "opening actions closes the view group"); + app.update(Action::ToggleActions); + assert!(!app.actions_expanded, "the prefix key toggles the group"); + } + + #[test] + fn enrich_group_keeps_git_notes_available_on_immutable_commits() { + let mut app = App::new(1); + app.extend_commits(vec![row(1)]); + complete(&mut app); + + app.update(Action::ToggleEnrich); + assert!(app.enrich_expanded); + assert_eq!( + app.update(Action::ToggleTodo), + vec![Effect::ToggleTodo(id(1))], + "todo reuses reword eligibility" + ); + assert_eq!( + app.update(Action::EditNote), + vec![Effect::EditNote(id(1))], + "note uses the same eligibility" + ); + assert_eq!( + app.update(Action::EditGitNote), + vec![Effect::EditGitNote(id(1))], + "Git notes are available on every selection" + ); + assert_eq!( + app.update(Action::ToggleChecksPass), + vec![Effect::ToggleChecksPass(id(1))], + "tree enrichments are available on every selection" + ); + assert!(app.enrich_expanded, "the grouped actions keep enrich open"); + + app.update(Action::MoveDown); + assert!(!app.enrich_expanded, "navigation closes enrich"); + app.update(Action::ToggleEnrich); + app.update(Action::ToggleActions); + assert!(!app.enrich_expanded, "opening actions closes enrich"); + + app.hidden_rows.insert(id(1)); + app.update(Action::ToggleEnrich); + assert!( + app.update(Action::ToggleTodo).is_empty(), + "hidden boundaries are immutable" + ); + assert!(app.update(Action::EditNote).is_empty()); + assert_eq!( + app.update(Action::EditGitNote), + vec![Effect::EditGitNote(id(1))], + "immutable boundaries still accept Git notes" + ); + assert_eq!( + app.update(Action::ToggleChecksPass), + vec![Effect::ToggleChecksPass(id(1))], + "immutable boundaries still accept tree enrichments" + ); + } + + #[test] + fn information_group_keeps_direct_actions_and_excludes_other_prefixes() { + let mut app = App::new(1); + + app.update(Action::ToggleInformation); + assert!(app.information_expanded); + for action in [ + Action::ToggleAlign, + Action::ToggleCommit, + Action::ToggleChanges, + Action::VerifySignatures, + Action::ToggleRefTree, + ] { + app.update(action); + assert!(app.information_expanded, "information actions keep the group open"); + } + + app.update(Action::MoveDown); + assert!(!app.information_expanded, "navigation collapses the group"); + + app.update(Action::ToggleInformation); + app.update(Action::ToggleHistoryDisplay); + assert!(!app.information_expanded, "opening view closes information"); + app.update(Action::ToggleInformation); + assert!(!app.history_display_expanded, "opening information closes view"); + app.update(Action::ToggleActions); + assert!(!app.information_expanded, "opening actions closes information"); + } + + #[test] + fn cycles_both_tree_and_hidden_changes() { + let mut app = App::new(1); + assert_eq!(app.changes_mode, Some(ChangesMode::Both)); + + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + + app.changes_focus = Some(ChangePane::Tree); + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, None); + assert_eq!(app.changes_focus, None, "hiding changes returns focus to history"); + + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, Some(ChangesMode::Both)); + } + + #[test] + fn bare_repositories_cycle_only_tree_and_hidden_changes() { + let mut app = App::new(1); + app.changes_focus = Some(ChangePane::Worktree); + + app.set_worktree_changes_available(false); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + assert_eq!(app.changes_focus, None, "a hidden worktree pane cannot retain focus"); + + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, None); + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + } + + #[test] + fn cycles_changes_focus_in_visual_order_and_keeps_navigation_independent() { + let mut app = App::new(1); + app.changes_mode = Some(ChangesMode::Both); + app.set_changes_bounds(ChangePane::Tree, 2, 4, None, 10, 20); + app.set_changes_bounds(ChangePane::Worktree, 2, 4, None, 10, 20); + app.set_changes_layout(ChangesLayout::SideBySide, true, true); + + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Tree)); + app.update(Action::MoveDown); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Worktree)); + app.update(Action::MoveDown); + assert_eq!(app.tree_changes.selected, 1); + assert_eq!(app.worktree_changes.selected, 1); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenDiff(ChangePane::Worktree, 1)] + ); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, None); + + app.set_changes_layout(ChangesLayout::Stacked, true, true); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Worktree)); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Tree)); + + app.set_changes_layout(ChangesLayout::Stacked, false, true); + assert_eq!(app.changes_focus, Some(ChangePane::Worktree)); + app.set_changes_layout(ChangesLayout::Stacked, false, false); + assert_eq!(app.changes_focus, None); + } + + #[test] + fn changes_navigation_skips_display_separators() { + let mut app = App::new(1); + app.changes_focus = Some(ChangePane::Worktree); + app.set_changes_bounds(ChangePane::Worktree, 3, 5, Some(2), 10, 0); + + app.update(Action::PageDown); + assert_eq!(app.worktree_changes.selected, 2, "page movement counts only paths"); + assert_eq!( + app.worktree_changes.offset, 1, + "the divider remains in the display viewport" + ); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenDiff(ChangePane::Worktree, 2)], + "actions retain path indices" + ); + + app.update(Action::Last); + assert_eq!(app.worktree_changes.selected, 4); + assert_eq!( + app.worktree_changes.offset, 3, + "the display offset includes the divider row" + ); } #[test] @@ -1151,7 +6850,12 @@ mod tests { "the key is inert without hidden revisions" ); - app.has_hidden_filter = true; + app.configure_hidden_filter(true); + assert_eq!( + app.ref_mode, + RefMode::Default, + "hidden ancestry keeps the normal reference display" + ); app.extend_commits(vec![row(1)]); assert!( app.update(Action::ToggleHidden).is_empty(), @@ -1171,6 +6875,62 @@ mod tests { assert_eq!(app.update(Action::ToggleHidden), vec![Effect::Reload(false)]); } + #[test] + fn refresh_reloads_only_finished_history() { + let mut app = App::new(1); + assert!( + app.update(Action::Refresh).is_empty(), + "a running walk cannot be replaced" + ); + + app.extend_commits(vec![row(1)]); + complete(&mut app); + assert_eq!(app.update(Action::Refresh), vec![Effect::Reload(false)]); + + app.show_hidden = true; + app.state = State::Cancelled; + assert_eq!( + app.update(Action::Refresh), + vec![Effect::Reload(true)], + "refresh preserves the hidden-history setting" + ); + } + + #[test] + fn reload_retains_selection_or_falls_back_to_the_top() { + let mut app = App::new(3); + app.extend_commits(vec![row(1), row(2), row(3)]); + complete(&mut app); + app.update(Action::MoveDown); + let selected = app.rows[app.selected.expect("a row is selected")].id; + app.set_changes_bounds(ChangePane::Tree, 1, 3, None, 1, 2); + show_tree_changes(&mut app); + app.update(Action::ToggleChangesFocus); + app.update(Action::MoveDown); + app.update(Action::ScrollRight); + + app.reload(true); + assert_eq!(app.changes_focus, None, "reload returns focus to history"); + assert_eq!(app.tree_changes.selected, 0); + assert_eq!((app.tree_changes.offset, app.tree_changes.horizontal_offset), (0, 0)); + app.extend_commits(vec![row(1), row(2), row(3)]); + complete(&mut app); + assert_eq!( + app.rows[app.selected.expect("the old row remains selected")].id, + selected + ); + + app.reload(false); + app.extend_commits(vec![row(3)]); + app.extend_hidden_commits(vec![row(2)]); + complete(&mut app); + assert_eq!( + app.rows[app.selected.expect("the boundary selection is retained")].id, + selected, + "a selection which becomes a hidden boundary remains selected" + ); + } + #[test] fn cancellation_preserves_rows_and_ignores_late_worker_events() { let mut app = App::new(10); @@ -1190,6 +6950,32 @@ mod tests { ); } + #[test] + fn pane_exit_keys_return_to_history_but_control_c_quits() { + let mut app = App::new(1); + show_tree_changes(&mut app); + app.update(Action::ToggleChangesFocus); + + assert!(app.update(Action::Quit).is_empty()); + assert_eq!(app.changes_focus, None, "q returns focus to history"); + + app.update(Action::ToggleChangesFocus); + assert_eq!( + app.update(Action::ForceQuit), + vec![Effect::Quit], + "Ctrl-C quits even while changes have focus" + ); + assert!(app.update(Action::Cancel).is_empty()); + assert_eq!(app.changes_focus, None, "Escape returns focus to history"); + assert_eq!( + app.state, + State::Loading, + "Escape does not cancel while changes had focus" + ); + + assert_eq!(app.update(Action::Cancel), vec![Effect::Cancel]); + } + #[test] fn completion_and_copy_effects_use_the_current_selection() { let mut app = App::new(10); @@ -1204,6 +6990,10 @@ mod tests { app.extend_commits(vec![row(7)]); assert_eq!(app.update(Action::Copy), vec![Effect::CopyId(row(7).id)]); + assert_eq!( + app.update(Action::CopyPath("dir/file".into())), + vec![Effect::CopyPath("dir/file".into())] + ); assert_eq!(app.update(Action::CopyAuthor), vec![Effect::CopyAuthor(row(7).author)]); complete(&mut app); assert_eq!(app.state, State::Complete); diff --git a/gix-tix/src/change_id.rs b/gix-tix/src/change_id.rs new file mode 100644 index 00000000000..c317086b767 --- /dev/null +++ b/gix-tix/src/change_id.rs @@ -0,0 +1,256 @@ +use std::{ + cmp::Ordering as CmpOrdering, + collections::{HashMap, HashSet}, +}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BStr, BString}, + hash::ChangeId, + prelude::ObjectIdExt, +}; + +pub(crate) const HEADER: &str = "change-id"; + +pub(crate) fn effective<'a>(commit_id: ObjectId, mut values: impl Iterator<Item = &'a BStr>) -> ChangeId { + values + .find_map(|value| ChangeId::from_reverse_hex(value).ok()) + .unwrap_or_else(|| commit_id.into()) +} + +pub(crate) fn for_commit(repo: &gix::Repository, id: ObjectId) -> Result<ChangeId> { + let object = repo + .find_commit(id) + .context("could not read commit for its change ID")?; + let commit = object.decode().context("could not decode commit for its change ID")?; + Ok(effective(id, commit.extra_headers().find_all(HEADER))) +} + +pub(crate) fn display(repo: &gix::Repository, id: ObjectId, len: usize) -> Result<String> { + Ok(format!( + "{} {}", + id.to_hex_with_len(len), + for_commit(repo, id)?.to_reverse_hex_with_len(len) + )) +} + +pub(crate) fn display_short(repo: &gix::Repository, id: ObjectId) -> Result<String> { + let hash = id + .attach(repo) + .shorten() + .context("could not shorten commit ID")? + .to_string(); + Ok(format!( + "{hash} {}", + for_commit(repo, id)?.to_reverse_hex_with_len(hash.len()) + )) +} + +pub(crate) fn abbreviations( + repo: &gix::Repository, + ids: impl IntoIterator<Item = ObjectId>, + len: usize, +) -> Result<Abbreviations> { + let values = ids + .into_iter() + .map(|id| Ok((id, for_commit(repo, id)?))) + .collect::<Result<Vec<_>>>()?; + Ok(collect_abbreviations(values, len)) +} + +pub(crate) struct Abbreviations { + pub values: HashMap<ObjectId, ChangeId>, + pub ambiguous: HashSet<ObjectId>, +} + +pub(crate) fn resolve_prefix( + repo: &gix::Repository, + prefix: &str, + ids: impl IntoIterator<Item = ObjectId>, +) -> Result<Option<ObjectId>> { + let Ok(prefix) = gix::hash::Prefix::from_reverse_hex(prefix) else { + return Ok(None); + }; + let mut found = None; + for id in ids { + let change_id = for_commit(repo, id)?; + if prefix.cmp_oid(&change_id) != CmpOrdering::Equal { + continue; + } + if found.replace(id).is_some() { + anyhow::bail!( + "change ID prefix {} is ambiguous in the default Tix view", + prefix.to_reverse_hex() + ); + } + } + Ok(found) +} + +fn collect_abbreviations(values: impl IntoIterator<Item = (ObjectId, ChangeId)>, len: usize) -> Abbreviations { + let mut by_prefix = HashMap::new(); + let mut all = HashMap::new(); + let mut ambiguous = HashSet::new(); + for (id, change_id) in values { + let prefix = change_id.to_reverse_hex_with_len(len).to_string(); + if let Some(first) = by_prefix.insert(prefix, id) { + ambiguous.insert(first); + ambiguous.insert(id); + } + all.insert(id, change_id); + } + Abbreviations { values: all, ambiguous } +} + +pub(crate) fn inherit(repo: &gix::Repository, commit: &mut gix::objs::Commit, predecessor: ObjectId) -> Result<()> { + let change_id = for_commit(repo, predecessor).context("could not preserve predecessor change ID")?; + store(commit, change_id); + Ok(()) +} + +fn store(commit: &mut gix::objs::Commit, change_id: ChangeId) { + commit.extra_headers.retain(|(name, _)| name != HEADER); + commit + .extra_headers + .push((HEADER.into(), BString::from(change_id.to_string()))); +} + +#[derive(Default)] +pub(crate) struct Scan { + pub overrides: HashMap<ObjectId, ChangeId>, + pub duplicates: HashSet<ObjectId>, +} + +pub(crate) fn scan(repo: &gix::Repository, ids: &[ObjectId]) -> Result<Scan> { + let mut overrides = HashMap::new(); + let mut first_by_change = HashMap::new(); + let mut duplicates = HashSet::new(); + for &id in ids { + let object = repo + .find_commit(id) + .context("could not read commit while scanning change IDs")?; + let commit = object + .decode() + .context("could not decode commit while scanning change IDs")?; + let change_id = effective(id, commit.extra_headers().find_all(HEADER)); + if change_id != ChangeId::from(id) { + overrides.insert(id, change_id); + } + if let Some(first) = first_by_change.insert(change_id, id) { + duplicates.insert(first); + duplicates.insert(id); + } + } + Ok(Scan { overrides, duplicates }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> ObjectId { + ObjectId::Sha1([byte; 20]) + } + + #[test] + fn stores_one_canonical_header() { + let predecessor = id(1); + let inherited = ChangeId::from(id(2)); + let mut commit = gix::objs::Commit { + tree: id(3), + parents: Default::default(), + author: Default::default(), + committer: Default::default(), + encoding: None, + message: Default::default(), + extra_headers: vec![ + (HEADER.into(), "invalid".into()), + (HEADER.into(), inherited.to_string().into()), + ], + }; + + store(&mut commit, inherited); + assert_eq!(commit.extra_headers.len(), 1, "the rewrite stores one canonical header"); + assert_eq!( + effective(predecessor, commit.extra_headers().find_all(HEADER)), + inherited, + "the first valid inherited identity wins" + ); + } + + #[test] + fn marks_ambiguous_abbreviations_without_omitting_them() -> gix_testtools::Result { + let shared_a = ChangeId::from_reverse_hex(format!("zzzzzzz{}", "k".repeat(33)).as_bytes())?; + let shared_b = ChangeId::from_reverse_hex(format!("zzzzzzz{}", "l".repeat(33)).as_bytes())?; + let duplicate = ChangeId::from_reverse_hex(format!("yyyyyyy{}", "m".repeat(33)).as_bytes())?; + let unique = ChangeId::from_reverse_hex(format!("xxxxxxx{}", "n".repeat(33)).as_bytes())?; + let abbreviations = collect_abbreviations( + [ + (id(1), shared_a), + (id(2), shared_b), + (id(3), duplicate), + (id(4), duplicate), + (id(5), unique), + ], + 7, + ); + assert_eq!(abbreviations.values.len(), 5, "every change ID remains visible"); + assert_eq!( + abbreviations.ambiguous, + HashSet::from([id(1), id(2), id(3), id(4)]), + "prefix collisions and complete duplicates are marked" + ); + Ok(()) + } + + #[test] + fn scans_the_whole_scene_for_duplicate_identities() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let predecessor = repo.rev_parse_single("HEAD~1")?.detach(); + let mut duplicate = repo.find_commit(repo.head_id()?)?.decode()?.into_owned()?; + duplicate + .extra_headers + .push((HEADER.into(), ChangeId::from(predecessor).to_string().into())); + let duplicate = repo.write_object(&duplicate)?.detach(); + + let prefix = ChangeId::from(predecessor).to_reverse_hex_with_len(7).to_string(); + assert_eq!( + resolve_prefix(&repo, &prefix, [predecessor])?, + Some(predecessor), + "a unique default-view change ID resolves" + ); + assert!( + format!( + "{:#}", + resolve_prefix(&repo, &prefix, [predecessor, duplicate]) + .expect_err("two commits sharing a change ID are ambiguous") + ) + .contains("ambiguous"), + "ambiguity is reported explicitly" + ); + + let mut successor = repo.find_commit(duplicate)?.decode()?.into_owned()?; + successor.extra_headers.clear(); + inherit(&repo, &mut successor, duplicate)?; + assert_eq!( + effective(duplicate, successor.extra_headers().find_all(HEADER)), + ChangeId::from(predecessor), + "later rewrites inherit the stored identity from their predecessor" + ); + + let scan = scan(&repo, &[predecessor, duplicate])?; + assert_eq!( + scan.duplicates, + HashSet::from([predecessor, duplicate]), + "both off-screen and visible instances are marked" + ); + assert_eq!( + scan.overrides.get(&duplicate), + Some(&ChangeId::from(predecessor)), + "the header overrides the duplicate commit's trivial identity" + ); + Ok(()) + } +} diff --git a/gix-tix/src/command.rs b/gix-tix/src/command.rs new file mode 100644 index 00000000000..3693f240e44 --- /dev/null +++ b/gix-tix/src/command.rs @@ -0,0 +1,2020 @@ +use std::{ + collections::HashSet, + ffi::{OsStr, OsString}, + io::Write, + path::PathBuf, + sync::atomic::AtomicBool, +}; + +use anyhow::{Context, Result}; +use clap::Parser; +use ratatui::text::Line; + +mod enrich; +mod new; +mod rebase; +mod reword; +mod travel; + +/// Arguments and commands shared by the standalone `tix` binary and `gix tix`. +#[derive(Debug, clap::Args)] +#[command(args_conflicts_with_subcommands = true)] +pub struct Platform { + /// Draw on the normal screen so panic output remains visible. + #[arg(long)] + no_alt_screen: bool, + /// Exit after the final frame, optionally replaying read-only INPUTS first. + #[arg( + long, + value_name = "INPUTS", + num_args = 0..=1, + default_missing_value = "", + require_equals = true + )] + quit_on_finish: Option<String>, + /// Hide this revision and every commit reachable from it. + #[arg(short = 'x', long, value_name = "REVSPEC")] + hide: Vec<OsString>, + #[command(subcommand)] + command: Option<Command>, + /// Revisions whose reachable commits should be shown, or HEAD if omitted. + revisions: Vec<OsString>, +} + +#[derive(Debug, clap::Subcommand)] +enum Command { + /// Print the ref-tree of non-hidden references without opening the terminal UI. + RefTree(RefTree), + /// Print the complete history view without opening the terminal UI. + #[command(visible_alias = "status")] + Show(Show), + /// Perform repository maintenance. + #[command(subcommand)] + Admin(Admin), + /// Manage commit and tree enrichments. + #[command(subcommand)] + Enrich(enrich::Command), + /// Add staged changes, or worktree changes when nothing is staged, to HEAD. + Amend(Amend), + /// Move changes introduced by HEAD into the worktree. + Spill(Spill), + /// Split HEAD by amending worktree changes into it and committing staged index changes on top. + Split(Split), + /// Save index and worktree changes in a gix stash associated with the HEAD commit. + Stash, + /// Pin one or more commits as persistent history tips. + Pin(Pin), + /// Copy the change introduced by one commit above another commit. + CopyInsert(CopyInsert), + /// Travel to a commit while preserving reachable history through tix pins. + Travel(travel::Args), + /// Edit a commit and lazily rebase every descendant retained by a tix pin. + Reword(reword::Args), + /// Create a new commit at HEAD. + New(new::Args), + /// Generate or apply a self-contained history-rebase todo. + #[command(subcommand)] + Rebase(rebase::Command), +} + +#[derive(Debug, clap::Subcommand)] +enum Admin { + /// Clear this worktree's undo and redo history. + ClearUndo, +} + +#[derive(Debug, clap::Args)] +struct RefTree { + /// Omit tags as labels, traversal tips, and topology anchors. + #[arg(long)] + no_tags: bool, + /// Hide this revision and every commit reachable from it. + #[arg(short = 'x', long, value_name = "REVSPEC")] + hide: Vec<OsString>, + /// Do not infer hidden local branches from remote HEADs. + #[arg(long)] + no_auto_hide: bool, + /// Use the ref-tree view's Unicode line and node glyphs instead of ASCII. + #[arg(long)] + unicode: bool, + /// Revisions to traverse instead of all normal references. + #[arg(value_name = "REVSPEC")] + revisions: Vec<OsString>, +} + +#[derive(Debug, clap::Args)] +struct Show { + /// Hide this revision and every commit reachable from it. + #[arg(short = 'x', long, value_name = "REVSPEC")] + hide: Vec<OsString>, + /// Do not infer hidden local branches from remote HEADs. + #[arg(long)] + no_auto_hide: bool, + /// Visible traversal tips, or HEAD if omitted. + #[arg(value_name = "TIP")] + revisions: Vec<OsString>, +} + +#[derive(Debug, clap::Args)] +struct Amend { + /// Amend only staged index changes, without falling back to worktree changes. + #[arg(long)] + index: bool, +} + +#[derive(Debug, clap::Args)] +struct Spill { + /// Paths to spill, or every path if omitted. + #[arg(value_name = "PATH")] + paths: Vec<OsString>, +} + +#[derive(Debug, clap::Args)] +struct Split { + /// Mark the new upper commit as TODO. + #[arg(long)] + todo: bool, +} + +#[derive(Debug, clap::Args)] +struct Pin { + /// Revisions resolving to commits to pin. + #[arg(required = true, value_name = "REVSPEC")] + revisions: Vec<OsString>, +} + +#[derive(Debug, clap::Args)] +#[command( + after_long_help = "Conflicts change nothing by default. To materialize one and write a continuation todo:\n tix copy-insert --materialize-conflicts=todo.continue.md C I\nResolve the index, then run:\n tix rebase apply todo.continue.md\nUse --materialize-conflicts=- to write a continuation to non-terminal stdout." +)] +struct CopyInsert { + /// On conflict, materialize it and write a continuation todo to FILE, or stdout if omitted or '-'. + #[arg( + long, + value_name = "CONTINUE", + num_args = 0..=1, + default_missing_value = "-", + require_equals = true + )] + materialize_conflicts: Option<PathBuf>, + /// Revision resolving to the commit to copy. + #[arg(value_name = "SOURCE")] + source: OsString, + /// Revision resolving to the commit above which to insert the copy. + #[arg(value_name = "TARGET")] + target: OsString, +} + +#[derive(Debug, clap::Parser)] +#[command( + name = "tix", + about = "Browse or edit commit history", + after_long_help = "Commands which open an editor use Git's normal editor selection. Set GIT_EDITOR=<command> to override it." +)] +struct Cli { + #[command(flatten)] + platform: Platform, +} + +/// Parse the standalone `tix` command line. +pub fn parse() -> Platform { + Cli::parse_from(gix::env::args_os()).platform +} + +impl Platform { + /// Run this command against `repository`. + pub fn run(self, repository: gix::ThreadSafeRepository) -> Result<()> { + let Platform { + no_alt_screen, + quit_on_finish, + hide, + command, + revisions, + } = self; + let Some(command) = command else { + return crate::run( + repository, + revisions, + crate::Options { + no_alt_screen, + quit_on_finish, + hide, + }, + ); + }; + + let repository = repository.to_thread_local(); + let command = match command { + Command::RefTree(args) => return print_ref_tree(&repository, args), + Command::Show(args) => return show(&repository, args), + command => command, + }; + let _log_guard = crate::logging::init(); + match command { + Command::RefTree(_) | Command::Show(_) => unreachable!("display commands return before logging"), + Command::Amend(args) => { + let graph = crate::edit::loaded_view_graph(&repository)?; + let output_repository = repository.clone(); + let amended = if args.index { + crate::edit::head::amend_index_reporting(repository, &graph)? + } else { + crate::edit::head::perform_reporting(repository, &graph, crate::edit::head::Kind::Amend)? + }; + match amended { + Some(outcome) => { + let selected = outcome.selected.context("amending did not produce a selection")?; + println!("{}", crate::change_id::display(&output_repository, selected, 7)?); + print_ref_rewrites(&output_repository, &outcome.ref_rewrites)?; + record_undo(&output_repository, "amend", Ok(outcome.ref_changes)); + } + None => println!("nothing to amend"), + } + } + Command::Spill(args) => { + let selected_paths = resolve_spill_paths(&repository, &args.paths)?; + let graph = crate::edit::loaded_view_graph(&repository)?; + edit_head( + repository, + &graph, + crate::edit::head::Kind::Spill, + "spill", + selected_paths.as_deref(), + )?; + } + Command::Split(args) => { + let graph = crate::edit::loaded_view_graph(&repository)?; + split(repository, &graph, args)?; + } + Command::Stash => { + let id = repository + .head_id() + .context("stashing changes requires a born HEAD")? + .detach(); + let notice = crate::edit::stash::save_manual(repository.git_dir(), repository.is_bare(), id)?; + println!("{}", notice_with_change_id(&repository, ¬ice, id)?); + } + Command::Pin(args) => pin(&repository, args)?, + Command::CopyInsert(args) => return copy_insert(repository, args), + Command::Admin(Admin::ClearUndo) => crate::edit::undo::clear(&repository)?, + Command::Travel(args) => return travel::run(repository, args), + Command::Reword(args) => return reword::run(repository, args), + Command::New(args) => return new::run(repository, args), + Command::Enrich(command) => return enrich::run(repository, command), + Command::Rebase(command) => return rebase::run(repository, command), + } + Ok(()) + } +} + +fn print_ref_tree(repository: &gix::Repository, args: RefTree) -> Result<()> { + let rendered = render_ref_tree(repository, args)?; + std::io::Write::write_all(&mut std::io::stdout().lock(), rendered.as_bytes()) + .context("could not write ref-tree")?; + Ok(()) +} + +fn render_ref_tree(repository: &gix::Repository, args: RefTree) -> Result<String> { + let (hide, unavailable) = crate::history::available_hidden_revisions(repository, &args.hide, !args.no_auto_hide)?; + for (revision, err) in unavailable { + eprintln!( + "warning: ignoring unavailable hidden revision {}: {err}", + revision.to_string_lossy() + ); + } + let revisions = if args.revisions.is_empty() { + crate::history::ref_tree_revisions(repository, !args.no_tags)? + } else { + args.revisions + }; + crate::ref_tree::render_full(repository, &revisions, &hide, !args.no_tags, args.unicode) +} + +fn show(repository: &gix::Repository, args: Show) -> Result<()> { + let (hide, unavailable) = crate::history::available_hidden_revisions(repository, &args.hide, !args.no_auto_hide)?; + if hide.is_empty() { + anyhow::bail!("show requires at least one -x/--hide revision when no remote HEAD maps to a local branch"); + } + for (revision, err) in unavailable { + eprintln!( + "warning: ignoring unavailable hidden revision {}: {err}", + revision.to_string_lossy() + ); + } + write_history(repository, &args.revisions, &hide, std::io::stdout().lock()) +} + +fn write_history( + repository: &gix::Repository, + revisions: &[OsString], + hide: &[OsString], + mut out: impl Write, +) -> Result<()> { + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new( + crate::history::Authors::default(), + )); + let refs = crate::history::snapshot(repository, revisions, hide, false)?; + let mut app = crate::app::App::new(usize::MAX); + app.id_mode = crate::app::IdMode::Commit; + let mut decorations = crate::history::Decorations::default(); + let mut history_graph = None; + crate::history::load( + repository, + revisions, + hide, + false, + &authors, + &AtomicBool::new(false), + |event| { + match event { + crate::history::Event::Decorations(value) => decorations = value, + crate::history::Event::Commits(rows) => app.extend_commits(rows), + crate::history::Event::HiddenCommits(rows) => app.extend_hidden_commits(rows), + crate::history::Event::Complete(graph) => history_graph = Some(graph), + crate::history::Event::VisibleComplete | crate::history::Event::Cancelled => {} + } + true + }, + )?; + let graph = history_graph.context("history traversal did not complete")?; + let rows = app + .start_lane_computation() + .context("history rows were unavailable for lane computation")?; + let (rows, lanes, elapsed) = crate::app::compute_lanes(rows); + app.finish_lane_computation(rows, lanes, elapsed); + crate::update_hidden_branch_updates(&mut app, Some(&graph), &refs); + + for index in 0..app.rows.len() { + if app.rows[index].metadata_loaded { + continue; + } + let id = app.rows[index].id; + let (metadata, attributions) = + crate::history::load_metadata(repository, id, &authors).context("could not load displayed commit")?; + app.set_metadata(index, metadata, attributions); + } + + let mut note_ids = HashSet::new(); + let mut notes = repository.notes().context("could not open Git notes")?; + for row in &app.rows { + if !notes + .get(row.id) + .context("could not load displayed commit notes")? + .is_empty() + { + note_ids.insert(row.id); + } + } + + let mut todo_ids = HashSet::new(); + let mut enrichment_note_ids = HashSet::new(); + let mut enrichments = crate::enrich::open(repository)?; + for row in &app.rows { + let loaded = crate::change_id::for_commit(repository, row.id) + .and_then(|change_id| crate::enrich::load(&mut enrichments, change_id)); + match loaded { + Ok(enrichment) => { + if enrichment.todo { + todo_ids.insert(row.id); + } + if enrichment.note.is_some() { + enrichment_note_ids.insert(row.id); + } + } + Err(err) => tracing::warn!(commit_id = %row.id, error = %err, "ignored malformed tix enrichment"), + } + } + + let mut checks_pass_ids = HashSet::new(); + let mut tree_enrichments = crate::enrich::open_tree(repository)?; + for row in &app.rows { + let loaded = crate::enrich::tree_id(repository, row.id) + .and_then(|tree_id| crate::enrich::load_tree(&mut tree_enrichments, tree_id)); + match loaded { + Ok(enrichment) if enrichment.checks_pass => { + checks_pass_ids.insert(row.id); + } + Ok(_) => {} + Err(err) => tracing::warn!(commit_id = %row.id, error = %err, "ignored malformed tix tree enrichment"), + } + } + + let change_ids = crate::change_id::abbreviations(repository, app.rows.iter().map(|row| row.id), 7)?; + + let mailmap = repository.open_mailmap(); + let lanes = app.render_lanes(0..app.rows.len()); + let enrichment_gutter = app + .rows + .iter() + .map(|row| { + Line::raw(crate::enrich::marker( + todo_ids.contains(&row.id), + enrichment_note_ids.contains(&row.id), + checks_pass_ids.contains(&row.id), + )) + .width() + }) + .max() + .unwrap_or_default(); + let ambiguity_gutter = (!change_ids.ambiguous.is_empty()).then(|| Line::raw("💥").width()); + let render_line = |index: usize, row: &crate::app::SharedCommitRow| { + let metadata = crate::ui::plain_history_metadata( + &app, + row, + &decorations, + &mailmap, + note_ids.contains(&row.id), + change_ids.values.get(&row.id).copied(), + ); + let enrichment_marker = crate::enrich::marker( + todo_ids.contains(&row.id), + enrichment_note_ids.contains(&row.id), + checks_pass_ids.contains(&row.id), + ); + let ambiguity_marker = if change_ids.ambiguous.contains(&row.id) { + "💥" + } else { + "" + }; + let mut gutter = String::new(); + if enrichment_gutter != 0 { + gutter.push_str(enrichment_marker); + gutter.push_str(&" ".repeat(enrichment_gutter.saturating_sub(Line::raw(enrichment_marker).width()))); + } + if let Some(width) = ambiguity_gutter { + gutter.push_str(ambiguity_marker); + gutter.push_str(&" ".repeat(width.saturating_sub(Line::raw(ambiguity_marker).width()))); + } + let behind = app + .hidden_branch_behind(row.id) + .map(|behind| format!(" ⇣{behind}")) + .unwrap_or_default(); + let line = format!("{gutter}{}{metadata}{behind}", lanes.lane(index)); + let base = (app.visual_count(index) == Some(0)) + .then(|| format!("base {enrichment_marker}{ambiguity_marker}{metadata}{behind}")); + (line, base) + }; + let width = app + .rows + .iter() + .enumerate() + .map(|(index, row)| { + let (line, base) = render_line(index, row); + base.as_ref() + .map_or_else(|| Line::raw(&line).width(), |base| Line::raw(base).width() + 10) + }) + .max() + .unwrap_or_default(); + for (index, row) in app.rows.iter().enumerate() { + let (line, base) = render_line(index, row); + if let Some(base) = base { + let rails = width.saturating_sub(Line::raw(&base).width() + 2).max(8); + let left = rails / 2; + writeln!(out, "{} {base} {}", "─".repeat(left), "─".repeat(rails - left)) + .context("could not write history base")?; + } else { + writeln!(out, "{line}").context("could not write history row")?; + } + } + Ok(()) +} + +fn resolve_commit( + repository: &gix::Repository, + revision: &OsStr, + description: &str, +) -> Result<(gix::ObjectId, Option<crate::history::HistoryGraph>)> { + let revision = gix::path::os_str_into_bstr(revision) + .with_context(|| format!("revision {} is not valid UTF-8", revision.to_string_lossy()))?; + match crate::history::resolve_revision(repository, revision) { + Ok((id, _reference)) => Ok((id, None)), + Err(revision_error) => { + let graph = crate::edit::loaded_view_graph(repository)?; + let resolved = std::str::from_utf8(revision) + .ok() + .map(|prefix| crate::change_id::resolve_prefix(repository, prefix, graph.stored_commit_ids())) + .transpose()? + .flatten(); + match resolved { + Some(id) => Ok((id, Some(graph))), + None => Err(revision_error).with_context(|| format!("could not resolve {description} {revision:?}")), + } + } + } +} + +fn copy_insert(repository: gix::Repository, args: CopyInsert) -> Result<()> { + repository.workdir().context("copy-insert requires a worktree")?; + let (source, _) = resolve_commit(&repository, &args.source, "copy source")?; + let (target, _) = resolve_commit(&repository, &args.target, "copy target")?; + let revisions = [ + OsString::from("HEAD"), + OsString::from(source.to_string()), + OsString::from(target.to_string()), + ]; + let graph = crate::edit::loaded_view_graph_with(&repository, &revisions)?; + let plan = crate::edit::rebase::copy_insert_plan(&repository, &graph, source, target)?; + let repository_path = repository.git_dir().to_owned(); + let bare = repository.is_bare(); + match crate::edit::rebase::perform_plan(&repository, &graph, plan)? { + crate::edit::rebase::PlanPerform::Complete(outcome) => { + let copied = outcome.selected.context("copy-insert did not produce a selection")?; + let (_, changes) = + match crate::edit::time_travel::checkout_plan_reporting(&repository_path, bare, &outcome, &[], false) { + Ok(result) => result, + Err(err) => { + record_undo(&repository, "copy-insert commit", Ok(outcome.ref_changes)); + return Err(err).context("copy-insert applied, but could not check out the copied commit"); + } + }; + println!("{}", crate::change_id::display(&repository, copied, 7)?); + print_ref_rewrites(&repository, &outcome.ref_rewrites)?; + record_undo(&repository, "copy-insert commit", Ok(changes)); + Ok(()) + } + crate::edit::rebase::PlanPerform::Conflict(conflict) => rebase::handle_plan_conflict( + &repository, + conflict, + args.materialize_conflicts.as_deref(), + &[], + "copy-insert", + ), + } +} + +fn pin(repository: &gix::Repository, args: Pin) -> Result<()> { + let pins = create_pins(repository, &args.revisions)?; + for (pin, _created) in &pins { + println!("{}", display_pin(repository, pin)?); + } + record_undo( + repository, + "pin commit", + Ok(pins + .into_iter() + .filter_map(|(pin, created)| { + created.then_some(crate::edit::undo::RefChange { + name: pin.name, + before: crate::edit::undo::State::Missing, + after: match pin.target { + gix::refs::Target::Object(id) => crate::edit::undo::State::Object(id), + gix::refs::Target::Symbolic(name) => crate::edit::undo::State::Symbolic(name), + }, + }) + }) + .collect()), + ); + Ok(()) +} + +fn create_pins(repository: &gix::Repository, revisions: &[OsString]) -> Result<Vec<(crate::history::Pin, bool)>> { + let mut seen = HashSet::new(); + let targets = revisions + .iter() + .map(|revision| { + let revision = gix::path::os_str_into_bstr(revision) + .with_context(|| format!("revision {} is not valid UTF-8", revision.to_string_lossy()))?; + let (id, reference) = crate::history::resolve_revision(repository, revision) + .with_context(|| format!("could not resolve revision {revision:?}"))?; + let target = match reference { + Some(reference) if repository.find_reference(reference.as_ref())?.peel_to_commit()?.id == id => { + gix::refs::Target::Symbolic(reference) + } + _ => gix::refs::Target::Object(id), + }; + Ok((target, id)) + }) + .collect::<Result<Vec<_>>>()?; + targets + .into_iter() + .filter(|(target, _id)| seen.insert(target.clone())) + .map(|(target, id)| crate::edit::time_travel::create_or_reuse_pin(repository, target, id, "tix pin")) + .collect() +} + +fn display_pin(repository: &gix::Repository, pin: &crate::history::Pin) -> Result<String> { + Ok(format!( + "{} {}", + crate::edit::time_travel::pin_label(pin), + crate::change_id::display_short(repository, pin.id)? + )) +} + +fn edit_head( + repository: gix::Repository, + graph: &crate::history::HistoryGraph, + kind: crate::edit::head::Kind, + verb: &str, + selected_paths: Option<&[crate::PathChange]>, +) -> Result<()> { + let output_repository = repository.clone(); + match crate::edit::head::perform_with_changes( + repository, + graph, + kind, + selected_paths.map(|paths| (paths, None)), + false, + |_| {}, + )? { + Some(outcome) => { + let selected = outcome.selected.context("editing HEAD did not produce a selection")?; + println!("{}", crate::change_id::display(&output_repository, selected, 7)?); + print_ref_rewrites(&output_repository, &outcome.ref_rewrites)?; + record_undo(&output_repository, verb, Ok(outcome.ref_changes)); + } + None => println!("nothing to {verb}"), + } + Ok(()) +} + +fn resolve_spill_paths(repository: &gix::Repository, paths: &[OsString]) -> Result<Option<Vec<crate::PathChange>>> { + if paths.is_empty() { + return Ok(None); + } + + let head = repository + .head_id() + .context("spilling paths requires a born HEAD")? + .detach(); + let commit = repository.find_commit(head).context("could not load HEAD commit")?; + let new_tree = commit.tree().context("could not load HEAD tree")?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some( + repository + .find_commit(parent) + .context("could not load HEAD's first parent")? + .tree() + .context("could not load HEAD's first-parent tree")?, + ), + None => None, + }; + let changes = crate::load_tree_changes_without_lines(repository, old_tree.as_ref(), &new_tree, None)?; + let mut seen = HashSet::new(); + let mut selected = Vec::with_capacity(paths.len()); + for path in paths { + let display = path.to_string_lossy(); + let path = gix::path::os_str_into_bstr(path) + .with_context(|| format!("path {display:?} could not be converted to a Git path"))?; + let path = repository + .normalize_path(path) + .with_context(|| format!("could not normalize path {display:?}"))? + .into_owned(); + if path.is_empty() { + anyhow::bail!("path {display:?} does not name a file"); + } + if !seen.insert(path.clone()) { + continue; + } + let change = changes + .paths + .iter() + .find(|change| change.path == path) + .with_context(|| format!("path {display:?} is not changed by HEAD"))?; + selected.push(change.clone()); + } + Ok(Some(selected)) +} + +fn split(repository: gix::Repository, graph: &crate::history::HistoryGraph, args: Split) -> Result<()> { + let repository_path = repository.git_dir().to_owned(); + let bare = repository.is_bare(); + let prepared = crate::edit::split::prepare(repository, args.todo)?; + let Some(edited) = crate::edit::edit_document_without_terminal( + &prepared.editor, + &prepared.document, + &format!("tix-split-{}.md", std::process::id()), + )? + else { + println!("no split performed: no input was provided"); + return Ok(()); + }; + let mut repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository after editing split")?; + repository.object_cache_size(None); + let outcome = crate::edit::split::apply_reporting(repository, graph, prepared, &edited, |_| {})?; + let output_repository = + crate::open_repository(&repository_path, bare, false).context("could not reopen repository after splitting")?; + let selected = outcome.selected.context("splitting did not produce a selection")?; + println!("{}", crate::change_id::display(&output_repository, selected, 7)?); + print_ref_rewrites(&output_repository, &outcome.ref_rewrites)?; + record_undo(&output_repository, "split commit", Ok(outcome.ref_changes)); + Ok(()) +} + +pub(super) fn record_undo( + repository: &gix::Repository, + title: &str, + changes: Result<Vec<crate::edit::undo::RefChange>>, +) { + if let Err(err) = changes.and_then(|changes| crate::edit::undo::record(repository, title, &changes).map(|_| ())) { + eprintln!("warning: operation completed, but undo history was not updated: {err:#}"); + } +} + +fn print_ref_rewrites(repository: &gix::Repository, rewrites: &[crate::edit::rebase::RefRewrite]) -> Result<()> { + for line in ref_rewrite_lines(repository, rewrites)? { + println!("{line}"); + } + Ok(()) +} + +fn ref_rewrite_lines( + repository: &gix::Repository, + rewrites: &[crate::edit::rebase::RefRewrite], +) -> Result<Vec<String>> { + let mut rewrites = rewrites.to_vec(); + rewrites.sort_by(|a, b| a.name.cmp(&b.name)); + rewrites.dedup(); + rewrites + .into_iter() + .map(|rewrite| { + Ok(format!( + "{}: {} -> {}", + rewrite.name, + crate::change_id::display(repository, rewrite.old, 7)?, + crate::change_id::display(repository, rewrite.new, 7)? + )) + }) + .collect() +} + +fn notice_with_change_id(repository: &gix::Repository, notice: &str, id: gix::ObjectId) -> Result<String> { + let hash = id.to_hex_with_len(7).to_string(); + Ok(notice.replacen(&hash, &crate::change_id::display(repository, id, 7)?, 1)) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command as ProcessCommand}; + + use clap::{CommandFactory, error::ErrorKind}; + + use super::*; + + #[test] + fn rewritten_ref_lines_are_sorted_and_show_the_commit_mapping() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let old = repository.rev_parse_single("main~1")?.detach(); + let new = repository.rev_parse_single("main")?.detach(); + let branch = crate::edit::rebase::RefRewrite { + name: "refs/heads/z".try_into().expect("valid ref name"), + old, + new, + }; + let first = crate::edit::rebase::RefRewrite { + name: "refs/heads/a".try_into().expect("valid ref name"), + old, + new, + }; + assert_eq!( + ref_rewrite_lines(&repository, &[branch.clone(), first, branch])?, + [ + format!( + "refs/heads/a: {} -> {}", + crate::change_id::display(&repository, old, 7)?, + crate::change_id::display(&repository, new, 7)? + ), + format!( + "refs/heads/z: {} -> {}", + crate::change_id::display(&repository, old, 7)?, + crate::change_id::display(&repository, new, 7)? + ) + ], + "ref mappings are stable and duplicate-free" + ); + assert!( + ref_rewrite_lines(&repository, &[])?.is_empty(), + "unchanged refs add no output" + ); + Ok(()) + } + + #[test] + fn commit_notices_pair_their_hash_with_the_change_id() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let id = repository.head_id()?.detach(); + let notice = format!("stashed changes at {}; retained warning", id.to_hex_with_len(7)); + + assert_eq!( + notice_with_change_id(&repository, ¬ice, id)?, + format!( + "stashed changes at {}; retained warning", + crate::change_id::display(&repository, id, 7)? + ), + "the change ID stays adjacent to the hash without disturbing later text" + ); + Ok(()) + } + + #[test] + fn clap_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn parses_tui_options_and_top_level_commands() { + let cli = Cli::try_parse_from([ + "tix", + "--no-alt-screen", + "--quit-on-finish", + "-x", + "main", + "--hide", + "tag", + "topic", + ]) + .expect("TUI arguments parse"); + assert!(cli.platform.no_alt_screen); + assert_eq!(cli.platform.quit_on_finish, Some(String::new())); + assert_eq!(cli.platform.hide, ["main", "tag"], "hide options append"); + assert_eq!( + cli.platform.revisions, + ["topic"], + "positional revisions remain visible tips" + ); + assert!(cli.platform.command.is_none(), "omitting a command launches the TUI"); + + let cli = Cli::try_parse_from(["tix", "--quit-on-finish=jjjl"]).expect("diagnostic inputs parse"); + assert_eq!(cli.platform.quit_on_finish.as_deref(), Some("jjjl")); + + let ref_tree = Cli::try_parse_from([ + "tix", + "ref-tree", + "--no-tags", + "-x", + "private", + "--unicode", + "main", + "topic", + ]) + .expect("ref-tree options parse") + .platform + .command; + let Some(Command::RefTree(ref_tree)) = ref_tree else { + panic!("ref-tree was expected") + }; + assert!(ref_tree.no_tags); + assert_eq!(ref_tree.hide, ["private"]); + assert!(!ref_tree.no_auto_hide); + assert!(ref_tree.unicode); + assert_eq!(ref_tree.revisions, ["main", "topic"]); + + let ref_tree = Cli::try_parse_from(["tix", "ref-tree", "--no-auto-hide"]) + .expect("ref-tree can disable automatic hiding") + .platform + .command; + let Some(Command::RefTree(ref_tree)) = ref_tree else { + panic!("ref-tree was expected") + }; + assert!(ref_tree.no_auto_hide); + + let show = Cli::try_parse_from(["tix", "show", "-x", "main", "--hide", "tag", "topic"]) + .expect("show options parse") + .platform + .command; + let Some(Command::Show(show)) = show else { + panic!("show was expected") + }; + assert_eq!(show.hide, ["main", "tag"]); + assert!(!show.no_auto_hide); + assert_eq!(show.revisions, ["topic"]); + + let show = Cli::try_parse_from(["tix", "show", "--no-auto-hide", "topic"]) + .expect("show can disable automatic hiding") + .platform + .command; + let Some(Command::Show(show)) = show else { + panic!("show was expected") + }; + assert!(show.no_auto_hide); + assert!(show.hide.is_empty()); + + assert!(matches!( + Cli::try_parse_from(["tix", "status", "-x", "main"]) + .expect("status is a visible show alias") + .platform + .command, + Some(Command::Show(_)) + )); + assert!( + Cli::command().render_help().to_string().contains("status"), + "top-level help advertises the status alias" + ); + + for arguments in [ + &["tix", "enrich", "commit", "todo"][..], + &["tix", "enrich", "commit", "todo", "--clear", "topic"][..], + &["tix", "enrich", "commit", "note", "topic"][..], + &["tix", "enrich", "commit", "git-note"][..], + &["tix", "enrich", "tree", "checks-pass"][..], + &["tix", "enrich", "tree", "checks-pass", "--clear", "topic"][..], + ] { + assert!( + matches!( + Cli::try_parse_from(arguments) + .expect("enrich command parses") + .platform + .command, + Some(Command::Enrich(_)) + ), + "{arguments:?} reaches the enrich command" + ); + } + assert!( + Cli::try_parse_from(["tix", "enrich", "commit", "checks-pass"]).is_err(), + "tree enrichments are not commit subcommands" + ); + assert!( + Cli::try_parse_from(["tix", "enrich", "tree", "todo"]).is_err(), + "commit enrichments are not tree subcommands" + ); + + assert!( + Cli::try_parse_from(["tix", "--worktrees"]).is_err(), + "the removed TUI worktree option is rejected" + ); + assert!( + Cli::try_parse_from(["tix", "ref-tree", "-w"]).is_err(), + "the removed diagnostic worktree option is rejected" + ); + + assert!( + Cli::try_parse_from(["tix", "ref-tree", "--layout", "rail"]).is_err(), + "the removed layout selector is rejected" + ); + let old_name = Cli::try_parse_from(["tix", "tree"]) + .expect("tree remains a valid revision") + .platform; + assert!( + old_name.command.is_none(), + "the old tree command has no compatibility alias" + ); + assert_eq!(old_name.revisions, ["tree"]); + + let amend = Cli::try_parse_from(["tix", "amend", "--index"]) + .expect("index-only amend parses") + .platform + .command; + let Some(Command::Amend(amend)) = amend else { + panic!("amend was expected") + }; + assert!(amend.index); + let amend = Cli::try_parse_from(["tix", "amend"]) + .expect("default amend parses") + .platform + .command; + assert!(matches!(amend, Some(Command::Amend(Amend { index: false })))); + let spill = Cli::try_parse_from(["tix", "spill"]) + .expect("whole-commit spill parses") + .platform + .command; + let Some(Command::Spill(spill)) = spill else { + panic!("spill was expected") + }; + assert!(spill.paths.is_empty(), "omitting paths spills the whole commit"); + let spill = Cli::try_parse_from(["tix", "spill", "first", "second"]) + .expect("path spill parses") + .platform + .command; + let Some(Command::Spill(spill)) = spill else { + panic!("spill was expected") + }; + assert_eq!(spill.paths, ["first", "second"]); + assert!(matches!( + Cli::try_parse_from(["tix", "split"]) + .expect("split parses") + .platform + .command, + Some(Command::Split(Split { todo: false })) + )); + assert!(matches!( + Cli::try_parse_from(["tix", "split", "--todo"]) + .expect("TODO split parses") + .platform + .command, + Some(Command::Split(Split { todo: true })) + )); + assert!(matches!( + Cli::try_parse_from(["tix", "stash"]) + .expect("stash parses") + .platform + .command, + Some(Command::Stash) + )); + assert!(matches!( + Cli::try_parse_from(["tix", "admin", "clear-undo"]) + .expect("clear-undo parses") + .platform + .command, + Some(Command::Admin(Admin::ClearUndo)) + )); + let pin = Cli::try_parse_from(["tix", "pin", "main", "HEAD~2"]) + .expect("one or more pin revisions parse") + .platform + .command; + let Some(Command::Pin(pin)) = pin else { + panic!("pin was expected") + }; + assert_eq!(pin.revisions, ["main", "HEAD~2"]); + let copy_insert = Cli::try_parse_from([ + "tix", + "copy-insert", + "--materialize-conflicts=continue.md", + "main", + "HEAD~1", + ]) + .expect("copy-insert parses") + .platform + .command; + let Some(Command::CopyInsert(copy_insert)) = copy_insert else { + panic!("copy-insert was expected") + }; + assert_eq!(copy_insert.source, "main"); + assert_eq!(copy_insert.target, "HEAD~1"); + assert_eq!(copy_insert.materialize_conflicts, Some("continue.md".into())); + let Some(Command::CopyInsert(copy_insert)) = + Cli::try_parse_from(["tix", "copy-insert", "--materialize-conflicts", "HEAD", "main~1"]) + .expect("copy-insert defaults continuation output to stdout") + .platform + .command + else { + panic!("copy-insert was expected") + }; + assert_eq!(copy_insert.materialize_conflicts, Some("-".into())); + let travel = Cli::try_parse_from(["tix", "travel", "--materialize-conflicts", "HEAD~1"]) + .expect("travel parses") + .platform + .command; + let Some(Command::Travel(travel)) = travel else { + panic!("travel was expected") + }; + assert!(travel.materialize_conflicts); + assert_eq!(travel.revision.as_deref(), Some(std::ffi::OsStr::new("HEAD~1"))); + assert_eq!(travel.to, None); + for (value, expected) in [ + ("first", travel::To::First), + ("parent", travel::To::Parent), + ("child", travel::To::Child), + ("tip", travel::To::Tip), + ] { + let parsed = Cli::try_parse_from(["tix", "travel", "--to", value]) + .expect("relative travel parses") + .platform + .command; + let Some(Command::Travel(travel)) = parsed else { + panic!("travel was expected") + }; + assert_eq!(travel.revision, None); + assert_eq!(travel.to, Some(expected)); + } + assert_eq!( + Cli::try_parse_from(["tix", "travel"]) + .expect_err("travel requires one destination") + .kind(), + ErrorKind::MissingRequiredArgument + ); + assert_eq!( + Cli::try_parse_from(["tix", "travel", "HEAD", "--to", "tip"]) + .expect_err("relative and explicit travel are mutually exclusive") + .kind(), + ErrorKind::ArgumentConflict + ); + assert_eq!( + Cli::try_parse_from(["tix", "travel", "--to", "last"]) + .expect_err("tip is the sole name for the upper endpoint") + .kind(), + ErrorKind::InvalidValue + ); + let travel_help = Cli::try_parse_from(["tix", "travel", "--help"]) + .expect_err("help exits through clap") + .to_string(); + for description in [ + "Visit the oldest reachable root", + "Visit HEAD's direct visible parent", + "Visit HEAD's direct visible child", + "Visit the reachable leaf", + ] { + assert!( + travel_help.contains(description), + "travel help describes every relative destination: {travel_help}" + ); + } + let reword = Cli::try_parse_from(["tix", "reword", "HEAD~2"]) + .expect("reword parses") + .platform + .command; + let Some(Command::Reword(reword)) = reword else { + panic!("reword was expected") + }; + assert_eq!(reword.revision, "HEAD~2"); + assert!(reword.edit.message.is_empty()); + assert!(reword.edit.file.is_none()); + assert!(reword.edit.author.is_none()); + let reword = Cli::try_parse_from([ + "tix", + "reword", + "HEAD~2", + "--author", + "Agent <agent@example.com>", + "-m", + "title", + "-m", + "body", + ]) + .expect("reword messages parse") + .platform + .command; + let Some(Command::Reword(reword)) = reword else { + panic!("reword was expected") + }; + assert_eq!(reword.edit.message, ["title", "body"]); + assert_eq!( + reword.edit.author.as_deref(), + Some(std::ffi::OsStr::new("Agent <agent@example.com>")) + ); + assert!( + Cli::try_parse_from(["tix", "reword", "HEAD", "-m", "message", "-f", "message.txt"]).is_err(), + "message and file inputs are mutually exclusive" + ); + let new = Cli::try_parse_from([ + "tix", + "new", + "--index", + "--allow-empty", + "--todo", + "--author", + "Agent <agent@example.com>", + "-m", + "title", + ]) + .expect("new options parse") + .platform + .command; + let Some(Command::New(new)) = new else { + panic!("new was expected") + }; + assert!(new.index); + assert!(!new.worktree); + assert!(!new.worktree_untracked); + assert!(new.allow_empty); + assert!(new.todo); + assert_eq!(new.edit.message, ["title"]); + assert!(Cli::try_parse_from(["tix", "new", "--index", "--worktree", "-m", "title"]).is_err()); + assert!(Cli::try_parse_from(["tix", "new", "--index", "--worktree-untracked", "-m", "title"]).is_err()); + assert!(Cli::try_parse_from(["tix", "new", "--worktree", "--worktree-untracked", "-m", "title"]).is_err()); + assert!(Cli::try_parse_from(["tix", "new", "HEAD", "-m", "title"]).is_err()); + assert!(matches!( + Cli::try_parse_from([ + "tix", + "rebase", + "todo", + "--no-auto-hide", + "-x", + "main", + "--onto", + "next", + "--edit-and-apply", + "--materialize-conflicts", + "continue.md", + "topic" + ]) + .expect("rebase todo parses") + .platform + .command, + Some(Command::Rebase(rebase::Command::Todo(_))) + )); + assert!(matches!( + Cli::try_parse_from(["tix", "rebase", "todo", "-x", "main", "--update-base", "topic"]) + .expect("rebase update todo parses") + .platform + .command, + Some(Command::Rebase(rebase::Command::Todo(_))) + )); + assert!( + Cli::try_parse_from(["tix", "rebase", "todo", "-x", "main", "--onto", "next", "--update-base"]).is_err(), + "explicit and inferred rebase targets are mutually exclusive" + ); + assert!( + Cli::try_parse_from(["tix", "rebase", "todo", "-x", "main", "--materialize-conflicts"]).is_err(), + "todo conflict materialization requires immediate editing and application" + ); + assert!(matches!( + Cli::try_parse_from(["tix", "rebase", "apply", "-"]) + .expect("rebase apply parses") + .platform + .command, + Some(Command::Rebase(rebase::Command::Apply(_))) + )); + let parsed = Cli::try_parse_from([ + "tix", + "rebase", + "apply", + "--materialize-conflicts", + "continue.md", + "todo.md", + ]) + .expect("conflict materialization output parses"); + let Some(Command::Rebase(rebase::Command::Apply(args))) = parsed.platform.command else { + panic!("rebase apply was expected") + }; + assert_eq!( + args.materialize_conflicts.as_deref(), + Some(std::path::Path::new("continue.md")) + ); + assert_eq!(args.file.as_deref(), Some(std::path::Path::new("todo.md"))); + assert!( + Cli::command() + .render_help() + .to_string() + .contains("Split HEAD by amending worktree changes into it and committing staged index changes on top"), + "short help explains how split distributes index and worktree changes" + ); + assert!( + Cli::command() + .render_help() + .to_string() + .contains("gix stash associated with the HEAD commit"), + "short help distinguishes a commit-associated gix stash" + ); + assert!( + Cli::command().render_long_help().to_string().contains("GIT_EDITOR"), + "top-level help explains how to override Git's editor" + ); + } + + #[test] + fn copy_insert_command_rewrites_the_target_stack_and_is_undoable() -> gix_testtools::Result { + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = ProcessCommand::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) + } + + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + git(path, &["checkout", "-q", "-b", "destination", "HEAD~2"])?; + std::fs::write(path.join("destination"), b"target\n")?; + git(path, &["add", "destination"])?; + git(path, &["commit", "-q", "-m", "destination target"])?; + let target = crate::test_repository::open(path)?.head_id()?.detach(); + std::fs::write(path.join("destination-child"), b"child\n")?; + git(path, &["add", "destination-child"])?; + git(path, &["commit", "-q", "-m", "destination child"])?; + let destination_before = crate::test_repository::open(path)?.head_id()?.detach(); + git(path, &["checkout", "-q", "-b", "excluded", &target.to_string()])?; + std::fs::write(path.join("excluded"), b"excluded\n")?; + git(path, &["add", "excluded"])?; + git(path, &["commit", "-q", "-m", "excluded child"])?; + let excluded_before = crate::test_repository::open(path)?.head_id()?.detach(); + git(path, &["checkout", "-q", "destination"])?; + + let repository = crate::test_repository::open(path)?; + let source = repository.rev_parse_single("main")?.detach(); + copy_insert( + repository, + CopyInsert { + materialize_conflicts: None, + source: "main".into(), + target: target.to_string().into(), + }, + )?; + + let repository = crate::test_repository::open(path)?; + let copied = repository.head_id()?.detach(); + assert!(repository.head()?.is_detached(), "the new copy is checked out detached"); + assert_eq!( + repository.find_reference("refs/heads/main")?.id(), + source, + "the source branch remains at the original occurrence" + ); + assert_eq!( + repository.find_reference("refs/heads/excluded")?.id(), + excluded_before, + "an unpinned branch outside the command view is not rewritten" + ); + assert_eq!( + repository.find_commit(copied)?.parent_ids().next().map(gix::Id::detach), + Some(target), + "the copy is inserted immediately above the target" + ); + let destination_after = repository.find_reference("refs/heads/destination")?.id().detach(); + assert_ne!( + destination_after, destination_before, + "the target descendant is rewritten" + ); + assert_eq!( + repository + .find_commit(destination_after)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(copied), + "the rewritten descendant follows the copy" + ); + assert_eq!( + crate::edit::undo::position(&repository)?.title, + "copy-insert commit", + "the command records one undoable operation" + ); + let pins = crate::history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "the previous checkout receives one HEAD pin"); + assert_eq!( + pins[0].target.try_name().expect("the pin is symbolic"), + "refs/heads/destination", + "the HEAD pin remembers the destination branch" + ); + + crate::edit::undo::plan_undo(&repository)? + .context("copy-insert can be undone")? + .apply(&repository)?; + let repository = crate::test_repository::open(path)?; + assert_eq!( + repository.head_id()?, + destination_before, + "undo restores the previous checkout" + ); + assert_eq!( + repository.head()?.referent_name().expect("HEAD is attached"), + "refs/heads/destination", + "undo reattaches HEAD" + ); + assert_eq!( + repository.find_reference("refs/heads/destination")?.id(), + destination_before, + "undo restores the target branch" + ); + assert_eq!(repository.find_reference("refs/heads/main")?.id(), source); + assert_eq!(repository.find_reference("refs/heads/excluded")?.id(), excluded_before); + assert!( + crate::history::all_pins(&repository)?.is_empty(), + "undo removes the checkout pin" + ); + Ok(()) + } + + #[test] + fn copy_insert_conflicts_are_atomic_or_materialize_a_continuation() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let path = fixture.path(); + let repository = crate::test_repository::open(path)?; + let source = repository.head_id()?.detach(); + let target = repository.rev_parse_single("HEAD~2")?.detach(); + let before = gix_testtools::repository::snapshot(path)?; + let err = copy_insert( + repository, + CopyInsert { + materialize_conflicts: None, + source: source.to_string().into(), + target: target.to_string().into(), + }, + ) + .expect_err("copy-insert conflicts are atomic by default"); + assert!(format!("{err:#}").contains("pass --materialize-conflicts")); + assert_eq!( + gix_testtools::repository::snapshot(path)?, + before, + "the default conflict leaves the repository unchanged" + ); + + let output_dir = gix_testtools::tempfile::tempdir()?; + let continuation = output_dir.path().join("continue.md"); + let repository = crate::test_repository::open(path)?; + let err = copy_insert( + repository, + CopyInsert { + materialize_conflicts: Some(continuation.clone()), + source: source.to_string().into(), + target: target.to_string().into(), + }, + ) + .expect_err("materializing a conflict exits unsuccessfully"); + assert!(format!("{err:#}").contains("copy-insert stopped at a materialized conflict")); + let document = std::fs::read(&continuation)?; + let repository = crate::test_repository::open(path)?; + assert!( + crate::edit::todo::parse(&repository, &document)?.is_some(), + "the continuation is accepted by the ordinary rebase parser" + ); + assert_eq!( + crate::edit::undo::position(&repository)?.title, + "materialize rebase conflict", + "materialization is independently undoable" + ); + let unresolved = ProcessCommand::new("git") + .arg("-C") + .arg(path) + .args(["diff", "--name-only", "--diff-filter=U"]) + .output()?; + assert!(unresolved.status.success()); + assert_eq!( + unresolved.stdout, b"file\n", + "materialization writes the unmerged index" + ); + + std::fs::write(path.join("file"), b"base\n")?; + assert!( + ProcessCommand::new("git") + .arg("-C") + .arg(path) + .args(["add", "file"]) + .status()? + .success() + ); + rebase::run( + crate::test_repository::open(path)?, + rebase::Command::Apply(rebase::Apply { + materialize_conflicts: None, + file: Some(continuation), + }), + )?; + let unresolved = ProcessCommand::new("git") + .arg("-C") + .arg(path) + .args(["diff", "--name-only", "--diff-filter=U"]) + .output()?; + assert!(unresolved.status.success()); + assert!( + unresolved.stdout.is_empty(), + "the continuation consumes the resolved index" + ); + Ok(()) + } + + #[test] + fn copy_insert_rejects_a_bare_repository_before_rewriting_it() -> gix_testtools::Result { + let source = gix_testtools::scripted_fixture_read_only("rebase_edit.sh")?; + let fixture = gix_testtools::tempfile::tempdir()?; + assert!( + ProcessCommand::new("git") + .args(["clone", "-q", "--bare"]) + .arg(source) + .arg(fixture.path()) + .status()? + .success() + ); + let repository = crate::test_repository::open(fixture.path())?; + let before = repository.head_id()?.detach(); + let target = repository.rev_parse_single("HEAD~2")?.detach(); + let err = copy_insert( + repository, + CopyInsert { + materialize_conflicts: None, + source: before.to_string().into(), + target: target.to_string().into(), + }, + ) + .expect_err("copy-insert requires a checkout"); + assert!(format!("{err:#}").contains("requires a worktree")); + assert_eq!( + crate::test_repository::open(fixture.path())?.head_id()?, + before, + "the attached branch is unchanged" + ); + Ok(()) + } + + #[test] + fn ref_tree_omits_explicit_and_inferred_hidden_references() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let topic = repository.rev_parse_single("topic")?.detach(); + repository.reference( + "refs/heads/visible", + topic, + gix::refs::transaction::PreviousValue::MustNotExist, + "test visible alias", + )?; + + let args = |hide, no_auto_hide| RefTree { + no_tags: false, + hide, + no_auto_hide, + unicode: false, + revisions: Vec::new(), + }; + let all = render_ref_tree(&repository, args(Vec::new(), true))?; + let hidden = render_ref_tree(&repository, args(vec!["topic".into()], true))?; + assert!(all.contains("topic"), "the complete tree includes topic: {all:?}"); + assert!( + hidden.contains("visible"), + "a visible ref sharing the target remains: {hidden:?}" + ); + assert!( + !hidden.contains("topic"), + "the explicitly hidden label is gone: {hidden:?}" + ); + + for git_args in [ + ["config", "remote.origin.url", "https://example.com/repo"].as_slice(), + ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"].as_slice(), + ["update-ref", "refs/remotes/origin/main", "main"].as_slice(), + ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"].as_slice(), + ] { + let status = ProcessCommand::new("git") + .current_dir(fixture.path()) + .args(git_args) + .status()?; + assert!(status.success(), "git {git_args:?} prepares remote HEAD inference"); + } + let repository = crate::test_repository::open(fixture.path())?; + let automatic = render_ref_tree(&repository, args(Vec::new(), false))?; + assert!( + all.contains("@main"), + "disabling inference retains the local default: {all:?}" + ); + assert!( + !automatic.contains("@main") && automatic.contains("origin/main"), + "automatic hiding removes only the inferred local ref: {automatic:?}" + ); + Ok(()) + } + + #[test] + fn preserves_hide_and_help_semantics() { + for command in [ + &[][..], + &["ref-tree"], + &["show"], + &["status"], + &["amend"], + &["spill"], + &["split"], + &["stash"], + &["pin"], + &["copy-insert"], + &["travel"], + &["reword"], + &["admin"], + &["admin", "clear-undo"], + &["enrich"], + &["enrich", "commit"], + &["enrich", "commit", "todo"], + &["enrich", "commit", "note"], + &["enrich", "commit", "git-note"], + &["enrich", "tree"], + &["enrich", "tree", "checks-pass"], + &["rebase"], + &["rebase", "todo"], + &["rebase", "apply"], + ] { + for help in ["-h", "--help"] { + let arguments = std::iter::once("tix").chain(command.iter().copied()).chain([help]); + assert_eq!( + Cli::try_parse_from(arguments) + .expect_err("help exits through clap") + .kind(), + ErrorKind::DisplayHelp, + "{command:?} supports {help}" + ); + } + } + assert_eq!( + Cli::try_parse_from(["tix", "-x"]) + .expect_err("hide requires a value") + .kind(), + ErrorKind::InvalidValue + ); + assert_eq!( + Cli::try_parse_from(["tix", "amend", "topic"]) + .expect_err("commands reject TUI arguments") + .kind(), + ErrorKind::UnknownArgument + ); + assert_eq!( + Cli::try_parse_from(["tix", "pin"]) + .expect_err("pin requires at least one revision") + .kind(), + ErrorKind::MissingRequiredArgument + ); + + let cli = Cli::try_parse_from(["tix", "--", "amend"]).expect("-- makes amend a revision"); + assert!(cli.platform.command.is_none()); + assert_eq!(cli.platform.revisions, ["amend"]); + } + + #[test] + fn spills_multiple_cli_paths_atomically() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + std::fs::write(fixture.path().join("second"), "second\n")?; + std::fs::write(fixture.path().join("other"), "other\n")?; + assert!( + ProcessCommand::new("git") + .current_dir(fixture.path()) + .args(["add", "second", "other"]) + .status()? + .success(), + "git stages the additional tip paths" + ); + assert!( + ProcessCommand::new("git") + .current_dir(fixture.path()) + .args(["commit", "-q", "--amend", "--no-edit"]) + .status()? + .success(), + "git adds all three paths to HEAD" + ); + + let repository = crate::test_repository::open(fixture.path())?; + let old_head = repository.head_id()?.detach(); + let err = Platform { + no_alt_screen: false, + quit_on_finish: None, + hide: Vec::new(), + command: Some(Command::Spill(Spill { + paths: vec![OsString::from("tip"), OsString::from("missing")], + })), + revisions: Vec::new(), + } + .run(repository.into_sync()) + .expect_err("an unchanged path rejects the complete spill"); + assert!(err.to_string().contains("missing"), "the error identifies the path"); + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!(repository.head_id()?, old_head, "validation leaves HEAD untouched"); + + Platform { + no_alt_screen: false, + quit_on_finish: None, + hide: Vec::new(), + command: Some(Command::Spill(Spill { + paths: vec![OsString::from("tip"), OsString::from("second"), OsString::from("tip")], + })), + revisions: Vec::new(), + } + .run(repository.into_sync())?; + + let repository = crate::test_repository::open(fixture.path())?; + let tree = repository.head_commit()?.tree()?; + assert!( + tree.lookup_entry(["other"])?.is_some(), + "the unselected path remains in HEAD" + ); + assert!( + tree.lookup_entry(["tip"])?.is_none(), + "the first selected path is spilled" + ); + assert!( + tree.lookup_entry(["second"])?.is_none(), + "the second selected path is spilled" + ); + let status = ProcessCommand::new("git") + .current_dir(fixture.path()) + .args(["status", "--short"]) + .output()?; + assert!(status.status.success(), "git reads the resulting status"); + assert_eq!( + status.stdout, b"?? second\n?? tip\n", + "spilled content remains in the worktree" + ); + assert_eq!( + crate::edit::undo::position(&repository)?, + crate::edit::undo::Position { + title: "spill".into(), + undo: 1, + redo: 0, + }, + "all paths form one undoable operation" + ); + Ok(()) + } + + #[test] + fn clear_undo_is_worktree_local_and_idempotent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let linked = gix_testtools::tempfile::tempdir()?; + let linked_path = linked.path().join("linked"); + assert!( + ProcessCommand::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q", "--detach"]) + .arg(&linked_path) + .arg("topic") + .status()? + .success(), + "git creates the linked worktree" + ); + let main = crate::test_repository::open(fixture.path())?; + let linked = crate::test_repository::open(&linked_path)?; + let retained_ref: gix::refs::FullName = "refs/worktree/tix/admin-clear-test" + .try_into() + .expect("valid worktree reference"); + for repository in [&main, &linked] { + let id = repository.head_id()?.detach(); + repository.reference( + retained_ref.clone(), + id, + gix::refs::transaction::PreviousValue::MustNotExist, + "test clear-undo", + )?; + crate::edit::undo::record( + repository, + "create test ref", + &[crate::edit::undo::RefChange { + name: retained_ref.clone(), + before: crate::edit::undo::State::Missing, + after: crate::edit::undo::State::Object(id), + }], + )?; + } + let main_position = crate::edit::undo::position(&main)?; + let linked_target = linked.find_reference(retained_ref.as_ref())?.id().detach(); + + Platform { + no_alt_screen: false, + quit_on_finish: None, + hide: Vec::new(), + command: Some(Command::Admin(Admin::ClearUndo)), + revisions: Vec::new(), + } + .run(linked.into_sync())?; + + let linked = crate::test_repository::open(&linked_path)?; + assert!(linked.try_find_reference(crate::edit::undo::TIP_REF)?.is_none()); + assert!(linked.try_find_reference(crate::edit::undo::CURSOR_REF)?.is_none()); + assert_eq!( + linked.find_reference(retained_ref.as_ref())?.id(), + linked_target, + "clearing history does not apply or reverse a recorded operation" + ); + assert_eq!( + crate::edit::undo::position(&main)?, + main_position, + "another worktree keeps its private queue" + ); + + Platform { + no_alt_screen: false, + quit_on_finish: None, + hide: Vec::new(), + command: Some(Command::Admin(Admin::ClearUndo)), + revisions: Vec::new(), + } + .run(linked.into_sync())?; + Ok(()) + } + + #[test] + fn pin_follows_direct_references_and_keeps_derived_revisions_fixed() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let revisions = |values: &[&str]| values.iter().map(OsString::from).collect::<Vec<_>>(); + let main = repository.rev_parse_single("main")?.detach(); + + for invalid in ["missing", "main..topic", "HEAD^{tree}"] { + create_pins(&repository, &revisions(&["main", invalid])) + .expect_err("a non-commit revision rejects the complete request"); + assert!( + crate::history::all_pins(&repository)?.is_empty(), + "resolution failure is unobservable" + ); + } + + assert!( + ProcessCommand::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["symbolic-ref", "refs/worktree/tix/pins/follow", "refs/heads/main",]) + .status()? + .success(), + "the fixture has a movable symbolic pin" + ); + let root = repository.rev_parse_single("v1")?.object()?.peel_to_commit()?.id; + let parent = repository.rev_parse_single("main~1")?.detach(); + let short_main = main.to_hex_with_len(7).to_string(); + let pins = create_pins(&repository, &revisions(&["main", "v1", "main~1", &short_main]))?; + assert_eq!( + pins.iter().map(|(pin, _created)| pin.id).collect::<Vec<_>>(), + [main, root, parent, main], + "distinct pin targets preserve argument order even when IDs match" + ); + assert_eq!( + pins.iter() + .map(|(pin, _created)| pin.target.try_name().is_some()) + .collect::<Vec<_>>(), + [true, true, false, false], + "direct reference names follow symbolically while derived revisions and IDs stay fixed" + ); + assert_eq!( + crate::history::all_pins(&repository)?.len(), + 4, + "the existing branch pin is reused while other semantic targets remain distinct" + ); + + let repeated = create_pins(&repository, &revisions(&["main"]))?; + assert_eq!(repeated[0].0.name, pins[0].0.name, "an existing symbolic pin is reused"); + assert_eq!(crate::history::all_pins(&repository)?.len(), 4); + let display = display_pin(&repository, &pins[0].0)?; + let (label, ids) = display.split_once(' ').context("pin output has a label and IDs")?; + assert!(label.starts_with("pin:"), "output names the pin"); + assert_eq!( + ids, + crate::change_id::display_short(&repository, main)?, + "output uses matching repository-abbreviated commit and change IDs" + ); + repository.reference( + "refs/heads/main", + parent, + gix::refs::transaction::PreviousValue::MustExistAndMatch(gix::refs::Target::Object(main)), + "advance pinned reference", + )?; + let followed = crate::history::all_pins(&repository)? + .into_iter() + .find(|pin| pin.name == pins[0].0.name) + .context("the symbolic pin remains")?; + assert_eq!(followed.id, parent, "the symbolic pin follows the moved branch"); + Ok(()) + } + + #[test] + fn show_prints_the_complete_plain_history_view() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let err = show( + &repository, + Show { + hide: Vec::new(), + no_auto_hide: true, + revisions: Vec::new(), + }, + ) + .expect_err("disabling auto-hide requires an explicit hidden revision"); + assert!(format!("{err:#}").contains("at least one -x/--hide")); + let mut rounded = Vec::new(); + write_history(&repository, &[], &[OsString::from("v1")], &mut rounded)?; + let rounded = String::from_utf8(rounded)?; + assert!( + rounded.contains(['╭', '╮', '╰', '╯']) && !rounded.contains(['┌', '┐', '└', '┘']), + "history graph turns use rounded corners: {rounded:?}" + ); + create_pins(&repository, &[OsString::from("topic")])?; + let old_head = repository.head_id()?.detach(); + let parent = repository + .find_commit(old_head)? + .parent_ids() + .next() + .context("the fixture head has a parent")? + .detach(); + let mut commit = repository.find_commit(old_head)?.decode()?.into_owned()?; + commit.extra_headers.push(( + crate::change_id::HEADER.into(), + crate::change_id::for_commit(&repository, parent)?.to_string().into(), + )); + let head = repository.write_object(&commit)?.detach(); + let head_ref = repository + .head()? + .referent_name() + .context("the fixture head is attached")? + .to_owned(); + repository.reference( + head_ref, + head, + gix::refs::transaction::PreviousValue::ExistingMustMatch(gix::refs::Target::Object(old_head)), + "test ambiguous change ID", + )?; + let mut orphan = repository.find_commit(parent)?.decode()?.into_owned()?; + orphan.parents.clear(); + orphan.message = "orphan base".into(); + let orphan = repository.write_object(&orphan)?.detach(); + create_pins(&repository, &[OsString::from(orphan.to_string())])?; + let head_change_id = crate::change_id::for_commit(&repository, head)?; + assert!(crate::enrich::toggle(&repository, head)?.todo); + assert!(crate::enrich::toggle_checks_pass(&repository, head)?.checks_pass); + + let mut output = Vec::new(); + write_history(&repository, &[], &[OsString::from("v1")], &mut output)?; + let output = String::from_utf8(output)?; + + assert_eq!(output.lines().count(), 6, "the complete projected history is printed"); + let bases = output + .lines() + .filter(|line| line.contains(" base ")) + .collect::<Vec<_>>(); + assert_eq!(bases.len(), 2, "each distinct visible root becomes a base separator"); + assert!( + bases + .iter() + .all(|line| line.starts_with("────") && line.ends_with("────")), + "base separators use the rebase-todo rails: {bases:?}" + ); + assert!( + bases + .iter() + .any(|line| line.contains(&orphan.to_hex_with_len(7).to_string()) && line.contains("orphan base")), + "a base separator retains commit metadata: {bases:?}" + ); + assert_eq!( + bases + .iter() + .map(|line| Line::raw(*line).width()) + .collect::<HashSet<_>>() + .len(), + 1, + "all base separators span the same display width" + ); + assert!( + output.contains(&format!( + "{} {}", + head.to_hex_with_len(7), + head_change_id.to_reverse_hex_with_len(7) + )), + "a change ID follows its commit hash even when ambiguous: {output:?}" + ); + for id in [head, parent] { + let line = output + .lines() + .find(|line| line.contains(&id.to_hex_with_len(7).to_string())) + .context("the ambiguous commit is shown")?; + assert!( + line.contains('💥'), + "ambiguous change IDs are marked in the gutter: {line:?}" + ); + } + assert!(output.contains('●'), "history graph lanes are rendered"); + assert!( + output.lines().any(|line| line.starts_with("🚧✔️💥├")), + "commit and tree enrichments directly lead their rows: {output:?}" + ); + assert!(output.contains("📌"), "applicable pins are decorated and traversed"); + assert!( + output.contains("topic"), + "a pinned tip outside HEAD history is included" + ); + assert!( + output.contains("Mailmapped Author"), + "default mailmap formatting is retained" + ); + assert!( + output.contains("Co: Human Coauthor"), + "default trailer attribution is retained" + ); + assert!( + output.contains("v1") && output.contains("root"), + "the hidden boundary row is included" + ); + assert!(!output.contains('\u{1b}'), "plain output contains no terminal escapes"); + Ok(()) + } + + #[test] + fn split_command_uses_the_index_for_the_new_commit_and_worktree_for_its_parent() -> gix_testtools::Result { + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = ProcessCommand::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) + } + + let fixture = gix_testtools::scripted_fixture_writable("split_commit.sh")?; + let repository = crate::test_repository::open_with( + fixture.path(), + [format!( + "core.editor={}", + crate::test_repository::replacing_editor("what", "split") + )], + )?; + let graph = crate::edit::loaded_view_graph(&repository)?; + let original = repository.head_id()?.detach(); + crate::enrich::set_note(&repository, original, Some(b"source marker"))?; + split(repository, &graph, Split { todo: true })?; + + assert_eq!(git(fixture.path(), &["log", "-1", "--format=%s"])?, b"split\n"); + assert_eq!(git(fixture.path(), &["show", "HEAD^:unstaged"])?, b"worktree\n"); + assert_eq!(git(fixture.path(), &["show", "HEAD:staged"])?, b"staged\n"); + assert!(git(fixture.path(), &["diff", "--exit-code"])?.is_empty()); + assert!(git(fixture.path(), &["diff", "--cached", "--exit-code"])?.is_empty()); + let repository = crate::test_repository::open(fixture.path())?; + let upper = repository.head_id()?.detach(); + let lower = repository + .find_commit(upper)? + .parent_ids() + .next() + .expect("split has a lower commit") + .detach(); + let mut enrichments = crate::enrich::open(&repository)?; + assert_eq!( + crate::enrich::load(&mut enrichments, crate::change_id::for_commit(&repository, upper)?)?, + crate::enrich::Enrichment { todo: true, note: None }, + "--todo marks only the new upper commit" + ); + assert_eq!( + crate::enrich::load(&mut enrichments, crate::change_id::for_commit(&repository, lower)?)?.note, + Some("source marker".into()), + "the original enrichment remains with the rewritten lower identity" + ); + Ok(()) + } +} diff --git a/gix-tix/src/command/enrich.rs b/gix-tix/src/command/enrich.rs new file mode 100644 index 00000000000..21c5b52b176 --- /dev/null +++ b/gix-tix/src/command/enrich.rs @@ -0,0 +1,319 @@ +use std::ffi::OsString; + +use anyhow::{Context, Result}; +use gix::bstr::ByteSlice; + +#[derive(Debug, clap::Subcommand)] +pub(super) enum Command { + /// Manage enrichments keyed by a commit's change ID. + #[command(subcommand)] + Commit(Commit), + /// Manage enrichments keyed by a commit's tree ID. + #[command(subcommand)] + Tree(Tree), +} + +#[derive(Debug, clap::Subcommand)] +pub(super) enum Commit { + /// Mark the commit as todo, or clear the mark. + Todo(BooleanTarget), + /// Edit the commit's Tix note with Git's editor. + Note(Target), + /// Edit the commit's ordinary Git note with Git's editor. + GitNote(Target), +} + +#[derive(Debug, clap::Subcommand)] +pub(super) enum Tree { + /// Mark the tree as passing checks, or clear the mark. + ChecksPass(BooleanTarget), +} + +#[derive(Debug, clap::Args)] +pub(super) struct BooleanTarget { + /// Clear the enrichment instead of setting it. + #[arg(long)] + clear: bool, + #[command(flatten)] + target: Target, +} + +#[derive(Debug, clap::Args)] +pub(super) struct Target { + /// Commit whose enrichment should be changed. + #[arg(default_value = "HEAD", value_name = "REVSPEC")] + revision: OsString, +} + +pub(super) fn run(repository: gix::Repository, command: Command) -> Result<()> { + match command { + Command::Commit(Commit::Todo(args)) => { + let target = resolve(&repository, &args.target)?; + let reference = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); + let (enrichment, changes) = tracked_ref_update(&repository, reference, |repository| { + crate::enrich::ensure_todo(repository, target, !args.clear) + })?; + super::record_undo( + &repository, + if enrichment.todo { + "mark commit todo" + } else { + "clear commit todo" + }, + changes, + ); + feedback( + &repository, + target, + if enrichment.todo { + "marked commit todo" + } else { + "cleared commit todo" + }, + ) + } + Command::Commit(Commit::Note(args)) => edit_note(&repository, &args), + Command::Commit(Commit::GitNote(args)) => edit_git_note(&repository, &args), + Command::Tree(Tree::ChecksPass(args)) => { + let target = resolve(&repository, &args.target)?; + let reference = crate::enrich::TREE_REF_NAME.try_into().expect("valid tree enrich ref"); + let (enrichment, changes) = tracked_ref_update(&repository, reference, |repository| { + crate::enrich::ensure_checks_pass(repository, target, !args.clear) + })?; + super::record_undo( + &repository, + if enrichment.checks_pass { + "mark tree checks-pass" + } else { + "clear tree checks-pass" + }, + changes, + ); + feedback( + &repository, + target, + if enrichment.checks_pass { + "marked tree checks-pass" + } else { + "cleared tree checks-pass" + }, + ) + } + } +} + +fn resolve(repository: &gix::Repository, target: &Target) -> Result<gix::ObjectId> { + super::resolve_commit(repository, &target.revision, "enrichment target").map(|(id, _)| id) +} + +fn feedback(repository: &gix::Repository, target: gix::ObjectId, status: &str) -> Result<()> { + println!("{} {status}", crate::change_id::display(repository, target, 7)?); + Ok(()) +} + +fn tracked_ref_update<T>( + repository: &gix::Repository, + name: gix::refs::FullName, + update: impl FnOnce(&gix::Repository) -> Result<T>, +) -> Result<(T, Result<Vec<crate::edit::undo::RefChange>>)> { + let before = crate::edit::undo::state(repository, name.as_ref()); + let value = update(repository)?; + let changes = before.and_then(|before| { + crate::edit::undo::state(repository, name.as_ref()).map(|after| { + (before != after) + .then_some(crate::edit::undo::RefChange { name, before, after }) + .into_iter() + .collect() + }) + }); + Ok((value, changes)) +} + +fn edit_note(repository: &gix::Repository, args: &Target) -> Result<()> { + let target = resolve(repository, args)?; + let enrichment = crate::enrich::load( + &mut crate::enrich::open(repository)?, + crate::change_id::for_commit(repository, target)?, + )?; + let document = enrichment.note.clone().unwrap_or_default(); + let editor = repository.editor().context("no Git editor is available")?; + let edited = crate::edit::edit_document_without_terminal( + &editor, + &document, + &format!("tix-note-{}-{}.md", std::process::id(), target.to_hex_with_len(7)), + )?; + let cleaned = crate::edit::reword::cleanup_message(edited.as_deref().unwrap_or(&document), None); + let desired = (!cleaned.is_empty()).then_some(cleaned.as_bstr()); + let (status, changes) = if enrichment.note.as_ref().map(|note| note.as_bstr()) == desired { + ("note unchanged", Ok(Vec::new())) + } else { + let reference = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); + let (_, changes) = tracked_ref_update(repository, reference, |repository| { + crate::enrich::set_note(repository, target, desired.map(AsRef::as_ref)) + })?; + ( + if desired.is_some() { + "saved note" + } else { + "cleared note" + }, + changes, + ) + }; + super::record_undo(repository, "edit commit note", changes); + feedback(repository, target, status) +} + +fn edit_git_note(repository: &gix::Repository, args: &Target) -> Result<()> { + let target = resolve(repository, args)?; + let notes = repository.notes()?; + let reference = notes + .default_ref() + .context("no default Git notes reference is configured")? + .to_owned(); + let mut notes = notes.with_refs([reference.as_bstr()])?; + let document = notes + .get(target)? + .first() + .map(|note| note.blob.data.clone()) + .unwrap_or_default(); + let editor = repository.editor().context("no Git editor is available")?; + let edited = crate::edit::edit_document_without_terminal( + &editor, + &document, + &format!("tix-git-note-{}-{}.md", std::process::id(), target.to_hex_with_len(7)), + )?; + let cleaned = crate::edit::reword::cleanup_message(edited.as_deref().unwrap_or(&document), None); + let (status, changes) = if cleaned == document { + ("Git note unchanged", Ok(Vec::new())) + } else { + let saved = !cleaned.is_empty(); + let (_, changes) = tracked_ref_update(repository, reference.clone(), |_repository| { + let data: Option<&[u8]> = saved.then_some(cleaned.as_ref()); + match data { + Some(data) => notes + .replace_at_ref(reference.as_ref(), target, data) + .context("could not save Git note")?, + None => notes + .remove(reference.as_ref().as_partial_name().to_owned(), target) + .context("could not remove Git note")?, + }; + Ok(()) + })?; + (if saved { "saved Git note" } else { "cleared Git note" }, changes) + }; + super::record_undo(repository, "edit Git note", changes); + feedback(repository, target, status) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target(revision: &str) -> Target { + Target { + revision: revision.into(), + } + } + + #[test] + fn boolean_enrichments_are_idempotent_and_target_the_selected_commit() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let topic = repository.rev_parse_single("topic")?.detach(); + let head = repository.head_id()?.detach(); + + for clear in [false, false, true] { + run( + repository.clone(), + Command::Commit(Commit::Todo(BooleanTarget { + clear, + target: target("topic"), + })), + )?; + run( + repository.clone(), + Command::Tree(Tree::ChecksPass(BooleanTarget { + clear, + target: target("topic"), + })), + )?; + assert_eq!( + crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, topic)?, + )? + .todo, + !clear, + "todo is set or cleared without toggling" + ); + assert_eq!( + crate::enrich::load_tree( + &mut crate::enrich::open_tree(&repository)?, + crate::enrich::tree_id(&repository, topic)?, + )? + .checks_pass, + !clear, + "checks-pass is set or cleared without toggling" + ); + if !clear { + assert!( + !crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, head)?, + )? + .todo, + "the default HEAD remains untouched" + ); + assert!( + !crate::enrich::load_tree( + &mut crate::enrich::open_tree(&repository)?, + crate::enrich::tree_id(&repository, head)?, + )? + .checks_pass, + "the default HEAD tree remains untouched" + ); + } + } + Ok(()) + } + + #[test] + fn editors_preserve_other_enrichments() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open_with( + fixture.path(), + [format!( + "core.editor={}", + crate::test_repository::replacing_editor("old", "new") + )], + )?; + let topic = repository.rev_parse_single("topic")?.detach(); + crate::enrich::ensure_todo(&repository, topic, true)?; + crate::enrich::set_note(&repository, topic, Some(b"old\n"))?; + let notes = repository.notes()?; + let reference = notes.default_ref().context("the fixture has a notes ref")?.to_owned(); + crate::set_git_note(&repository, reference.as_ref(), topic, Some(b"old\n"))?; + + run(repository.clone(), Command::Commit(Commit::Note(target("topic"))))?; + run(repository.clone(), Command::Commit(Commit::GitNote(target("topic"))))?; + + let enrichment = crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, topic)?, + )?; + assert!(enrichment.todo, "editing a note preserves todo"); + assert_eq!( + enrichment.note.as_ref().map(|note| note.as_bstr()), + Some(b"new\n".as_bstr()), + "the Tix note uses the editor output" + ); + let mut notes = repository.notes()?.with_refs([reference.as_bstr()])?; + assert_eq!( + notes.get(topic)?.first().map(|note| note.blob.data.as_bstr()), + Some(b"new\n".as_bstr()), + "the ordinary Git note uses the editor output" + ); + Ok(()) + } +} diff --git a/gix-tix/src/command/new.rs b/gix-tix/src/command/new.rs new file mode 100644 index 00000000000..8af06f08b31 --- /dev/null +++ b/gix-tix/src/command/new.rs @@ -0,0 +1,300 @@ +use anyhow::{Context, Result}; + +#[derive(Debug, clap::Args)] +pub(super) struct Args { + /// Take changes only from the index. + #[arg(long, conflicts_with_all = ["worktree", "worktree_untracked"])] + pub(super) index: bool, + /// Take only tracked worktree changes, ignoring staged-only changes. + #[arg(long, conflicts_with_all = ["index", "worktree_untracked"])] + pub(super) worktree: bool, + /// Take worktree changes and untracked files, ignoring staged-only changes. + #[arg(long, conflicts_with_all = ["index", "worktree"])] + pub(super) worktree_untracked: bool, + /// Create the commit even when the selected tree is unchanged. + #[arg(long)] + pub(super) allow_empty: bool, + /// Mark the new commit as TODO. + #[arg(long)] + pub(super) todo: bool, + #[command(flatten)] + pub(super) edit: super::reword::MessageArgs, +} + +pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { + let parent = repository + .head() + .context("could not read HEAD before creating a commit")? + .id() + .map(gix::Id::detach); + let graph = crate::edit::loaded_graph(&repository)?; + let source = if args.index { + crate::edit::create::Source::Index + } else if args.worktree_untracked { + crate::edit::create::Source::WorktreeUntracked + } else if args.worktree { + crate::edit::create::Source::Worktree + } else { + crate::edit::create::Source::Default + }; + let author = args + .edit + .author + .as_deref() + .map(gix::path::os_str_into_bstr) + .transpose() + .context("author is not valid UTF-8")?; + let repository_path = repository.git_dir().to_owned(); + let bare = repository.is_bare(); + let prepared = crate::edit::create::prepare_from(repository, parent, source, author, args.todo)?; + if prepared.is_empty && !args.allow_empty { + anyhow::bail!("the new commit would be empty; use --allow-empty to create it anyway"); + } + + let explicit = super::reword::explicit_message(&args.edit, std::io::stdin())?; + let outcome = if let Some(message) = explicit { + let mut repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository before creating commit")?; + repository.object_cache_size(None); + crate::edit::create::apply_message_reporting(repository, &graph, prepared, &message)? + } else { + let Some(edited) = crate::edit::edit_document_without_terminal( + &prepared.editor, + &prepared.document, + &format!("tix-commit-{}.md", std::process::id()), + )? + else { + println!("no commit created: no input was provided"); + return Ok(()); + }; + let mut repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + crate::edit::create::apply_reporting(repository, &graph, prepared, &edited)? + }; + let repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository after creating commit")?; + let selected = outcome + .selected + .context("creating a commit did not produce a selection")?; + println!("{}", crate::change_id::display(&repository, selected, 7)?); + super::print_ref_rewrites(&repository, &outcome.ref_rewrites)?; + super::record_undo(&repository, "create commit", Ok(outcome.ref_changes)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use super::*; + + fn args() -> Args { + Args { + index: false, + worktree: false, + worktree_untracked: false, + allow_empty: false, + todo: false, + edit: super::super::reword::MessageArgs { + message: vec!["new title".into(), "new body".into()], + file: None, + author: Some("New Author <new@example.com>".into()), + }, + } + } + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) + } + + #[test] + fn explicit_message_uses_the_default_staged_tree_and_author() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + run( + crate::test_repository::open_with(fixture.path(), ["core.editor=false"])?, + args(), + )?; + + assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"staged\n"); + assert_eq!( + git(fixture.path(), &["log", "-1", "--format=%B"])?, + b"new title\n\nnew body\n\n" + ); + assert_eq!( + git(fixture.path(), &["log", "-1", "--format=%an <%ae>"])?, + b"New Author <new@example.com>\n" + ); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); + Ok(()) + } + + #[test] + fn explicit_message_can_mark_the_new_commit_as_todo() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let mut input = args(); + input.todo = true; + run(crate::test_repository::open(fixture.path())?, input)?; + + let repository = crate::test_repository::open(fixture.path())?; + let id = repository.head_id()?.detach(); + assert!( + crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, id)?, + )? + .todo, + "--todo marks a non-interactive new commit" + ); + Ok(()) + } + + #[test] + fn todo_prefills_the_editable_header() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let parent = repository.head_id()?.detach(); + let prepared = crate::edit::create::prepare_from( + repository, + Some(parent), + crate::edit::create::Source::Default, + None, + true, + )?; + assert!( + prepared + .document + .windows(b"\nTodo\n".len()) + .any(|window| window == b"\nTodo\n"), + "--todo activates the existing editable header" + ); + Ok(()) + } + + #[test] + fn index_and_worktree_select_only_their_requested_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + std::fs::write(fixture.path().join("staged-only"), b"staged only\n")?; + git(fixture.path(), &["add", "staged-only"])?; + let mut worktree = args(); + worktree.worktree = true; + run(crate::test_repository::open(fixture.path())?, worktree)?; + assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"unstaged\n"); + assert!(git(fixture.path(), &["cat-file", "-e", "HEAD:staged-only"]).is_err()); + assert!(git(fixture.path(), &["cat-file", "-e", "HEAD:untracked"]).is_err()); + + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let mut index = args(); + index.index = true; + run(crate::test_repository::open(fixture.path())?, index)?; + assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"staged\n"); + Ok(()) + } + + #[test] + fn worktree_untracked_includes_untracked_but_not_staged_or_ignored_files() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + std::fs::write(fixture.path().join("staged-only"), b"staged only\n")?; + git(fixture.path(), &["add", "staged-only"])?; + std::fs::write(fixture.path().join(".git/info/exclude"), b"ignored\n")?; + std::fs::write(fixture.path().join("ignored"), b"ignored\n")?; + let mut worktree = args(); + worktree.worktree_untracked = true; + run(crate::test_repository::open(fixture.path())?, worktree)?; + + assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"unstaged\n"); + assert_eq!(git(fixture.path(), &["show", "HEAD:untracked"])?, b"untracked\n"); + assert!(git(fixture.path(), &["cat-file", "-e", "HEAD:staged-only"]).is_err()); + assert!(git(fixture.path(), &["cat-file", "-e", "HEAD:ignored"]).is_err()); + Ok(()) + } + + #[test] + fn unchanged_selected_trees_require_allow_empty() -> gix_testtools::Result { + for worktree in [false, true] { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + if worktree { + git(fixture.path(), &["checkout", "--", "tracked"])?; + } else { + git(fixture.path(), &["reset", "-q", "HEAD"])?; + } + let parent = git(fixture.path(), &["rev-parse", "HEAD^{tree}"])?; + let mut selected = args(); + selected.index = !worktree; + selected.worktree = worktree; + let err = run(crate::test_repository::open(fixture.path())?, selected) + .expect_err("an unchanged selected tree is rejected"); + assert!(format!("{err:#}").contains("--allow-empty")); + + let mut empty = args(); + empty.index = !worktree; + empty.worktree = worktree; + empty.allow_empty = true; + run(crate::test_repository::open(fixture.path())?, empty)?; + assert_eq!(git(fixture.path(), &["rev-parse", "HEAD^{tree}"])?, parent); + } + Ok(()) + } + + #[test] + fn allow_empty_creates_an_unborn_root() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + git(fixture.path(), &["init", "-q", "-b", "main"])?; + git(fixture.path(), &["config", "user.name", "author"])?; + git(fixture.path(), &["config", "user.email", "author@example.com"])?; + let mut empty = args(); + empty.allow_empty = true; + run(crate::test_repository::open(fixture.path())?, empty)?; + + assert_eq!(git(fixture.path(), &["rev-list", "--count", "HEAD"])?, b"1\n"); + assert_eq!(git(fixture.path(), &["ls-tree", "HEAD"])?, b""); + Ok(()) + } + + #[test] + fn unchanged_worktree_untracked_requires_allow_empty() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + git(fixture.path(), &["clean", "-fdq"])?; + git(fixture.path(), &["checkout", "--", "tracked"])?; + let parent = git(fixture.path(), &["rev-parse", "HEAD^{tree}"])?; + let mut selected = args(); + selected.worktree_untracked = true; + let err = run(crate::test_repository::open(fixture.path())?, selected) + .expect_err("an unchanged worktree including untracked files is rejected"); + assert!(format!("{err:#}").contains("--allow-empty")); + + let mut empty = args(); + empty.worktree_untracked = true; + empty.allow_empty = true; + run(crate::test_repository::open(fixture.path())?, empty)?; + assert_eq!(git(fixture.path(), &["rev-parse", "HEAD^{tree}"])?, parent); + Ok(()) + } + + #[test] + fn unchanged_editor_input_creates_nothing() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let old = git(fixture.path(), &["rev-parse", "HEAD"])?; + let mut editor = args(); + editor.edit.message.clear(); + editor.edit.author = None; + run( + crate::test_repository::open_with(fixture.path(), ["core.editor=:"])?, + editor, + )?; + + assert_eq!(git(fixture.path(), &["rev-parse", "HEAD"])?, old); + Ok(()) + } +} diff --git a/gix-tix/src/command/rebase.rs b/gix-tix/src/command/rebase.rs new file mode 100644 index 00000000000..c2694c1f439 --- /dev/null +++ b/gix-tix/src/command/rebase.rs @@ -0,0 +1,697 @@ +use std::{ + ffi::{OsStr, OsString}, + io::{IsTerminal, Read, Write}, + path::{Path, PathBuf}, + sync::atomic::AtomicBool, +}; + +use anyhow::{Context, Result}; +use gix::ObjectId; + +use crate::{ + app::App, + edit::{self, rebase, todo}, + history::{self, Authors, Decorations, Event, HistoryGraph}, +}; + +#[derive(Debug, clap::Subcommand)] +pub(super) enum Command { + /// Produce a self-contained rebase todo, or edit and apply it immediately. + Todo(Todo), + /// Apply a self-contained rebase todo from FILE or standard input. + #[command( + after_long_help = "Conflicts change nothing by default. To opt in, write the continuation only when needed:\n tix rebase apply --materialize-conflicts todo.continue.md todo.md\nResolve the index, then run:\n tix rebase apply todo.continue.md\nUse --materialize-conflicts=- to write a continuation to non-terminal stdout." + )] + Apply(Apply), +} + +#[derive(Debug, clap::Args)] +#[command( + after_long_help = "Without --edit-and-apply, the todo is written to stdout. With it, Git's normal editor selection is used; GIT_EDITOR=<command> overrides it.\n\nExamples:\n tix rebase todo -x main topic >todo.md\n ${GIT_EDITOR:-editor} todo.md\n tix rebase apply todo.md\n tix rebase todo --edit-and-apply -x main topic\n tix rebase todo --edit-and-apply --materialize-conflicts todo.continue.md -x main topic" +)] +pub(super) struct Todo { + /// Hide this revision and derive the editable fork point from it. + #[arg(short = 'x', long, value_name = "REVSPEC")] + hide: Vec<OsString>, + /// Do not infer hidden local branches from remote HEADs. + #[arg(long)] + no_auto_hide: bool, + /// Rebase the derived scope onto this commit instead of its fork point. + #[arg(long, value_name = "REV", conflicts_with = "update_base")] + onto: Option<OsString>, + /// Rebase onto the newer hidden local branch tip associated with the fork point. + #[arg(long)] + update_base: bool, + /// Open the todo in Git's editor and apply it after the editor exits. + #[arg(long)] + edit_and_apply: bool, + /// On conflict, materialize it and write a continuation todo to FILE, or stdout if omitted or '-'. + #[arg( + long, + value_name = "CONTINUE", + num_args = 0..=1, + default_missing_value = "-", + requires = "edit_and_apply" + )] + materialize_conflicts: Option<PathBuf>, + /// Visible traversal tips, or HEAD if omitted. + #[arg(value_name = "TIP")] + tips: Vec<OsString>, +} + +#[derive(Debug, clap::Args)] +pub(super) struct Apply { + /// On conflict, materialize it and write a continuation todo to FILE, or stdout if omitted or '-'. + #[arg(long, value_name = "CONTINUE", num_args = 0..=1, default_missing_value = "-")] + pub(super) materialize_conflicts: Option<PathBuf>, + /// Todo file to apply; omit or use '-' to read standard input. + #[arg(value_name = "FILE")] + pub(super) file: Option<PathBuf>, +} + +pub(super) fn run(repo: gix::Repository, command: Command) -> Result<()> { + match command { + Command::Todo(args) => todo(repo, args), + Command::Apply(args) => apply(repo, args), + } +} + +fn todo(repo: gix::Repository, args: Todo) -> Result<()> { + let prepared = prepare(&repo, &args)?; + if !args.edit_and_apply { + std::io::stdout() + .write_all(&prepared.document) + .context("could not write the rebase todo")?; + return Ok(()); + } + + let editor = repo.editor().context("no Git editor is available")?; + let edited = edit::edit_document_without_terminal( + &editor, + &prepared.document, + &format!("tix-rebase-{}.md", std::process::id()), + )? + .unwrap_or(prepared.document); + apply_document(repo, &edited, args.materialize_conflicts.as_deref()) +} + +fn prepare(repo: &gix::Repository, args: &Todo) -> Result<todo::Prepared> { + let (hide, unavailable) = history::available_hidden_revisions(repo, &args.hide, !args.no_auto_hide)?; + if hide.is_empty() { + anyhow::bail!( + "rebase todo requires at least one -x/--hide revision when no remote HEAD maps to a local branch" + ); + } + for (revision, err) in unavailable { + eprintln!( + "warning: ignoring unavailable hidden revision {}: {err}", + revision.to_string_lossy() + ); + } + let refs = history::snapshot(repo, &args.tips, &hide, false)?; + + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut app = App::new(usize::MAX); + let mut decorations = Decorations::default(); + let mut graph = None; + history::load( + repo, + &args.tips, + &hide, + false, + &authors, + &AtomicBool::new(false), + |event| { + match event { + Event::Decorations(value) => decorations = value, + Event::Commits(commits) => app.extend_commits(commits), + Event::HiddenCommits(commits) => app.extend_hidden_commits(commits), + Event::Complete(value) => graph = Some(value), + Event::VisibleComplete | Event::Cancelled => {} + } + true + }, + )?; + let graph = graph.context("history traversal did not produce a graph")?; + crate::update_hidden_branch_updates(&mut app, Some(&graph), &refs); + let mut candidates = app.hidden_rebase_candidates(); + if candidates.len() != 1 { + if candidates.is_empty() { + anyhow::bail!("the hidden and visible revisions have no editable fork point"); + } + candidates.sort_by_key(|(id, _)| *id); + anyhow::bail!( + "the revisions have multiple editable fork points: {}", + candidates + .iter() + .map(|(id, _)| id.to_string()) + .collect::<Vec<_>>() + .join(", ") + ); + } + let (base, scope) = candidates.pop().context("one rebase candidate was expected")?; + let (onto, onto_kind) = if args.update_base { + let onto = app + .hidden_branch_update(base) + .context("--update-base found no newer hidden local branch tip for the derived base")?; + (onto, todo::OntoKind::UpdatedBase) + } else { + ( + args.onto + .as_deref() + .map(|revision| resolve_commit(repo, revision, "onto revision")) + .transpose()? + .unwrap_or(base), + todo::OntoKind::Onto, + ) + }; + + let commits = crate::load_rebase_todo_commits(repo, &mut app, &authors, &scope)?; + todo::prepare(repo, base, onto, &commits, &refs.view_tips, onto_kind, true) +} + +fn resolve_commit(repo: &gix::Repository, revision: &OsStr, description: &str) -> Result<ObjectId> { + let revision = + gix::path::os_str_into_bstr(revision).with_context(|| format!("{description} is not valid UTF-8"))?; + crate::history::resolve_revision(repo, revision) + .with_context(|| format!("could not resolve {description}")) + .map(|(id, _reference)| id) +} + +fn apply(repo: gix::Repository, args: Apply) -> Result<()> { + let mut document = Vec::new(); + match args.file.as_deref() { + None => { + std::io::stdin() + .read_to_end(&mut document) + .context("could not read the rebase todo from standard input")?; + } + Some(path) if path == Path::new("-") => { + std::io::stdin() + .read_to_end(&mut document) + .context("could not read the rebase todo from standard input")?; + } + Some(path) => { + document = + std::fs::read(path).with_context(|| format!("could not read rebase todo at {}", path.display()))?; + } + } + apply_document(repo, &document, args.materialize_conflicts.as_deref()) +} + +fn apply_document(repo: gix::Repository, document: &[u8], materialize_conflicts: Option<&Path>) -> Result<()> { + let Some(parsed) = todo::parse(&repo, document)? else { + println!("no rebase performed: the todo was cancelled"); + return Ok(()); + }; + let graph = HistoryGraph::for_commits(&repo, &parsed.plan.scope)?; + let repository_path = repo.git_dir().to_owned(); + let bare = repo.is_bare(); + let tips = parsed.tips; + match rebase::perform_plan(&repo, &graph, parsed.plan)? { + rebase::PlanPerform::Complete(outcome) => { + let revisions = mapped_revisions(&tips, |id| outcome.map(id)); + let changes = if let Some(selected) = outcome.selected { + let (notice, changes) = match edit::time_travel::checkout_plan_reporting( + &repository_path, + bare, + &outcome, + &revisions, + false, + ) { + Ok(result) => result, + Err(err) => { + super::record_undo(&repo, "rebase history", Ok(outcome.ref_changes)); + return Err(err); + } + }; + let notice = notice.unwrap_or_else(|| "rebased history".into()); + println!("{}", super::notice_with_change_id(&repo, ¬ice, selected)?); + changes + } else { + println!("rebased history"); + outcome.ref_changes.clone() + }; + super::print_ref_rewrites(&repo, &outcome.ref_rewrites)?; + super::record_undo(&repo, "rebase history", Ok(changes)); + Ok(()) + } + rebase::PlanPerform::Conflict(conflict) => { + handle_plan_conflict(&repo, conflict, materialize_conflicts, &tips, "rebase") + } + } +} + +pub(super) fn handle_plan_conflict( + repo: &gix::Repository, + mut conflict: rebase::PlanConflict, + materialize_conflicts: Option<&Path>, + tips: &[ObjectId], + operation: &str, +) -> Result<()> { + let Some(destination) = materialize_conflicts else { + anyhow::bail!( + "{operation} aborted without changes: conflict while applying {}; pass --materialize-conflicts to opt in", + conflict.original().to_hex_with_len(7) + ); + }; + if destination == Path::new("-") && std::io::stdout().is_terminal() { + anyhow::bail!( + "{operation} aborted without changes: refusing to materialize a conflict without a continuation output file" + ); + } + conflict.persist_objects()?; + let plan = conflict.continuation_plan(); + let mapped_tips = tips.iter().filter_map(|id| conflict.map(*id)).collect(); + let continuation = todo::prepare_continuation(conflict.repository(), &plan, mapped_tips, true)?.document; + let revisions = mapped_revisions(tips, |id| conflict.map(id)); + if destination == Path::new("-") { + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(&continuation) + .and_then(|_| stdout.flush()) + .context("could not write the continuation rebase todo")?; + } else { + let mut output = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .with_context(|| format!("could not create continuation rebase todo at {}", destination.display()))?; + output + .write_all(&continuation) + .with_context(|| format!("could not write continuation rebase todo at {}", destination.display()))?; + } + let materialized = edit::time_travel::materialize_plan_conflict_reporting( + conflict, + repo.git_dir(), + repo.is_bare(), + &revisions, + false, + ); + let (notice, _, ref_rewrites, ref_changes) = match materialized { + Ok(materialized) => materialized, + Err(err) => { + if destination != Path::new("-") { + let _ = std::fs::remove_file(destination); + } + return Err(err); + } + }; + if destination == Path::new("-") { + for line in super::ref_rewrite_lines(repo, &ref_rewrites)? { + eprintln!("{line}"); + } + } else { + super::print_ref_rewrites(repo, &ref_rewrites)?; + } + super::record_undo(repo, "materialize rebase conflict", Ok(ref_changes)); + eprintln!("{notice}; continue with `tix rebase apply {}`", destination.display()); + anyhow::bail!("{operation} stopped at a materialized conflict") +} + +fn mapped_revisions(tips: &[ObjectId], mut map: impl FnMut(ObjectId) -> Option<ObjectId>) -> Vec<OsString> { + tips.iter() + .filter_map(|id| map(*id)) + .map(|id| OsString::from(id.to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn repository() -> gix_testtools::Result<(gix_testtools::tempfile::TempDir, gix::Repository)> { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], + )?; + Ok((fixture, repo)) + } + + #[test] + fn generates_a_self_contained_todo_from_hidden_and_visible_revisions() -> gix_testtools::Result { + let (_fixture, repo) = repository()?; + let prepared = prepare( + &repo, + &Todo { + hide: vec!["HEAD~2".into()], + no_auto_hide: false, + onto: None, + update_base: false, + edit_and_apply: false, + materialize_conflicts: None, + tips: Vec::new(), + }, + )?; + let document = String::from_utf8(prepared.document)?; + assert!(document.contains("<!-- tix-rebase-state-v2"), "state is embedded"); + assert!(document.contains("`@pick "), "HEAD is the generated checkout"); + assert!( + document.contains("2000-01-02 author middle"), + "default TUI metadata is present" + ); + assert!( + document.contains("2000-01-03 author tip"), + "the subject is always present" + ); + Ok(()) + } + + #[test] + fn requires_a_hidden_revision_at_runtime() -> gix_testtools::Result { + let (_fixture, repo) = repository()?; + let err = prepare( + &repo, + &Todo { + hide: Vec::new(), + no_auto_hide: true, + onto: None, + update_base: false, + edit_and_apply: false, + materialize_conflicts: None, + tips: Vec::new(), + }, + ) + .expect_err("a hidden revision is required"); + assert!(format!("{err:#}").contains("at least one -x/--hide")); + Ok(()) + } + + #[test] + fn infers_the_hidden_base_from_a_remote_head() -> gix_testtools::Result { + let (fixture, _repo) = repository()?; + for args in [ + &["branch", "base", "HEAD~2"][..], + &["config", "remote.origin.url", "https://example.com/repo"][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + &["update-ref", "refs/remotes/origin/base", "refs/heads/base"][..], + &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/base"][..], + ] { + let output = Command::new("git").current_dir(fixture.path()).args(args).output()?; + assert!( + output.status.success(), + "git {args:?} prepares the remote default: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let repo = crate::test_repository::open_with( + fixture.path(), + ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], + )?; + let prepared = prepare( + &repo, + &Todo { + hide: Vec::new(), + no_auto_hide: false, + onto: None, + update_base: false, + edit_and_apply: false, + materialize_conflicts: None, + tips: Vec::new(), + }, + )?; + assert!( + String::from_utf8(prepared.document)?.contains("# Rebase from"), + "the inferred local default branch provides the rebase base" + ); + Ok(()) + } + + #[test] + fn update_base_uses_the_newer_hidden_local_branch_tip() -> gix_testtools::Result { + let (fixture, repo) = repository()?; + drop(repo); + for args in [ + &["branch", "base", "HEAD~2"][..], + &["checkout", "-q", "base"][..], + &[ + "-c", + "user.name=updated base", + "-c", + "user.email=updated@example.com", + "-c", + "commit.gpgSign=false", + "commit", + "-q", + "--allow-empty", + "-m", + "updated base", + ][..], + &["checkout", "-q", "main"][..], + ] { + let output = Command::new("git").current_dir(fixture.path()).args(args).output()?; + assert!( + output.status.success(), + "git {args:?} prepares the updated base: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let repo = crate::test_repository::open_with( + fixture.path(), + ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], + )?; + let updated = repo.rev_parse_single("base")?.detach(); + let prepared = prepare( + &repo, + &Todo { + hide: vec!["base".into()], + no_auto_hide: false, + onto: None, + update_base: true, + edit_and_apply: false, + materialize_conflicts: None, + tips: Vec::new(), + }, + )?; + let document = String::from_utf8(prepared.document)?; + assert!( + prepared.apply_unchanged, + "moving to the updated base makes an unchanged todo actionable" + ); + assert!( + document.contains(&format!( + "{} (updated-base)", + crate::change_id::display_short(&repo, updated)? + )), + "the TUI-selected hidden tip is labelled as the updated base: {document}" + ); + + let err = prepare( + &repo, + &Todo { + hide: vec!["HEAD~2".into()], + no_auto_hide: false, + onto: None, + update_base: true, + edit_and_apply: false, + materialize_conflicts: None, + tips: Vec::new(), + }, + ) + .expect_err("a derived revision without a hidden local branch has no update target"); + assert!(format!("{err:#}").contains("no newer hidden local branch tip")); + Ok(()) + } + + #[test] + fn materialized_conflicts_emit_an_applicable_continuation_todo() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + // This descendant is rewritten into object memory and conflicts after the earlier conflict is resolved. + std::fs::write(fixture.path().join("file"), b"after\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "file"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-q", "-m", "after"]) + .status()? + .success() + ); + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + let base = repo.rev_parse_single("HEAD~3")?.detach(); + let middle = repo.rev_parse_single("HEAD~2")?.detach(); + let tip = repo.rev_parse_single("HEAD~1")?.detach(); + let after = repo.head_id()?.detach(); + let prepared = todo::prepare( + &repo, + base, + base, + &[ + todo::Commit { + id: tip, + parents: vec![middle], + info: "tip".into(), + }, + todo::Commit { + id: middle, + parents: vec![base], + info: "middle".into(), + }, + todo::Commit { + id: after, + parents: vec![tip], + info: "after".into(), + }, + ], + &[after], + todo::OntoKind::Onto, + true, + )?; + let generated = std::str::from_utf8(&prepared.document)?; + let state = &generated[generated + .find("<!-- tix-rebase-state-v2") + .expect("generated state is present")..]; + let edited = format!( + "`@pick {}` after\n`pick {}` tip\n──── fork {} ────\n\n{state}", + after.to_hex_with_len(7), + tip.to_hex_with_len(7), + base.to_hex_with_len(7) + ); + let output_dir = gix_testtools::tempfile::tempdir()?; + let output = output_dir.path().join("continue.md"); + let err = apply_document(repo, edited.as_bytes(), Some(&output)).expect_err("the conflict stops the command"); + assert!( + format!("{err:#}").contains("materialized conflict"), + "the conflict is materialized after its continuation is written: {err:#}" + ); + let continuation = std::fs::read(&output)?; + assert!( + continuation + .windows(40) + .any(|window| window.iter().all(|byte| *byte == b'0')), + "the conflicting command is represented by the full null object ID" + ); + let unresolved = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["diff", "--name-only", "--diff-filter=U"]) + .output()?; + assert!(unresolved.status.success()); + assert_eq!( + unresolved.stdout, b"file\n", + "materialization writes the unmerged index" + ); + let materialized = crate::test_repository::open(fixture.path())?; + let conflict_commit = materialized.head_commit()?; + assert_eq!( + conflict_commit.tree_id()?, + conflict_commit + .parent_ids() + .next() + .expect("a cherry-picked commit has a parent") + .object()? + .peel_to_tree()? + .id, + "the materialized conflict commit records the ours tree" + ); + + std::fs::write(fixture.path().join("file"), b"resolved first\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "file"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-q", "--amend", "--no-edit"]) + .status()? + .success(), + "resolving through the CLI may amend the materialized conflict before continuing" + ); + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + let first_resolution = repo.head_id()?.detach(); + let next_output = output_dir.path().join("continue-again.md"); + let err = apply_document(repo, &continuation, Some(&next_output)) + .expect_err("the descendant conflict stops the continuation"); + assert!( + format!("{err:#}").contains("materialized conflict"), + "the second conflict is materialized: {err:#}" + ); + assert!( + crate::history::all_pins(&crate::test_repository::open(fixture.path())?)? + .iter() + .all(|pin| pin.id != first_resolution), + "continuing does not pin the superseded CLI resolution" + ); + std::fs::write(fixture.path().join("file"), b"resolved again\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "file"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-q", "--amend", "--no-edit"]) + .status()? + .success(), + "the final conflict may also be amended before continuing" + ); + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + apply_document(repo, &std::fs::read(next_output)?, None)?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["diff", "--name-only", "--diff-filter=U"]) + .output()? + .stdout + .is_empty(), + "the continuation consumes the resolved index" + ); + assert!( + crate::history::all_pins(&crate::test_repository::open(fixture.path())?)?.is_empty(), + "successive materialized conflicts do not leave departure pins" + ); + Ok(()) + } + + #[test] + fn successful_apply_does_not_create_a_continuation_file() -> gix_testtools::Result { + let (_fixture, repo) = repository()?; + let prepared = prepare( + &repo, + &Todo { + hide: vec!["HEAD~2".into()], + no_auto_hide: false, + onto: None, + update_base: false, + edit_and_apply: false, + materialize_conflicts: None, + tips: Vec::new(), + }, + )?; + let output_dir = gix_testtools::tempfile::tempdir()?; + let output = output_dir.path().join("unused.md"); + apply_document(repo, &prepared.document, Some(&output))?; + assert!(!output.exists(), "continuation output is created only after a conflict"); + Ok(()) + } +} diff --git a/gix-tix/src/command/reword.rs b/gix-tix/src/command/reword.rs new file mode 100644 index 00000000000..4778ffd0a00 --- /dev/null +++ b/gix-tix/src/command/reword.rs @@ -0,0 +1,443 @@ +use std::{ + ffi::OsString, + io::Read, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; + +#[derive(Debug, clap::Args)] +pub(super) struct MessageArgs { + /// Use this message instead of opening an editor; repeat to add paragraphs. + #[arg(short = 'm', long, value_name = "MESSAGE", conflicts_with = "file")] + pub(super) message: Vec<OsString>, + /// Read the new message from this file, or from standard input with `-`. + #[arg(short = 'f', long, value_name = "FILE", conflicts_with = "message")] + pub(super) file: Option<PathBuf>, + /// Set the author actor while preserving the original author date. + #[arg(long, value_name = "NAME <EMAIL>")] + pub(super) author: Option<OsString>, +} + +#[derive(Debug, clap::Args)] +pub(super) struct Args { + /// Revision resolving to the commit whose message should be edited. + #[arg(value_name = "REVSPEC")] + pub(super) revision: OsString, + #[command(flatten)] + pub(super) edit: MessageArgs, +} + +pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { + let (target, resolved_graph) = super::resolve_commit(&repository, &args.revision, "reword target")?; + let head = repository.head().context("could not read HEAD before rewording")?; + let head_id = head.id().map(gix::Id::detach).context("cannot reword an unborn HEAD")?; + let attached_head = !head.is_detached() && target == head_id; + drop(head); + + let pins = crate::history::all_pins(&repository)?; + let mut revisions = vec![OsString::from("HEAD"), OsString::from(target.to_string())]; + revisions.extend( + pins.iter() + .map(|pin| gix::path::from_bstr(pin.name.as_bstr()).into_owned().into_os_string()), + ); + let graph = match resolved_graph { + Some(graph) => graph, + None => crate::edit::loaded_view_graph_with(&repository, &revisions)?, + }; + ensure_retained_target(&graph, target, &pins, attached_head)?; + + let author = args + .edit + .author + .as_deref() + .map(gix::path::os_str_into_bstr) + .transpose() + .context("author is not valid UTF-8")?; + + if let Some(message) = explicit_message(&args.edit, std::io::stdin())? { + let output_repository = repository.clone(); + return finish( + &output_repository, + crate::edit::reword::apply_message_reporting( + repository, + &graph, + target, + &message, + author.map(AsRef::as_ref), + )?, + ); + } + + let repository_path = repository.git_dir().to_owned(); + let bare = repository.is_bare(); + let change_id = crate::change_id::for_commit(&repository, target)?; + let (editor, document) = crate::edit::reword::document_with_author(&repository, target, author.map(AsRef::as_ref))?; + drop(repository); + let edited = crate::edit::edit_document_without_terminal( + &editor, + &document, + &format!("tix-reword-{}-{}.md", std::process::id(), target.to_hex_with_len(7)), + )?; + let edited = match edited { + Some(edited) => edited, + None if author.is_some() => document, + None => { + println!("no reword performed: the editor document was unchanged"); + return Ok(()); + } + }; + + let mut repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + let (graph, target) = crate::edit::reword::relocate_after_editor(&repository, &[], &[], change_id)?; + let pins = crate::history::all_pins(&repository)?; + let head = repository.head().context("could not read HEAD after editing commit")?; + let attached_head = !head.is_detached() && head.id().map(gix::Id::detach) == Some(target); + drop(head); + ensure_retained_target(&graph, target, &pins, attached_head)?; + let output_repository = repository.clone(); + finish_editor( + &output_repository, + crate::edit::reword::apply(repository, &graph, target, &edited)?, + ) +} + +fn ensure_retained_target( + graph: &crate::history::HistoryGraph, + target: gix::ObjectId, + pins: &[crate::history::Pin], + attached_head: bool, +) -> Result<()> { + if !attached_head && !pins.iter().any(|pin| graph.is_ancestor(target, pin.id)) { + anyhow::bail!("the reword target or one of its descendants must be pinned"); + } + Ok(()) +} + +pub(super) fn explicit_message(args: &MessageArgs, mut stdin: impl Read) -> Result<Option<Vec<u8>>> { + if !args.message.is_empty() { + let mut out = Vec::new(); + for (index, message) in args.message.iter().enumerate() { + if index > 0 { + out.extend_from_slice(b"\n\n"); + } + out.extend_from_slice( + gix::path::os_str_into_bstr(message) + .with_context(|| format!("message {} is not valid UTF-8", index + 1))?, + ); + } + return Ok(Some(out)); + } + let Some(path) = args.file.as_deref() else { + return Ok(None); + }; + if path == Path::new("-") { + let mut out = Vec::new(); + stdin + .read_to_end(&mut out) + .context("could not read the commit message from standard input")?; + Ok(Some(out)) + } else { + std::fs::read(path) + .with_context(|| format!("could not read commit message at {}", path.display())) + .map(Some) + } +} + +fn finish(repository: &gix::Repository, outcome: crate::edit::reword::Outcome) -> Result<()> { + match outcome.commit { + Some(id) => println!("{}", crate::change_id::display(repository, id, 7)?), + None => println!("no reword performed: the edited commit was unchanged"), + } + super::print_ref_rewrites(repository, &outcome.ref_rewrites)?; + super::record_undo(repository, "reword commit", Ok(outcome.ref_changes)); + Ok(()) +} + +fn finish_editor(repository: &gix::Repository, outcome: crate::edit::reword::Outcome) -> Result<()> { + let title = if outcome.commit.is_some() { + "reword commit" + } else { + "edit commit enrichment" + }; + match (outcome.commit, outcome.enrichment) { + (Some(id), _) => println!("{}", crate::change_id::display(repository, id, 7)?), + (None, Some(_)) => println!("{}", crate::change_id::display(repository, outcome.target, 7)?), + (None, None) => println!("no reword performed: the edited commit was unchanged"), + } + super::print_ref_rewrites(repository, &outcome.ref_rewrites)?; + super::record_undo(repository, title, Ok(outcome.ref_changes)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!("git {} failed: {}", args.join(" "), output.stderr.trim().to_str_lossy()).into()); + } + Ok(output.stdout) + } + + fn open(path: &Path, editor: &str) -> gix_testtools::Result<gix::Repository> { + Ok(crate::test_repository::open_with( + path, + [format!("core.editor={editor}")], + )?) + } + + fn args(revision: &str) -> Args { + Args { + revision: revision.into(), + edit: MessageArgs { + message: Vec::new(), + file: None, + author: None, + }, + } + } + + #[test] + fn explicit_message_sources_are_complete_and_git_like() -> gix_testtools::Result { + let mut message_args = args("HEAD"); + message_args.edit.message = vec!["title".into(), "body".into()]; + assert_eq!( + explicit_message(&message_args.edit, &b"ignored"[..])?, + Some(b"title\n\nbody".to_vec()), + "repeated messages become paragraphs without reading stdin" + ); + + let mut file_args = args("HEAD"); + file_args.edit.file = Some("-".into()); + assert_eq!( + explicit_message(&file_args.edit, &b"from stdin\n"[..])?, + Some(b"from stdin\n".to_vec()), + "a dash reads the entire message from stdin" + ); + + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path().join("message.md"); + std::fs::write(&path, b"from file\n\nbody\n")?; + let mut file_args = args("HEAD"); + file_args.edit.file = Some(path); + assert_eq!( + explicit_message(&file_args.edit, &b"ignored"[..])?, + Some(b"from file\n\nbody\n".to_vec()), + "a file supplies the complete message" + ); + Ok(()) + } + + #[test] + fn message_bypasses_the_editor_and_rewords_head() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let original = repository.head_id()?.detach(); + crate::enrich::toggle(&repository, original)?; + crate::enrich::set_note(&repository, original, Some(b"kept\n\nbody"))?; + let mut initial = args("HEAD"); + initial.edit.message = vec!["replacement title".into(), ";literal body".into()]; + initial.edit.author = Some("Agent <agent@example.com>".into()); + run(open(fixture.path(), "false")?, initial)?; + + assert_eq!( + git(fixture.path(), &["log", "-1", "--format=%B"])?, + b"replacement title\n\n;literal body\n\n", + "an explicit message bypasses the editor and retains editor-comment-looking lines" + ); + assert_eq!( + git(fixture.path(), &["log", "-1", "--format=%an <%ae>"])?, + b"Agent <agent@example.com>\n", + "an explicit message applies the author without opening an editor" + ); + let repository = crate::test_repository::open(fixture.path())?; + let head = repository.head_id()?.detach(); + assert_eq!( + crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, head)? + )?, + crate::enrich::Enrichment { + todo: true, + note: Some("kept\n\nbody".into()), + }, + "explicit messages preserve enrichments" + ); + let rewritten = git(fixture.path(), &["rev-parse", "HEAD"])?; + let mut same = args("HEAD"); + same.edit.message = vec!["replacement title\n\n;literal body".into()]; + run(open(fixture.path(), "false")?, same)?; + assert_eq!( + git(fixture.path(), &["rev-parse", "HEAD"])?, + rewritten, + "an unchanged explicit message does not rewrite the commit" + ); + + let mut empty = args("HEAD"); + empty.edit.message = vec![" ".into()]; + let err = run(open(fixture.path(), "false")?, empty) + .expect_err("an empty cleaned message must not replace the commit message"); + assert!(format!("{err:#}").contains("message is empty")); + Ok(()) + } + + #[test] + fn author_prefills_the_editor_and_preserves_message_and_date() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let message = git(fixture.path(), &["log", "-1", "--format=%B"])?; + let author_date = git(fixture.path(), &["log", "-1", "--format=%aI"])?; + let mut reword = args("HEAD"); + reword.edit.author = Some("Agent <agent@example.com>".into()); + run(open(fixture.path(), ":")?, reword)?; + + assert_eq!(git(fixture.path(), &["log", "-1", "--format=%B"])?, message); + assert_eq!(git(fixture.path(), &["log", "-1", "--format=%aI"])?, author_date); + assert_eq!( + git(fixture.path(), &["log", "-1", "--format=%an <%ae>"])?, + b"Agent <agent@example.com>\n" + ); + + let old = git(fixture.path(), &["rev-parse", "HEAD"])?; + let mut invalid = args("HEAD"); + invalid.edit.author = Some("missing-email".into()); + let err = run(open(fixture.path(), "false")?, invalid) + .expect_err("an invalid author is rejected before invoking the editor"); + assert!(format!("{err:#}").contains("author identity")); + assert_eq!(git(fixture.path(), &["rev-parse", "HEAD"])?, old); + Ok(()) + } + + #[test] + fn attached_head_needs_no_pin() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + run( + open( + fixture.path(), + &crate::test_repository::replacing_editor("tip", "rewritten tip"), + )?, + args("HEAD"), + )?; + + assert_eq!(git(fixture.path(), &["log", "-1", "--format=%s"])?, b"rewritten tip\n"); + let repository = crate::test_repository::open(fixture.path())?; + assert!(!repository.head()?.is_detached()); + assert!(crate::history::all_pins(&repository)?.is_empty()); + Ok(()) + } + + #[test] + fn detached_head_requires_a_pin_before_the_editor() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + git(fixture.path(), &["checkout", "-q", "--detach", "HEAD"])?; + let err = run(open(fixture.path(), "false")?, args("HEAD")) + .expect_err("a detached HEAD does not provide a durable rewrite tip"); + assert!(format!("{err:#}").contains("must be pinned")); + assert!(crate::history::all_pins(&crate::test_repository::open(fixture.path())?)?.is_empty()); + Ok(()) + } + + #[test] + fn other_targets_require_a_covering_pin_before_the_editor() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + let err = run(open(path, "false")?, args("HEAD~1")).expect_err("an ancestor without a pin is rejected"); + assert!(format!("{err:#}").contains("must be pinned")); + + let repository = crate::test_repository::open(path)?; + let old_tip = repository.head_id()?.detach(); + repository.reference( + "refs/worktree/tix/pins/keep", + old_tip, + gix::refs::transaction::PreviousValue::MustNotExist, + "test pin", + )?; + let change_id = crate::change_id::for_commit(&repository, repository.rev_parse_single("HEAD~1")?.detach())? + .to_reverse_hex_with_len(7) + .to_string(); + drop(repository); + run( + open( + path, + &crate::test_repository::replacing_editor("middle", "rewritten middle"), + )?, + args(&change_id), + )?; + + let repository = crate::test_repository::open(path)?; + let new_tip = repository.head_id()?.detach(); + assert_ne!(new_tip, old_tip, "the checked-out descendant is reparented"); + assert_eq!( + repository + .find_commit(new_tip)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(repository.rev_parse_single("HEAD~1")?.detach()), + "the rewritten descendant points to the edited commit" + ); + assert_eq!( + crate::history::all_pins(&repository)?[0].id, + new_tip, + "the covering pin follows its rewritten descendant" + ); + assert!( + repository + .find_commit(new_tip)? + .decode()? + .extra_headers() + .find("tix-rebase-parent") + .is_none(), + "the checked-out descendant is replayed eagerly" + ); + assert_eq!( + git(path, &["log", "-1", "--format=%s", "HEAD~1"])?, + b"rewritten middle\n" + ); + Ok(()) + } + + #[test] + fn a_pin_can_expose_an_unrelated_reword_stack() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + git(path, &["branch", "side", "HEAD~2"])?; + git(path, &["checkout", "-q", "side"])?; + git(path, &["commit", "-q", "--allow-empty", "-m", "side"])?; + let side = String::from_utf8(git(path, &["rev-parse", "HEAD"])?)?; + let side = side.trim(); + git(path, &["checkout", "-q", "main"])?; + git(path, &["update-ref", "refs/worktree/tix/pins/side", side])?; + let main = git(path, &["rev-parse", "main"])?; + + run( + open( + path, + &crate::test_repository::replacing_editor("side", "rewritten side"), + )?, + args("side"), + )?; + + assert_eq!( + git(path, &["rev-parse", "main"])?, + main, + "the unrelated checkout stack is untouched" + ); + assert_eq!(git(path, &["log", "-1", "--format=%s", "side"])?, b"rewritten side\n"); + let repository = crate::test_repository::open(path)?; + assert_eq!( + crate::history::all_pins(&repository)?[0].id, + repository.rev_parse_single("side")?, + "the explicit unrelated pin follows the rewritten commit" + ); + Ok(()) + } +} diff --git a/gix-tix/src/command/travel.rs b/gix-tix/src/command/travel.rs new file mode 100644 index 00000000000..9fc9eef1440 --- /dev/null +++ b/gix-tix/src/command/travel.rs @@ -0,0 +1,669 @@ +use std::{ + collections::{HashMap, HashSet}, + ffi::OsString, +}; + +use anyhow::{Context, Result}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] +pub(super) enum To { + /// Visit the oldest reachable root in HEAD's visible history. + First, + /// Visit HEAD's direct visible parent. + Parent, + /// Visit HEAD's direct visible child. + Child, + /// Visit the reachable leaf in HEAD's visible history. + Tip, +} + +impl To { + fn name(self) -> &'static str { + match self { + To::First => "first", + To::Parent => "parent", + To::Child => "child", + To::Tip => "tip", + } + } +} + +#[derive(Debug, clap::Args)] +#[command(group( + clap::ArgGroup::new("destination") + .required(true) + .multiple(false) + .args(["revision", "to"]) +))] +pub(super) struct Args { + /// Check out an encountered replay conflict and write its unmerged index. + #[arg(long)] + pub(super) materialize_conflicts: bool, + /// Revision resolving to the commit to visit. + #[arg(value_name = "REVSPEC")] + pub(super) revision: Option<OsString>, + /// Visit a commit relative to HEAD in the default Tix view. + #[arg(long, value_enum, value_name = "DESTINATION")] + pub(super) to: Option<To>, +} + +pub(super) fn run(repository: gix::Repository, args: Args) -> Result<()> { + let head = repository.head().context("could not read HEAD before time-travel")?; + let head_id = head + .id() + .map(gix::Id::detach) + .context("cannot time-travel from an unborn HEAD")?; + let detached = head.is_detached(); + drop(head); + let (selected, resolved_graph) = match (&args.revision, args.to) { + (Some(revision), None) => super::resolve_commit(&repository, revision, "time-travel destination")?, + (None, Some(to)) => { + let hidden = crate::history::available_hidden_revisions(&repository, &[], true)?.0; + let hidden_tips = crate::history::snapshot(&repository, &[], &hidden, false)?.hidden_tips; + let graph = crate::edit::loaded_view_graph_with_hidden(&repository, &[], &hidden)?; + let selected = relative_destination(&repository, &graph, &hidden_tips, head_id, to)?; + (selected, Some(graph)) + } + _ => anyhow::bail!("exactly one time-travel destination is required"), + }; + if selected == head_id { + println!("already at {}", crate::change_id::display(&repository, selected, 7)?); + return Ok(()); + } + + let revisions = vec![OsString::from("HEAD"), OsString::from(selected.to_string())]; + let graph = match resolved_graph { + Some(graph) => graph, + None => crate::edit::loaded_view_graph_with(&repository, &revisions)?, + }; + let forward = graph.is_ancestor(head_id, selected); + if detached && !forward { + let source_is_pinned = crate::history::all_pins(&repository)? + .into_iter() + .any(|pin| graph.is_ancestor(head_id, pin.id)); + if !source_is_pinned { + anyhow::bail!( + "detached HEAD or one of its descendants must be pinned before travelling into the past or sideways" + ); + } + } + + let reviews = crate::history::all_reviews(&repository)? + .into_iter() + .map(|review| review.id) + .collect::<Vec<_>>(); + let repository_path = repository.git_dir().to_owned(); + let bare = repository.is_bare(); + drop(repository); + match crate::edit::time_travel::perform(&repository_path, bare, selected, &graph, &reviews, &[], false)? { + crate::edit::time_travel::Perform::Complete { + notice, + selected, + ref_rewrites, + ref_changes, + } => { + let repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository after time-travel")?; + println!( + "{}", + super::notice_with_change_id( + &repository, + ¬ice.unwrap_or_else(|| format!("already at {}", selected.to_hex_with_len(7))), + selected, + )? + ); + super::print_ref_rewrites(&repository, &ref_rewrites)?; + super::record_undo(&repository, "time travel", Ok(ref_changes)); + } + crate::edit::time_travel::Perform::Conflict(conflict) if args.materialize_conflicts => { + let (notice, _, ref_rewrites, ref_changes) = conflict.accept()?; + let repository = crate::open_repository(&repository_path, bare, false) + .context("could not reopen repository after materializing time-travel")?; + super::print_ref_rewrites(&repository, &ref_rewrites)?; + super::record_undo(&repository, "materialize time-travel conflict", Ok(ref_changes)); + anyhow::bail!("{notice}"); + } + crate::edit::time_travel::Perform::Conflict(_) => { + anyhow::bail!("time-travel would conflict; retry with --materialize-conflicts to check it out") + } + } + Ok(()) +} + +fn relative_destination( + repository: &gix::Repository, + graph: &crate::history::HistoryGraph, + hidden_tips: &[gix::ObjectId], + head: gix::ObjectId, + to: To, +) -> Result<gix::ObjectId> { + let order = graph + .stored_commit_ids() + .filter(|id| !hidden_tips.iter().any(|hidden| graph.is_ancestor(*id, *hidden))) + .collect::<Vec<_>>(); + let stored = order.iter().copied().collect::<HashSet<_>>(); + if !stored.contains(&head) { + anyhow::bail!("HEAD is not present in the default Tix view"); + } + + let candidates = match to { + To::Parent => visible_parents(graph, head, &stored), + To::Child => order + .iter() + .copied() + .filter(|id| graph.parents_of(*id).is_some_and(|parents| parents.contains(&head))) + .collect(), + To::First => first_candidates(graph, head, &stored, &order), + To::Tip => terminal_candidates(head, &children_by_parent(graph, &stored, &order), &order), + }; + match candidates.as_slice() { + [candidate] => Ok(*candidate), + [] => anyhow::bail!("HEAD has no {} in the default Tix view", to.name()), + candidates => { + let candidates = candidates + .iter() + .map(|id| crate::change_id::display_short(repository, *id)) + .collect::<Result<Vec<_>>>()? + .join("\n "); + anyhow::bail!( + "--to {} is ambiguous; candidates:\n {candidates}\ntravel to one directly with `tix travel REVSPEC`", + to.name() + ) + } + } +} + +fn visible_parents( + graph: &crate::history::HistoryGraph, + id: gix::ObjectId, + stored: &HashSet<gix::ObjectId>, +) -> Vec<gix::ObjectId> { + graph + .parents_of(id) + .unwrap_or_default() + .into_iter() + .filter(|parent| stored.contains(parent)) + .collect() +} + +fn first_candidates( + graph: &crate::history::HistoryGraph, + start: gix::ObjectId, + stored: &HashSet<gix::ObjectId>, + order: &[gix::ObjectId], +) -> Vec<gix::ObjectId> { + let mut pending = vec![start]; + let mut seen = HashSet::new(); + let mut terminals = HashSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let parents = visible_parents(graph, id, stored); + if parents.is_empty() { + terminals.insert(id); + } else { + pending.extend(parents); + } + } + order.iter().copied().filter(|id| terminals.contains(id)).collect() +} + +fn children_by_parent( + graph: &crate::history::HistoryGraph, + stored: &HashSet<gix::ObjectId>, + order: &[gix::ObjectId], +) -> HashMap<gix::ObjectId, Vec<gix::ObjectId>> { + let mut children = HashMap::<_, Vec<_>>::new(); + for &id in order { + for parent in visible_parents(graph, id, stored) { + children.entry(parent).or_default().push(id); + } + } + children +} + +fn terminal_candidates( + start: gix::ObjectId, + adjacent: &HashMap<gix::ObjectId, Vec<gix::ObjectId>>, + order: &[gix::ObjectId], +) -> Vec<gix::ObjectId> { + let mut pending = vec![start]; + let mut seen = HashSet::new(); + let mut terminals = HashSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + match adjacent.get(&id).filter(|next| !next.is_empty()) { + Some(next) => pending.extend(next), + None => { + terminals.insert(id); + } + } + } + order.iter().copied().filter(|id| terminals.contains(id)).collect() +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!("git {} failed: {}", args.join(" "), output.stderr.trim().to_str_lossy()).into()); + } + Ok(output.stdout) + } + + fn observable_state(path: &Path) -> gix_testtools::Result<(Vec<u8>, Vec<u8>)> { + Ok(( + git(path, &["status", "--porcelain=v2", "--branch"])?, + git(path, &["show-ref"])?, + )) + } + + fn args(revision: &str) -> Args { + Args { + materialize_conflicts: false, + revision: Some(revision.into()), + to: None, + } + } + + fn relative_args(to: To) -> Args { + Args { + materialize_conflicts: false, + revision: None, + to: Some(to), + } + } + + #[test] + fn change_ids_ignore_non_visible_tracking_history() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + let repository = crate::test_repository::open(path)?; + let main = repository.rev_parse_single("main")?.detach(); + let original_topic = repository.rev_parse_single("topic")?.detach(); + let change_id = crate::change_id::for_commit(&repository, main)?; + let mut topic = repository.find_commit(original_topic)?.decode()?.into_owned()?; + topic + .extra_headers + .push((crate::change_id::HEADER.into(), change_id.to_string().into())); + let topic = repository.write_object(&topic)?.detach(); + drop(repository); + + git(path, &["update-ref", "refs/heads/topic", &topic.to_string()])?; + git(path, &["config", "remote.origin.url", "https://example.com/repo"])?; + git( + path, + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], + )?; + git(path, &["config", "branch.topic.remote", "origin"])?; + git(path, &["config", "branch.topic.merge", "refs/heads/main"])?; + git(path, &["update-ref", "refs/remotes/origin/main", &main.to_string()])?; + git(path, &["switch", "-q", "topic"])?; + + let repository = crate::test_repository::open(path)?; + let graph = crate::edit::loaded_view_graph(&repository)?; + assert!( + graph.index(main).is_some(), + "the tracking commit is loaded for topology" + ); + assert!( + !graph.stored_commit_ids().any(|id| id == main), + "the tracking commit is absent from the visible view" + ); + assert!( + graph.stored_commit_ids().any(|id| id == topic), + "the checked-out topic is visible" + ); + run(repository, args(&change_id.to_reverse_hex().to_string()))?; + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + topic, + "the visible change ID resolves to the already checked-out topic" + ); + Ok(()) + } + + #[test] + fn attached_past_travel_saves_and_returns_to_the_branch() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let change_id = crate::change_id::for_commit(&repository, middle)? + .to_reverse_hex_with_len(7) + .to_string(); + run(repository, args(&change_id))?; + + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!(repository.head_id()?, middle); + assert!(repository.head()?.is_detached()); + let pins = crate::history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "leaving the attached tip creates one source pin"); + assert_eq!( + pins[0].target.try_name().expect("the source pin is symbolic"), + "refs/heads/main", + "the source pin follows the departed branch" + ); + + run(repository, args("main"))?; + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!( + repository.head()?.referent_name().expect("HEAD is attached"), + "refs/heads/main", + "travelling to the pinned destination reattaches HEAD" + ); + assert!(crate::history::all_pins(&repository)?.is_empty()); + run(repository, args("HEAD"))?; + let repository = crate::test_repository::open(fixture.path())?; + assert!( + !repository.head()?.is_detached(), + "travelling to the current attached HEAD is a no-op" + ); + assert!(crate::history::all_pins(&repository)?.is_empty()); + Ok(()) + } + + #[test] + fn detached_travel_needs_a_source_pin_except_toward_descendants() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + git(path, &["branch", "side", "HEAD~2"])?; + git(path, &["checkout", "-q", "side"])?; + git(path, &["commit", "-q", "--allow-empty", "-m", "side"])?; + git(path, &["checkout", "-q", "--detach", "main~1"])?; + let repository = crate::test_repository::open(path)?; + run(repository, args("main"))?; + let repository = crate::test_repository::open(path)?; + assert!(repository.head()?.is_detached()); + assert!(crate::history::all_pins(&repository)?.is_empty()); + + let before_rejected = repository.head_id()?.detach(); + let err = run(repository, args("HEAD~1")).expect_err("past travel from detached HEAD needs a pin"); + assert!(format!("{err:#}").contains("must be pinned")); + let repository = crate::test_repository::open(path)?; + assert_eq!( + repository.head_id()?, + before_rejected, + "the rejected command does not move HEAD" + ); + let err = run(repository, args("side")).expect_err("sideways travel from detached HEAD needs a pin"); + assert!(format!("{err:#}").contains("must be pinned")); + let repository = crate::test_repository::open(path)?; + let tip = repository.head_id()?.detach(); + repository.reference( + "refs/worktree/tix/pins/keep", + tip, + gix::refs::transaction::PreviousValue::MustNotExist, + "test pin", + )?; + run(repository, args("side"))?; + Ok(()) + } + + #[test] + fn relative_targets_follow_the_current_default_view_component() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + let repository = crate::test_repository::open(path)?; + let tip = repository.head_id()?.detach(); + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let root = repository.rev_parse_single("HEAD~2")?.detach(); + drop(repository); + + let orphan = gix::ObjectId::from_hex(git(path, &["commit-tree", "HEAD^{tree}", "-m", "orphan"])?.trim())?; + let orphan = orphan.to_string(); + git(path, &["update-ref", "refs/worktree/tix/pins/unrelated", &orphan])?; + let tip_hex = tip.to_string(); + let upstream = gix::ObjectId::from_hex( + git(path, &["commit-tree", "HEAD^{tree}", "-p", &tip_hex, "-m", "upstream"])?.trim(), + )? + .to_string(); + git(path, &["update-ref", "refs/remotes/origin/main", &upstream])?; + git(path, &["config", "branch.main.remote", "origin"])?; + git(path, &["config", "branch.main.merge", "refs/heads/main"])?; + + run(crate::test_repository::open(path)?, relative_args(To::Tip))?; + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + tip, + "tip travel at the attached tip is a no-op" + ); + let before = observable_state(path)?; + let err = run(crate::test_repository::open(path)?, relative_args(To::Child)) + .expect_err("the visible tip has no child"); + assert!(format!("{err:#}").contains("no child")); + assert_eq!( + observable_state(path)?, + before, + "a missing child leaves the repository unchanged" + ); + + run(crate::test_repository::open(path)?, relative_args(To::Parent))?; + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + middle, + "parent travels down by one commit" + ); + run(crate::test_repository::open(path)?, relative_args(To::First))?; + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + root, + "first reaches this component's oldest commit" + ); + run(crate::test_repository::open(path)?, relative_args(To::First))?; + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + root, + "first travel at the oldest commit is a no-op" + ); + + let before = observable_state(path)?; + let err = run(crate::test_repository::open(path)?, relative_args(To::Parent)) + .expect_err("the visible root has no parent"); + assert!(format!("{err:#}").contains("no parent")); + assert_eq!( + observable_state(path)?, + before, + "a missing parent leaves the repository unchanged" + ); + + run(crate::test_repository::open(path)?, relative_args(To::Child))?; + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + middle, + "child travels up by one commit" + ); + run(crate::test_repository::open(path)?, relative_args(To::Tip))?; + let repository = crate::test_repository::open(path)?; + assert_eq!(repository.head_id()?, tip, "tip reaches this component's leaf"); + assert_eq!( + repository.head()?.referent_name().expect("HEAD is attached"), + "refs/heads/main", + "travelling to the branch tip reattaches HEAD" + ); + Ok(()) + } + + #[test] + fn first_stops_above_the_inferred_hidden_base() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + git(path, &["config", "remote.origin.url", "."])?; + git( + path, + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], + )?; + git(path, &["update-ref", "refs/remotes/origin/main", "main"])?; + git( + path, + &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], + )?; + git(path, &["checkout", "-q", "-b", "topic"])?; + git(path, &["commit", "-q", "--allow-empty", "-m", "first topic commit"])?; + let first = crate::test_repository::open(path)?.head_id()?.detach(); + git(path, &["commit", "-q", "--allow-empty", "-m", "topic tip"])?; + + run(crate::test_repository::open(path)?, relative_args(To::First))?; + + assert_eq!( + crate::test_repository::open(path)?.head_id()?, + first, + "first selects the oldest commit displayed above the inferred base" + ); + Ok(()) + } + + #[test] + fn relative_target_ambiguity_lists_every_candidate() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + let repository = crate::test_repository::open(path)?; + let old_tip = repository.head_id()?.detach(); + let root = repository.rev_parse_single("HEAD~2")?.detach(); + drop(repository); + + let orphan = gix::ObjectId::from_hex(git(path, &["commit-tree", "HEAD^{tree}", "-m", "orphan"])?.trim())?; + let old_tip_hex = old_tip.to_string(); + let orphan_hex = orphan.to_string(); + let merge = gix::ObjectId::from_hex( + git( + path, + &[ + "commit-tree", + "HEAD^{tree}", + "-p", + &old_tip_hex, + "-p", + &orphan_hex, + "-m", + "merge", + ], + )? + .trim(), + )?; + let merge_hex = merge.to_string(); + let left = gix::ObjectId::from_hex( + git(path, &["commit-tree", "HEAD^{tree}", "-p", &merge_hex, "-m", "left"])?.trim(), + )?; + let right = gix::ObjectId::from_hex( + git(path, &["commit-tree", "HEAD^{tree}", "-p", &merge_hex, "-m", "right"])?.trim(), + )?; + git(path, &["update-ref", "refs/heads/main", &merge_hex, &old_tip_hex])?; + let left_hex = left.to_string(); + let right_hex = right.to_string(); + git(path, &["update-ref", "refs/worktree/tix/pins/left", &left_hex])?; + git(path, &["update-ref", "refs/worktree/tix/pins/right", &right_hex])?; + + let repository = crate::test_repository::open(path)?; + let cases = [ + (To::First, vec![root, orphan]), + (To::Parent, vec![old_tip, orphan]), + (To::Child, vec![left, right]), + (To::Tip, vec![left, right]), + ]; + let expected = cases + .iter() + .map(|(to, candidates)| { + Ok::<_, anyhow::Error>(( + *to, + candidates + .iter() + .map(|id| crate::change_id::display_short(&repository, *id)) + .collect::<Result<Vec<_>>>()?, + )) + }) + .collect::<Result<Vec<_>>>()?; + drop(repository); + let before = observable_state(path)?; + for (to, candidates) in expected { + let err = run(crate::test_repository::open(path)?, relative_args(to)) + .expect_err("multiple relative destinations are ambiguous"); + let message = format!("{err:#}"); + for candidate in candidates { + assert!( + message.contains(&candidate), + "{to:?} ambiguity lists candidate {candidate}: {message}" + ); + } + assert!( + message.contains("tix travel REVSPEC"), + "ambiguity suggests direct travel: {message}" + ); + assert_eq!( + observable_state(path)?, + before, + "ambiguity leaves the repository unchanged" + ); + } + Ok(()) + } + + #[test] + fn replay_conflicts_are_unobservable_until_materialization_is_requested() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let path = fixture.path(); + git( + path, + &["config", "gitoxide.commit.committerDate", "2001-01-01T00:00:00 +0000"], + )?; + let repository = crate::test_repository::open(path)?; + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + std::fs::write(path.join("after"), "after\n")?; + git(path, &["add", "after"])?; + git(path, &["commit", "-q", "-m", "after"])?; + let root = repository.rev_parse_single("HEAD~3")?.detach(); + git(path, &["checkout", "-q", "--detach", &root.to_string()])?; + let graph = crate::edit::loaded_graph(&repository)?; + crate::edit::rebase::perform( + &repository, + &graph, + crate::edit::rebase::Edit::Remove { target: middle }, + crate::edit::rebase::Signature::RedoIfNeeded, + crate::edit::rebase::Tree::LeaveAsIsAndMark, + )? + .complete()?; + let tip = repository.find_reference("refs/heads/main")?.id().detach(); + drop(repository); + + git(path, &["checkout", "-q", "main"])?; + run(crate::test_repository::open(path)?, args(&root.to_string()))?; + let before = gix_testtools::repository::snapshot(path)?; + let err = run(crate::test_repository::open(path)?, args(&tip.to_string())) + .expect_err("a conflict needs explicit materialization"); + assert!(format!("{err:#}").contains("--materialize-conflicts")); + assert_eq!( + gix_testtools::repository::snapshot(path)?, + before, + "declining materialization leaves the complete repository unchanged" + ); + + let err = run( + crate::test_repository::open(path)?, + Args { + materialize_conflicts: true, + revision: Some(tip.to_string().into()), + to: None, + }, + ) + .expect_err("a materialized conflict remains an incomplete command"); + assert!(format!("{err:#}").contains("ready to resolve conflicts")); + assert!( + crate::test_repository::open(path)? + .index_or_empty()? + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted), + "opt-in materialization writes the unresolved index" + ); + Ok(()) + } +} diff --git a/gix-tix/src/command_menu.rs b/gix-tix/src/command_menu.rs new file mode 100644 index 00000000000..50cdbad0f9c --- /dev/null +++ b/gix-tix/src/command_menu.rs @@ -0,0 +1,710 @@ +use crate::{ + app::{Action, Alignment, App, DateMode, IdMode, NameMode, RefMode}, + history::{DecorationKind, Decorations}, +}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum CommandId { + Date, + Ids, + Emails, + Names, + Mailmap, + Trailers, + Refs, + Hidden, + Reword, + NewCommit, + NewEmptyCommit, + Amend, + Spill, + Split, + Forget, + Pin, + Unpin, + Stash, + Unstash, + Rebase, + RebaseUpdate, + StartReview, + FinishReview, + Squash, + CopyInsert, + MoveInsert, + StackInsert, + ForkCommit, + Attach, + Todo, + Note, + ChecksPass, + GitNote, + VerifySignatures, + Alignment, + RefTree, + CommitMessage, + Changes, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum CommandGroup { + View, + Actions, + Enrich, + Information, +} + +impl CommandGroup { + pub(crate) fn label(self) -> &'static str { + match self { + CommandGroup::View => "View", + CommandGroup::Actions => "Actions", + CommandGroup::Enrich => "Enrich", + CommandGroup::Information => "Information", + } + } + + pub(crate) fn prefix(self) -> char { + match self { + CommandGroup::View => 'v', + CommandGroup::Actions => 'a', + CommandGroup::Enrich => 'n', + CommandGroup::Information => '?', + } + } + + fn index(self) -> usize { + match self { + CommandGroup::View => 0, + CommandGroup::Actions => 1, + CommandGroup::Enrich => 2, + CommandGroup::Information => 3, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Command { + pub(crate) id: CommandId, + pub(crate) group: CommandGroup, + pub(crate) row: usize, + pub(crate) label: &'static str, + pub(crate) shortcut: &'static str, + pub(crate) active: bool, + pub(crate) action: Action, +} + +impl Command { + pub(crate) fn key(&self) -> char { + self.shortcut + .chars() + .next_back() + .expect("command shortcuts always contain a leaf key") + } +} + +pub(crate) fn commands(app: &App, decorations: &Decorations, has_verifiable_signatures: bool) -> Vec<Command> { + let mut out = Vec::with_capacity(35); + let mut push = |id, group, row, label, shortcut, active, action| { + out.push(Command { + id, + group, + row, + label, + shortcut, + active, + action, + }); + }; + + let (date_label, date_active) = match app.date_mode { + DateMode::Author => ("author date", true), + DateMode::Committer => ("committer date", true), + DateMode::None => ("date", false), + }; + push( + CommandId::Date, + CommandGroup::View, + 0, + date_label, + "vd", + date_active, + Action::ToggleDate, + ); + let (ids_label, ids_active) = match (app.id_mode, app.effective_id_mode()) { + (IdMode::Off, IdMode::Change) => ("auto change ids", true), + (IdMode::Commit, _) => ("commit ids", true), + (IdMode::Change, _) => ("change ids", true), + (IdMode::Off, _) => ("ids", false), + }; + push( + CommandId::Ids, + CommandGroup::View, + 0, + ids_label, + "vi", + ids_active, + Action::CycleIds, + ); + push( + CommandId::Emails, + CommandGroup::View, + 0, + "emails", + "vs", + app.show_emails, + Action::ToggleEmail, + ); + let (names_label, names_active) = match app.name_mode { + NameMode::All => ("names", true), + NameMode::Author => ("name", true), + NameMode::None => ("name", false), + }; + push( + CommandId::Names, + CommandGroup::View, + 0, + names_label, + "ve", + names_active, + Action::ToggleName, + ); + push( + CommandId::Mailmap, + CommandGroup::View, + 0, + "mailmap", + "vm", + app.use_mailmap, + Action::ToggleMailmap, + ); + push( + CommandId::Trailers, + CommandGroup::View, + 0, + "trailers", + "vt", + app.show_trailers, + Action::ToggleTrailers, + ); + let refs_label = match app.ref_mode { + RefMode::All => "all refs", + RefMode::Default => "refs", + RefMode::None => "no refs", + }; + push( + CommandId::Refs, + CommandGroup::View, + 0, + refs_label, + "vr", + app.ref_mode != RefMode::None, + Action::CycleRefs, + ); + if app.has_hidden_filter { + push( + CommandId::Hidden, + CommandGroup::View, + 0, + if app.show_hidden { "hide hidden" } else { "show hidden" }, + "vh", + app.show_hidden, + Action::ToggleHidden, + ); + } + + let selected_is_segment = app.selected_is_segment(); + let actions_visible = + !selected_is_segment && (app.changes_focus != Some(crate::app::ChangePane::Worktree) || app.can_amend()); + if actions_visible { + if app.changes_focus.is_none() && app.reword_shortcut_visible() { + push( + CommandId::Reword, + CommandGroup::Actions, + 0, + "reword", + "ao", + true, + Action::Reword, + ); + } + if app.changes_focus.is_none() && app.can_create_commit() { + push( + CommandId::NewCommit, + CommandGroup::Actions, + 0, + "new", + "aw", + true, + Action::NewCommit, + ); + } + if app.changes_focus.is_none() && app.can_create_empty_commit() { + push( + CommandId::NewEmptyCommit, + CommandGroup::Actions, + 0, + "new-empty", + "an", + true, + Action::NewEmptyCommit, + ); + } + if app.can_amend() { + push( + CommandId::Amend, + CommandGroup::Actions, + 0, + "amend", + "ae", + true, + Action::Amend, + ); + } + if app.can_spill() { + push( + CommandId::Spill, + CommandGroup::Actions, + 0, + "spill", + "al", + true, + Action::Spill, + ); + } + if app.can_split() { + push( + CommandId::Split, + CommandGroup::Actions, + 0, + "split", + "ap", + true, + Action::Split, + ); + } + if app.changes_focus.is_none() && app.can_forget() { + push( + CommandId::Forget, + CommandGroup::Actions, + 0, + "d forget", + "ad", + true, + Action::Forget, + ); + } + if app.changes_focus.is_none() + && let Some(selected) = app.selected.and_then(|index| app.rows.get(index)) + { + let pinned = decorations.get(&selected.id).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Pin) + }); + push( + if pinned { CommandId::Unpin } else { CommandId::Pin }, + CommandGroup::Actions, + 0, + if pinned { "unpin" } else { "pin" }, + "ai", + true, + Action::TogglePin, + ); + } + + if app.changes_focus.is_none() && app.can_stash() { + push( + CommandId::Stash, + CommandGroup::Actions, + 1, + "z stash", + "az", + true, + Action::Stash, + ); + } else if app.changes_focus.is_none() && app.can_unstash() { + push( + CommandId::Unstash, + CommandGroup::Actions, + 1, + "z unstash", + "az", + true, + Action::Stash, + ); + } + if app.changes_focus.is_none() && app.can_rebase() { + push( + CommandId::Rebase, + CommandGroup::Actions, + 1, + "rebase", + "ab", + true, + Action::Rebase, + ); + } + if app.changes_focus.is_none() && app.can_rebase_update() { + push( + CommandId::RebaseUpdate, + CommandGroup::Actions, + 1, + "rebase-update", + "au", + true, + Action::RebaseUpdate, + ); + } + if app.changes_focus.is_none() && app.can_finish_review() { + push( + CommandId::FinishReview, + CommandGroup::Actions, + 1, + "finish-review", + "ar", + true, + Action::Review, + ); + } else if app.changes_focus.is_none() && app.can_review() { + push( + CommandId::StartReview, + CommandGroup::Actions, + 1, + "review", + "ar", + true, + Action::Review, + ); + } + if app.changes_focus.is_none() && app.can_squash() { + push( + CommandId::Squash, + CommandGroup::Actions, + 1, + "squash", + "as", + true, + Action::Squash, + ); + } + if app.changes_focus.is_none() && app.can_copy_insert() { + push( + CommandId::CopyInsert, + CommandGroup::Actions, + 1, + "copy-insert", + "ay", + true, + Action::CopyInsert, + ); + } + if app.changes_focus.is_none() && app.can_move_insert() { + push( + CommandId::MoveInsert, + CommandGroup::Actions, + 1, + "move-insert", + "am", + true, + Action::MoveInsert, + ); + } + if app.changes_focus.is_none() && app.can_stack_insert() { + push( + CommandId::StackInsert, + CommandGroup::Actions, + 1, + "stack-insert", + "at", + true, + Action::StackInsert, + ); + } + if app.changes_focus.is_none() && app.can_fork_commit() { + push( + CommandId::ForkCommit, + CommandGroup::Actions, + 1, + "fork", + "af", + true, + Action::ForkCommit, + ); + } + if app.changes_focus.is_none() && app.can_attach() { + push( + CommandId::Attach, + CommandGroup::Actions, + 1, + "attach", + "ah", + true, + Action::Attach, + ); + } + } + + if !selected_is_segment && let Some(row) = app.selected.and_then(|index| app.rows.get(index)) { + if app.can_reword() { + push( + CommandId::Todo, + CommandGroup::Enrich, + 0, + "todo", + "nt", + app.todo(row.id), + Action::ToggleTodo, + ); + push( + CommandId::Note, + CommandGroup::Enrich, + 0, + "note", + "no", + app.note(row.id).is_some(), + Action::EditNote, + ); + } + push( + CommandId::ChecksPass, + CommandGroup::Enrich, + 0, + "checks-pass", + "ne", + app.checks_pass(row.id), + Action::ToggleChecksPass, + ); + push( + CommandId::GitNote, + CommandGroup::Enrich, + 0, + "git note", + "ng", + !app.notes(row.id).is_empty(), + Action::EditGitNote, + ); + } + + if app.signature_failures > 0 || has_verifiable_signatures { + push( + CommandId::VerifySignatures, + CommandGroup::Information, + 0, + "verify signatures", + "?s", + true, + Action::VerifySignatures, + ); + } + let (alignment_label, alignment_active) = match app.alignment { + Alignment::Title => ("[ title", true), + Alignment::Columns => ("[ columns", true), + Alignment::None => ("[ align", false), + Alignment::Compressed => ("[ compressed", true), + }; + push( + CommandId::Alignment, + CommandGroup::Information, + 0, + alignment_label, + "?[", + alignment_active, + Action::ToggleAlign, + ); + push( + CommandId::RefTree, + CommandGroup::Information, + 0, + "ref-tree", + "?t", + false, + Action::ToggleRefTree, + ); + if !selected_is_segment { + push( + CommandId::CommitMessage, + CommandGroup::Information, + 0, + "message", + "?m", + app.show_commit, + Action::ToggleCommit, + ); + push( + CommandId::Changes, + CommandGroup::Information, + 0, + "changes", + "?e", + app.changes_mode.is_some(), + Action::ToggleChanges, + ); + } + + let mut positions = [0; 4]; + let mut balanced = out + .into_iter() + .map(|command| { + let group = command.group.index(); + let position = positions[group]; + positions[group] += 1; + (position, group, command) + }) + .collect::<Vec<_>>(); + balanced.sort_by_key(|(position, group, _)| (*position, *group)); + balanced.into_iter().map(|(_, _, command)| command).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + app::{Author, Commit, LoadedCommit, SignatureState, State}, + history::Decoration, + menu::Menu, + }; + use gix::{ObjectId, bstr::ByteSlice}; + + fn id(n: u8) -> ObjectId { + let mut bytes = [0; 20]; + bytes[19] = n; + ObjectId::Sha1(bytes) + } + + fn row(n: u8, parents: &[u8]) -> LoadedCommit { + Commit { + id: id(n), + parent_ids: parents.iter().copied().map(id).collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: Box::leak(Box::new(Author { + name: b"author".as_bstr(), + email: b"author@example.com".as_bstr(), + })), + attributions: 0..0, + title: format!("commit {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + } + } + + fn has(commands: &[Command], id: CommandId) -> bool { + commands.iter().any(|command| command.id == id) + } + + #[test] + fn default_catalog_interleaves_groups_and_preserves_their_internal_order() { + assert_eq!( + [ + CommandGroup::View.prefix(), + CommandGroup::Actions.prefix(), + CommandGroup::Enrich.prefix(), + CommandGroup::Information.prefix(), + ], + ['v', 'a', 'n', '?'], + "command scopes use their displayed prefix keys" + ); + let commands = commands(&App::new(1), &Decorations::default(), true); + let entries = commands + .iter() + .map(|command| (command.group, command.label, command.shortcut, command.key())) + .collect::<Vec<_>>(); + + assert_eq!( + entries, + [ + (CommandGroup::View, "author date", "vd", 'd'), + (CommandGroup::Information, "verify signatures", "?s", 's'), + (CommandGroup::View, "ids", "vi", 'i'), + (CommandGroup::Information, "[ title", "?[", '['), + (CommandGroup::View, "emails", "vs", 's'), + (CommandGroup::Information, "ref-tree", "?t", 't'), + (CommandGroup::View, "names", "ve", 'e'), + (CommandGroup::Information, "message", "?m", 'm'), + (CommandGroup::View, "mailmap", "vm", 'm'), + (CommandGroup::Information, "changes", "?e", 'e'), + (CommandGroup::View, "trailers", "vt", 't'), + (CommandGroup::View, "refs", "vr", 'r'), + ], + "groups alternate without disturbing their popup order" + ); + } + + #[test] + fn contextual_opposites_replace_each_other_without_becoming_the_recalled_command() { + let mut app = App::new(2); + app.extend_commits(vec![row(2, &[1]), row(1, &[])]); + app.state = State::Complete; + app.set_worktree_head(Some(id(2)), false); + app.set_head_edit_availability(false, true, false, false, false, false, false); + + let mut decorations = Decorations::default(); + let initial = commands(&app, &decorations, false); + assert_eq!( + initial.iter().take(9).map(|command| command.group).collect::<Vec<_>>(), + [ + CommandGroup::View, + CommandGroup::Actions, + CommandGroup::Enrich, + CommandGroup::Information, + CommandGroup::View, + CommandGroup::Actions, + CommandGroup::Enrich, + CommandGroup::Information, + CommandGroup::View, + ], + "the initial menu balances all available prefix groups" + ); + assert!(has(&initial, CommandId::Stash)); + assert!(has(&initial, CommandId::StartReview)); + assert!(has(&initial, CommandId::Pin)); + assert!(!has(&initial, CommandId::Unstash)); + assert!(!has(&initial, CommandId::FinishReview)); + assert!(!has(&initial, CommandId::Unpin)); + + let items = crate::command_picker_items(&initial); + let mut menu = Menu::default(); + menu.open(&items); + menu.paste("actions", &items); + assert!( + !menu.visible_indices().is_empty() + && menu + .visible_indices() + .iter() + .all(|index| initial[*index].group == CommandGroup::Actions), + "the production picker searches displayed prefix groups" + ); + menu.open(&items); + for character in "stash".chars() { + menu.insert(character, &items); + } + assert_eq!(menu.submit_selected(&items), Some(CommandId::Stash)); + + std::sync::Arc::make_mut(&mut app.rows[0]).is_review = true; + app.set_head_edit_availability(false, false, true, false, true, false, false); + decorations.insert( + id(2), + vec![Decoration { + name: b"pin".as_bstr().to_owned(), + kind: DecorationKind::Pin, + }], + ); + let changed = commands(&app, &decorations, false); + assert!(has(&changed, CommandId::Unstash)); + assert!(has(&changed, CommandId::FinishReview)); + assert!(has(&changed, CommandId::Unpin)); + assert!(!has(&changed, CommandId::Stash)); + assert!(!has(&changed, CommandId::StartReview)); + assert!(!has(&changed, CommandId::Pin)); + + let changed_items = crate::command_picker_items(&changed); + menu.open(&changed_items); + assert_eq!( + menu.selected_index(), + None, + "a contextual opposite does not replace the unavailable recalled command" + ); + } +} diff --git a/gix-tix/src/edit/create.rs b/gix-tix/src/edit/create.rs new file mode 100644 index 00000000000..daf06dca518 --- /dev/null +++ b/gix-tix/src/edit/create.rs @@ -0,0 +1,853 @@ +use std::ffi::OsString; + +use anyhow::{Context, Result}; +use gix::{ObjectId, bstr::ByteSlice}; + +use crate::{ + ChangeGroup, ChangeKind, ComparedParent, add_line_counts, load_tree_changes_without_lines, + load_worktree_changes_without_lines, ui, +}; + +use super::{rebase, reword}; + +pub(crate) struct Prepared { + pub editor: OsString, + pub document: Vec<u8>, + pub(super) parent: Option<ObjectId>, + pub(super) tree: ObjectId, + pub(super) objects: gix::odb::memory::Storage, + pub(crate) is_empty: bool, + pub(super) reset_index: bool, +} + +#[derive(Clone, Copy)] +pub(crate) enum Source { + Default, + Index, + Worktree, + WorktreeUntracked, +} + +#[tracing::instrument(skip_all, fields(parent = ?parent))] +pub(crate) fn prepare(repo: gix::Repository, parent: Option<ObjectId>) -> Result<Prepared> { + prepare_inner(repo, parent, false, Source::Default, None, false) +} + +#[tracing::instrument(skip_all, fields(parent = ?parent))] +pub(crate) fn prepare_empty(repo: gix::Repository, parent: Option<ObjectId>) -> Result<Prepared> { + prepare_inner(repo, parent, true, Source::Default, None, false) +} + +pub(crate) fn prepare_from( + repo: gix::Repository, + parent: Option<ObjectId>, + source: Source, + author: Option<&gix::bstr::BStr>, + todo: bool, +) -> Result<Prepared> { + prepare_inner(repo, parent, false, source, author, todo) +} + +fn prepare_inner( + mut repo: gix::Repository, + parent: Option<ObjectId>, + empty: bool, + source: Source, + author_override: Option<&gix::bstr::BStr>, + todo: bool, +) -> Result<Prepared> { + repo.workdir().context("creating a commit requires a worktree")?; + let head = repo.head().context("could not read HEAD before creating a commit")?; + let head_id = head.id().map(gix::Id::detach); + if parent.is_none() && !head.is_unborn() { + anyhow::bail!("an unborn history is required to create a root commit"); + } + if let Some(parent) = parent { + repo.find_commit(parent) + .context("could not find the selected parent commit")?; + } + if parent.is_none() { + head.referent_name().context("an unborn HEAD must point to a branch")?; + } + let editor = repo.editor().context("no Git editor is available")?; + let mut author = repo + .author() + .context("no Git author is configured")? + .context("could not resolve the Git author")? + .to_owned() + .context("could not own the Git author")?; + if let Some(value) = author_override { + author = reword::actor(value, author.time, "author")?; + } + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned() + .context("could not own the Git committer")?; + repo.commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + + repo = repo.with_object_memory(); + let baseline = match parent { + Some(id) => repo + .find_commit(id) + .context("could not find the parent commit")? + .tree() + .context("could not load the parent tree")?, + None => repo.empty_tree(), + }; + let baseline_id = baseline.id; + let index = repo.index_or_empty().context("could not load the index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("cannot create a commit with unresolved index conflicts"); + } + let index_tree = index_tree(&repo, &index)?; + let based_on_parent = head_id == parent; + let tree = if empty || !based_on_parent { + baseline.id + } else { + match source { + Source::Default if index_tree != baseline.id => index_tree, + Source::Default | Source::Worktree => worktree_tree_tracked(&repo, &baseline, &index)?, + Source::Index => index_tree, + Source::WorktreeUntracked => worktree_tree(&repo, &baseline)?, + } + }; + + let new_tree = repo.find_tree(tree).context("could not load the candidate tree")?; + let mut changes = load_tree_changes_without_lines( + &repo, + parent.map(|_| &baseline), + &new_tree, + parent.map(|id| ComparedParent { index: 0, total: 1, id }), + )?; + let line_counts = add_line_counts(&repo, &mut changes)?; + let mut document = Vec::new(); + reword::write_headers( + &mut document, + &author, + &committer, + &crate::enrich::Enrichment { + todo, + ..Default::default() + }, + )?; + document.extend_from_slice(b"\nwhat\n\nwhy\n"); + for trailer in reword::missing_agent_trailers(b"what\n\nwhy\n").into_iter().flatten() { + document.extend_from_slice(b"\n;"); + document.extend_from_slice(trailer); + } + document.extend_from_slice(b"\n\n; Changes to be committed:\n"); + for line in ui::commit_diff_summary(&changes, &line_counts, changes.lines_added, changes.lines_removed) { + document.extend_from_slice(b"; "); + for span in line.spans { + document.extend_from_slice(span.content.as_bytes()); + } + document.push(b'\n'); + } + drop(new_tree); + drop(baseline); + drop(index); + + let provisional = repo + .new_commit("what\n\nwhy\n", tree, parent) + .context("could not prepare the commit object")? + .id; + let mut objects = repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + objects.remove(&provisional); + Ok(Prepared { + editor, + document, + parent, + tree, + objects, + is_empty: tree == baseline_id, + reset_index: !empty, + }) +} + +pub(crate) fn index_tree(repo: &gix::Repository, index: &gix::index::File) -> Result<ObjectId> { + let mut editor = repo.empty_tree().edit().context("could not prepare the index tree")?; + for entry in index.entries() { + let mode = entry + .mode + .to_tree_entry_mode() + .context("an index entry has an invalid mode")?; + editor + .upsert(entry.path(index), mode.kind(), entry.id) + .context("could not add an index entry to the candidate tree")?; + } + Ok(editor + .write() + .context("could not build the candidate index tree")? + .detach()) +} + +pub(super) fn worktree_tree(repo: &gix::Repository, baseline: &gix::Tree<'_>) -> Result<ObjectId> { + let changes = load_worktree_changes_without_lines(repo)?; + worktree_tree_with_changes_inner(repo, baseline, &changes, None) +} + +fn worktree_tree_tracked( + repo: &gix::Repository, + baseline: &gix::Tree<'_>, + index: &gix::index::File, +) -> Result<ObjectId> { + let changes = load_worktree_changes_without_lines(repo)?; + worktree_tree_with_changes_inner(repo, baseline, &changes, Some(index)) +} + +pub(super) fn worktree_tree_with_changes( + repo: &gix::Repository, + baseline: &gix::Tree<'_>, + changes: &crate::Changes, +) -> Result<ObjectId> { + worktree_tree_with_changes_inner(repo, baseline, changes, None) +} + +fn worktree_tree_with_changes_inner( + repo: &gix::Repository, + baseline: &gix::Tree<'_>, + changes: &crate::Changes, + tracked_by: Option<&gix::index::File>, +) -> Result<ObjectId> { + if changes.paths.is_empty() { + return Ok(baseline.id); + } + let (mut pipeline, index) = repo + .filter_pipeline(None) + .context("could not initialize worktree filters")?; + let mut editor = baseline.edit().context("could not edit the parent tree")?; + for change in changes + .paths + .iter() + .filter(|change| change.group == ChangeGroup::Unstaged) + { + if tracked_by.is_some_and(|index| { + index.entry_by_path(change.path.as_bstr()).is_none() + && change + .source + .as_ref() + .is_none_or(|source| index.entry_by_path(source.as_bstr()).is_none()) + }) { + continue; + } + if change.kind == ChangeKind::Renamed + && let Some(source) = &change.source + { + editor + .remove(source) + .context("could not remove a renamed source path")?; + } + if change.kind == ChangeKind::Deleted { + editor + .remove(&change.path) + .context("could not remove a deleted worktree path")?; + continue; + } + match pipeline + .worktree_file_to_object(change.path.as_bstr(), &index) + .with_context(|| format!("could not prepare {}", change.path.to_str_lossy()))? + { + Some((id, kind, _)) => { + editor + .upsert(&change.path, kind, id) + .context("could not add a worktree path to the candidate tree")?; + } + None => { + editor + .remove(&change.path) + .context("could not remove an unavailable worktree path")?; + } + } + } + Ok(editor.write().context("could not build the worktree tree")?.detach()) +} + +#[tracing::instrument(skip_all, fields(parent = ?prepared.parent))] +#[cfg(test)] +pub(crate) fn apply( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + edited: &[u8], +) -> Result<ObjectId> { + apply_reporting(repo, graph, prepared, edited)? + .selected + .context("inserting a commit did not produce a selection") +} + +pub(crate) fn apply_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + edited: &[u8], +) -> Result<rebase::Outcome> { + apply_conflict_reporting(repo, graph, prepared, edited, |_| {})?.complete() +} + +pub(crate) fn apply_conflict_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + edited: &[u8], + report: impl FnMut(rebase::Progress), +) -> Result<rebase::Perform> { + let edit = commit_from_edit(&prepared, edited)?; + apply_commit_conflict(repo, graph, prepared, edit, report) +} + +pub(crate) fn apply_message_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + message: &[u8], +) -> Result<rebase::Outcome> { + let mut edit = reword::parse(&prepared.document)?; + edit.message = reword::cleanup_message(message, None); + let commit = commit_from_parsed_edit(&prepared, edit)?; + apply_commit(repo, graph, prepared, commit) +} + +fn apply_commit( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + edit: (gix::objs::Commit, crate::enrich::Headers), +) -> Result<rebase::Outcome> { + apply_commit_conflict(repo, graph, prepared, edit, |_| {})?.complete() +} + +fn apply_commit_conflict( + mut repo: gix::Repository, + graph: &crate::history::HistoryGraph, + mut prepared: Prepared, + (commit, enrichment): (gix::objs::Commit, crate::enrich::Headers), + report: impl FnMut(rebase::Progress), +) -> Result<rebase::Perform> { + repo.objects.set_object_memory(std::mem::take(&mut prepared.objects)); + let (performed, _) = rebase::perform_with_enrichment_and_progress( + &repo, + graph, + rebase::Edit::Insert { + anchor: prepared.parent, + commit, + reset_index: prepared.reset_index, + }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIsAndMark, + &enrichment, + report, + )?; + Ok(performed) +} + +#[tracing::instrument(skip_all, fields(parent = ?prepared.parent))] +#[cfg(test)] +pub(crate) fn apply_fork( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + edited: &[u8], +) -> Result<ObjectId> { + apply_fork_reporting(repo, graph, prepared, edited)? + .selected + .context("forking a commit did not produce a selection") +} + +pub(crate) fn apply_fork_reporting( + mut repo: gix::Repository, + graph: &crate::history::HistoryGraph, + mut prepared: Prepared, + edited: &[u8], +) -> Result<rebase::Outcome> { + let repository_path = repo.git_dir().to_owned(); + let bare = repo.is_bare(); + repo.objects.set_object_memory(std::mem::take(&mut prepared.objects)); + let parent = prepared.parent.context("a fork commit requires a parent")?; + let (commit, enrichment) = commit_from_edit(&prepared, edited)?; + let mut outcome = rebase::perform( + &repo, + graph, + rebase::Edit::Fork { anchor: parent, commit }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIs, + )? + .complete()?; + let id = outcome + .selected + .context("forking a commit did not produce a selection")?; + drop(repo); + let repo = crate::open_repository(&repository_path, bare, false)?; + let name: gix::refs::FullName = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); + let before = super::undo::state(&repo, name.as_ref())?; + crate::enrich::apply_headers(&repo, id, &enrichment) + .context("the fork was created, but its enrichment could not be saved")?; + let after = super::undo::state(&repo, name.as_ref())?; + if before != after { + outcome.ref_changes.push(super::undo::RefChange { name, before, after }); + } + Ok(outcome) +} + +pub(super) fn commit_from_edit( + prepared: &Prepared, + edited: &[u8], +) -> Result<(gix::objs::Commit, crate::enrich::Headers)> { + let edit = reword::parse(edited)?; + commit_from_parsed_edit(prepared, edit) +} + +fn commit_from_parsed_edit( + prepared: &Prepared, + edit: reword::Edit<'_>, +) -> Result<(gix::objs::Commit, crate::enrich::Headers)> { + if edit.message.is_empty() { + anyhow::bail!("the edited commit message is empty"); + } + Ok(( + gix::objs::Commit { + message: edit.message, + tree: prepared.tree, + author: reword::actor(edit.author, edit.author_time, "author")?, + committer: reword::actor(edit.committer, edit.committer_time, "committer")?, + encoding: None, + parents: prepared.parent.into_iter().collect(), + extra_headers: Vec::new(), + }, + edit.enrichment, + )) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result<gix::Repository> { + Ok(crate::test_repository::open(path)?) + } + + fn object_count(path: &Path) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["count-objects", "-v"]) + .output()?; + if !output.status.success() { + return Err(format!("git count-objects failed: {}", output.stderr.to_str_lossy()).into()); + } + Ok(output.stdout) + } + + #[test] + fn preparation_is_unobservable_and_staged_changes_win() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let parent = open(fixture.path())?.head_id()?.detach(); + for name in ["refs/patches/create", "refs/tags/keep", "refs/remotes/origin/keep"] { + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", name, &parent.to_string()]) + .status()? + .success(), + "the test reference is created" + ); + } + let before = gix_testtools::repository::snapshot(fixture.path())?; + let objects_before = object_count(fixture.path())?; + let prepared = prepare(open(fixture.path())?, Some(parent))?; + assert_eq!( + prepared + .document + .split(|byte| *byte == b'\n') + .filter(|line| line.strip_prefix(b";").unwrap_or(line).starts_with(b"Author: ")) + .count(), + 1, + "new-commit editors contain only the configured author" + ); + assert!( + prepared + .document + .windows(b"tracked | 2 +- 0".len()) + .any(|window| window == b"tracked | 2 +- 0"), + "the editor buffer includes a commented per-file diffstat with net lines: {}", + prepared.document.as_bstr() + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "preparing the tree and commit leaves the complete repository state unchanged" + ); + assert_eq!( + object_count(fixture.path())?, + objects_before, + "preparation writes no loose objects" + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", "refs/patches/late", &parent.to_string()]) + .status()? + .success(), + "a ref may appear while the editor is open" + ); + + let edited = prepared + .document + .replacen(b"what\n\nwhy", b"title\n\nbody", 1) + .replacen(b";Todo\n;Message:", b"Todo\nMessage: created enrichment", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + after.head, + gix_testtools::repository::Head::Symbolic { + name: b"refs/heads/main".into(), + id: new_id, + }, + "the checked-out branch advances to the new commit" + ); + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert_eq!( + crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, new_id)? + )?, + crate::enrich::Enrichment { + todo: true, + note: Some("created enrichment".into()), + } + ); + assert_eq!(commit.parent_ids().next().map(gix::Id::detach), Some(parent)); + assert_eq!(commit.message_raw()?, b"title\n\nbody\n".as_bstr()); + assert_eq!( + Some(commit.tree_id()?.detach()), + before.index_tree, + "a changed index supplies the commit tree even when the worktree differs" + ); + assert_eq!(after.index_tree, before.index_tree, "the committed index stays intact"); + assert_eq!( + after.worktree, before.worktree, + "unstaged and untracked files stay intact" + ); + assert_eq!( + after.commits.len(), + before.commits.len() + 2, + "the history commit and its enrichment note commit are added" + ); + for name in ["refs/heads/main", "refs/patches/create", "refs/patches/late"] { + assert_eq!( + repository.find_reference(name)?.id(), + new_id, + "{name} advances to the new commit" + ); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!(repository.find_reference(name)?.id(), parent, "{name} is not edited"); + } + Ok(()) + } + + #[test] + fn worktree_changes_supply_the_tree_when_the_index_is_unchanged() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["reset", "-q", "HEAD"]) + .status()? + .success() + ); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let parent = open(fixture.path())?.head_id()?.detach(); + let prepared = prepare(open(fixture.path())?, Some(parent))?; + let edited = prepared.document.replacen(b"what\n\nwhy", b"worktree\n\nstate", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let after = gix_testtools::repository::snapshot(fixture.path())?; + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert_ne!( + Some(commit.tree_id()?.detach()), + before.index_tree, + "worktree changes produce a new tree" + ); + assert_eq!( + after.index_tree, + Some(commit.tree_id()?.detach()), + "checking out the commit updates the index to its worktree-derived tree" + ); + assert_eq!( + after.worktree, before.worktree, + "the committed worktree bytes remain exactly as prepared" + ); + Ok(()) + } + + #[test] + fn fork_creates_an_independent_pinned_child_then_time_travels_to_it() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + for args in [ + ["reset", "--hard", "-q", "HEAD"].as_slice(), + ["clean", "-fdq"].as_slice(), + ["commit", "--allow-empty", "-q", "-m", "descendant"].as_slice(), + ] { + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(args) + .status()? + .success(), + "the fork fixture has a clean descendant" + ); + } + let repository = open(fixture.path())?; + let main = repository.head_id()?.detach(); + let parent = repository.rev_parse_single("HEAD~1")?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare(open(fixture.path())?, Some(parent))?; + let edited = prepared + .document + .replacen(b"what\n\nwhy", b"fork\n\nreason", 1) + .replacen(b";Todo\n;Message:", b"Todo\nMessage: fork enrichment", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let fork = apply_fork(open(fixture.path())?, &graph, prepared, &edited)?; + + let repository = open(fixture.path())?; + assert_eq!( + crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, fork)? + )?, + crate::enrich::Enrichment { + todo: true, + note: Some("fork enrichment".into()), + } + ); + assert_eq!(repository.head_id()?, main, "forking does not move HEAD"); + assert_eq!( + repository.find_reference("refs/heads/main")?.id(), + main, + "forking does not move the source branch" + ); + assert_eq!( + repository.find_commit(fork)?.parent_ids().next().map(gix::Id::detach), + Some(parent), + "the fork is a direct child of the selected commit" + ); + let pins = crate::history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "the new leaf is retained until checkout"); + assert_eq!(pins[0].id, fork); + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + after.index, before.index, + "creating the fork leaves the index untouched" + ); + assert_eq!(after.index_tree, before.index_tree, "the index tree remains unchanged"); + assert_eq!( + after.worktree, before.worktree, + "creating the fork leaves worktree files untouched" + ); + + let repository_path = repository.git_dir().to_owned(); + let graph = super::super::loaded_graph(&repository)?; + drop(repository); + super::super::time_travel::perform(&repository_path, false, fork, &graph, &[], &[], false)?.complete()?; + let repository = open(fixture.path())?; + assert!(repository.head()?.is_detached(), "automatic fork travel detaches HEAD"); + assert_eq!(repository.head_id()?, fork); + let pins = crate::history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "the checkout consumes the temporary fork pin"); + assert!(pins[0].is_head(), "the singleton remembers the departed branch"); + assert_eq!(pins[0].id, main); + Ok(()) + } + + #[test] + fn creates_an_empty_root_commit_for_an_unborn_head() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["init", "-q", "-b", "main"]) + .status()? + .success() + ); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare_empty(open(fixture.path())?, None)?; + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "root-commit preflight is unobservable" + ); + let edited = prepared.document.replacen(b"what\n\nwhy", b"root\n\nreason", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert!(commit.parent_ids().next().is_none(), "the root has no parent"); + assert_eq!( + commit.tree_id()?, + ObjectId::empty_tree(repository.object_hash()), + "no index or worktree changes reuse the empty tree" + ); + assert_eq!( + repository.head_name()?.expect("HEAD is attached"), + "refs/heads/main", + "the unborn branch is created and remains checked out" + ); + Ok(()) + } + + #[test] + fn creates_the_unborn_branch_above_an_existing_base() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = open(fixture.path())?; + let base = repository.rev_parse_single("main")?.detach(); + let graph = crate::history::HistoryGraph::for_commits(&repository, &[base])?; + drop(repository); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["symbolic-ref", "HEAD", "refs/heads/unborn"]) + .status()? + .success() + ); + + let prepared = prepare_empty(open(fixture.path())?, Some(base))?; + let edited = prepared.document.replacen(b"what\n\nwhy", b"first\n\nreason", 1); + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let repository = open(fixture.path())?; + + assert_eq!(repository.head_id()?, new_id); + assert_eq!(repository.find_reference("refs/heads/main")?.id(), base); + assert_eq!( + repository.find_commit(new_id)?.parent_ids().next().map(gix::Id::detach), + Some(base), + "the first commit is based on the selected hidden tip" + ); + Ok(()) + } + + #[test] + fn explicit_empty_commit_preserves_index_and_worktree_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let parent = open(fixture.path())?.head_id()?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare_empty(open(fixture.path())?, Some(parent))?; + assert!(prepared.is_empty, "the explicit commit reuses its parent's tree"); + let edited = prepared.document.replacen(b"what\n\nwhy", b"empty\n\nreason", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert_eq!( + commit.tree_id()?, + repository.find_commit(parent)?.tree_id()?, + "the explicit empty commit keeps the parent tree" + ); + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!(after.index, before.index, "staged changes remain staged"); + assert_eq!( + after.worktree, before.worktree, + "worktree changes remain byte-identical" + ); + Ok(()) + } + + #[test] + fn implicit_new_commit_ignores_untracked_files() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["reset", "--hard", "-q", "HEAD"]) + .status()? + .success(), + "tracked files are restored while the untracked fixture remains" + ); + let parent = open(fixture.path())?.head_id()?.detach(); + let prepared = prepare(open(fixture.path())?, Some(parent))?; + assert!(prepared.is_empty, "untracked files do not enter an implicit new commit"); + Ok(()) + } + + #[test] + fn an_unrelated_worktree_head_is_not_checked_out_or_moved() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let parent = open(fixture.path())?.head_id()?.detach(); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "--orphan", "other"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["rm", "-rf", "-q", "."]) + .status()? + .success() + ); + std::fs::write(fixture.path().join("other"), b"other\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "other"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["-c", "commit.gpgSign=false", "commit", "-q", "-m", "other"]) + .status()? + .success() + ); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare(open(fixture.path())?, Some(parent))?; + let edited = prepared.document.replacen(b"what\n\nwhy", b"child\n\nreason", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + after.head, before.head, + "the unrelated checked-out branch does not move" + ); + assert_eq!(after.index, before.index, "the unrelated index does not change"); + assert_eq!( + after.worktree, before.worktree, + "the unrelated worktree does not change" + ); + assert_eq!( + open(fixture.path())?.find_reference("refs/heads/main")?.id(), + new_id, + "the selected parent branch advances independently" + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/forget.rs b/gix-tix/src/edit/forget.rs new file mode 100644 index 00000000000..8418fc531c1 --- /dev/null +++ b/gix-tix/src/edit/forget.rs @@ -0,0 +1,354 @@ +use std::{io::Write, path::Path, process::Command}; + +use anyhow::{Context, Result}; +use gix::{ObjectId, bstr::ByteSlice}; + +use super::rebase; + +pub(crate) struct Outcome { + pub selected: Option<ObjectId>, + pub review_return: Option<gix::refs::FullName>, + pub ref_changes: Vec<super::undo::RefChange>, +} + +pub(crate) enum Perform { + Complete(Outcome), + Conflict(Conflict), +} + +#[cfg(test)] +impl Perform { + fn complete(self) -> Result<Outcome> { + match self { + Perform::Complete(outcome) => Ok(outcome), + Perform::Conflict(_) => anyhow::bail!("forgetting the commit would cause a merge conflict"), + } + } +} + +pub(crate) struct Conflict { + rebase: rebase::Conflict, +} + +impl Conflict { + pub(crate) fn into_rebase(self) -> rebase::Conflict { + self.rebase + } +} + +#[cfg(test)] +#[tracing::instrument(skip_all, fields(commit_id = %id))] +pub(crate) fn perform(repo: gix::Repository, graph: &crate::history::HistoryGraph, id: ObjectId) -> Result<Outcome> { + perform_conflict(repo, graph, id, |_| {})?.complete() +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +pub(crate) fn perform_conflict( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + id: ObjectId, + report: impl FnMut(rebase::Progress), +) -> Result<Perform> { + let commit = repo + .find_commit(id) + .context("could not find the commit to forget")? + .decode()? + .into_owned()?; + let deletions = super::review::deletions(&repo, &commit)?; + let review_return = if repo.head_id().ok().map(gix::Id::detach) == Some(id) && !deletions.is_empty() { + match super::review::return_to(&commit)? { + Some(name) => Some(name), + None => { + let review = super::review::reference(&commit)?.context("the review return anchor is missing")?; + repo.find_reference(review.as_ref())? + .target() + .try_name() + .map(ToOwned::to_owned) + } + } + } else { + None + }; + let result = if deletions.is_empty() { + rebase::perform_with_progress( + &repo, + graph, + rebase::Edit::Remove { target: id }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIsAndMark, + report, + ) + } else { + rebase::perform_deleting_refs_with_progress( + &repo, + graph, + rebase::Edit::Remove { target: id }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIsAndMark, + deletions, + report, + ) + }?; + Ok(match result { + rebase::Perform::Complete(outcome) => Perform::Complete(Outcome { + selected: outcome.selected, + review_return, + ref_changes: outcome.ref_changes, + }), + rebase::Perform::Conflict(rebase) => { + anyhow::ensure!( + review_return.is_none(), + "a checked-out review root cannot suspend while returning from review" + ); + Perform::Conflict(Conflict { rebase }) + } + }) +} + +pub(super) fn preflight_tree_transition( + repo: &gix::Repository, + workdir: &Path, + old: ObjectId, + new: ObjectId, +) -> Result<()> { + let mut index = gix::tempfile::writable_at( + std::env::temp_dir().join(format!( + "tix-forget-index-{}-{old}-{:?}", + std::process::id(), + std::thread::current().id() + )), + gix::tempfile::ContainingDirectory::Exists, + gix::tempfile::AutoRemove::Tempfile, + ) + .context("could not create a temporary index for forget preflight")?; + index + .write_all(&std::fs::read(repo.index_path()).context("could not read the index before forgetting")?) + .context("could not copy the index for forget preflight")?; + index.flush().context("could not flush the forget preflight index")?; + let index = index.take().context("the forget preflight index disappeared")?; + let refresh = Command::new("git") + .arg("-C") + .arg(workdir) + .env("GIT_INDEX_FILE", index.path()) + .args(["update-index", "-q", "--refresh"]) + .output() + .context("could not refresh the index before forgetting")?; + if !refresh.status.success() { + anyhow::bail!("{}", refresh.stderr.to_str_lossy().trim()); + } + run_read_tree(workdir, Some(index.path()), true, old, new) + .context("local changes conflict with forgetting this commit") +} + +pub(super) fn apply_tree_transition(workdir: &Path, old: ObjectId, new: ObjectId) -> Result<()> { + let refresh = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["update-index", "-q", "--refresh"]) + .output() + .context("could not refresh the index before applying forget")?; + if !refresh.status.success() { + anyhow::bail!("{}", refresh.stderr.to_str_lossy().trim()); + } + run_read_tree(workdir, None, false, old, new).context("could not update the index and worktree") +} + +fn run_read_tree(workdir: &Path, index: Option<&Path>, dry_run: bool, old: ObjectId, new: ObjectId) -> Result<()> { + let mut command = Command::new("git"); + command.arg("-C").arg(workdir).arg("read-tree"); + if let Some(index) = index { + command.env("GIT_INDEX_FILE", index); + } + if dry_run { + command.arg("-n"); + } + let output = command + .args(["-m", "-u"]) + .arg(old.to_string()) + .arg(new.to_string()) + .output() + .context("could not run git read-tree")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("{}", output.stderr.to_str_lossy().trim()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open(path: &Path) -> gix_testtools::Result<gix::Repository> { + Ok(crate::test_repository::open(path)?) + } + + #[test] + fn forgets_a_tip_atomically_and_preserves_untracked_files() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("forget_commit.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + let repository = open(fixture.path())?; + let top = repository.head_id()?.detach(); + let parent = repository + .find_commit(top)? + .parent_ids() + .next() + .expect("top has a parent") + .detach(); + + let graph = super::super::loaded_graph(&repository)?; + assert_eq!(perform(repository.clone(), &graph, top)?.selected, Some(parent)); + let state = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + state.head, + gix_testtools::repository::Head::Symbolic { + name: b"refs/heads/main".into(), + id: parent, + }, + "the attached branch is retargeted to the parent" + ); + assert_eq!( + std::fs::read(fixture.path().join("tracked"))?, + b"base\n", + "the selected commit's tracked change is discarded" + ); + assert!( + !fixture.path().join("added").exists(), + "the selected commit's added file is removed" + ); + assert_eq!( + std::fs::read(fixture.path().join("untracked"))?, + b"untracked\n", + "unrelated untracked files survive" + ); + assert_eq!( + state.index_tree, + Some(repository.find_commit(parent)?.tree_id()?.detach()), + "the index matches the parent tree" + ); + for name in ["refs/heads/main", "refs/patches/forget"] { + assert_eq!( + repository.find_reference(name)?.id(), + parent, + "{name} follows the forget" + ); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!(repository.find_reference(name)?.id(), top, "{name} remains immutable"); + } + Ok(()) + } + + #[test] + fn refuses_conflicting_local_changes_without_mutating_repository_state() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("forget_commit.sh")?; + std::fs::write(fixture.path().join("tracked"), b"local\n")?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + let repository = open(fixture.path())?; + let top = repository.head_id()?.detach(); + assert!( + perform(repository.clone(), &super::super::loaded_graph(&repository)?, top).is_err(), + "overlapping local changes are rejected" + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "a failed preflight leaves refs, index, commits, and worktree unchanged" + ); + Ok(()) + } + + #[test] + fn forgetting_the_checked_out_root_leaves_an_unborn_branch() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + let git = |args: &[&str]| -> std::io::Result<std::process::ExitStatus> { + Command::new("git").arg("-C").arg(fixture.path()).args(args).status() + }; + assert!(git(&["init", "-q", "-b", "main"])?.success()); + assert!(git(&["config", "user.name", "author"])?.success()); + assert!(git(&["config", "user.email", "author@example.com"])?.success()); + std::fs::write(fixture.path().join("tracked"), b"root\n")?; + assert!(git(&["add", "tracked"])?.success()); + assert!(git(&["-c", "commit.gpgSign=false", "commit", "-q", "-m", "root"])?.success()); + std::fs::write(fixture.path().join("untracked"), b"keep\n")?; + let repository = open(fixture.path())?; + let root = repository.head_id()?.detach(); + + let graph = super::super::loaded_graph(&repository)?; + assert_eq!(perform(repository.clone(), &graph, root)?.selected, None); + let state = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + state.head, + gix_testtools::repository::Head::Unborn(b"refs/heads/main".into()), + "deleting the root branch leaves symbolic HEAD unborn" + ); + assert!(state.index.is_empty(), "the index becomes empty"); + assert!( + !fixture.path().join("tracked").exists(), + "tracked root content is removed" + ); + assert_eq!(std::fs::read(fixture.path().join("untracked"))?, b"keep\n"); + Ok(()) + } + + #[test] + fn forgetting_without_a_worktree_only_retargets_references() -> gix_testtools::Result { + let source = gix_testtools::scripted_fixture_read_only("forget_commit.sh")?; + let fixture = gix_testtools::tempfile::tempdir()?; + assert!( + Command::new("git") + .args(["clone", "-q", "--bare"]) + .arg(source) + .arg(fixture.path()) + .status()? + .success() + ); + let repository = open(fixture.path())?; + assert!(repository.is_bare(), "the scenario has no worktree"); + let top = repository.head_id()?.detach(); + let parent = repository + .find_commit(top)? + .parent_ids() + .next() + .expect("top has a parent") + .detach(); + let graph = super::super::loaded_graph(&repository)?; + assert_eq!(perform(repository.clone(), &graph, top)?.selected, Some(parent)); + assert_eq!(repository.head_id()?, parent, "HEAD's branch moves without checkout"); + Ok(()) + } + + #[test] + fn refuses_to_forget_a_checked_out_detached_root() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + let git = |args: &[&str]| -> std::io::Result<std::process::ExitStatus> { + Command::new("git").arg("-C").arg(fixture.path()).args(args).status() + }; + assert!(git(&["init", "-q", "-b", "main"])?.success()); + assert!(git(&["config", "user.name", "author"])?.success()); + assert!(git(&["config", "user.email", "author@example.com"])?.success()); + assert!( + git(&[ + "-c", + "commit.gpgSign=false", + "commit", + "--allow-empty", + "-q", + "-m", + "root" + ])? + .success() + ); + assert!(git(&["checkout", "-q", "--detach"])?.success()); + assert!(git(&["branch", "-D", "main"])?.success()); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let repository = open(fixture.path())?; + let root = repository.head_id()?.detach(); + assert!( + perform(repository.clone(), &super::super::loaded_graph(&repository)?, root).is_err(), + "detached HEAD cannot become unborn" + ); + assert_eq!(gix_testtools::repository::snapshot(fixture.path())?, before); + Ok(()) + } +} diff --git a/gix-tix/src/edit/head.rs b/gix-tix/src/edit/head.rs new file mode 100644 index 00000000000..9b86b95a1df --- /dev/null +++ b/gix-tix/src/edit/head.rs @@ -0,0 +1,736 @@ +use anyhow::{Context, Result}; +use gix::{ObjectId, bstr::BStr}; + +use super::{create, rebase}; +use crate::{ChangeKind, PathChange}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + Amend, + Spill, +} + +#[cfg(test)] +#[tracing::instrument(skip_all, fields(?kind))] +pub fn perform( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + kind: Kind, + selected_paths: Option<(&[PathChange], Option<ObjectId>)>, +) -> Result<Option<ObjectId>> { + Ok(perform_inner(repo, graph, kind, selected_paths, false, false, |_| {})?.and_then(|outcome| outcome.selected)) +} + +pub(crate) fn perform_with_changes( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + kind: Kind, + selected_paths: Option<(&[PathChange], Option<ObjectId>)>, + resolve_pending: bool, + report: impl FnMut(rebase::Progress), +) -> Result<Option<rebase::Outcome>> { + perform_inner(repo, graph, kind, selected_paths, false, resolve_pending, report) +} + +pub(crate) fn perform_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + kind: Kind, +) -> Result<Option<rebase::Outcome>> { + perform_inner(repo, graph, kind, None, false, false, |_| {}) +} + +#[tracing::instrument(skip_all)] +#[cfg(test)] +pub fn amend_index(repo: gix::Repository, graph: &crate::history::HistoryGraph) -> Result<Option<ObjectId>> { + Ok(perform_inner(repo, graph, Kind::Amend, None, true, true, |_| {})?.and_then(|outcome| outcome.selected)) +} + +pub(crate) fn amend_index_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, +) -> Result<Option<rebase::Outcome>> { + perform_inner(repo, graph, Kind::Amend, None, true, true, |_| {}) +} + +fn perform_inner( + mut repo: gix::Repository, + graph: &crate::history::HistoryGraph, + kind: Kind, + selected_paths: Option<(&[PathChange], Option<ObjectId>)>, + index_only: bool, + resolve_pending: bool, + mut report: impl FnMut(rebase::Progress), +) -> Result<Option<rebase::Outcome>> { + let head = repo + .head_id() + .context("editing requires an existing HEAD commit")? + .detach(); + let mut commit = repo + .find_commit(head) + .context("could not find HEAD commit")? + .decode() + .context("could not decode HEAD commit")? + .into_owned() + .context("could not own HEAD commit")?; + repo.workdir().context("editing HEAD requires a worktree")?; + repo.commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + repo = repo.with_object_memory(); + let old_tree = commit.tree; + let pending = rebase::is_pending(&commit); + let review = super::review::is_review(&commit); + let parent_tree = match commit.parents.first().copied() { + Some(parent) => repo.find_commit(parent)?.tree_id()?.detach(), + None => repo.empty_tree().id, + }; + let selected_amend_path = match (kind, selected_paths) { + (Kind::Amend, Some(([path], _))) => Some(path), + (Kind::Amend, Some(_)) => anyhow::bail!("amending requires exactly one selected path"), + _ => None, + }; + let tree = match kind { + Kind::Spill => match selected_paths { + Some((paths, selected_parent)) => { + spill_paths_tree(&repo, old_tree, selected_parent.unwrap_or(parent_tree), paths)? + } + None => parent_tree, + }, + Kind::Amend => { + let index = repo.index_or_empty().context("could not load the index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("cannot amend with unresolved index conflicts"); + } + if let Some(path) = selected_amend_path { + if review && path.group != crate::ChangeGroup::Staged { + anyhow::bail!("review commits can amend only staged paths"); + } + amend_path_tree(&repo, old_tree, path, &index)? + } else if review || index_only { + let index_tree = create::index_tree(&repo, &index)?; + if index_tree == old_tree && !pending { + return Ok(None); + } + index_tree + } else { + let index_tree = create::index_tree(&repo, &index)?; + if index_tree != old_tree { + index_tree + } else { + let baseline = repo.find_tree(old_tree)?; + create::worktree_tree(&repo, &baseline)? + } + } + } + }; + if tree == old_tree && !pending { + return Ok(None); + } + commit.tree = tree; + let edit = rebase::Edit::Replace { target: head, commit }; + let signature = if review { + rebase::Signature::Remove + } else if kind == Kind::Amend || pending { + rebase::Signature::RedoIfNeeded + } else { + rebase::Signature::InvalidateExisting + }; + let tree_mode = if review { + rebase::Tree::LeaveAsIsAndMarkDescendants + } else { + rebase::Tree::LeaveAsIsAndMark + }; + let performed = match selected_amend_path { + Some(path) if resolve_pending => { + let mut paths = vec![path.path.clone()]; + if path.kind == ChangeKind::Renamed + && let Some(source) = &path.source + { + paths.push(source.clone()); + } + rebase::perform_resetting_index_paths_allowing_pending_checkout_with_progress( + &repo, + graph, + edit, + signature, + tree_mode, + paths, + &mut report, + )? + } + Some(path) => { + let mut paths = vec![path.path.clone()]; + if path.kind == ChangeKind::Renamed + && let Some(source) = &path.source + { + paths.push(source.clone()); + } + rebase::perform_resetting_index_paths_with_progress( + &repo, + graph, + edit, + signature, + tree_mode, + paths, + &mut report, + )? + } + _ if kind == Kind::Amend && resolve_pending => rebase::perform_allowing_pending_checkout_with_progress( + &repo, + graph, + edit, + signature, + tree_mode, + &mut report, + )?, + _ => rebase::perform_with_progress(&repo, graph, edit, signature, tree_mode, &mut report)?, + }; + Ok(Some(performed.complete()?)) +} + +fn amend_path_tree( + repo: &gix::Repository, + commit_tree: ObjectId, + change: &PathChange, + index: &gix::index::File, +) -> Result<ObjectId> { + match change.group { + crate::ChangeGroup::Staged => { + let index_tree = create::index_tree(repo, index)?; + apply_path_from_tree(repo, commit_tree, index_tree, change) + } + crate::ChangeGroup::Unstaged => { + let baseline = repo.find_tree(commit_tree)?; + create::worktree_tree_with_changes( + repo, + &baseline, + &crate::Changes { + paths: vec![change.clone()], + ..crate::Changes::default() + }, + ) + } + crate::ChangeGroup::Tree => anyhow::bail!("a tree change cannot be amended from the worktree"), + } +} + +fn apply_path_from_tree( + repo: &gix::Repository, + commit_tree: ObjectId, + source_tree: ObjectId, + change: &PathChange, +) -> Result<ObjectId> { + let mut editor = repo.find_tree(commit_tree)?.edit()?; + if change.kind == ChangeKind::Renamed + && let Some(source) = &change.source + { + editor.remove(source)?; + } + if change.kind == ChangeKind::Deleted { + editor.remove(&change.path)?; + } else { + let source = repo.find_tree(source_tree)?; + let entry = source + .lookup_entry( + change + .path + .split(|byte| *byte == b'/') + .map(|component| BStr::new(component).to_owned()), + )? + .context("the selected path is absent from its source tree")?; + editor.upsert(&change.path, entry.mode().kind(), entry.object_id())?; + } + Ok(editor.write()?.detach()) +} + +fn spill_paths_tree( + repo: &gix::Repository, + commit_tree: ObjectId, + parent_tree: ObjectId, + changes: &[PathChange], +) -> Result<ObjectId> { + let parent = repo.find_tree(parent_tree).context("could not load the parent tree")?; + let mut editor = repo + .find_tree(commit_tree) + .context("could not load the commit tree")? + .edit() + .context("could not edit the commit tree")?; + for change in changes { + match change.kind { + ChangeKind::Added => { + editor.remove(&change.path).context("could not spill the added path")?; + } + ChangeKind::Deleted | ChangeKind::Modified | ChangeKind::TypeChanged => { + restore_path(&parent, &mut editor, &change.path)?; + } + ChangeKind::Renamed | ChangeKind::Copied => { + editor + .remove(&change.path) + .context("could not spill the rewritten destination")?; + if change.kind == ChangeKind::Renamed { + restore_path( + &parent, + &mut editor, + change.source.as_ref().context("a rename has no source path")?, + )?; + } + } + ChangeKind::Unmerged => anyhow::bail!("cannot spill an unmerged path"), + } + } + Ok(editor + .write() + .context("could not build the partially spilled tree")? + .detach()) +} + +fn restore_path( + parent: &gix::Tree<'_>, + editor: &mut gix::object::tree::Editor<'_>, + path: &gix::bstr::BString, +) -> Result<()> { + let entry = parent + .lookup_entry( + path.split(|byte| *byte == b'/') + .map(|component| BStr::new(component).to_owned()), + ) + .context("could not look up the path in the parent tree")? + .context("the path is absent from the parent tree")?; + editor + .upsert(path, entry.mode().kind(), entry.object_id()) + .context("could not restore the path from the parent tree")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result<gix::Repository> { + Ok(crate::test_repository::open_with( + path, + ["user.name=editor", "user.email=editor@example.com"], + )?) + } + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!("git {} failed: {}", args.join(" "), output.stderr.to_str_lossy()).into()); + } + Ok(output.stdout) + } + + #[test] + fn amend_prefers_the_index_and_leaves_worktree_files_alone() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let graph = super::super::loaded_graph(&repo)?; + let new = amend_index(repo, &graph)?.expect("staged changes amend HEAD"); + assert_ne!(new, old); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); + assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"staged\n"); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the index follows the amended commit" + ); + let commit = open(fixture.path())?.find_commit(new)?.decode()?.into_owned()?; + assert!( + !super::super::rebase::is_pending(&commit), + "an unsigned amended commit already has its final tree and parent" + ); + Ok(()) + } + + #[test] + fn signed_worktree_amend_is_finalized_immediately() -> gix_testtools::Result { + if !gix_testtools::signature::program_available("ssh-keygen") { + return Ok(()); + } + let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + git(fixture.path(), &["reset", "-q", "HEAD", "--", "tracked"])?; + let repo = crate::test_repository::open_with( + fixture.path(), + [ + "user.name=editor".to_owned(), + "user.email=editor@example.com".to_owned(), + "commit.gpgSign=true".to_owned(), + "gpg.format=ssh".to_owned(), + format!("user.signingKey={}", key.display()), + format!( + "gpg.ssh.allowedSignersFile={}", + gix_testtools::signature::fixture("ssh-allowed-signers").display() + ), + ], + )?; + let old = repo.head_id()?.detach(); + let signed = repo.find_commit(old)?.decode()?.sign( + repo.commit_signing_options_if_enabled()? + .expect("commit signing is configured"), + )?; + let signed = repo.write_object(&signed)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(signed, "prepare signed worktree amend")?; + let graph = super::super::loaded_graph(&repo)?; + + let amended = perform(repo.clone(), &graph, Kind::Amend, None)?.expect("the worktree change amends HEAD"); + assert_eq!(repo.head_id()?, amended, "HEAD follows the amended commit"); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the amended index is clean" + ); + assert!( + git(fixture.path(), &["diff", "--name-only"])?.is_empty(), + "the amended worktree is clean" + ); + let commit = repo.find_commit(amended)?; + assert!( + !super::super::rebase::is_pending(&commit.decode()?.into_owned()?), + "the checked-out amended commit needs no later replay" + ); + assert!( + commit + .verify_signature()? + .expect("the amended commit is signed") + .is_valid(), + "the amended commit receives a valid configured signature" + ); + Ok(()) + } + + #[test] + fn index_only_amend_does_not_fall_back_to_worktree_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + git(fixture.path(), &["reset", "-q", "HEAD", "--", "tracked"])?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let graph = super::super::loaded_graph(&repo)?; + + assert!(amend_index(repo, &graph)?.is_none(), "an unchanged index is a no-op"); + let repo = open(fixture.path())?; + assert_eq!(repo.head_id()?, old, "HEAD remains unchanged"); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the index remains clean" + ); + assert_eq!( + git(fixture.path(), &["diff", "--name-only"])?, + b"tracked\n", + "worktree-only changes remain uncommitted" + ); + Ok(()) + } + + #[test] + fn index_only_amend_finalizes_a_pending_commit_even_when_its_tree_is_unchanged() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let mut commit = repo.find_commit(old)?.decode()?.into_owned()?; + let parent = commit.parents.first().copied().expect("the fixture HEAD has a parent"); + commit + .extra_headers + .push(("tix-rebase-parent".into(), parent.to_string().into())); + let pending = repo.write_object(&commit)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(pending, "prepare pending amend")?; + let graph = super::super::loaded_graph(&repo)?; + + let finalized = amend_index(repo, &graph)?.expect("a pending commit must be finalized"); + let repo = open(fixture.path())?; + assert_eq!(repo.head_id()?, finalized); + assert!( + !super::super::rebase::is_pending(&repo.find_commit(finalized)?.decode()?.into_owned()?), + "an all-ours resolution removes the pending marker" + ); + Ok(()) + } + + #[test] + fn ordinary_amend_rejects_a_pending_head_outside_conflict_resolution() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let mut commit = repo.find_commit(old)?.decode()?.into_owned()?; + let parent = commit.parents.first().copied().expect("the fixture HEAD has a parent"); + commit + .extra_headers + .push(("tix-rebase-parent".into(), parent.to_string().into())); + let pending = repo.write_object(&commit)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(pending, "prepare an externally checked-out pending commit")?; + let graph = super::super::loaded_graph(&repo)?; + + let err = match perform(repo, &graph, Kind::Amend, None) { + Ok(_) => return Err("an ordinary amend must not resolve an arbitrary pending HEAD".into()), + Err(err) => err, + }; + assert!(err.to_string().contains("time-travel to HEAD"), "{err:#}"); + assert_eq!(open(fixture.path())?.head_id()?, pending); + Ok(()) + } + + #[test] + fn amend_rejects_an_unmarked_head_above_pending_ancestry() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old_tip = repo.head_id()?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let mut pending = repo.find_commit(middle)?.decode()?.into_owned()?; + pending + .extra_headers + .push(("tix-rebase-parent".into(), base.to_string().into())); + let pending = repo.write_object(&pending)?.detach(); + let mut tip = repo.find_commit(old_tip)?.decode()?.into_owned()?; + tip.parents = [pending].into_iter().collect(); + let tip = repo.write_object(&tip)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(tip, "prepare pending checkout ancestry")?; + std::fs::write(fixture.path().join("tip"), b"amended\n")?; + git(fixture.path(), &["add", "tip"])?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + + let err = match perform(repo, &graph, Kind::Amend, None) { + Ok(_) => return Err("amend must not preserve pending checkout ancestry".into()), + Err(err) => err, + }; + assert!(err.to_string().contains("time-travel to HEAD"), "{err:#}"); + assert_eq!(gix_testtools::repository::snapshot(fixture.path())?, before); + Ok(()) + } + + #[test] + fn amending_one_worktree_path_preserves_unrelated_staging() -> gix_testtools::Result { + for (group, expected) in [ + (crate::ChangeGroup::Staged, b"staged\n".as_slice()), + (crate::ChangeGroup::Unstaged, b"unstaged\n".as_slice()), + ] { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + std::fs::write(fixture.path().join("other"), "other\n")?; + git(fixture.path(), &["add", "other"])?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let selected = PathChange { + kind: ChangeKind::Modified, + group, + source: None, + path: "tracked".into(), + lines: None, + }; + let new = perform(repo, &graph, Kind::Amend, Some((std::slice::from_ref(&selected), None)))? + .expect("the selected path changes HEAD"); + assert_eq!(git(fixture.path(), &["show", &format!("{new}:tracked")])?, expected); + assert_eq!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?, + b"other\n", + "an unrelated addition remains staged" + ); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); + let unstaged = git(fixture.path(), &["diff", "--name-only"])?; + if group == crate::ChangeGroup::Staged { + assert_eq!(unstaged, b"tracked\n", "the worktree-only delta remains"); + } else { + assert!(unstaged.is_empty(), "the amended worktree version becomes clean"); + } + } + Ok(()) + } + + #[test] + fn scoped_amend_rolls_back_if_the_index_cannot_be_locked() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let index_before = std::fs::read(repo.index_path())?; + let graph = super::super::loaded_graph(&repo)?; + let selected = PathChange { + kind: ChangeKind::Modified, + group: crate::ChangeGroup::Staged, + source: None, + path: "tracked".into(), + lines: None, + }; + std::fs::write(fixture.path().join(".git/index.lock"), "locked")?; + let err = perform(repo, &graph, Kind::Amend, Some((std::slice::from_ref(&selected), None))) + .expect_err("an index lock prevents the amend"); + assert!(format!("{err:#}").contains("selected index paths")); + let repo = open(fixture.path())?; + assert_eq!(repo.head_id()?, old, "the rewritten ref is rolled back"); + assert_eq!( + std::fs::read(repo.index_path())?, + index_before, + "the original index is restored" + ); + Ok(()) + } + + #[test] + fn scoped_amend_synchronizes_changed_index_paths() -> gix_testtools::Result { + for kind in [ + ChangeKind::Added, + ChangeKind::Deleted, + ChangeKind::Renamed, + ChangeKind::Copied, + ] { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + git( + fixture.path(), + &["restore", "--source=HEAD", "--staged", "--worktree", "tracked"], + )?; + std::fs::write(fixture.path().join("other"), "other\n")?; + git(fixture.path(), &["add", "other"])?; + let (path, source) = match kind { + ChangeKind::Added => { + std::fs::write(fixture.path().join("added"), "added\n")?; + git(fixture.path(), &["add", "added"])?; + ("added", None) + } + ChangeKind::Deleted => { + std::fs::remove_file(fixture.path().join("tracked"))?; + git(fixture.path(), &["add", "-u", "tracked"])?; + ("tracked", None) + } + ChangeKind::Renamed => { + git(fixture.path(), &["mv", "tracked", "renamed"])?; + ("renamed", Some("tracked")) + } + ChangeKind::Copied => { + std::fs::copy(fixture.path().join("tracked"), fixture.path().join("copied"))?; + git(fixture.path(), &["add", "copied"])?; + ("copied", Some("tracked")) + } + _ => unreachable!("the test lists only path-shape changes"), + }; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let selected = PathChange { + kind, + group: crate::ChangeGroup::Staged, + source: source.map(Into::into), + path: path.into(), + lines: None, + }; + let new = perform(repo, &graph, Kind::Amend, Some((std::slice::from_ref(&selected), None)))? + .expect("the selected path changes HEAD"); + let repo = open(fixture.path())?; + let tree = repo.find_commit(new)?.tree()?; + assert_eq!( + tree.lookup_entry([path])?.is_some(), + kind != ChangeKind::Deleted, + "the destination follows the selected change" + ); + if kind == ChangeKind::Renamed + && let Some(source) = source + { + assert!(tree.lookup_entry([source])?.is_none(), "the renamed source is removed"); + } else if kind == ChangeKind::Copied { + assert!( + tree.lookup_entry(["tracked"])?.is_some(), + "the copied source is retained" + ); + } + assert_eq!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?, + b"other\n", + "the unrelated addition remains staged" + ); + } + Ok(()) + } + + #[test] + fn spill_moves_the_tip_tree_change_to_the_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let parent_tree = repo + .find_commit(old)? + .parent_ids() + .next() + .expect("tip has parent") + .object()? + .peel_to_tree()? + .id; + let graph = super::super::loaded_graph(&repo)?; + let new = perform(repo, &graph, Kind::Spill, None)?.expect("the tip introduces changes"); + let repo = open(fixture.path())?; + assert_eq!(repo.find_commit(new)?.tree_id()?, parent_tree); + assert_eq!( + std::fs::read(fixture.path().join("tip"))?, + b"tip\n", + "worktree content survives" + ); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the index follows the spilled commit" + ); + assert_eq!(git(fixture.path(), &["status", "--short"])?, b"?? tip\n"); + let graph = super::super::loaded_graph(&repo)?; + assert_eq!( + perform(repo, &graph, Kind::Spill, None)?, + None, + "an empty spill is a no-op" + ); + Ok(()) + } + + #[test] + fn spilling_a_root_uses_the_empty_tree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let new = perform(repo, &graph, Kind::Spill, None)?.expect("the root has a non-empty tree"); + let repo = open(fixture.path())?; + assert_eq!(repo.find_commit(new)?.tree_id()?, repo.empty_tree().id); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the root spill resets the index to empty" + ); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); + Ok(()) + } + + #[test] + fn spilling_one_path_keeps_the_other_commit_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + std::fs::write(fixture.path().join("other"), "other\n")?; + git(fixture.path(), &["add", "other"])?; + git(fixture.path(), &["commit", "--amend", "--no-edit"])?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let selected = PathChange { + kind: ChangeKind::Added, + group: crate::ChangeGroup::Tree, + source: None, + path: "tip".into(), + lines: None, + }; + let new = perform(repo, &graph, Kind::Spill, Some((std::slice::from_ref(&selected), None)))? + .expect("the selected path can be spilled"); + let repo = open(fixture.path())?; + let tree = repo.find_commit(new)?.tree()?; + assert!( + tree.lookup_entry(["other"])?.is_some(), + "the unselected addition remains committed" + ); + assert!( + tree.lookup_entry(["tip"])?.is_none(), + "the selected addition is spilled" + ); + assert_eq!(std::fs::read(fixture.path().join("tip"))?, b"tip\n"); + assert_eq!(git(fixture.path(), &["status", "--short"])?, b"?? tip\n"); + Ok(()) + } +} diff --git a/gix-tix/src/edit/mod.rs b/gix-tix/src/edit/mod.rs new file mode 100644 index 00000000000..d3a47fd4b3e --- /dev/null +++ b/gix-tix/src/edit/mod.rs @@ -0,0 +1,242 @@ +use std::{ffi::OsStr, io::Write, process::Command}; + +use anyhow::{Context, Result}; + +pub(super) fn loaded_graph(repo: &gix::Repository) -> Result<crate::history::HistoryGraph> { + if repo.head_id().is_err() { + return Ok(crate::history::HistoryGraph::default()); + } + let mut revisions = Vec::new(); + for reference in repo.references()?.all()? { + let reference = reference.map_err(|err| anyhow::anyhow!("could not read reference: {err}"))?; + if reference.name().as_bstr().starts_with(crate::history::STASH_PREFIX) + || reference + .name() + .as_bstr() + .starts_with(crate::history::REVIEW_STASH_PREFIX) + || undo::is_queue_ref(reference.name().as_bstr()) + { + continue; + } + let Some(id) = reference.try_id() else { continue }; + if reference.name().as_bstr() == b"HEAD" + || repo + .find_header(id) + .context("could not inspect reference target")? + .kind() + != gix::object::Kind::Commit + { + continue; + } + revisions.push( + gix::path::from_bstr(reference.name().as_bstr()) + .into_owned() + .into_os_string(), + ); + } + if repo.head().is_ok_and(|head| head.referent_name().is_none()) { + revisions.push("HEAD".into()); + } + load_graph(repo, &revisions, &[]) +} + +pub(super) fn loaded_view_graph(repo: &gix::Repository) -> Result<crate::history::HistoryGraph> { + load_graph(repo, &[], &[]) +} + +pub(super) fn loaded_view_graph_with( + repo: &gix::Repository, + revisions: &[std::ffi::OsString], +) -> Result<crate::history::HistoryGraph> { + load_graph(repo, revisions, &[]) +} + +pub(super) fn loaded_view_graph_with_hidden( + repo: &gix::Repository, + revisions: &[std::ffi::OsString], + hidden_revisions: &[std::ffi::OsString], +) -> Result<crate::history::HistoryGraph> { + load_graph(repo, revisions, hidden_revisions) +} + +fn load_graph( + repo: &gix::Repository, + revisions: &[std::ffi::OsString], + hidden_revisions: &[std::ffi::OsString], +) -> Result<crate::history::HistoryGraph> { + use std::sync::atomic::AtomicBool; + + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new( + crate::history::Authors::default(), + )); + let mut graph = None; + crate::history::load( + repo, + revisions, + hidden_revisions, + false, + &authors, + &AtomicBool::new(false), + |event| { + if let crate::history::Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + graph.context("history traversal did not produce a graph") +} + +pub(crate) mod create; +pub(crate) mod forget; +pub(crate) mod head; +pub(crate) mod rebase; +pub(crate) mod review; +pub(crate) mod reword; +pub(crate) mod split; +pub(crate) mod stash; +pub(crate) mod time_travel; +pub(crate) mod todo; +pub(crate) mod undo; + +#[tracing::instrument(skip_all, fields(filename))] +pub(crate) fn edit_document( + terminal: &mut ratatui::DefaultTerminal, + editor: &OsStr, + document: &[u8], + filename: &str, + enhanced_keyboard: bool, +) -> Result<Option<Vec<u8>>> { + if editor == ":" { + return edit_document_without_terminal(editor, document, filename); + } + crate::with_suspended_terminal(terminal, enhanced_keyboard, || { + edit_document_without_terminal(editor, document, filename) + }) +} + +pub(crate) fn edit_document_without_terminal( + editor: &OsStr, + document: &[u8], + filename: &str, +) -> Result<Option<Vec<u8>>> { + let mut tempfile = gix::tempfile::writable_at( + std::env::temp_dir().join(filename), + gix::tempfile::ContainingDirectory::Exists, + gix::tempfile::AutoRemove::Tempfile, + ) + .context("could not create commit message file")?; + tempfile + .write_all(document) + .context("could not write commit message file")?; + tempfile.flush().context("could not flush commit message file")?; + let path = tempfile + .with_mut(|tempfile| tempfile.path().to_owned()) + .context("commit message file disappeared")?; + let _tempfile = tempfile.close().context("could not close commit message file")?; + + if editor != ":" { + let status = Command::from( + gix::command::prepare(editor) + .arg(&path) + .command_may_be_shell_script_allow_manual_argument_splitting(), + ) + .status() + .with_context(|| format!("could not launch Git editor {}", editor.to_string_lossy()))?; + if !status.success() { + anyhow::bail!("Git editor {} exited with {status}", editor.to_string_lossy()); + } + } + let edited = std::fs::read(path).context("could not read edited commit message")?; + Ok((edited != document).then_some(edited)) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use super::*; + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) + } + + #[test] + fn edit_graph_ignores_refs_that_do_not_point_to_commits() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + git(fixture.path(), &["update-ref", "refs/cache/tree", "HEAD^{tree}"])?; + let repo = crate::test_repository::open(fixture.path())?; + let head = repo.head_id()?.detach(); + let graph = loaded_graph(&repo)?; + assert!(graph.parents_of(head).is_some(), "HEAD remains part of the edit graph"); + Ok(()) + } + + #[test] + fn edit_graph_ignores_undo_queue_retention_commits() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let path = fixture.path(); + let retained = String::from_utf8(git( + path, + &["commit-tree", "HEAD^{tree}", "-p", "HEAD", "-m", "undo-only"], + )?)?; + let retained = retained.trim(); + git(path, &["update-ref", undo::TIP_REF, retained])?; + git(path, &["update-ref", undo::CURSOR_REF, retained])?; + + let repo = crate::test_repository::open(path)?; + let retained = gix::ObjectId::from_hex(retained.as_bytes())?; + assert!( + loaded_graph(&repo)?.parents_of(retained).is_none(), + "undo retention commits never enter the editable graph" + ); + Ok(()) + } + + #[test] + fn edit_graph_excludes_unrelated_descendant_merges() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let path = fixture.path(); + let head = String::from_utf8(git(path, &["rev-parse", "HEAD"])?)?; + let head = head.trim(); + let tree = String::from_utf8(git(path, &["rev-parse", "HEAD^{tree}"])?)?; + let tree = tree.trim(); + let pinned = String::from_utf8(git(path, &["commit-tree", tree, "-p", head, "-m", "pinned tip"])?)?; + let pinned = pinned.trim(); + let merge = String::from_utf8(git( + path, + &["commit-tree", tree, "-p", head, "-p", pinned, "-m", "unrelated merge"], + )?)?; + let merge = merge.trim(); + git(path, &["update-ref", "refs/worktree/tix/pins/keep", pinned])?; + git(path, &["update-ref", "refs/heads/unrelated", merge])?; + git(path, &["checkout", "--detach", head])?; + + let repo = crate::test_repository::open(path)?; + let graph = loaded_view_graph(&repo)?; + let pinned = gix::ObjectId::from_hex(pinned.as_bytes())?; + let merge = gix::ObjectId::from_hex(merge.as_bytes())?; + assert!( + graph.parents_of(pinned).is_some(), + "the applicable pin remains in edit scope" + ); + assert!( + graph.parents_of(merge).is_none(), + "an unrelated ref does not add its descendant merge to edit scope" + ); + assert!( + head::perform(repo, &graph, head::Kind::Amend, None)?.is_some(), + "amending HEAD succeeds despite the unrelated merge" + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/rebase.rs b/gix-tix/src/edit/rebase.rs new file mode 100644 index 00000000000..590d4b92dad --- /dev/null +++ b/gix-tix/src/edit/rebase.rs @@ -0,0 +1,5522 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + process::Command, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BStr, BString, ByteSlice}, + objs::Write, + prelude::ObjectIdExt, + refs::{ + Category, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +use crate::history::HistoryGraph; + +const MARKER: &[u8] = b"tix-rebase"; +const ORIGINAL_PARENT: &[u8] = b"tix-rebase-parent"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Signature { + InvalidateExisting, + RedoIfNeeded, + Remove, +} + +enum CommitState { + Unmarked(Signature), + Pending { original_parent: Option<ObjectId> }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tree { + #[cfg_attr(not(test), allow(dead_code))] + LeaveAsIs, + LeaveAsIsAndMark, + LeaveAsIsAndMarkDescendants, + CherryPick, +} + +pub(crate) enum Edit { + Replace { + target: ObjectId, + commit: gix::objs::Commit, + }, + Insert { + anchor: Option<ObjectId>, + commit: gix::objs::Commit, + reset_index: bool, + }, + Fork { + anchor: ObjectId, + commit: gix::objs::Commit, + }, + Remove { + target: ObjectId, + }, + Split { + target: ObjectId, + source: gix::objs::Commit, + upper: gix::objs::Commit, + }, + Repeat { + base: ObjectId, + checkout: ObjectId, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PlanParent { + Existing(ObjectId), + Step(usize), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PlanCommit { + Pick(ObjectId), + Copy(ObjectId), + Resolved(ObjectId), + Empty(BString), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanStep { + pub parent: PlanParent, + pub commit: PlanCommit, + pub squash: Vec<ObjectId>, +} + +#[derive(Clone, Debug)] +pub(crate) struct ExpectedRef { + pub name: gix::refs::FullName, + pub old: Option<ObjectId>, + pub target: ObjectId, + pub new: Option<ObjectId>, + pub follows_tip: bool, + pub editable: bool, + pub placement: Option<PlanParent>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlanCheckout { + pub target: PlanParent, + pub reference: Option<gix::refs::FullName>, +} + +#[derive(Clone, Debug)] +pub(crate) struct Plan { + pub base: ObjectId, + pub scope: Vec<ObjectId>, + pub steps: Vec<PlanStep>, + pub checkout: Option<PlanCheckout>, + pub expected_refs: Vec<ExpectedRef>, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct Progress { + pub total: usize, + pub processed: usize, + pub cherry_picked: usize, + pub signed: usize, + pub cherry_pick_time: Duration, + pub signing_time: Duration, +} + +impl Progress { + fn for_plan(plan: &Plan) -> Self { + Progress { + total: plan.steps.len() + plan.steps.iter().map(|step| step.squash.len()).sum::<usize>(), + ..Progress::default() + } + } +} + +pub(crate) enum PlanPerform { + Complete(Outcome), + Conflict(PlanConflict), +} + +impl PlanPerform { + #[cfg(test)] + pub(crate) fn complete(self) -> Result<Outcome> { + match self { + PlanPerform::Complete(outcome) => Ok(outcome), + PlanPerform::Conflict(conflict) => anyhow::bail!("rebase plan conflicts at {}", conflict.original()), + } + } +} + +pub(crate) struct PlanConflict { + conflict: Conflict, + plan: Plan, + produced: Vec<ObjectId>, + rewritten: HashMap<ObjectId, Option<ObjectId>>, + conflict_step: usize, + continuation_start: usize, + remaining_squash: Vec<Vec<ObjectId>>, + final_refs: HashSet<gix::refs::FullName>, +} + +impl PlanConflict { + pub(crate) fn original(&self) -> ObjectId { + self.conflict.original() + } + + pub(crate) fn commit(&self) -> ObjectId { + self.conflict.commit + } + + pub(crate) fn produced(&self) -> &[ObjectId] { + &self.produced + } + + pub(crate) fn repository(&self) -> &gix::Repository { + &self.conflict.prepared.repo + } + + pub(crate) fn persist_objects(&mut self) -> Result<()> { + self.conflict.prepared.persist_objects() + } + + pub(crate) fn map(&self, id: ObjectId) -> Option<ObjectId> { + self.rewritten.get(&id).copied().unwrap_or(Some(id)) + } + + pub(crate) fn continuation_plan(&self) -> Plan { + let expected_refs = self + .plan + .expected_refs + .iter() + .filter(|expected| !self.final_refs.contains(&expected.name)) + .map(|expected| ExpectedRef { + name: expected.name.clone(), + old: expected.old, + target: expected.new.expect("continued refs have a target"), + new: expected.new, + follows_tip: expected.follows_tip, + editable: expected.editable, + placement: expected.placement.map(|target| match target { + PlanParent::Existing(id) => PlanParent::Existing(id), + PlanParent::Step(index) if index < self.continuation_start => { + PlanParent::Existing(self.produced[index]) + } + PlanParent::Step(index) => PlanParent::Step(index - self.continuation_start), + }), + }) + .collect(); + let mut scope = self.produced[self.continuation_start..].to_vec(); + scope.extend(self.remaining_squash.iter().flatten().copied()); + let base = match self.plan.steps[self.continuation_start].parent { + PlanParent::Existing(id) => id, + PlanParent::Step(parent) => self.produced[parent], + }; + Plan { + base, + scope, + steps: self + .plan + .steps + .iter() + .enumerate() + .skip(self.continuation_start) + .map(|(index, step)| PlanStep { + parent: match step.parent { + PlanParent::Existing(id) => PlanParent::Existing(id), + PlanParent::Step(parent) if parent < self.continuation_start => { + PlanParent::Existing(self.produced[parent]) + } + PlanParent::Step(parent) => PlanParent::Step(parent - self.continuation_start), + }, + commit: if index == self.conflict_step { + PlanCommit::Resolved(self.produced[index]) + } else { + PlanCommit::Pick(self.produced[index]) + }, + squash: self.remaining_squash[index].clone(), + }) + .collect(), + checkout: self.plan.checkout.as_ref().map(|checkout| PlanCheckout { + target: match checkout.target { + PlanParent::Existing(id) => PlanParent::Existing(id), + PlanParent::Step(index) if index < self.continuation_start => { + PlanParent::Existing(self.produced[index]) + } + PlanParent::Step(index) => PlanParent::Step(index - self.continuation_start), + }, + reference: checkout.reference.clone(), + }), + expected_refs, + } + } + + pub(crate) fn into_conflict(self) -> Conflict { + self.conflict + } +} + +pub(crate) struct Outcome { + pub selected: Option<ObjectId>, + pub checkout_reference: Option<gix::refs::FullName>, + pub deferred_ref_deletions: Vec<(gix::refs::FullName, ObjectId)>, + pub ref_rewrites: Vec<RefRewrite>, + pub ref_changes: Vec<super::undo::RefChange>, + rewritten: HashMap<ObjectId, Option<ObjectId>>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RefRewrite { + pub name: gix::refs::FullName, + pub old: ObjectId, + pub new: ObjectId, +} + +impl Outcome { + pub(crate) fn map(&self, id: ObjectId) -> Option<ObjectId> { + self.rewritten.get(&id).copied().unwrap_or(Some(id)) + } +} + +pub(crate) enum Perform { + Complete(Outcome), + Conflict(Conflict), +} + +impl Perform { + pub(crate) fn complete(self) -> Result<Outcome> { + match self { + Perform::Complete(outcome) => Ok(outcome), + Perform::Conflict(_) => anyhow::bail!("an edit unexpectedly produced a merge conflict"), + } + } +} + +pub(crate) struct Conflict { + prepared: Prepared, + conflicts: Vec<gix::merge::tree::Conflict>, + merged_tree: ObjectId, + commit: ObjectId, + original: ObjectId, +} + +impl Conflict { + pub(crate) fn original(&self) -> ObjectId { + self.original + } + + pub(crate) fn persist(mut self) -> Result<PersistedConflict> { + let outcome = self.prepared.finish()?; + Ok(PersistedConflict { + repo: self.prepared.repo, + conflicts: self.conflicts, + merged_tree: self.merged_tree, + commit: self.commit, + deferred_ref_deletions: outcome.deferred_ref_deletions, + ref_rewrites: outcome.ref_rewrites, + ref_changes: outcome.ref_changes, + rewritten: outcome.rewritten, + }) + } +} + +pub(crate) struct PersistedConflict { + repo: gix::Repository, + conflicts: Vec<gix::merge::tree::Conflict>, + merged_tree: ObjectId, + pub(crate) commit: ObjectId, + pub(crate) deferred_ref_deletions: Vec<(gix::refs::FullName, ObjectId)>, + pub(crate) ref_rewrites: Vec<RefRewrite>, + pub(crate) ref_changes: Vec<super::undo::RefChange>, + rewritten: HashMap<ObjectId, Option<ObjectId>>, +} + +impl PersistedConflict { + pub(crate) fn map(&self, id: ObjectId) -> Option<ObjectId> { + self.rewritten.get(&id).copied().unwrap_or(Some(id)) + } + + pub(crate) fn materialize(&mut self) -> Result<()> { + let mut index = self + .repo + .index_from_tree(&self.merged_tree) + .context("could not prepare the conflicting index")?; + if !gix::merge::tree::apply_index_entries( + &self.conflicts, + gix::merge::tree::TreatAsUnresolved::git(), + &mut index, + gix::merge::tree::apply_index_entries::RemovalMode::Prune, + ) { + anyhow::bail!("could not apply conflict stages to the prepared index"); + } + index.remove_tree(); + let ours_tree = self + .repo + .find_commit(self.commit) + .context("could not find the conflicting commit")? + .tree_id() + .context("could not read the conflicting commit tree")? + .detach(); + let workdir = self + .repo + .workdir() + .context("materializing a conflict requires a worktree")?; + super::forget::apply_tree_transition(workdir, ours_tree, self.merged_tree) + .context("could not check out the conflicting merge result")?; + if let Err(err) = index + .write(gix::index::write::Options::default()) + .context("could not write the conflicting index") + { + return match super::forget::apply_tree_transition(workdir, self.merged_tree, ours_tree) { + Ok(()) => Err(err), + Err(rollback) => Err(err.context(format!("conflict checkout rollback failed: {rollback:#}"))), + }; + } + Ok(()) + } +} + +struct Prepared { + repo: gix::Repository, + reset_indices: HashSet<ObjectId>, + reset_index_paths: Option<Vec<BString>>, + skip_worktree_transitions: bool, + selected: Option<ObjectId>, + rewritten: HashMap<ObjectId, Option<ObjectId>>, + note_rewrites: Vec<(ObjectId, ObjectId)>, + stash_rewritten: HashMap<ObjectId, Option<ObjectId>>, + removed: HashSet<ObjectId>, + committer: gix::actor::Signature, + expected_refs: Option<Vec<ExpectedRef>>, + checkout_reference: Option<gix::refs::FullName>, + checkout_after_finish: bool, + pins: Vec<ObjectId>, + delete_refs: Vec<(gix::refs::FullName, Target)>, + enrichment: Option<(ObjectId, BString)>, +} + +pub(crate) fn capture_refs(repo: &gix::Repository, scope: &[ObjectId], tips: &[ObjectId]) -> Result<Vec<ExpectedRef>> { + let scope: HashSet<_> = scope.iter().copied().collect(); + let tips: HashSet<_> = tips.iter().copied().collect(); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for reference in repo.references()?.all()? { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => anyhow::bail!("could not inspect a reference before editing: {err}"), + }; + if matches!( + reference.name().category(), + Some(Category::Tag | Category::RemoteBranch) + ) || super::undo::is_queue_ref(reference.name().as_bstr()) + { + continue; + } + let Some(old) = reference.try_id().map(gix::Id::detach) else { + continue; + }; + if scope.contains(&old) && seen.insert(reference.name().to_owned()) { + out.push(ExpectedRef { + name: reference.name().to_owned(), + old: Some(old), + target: old, + new: Some(old), + follows_tip: tips.contains(&old), + editable: !reference.name().as_bstr().starts_with(crate::history::PIN_PREFIX) + && !reference.name().as_bstr().starts_with(crate::history::REVIEW_PREFIX), + placement: None, + }); + } + } + Ok(out) +} + +pub(crate) fn squash_plan( + repo: &gix::Repository, + graph: &HistoryGraph, + source: ObjectId, + target: ObjectId, +) -> Result<Plan> { + if source == target || !graph.is_ancestor(target, source) { + anyhow::bail!("the squash target must be a strict ancestor of the source"); + } + let source_parents = graph + .parents_of(source) + .context("the squash source is not in the loaded history")?; + let [source_parent] = source_parents.as_slice() else { + anyhow::bail!("the squash source must have exactly one parent"); + }; + let source_parent = *source_parent; + let target_parents = graph + .parents_of(target) + .context("the squash target is not in the loaded history")?; + let [base] = target_parents.as_slice() else { + anyhow::bail!("the squash target must have exactly one parent"); + }; + let base = *base; + let scope = graph + .descendants_in_parent_order(target) + .context("the squash target is not in the loaded history")?; + let scope_set: HashSet<_> = scope.iter().copied().collect(); + let mut non_leaves = HashSet::new(); + for id in &scope { + let parents = graph + .parents_of(*id) + .context("an affected squash commit is incomplete")?; + if parents.len() > 1 { + anyhow::bail!("descendant merge commits cannot be squashed"); + } + non_leaves.extend(parents.into_iter().filter(|parent| scope_set.contains(parent))); + } + let tips: Vec<_> = scope.iter().copied().filter(|id| !non_leaves.contains(id)).collect(); + let mut steps = Vec::with_capacity(scope.len().saturating_sub(1)); + let mut step_by_id = HashMap::new(); + for id in scope.iter().copied().filter(|id| *id != source) { + let original_parent = graph + .parents_of(id) + .and_then(|parents| parents.first().copied()) + .context("an affected squash commit has no parent")?; + let parent = if original_parent == source { + source_parent + } else { + original_parent + }; + let parent = step_by_id + .get(&parent) + .copied() + .map_or(PlanParent::Existing(parent), PlanParent::Step); + let index = steps.len(); + steps.push(PlanStep { + parent, + commit: PlanCommit::Pick(id), + squash: (id == target).then_some(source).into_iter().collect(), + }); + step_by_id.insert(id, index); + } + let target_step = *step_by_id.get(&target).context("the squash target has no plan step")?; + step_by_id.insert(source, target_step); + + let mut expected_refs = capture_refs(repo, &scope, &tips)?; + let source_tip_target = PlanParent::Step(step_by_id[&source_parent]); + for expected in &mut expected_refs { + if expected.target == source && expected.follows_tip { + expected.placement = Some(source_tip_target); + } + } + let head = repo.head()?; + let checkout = head + .id() + .map(gix::Id::detach) + .filter(|id| scope_set.contains(id)) + .map(|id| { + let reference = head + .referent_name() + .filter(|name| name.category() == Some(Category::LocalBranch)) + .map(ToOwned::to_owned); + let target = reference + .as_ref() + .and_then(|name| { + expected_refs + .iter() + .find(|expected| expected.name == *name) + .and_then(|expected| expected.placement) + }) + .unwrap_or(PlanParent::Step(step_by_id[&id])); + PlanCheckout { target, reference } + }); + + Ok(Plan { + base, + scope, + steps, + checkout, + expected_refs, + }) +} + +pub(crate) fn copy_insert_plan( + repo: &gix::Repository, + graph: &HistoryGraph, + source: ObjectId, + target: ObjectId, +) -> Result<Plan> { + let source_parents = graph + .parents_of(source) + .context("the copy source is not in the loaded history")?; + let [_source_parent] = source_parents.as_slice() else { + anyhow::bail!("copying a commit requires it to have exactly one parent"); + }; + let source_commit = repo + .find_commit(source) + .context("could not find the copy source")? + .decode() + .context("could not decode the copy source")? + .into_owned() + .context("could not own the copy source")?; + if super::review::reference(&source_commit)?.is_some() { + anyhow::bail!("review commits cannot be copied"); + } + if source == target { + anyhow::bail!("the copy source and target must differ"); + } + + let mut scope = graph + .descendants_in_parent_order(target) + .context("the copy target is not in the loaded history")?; + scope.retain(|id| *id != target); + let mut steps = vec![PlanStep { + parent: PlanParent::Existing(target), + commit: PlanCommit::Copy(source), + squash: Vec::new(), + }]; + let mut step_by_id = HashMap::with_capacity(scope.len()); + for id in &scope { + let parents = graph.parents_of(*id).context("an affected copy commit is incomplete")?; + let [parent] = parents.as_slice() else { + anyhow::bail!("copying a commit cannot rewrite root or merge commits"); + }; + let parent = if *parent == target { + PlanParent::Step(0) + } else { + step_by_id + .get(parent) + .copied() + .map_or(PlanParent::Existing(*parent), PlanParent::Step) + }; + step_by_id.insert(*id, steps.len()); + steps.push(PlanStep { + parent, + commit: PlanCommit::Pick(*id), + squash: Vec::new(), + }); + } + + let mut ref_scope = Vec::with_capacity(scope.len() + 1); + ref_scope.push(target); + ref_scope.extend(scope.iter().copied()); + let ref_scope_set: HashSet<_> = ref_scope.iter().copied().collect(); + let mut non_leaves = HashSet::new(); + for id in &ref_scope { + non_leaves.extend( + graph + .parents_of(*id) + .context("an affected copy commit is incomplete")? + .into_iter() + .filter(|parent| ref_scope_set.contains(parent)), + ); + } + let tips: Vec<_> = ref_scope + .iter() + .copied() + .filter(|id| !non_leaves.contains(id)) + .collect(); + let expected_refs = capture_refs(repo, &ref_scope, &tips)?; + let checkout = Some(PlanCheckout { + target: PlanParent::Step(0), + reference: None, + }); + + Ok(Plan { + base: target, + scope, + steps, + checkout, + expected_refs, + }) +} + +pub(crate) fn move_insert_plan( + repo: &gix::Repository, + graph: &HistoryGraph, + source: ObjectId, + target: ObjectId, +) -> Result<Plan> { + stack_insert_plan(repo, graph, source, source, target) +} + +pub(crate) fn stack_insert_plan( + repo: &gix::Repository, + graph: &HistoryGraph, + base: ObjectId, + head: ObjectId, + target: ObjectId, +) -> Result<Plan> { + if repo.head_id()?.detach() != head { + anyhow::bail!("the move source must be the current HEAD"); + } + if !graph.is_ancestor(base, head) { + anyhow::bail!("the stack base must be an ancestor of HEAD"); + } + graph + .parents_of(target) + .context("the move target is not in the loaded history")?; + + let mut stack = vec![head]; + let mut stack_parent = HashMap::new(); + loop { + let id = *stack.last().expect("a stack always contains HEAD"); + let parents = graph.parents_of(id).context("a moved stack commit is incomplete")?; + let [parent] = parents.as_slice() else { + anyhow::bail!("moving a stack requires every commit to have exactly one parent"); + }; + stack_parent.insert(id, *parent); + if id == base { + break; + } + stack.push(*parent); + } + stack.reverse(); + let stack_set: HashSet<_> = stack.iter().copied().collect(); + if stack_set.contains(&target) { + anyhow::bail!("the move target must not be part of the moved stack"); + } + let base_parent = stack_parent[&base]; + if base_parent == target { + anyhow::bail!("the stack is already directly above the move target"); + } + + let target_rewritten = graph.is_ancestor(base, target); + let mut scope = Vec::new(); + let mut scope_set = HashSet::new(); + for id in graph + .descendants_in_parent_order(base) + .context("the stack base is not in the loaded history")? + .into_iter() + .chain( + graph + .descendants_in_parent_order(target) + .context("the move target is not in the loaded history")?, + ) + { + if (id != target || target_rewritten) && scope_set.insert(id) { + scope.push(id); + } + } + + let mut new_parent = HashMap::with_capacity(scope.len()); + for id in &scope { + let parents = graph.parents_of(*id).context("an affected move commit is incomplete")?; + let [parent] = parents.as_slice() else { + anyhow::bail!("moving a stack cannot rewrite root or merge commits"); + }; + new_parent.insert( + *id, + if *id == base { + target + } else if stack_set.contains(id) { + *parent + } else if let Some(old_parent) = stack_parent.get(parent) { + *old_parent + } else if *parent == target { + head + } else { + *parent + }, + ); + } + + let mut steps = Vec::with_capacity(scope.len()); + let mut step_by_id = HashMap::with_capacity(scope.len()); + while steps.len() < scope.len() { + let before = steps.len(); + for id in &scope { + if step_by_id.contains_key(id) { + continue; + } + let parent = new_parent[id]; + if scope_set.contains(&parent) && !step_by_id.contains_key(&parent) { + continue; + } + let parent = step_by_id + .get(&parent) + .copied() + .map_or(PlanParent::Existing(parent), PlanParent::Step); + step_by_id.insert(*id, steps.len()); + steps.push(PlanStep { + parent, + commit: PlanCommit::Pick(*id), + squash: Vec::new(), + }); + } + if steps.len() == before { + anyhow::bail!("moving the stack would create a commit cycle"); + } + } + + let head_step = PlanParent::Step(step_by_id[&head]); + let mut ref_scope = scope.clone(); + if !scope_set.contains(&target) { + ref_scope.push(target); + } + let ref_scope_set: HashSet<_> = ref_scope.iter().copied().collect(); + let mut non_leaves = HashSet::new(); + for id in &ref_scope { + non_leaves.extend( + graph + .parents_of(*id) + .context("an affected move commit is incomplete")? + .into_iter() + .filter(|parent| ref_scope_set.contains(parent)), + ); + } + let tips: Vec<_> = ref_scope + .iter() + .copied() + .filter(|id| !non_leaves.contains(id)) + .collect(); + let mut expected_refs = capture_refs(repo, &ref_scope, &tips)?; + for expected in &mut expected_refs { + if stack_set.contains(&expected.target) { + expected.placement = Some(PlanParent::Step(step_by_id[&expected.target])); + } + } + let head = repo.head()?; + let reference = head + .referent_name() + .filter(|name| name.category() == Some(Category::LocalBranch)) + .map(ToOwned::to_owned); + + Ok(Plan { + base: base_parent, + scope, + steps, + checkout: Some(PlanCheckout { + target: head_step, + reference, + }), + expected_refs, + }) +} + +#[tracing::instrument(skip_all, fields(signature = ?signature, tree = ?tree_mode))] +pub(crate) fn perform( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + None, + false, + None, + |_, _| {}, + ) + .map(|(perform, _)| perform) +} + +pub(crate) fn perform_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + mut report: impl FnMut(Progress), +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + None, + false, + None, + |_, progress| report(progress), + ) + .map(|(perform, _)| perform) +} + +#[cfg(test)] +pub(crate) fn perform_with_enrichment( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + headers: &crate::enrich::Headers, +) -> Result<(Perform, Option<crate::enrich::Enrichment>)> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + None, + false, + Some(headers), + |_, _| {}, + ) +} + +pub(crate) fn perform_with_enrichment_and_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + headers: &crate::enrich::Headers, + mut report: impl FnMut(Progress), +) -> Result<(Perform, Option<crate::enrich::Enrichment>)> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + None, + false, + Some(headers), + |_, progress| report(progress), + ) +} + +pub(super) fn perform_allowing_pending_checkout_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + mut report: impl FnMut(Progress), +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + None, + true, + None, + |_, progress| report(progress), + ) + .map(|(perform, _)| perform) +} + +pub(crate) fn perform_reporting_rebased( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + mut report: impl FnMut(ObjectId), +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + None, + false, + None, + |id, _| { + if let Some(id) = id { + report(id); + } + }, + ) + .map(|(perform, _)| perform) +} + +pub(super) fn perform_resetting_index_paths_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + paths: Vec<BString>, + mut report: impl FnMut(Progress), +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + Some(paths), + false, + None, + |_, progress| report(progress), + ) + .map(|(perform, _)| perform) +} + +pub(super) fn perform_resetting_index_paths_allowing_pending_checkout_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + paths: Vec<BString>, + mut report: impl FnMut(Progress), +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + Vec::new(), + Some(paths), + true, + None, + |_, progress| report(progress), + ) + .map(|(perform, _)| perform) +} + +pub(super) fn perform_deleting_refs_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + deletions: Vec<(gix::refs::FullName, Target)>, + mut report: impl FnMut(Progress), +) -> Result<Perform> { + perform_inner( + repo, + graph, + edit, + signature, + tree_mode, + deletions, + None, + false, + None, + |_, progress| report(progress), + ) + .map(|(perform, _)| perform) +} + +#[expect( + clippy::too_many_arguments, + reason = "shared edit preparation plus rebased-commit reporting" +)] +fn perform_inner( + repo: &gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + tree_mode: Tree, + delete_refs: Vec<(gix::refs::FullName, Target)>, + reset_index_paths: Option<Vec<BString>>, + allow_pending_checkout: bool, + enrichment_headers: Option<&crate::enrich::Headers>, + mut report: impl FnMut(Option<ObjectId>, Progress), +) -> Result<(Perform, Option<crate::enrich::Enrichment>)> { + let mut repo = repo.clone(); + let repeat_checkout = match &edit { + Edit::Repeat { checkout, .. } => Some(*checkout), + _ => None, + }; + let (root, replacement, inserted, reset_index, forked, removed, repeat, mut split_upper) = match edit { + Edit::Replace { target, commit } => (Some(target), Some(commit), false, false, false, false, false, None), + Edit::Insert { + anchor, + commit, + reset_index, + } => (anchor, Some(commit), true, reset_index, false, false, false, None), + Edit::Fork { anchor, commit } => (Some(anchor), Some(commit), false, false, true, false, false, None), + Edit::Remove { target } => (Some(target), None, false, false, false, true, false, None), + Edit::Split { target, source, upper } => ( + Some(target), + Some(source), + false, + false, + false, + false, + false, + Some(upper), + ), + Edit::Repeat { base, .. } => (Some(base), None, false, false, false, false, true, None), + }; + + let affected = match root.filter(|_| !forked) { + Some(root) => graph + .descendants_in_parent_order(root) + .context("the edited commit is not in the loaded history")?, + None => Vec::new(), + }; + let mut progress = Progress { + total: affected.len() + + usize::from(split_upper.is_some()) + + usize::from((inserted || forked) && affected.is_empty()), + ..Progress::default() + }; + report(None, progress); + let checkout = if repo.workdir().is_some() { + repo.head()?.id().map(gix::Id::detach) + } else { + None + }; + let checkout_path: HashSet<_> = checkout + .into_iter() + .flat_map(|checkout| { + affected + .iter() + .copied() + .filter(move |id| graph.is_ancestor(*id, checkout)) + }) + .collect(); + if !repeat && !checkout_path.is_empty() { + let checkout = checkout.expect("a non-empty checkout path has a checkout"); + let scan_from = if allow_pending_checkout && root == Some(checkout) { + repo.find_commit(checkout)? + .decode()? + .into_owned()? + .parents + .first() + .copied() + } else { + Some(checkout) + }; + if let Some(id) = scan_from { + reject_pending_checkout_path(&repo, id)?; + } + } + validate(&repo, graph, &affected, removed, repeat, tree_mode)?; + + let signing = repo + .commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned() + .context("could not own the Git committer")?; + repo = repo.with_object_memory(); + + let mut rewritten = HashMap::<ObjectId, Option<ObjectId>>::new(); + let mut note_rewrites = Vec::new(); + let mut selected = None; + let mut conflict = None; + let mut eager_checkout_rewrite = false; + let mut finalized_empty = HashSet::new(); + if inserted || forked { + let mut commit = replacement.clone().context("an inserted commit is required")?; + commit.parents = root.into_iter().collect(); + let (id, signing_time) = write_commit_timed( + &repo, + commit, + None, + &committer, + CommitState::Unmarked(signature), + signing.clone(), + )?; + if let Some(elapsed) = signing_time { + progress.signed += 1; + progress.signing_time += elapsed; + } + progress.processed += 1; + report(None, progress); + selected = Some(id); + if inserted { + if let Some(root) = root { + rewritten.insert(root, Some(id)); + } else { + rewritten.insert(id, Some(id)); + } + } + } else if removed { + let root = root.context("a removed commit is required")?; + let parent = graph + .parents_of(root) + .context("the removed commit is not in the loaded history")? + .first() + .copied(); + rewritten.insert(root, parent); + selected = parent; + progress.processed += 1; + report(None, progress); + } + + let mut pending = affected; + if inserted || removed { + pending.retain(|id| Some(*id) != root); + } + for old_id in pending { + let old_parents = graph.parents_of(old_id).context("an affected commit is incomplete")?; + let mut commit = if Some(old_id) == root { + match replacement.clone() { + Some(commit) => commit, + None => repo + .find_commit(old_id) + .context("could not find commit to rewrite")? + .decode() + .context("could not decode commit to rewrite")? + .into_owned() + .context("could not own commit to rewrite")?, + } + } else { + repo.find_commit(old_id) + .context("could not find descendant commit")? + .decode() + .context("could not decode descendant commit")? + .into_owned() + .context("could not own descendant commit")? + }; + let new_parents: Vec<_> = old_parents + .iter() + .filter_map(|parent| rewritten.get(parent).copied().unwrap_or(Some(*parent))) + .collect(); + if Some(old_id) != root && old_parents == new_parents && !is_pending(&commit) { + progress.processed += 1; + report(None, progress); + continue; + } + let recorded_parent = has_marker(&commit).then(|| marked_parent(&commit)).transpose()?; + let original_parents = + recorded_parent.map_or_else(|| old_parents.clone(), |parent| parent.into_iter().collect::<Vec<_>>()); + let eager = conflict.is_none() + && if repeat { + repeat_checkout.is_some_and(|checkout| graph.is_ancestor(old_id, checkout)) + } else { + Some(old_id) != root && checkout_path.contains(&old_id) + }; + eager_checkout_rewrite |= !repeat && eager; + let mut commit_tree_mode = if eager { + Tree::CherryPick + } else if repeat || conflict.is_some() { + Tree::LeaveAsIsAndMark + } else { + tree_mode + }; + let finalize_empty = conflict.is_none() + && matches!( + commit_tree_mode, + Tree::LeaveAsIsAndMark | Tree::LeaveAsIsAndMarkDescendants + ) + && empty_commit_has_final_parent( + &repo, + commit.tree, + original_parents.first().copied(), + new_parents.first().copied(), + )?; + if finalize_empty { + commit_tree_mode = Tree::CherryPick; + finalized_empty.insert(old_id); + } + let cherry_pick_started = eager.then(Instant::now); + let rewritten_tree = rewritten_tree( + &repo, + &commit, + if commit_tree_mode == Tree::CherryPick { + &original_parents + } else { + &old_parents + }, + &new_parents, + commit_tree_mode, + )?; + let mut new_conflict = None; + commit.tree = match rewritten_tree { + TreeRewrite::Complete(tree) => tree, + TreeRewrite::Conflict { + ours, + merged, + conflicts, + } => { + new_conflict = Some((merged, conflicts)); + ours + } + }; + if let Some(started) = cherry_pick_started.filter(|_| new_conflict.is_none()) { + progress.cherry_picked += 1; + progress.cherry_pick_time += started.elapsed(); + } + commit.parents = new_parents.into_iter().collect(); + let pending = commit_tree_mode == Tree::LeaveAsIsAndMark + || (commit_tree_mode == Tree::LeaveAsIsAndMarkDescendants && Some(old_id) != root) + || conflict.is_some() + || new_conflict.is_some(); + let is_conflicting_commit = new_conflict.is_some(); + let signature = if conflict.is_some() || is_conflicting_commit || (repeat && !eager) { + Signature::InvalidateExisting + } else if eager || finalize_empty { + Signature::RedoIfNeeded + } else { + signature + }; + let state = if pending && (repeat || Some(old_id) != root) { + CommitState::Pending { + original_parent: recorded_parent.flatten().or_else(|| old_parents.first().copied()), + } + } else { + CommitState::Unmarked(signature) + }; + let (new_id, signing_time) = + write_commit_timed(&repo, commit, Some(old_id), &committer, state, signing.clone())?; + if let Some(elapsed) = signing_time { + progress.signed += 1; + progress.signing_time += elapsed; + } + progress.processed += 1; + report(Some(old_id), progress); + if new_id != old_id { + note_rewrites.push((old_id, new_id)); + } + rewritten.insert(old_id, Some(new_id)); + if let Some((tree, conflicts)) = new_conflict { + conflict = Some((old_id, tree, conflicts, new_id)); + } + if Some(old_id) == root { + if let Some(mut upper) = split_upper.take() { + upper.parents = [new_id].into_iter().collect(); + let (upper_id, signing_time) = write_commit_timed( + &repo, + upper, + None, + &committer, + CommitState::Unmarked(Signature::RedoIfNeeded), + signing.clone(), + )?; + if let Some(elapsed) = signing_time { + progress.signed += 1; + progress.signing_time += elapsed; + } + progress.processed += 1; + report(None, progress); + rewritten.insert(old_id, Some(upper_id)); + selected = Some(upper_id); + } else { + selected = Some(new_id); + } + } + } + + let marked = (!forked && matches!(tree_mode, Tree::LeaveAsIsAndMark | Tree::LeaveAsIsAndMarkDescendants)) + || conflict.is_some(); + let checkout_after_finish = conflict.is_some(); + let skip_worktree_transitions = !eager_checkout_rewrite + && (inserted + || forked + || (matches!(tree_mode, Tree::LeaveAsIsAndMark | Tree::LeaveAsIsAndMarkDescendants) && !removed)); + let mut reset_indices: HashSet<_> = ((inserted && reset_index) || (!inserted && marked)) + .then_some(root) + .flatten() + .into_iter() + .collect(); + if skip_worktree_transitions { + reset_indices.extend(finalized_empty); + } + let mut prepared = Prepared { + repo, + reset_indices, + reset_index_paths, + skip_worktree_transitions, + selected, + note_rewrites, + stash_rewritten: rewritten.clone(), + rewritten, + removed: if removed { + root.into_iter().collect() + } else { + HashSet::new() + }, + committer, + expected_refs: None, + checkout_reference: None, + checkout_after_finish, + pins: if forked { + selected.into_iter().collect() + } else { + Vec::new() + }, + delete_refs, + enrichment: None, + }; + let enrichment = prepare_enrichment(&mut prepared, enrichment_headers)?; + let perform = match conflict { + Some((original, merged_tree, conflicts, commit)) => Perform::Conflict(Conflict { + prepared, + conflicts, + merged_tree, + commit, + original, + }), + None => Perform::Complete(prepared.finish()?), + }; + Ok((perform, enrichment)) +} + +#[tracing::instrument(skip_all, fields(%review, %tip))] +#[expect(clippy::too_many_arguments, reason = "review preparation plus progress reporting")] +pub(super) fn finish_review_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + review: ObjectId, + tip: ObjectId, + review_ref: gix::refs::FullName, + delete_refs: Vec<(gix::refs::FullName, Target)>, + checkout: Option<(ObjectId, Option<gix::refs::FullName>)>, + mut report: impl FnMut(Progress), +) -> Result<Perform> { + let mut repo = repo.clone(); + let signing = repo + .commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned()?; + repo = repo.with_object_memory(); + + let review_ids = graph + .descendants_in_parent_order(review) + .context("the review commit is not in the loaded history")?; + let review_set: HashSet<_> = review_ids.iter().copied().collect(); + let natural_ids: Vec<_> = graph + .descendants_in_parent_order(tip) + .context("the reviewed commit is not in the loaded history")? + .into_iter() + .filter(|id| *id != tip && !review_set.contains(id)) + .collect(); + let mut progress = Progress { + total: review_ids.len() + natural_ids.len(), + ..Progress::default() + }; + report(progress); + let checkout_path: HashSet<_> = if repo.workdir().is_some() { + checkout + .as_ref() + .map(|(checkout, _)| { + natural_ids + .iter() + .copied() + .filter(|id| graph.is_ancestor(*id, *checkout)) + .collect() + }) + .unwrap_or_default() + } else { + HashSet::new() + }; + if !checkout_path.is_empty() { + reject_pending_checkout_path( + &repo, + checkout.as_ref().expect("a non-empty checkout path has a checkout").0, + )?; + } + for id in review_ids.iter().chain(&natural_ids) { + if graph + .parents_of(*id) + .context("a review descendant is incomplete")? + .len() + > 1 + { + anyhow::bail!("review finish cannot rewrite merge descendants"); + } + } + + let mut rewritten = HashMap::<ObjectId, Option<ObjectId>>::new(); + let mut note_rewrites = Vec::new(); + let mut finished_review = None; + let mut conflict = None; + for old in &review_ids { + let old_parents = graph.parents_of(*old).context("a review descendant is incomplete")?; + let mut commit = repo.find_commit(*old)?.decode()?.into_owned()?; + let new_parents = if *old == review { + vec![tip] + } else { + old_parents + .iter() + .filter_map(|parent| rewritten.get(parent).copied().unwrap_or(Some(*parent))) + .collect() + }; + commit.parents = new_parents.into_iter().collect(); + if *old == review { + commit.extra_headers.retain(|(name, value)| { + !(name.as_slice() == MARKER + && value.as_slice().strip_prefix(b"onto ") == Some(review_ref.as_bstr().as_ref())) + && name.as_slice() != super::review::RETURN_TO + }); + } + let (new, signing_time) = write_commit_timed( + &repo, + commit, + Some(*old), + &committer, + CommitState::Unmarked(Signature::RedoIfNeeded), + signing.clone(), + )?; + if let Some(elapsed) = signing_time { + progress.signed += 1; + progress.signing_time += elapsed; + } + progress.processed += 1; + report(progress); + if new != *old { + note_rewrites.push((*old, new)); + } + rewritten.insert(*old, Some(new)); + if *old == review { + finished_review = Some(new); + } + } + let finished_review = finished_review.context("the review commit was not rewritten")?; + let non_leaves: HashSet<_> = review_ids + .iter() + .flat_map(|id| graph.parents_of(*id).unwrap_or_default()) + .filter(|parent| review_set.contains(parent)) + .collect(); + let leaves: Vec<_> = review_ids + .iter() + .filter(|id| !non_leaves.contains(*id)) + .copied() + .collect(); + let insertion = if leaves.len() == 1 { + rewritten[&leaves[0]].context("the review leaf disappeared")? + } else { + finished_review + }; + + rewritten.insert(tip, Some(finished_review)); + for old in natural_ids { + let old_parents = graph.parents_of(old).context("a reviewed descendant is incomplete")?; + let mut commit = repo.find_commit(old)?.decode()?.into_owned()?; + let new_parents: Vec<_> = old_parents + .iter() + .filter_map(|parent| { + if *parent == tip { + Some(insertion) + } else { + rewritten.get(parent).copied().unwrap_or(Some(*parent)) + } + }) + .collect(); + let recorded_parent = has_marker(&commit).then(|| marked_parent(&commit)).transpose()?; + let original_parents = + recorded_parent.map_or_else(|| old_parents.clone(), |parent| parent.into_iter().collect::<Vec<_>>()); + let eager = conflict.is_none() && checkout_path.contains(&old); + let mut mode = if eager { + Tree::CherryPick + } else { + Tree::LeaveAsIsAndMark + }; + let finalize_empty = conflict.is_none() + && empty_commit_has_final_parent( + &repo, + commit.tree, + original_parents.first().copied(), + new_parents.first().copied(), + )?; + if finalize_empty { + mode = Tree::CherryPick; + } + let cherry_pick_started = eager.then(Instant::now); + let mut new_conflict = None; + commit.tree = match rewritten_tree(&repo, &commit, &original_parents, &new_parents, mode)? { + TreeRewrite::Complete(tree) => tree, + TreeRewrite::Conflict { + ours, + merged, + conflicts, + } => { + new_conflict = Some((merged, conflicts)); + ours + } + }; + if let Some(started) = cherry_pick_started.filter(|_| new_conflict.is_none()) { + progress.cherry_picked += 1; + progress.cherry_pick_time += started.elapsed(); + } + commit.parents = new_parents.into_iter().collect(); + let pending = !(eager || finalize_empty) || conflict.is_some() || new_conflict.is_some(); + let (new, signing_time) = write_commit_timed( + &repo, + commit, + Some(old), + &committer, + if pending { + CommitState::Pending { + original_parent: recorded_parent.flatten().or_else(|| old_parents.first().copied()), + } + } else { + CommitState::Unmarked(Signature::RedoIfNeeded) + }, + signing.clone(), + )?; + if let Some(elapsed) = signing_time { + progress.signed += 1; + progress.signing_time += elapsed; + } + progress.processed += 1; + report(progress); + if new != old { + note_rewrites.push((old, new)); + } + rewritten.insert(old, Some(new)); + if let Some((tree, conflicts)) = new_conflict { + conflict = Some((old, tree, conflicts, new)); + } + } + + let (selected, checkout_reference) = checkout.map_or((finished_review, None), |(old, reference)| { + (rewritten.get(&old).copied().flatten().unwrap_or(old), reference) + }); + let mut prepared = Prepared { + repo, + reset_indices: HashSet::new(), + reset_index_paths: None, + skip_worktree_transitions: false, + selected: Some(selected), + note_rewrites, + stash_rewritten: rewritten.clone(), + rewritten, + removed: HashSet::new(), + committer, + expected_refs: None, + checkout_reference, + checkout_after_finish: conflict.is_some(), + pins: Vec::new(), + delete_refs, + enrichment: None, + }; + match conflict { + Some((original, merged_tree, conflicts, commit)) => Ok(Perform::Conflict(Conflict { + prepared, + conflicts, + merged_tree, + commit, + original, + })), + None => Ok(Perform::Complete(prepared.finish()?)), + } +} + +pub(crate) fn perform_plan(repo: &gix::Repository, graph: &HistoryGraph, plan: Plan) -> Result<PlanPerform> { + perform_plan_with_progress(repo, graph, plan, |_| {}) +} + +#[tracing::instrument(skip_all, fields(base = %plan.base, steps = plan.steps.len()))] +pub(crate) fn perform_plan_with_progress( + repo: &gix::Repository, + graph: &HistoryGraph, + mut plan: Plan, + mut report: impl FnMut(Progress), +) -> Result<PlanPerform> { + let mut progress = Progress::for_plan(&plan); + report(progress); + let mut repo = repo.clone(); + let signing = repo + .commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + let author = repo + .author() + .context("no Git author is configured")? + .context("could not resolve the Git author")? + .to_owned() + .context("could not own the Git author")?; + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned() + .context("could not own the Git committer")?; + repo = repo.with_object_memory(); + + let scope: HashSet<_> = plan.scope.iter().copied().collect(); + let mut picked = HashSet::new(); + for step in &plan.steps { + if let PlanCommit::Copy(id) = step.commit + && graph.parents_of(id).context("a copied commit is incomplete")?.len() != 1 + { + anyhow::bail!("copying a commit requires it to have exactly one parent"); + } + let ids = match step.commit { + PlanCommit::Pick(id) | PlanCommit::Resolved(id) => Some(id).into_iter().chain(step.squash.iter().copied()), + PlanCommit::Copy(_) | PlanCommit::Empty(_) => None.into_iter().chain(step.squash.iter().copied()), + }; + for id in ids { + if !scope.contains(&id) || !picked.insert(id) { + anyhow::bail!("a rebase plan contains an invalid or duplicate pick"); + } + if graph.parents_of(id).context("a picked commit is incomplete")?.len() > 1 { + anyhow::bail!("merge commits cannot be picked by the rebase editor"); + } + } + } + + let checkout_target = if repo.workdir().is_some() { + plan.checkout + .as_ref() + .map(|checkout| checkout.target) + .or(infer_plan_checkout(&repo, graph, &plan)?) + } else { + None + }; + if plan.checkout.is_none() + && checkout_target.is_some() + && !plan + .steps + .iter() + .any(|step| matches!(step.commit, PlanCommit::Resolved(_))) + && let Some(head) = repo.head()?.id().map(gix::Id::detach) + && plan.scope.contains(&head) + { + reject_pending_checkout_path(&repo, head)?; + } + let mut eager = HashSet::new(); + let mut cursor = checkout_target; + while let Some(PlanParent::Step(index)) = cursor { + if !eager.insert(index) { + anyhow::bail!("the checkout ancestry contains a cycle"); + } + cursor = match plan.steps.get(index).context("the checkout step is missing")?.parent { + parent @ PlanParent::Step(_) => Some(parent), + PlanParent::Existing(_) => None, + }; + } + + let mut rewritten = HashMap::<ObjectId, Option<ObjectId>>::new(); + let mut note_rewrites = Vec::new(); + let mut produced = Vec::with_capacity(plan.steps.len()); + let mut delete_refs = Vec::new(); + let mut conflict = None; + let mut marked = false; + for (index, step) in plan.steps.iter().enumerate() { + let mut resolved_head = None; + let parent = match step.parent { + PlanParent::Existing(id) => { + repo.find_commit(id).context("could not find a fork target")?; + id + } + PlanParent::Step(parent) => *produced.get(parent).context("a fork points to a later commit")?, + }; + let eager = conflict.is_none() + && (matches!(step.commit, PlanCommit::Copy(_)) || eager.contains(&index) || !step.squash.is_empty()); + let mut commit = match &step.commit { + PlanCommit::Pick(id) | PlanCommit::Copy(id) => repo + .find_commit(*id) + .context("could not find a picked commit")? + .decode() + .context("could not decode a picked commit")? + .into_owned() + .context("could not own a picked commit")?, + PlanCommit::Resolved(planned) => { + let head = repo + .head()? + .peel_to_commit() + .context("could not resolve the conflicted HEAD commit")?; + resolved_head = Some((*planned, head.id)); + let mut commit = head + .decode() + .context("could not decode the conflicted HEAD commit")? + .into_owned() + .context("could not own the conflicted HEAD commit")?; + let index = repo + .index_or_empty() + .context("could not load the resolved conflict index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("the conflict index still has unresolved entries"); + } + commit.tree = super::create::index_tree(&repo, &index)?; + commit + } + PlanCommit::Empty(title) => gix::objs::Commit { + tree: parent_tree(&repo, Some(parent))?, + parents: [parent].into_iter().collect(), + author: author.clone(), + committer: committer.clone(), + encoding: None, + message: title.clone(), + extra_headers: Vec::new(), + }, + }; + let graph_parents = match step.commit { + PlanCommit::Pick(id) | PlanCommit::Copy(id) | PlanCommit::Resolved(id) => { + graph.parents_of(id).context("a picked commit is incomplete")? + } + PlanCommit::Empty(_) => vec![parent], + }; + let recorded_parent = has_marker(&commit).then(|| marked_parent(&commit)).transpose()?; + if let PlanCommit::Pick(id) = step.commit + && step.squash.is_empty() + && !is_pending(&commit) + && graph_parents.as_slice() == [parent] + { + rewritten.insert(id, Some(id)); + produced.push(id); + progress.processed += 1; + report(progress); + continue; + } + let replay_parents = recorded_parent.map_or_else( + || graph_parents.clone(), + |parent| parent.into_iter().collect::<Vec<_>>(), + ); + let finalize_empty = conflict.is_none() + && step.squash.is_empty() + && empty_commit_has_final_parent(&repo, commit.tree, replay_parents.first().copied(), Some(parent))?; + let mode = if eager || finalize_empty { + Tree::CherryPick + } else { + Tree::LeaveAsIsAndMark + }; + let cherry_pick_started = (eager + && matches!( + step.commit, + PlanCommit::Pick(_) | PlanCommit::Copy(_) | PlanCommit::Resolved(_) + )) + .then(Instant::now); + let mut step_conflict = None; + commit.tree = match rewritten_tree(&repo, &commit, &replay_parents, &[parent], mode)? { + TreeRewrite::Complete(tree) => tree, + TreeRewrite::Conflict { + ours, + merged, + conflicts, + } => { + let (PlanCommit::Pick(original) | PlanCommit::Copy(original) | PlanCommit::Resolved(original)) = + step.commit + else { + unreachable!("empty commits cannot conflict") + }; + step_conflict = Some((original, merged, conflicts, None)); + ours + } + }; + if let Some(started) = cherry_pick_started.filter(|_| step_conflict.is_none()) { + progress.cherry_picked += 1; + progress.cherry_pick_time += started.elapsed(); + } + if matches!( + step.commit, + PlanCommit::Pick(_) | PlanCommit::Copy(_) | PlanCommit::Resolved(_) + ) { + progress.processed += 1; + report(progress); + } + let mut squashed = Vec::with_capacity(step.squash.len()); + for id in &step.squash { + let source = repo + .find_commit(*id) + .context("could not find a squashed commit")? + .decode() + .context("could not decode a squashed commit")? + .into_owned() + .context("could not own a squashed commit")?; + let graph_parents = graph.parents_of(*id).context("a squashed commit is incomplete")?; + let recorded_parent = has_marker(&source).then(|| marked_parent(&source)).transpose()?; + let replay_parents = recorded_parent.map_or_else( + || graph_parents.clone(), + |parent| parent.into_iter().collect::<Vec<_>>(), + ); + squashed.push((*id, source, replay_parents)); + } + let mut applied_squash = 0; + if !squashed.is_empty() && conflict.is_none() && step_conflict.is_none() { + for (squash_index, (id, source, replay_parents)) in squashed.iter().enumerate() { + let old_base = parent_tree(&repo, replay_parents.first().copied())?; + let started = Instant::now(); + commit.tree = match cherry_pick_tree_outcome(&repo, old_base, commit.tree, source.tree)? { + TreeRewrite::Complete(tree) => tree, + TreeRewrite::Conflict { + ours, + merged, + conflicts, + } => { + step_conflict = Some((*id, merged, conflicts, Some(squash_index))); + ours + } + }; + applied_squash += 1; + if step_conflict.is_some() { + break; + } + progress.cherry_picked += 1; + progress.cherry_pick_time += started.elapsed(); + progress.processed += 1; + report(progress); + delete_refs.extend(super::review::deletions(&repo, source)?); + } + squash_message(&repo, &mut commit, &squashed[..applied_squash])?; + } + commit.parents = [parent].into_iter().collect(); + let state = if step_conflict.is_some() { + CommitState::Unmarked(Signature::InvalidateExisting) + } else if eager || finalize_empty { + CommitState::Unmarked(Signature::RedoIfNeeded) + } else { + marked = true; + CommitState::Pending { + original_parent: recorded_parent.flatten().or_else(|| graph_parents.first().copied()), + } + }; + let predecessor = match step.commit { + PlanCommit::Pick(id) | PlanCommit::Copy(id) | PlanCommit::Resolved(id) => Some(id), + PlanCommit::Empty(_) => None, + }; + let (new_id, signing_time) = + write_commit_timed(&repo, commit, predecessor, &committer, state, signing.clone())?; + if let Some(elapsed) = signing_time { + progress.signed += 1; + progress.signing_time += elapsed; + } + if matches!(step.commit, PlanCommit::Empty(_)) { + progress.processed += 1; + } + report(progress); + if let PlanCommit::Pick(old_id) | PlanCommit::Resolved(old_id) = step.commit { + rewritten.insert(old_id, Some(new_id)); + if old_id != new_id { + note_rewrites.push((old_id, new_id)); + } + } else if let PlanCommit::Copy(old_id) = step.commit + && old_id != new_id + { + note_rewrites.push((old_id, new_id)); + } + if let Some((planned, actual)) = resolved_head + && actual != planned + { + rewritten.insert(actual, Some(new_id)); + if actual != new_id { + note_rewrites.push((actual, new_id)); + } + } + for old_id in step.squash.iter().take(applied_squash) { + rewritten.insert(*old_id, Some(new_id)); + if *old_id != new_id { + note_rewrites.push((*old_id, new_id)); + } + } + for old_id in step.squash.iter().skip(applied_squash) { + rewritten.insert(*old_id, Some(new_id)); + } + produced.push(new_id); + if let Some((original, tree, conflicts, squash_index)) = step_conflict { + let remaining_squash = squash_index.map_or_else( + || step.squash.clone(), + |squash_index| step.squash[squash_index + 1..].to_vec(), + ); + conflict = Some((original, tree, conflicts, new_id, index, remaining_squash)); + } + } + + let dropped: Vec<_> = plan.scope.iter().copied().filter(|id| !picked.contains(id)).collect(); + let removed: HashSet<_> = dropped.iter().copied().collect(); + for id in &dropped { + let commit = repo.find_commit(*id)?.decode()?.into_owned()?; + delete_refs.extend(super::review::deletions(&repo, &commit)?); + } + for dropped in dropped { + let mut ancestor = graph + .parents_of(dropped) + .context("a dropped commit is incomplete")? + .first() + .copied(); + while let Some(id) = ancestor { + if let Some(new) = rewritten.get(&id).copied().flatten() { + ancestor = Some(new); + break; + } + if !scope.contains(&id) { + break; + } + ancestor = graph + .parents_of(id) + .context("a dropped ancestor is incomplete")? + .first() + .copied(); + } + rewritten.insert(dropped, ancestor.or(Some(plan.base))); + } + + let mut primary_children = HashMap::new(); + for (index, step) in plan.steps.iter().enumerate() { + let parent = match step.parent { + PlanParent::Existing(id) => id, + PlanParent::Step(parent) => produced[parent], + }; + primary_children.entry(parent).or_insert(produced[index]); + } + for expected in &mut plan.expected_refs { + if let Some(target) = expected.placement { + expected.new = Some(match target { + PlanParent::Existing(id) => id, + PlanParent::Step(index) => *produced.get(index).context("a reference points to a missing step")?, + }); + } else if expected.new.is_some() { + let mut target = rewritten + .get(&expected.target) + .copied() + .flatten() + .unwrap_or(expected.target); + if expected.follows_tip { + while let Some(child) = primary_children.get(&target) { + target = *child; + } + } + expected.new = Some(target); + } + } + + let planned_non_leaves: HashSet<_> = plan + .steps + .iter() + .filter_map(|step| match step.parent { + PlanParent::Step(index) => Some(index), + PlanParent::Existing(_) => None, + }) + .collect(); + let mut pins = Vec::new(); + for (index, id) in produced.iter().copied().enumerate() { + if planned_non_leaves.contains(&index) + || plan + .checkout + .as_ref() + .is_some_and(|checkout| checkout.target == PlanParent::Step(index)) + { + continue; + } + let referenced = plan.expected_refs.iter().any(|expected| expected.new == Some(id)); + if !referenced { + pins.push(id); + } + } + let selected = plan.checkout.as_ref().map(|checkout| match checkout.target { + PlanParent::Existing(id) => id, + PlanParent::Step(index) => produced[index], + }); + let mut prepared = Prepared { + repo, + reset_indices: marked.then_some(plan.base).into_iter().collect(), + reset_index_paths: None, + skip_worktree_transitions: false, + selected, + note_rewrites, + rewritten: rewritten.clone(), + stash_rewritten: rewritten.clone(), + removed, + committer, + expected_refs: Some(plan.expected_refs.clone()), + checkout_reference: plan.checkout.as_ref().and_then(|checkout| checkout.reference.clone()), + checkout_after_finish: false, + pins, + delete_refs, + enrichment: None, + }; + tracing::info!( + total = progress.total, + processed = progress.processed, + cherry_picked = progress.cherry_picked, + signed = progress.signed, + cherry_pick_ms = progress.cherry_pick_time.as_millis(), + signing_ms = progress.signing_time.as_millis(), + "prepared rebase plan" + ); + let Some((original, merged_tree, conflicts, commit, conflict_step, conflict_remaining_squash)) = conflict else { + return Ok(PlanPerform::Complete(prepared.finish()?)); + }; + + let continuation_start = checkout_target + .and_then(|checkout| match checkout { + PlanParent::Step(index) if index < conflict_step => Some(index), + _ => None, + }) + .map_or(conflict_step, |_| 0); + let affected_steps: HashSet<_> = (continuation_start..plan.steps.len()).collect(); + let affected_ids: HashSet<_> = affected_steps.iter().map(|index| produced[*index]).collect(); + let final_refs: Vec<_> = plan + .expected_refs + .iter() + .filter(|expected| expected.new.is_none_or(|new| !affected_ids.contains(&new))) + .cloned() + .collect(); + let final_ref_names = final_refs.iter().map(|expected| expected.name.clone()).collect(); + let mut remaining_squash = vec![Vec::new(); plan.steps.len()]; + remaining_squash[conflict_step] = conflict_remaining_squash; + for (index, step) in plan.steps.iter().enumerate().skip(conflict_step + 1) { + remaining_squash[index].clone_from(&step.squash); + } + prepared.selected = None; + prepared.checkout_reference = None; + prepared.checkout_after_finish = true; + prepared.reset_indices.clear(); + prepared.rewritten = final_refs + .iter() + .filter_map(|expected| expected.old.map(|old| (old, expected.new))) + .collect(); + prepared.expected_refs = Some(final_refs); + prepared.pins.retain(|id| !affected_ids.contains(id)); + Ok(PlanPerform::Conflict(PlanConflict { + conflict: Conflict { + prepared, + conflicts, + merged_tree, + commit, + original, + }, + plan, + produced, + rewritten, + conflict_step, + continuation_start, + remaining_squash, + final_refs: final_ref_names, + })) +} + +fn infer_plan_checkout(repo: &gix::Repository, graph: &HistoryGraph, plan: &Plan) -> Result<Option<PlanParent>> { + let head = repo.head()?; + let Some(name) = head.referent_name() else { + return Ok(None); + }; + let Some(expected) = plan + .expected_refs + .iter() + .find(|expected| expected.name.as_bstr() == name.as_bstr()) + else { + return Ok(None); + }; + if let Some(target) = expected.placement { + return Ok(Some(target)); + } + let scope: HashSet<_> = plan.scope.iter().copied().collect(); + let mut source = expected.target; + let mut target = loop { + if let Some(index) = plan.steps.iter().position(|step| { + matches!( + step.commit, + PlanCommit::Pick(candidate) | PlanCommit::Resolved(candidate) if candidate == source + ) || step.squash.contains(&source) + }) { + break Some(PlanParent::Step(index)); + } + if !scope.contains(&source) { + break Some(PlanParent::Existing(source)); + } + let Some(parent) = graph + .parents_of(source) + .context("a dropped checkout commit is incomplete")? + .first() + .copied() + else { + break Some(PlanParent::Existing(plan.base)); + }; + source = parent; + }; + if expected.follows_tip { + while let Some(parent) = target { + let Some(index) = plan.steps.iter().position(|step| step.parent == parent) else { + break; + }; + target = Some(PlanParent::Step(index)); + } + } + Ok(target.filter(|target| matches!(target, PlanParent::Step(_)))) +} + +fn prepare_enrichment( + prepared: &mut Prepared, + headers: Option<&crate::enrich::Headers>, +) -> Result<Option<crate::enrich::Enrichment>> { + let Some(headers) = headers else { return Ok(None) }; + let selected = prepared.selected.context("an enriched edit must select its commit")?; + let Some((object, data, enrichment)) = crate::enrich::prepare_headers(&prepared.repo, selected, headers)? else { + return Ok(None); + }; + prepared.enrichment = Some((object, data)); + Ok(Some(enrichment)) +} + +impl Prepared { + fn persist_objects(&mut self) -> Result<()> { + let objects = self + .repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + for (id, (kind, data)) in objects.iter() { + self.repo + .write_buf_with_known_id(*kind, data, *id) + .map_err(|err| anyhow::anyhow!("could not persist a prepared rebase object: {err}"))?; + } + self.repo.objects.set_object_memory(Default::default()); + Ok(()) + } + + fn finish(&mut self) -> Result<Outcome> { + let mut resource_edits = super::stash::rewrite_edits(&self.repo, &self.stash_rewritten, &self.removed)?; + let note_edits = note_rewrite_edits(&self.repo, &self.note_rewrites, &self.committer)?; + let enrichment_edits = self + .enrichment + .take() + .map(|(object, data)| enrichment_edits(&self.repo, object, data, &self.committer)) + .transpose()? + .unwrap_or_else(|| super::stash::RewriteEdits { + forward: Vec::new(), + rollback: Vec::new(), + }); + resource_edits.forward.extend(note_edits.forward); + resource_edits.rollback.extend(note_edits.rollback); + resource_edits.forward.extend(enrichment_edits.forward); + resource_edits.rollback.extend(enrichment_edits.rollback); + self.persist_objects()?; + let _ = self + .repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + + let transitions = worktree_transitions( + &self.repo, + &self.rewritten, + self.expected_refs.as_deref(), + self.skip_worktree_transitions, + )?; + let index_resets = (!self.reset_indices.is_empty()) + .then(|| index_resets(&self.repo, &self.rewritten, &self.reset_indices)) + .transpose()?; + for transition in &transitions { + super::forget::preflight_tree_transition( + &transition.repo, + &transition.workdir, + transition.old, + transition.new, + )?; + } + let head = self.repo.head()?; + let unborn = head.is_unborn(); + let current_ref = head.referent_name().map(ToOwned::to_owned); + let mut deferred_ref_deletions = Vec::new(); + if let (Some(expected_refs), Some(current_ref)) = (&mut self.expected_refs, current_ref) { + if expected_refs + .iter() + .any(|expected| expected.name == current_ref && expected.old.is_some() && expected.new.is_none()) + && (self.repo.workdir().is_none() || (self.selected.is_none() && !self.checkout_after_finish)) + { + anyhow::bail!("cannot delete the checked-out branch without selecting another checkout"); + } + expected_refs.retain(|expected| { + let defer = expected.name == current_ref && expected.old.is_some() && expected.new.is_none(); + if defer { + deferred_ref_deletions.push((expected.name.clone(), expected.old.expect("checked above"))); + } + !defer + }); + } + let updated_refs = update_refs( + &self.repo, + &self.rewritten, + unborn, + self.selected, + &self.committer, + self.expected_refs.take(), + (&self.pins, &self.delete_refs), + resource_edits, + )?; + for (transitioned, transition) in transitions.iter().enumerate() { + if let Err(err) = super::forget::apply_tree_transition(&transition.workdir, transition.old, transition.new) + { + return rollback( + &self.repo, + &self.committer, + &transitions[..transitioned], + &updated_refs.rollback, + err, + ); + } + } + let mut index_resets = index_resets.unwrap_or_default(); + for index in 0..index_resets.len() { + if let Err(mut err) = reset_index(&mut index_resets[index], self.reset_index_paths.as_deref()) { + for applied in index_resets[..=index].iter().rev() { + if let Err(restore) = std::fs::write(&applied.index, &applied.before) { + err = err.context(format!("index rollback failed: {restore}")); + } + } + return rollback(&self.repo, &self.committer, &transitions, &updated_refs.rollback, err); + } + } + Ok(Outcome { + selected: self.selected, + checkout_reference: self.checkout_reference.clone(), + deferred_ref_deletions, + ref_rewrites: updated_refs.rewritten, + ref_changes: updated_refs.changes, + rewritten: std::mem::take(&mut self.rewritten), + }) + } +} + +fn note_rewrite_edits( + repo: &gix::Repository, + rewrites: &[(ObjectId, ObjectId)], + committer: &gix::actor::Signature, +) -> Result<super::stash::RewriteEdits> { + let empty = || super::stash::RewriteEdits { + forward: Vec::new(), + rollback: Vec::new(), + }; + if rewrites.is_empty() { + return Ok(empty()); + } + let notes = repo.notes()?; + let Some(name) = notes.default_ref().map(ToOwned::to_owned) else { + return Ok(empty()); + }; + let (original_root, parent) = match repo.try_find_reference(name.as_ref())? { + Some(mut reference) => { + let parent = reference + .try_id() + .context("the default Git notes reference must be direct")? + .detach(); + let root = reference + .peel_to_tree() + .context("could not read the default Git notes tree")? + .id; + (root, Some(parent)) + } + None => (ObjectId::empty_tree(repo.object_hash()), None), + }; + let mut root = original_root; + for &(old, new) in rewrites { + let Some(source) = gix::note::plumbing::get(original_root, &old, repo) + .map_err(gix::Exn::into_error) + .context("could not find a Git note to copy")? + else { + continue; + }; + let source = repo.find_blob(source).context("could not read a Git note to copy")?; + let destination = gix::note::plumbing::get(root, &new, repo) + .map_err(gix::Exn::into_error) + .context("could not inspect the successor Git note")?; + let data = match destination { + Some(destination) => { + let destination = repo + .find_blob(destination) + .context("could not read the successor Git note")?; + let mut data = destination.data.clone(); + data.extend_from_slice(b"\n\n"); + data.extend_from_slice(&source.data); + data + } + None => source.data.clone(), + }; + let note = repo.write_blob(data)?.detach(); + root = gix::note::plumbing::replace(root, new, note, repo) + .map_err(gix::Exn::into_error) + .context("could not copy a Git note onto its successor")? + .tree; + } + if root == original_root { + return Ok(empty()); + } + + let mut time = gix::date::parse::TimeBuf::default(); + let actor = committer.to_ref(&mut time); + let commit = repo + .new_commit_as(actor, actor, "Notes copied by tix", root, parent) + .context("could not prepare the rewritten Git notes commit")? + .id; + Ok(super::stash::RewriteEdits { + forward: vec![ref_edit(name.clone(), parent, Some(commit))], + rollback: vec![ref_edit(name, Some(commit), parent)], + }) +} + +fn enrichment_edits( + repo: &gix::Repository, + object: ObjectId, + data: BString, + committer: &gix::actor::Signature, +) -> Result<super::stash::RewriteEdits> { + let name: gix::refs::FullName = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); + let (root, parent) = match repo.try_find_reference(name.as_ref())? { + Some(mut reference) => { + let parent = reference + .try_id() + .context("the tix enrich reference must be direct")? + .detach(); + let root = reference + .peel_to_tree() + .context("could not read the tix enrich tree")? + .id; + (root, Some(parent)) + } + None => (ObjectId::empty_tree(repo.object_hash()), None), + }; + let note = repo.write_blob(data)?.detach(); + let tree = gix::note::plumbing::replace(root, object, note, repo) + .map_err(gix::Exn::into_error) + .context("could not prepare the tix enrichment")? + .tree; + let author = repo + .author() + .context("no Git author is configured")? + .context("could not resolve the Git author")? + .to_owned()?; + let mut author_time = gix::date::parse::TimeBuf::default(); + let mut committer_time = gix::date::parse::TimeBuf::default(); + let commit = repo + .new_commit_as( + author.to_ref(&mut author_time), + committer.to_ref(&mut committer_time), + "Notes added by gitoxide", + tree, + parent, + ) + .context("could not prepare the tix enrichment commit")? + .id; + Ok(super::stash::RewriteEdits { + forward: vec![ref_edit(name.clone(), parent, Some(commit))], + rollback: vec![ref_edit(name, Some(commit), parent)], + }) +} + +fn rollback<T>( + repo: &gix::Repository, + committer: &gix::actor::Signature, + transitions: &[Transition], + refs: &[RefEdit], + cause: anyhow::Error, +) -> Result<T> { + let mut failures = Vec::new(); + for transition in transitions.iter().rev() { + if let Err(err) = super::forget::apply_tree_transition(&transition.workdir, transition.new, transition.old) { + failures.push(format!("worktree rollback failed: {err:#}")); + } + } + let mut time = gix::date::parse::TimeBuf::default(); + if let Err(err) = repo.edit_references_as(refs.iter().cloned(), Some(committer.to_ref(&mut time))) { + failures.push(format!("reference rollback failed: {err}")); + } + if failures.is_empty() { + Err(cause) + } else { + Err(cause.context(failures.join("; "))) + } +} + +fn index_resets( + repo: &gix::Repository, + rewritten: &HashMap<ObjectId, Option<ObjectId>>, + reset_from: &HashSet<ObjectId>, +) -> Result<Vec<IndexReset>> { + let mut repos = vec![ + repo.main_repo() + .context("could not open the main worktree repository")?, + ]; + for proxy in repo.worktrees().context("could not enumerate linked worktrees")? { + if let Ok(worktree_repo) = proxy.into_repo_with_possibly_inaccessible_worktree() { + repos.push(worktree_repo); + } + } + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for worktree_repo in repos { + if !seen.insert(worktree_repo.git_dir().to_owned()) { + continue; + } + let Some(old) = worktree_repo + .head() + .ok() + .and_then(|head| head.id().map(gix::Id::detach)) + else { + continue; + }; + if !reset_from.contains(&old) { + continue; + } + let Some(Some(new)) = rewritten.get(&old).copied() else { + continue; + }; + if let Some(workdir) = worktree_repo.workdir().filter(|path| path.is_dir()).map(PathBuf::from) { + let index = worktree_repo.index_path(); + let index_path = index.to_owned(); + let before = std::fs::read(index).context("could not preserve an affected index")?; + out.push(IndexReset { + repo: worktree_repo, + workdir, + index: index_path, + before, + new, + }); + } + } + Ok(out) +} + +struct IndexReset { + repo: gix::Repository, + workdir: PathBuf, + index: PathBuf, + before: Vec<u8>, + new: ObjectId, +} + +fn reset_index(reset: &mut IndexReset, paths: Option<&[BString]>) -> Result<()> { + if let Some(paths) = paths { + return reset_index_paths(&reset.repo, reset.new, paths); + } + let output = Command::new("git") + .arg("-C") + .arg(&reset.workdir) + .args(["reset", "--mixed", "--quiet"]) + .arg(reset.new.to_string()) + .output() + .context("could not update the index after inserting a commit")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("git reset failed: {}", String::from_utf8_lossy(&output.stderr).trim()) + } +} + +fn reset_index_paths(repo: &gix::Repository, id: ObjectId, paths: &[BString]) -> Result<()> { + let tree = repo.find_commit(id)?.tree()?; + let mut index = repo + .open_index() + .context("could not load the index to update selected paths")?; + for path in paths { + let previous = index + .entry_by_path(path.as_bstr()) + .map(|entry| (entry.stat, entry.flags)); + index.remove_entries(|_, candidate, _| candidate == path.as_bstr()); + if let Some(entry) = tree.lookup_entry( + path.split(|byte| *byte == b'/') + .map(|component| BStr::new(component).to_owned()), + )? { + let (stat, flags) = + previous.unwrap_or((gix::index::entry::Stat::default(), gix::index::entry::Flags::empty())); + index.dangerously_push_entry( + stat, + entry.object_id(), + flags, + entry.mode().kind().into(), + path.as_bstr(), + ); + } + } + index.sort_entries(); + index.remove_tree(); + index + .write(gix::index::write::Options::default()) + .context("could not update selected index paths") +} + +fn validate( + repo: &gix::Repository, + graph: &HistoryGraph, + affected: &[ObjectId], + removed: bool, + repeat: bool, + tree: Tree, +) -> Result<()> { + for (position, id) in affected.iter().enumerate() { + let parents = graph.parents_of(*id).context("an affected commit is incomplete")?; + if parents.len() > 1 && (position > 0 || removed || tree == Tree::CherryPick) { + anyhow::bail!("descendant merge commits cannot be rebased"); + } + if repeat && position == 0 { + let commit = repo.find_commit(*id)?.decode()?.into_owned()?; + if !is_pending(&commit) { + anyhow::bail!("the root of a repeated rebase must be pending"); + } + } + } + if repeat + && let Some(base) = affected.first() + && let Some(parent) = graph.parents_of(*base).and_then(|parents| parents.first().copied()) + && is_pending(&repo.find_commit(parent)?.decode()?.into_owned()?) + { + anyhow::bail!("the parent of a repeated rebase must not be pending"); + } + Ok(()) +} + +fn reject_pending_checkout_path(repo: &gix::Repository, mut id: ObjectId) -> Result<()> { + let mut seen = HashSet::new(); + while seen.insert(id) { + let commit = repo.find_commit(id)?.decode()?.into_owned()?; + if is_pending(&commit) { + anyhow::bail!("the current checkout has a pending rebase; time-travel to HEAD before editing it"); + } + let Some(parent) = commit.parents.first().copied() else { + break; + }; + id = parent; + } + Ok(()) +} + +enum TreeRewrite { + Complete(ObjectId), + Conflict { + ours: ObjectId, + merged: ObjectId, + conflicts: Vec<gix::merge::tree::Conflict>, + }, +} + +fn squash_message( + repo: &gix::Repository, + commit: &mut gix::objs::Commit, + squashed: &[(ObjectId, gix::objs::Commit, Vec<ObjectId>)], +) -> Result<()> { + let mut known = HashSet::<(BString, BString)>::new(); + collect_co_authors(&commit.message, &mut known); + for (_, source, _) in squashed { + collect_co_authors(&source.message, &mut known); + } + known.insert(author_identity(&commit.author)); + let mut additional = Vec::new(); + for (_, source, _) in squashed { + let author = author_identity(&source.author); + if known.insert(author.clone()) { + additional.push(author); + } + } + + let mut message = commit.message.to_vec(); + for (id, source, _) in squashed { + while message.last().is_some_and(|byte| matches!(byte, b'\n' | b'\r')) { + message.pop(); + } + let short = id + .attach(repo) + .shorten() + .context("could not shorten a squashed commit ID")?; + message.extend_from_slice(b"\n\n# "); + message.extend_from_slice(short.to_string().as_bytes()); + message.push(b' '); + message.extend_from_slice( + gix::objs::commit::MessageRef::from_bytes(&source.message) + .summary() + .as_ref(), + ); + message.extend_from_slice(b"\n\n"); + message.extend_from_slice(&source.message); + } + if !additional.is_empty() { + let has_trailers = gix::objs::commit::MessageRef::from_bytes(&message) + .body() + .is_some_and(|body| body.trailers().next().is_some()); + while message.last().is_some_and(|byte| matches!(byte, b'\n' | b'\r')) { + message.pop(); + } + message.extend_from_slice(if has_trailers { b"\n" } else { b"\n\n" }); + for (name, email) in additional { + message.extend_from_slice(b"Co-authored-by: "); + message.extend_from_slice(&name); + message.extend_from_slice(b" <"); + message.extend_from_slice(&email); + message.extend_from_slice(b">\n"); + } + } + commit.message = message.into(); + Ok(()) +} + +fn author_identity(author: &gix::actor::Signature) -> (BString, BString) { + ( + author.name.trim().to_owned().into(), + author.email.trim().to_owned().into(), + ) +} + +fn collect_co_authors(message: &[u8], out: &mut HashSet<(BString, BString)>) { + let Some(body) = gix::objs::commit::MessageRef::from_bytes(message).body() else { + return; + }; + for trailer in body.trailers().co_authored_by() { + let mut value: &[u8] = trailer.value.as_ref(); + let Ok(identity) = gix::actor::IdentityRef::from_bytes_consuming(&mut value) else { + continue; + }; + if value.trim().is_empty() { + let identity = identity.trim(); + out.insert((identity.name.to_owned(), identity.email.to_owned())); + } + } +} + +fn rewritten_tree( + repo: &gix::Repository, + commit: &gix::objs::Commit, + old_parents: &[ObjectId], + new_parents: &[ObjectId], + mode: Tree, +) -> Result<TreeRewrite> { + if mode != Tree::CherryPick || old_parents == new_parents { + return Ok(TreeRewrite::Complete(commit.tree)); + } + let old_base = parent_tree(repo, old_parents.first().copied())?; + let new_base = parent_tree(repo, new_parents.first().copied())?; + if commit.tree == old_base { + return Ok(TreeRewrite::Complete(new_base)); + } + if old_base == new_base { + return Ok(TreeRewrite::Complete(commit.tree)); + } + cherry_pick_tree_outcome(repo, old_base, new_base, commit.tree) +} + +pub(super) fn cherry_pick_tree( + repo: &gix::Repository, + old_base: ObjectId, + new_base: ObjectId, + tree: ObjectId, +) -> Result<ObjectId> { + match cherry_pick_tree_outcome(repo, old_base, new_base, tree)? { + TreeRewrite::Complete(tree) => Ok(tree), + TreeRewrite::Conflict { .. } => anyhow::bail!("rebasing would cause a merge conflict"), + } +} + +fn cherry_pick_tree_outcome( + repo: &gix::Repository, + old_base: ObjectId, + new_base: ObjectId, + tree: ObjectId, +) -> Result<TreeRewrite> { + if tree == old_base { + return Ok(TreeRewrite::Complete(new_base)); + } + if old_base == new_base { + return Ok(TreeRewrite::Complete(tree)); + } + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: Some(BStr::new(b"parent")), + current: Some(BStr::new(b"rebased parent")), + other: Some(BStr::new(b"commit")), + }; + let mut outcome = repo + .merge_trees(old_base, new_base, tree, labels, repo.tree_merge_options()?) + .context("could not cherry-pick a descendant tree")?; + let unresolved = outcome.has_unresolved_conflicts(gix::merge::tree::TreatAsUnresolved::git()); + let merged = outcome + .tree + .write() + .context("could not prepare a rebased tree")? + .detach(); + if unresolved { + Ok(TreeRewrite::Conflict { + ours: new_base, + merged, + conflicts: outcome.conflicts, + }) + } else { + Ok(TreeRewrite::Complete(merged)) + } +} + +fn parent_tree(repo: &gix::Repository, parent: Option<ObjectId>) -> Result<ObjectId> { + match parent { + Some(parent) => Ok(repo.find_commit(parent)?.tree_id()?.detach()), + None => Ok(repo.empty_tree().id), + } +} + +fn empty_commit_has_final_parent( + repo: &gix::Repository, + tree: ObjectId, + original_parent: Option<ObjectId>, + rewritten_parent: Option<ObjectId>, +) -> Result<bool> { + if tree != parent_tree(repo, original_parent)? { + return Ok(false); + } + let Some(parent) = rewritten_parent else { + return Ok(true); + }; + Ok(!is_pending(&repo.find_commit(parent)?.decode()?.into_owned()?)) +} + +fn marker(commit: &mut gix::objs::Commit, add: bool, original_parent: Option<ObjectId>) { + commit + .extra_headers + .retain(|(name, _)| name.as_slice() != ORIGINAL_PARENT); + if add { + let parent = original_parent.unwrap_or_else(|| ObjectId::null(commit.tree.kind())); + commit + .extra_headers + .push((ORIGINAL_PARENT.into(), parent.to_hex().to_string().into())); + } +} + +fn marked_parent(commit: &gix::objs::Commit) -> Result<Option<ObjectId>> { + commit + .extra_headers + .iter() + .find(|(name, _)| name.as_slice() == ORIGINAL_PARENT) + .map(|(_, value)| parse_marked_parent(value.as_bstr())) + .transpose() + .map(Option::flatten) +} + +pub(crate) fn marked_parent_ref(commit: &gix::objs::CommitRef<'_>) -> Result<Option<Option<ObjectId>>> { + commit + .extra_headers() + .find("tix-rebase-parent") + .map(parse_marked_parent) + .transpose() +} + +fn parse_marked_parent(value: &BStr) -> Result<Option<ObjectId>> { + ObjectId::from_hex(value) + .context("pending rebase has an invalid original parent") + .map(|id| (!id.is_null()).then_some(id)) +} + +pub(super) fn has_marker(commit: &gix::objs::Commit) -> bool { + commit + .extra_headers + .iter() + .any(|(name, _)| name.as_slice() == ORIGINAL_PARENT) +} + +pub(crate) fn is_pending(commit: &gix::objs::Commit) -> bool { + has_marker(commit) + || commit + .extra_headers + .iter() + .any(|(name, value)| is_signature(name) && value.is_empty()) +} + +fn write_commit_timed( + repo: &gix::Repository, + mut commit: gix::objs::Commit, + predecessor: Option<ObjectId>, + committer: &gix::actor::Signature, + state: CommitState, + signing: Option<gix::objs::signature::sign::Options>, +) -> Result<(ObjectId, Option<Duration>)> { + if let Some(predecessor) = predecessor { + crate::change_id::inherit(repo, &mut commit, predecessor)?; + } + commit.committer = committer.clone(); + let signature = match state { + CommitState::Unmarked(signature) => { + marker(&mut commit, false, None); + signature + } + CommitState::Pending { original_parent } => { + marker(&mut commit, true, original_parent); + Signature::InvalidateExisting + } + }; + let had_signature = commit.extra_headers.iter().any(|(name, _)| is_signature(name)); + commit.extra_headers.retain(|(name, _)| !is_signature(name)); + let mut signing_time = None; + commit = match (signature, signing) { + (Signature::RedoIfNeeded, Some(options)) => { + let started = Instant::now(); + let signed = commit.sign(options).context("could not sign rebased commit")?; + signing_time = Some(started.elapsed()); + signed + } + (Signature::InvalidateExisting, Some(_)) if had_signature => { + let field = gix::objs::commit::signature_field_name(commit.tree.kind()); + commit.extra_headers.push((field.into(), BString::default())); + commit + } + (Signature::Remove, _) => commit, + _ => commit, + }; + Ok(( + repo.write_object(&commit) + .context("could not prepare rebased commit")? + .detach(), + signing_time, + )) +} + +fn is_signature(name: &BString) -> bool { + name.as_slice() == gix::objs::commit::SIGNATURE_FIELD_NAME.as_bytes() + || name.as_slice() == gix::objs::commit::SIGNATURE_FIELD_NAME_SHA256.as_bytes() +} + +struct Transition { + repo: gix::Repository, + workdir: PathBuf, + old: ObjectId, + new: ObjectId, +} + +fn worktree_transitions( + repo: &gix::Repository, + rewritten: &HashMap<ObjectId, Option<ObjectId>>, + expected_refs: Option<&[ExpectedRef]>, + inserted: bool, +) -> Result<Vec<Transition>> { + if inserted { + return Ok(Vec::new()); + } + let mut repos = vec![ + repo.main_repo() + .context("could not open the main worktree repository")?, + ]; + for proxy in repo.worktrees().context("could not enumerate linked worktrees")? { + if let Ok(worktree_repo) = proxy.into_repo_with_possibly_inaccessible_worktree() { + repos.push(worktree_repo); + } + } + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for worktree_repo in repos { + if !seen.insert(worktree_repo.git_dir().to_owned()) { + continue; + } + let Ok(head) = worktree_repo.head() else { + continue; + }; + let Some(old) = head.id().map(gix::Id::detach) else { + continue; + }; + let planned = head.referent_name().and_then(|name| { + expected_refs.and_then(|refs| refs.iter().find(|expected| expected.name.as_bstr() == name.as_bstr())) + }); + let new = match planned { + Some(expected) if expected.new.is_none() => { + if worktree_repo.git_dir() == repo.git_dir() { + continue; + } + anyhow::bail!( + "cannot delete {} because another worktree has it checked out", + expected.name.shorten() + ); + } + Some(expected) => Some(expected.new), + None => rewritten.get(&old).copied(), + }; + let Some(new) = new else { continue }; + if worktree_repo.workdir().is_none() && worktree_repo.is_bare() { + continue; + } + let old_tree = worktree_repo.find_commit(old)?.tree_id()?.detach(); + let new_tree = match new { + Some(new) => repo.find_commit(new)?.tree_id()?.detach(), + None if worktree_repo.head()?.referent_name().is_some() => repo.empty_tree().id, + None => anyhow::bail!("a detached checked-out root commit cannot be removed"), + }; + if old_tree == new_tree { + continue; + } + let workdir = worktree_repo + .workdir() + .filter(|path| path.is_dir()) + .context("an affected worktree is inaccessible")? + .to_owned(); + out.push(Transition { + repo: worktree_repo, + workdir, + old: old_tree, + new: new_tree, + }); + } + Ok(out) +} + +#[expect(clippy::too_many_arguments)] +fn update_refs( + repo: &gix::Repository, + rewritten: &HashMap<ObjectId, Option<ObjectId>>, + unborn: bool, + inserted: Option<ObjectId>, + committer: &gix::actor::Signature, + expected_refs: Option<Vec<ExpectedRef>>, + resources: (&[ObjectId], &[(gix::refs::FullName, Target)]), + stash_edits: super::stash::RewriteEdits, +) -> Result<UpdatedRefs> { + let (pins, delete_refs) = resources; + let mut edits = stash_edits.forward; + let mut rollback = stash_edits.rollback; + let mut ref_rewrites = Vec::new(); + if let Some(expected_refs) = expected_refs { + for ExpectedRef { name, old, new, .. } in expected_refs { + if delete_refs.iter().any(|(delete, _)| delete == &name) { + continue; + } + if old == new { + continue; + } + if let (Some(old), Some(new)) = (old, new) { + ref_rewrites.push(RefRewrite { + name: name.clone(), + old, + new, + }); + } + edits.push(ref_edit(name.clone(), old, new)); + rollback.push(ref_edit(name, new, old)); + } + } else if !unborn { + for reference in repo.references()?.all()? { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => anyhow::bail!("could not inspect a reference before rebasing: {err}"), + }; + if matches!( + reference.name().category(), + Some(Category::Tag | Category::RemoteBranch) + ) || super::undo::is_queue_ref(reference.name().as_bstr()) + { + continue; + } + let Some(old) = reference.try_id().map(gix::Id::detach) else { + continue; + }; + let Some(new) = rewritten.get(&old) else { continue }; + let name = reference.name().to_owned(); + if delete_refs.iter().any(|(delete, _)| delete == &name) { + continue; + } + if let Some(new) = *new { + ref_rewrites.push(RefRewrite { + name: name.clone(), + old, + new, + }); + } + edits.push(ref_edit(name.clone(), Some(old), *new)); + rollback.push(ref_edit(name, *new, Some(old))); + } + if let Some(head) = repo.try_find_reference("HEAD")? + && let Some(old) = head.try_id().map(gix::Id::detach) + && let Some(new) = rewritten.get(&old) + { + let name = head.name().to_owned(); + if let Some(new) = *new { + ref_rewrites.push(RefRewrite { + name: name.clone(), + old, + new, + }); + } + edits.push(ref_edit(name.clone(), Some(old), *new)); + rollback.push(ref_edit(name, *new, Some(old))); + } + } + if unborn { + let name = repo + .head()? + .referent_name() + .context("an unborn HEAD must point to a branch")? + .to_owned(); + let new = inserted.context("an unborn insertion must create a commit")?; + edits.push(RefEdit { + name: name.clone(), + deref: false, + change: Change::Update { + log: log_change(), + expected: PreviousValue::MustNotExist, + new: Target::Object(new), + }, + }); + rollback.push(RefEdit { + name, + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(new)), + log: RefLog::AndReference, + }, + }); + } + let mut reserved = HashSet::new(); + for id in pins { + let name = pin_name(repo, *id, &reserved)?; + reserved.insert(name.clone()); + edits.push(RefEdit { + name: name.clone(), + deref: false, + change: Change::Update { + log: log_change(), + expected: PreviousValue::MustNotExist, + new: Target::Object(*id), + }, + }); + rollback.push(RefEdit { + name, + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(*id)), + log: RefLog::AndReference, + }, + }); + } + for (name, target) in delete_refs { + edits.push(RefEdit { + name: name.clone(), + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(target.clone()), + log: RefLog::AndReference, + }, + }); + rollback.push(RefEdit { + name: name.clone(), + deref: false, + change: Change::Update { + log: log_change(), + expected: PreviousValue::MustNotExist, + new: target.clone(), + }, + }); + } + if edits.is_empty() { + return Ok(UpdatedRefs::default()); + } + let mut time = gix::date::parse::TimeBuf::default(); + let applied = repo + .edit_references_as(edits, Some(committer.to_ref(&mut time))) + .context("could not update references after rebasing")?; + let changes = super::undo::changes_from_edits(applied)?; + ref_rewrites.sort_by(|a, b| a.name.cmp(&b.name)); + ref_rewrites.dedup(); + Ok(UpdatedRefs { + rollback, + rewritten: ref_rewrites, + changes, + }) +} + +#[derive(Default)] +struct UpdatedRefs { + rollback: Vec<RefEdit>, + rewritten: Vec<RefRewrite>, + changes: Vec<super::undo::RefChange>, +} + +fn pin_name( + repo: &gix::Repository, + id: ObjectId, + reserved: &HashSet<gix::refs::FullName>, +) -> Result<gix::refs::FullName> { + let hex = id.to_hex().to_string(); + let mut len = 8.min(hex.len()); + let mut number = 2; + loop { + let suffix = if len <= hex.len() { + hex[..len].to_owned() + } else { + let suffix = format!("{hex}{number}"); + number += 1; + suffix + }; + let name: gix::refs::FullName = format!("{}{}", String::from_utf8_lossy(crate::history::PIN_PREFIX), suffix) + .try_into() + .context("generated an invalid tix pin name")?; + if !reserved.contains(&name) && repo.try_find_reference(name.as_ref())?.is_none() { + return Ok(name); + } + if len < hex.len() { + len += 1; + } else { + len = hex.len() + 1; + } + } +} + +fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { + loop { + if err + .downcast_ref::<std::io::Error>() + .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + return true; + } + let Some(source) = err.source() else { return false }; + err = source; + } +} + +fn ref_edit(name: gix::refs::FullName, old: Option<ObjectId>, new: Option<ObjectId>) -> RefEdit { + RefEdit { + name, + deref: false, + change: match (old, new) { + (Some(old), Some(new)) => Change::Update { + log: log_change(), + expected: PreviousValue::MustExistAndMatch(Target::Object(old)), + new: Target::Object(new), + }, + (Some(old), None) => Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(old)), + log: RefLog::AndReference, + }, + (None, Some(new)) => Change::Update { + log: log_change(), + expected: PreviousValue::MustNotExist, + new: Target::Object(new), + }, + (None, None) => unreachable!("unchanged absent refs are filtered before editing"), + }, + } +} + +fn log_change() -> LogChange { + LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: BString::from("tix rebase"), + } +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result<gix::Repository> { + Ok(crate::test_repository::open_with( + path, + [ + "user.name=rebasing committer", + "user.email=rebasing@example.com", + "core.notesRef=refs/notes/review", + ], + )?) + } + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if output.status.success() { + Ok(output.stdout) + } else { + Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()) + } + } + + fn set_git_note(repo: &gix::Repository, id: ObjectId, data: &[u8]) -> gix_testtools::Result { + let mut notes = repo.notes()?; + let reference = notes + .default_ref() + .expect("the test repository has a default notes ref") + .to_owned(); + notes.replace_at_ref(reference.as_ref(), id, data)?; + Ok(()) + } + + fn git_note(repo: &gix::Repository, id: ObjectId) -> gix_testtools::Result<Option<Vec<u8>>> { + let notes = repo.notes()?; + let reference = notes + .default_ref() + .expect("the test repository has a default notes ref") + .to_owned(); + let mut notes = notes.with_refs([reference.as_bstr()])?; + Ok(notes.get(id)?.first().map(|note| note.blob.data.clone())) + } + + #[test] + fn rewords_a_middle_commit_and_reparents_all_linear_descendants() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + let old_tip_tree = repo.find_commit(old_tip)?.tree_id()?.detach(); + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.message = "rewritten middle".into(); + + let mut progress = Vec::new(); + let outcome = perform_with_progress( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIs, + |update| progress.push(update), + )? + .complete()?; + let progress = progress.last().context("stack-edit progress is reported")?; + assert_eq!(progress.total, 2, "the edited commit and its descendant are counted"); + assert_eq!(progress.processed, 2, "the completed edit reports every commit"); + assert_eq!(progress.cherry_picked, 1, "only the checked-out descendant is replayed"); + let new_middle = outcome.selected.expect("replacement selects the rewritten commit"); + let new_tip = repo.head_id()?.detach(); + assert_ne!(new_tip, old_tip, "the descendant is rewritten"); + assert!( + outcome.ref_rewrites.iter().any(|rewrite| { + rewrite.name == "refs/heads/main" && rewrite.old == old_tip && rewrite.new == new_tip + }), + "the successful branch transaction reports its old and final descendant IDs" + ); + assert_eq!( + repo.find_commit(new_tip)?.parent_ids().next().map(gix::Id::detach), + Some(new_middle), + "the descendant follows the replacement" + ); + assert_eq!( + repo.find_commit(new_tip)?.tree_id()?, + old_tip_tree, + "a reword preserves descendant trees" + ); + for (id, predecessor) in [(new_middle, middle), (new_tip, old_tip)] { + let commit = repo.find_commit(id)?.decode()?.into_owned()?; + assert_eq!(commit.committer.name, b"rebasing committer".as_bstr()); + assert_eq!(commit.committer.email, b"rebasing@example.com".as_bstr()); + assert_eq!( + commit.committer.time.seconds, 978_307_200, + "every rewritten commit receives the operation's current committer date" + ); + assert_eq!( + crate::change_id::effective( + id, + commit + .extra_headers + .iter() + .filter_map(|(name, value)| (name == crate::change_id::HEADER).then_some(value.as_ref())) + ), + gix::hash::ChangeId::from(predecessor), + "each rewritten commit retains the identity of its predecessor" + ); + } + insta::assert_snapshot!( + "reworded-middle-stack", + gix_testtools::repository::snapshot_portable(fixture.path())?.to_string() + ); + Ok(()) + } + + #[test] + fn an_unchanged_checkout_descendant_retains_its_commit() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old_middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + let mut middle_commit = repo.find_commit(old_middle)?.decode()?.into_owned()?; + middle_commit.committer = repo + .committer() + .context("the test repository has a committer")? + .context("the test committer is valid")? + .to_owned()?; + crate::change_id::inherit(&repo, &mut middle_commit, old_middle)?; + let middle = repo.write_object(&middle_commit)?.detach(); + let mut tip_commit = repo.find_commit(old_tip)?.decode()?.into_owned()?; + tip_commit.parents = [middle].into_iter().collect(); + let tip = repo.write_object(&tip_commit)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(tip, "prepare an unchanged checkout descendant")?; + repo.find_reference("refs/patches/middle")? + .set_target_id(middle, "prepare an unchanged target")?; + repo.find_reference("refs/patches/tip")? + .set_target_id(tip, "prepare an unchanged checkout descendant")?; + let graph = super::super::loaded_graph(&repo)?; + let mut reported = Vec::new(); + + let outcome = perform_reporting_rebased( + &repo, + &graph, + Edit::Replace { + target: middle, + commit: middle_commit, + }, + Signature::Remove, + Tree::LeaveAsIsAndMark, + |id| reported.push(id), + )? + .complete()?; + + assert_eq!(outcome.map(middle), Some(middle), "the no-op target retains its ID"); + assert_eq!(outcome.map(tip), Some(tip), "its unaffected descendant retains its ID"); + assert_eq!(repo.head_id()?, tip, "HEAD remains on the original commit"); + assert_eq!(reported, [middle], "only the explicit target enters commit writing"); + Ok(()) + } + + #[test] + fn git_notes_follow_each_actual_rewritten_successor() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + set_git_note(&repo, middle, b"middle note")?; + set_git_note(&repo, old_tip, b"tip note")?; + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.message = "rewritten middle".into(); + + let outcome = perform( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIs, + )? + .complete()?; + let new_middle = outcome.selected.context("the replacement is selected")?; + let new_tip = repo.head_id()?.detach(); + for (old, new, note) in [ + (middle, new_middle, b"middle note".as_slice()), + (old_tip, new_tip, b"tip note".as_slice()), + ] { + assert_eq!( + git_note(&repo, old)?.as_deref(), + Some(note), + "the predecessor retains its Git note" + ); + assert_eq!( + git_note(&repo, new)?.as_deref(), + Some(note), + "the rewritten successor receives the Git note" + ); + } + assert!( + repo.try_find_reference("refs/notes/review")?.is_some(), + "the configured default notes ref is updated" + ); + Ok(()) + } + + #[test] + fn rewriting_a_stashed_commit_moves_its_stash_association() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let saved = repo.write_blob(b"saved worktree state")?.detach(); + let old_name = super::super::stash::reference(middle)?; + repo.reference(old_name.clone(), saved, PreviousValue::MustNotExist, "test stash")?; + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.message = "rewritten middle".into(); + + let outcome = perform( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIs, + )? + .complete()?; + let rewritten = outcome.map(middle).expect("the stashed commit is retained"); + let new_name = super::super::stash::reference(rewritten)?; + assert!(repo.try_find_reference(old_name.as_ref())?.is_none()); + assert_eq!( + repo.find_reference(new_name.as_ref())?.id(), + saved, + "the saved state follows the rewritten commit without changing its target" + ); + Ok(()) + } + + #[test] + fn removes_a_middle_commit_by_cherry_picking_its_descendant() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip_tree = repo.find_commit(repo.head_id()?)?.tree_id()?.detach(); + set_git_note(&repo, middle, b"removed note")?; + + let outcome = perform( + &repo, + &graph, + Edit::Remove { target: middle }, + Signature::RedoIfNeeded, + Tree::CherryPick, + )? + .complete()?; + assert_eq!(outcome.selected, Some(base), "removal selects its parent"); + assert_eq!(outcome.map(base), Some(base), "the unaffected parent retains its ID"); + let tip = repo.head_id()?.detach(); + assert_eq!( + repo.find_commit(tip)?.parent_ids().next().map(gix::Id::detach), + Some(base), + "the descendant is transplanted onto the removed commit's parent" + ); + assert_ne!( + repo.find_commit(tip)?.tree_id()?, + old_tip_tree, + "the removed commit's tree contribution is absent" + ); + assert_eq!(git_note(&repo, middle)?.as_deref(), Some(b"removed note".as_slice())); + assert_eq!( + git_note(&repo, tip)?, + None, + "a dropped commit does not donate its Git note" + ); + Ok(()) + } + + #[test] + fn a_marked_rebase_can_be_repeated_and_clears_its_markers() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.tree = repo.find_commit(base)?.tree_id()?.detach(); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "--detach", &base.to_string()]) + .status()? + .success(), + "the pending stack is prepared away from the current checkout" + ); + let marked = perform( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::InvalidateExisting, + Tree::LeaveAsIsAndMark, + )? + .complete()?; + let tip = marked.map(old_tip).context("marking retains the pending tip")?; + + let graph = super::super::loaded_graph(&repo)?; + let repeated = perform( + &repo, + &graph, + Edit::Repeat { + base: tip, + checkout: tip, + }, + Signature::RedoIfNeeded, + Tree::CherryPick, + )? + .complete()?; + let repeated_tip = repeated.map(tip).context("repeating retains the pending tip")?; + let mut id = Some(repeated_tip); + while let Some(current) = id { + let commit = repo.find_commit(current)?.decode()?.into_owned()?; + assert!(!has_marker(&commit), "repeating clears every pending marker"); + id = commit.parents.first().copied(); + } + let files = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["ls-tree", "-r", "--name-only", &repeated_tip.to_string()]) + .output()?; + assert!(files.status.success()); + assert_eq!( + files.stdout, b"base\ntip\n", + "repeat cherry-picks the descendant against its recorded original parent" + ); + Ok(()) + } + + #[test] + fn a_repeat_finalizes_empty_descendants_above_legacy_signed_pending_commits() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "--allow-empty", "-q", "-m", "newer"]) + .status()? + .success(), + "the fixture gains a second descendant" + ); + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~3")?.detach(); + let middle = repo.rev_parse_single("HEAD~2")?.detach(); + let old_tip = repo.rev_parse_single("HEAD~1")?.detach(); + let old_newer = repo.head_id()?.detach(); + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.tree = repo.find_commit(base)?.tree_id()?.detach(); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "--detach", &base.to_string()]) + .status()? + .success(), + "the legacy pending stack is prepared away from the current checkout" + ); + let marked = perform( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::InvalidateExisting, + Tree::LeaveAsIsAndMark, + )? + .complete()?; + let pending_tip = marked.map(old_tip).expect("the first descendant remains"); + let pending_newer = marked.map(old_newer).expect("the second descendant remains"); + + let mut legacy_tip = repo.find_commit(pending_tip)?.decode()?.into_owned()?; + legacy_tip + .extra_headers + .push(("gpgsig".into(), "legacy signature".into())); + let legacy_tip = repo.write_object(&legacy_tip)?.detach(); + let mut legacy_newer = repo.find_commit(pending_newer)?.decode()?.into_owned()?; + legacy_newer.parents = [legacy_tip].into_iter().collect(); + legacy_newer + .extra_headers + .push(("gpgsig".into(), "legacy signature".into())); + let legacy_newer = repo.write_object(&legacy_newer)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(legacy_newer, "test legacy pending commits")?; + drop(repo); + + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "main"]) + .status()? + .success(), + "the legacy pending stack becomes the current checkout" + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "--allow-empty", "-q", "-m", "ordinary descendant"]) + .status()? + .success(), + "an ordinary commit can be created above pending history" + ); + let repo = open(fixture.path())?; + let ordinary = repo.head_id()?.detach(); + let graph = super::super::loaded_graph(&repo)?; + let outcome = perform( + &repo, + &graph, + Edit::Repeat { + base: legacy_tip, + checkout: legacy_tip, + }, + Signature::RedoIfNeeded, + Tree::CherryPick, + )? + .complete()?; + let selected = outcome + .map(legacy_tip) + .expect("the selected pending commit is retained"); + assert!(!has_marker(&repo.find_commit(selected)?.decode()?.into_owned()?)); + let mut parent = selected; + for id in [legacy_newer, ordinary].into_iter().map(|id| { + outcome + .map(id) + .expect("every descendant is retained while repeating the rebase") + }) { + let commit = repo.find_commit(id)?.decode()?.into_owned()?; + assert!(!is_pending(&commit), "empty descendants of a final parent are final"); + assert_eq!(commit.parents.first().copied(), Some(parent)); + assert!( + commit + .extra_headers + .iter() + .all(|(name, _)| name != "gpgsig" && name != "gpgsig-sha256"), + "final unsigned descendants discard stale signatures" + ); + parent = id; + } + Ok(()) + } + + #[test] + fn authoritative_replacements_do_not_record_an_original_parent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let root = repo.rev_parse_single("HEAD~2")?.detach(); + let commit = repo.find_commit(root)?.decode()?.into_owned()?; + let marked = perform( + &repo, + &graph, + Edit::Replace { target: root, commit }, + Signature::InvalidateExisting, + Tree::LeaveAsIsAndMark, + )? + .complete()? + .selected + .expect("the replacement selects its rewritten root"); + let commit = repo.find_commit(marked)?.decode()?.into_owned()?; + assert!( + !has_marker(&commit), + "the replacement tree and unchanged parent need no later cherry-pick" + ); + Ok(()) + } + + #[test] + fn empty_descendants_of_a_finalized_parent_are_not_left_pending() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + drop(repo); + git(fixture.path(), &["checkout", "-q", "--detach", &base.to_string()])?; + + let repo = open(fixture.path())?; + let mut empty = repo.find_commit(tip)?.decode()?.into_owned()?; + empty.tree = repo.find_commit(middle)?.tree_id()?.detach(); + let empty = repo.write_object(&empty)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(empty, "prepare an empty descendant")?; + drop(repo); + let linked_root = gix_testtools::tempfile::tempdir()?; + let linked = linked_root.path().join("linked"); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q"]) + .arg(&linked) + .arg("main") + .status()? + .success(), + "the empty descendant is checked out in a linked worktree" + ); + + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let mut spilled = repo.find_commit(middle)?.decode()?.into_owned()?; + spilled.tree = repo.find_commit(base)?.tree_id()?.detach(); + + let outcome = perform( + &repo, + &graph, + Edit::Replace { + target: middle, + commit: spilled, + }, + Signature::InvalidateExisting, + Tree::LeaveAsIsAndMark, + )? + .complete()?; + let middle = outcome.map(middle).context("the spilled commit is retained")?; + let empty = outcome.map(empty).context("the empty descendant is retained")?; + let middle = repo.find_commit(middle)?.decode()?.into_owned()?; + let empty = repo.find_commit(empty)?.decode()?.into_owned()?; + + assert!(!is_pending(&middle), "the edited commit is final"); + assert!(!is_pending(&empty), "its empty descendant is final too"); + assert_eq!( + empty.tree, middle.tree, + "the empty descendant follows its final parent tree" + ); + assert_eq!( + git(&linked, &["status", "--short"])?, + b"?? middle\n", + "the linked index follows the finalized commit while spilled bytes stay in its worktree" + ); + Ok(()) + } + + #[test] + fn rewrites_every_fork_without_flattening_it() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tree = repo.find_commit(middle)?.tree_id()?.detach(); + let tree_hex = tree.to_string(); + let middle_hex = middle.to_string(); + let side = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args([ + "-c", + "commit.gpgSign=false", + "commit-tree", + &tree_hex, + "-p", + &middle_hex, + "-m", + "side", + ]) + .env("GIT_AUTHOR_DATE", "2000-01-04T00:00:00 +0000") + .env("GIT_COMMITTER_DATE", "2000-01-04T00:00:00 +0000") + .output()?; + assert!(side.status.success(), "the side commit fixture is created"); + let side = ObjectId::from_hex(side.stdout.trim())?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", "refs/heads/side", &side.to_string()]) + .status()? + .success(), + "the side tip is made visible to history loading" + ); + let graph = super::super::loaded_graph(&repo)?; + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.message = "fork point".into(); + let rewritten_middle = perform( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIs, + )? + .complete()? + .selected + .expect("the fork point is rewritten"); + + let main = repo.head_id()?.detach(); + let side = repo.find_reference("refs/heads/side")?.id().detach(); + assert_ne!(main, side, "the two descendant lines remain distinct"); + for (name, tip) in [("main", main), ("side", side)] { + assert_eq!( + repo.find_commit(tip)?.parent_ids().next().map(gix::Id::detach), + Some(rewritten_middle), + "the {name} fork follows the rewritten fork point" + ); + } + Ok(()) + } + + #[test] + fn a_lazy_edit_eagerly_detects_checkout_conflicts_without_observable_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let objects_before = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["count-objects", "-v"]) + .output()? + .stdout; + + let Perform::Conflict(conflict) = perform( + &repo, + &graph, + Edit::Remove { target: middle }, + Signature::RedoIfNeeded, + Tree::LeaveAsIsAndMark, + )? + else { + return Err("the unresolved cherry-pick should suspend the complete rebase".into()); + }; + drop(conflict); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "refs, index, and worktree remain unchanged" + ); + assert_eq!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["count-objects", "-v"]) + .output()? + .stdout, + objects_before, + "failed in-memory rebases write no objects" + ); + Ok(()) + } + + #[test] + fn conflicting_enriched_edits_are_atomic_and_report_one_ref_transaction() -> gix_testtools::Result { + let headers = crate::enrich::Headers { + todo: true, + message: Some("conflict note".into()), + }; + let prepare = |repo: &gix::Repository| -> gix_testtools::Result<(HistoryGraph, ObjectId, gix::objs::Commit)> { + let graph = super::super::loaded_graph(repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.tree = repo.find_commit(base)?.tree_id()?.detach(); + commit.message = "rewritten middle".into(); + Ok((graph, middle, commit)) + }; + + let cancelled = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(cancelled.path())?; + let (graph, middle, commit) = prepare(&repo)?; + let before = gix_testtools::repository::snapshot(cancelled.path())?; + let (Perform::Conflict(conflict), enrichment) = perform_with_enrichment( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIsAndMark, + &headers, + )? + else { + return Err("the enriched edit should suspend on its checkout conflict".into()); + }; + assert!(enrichment.is_some(), "the enrichment is prepared in memory"); + drop(conflict); + assert_eq!( + gix_testtools::repository::snapshot(cancelled.path())?, + before, + "cancelling leaks neither history nor enrichment refs" + ); + + let accepted = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(accepted.path())?; + let (graph, middle, commit) = prepare(&repo)?; + let (Perform::Conflict(conflict), _) = perform_with_enrichment( + &repo, + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIsAndMark, + &headers, + )? + else { + return Err("the enriched edit should suspend on its checkout conflict".into()); + }; + let persisted = conflict.persist()?; + assert!( + persisted + .ref_changes + .iter() + .any(|change| change.name.as_bstr() == crate::enrich::REF_NAME.as_bytes()), + "accepted conflict records the enrichment in the same undo change set" + ); + assert!( + persisted + .ref_changes + .iter() + .any(|change| change.name == "refs/heads/main"), + "the same change set also contains the rewritten branch" + ); + Ok(()) + } + + #[test] + fn an_edit_cannot_leave_the_checkout_on_pending_ancestry() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old_tip = repo.head_id()?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let base = repo.rev_parse_single("HEAD~2")?.detach(); + + let mut pending = repo.find_commit(middle)?.decode()?.into_owned()?; + pending + .extra_headers + .push((ORIGINAL_PARENT.into(), base.to_string().into())); + let pending = repo.write_object(&pending)?.detach(); + let mut tip = repo.find_commit(old_tip)?.decode()?.into_owned()?; + tip.parents = [pending].into_iter().collect(); + let tip = repo.write_object(&tip)?.detach(); + repo.find_reference("refs/heads/main")? + .set_target_id(tip, "prepare pending checkout ancestry")?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + + let err = match perform( + &repo, + &graph, + Edit::Remove { target: tip }, + Signature::RedoIfNeeded, + Tree::LeaveAsIsAndMark, + ) { + Ok(_) => return Err("removing HEAD must not expose its pending parent".into()), + Err(err) => err, + }; + assert!(err.to_string().contains("time-travel to HEAD"), "{err:#}"); + assert_eq!(gix_testtools::repository::snapshot(fixture.path())?, before); + Ok(()) + } + + #[test] + fn a_conflicting_todo_aborts_at_the_original_commit_without_observable_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let objects_before = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["count-objects", "-v"]) + .output()? + .stdout; + + let PlanPerform::Conflict(conflict) = perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }], + checkout: Some(PlanCheckout { + target: PlanParent::Step(0), + reference: None, + }), + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + else { + return Err("the conflicting todo should abort before persistence".into()); + }; + assert_eq!(conflict.original(), tip, "the diagnostic identifies the source delta"); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "refs, index, checkout, and worktree remain unchanged" + ); + assert_eq!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["count-objects", "-v"]) + .output()? + .stdout, + objects_before, + "the discarded object-memory transaction writes no objects" + ); + Ok(()) + } + + #[test] + fn a_conflicting_squash_plan_produces_a_materializable_continuation() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + std::fs::write(fixture.path().join("file"), b"source\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-qam", "source"]) + .status()? + .success() + ); + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let target = repo.rev_parse_single("HEAD~2")?.detach(); + let source = repo.head_id()?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + + let PlanPerform::Conflict(conflict) = perform_plan(&repo, &graph, squash_plan(&repo, &graph, source, target)?)? + else { + return Err("the non-adjacent squash should suspend on its conflicting source delta".into()); + }; + assert_eq!(conflict.original(), source); + assert!( + conflict + .continuation_plan() + .steps + .iter() + .any(|step| matches!(step.commit, PlanCommit::Resolved(_))), + "the ordinary todo continuation can resume the squash" + ); + drop(conflict); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "an unmaterialized squash conflict leaves the repository unchanged" + ); + Ok(()) + } + + #[test] + fn a_todo_rebase_eagerly_replays_only_the_checkout_ancestry_and_keeps_the_branch_at_the_leaf() + -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let old_middle_tree = repo.find_commit(middle)?.tree_id()?.detach(); + let expected_refs = capture_refs(&repo, &[middle, tip], &[tip])?; + + let mut progress = Vec::new(); + let outcome = perform_plan_with_progress( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![ + PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Pick(middle), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(1), + commit: PlanCommit::Empty("checkpoint".into()), + squash: Vec::new(), + }, + ], + checkout: Some(PlanCheckout { + target: PlanParent::Step(0), + reference: None, + }), + expected_refs, + }, + |update| progress.push(update), + )? + .complete()?; + let progress = progress.last().context("rebase progress is reported")?; + assert_eq!(progress.total, 3, "every todo command contributes to the total"); + assert_eq!(progress.processed, 3, "eager, lazy, and empty commands are processed"); + assert_eq!(progress.cherry_picked, 1, "only the checkout ancestry is eager"); + assert_eq!(progress.signed, 0, "signing is counted only when configured"); + assert!( + progress.cherry_pick_time > Duration::ZERO, + "cherry-pick time accumulates" + ); + let new_tip = outcome.map(tip).context("the picked tip is retained")?; + let new_middle = outcome.map(middle).context("the picked middle is retained")?; + assert_eq!( + outcome.selected, + Some(new_tip), + "the checkout still follows its picked commit" + ); + let tip_commit = repo.find_commit(new_tip)?.decode()?.into_owned()?; + assert!(!has_marker(&tip_commit), "the checkout ancestry is eagerly replayed"); + assert_eq!(tip_commit.parents.first().copied(), Some(base)); + let middle_commit = repo.find_commit(new_middle)?.decode()?.into_owned()?; + assert!( + has_marker(&middle_commit), + "history outside the checkout ancestry stays lazy" + ); + assert_eq!( + middle_commit.tree, old_middle_tree, + "a lazy rewrite keeps its original tree" + ); + assert_eq!(middle_commit.parents.first().copied(), Some(new_tip)); + + let empty_id = repo.head_id()?.detach(); + let empty = repo.find_commit(empty_id)?.decode()?.into_owned()?; + assert_eq!( + empty.message, b"checkpoint", + "the complete empty-commit title becomes its message" + ); + assert!(has_marker(&empty), "an empty commit outside @ remains lazy"); + assert_eq!(empty.parents.first().copied(), Some(new_middle)); + assert_eq!( + empty.tree, middle_commit.tree, + "the empty commit reuses its parent tree" + ); + assert!( + !repo.head()?.is_detached(), + "the branch remains checked out after rebasing" + ); + assert!( + crate::history::all_pins(&repo)?.is_empty(), + "the referenced leaf needs no pin" + ); + let repository_path = repo.git_dir().to_owned(); + drop(repo); + super::super::time_travel::checkout_without_replay(&repository_path, false, new_tip, &[], false)?; + let repo = open(fixture.path())?; + assert!( + repo.head()?.is_detached(), + "moving @ below the branch tip detaches HEAD" + ); + assert_eq!(repo.head_id()?, new_tip, "HEAD follows the checkout marker"); + assert_eq!( + repo.find_reference("refs/heads/main")?.id(), + empty_id, + "the branch remains at the resulting leaf" + ); + let pins = crate::history::all_pins(&repo)?; + assert_eq!(pins.len(), 1, "the detached view retains the branch through a pin"); + assert_eq!( + pins[0].target.try_name().expect("the pin is symbolic"), + "refs/heads/main", + "the visibility pin follows the branch symbolically" + ); + Ok(()) + } + + #[test] + fn a_pending_checkout_rewrites_only_from_the_first_pending_commit() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let clean = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + + let mut checkout = repo.find_commit(old_tip)?.decode()?.into_owned()?; + checkout.extra_headers.retain(|(name, _)| !is_signature(name)); + checkout.extra_headers.push(( + gix::objs::commit::signature_field_name(checkout.tree.kind()).into(), + BString::default(), + )); + let checkout = repo.write_object(&checkout)?.detach(); + + let mut descendant = repo.find_commit(checkout)?.decode()?.into_owned()?; + descendant.parents = [checkout].into_iter().collect(); + descendant.message = "pending descendant".into(); + descendant + .extra_headers + .push((ORIGINAL_PARENT.into(), checkout.to_hex().to_string().into())); + let descendant = repo.write_object(&descendant)?.detach(); + repo.reference( + "refs/heads/main", + descendant, + PreviousValue::MustExistAndMatch(Target::Object(old_tip)), + "test pending tip", + )?; + + let graph = super::super::loaded_graph(&repo)?; + let mut progress = Vec::new(); + let outcome = perform_plan_with_progress( + &repo, + &graph, + Plan { + base, + scope: vec![clean, checkout, descendant], + steps: vec![ + PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(clean), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Pick(checkout), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(1), + commit: PlanCommit::Pick(descendant), + squash: Vec::new(), + }, + ], + checkout: Some(PlanCheckout { + target: PlanParent::Step(1), + reference: None, + }), + expected_refs: capture_refs(&repo, &[clean, checkout, descendant], &[descendant])?, + }, + |update| progress.push(update), + )? + .complete()?; + + assert_eq!( + outcome.map(clean), + Some(clean), + "the clean prefix retains its commit ID" + ); + let rewritten_checkout = outcome.map(checkout).context("the pending checkout is retained")?; + assert_ne!(rewritten_checkout, checkout, "the empty signature is materialized"); + assert_eq!(outcome.selected, Some(rewritten_checkout)); + assert!( + !is_pending(&repo.find_commit(rewritten_checkout)?.decode()?.into_owned()?), + "the selected commit is no longer pending" + ); + let rewritten_descendant = outcome.map(descendant).context("the descendant is retained")?; + let descendant = repo.find_commit(rewritten_descendant)?.decode()?.into_owned()?; + assert!( + !is_pending(&descendant), + "an empty descendant of the finalized checkout is final too" + ); + assert_eq!(descendant.parents.as_slice(), [rewritten_checkout]); + assert_eq!( + repo.find_reference("refs/heads/main")?.id(), + rewritten_descendant, + "the branch follows the finalized empty tip" + ); + let progress = progress.last().context("rebase progress is reported")?; + assert_eq!(progress.processed, 3); + assert_eq!( + progress.cherry_picked, 1, + "only the pending checkout is replayed eagerly" + ); + Ok(()) + } + + #[test] + fn a_todo_squash_materializes_one_commit_and_maps_every_source_to_it() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let tip_tree = repo.find_commit(tip)?.tree_id()?.detach(); + set_git_note(&repo, middle, b"middle note")?; + set_git_note(&repo, tip, b"tip note")?; + + let mut progress = Vec::new(); + let outcome = perform_plan_with_progress( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(middle), + squash: vec![tip], + }], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + |update| progress.push(update), + )? + .complete()?; + let progress = progress.last().context("squash progress is reported")?; + assert_eq!(progress.total, 2, "both squash source commits contribute to the total"); + assert_eq!(progress.processed, 2); + assert_eq!(progress.cherry_picked, 2, "both source trees are replayed eagerly"); + assert_eq!(progress.signed, 0, "the fixture has no signer configured"); + let combined = outcome.map(middle).context("the first commit is retained")?; + assert_eq!(outcome.map(tip), Some(combined), "both source IDs map to one commit"); + let commit = repo.find_commit(combined)?.decode()?.into_owned()?; + assert_eq!(commit.parents.as_slice(), [base]); + assert_eq!(commit.tree, tip_tree, "all source tree changes are present"); + assert_eq!(commit.author.name, b"author".as_bstr()); + assert_eq!(commit.committer.name, b"rebasing committer".as_bstr()); + assert_eq!( + crate::change_id::effective(combined, commit.extra_headers().find_all(crate::change_id::HEADER)), + gix::hash::ChangeId::from(middle), + "the primary picked commit supplies the squash identity" + ); + assert!(!has_marker(&commit), "squash is eager even without a checkout marker"); + assert_eq!( + commit.message, + format!("middle\n\n# {} tip\n\ntip\n", tip.to_hex_with_len(7)).as_bytes(), + "the source title permanently identifies the appended full message" + ); + assert_eq!(repo.head_id()?, combined, "the branch follows the combined tip"); + assert_eq!( + repo.find_reference("refs/patches/middle")?.id(), + combined, + "a ref on the first source follows the combined commit" + ); + assert_eq!( + git_note(&repo, combined)?.as_deref(), + Some(b"middle note\n\ntip note".as_slice()), + "squashed Git notes concatenate in source order" + ); + Ok(()) + } + + #[test] + fn squash_plan_moves_a_non_adjacent_source_into_its_target() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + std::fs::write(fixture.path().join("upper"), b"upper\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "upper"]) + .status()? + .success(), + "the extra source file is staged" + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-q", "-m", "upper"]) + .status()? + .success(), + "the non-adjacent squash source is committed" + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "-b", "side", "HEAD~2"]) + .status()? + .success(), + "a sibling branch starts at the squash target" + ); + std::fs::write(fixture.path().join("side"), b"side\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "side"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-q", "-m", "side"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "main"]) + .status()? + .success() + ); + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let target = repo.rev_parse_single("HEAD~2")?.detach(); + let intermediate = repo.rev_parse_single("HEAD~1")?.detach(); + let source = repo.head_id()?.detach(); + let side = repo.rev_parse_single("side")?.detach(); + + let plan = squash_plan(&repo, &graph, source, target)?; + assert_eq!(plan.steps.len(), 3, "the source becomes part of the target step"); + let step_for = |id| { + plan.steps + .iter() + .position(|step| step.commit == PlanCommit::Pick(id)) + .expect("the retained commit has a plan step") + }; + let target_step = step_for(target); + let intermediate_step = step_for(intermediate); + let side_step = step_for(side); + assert_eq!(plan.steps[target_step].squash, [source]); + assert_eq!(plan.steps[intermediate_step].parent, PlanParent::Step(target_step)); + assert_eq!(plan.steps[side_step].parent, PlanParent::Step(target_step)); + assert_eq!( + plan.checkout.as_ref().map(|checkout| checkout.target), + Some(PlanParent::Step(intermediate_step)), + "the attached tip follows the reordered stack" + ); + + let outcome = perform_plan(&repo, &graph, plan)?.complete()?; + let combined = outcome.map(target).context("the squash target is retained")?; + assert_eq!( + outcome.map(source), + Some(combined), + "the source maps to the combined commit" + ); + let rewritten_intermediate = outcome + .map(intermediate) + .context("the intermediate commit is retained")?; + assert_eq!( + repo.find_commit(rewritten_intermediate)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(combined), + "the intermediate commit remains above the combined result" + ); + assert_eq!( + repo.head_id()?, + rewritten_intermediate, + "the attached branch remains at the final reordered tip" + ); + let rewritten_side = outcome.map(side).context("the sibling branch is retained")?; + assert_eq!( + repo.find_commit(rewritten_side)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(combined), + "a sibling fork remains attached to the rewritten target" + ); + assert_eq!(repo.find_reference("refs/heads/side")?.id(), rewritten_side); + Ok(()) + } + + #[test] + fn stack_insert_moves_the_linear_range_and_reconnects_side_children() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + git(fixture.path(), &["checkout", "-q", "-b", "fork", "HEAD~1"])?; + std::fs::write(fixture.path().join("fork"), b"fork\n")?; + git(fixture.path(), &["add", "fork"])?; + git(fixture.path(), &["commit", "-q", "-m", "fork"])?; + git(fixture.path(), &["checkout", "-q", "-b", "destination", "main~2"])?; + std::fs::write(fixture.path().join("destination"), b"destination\n")?; + git(fixture.path(), &["add", "destination"])?; + git(fixture.path(), &["commit", "-q", "-m", "destination"])?; + std::fs::write(fixture.path().join("destination-child"), b"destination child\n")?; + git(fixture.path(), &["add", "destination-child"])?; + git(fixture.path(), &["commit", "-q", "-m", "destination child"])?; + git(fixture.path(), &["checkout", "-q", "main"])?; + + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let old_parent = repo.rev_parse_single("main~2")?.detach(); + let base = repo.rev_parse_single("main~1")?.detach(); + let head = repo.head_id()?.detach(); + let fork = repo.rev_parse_single("fork")?.detach(); + let target = repo.rev_parse_single("destination~1")?.detach(); + let target_child = repo.rev_parse_single("destination")?.detach(); + + let outcome = perform_plan(&repo, &graph, stack_insert_plan(&repo, &graph, base, head, target)?)?.complete()?; + let moved_base = outcome.map(base).context("the stack base is retained")?; + let moved_head = outcome.map(head).context("HEAD is retained")?; + let rewritten_fork = outcome.map(fork).context("the side child is retained")?; + let rewritten_target_child = outcome.map(target_child).context("the target child is retained")?; + let parent = |id| -> gix_testtools::Result<Option<ObjectId>> { + Ok(repo.find_commit(id)?.parent_ids().next().map(gix::Id::detach)) + }; + assert_eq!( + parent(moved_base)?, + Some(target), + "the stack is inserted above the target" + ); + assert_eq!( + parent(moved_head)?, + Some(moved_base), + "the stack keeps its internal ancestry" + ); + assert_eq!( + parent(rewritten_fork)?, + Some(old_parent), + "a non-stack child reconnects to its moved parent's original parent" + ); + assert_eq!( + parent(rewritten_target_child)?, + Some(moved_head), + "the target's former child follows the moved stack" + ); + assert_eq!(repo.head_id()?, moved_head, "checkout follows rewritten HEAD"); + for (name, expected) in [ + ("refs/patches/middle", moved_base), + ("refs/patches/tip", moved_head), + ("refs/heads/fork", rewritten_fork), + ("refs/heads/destination", rewritten_target_child), + ] { + assert_eq!( + repo.find_reference(name)?.id(), + expected, + "{name} follows its corresponding rewrite" + ); + } + Ok(()) + } + + #[test] + fn stack_insert_rejects_invalid_ranges_and_cycles() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + git(fixture.path(), &["checkout", "-q", "-b", "cycle-target"])?; + std::fs::write(fixture.path().join("cycle-target"), b"cycle target\n")?; + git(fixture.path(), &["add", "cycle-target"])?; + git(fixture.path(), &["commit", "-q", "-m", "cycle target"])?; + git(fixture.path(), &["checkout", "-q", "main"])?; + + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let root = repo.rev_parse_single("main~2")?.detach(); + let base = repo.rev_parse_single("main~1")?.detach(); + let head = repo.head_id()?.detach(); + let target = repo.rev_parse_single("cycle-target")?.detach(); + let err = stack_insert_plan(&repo, &graph, target, head, root) + .expect_err("the selected base must be in HEAD's ancestry"); + assert!(err.to_string().contains("ancestor"), "{err:#}"); + let err = stack_insert_plan(&repo, &graph, base, head, base) + .expect_err("the insertion target cannot be part of the stack"); + assert!(err.to_string().contains("moved stack"), "{err:#}"); + let err = stack_insert_plan(&repo, &graph, base, head, target) + .expect_err("inserting a stack into its own side descendant would cycle"); + assert!(err.to_string().contains("cycle"), "{err:#}"); + Ok(()) + } + + #[test] + fn copy_insert_preserves_the_source_and_inserts_above_target_children() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let source = repo.head_id()?.detach(); + drop(repo); + + git(fixture.path(), &["checkout", "-q", "--orphan", "destination"])?; + git(fixture.path(), &["rm", "-q", "-r", "-f", "."])?; + std::fs::write(fixture.path().join("destination"), b"destination\n")?; + git(fixture.path(), &["add", "destination"])?; + git(fixture.path(), &["commit", "-q", "-m", "destination"])?; + let target = open(fixture.path())?.head_id()?.detach(); + std::fs::write(fixture.path().join("destination-child"), b"child\n")?; + git(fixture.path(), &["add", "destination-child"])?; + git(fixture.path(), &["commit", "-q", "-m", "destination child"])?; + let target_child = open(fixture.path())?.head_id()?.detach(); + git(fixture.path(), &["checkout", "-q", "main"])?; + + let repo = open(fixture.path())?; + set_git_note(&repo, source, b"source note")?; + let graph = super::super::loaded_graph(&repo)?; + let outcome = perform_plan(&repo, &graph, copy_insert_plan(&repo, &graph, source, target)?)?.complete()?; + let rewritten_child = outcome.map(target_child).context("the target child is retained")?; + let copied = repo + .find_commit(rewritten_child)? + .parent_ids() + .next() + .map(gix::Id::detach) + .context("the rewritten target child has a parent")?; + + assert_eq!( + repo.find_commit(copied)?.parent_ids().next().map(gix::Id::detach), + Some(target) + ); + assert_eq!(outcome.selected, Some(copied), "copy-insert selects the new copy"); + assert!( + outcome.checkout_reference.is_none(), + "copy-insert detaches instead of moving the source branch" + ); + assert_eq!(outcome.map(source), Some(source), "copying does not rewrite the source"); + assert_eq!(repo.head_id()?, source, "copying elsewhere leaves HEAD at the source"); + assert_eq!(repo.find_reference("refs/heads/main")?.id(), source); + assert_eq!(repo.find_reference("refs/heads/destination")?.id(), rewritten_child); + assert_eq!(git_note(&repo, source)?.as_deref(), Some(b"source note".as_slice())); + assert_eq!(git_note(&repo, copied)?.as_deref(), Some(b"source note".as_slice())); + let repository_path = repo.git_dir().to_owned(); + drop(repo); + super::super::time_travel::checkout_plan(&repository_path, false, &outcome, &[], false)?; + let repo = open(fixture.path())?; + assert!( + repo.head()?.is_detached(), + "the destination copy becomes the detached checkout" + ); + assert_eq!(repo.head_id()?, copied); + assert_eq!(repo.find_reference("refs/heads/main")?.id(), source); + let pins = crate::history::all_pins(&repo)?; + assert_eq!(pins.len(), 1, "the source branch stays visible through a HEAD pin"); + assert_eq!( + pins[0].target.try_name().expect("the pin is symbolic"), + "refs/heads/main" + ); + Ok(()) + } + + #[test] + fn copy_insert_keeps_the_source_occurrence_above_its_current_parent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let target = repo.rev_parse_single("HEAD~1")?.detach(); + let source = repo.head_id()?.detach(); + + let outcome = perform_plan(&repo, &graph, copy_insert_plan(&repo, &graph, source, target)?)?.complete()?; + let copied = outcome + .selected + .context("copy-insert selects the inserted occurrence")?; + let retained_source = outcome.map(source).context("the source occurrence is retained")?; + assert_ne!(copied, retained_source, "copy and source remain separate occurrences"); + assert_eq!( + repo.find_commit(copied)?.parent_ids().next().map(gix::Id::detach), + Some(target) + ); + assert_eq!( + repo.find_reference("refs/heads/main")?.id(), + retained_source, + "the source branch follows the ordinary source rewrite" + ); + assert_eq!( + repo.find_commit(retained_source)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(copied), + "copying at the current parent creates two consecutive occurrences" + ); + Ok(()) + } + + #[test] + fn copy_insert_rejects_review_sources() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let target = repo.rev_parse_single("HEAD~2")?.detach(); + let old_source = repo.head_id()?.detach(); + let mut source = repo.find_commit(old_source)?.decode()?.into_owned()?; + source + .extra_headers + .push(("tix-rebase".into(), "onto refs/worktree/tix/review/1".into())); + let source = repo.write_object(&source)?.detach(); + repo.reference( + "refs/heads/main", + source, + PreviousValue::ExistingMustMatch(Target::Object(old_source)), + "prepare review source", + )?; + let graph = super::super::loaded_graph(&repo)?; + + let err = copy_insert_plan(&repo, &graph, source, target) + .expect_err("copying a review commit would duplicate its resource identity"); + assert!(err.to_string().contains("review commits cannot be copied"), "{err:#}"); + Ok(()) + } + + #[test] + fn a_conflicting_copy_insert_produces_a_materializable_continuation() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let target = repo.rev_parse_single("HEAD~2")?.detach(); + let source = repo.head_id()?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + + let PlanPerform::Conflict(conflict) = + perform_plan(&repo, &graph, copy_insert_plan(&repo, &graph, source, target)?)? + else { + return Err("copying the tip delta onto the root should conflict".into()); + }; + assert_eq!(conflict.original(), source); + assert!( + conflict + .continuation_plan() + .steps + .iter() + .any(|step| matches!(step.commit, PlanCommit::Resolved(_))), + "the ordinary todo continuation can resume copy-insert" + ); + drop(conflict); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "an unmaterialized copy conflict leaves the repository unchanged" + ); + Ok(()) + } + + #[test] + fn move_insert_places_head_before_every_target_child_and_keeps_its_refs() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + git(fixture.path(), &["checkout", "-q", "-b", "side", "HEAD~2"])?; + std::fs::write(fixture.path().join("side"), b"side\n")?; + git(fixture.path(), &["add", "side"])?; + git(fixture.path(), &["commit", "-q", "-m", "side"])?; + git(fixture.path(), &["checkout", "-q", "main"])?; + + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let target = repo.rev_parse_single("main~2")?.detach(); + let middle = repo.rev_parse_single("main~1")?.detach(); + let source = repo.head_id()?.detach(); + let side = repo.rev_parse_single("side")?.detach(); + set_git_note(&repo, source, b"source note")?; + + let outcome = perform_plan(&repo, &graph, move_insert_plan(&repo, &graph, source, target)?)?.complete()?; + let moved = outcome.map(source).context("HEAD is retained")?; + let rewritten_middle = outcome.map(middle).context("the old source ancestry is retained")?; + let rewritten_side = outcome.map(side).context("the target's side child is retained")?; + assert_eq!( + repo.find_commit(moved)?.parent_ids().next().map(gix::Id::detach), + Some(target), + "HEAD is inserted directly above the target" + ); + for (name, id) in [("middle", rewritten_middle), ("side", rewritten_side)] { + assert_eq!( + repo.find_commit(id)?.parent_ids().next().map(gix::Id::detach), + Some(moved), + "the target's {name} child follows moved HEAD" + ); + } + assert_eq!(repo.head_id()?, moved, "the attached branch follows HEAD itself"); + assert_eq!(repo.find_reference("refs/heads/side")?.id(), rewritten_side); + assert_eq!(git_note(&repo, moved)?.as_deref(), Some(b"source note".as_slice())); + Ok(()) + } + + #[test] + fn move_insert_can_cross_a_descendant_or_unrelated_history() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let middle = repo.rev_parse_single("main~1")?.detach(); + let tip = repo.head_id()?.detach(); + drop(repo); + git(fixture.path(), &["checkout", "-q", "--detach", &middle.to_string()])?; + + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("main~2")?.detach(); + let outcome = perform_plan(&repo, &graph, move_insert_plan(&repo, &graph, middle, tip)?)?.complete()?; + let moved = outcome.map(middle).context("detached HEAD is retained")?; + let rewritten_tip = outcome.map(tip).context("the descendant target is retained")?; + assert_eq!( + repo.find_commit(rewritten_tip)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(base), + "the target reconnects around HEAD's old position" + ); + assert_eq!( + repo.find_commit(moved)?.parent_ids().next().map(gix::Id::detach), + Some(rewritten_tip), + "HEAD is reinserted above its former descendant" + ); + let repository_path = repo.git_dir().to_owned(); + super::super::time_travel::checkout_plan(&repository_path, false, &outcome, &[], false)?; + let repo = open(fixture.path())?; + assert!(repo.head()?.is_detached(), "a detached checkout remains detached"); + assert_eq!(repo.head_id()?, moved); + drop(repo); + + git(fixture.path(), &["checkout", "-q", "main"])?; + git(fixture.path(), &["checkout", "-q", "--orphan", "other"])?; + git(fixture.path(), &["rm", "-q", "-r", "-f", "."])?; + std::fs::write(fixture.path().join("other"), b"other\n")?; + git(fixture.path(), &["add", "other"])?; + git(fixture.path(), &["commit", "-q", "-m", "other"])?; + let target = open(fixture.path())?.head_id()?.detach(); + git(fixture.path(), &["checkout", "-q", "main"])?; + let repo = open(fixture.path())?; + let source = repo.head_id()?.detach(); + let graph = super::super::loaded_graph(&repo)?; + let outcome = perform_plan(&repo, &graph, move_insert_plan(&repo, &graph, source, target)?)?.complete()?; + let moved = outcome.map(source).context("HEAD is retained across histories")?; + assert_eq!( + repo.find_commit(moved)?.parent_ids().next().map(gix::Id::detach), + Some(target), + "an unrelated target becomes HEAD's parent" + ); + assert_eq!(repo.head_id()?, moved); + Ok(()) + } + + #[test] + fn a_conflicting_move_insert_produces_a_materializable_continuation() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let target = repo.rev_parse_single("HEAD~2")?.detach(); + let source = repo.head_id()?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + + let PlanPerform::Conflict(conflict) = + perform_plan(&repo, &graph, move_insert_plan(&repo, &graph, source, target)?)? + else { + return Err("moving the tip delta onto the root should conflict".into()); + }; + assert_eq!(conflict.original(), source); + assert!( + conflict + .continuation_plan() + .steps + .iter() + .any(|step| matches!(step.commit, PlanCommit::Resolved(_))), + "the ordinary todo continuation can resume the move" + ); + drop(conflict); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "an unmaterialized move conflict leaves the repository unchanged" + ); + Ok(()) + } + + #[test] + fn move_insert_rejects_a_root_head() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let root = repo.rev_parse_single("main~2")?.detach(); + let target = repo.head_id()?.detach(); + drop(repo); + git(fixture.path(), &["checkout", "-q", "--detach", &root.to_string()])?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let err = move_insert_plan(&repo, &graph, root, target) + .expect_err("root HEAD cannot be removed from its old position"); + assert!(err.to_string().contains("exactly one parent"), "{err:#}"); + Ok(()) + } + + #[test] + fn a_squash_group_reports_one_configured_signature() -> gix_testtools::Result { + if !gix_testtools::signature::program_available("ssh-keygen") { + return Ok(()); + } + let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + [ + "user.name=rebasing committer".to_owned(), + "user.email=rebasing@example.com".to_owned(), + "gitoxide.commit.committerDate=2001-01-01T00:00:00 +0000".to_owned(), + "commit.gpgSign=true".to_owned(), + "gpg.format=ssh".to_owned(), + format!("user.signingKey={}", key.display()), + format!( + "gpg.ssh.allowedSignersFile={}", + gix_testtools::signature::fixture("ssh-allowed-signers").display() + ), + ], + )?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let mut updates = Vec::new(); + + let outcome = perform_plan_with_progress( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(middle), + squash: vec![tip], + }], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + |progress| updates.push(progress), + )? + .complete()?; + let progress = updates.last().context("signed progress is reported")?; + assert_eq!(progress.total, 2); + assert_eq!(progress.processed, 2); + assert_eq!(progress.cherry_picked, 2); + assert_eq!(progress.signed, 1, "the combined result is signed only once"); + assert!(progress.signing_time > Duration::ZERO, "signing time accumulates"); + assert!( + repo.find_commit(outcome.map(middle).context("the squash result is retained")?)? + .verify_signature()? + .expect("the squash result is signed") + .is_valid(), + "the reported signature is valid" + ); + Ok(()) + } + + #[test] + fn squash_messages_add_distinct_raw_authors_after_permanent_source_sections() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let mut first = repo.find_commit(base)?.decode()?.into_owned()?; + first.author.name = "Alice".into(); + first.author.email = "alice@example.com".into(); + first.message = "first title\n\nfirst body".into(); + + let mut bob = repo.find_commit(base)?.decode()?.into_owned()?; + bob.author.name = "Bob".into(); + bob.author.email = "bob@example.com".into(); + bob.message = "bob title\n\nbob body".into(); + let mut carol = repo.find_commit(middle)?.decode()?.into_owned()?; + carol.author.name = "Carol".into(); + carol.author.email = "carol@example.com".into(); + carol.message = "carol title\n\nCo-authored-by: Bob <bob@example.com>".into(); + let mut repeated_carol = repo.find_commit(tip)?.decode()?.into_owned()?; + repeated_carol.author = carol.author.clone(); + repeated_carol.message = "carol follow-up".into(); + + squash_message( + &repo, + &mut first, + &[ + (base, bob, Vec::new()), + (middle, carol, Vec::new()), + (tip, repeated_carol, Vec::new()), + ], + )?; + assert_eq!( + first.message, + format!( + "first title\n\nfirst body\n\n# {} bob title\n\nbob title\n\nbob body\n\n# {} carol title\n\ncarol title\n\nCo-authored-by: Bob <bob@example.com>\n\n# {} carol follow-up\n\ncarol follow-up\n\nCo-authored-by: Carol <carol@example.com>\n", + base.to_hex_with_len(7), + middle.to_hex_with_len(7), + tip.to_hex_with_len(7) + ) + .as_bytes(), + "existing trailers suppress duplicates and repeated raw authors appear once" + ); + Ok(()) + } + + #[test] + fn tip_refs_follow_the_primary_continuation_and_other_leaves_are_pinned() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + repo.reference( + "refs/custom/tip", + tip, + PreviousValue::MustNotExist, + "test custom tip reference", + )?; + + let outcome = perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![ + PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Pick(middle), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Empty("side".into()), + squash: Vec::new(), + }, + ], + checkout: Some(PlanCheckout { + target: PlanParent::Step(0), + reference: None, + }), + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + .complete()?; + let primary = outcome.map(middle).context("the primary leaf is retained")?; + for name in ["refs/heads/main", "refs/custom/tip"] { + assert_eq!( + repo.find_reference(name)?.id(), + primary, + "{name} follows the first continuation" + ); + } + let pins = crate::history::all_pins(&repo)?; + assert_eq!(pins.len(), 1, "the secondary leaf receives one pin"); + assert_eq!( + repo.find_commit(pins[0].id)?.parent_ids().next().map(gix::Id::detach), + outcome.map(tip), + "the pin retains the secondary fork" + ); + Ok(()) + } + + #[test] + fn dropping_a_referenced_tip_advances_its_refs_to_the_primary_leaf() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + + perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![ + PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(middle), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Empty("replacement tip".into()), + squash: Vec::new(), + }, + ], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + .complete()?; + assert_eq!( + repo.find_commit(repo.head_id()?)?.message_raw()?, + b"replacement tip", + "the branch advances past the retained ancestor" + ); + assert!( + !is_pending(&repo.find_commit(repo.head_id()?)?.decode()?.into_owned()?), + "the inferred checked-out replacement is fully replayed" + ); + assert!( + crate::history::all_pins(&repo)?.is_empty(), + "the referenced replacement leaf needs no pin" + ); + Ok(()) + } + + #[test] + fn a_plan_without_checkout_does_not_infer_detached_head() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let plan = Plan { + base, + scope: vec![middle, tip], + steps: vec![ + PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(middle), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Empty("replacement tip".into()), + squash: Vec::new(), + }, + ], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }; + assert_eq!( + infer_plan_checkout(&repo, &graph, &plan)?, + Some(PlanParent::Step(1)), + "an attached branch preserves its implicit checkout" + ); + + git(fixture.path(), &["checkout", "-q", "--detach", &tip.to_string()])?; + assert_eq!( + infer_plan_checkout(&repo, &graph, &plan)?, + None, + "a detached HEAD never supplies an implicit checkout" + ); + Ok(()) + } + + #[test] + fn a_todo_rebase_can_update_onto_a_hidden_branch_without_moving_it() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let mut hidden = repo.find_commit(base)?.decode()?.into_owned()?; + hidden.parents = [base].into_iter().collect(); + hidden.message = "updated hidden base".into(); + let onto = repo.write_object(&hidden)?.detach(); + repo.reference( + "refs/heads/hidden", + onto, + PreviousValue::MustNotExist, + "test hidden update target", + )?; + + let outcome = perform_plan( + &repo, + &graph, + Plan { + base: onto, + scope: vec![middle, tip], + steps: vec![ + PlanStep { + parent: PlanParent::Existing(onto), + commit: PlanCommit::Pick(middle), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }, + ], + checkout: Some(PlanCheckout { + target: PlanParent::Step(1), + reference: None, + }), + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + .complete()?; + let new_middle = outcome.map(middle).context("the middle commit is retained")?; + assert_eq!( + repo.find_commit(new_middle)?.parent_ids().next().map(gix::Id::detach), + Some(onto), + "the visible stack starts at the latest hidden branch tip" + ); + assert_eq!( + repo.find_reference("refs/heads/hidden")?.id(), + onto, + "the hidden branch itself remains untouched" + ); + Ok(()) + } + + #[test] + fn a_todo_rebase_uses_the_reference_snapshot_from_before_editing() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + set_git_note(&repo, tip, b"atomic note")?; + let notes_before = repo.find_reference("refs/notes/review")?.id().detach(); + let expected_refs = capture_refs(&repo, &[middle, tip], &[tip])?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", "refs/heads/main", &base.to_string(), &tip.to_string()]) + .status()? + .success(), + "the branch moves while the editor is open" + ); + + let err = match perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }], + checkout: None, + expected_refs, + }, + ) { + Ok(_) => return Err("the stale reference snapshot must fail its compare-and-swap".into()), + Err(err) => err, + }; + assert_eq!( + repo.find_reference("refs/heads/main")?.id(), + base, + "a concurrent branch update wins" + ); + assert_eq!( + repo.find_reference("refs/notes/review")?.id(), + notes_before, + "a failed branch transaction cannot partially advance the notes ref" + ); + assert!( + format!("{err:#}").contains("reference"), + "the failure identifies reference persistence: {err:#}" + ); + Ok(()) + } + + #[test] + fn checked_out_linked_worktree_refs_move_but_cannot_be_deleted() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let linked_root = gix_testtools::tempfile::tempdir()?; + let linked = linked_root.path().join("linked"); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q", "-b", "linked"]) + .arg(&linked) + .arg("HEAD") + .status()? + .success(), + "the linked worktree is created" + ); + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let steps = || { + vec![ + PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(middle), + squash: Vec::new(), + }, + PlanStep { + parent: PlanParent::Step(0), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }, + ] + }; + + let mut deleting = capture_refs(&repo, &[middle, tip], &[tip])?; + let linked_ref = deleting + .iter_mut() + .find(|reference| reference.name == "refs/heads/linked") + .expect("the linked branch is captured"); + linked_ref.new = None; + let err = match perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: steps(), + checkout: None, + expected_refs: deleting, + }, + ) { + Ok(_) => return Err("a checked-out linked branch was deleted".into()), + Err(err) => err, + }; + assert!( + format!("{err:#}").contains("another worktree has it checked out"), + "the refusal identifies the linked-worktree constraint" + ); + + let mut moving = capture_refs(&repo, &[middle, tip], &[tip])?; + let linked_ref = moving + .iter_mut() + .find(|reference| reference.name == "refs/heads/linked") + .expect("the linked branch is captured"); + linked_ref.new = None; + linked_ref.placement = Some(PlanParent::Step(0)); + let outcome = perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: steps(), + checkout: None, + expected_refs: moving, + }, + )? + .complete()?; + assert_eq!( + repo.find_reference("refs/heads/linked")?.id(), + outcome.map(middle).expect("the middle commit is retained"), + "the linked branch moves to its explicit todo destination" + ); + assert!( + linked.join("middle").is_file(), + "the linked worktree receives the new tree" + ); + assert!( + !linked.join("tip").exists(), + "files beyond the moved branch are removed" + ); + Ok(()) + } + + #[test] + fn dropping_a_pick_moves_its_mutable_refs_to_the_nearest_retained_ancestor() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + repo.reference( + "refs/patches/example", + middle, + gix::refs::transaction::PreviousValue::MustNotExist, + "test reference", + )?; + let expected_refs = capture_refs(&repo, &[middle, tip], &[tip])?; + + perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }], + checkout: None, + expected_refs, + }, + )? + .complete()?; + assert_eq!( + repo.find_reference("refs/patches/example")?.id(), + base, + "the dropped commit's ref does not keep the old history alive" + ); + Ok(()) + } + + #[test] + fn dropping_a_stashed_commit_is_rejected_before_refs_change() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let saved = repo.write_blob(b"saved worktree state")?.detach(); + let name = super::super::stash::reference(middle)?; + repo.reference(name.clone(), saved, PreviousValue::MustNotExist, "test stash")?; + + let err = match perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + ) { + Ok(_) => panic!("dropping a commit with saved state must fail before producing an outcome"), + Err(err) => err, + }; + assert!(err.to_string().contains("cannot drop stashed commit")); + assert_eq!(repo.head_id()?, tip, "HEAD remains at the original tip"); + assert_eq!( + repo.find_reference(name.as_ref())?.id(), + saved, + "the stash association remains unchanged" + ); + Ok(()) + } + + #[test] + fn dropping_a_review_commit_removes_all_review_resources() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let old_middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + let mut middle = repo.find_commit(old_middle)?.decode()?.into_owned()?; + middle + .extra_headers + .push(("tix-rebase".into(), "onto refs/worktree/tix/review/1".into())); + let middle = repo.write_object(&middle)?.detach(); + let mut tip = repo.find_commit(old_tip)?.decode()?.into_owned()?; + tip.parents = [middle].into_iter().collect(); + let tip = repo.write_object(&tip)?.detach(); + repo.reference( + "refs/worktree/tix/review/1", + old_middle, + PreviousValue::MustNotExist, + "test review resource", + )?; + repo.reference( + "refs/worktree/tix/review/stashes/1", + old_tip, + PreviousValue::MustNotExist, + "test review stash resource", + )?; + repo.reference( + "refs/heads/main", + tip, + PreviousValue::ExistingMustMatch(Target::Object(old_tip)), + "prepare review history", + )?; + let graph = super::super::loaded_graph(&repo)?; + + perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: Vec::new(), + }], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + .complete()?; + assert!( + repo.try_find_reference("refs/worktree/tix/review/1")?.is_none(), + "dropping the review commit removes its associated resource" + ); + assert!( + repo.try_find_reference("refs/worktree/tix/review/stashes/1")?.is_none(), + "dropping the review commit also removes its saved worktree state" + ); + Ok(()) + } + + #[test] + fn squashing_a_later_review_commit_removes_its_resources() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let old_middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + let old_tip_tree = repo.find_commit(old_tip)?.tree_id()?.detach(); + let mut middle = repo.find_commit(old_middle)?.decode()?.into_owned()?; + middle + .extra_headers + .push(("tix-rebase".into(), "onto refs/worktree/tix/review/1".into())); + let middle = repo.write_object(&middle)?.detach(); + let mut tip = repo.find_commit(old_tip)?.decode()?.into_owned()?; + tip.parents = [middle].into_iter().collect(); + let tip = repo.write_object(&tip)?.detach(); + repo.reference( + "refs/worktree/tix/review/1", + old_middle, + PreviousValue::MustNotExist, + "test review resource", + )?; + repo.reference( + "refs/worktree/tix/review/stashes/1", + old_tip, + PreviousValue::MustNotExist, + "test review stash resource", + )?; + repo.reference( + "refs/heads/main", + tip, + PreviousValue::ExistingMustMatch(Target::Object(old_tip)), + "prepare review history", + )?; + let graph = super::super::loaded_graph(&repo)?; + + let outcome = perform_plan( + &repo, + &graph, + Plan { + base, + scope: vec![middle, tip], + steps: vec![PlanStep { + parent: PlanParent::Existing(base), + commit: PlanCommit::Pick(tip), + squash: vec![middle], + }], + checkout: None, + expected_refs: capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + .complete()?; + let combined = outcome.map(tip).context("the first member remains")?; + assert_eq!(outcome.map(middle), Some(combined)); + assert_eq!( + repo.find_commit(combined)?.tree_id()?, + old_tip_tree, + "reordered source deltas are composed into the combined tree" + ); + assert!( + repo.try_find_reference("refs/worktree/tix/review/1")?.is_none(), + "the consumed review resource is removed" + ); + assert!( + repo.try_find_reference("refs/worktree/tix/review/stashes/1")?.is_none(), + "the consumed review stash is removed" + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/review.rs b/gix-tix/src/edit/review.rs new file mode 100644 index 00000000000..462050cf62a --- /dev/null +++ b/gix-tix/src/edit/review.rs @@ -0,0 +1,844 @@ +use std::{path::Path, process::Command}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BStr, BString, ByteSlice}, + refs::Target, +}; + +use crate::{history, open_repository}; + +const HEADER: &[u8] = b"tix-rebase"; +const ONTO: &[u8] = b"onto "; +pub(super) const RETURN_TO: &[u8] = b"tix-review-return-to"; + +#[derive(Debug)] +pub(crate) struct Started { + pub commit: ObjectId, + pub reference: gix::refs::FullName, +} + +pub(crate) struct Finished { + pub commit: ObjectId, + pub outcome: super::rebase::Outcome, +} + +pub(crate) enum Finish { + Complete(Finished), + Conflict(super::rebase::Conflict), + SelectReturn { tip: ObjectId }, +} + +pub(crate) fn reference(commit: &gix::objs::Commit) -> Result<Option<gix::refs::FullName>> { + commit + .extra_headers + .iter() + .find_map(|(name, value)| { + (name.as_slice() == HEADER) + .then(|| value.as_slice().strip_prefix(ONTO)) + .flatten() + }) + .map(|name| { + if history::review_number(name.as_bstr()).is_none() { + anyhow::bail!("review commit names an invalid review reference"); + } + BString::from(name) + .try_into() + .context("review commit names an invalid reference") + }) + .transpose() +} + +pub(crate) fn is_review(commit: &gix::objs::Commit) -> bool { + reference(commit).ok().flatten().is_some() +} + +pub(super) fn return_to(commit: &gix::objs::Commit) -> Result<Option<gix::refs::FullName>> { + commit + .extra_headers + .iter() + .find(|(name, _)| name.as_slice() == RETURN_TO) + .map(|(_, value)| { + gix::refs::FullName::try_from(value.clone()).context("review commit names an invalid return reference") + }) + .transpose() +} + +pub(super) fn deletions( + repo: &gix::Repository, + commit: &gix::objs::Commit, +) -> Result<Vec<(gix::refs::FullName, gix::refs::Target)>> { + let Some(name) = reference(commit)? else { + return Ok(Vec::new()); + }; + resources(repo, name) +} + +pub(super) fn resources( + repo: &gix::Repository, + name: gix::refs::FullName, +) -> Result<Vec<(gix::refs::FullName, gix::refs::Target)>> { + let stash = stash_reference(name.as_bstr())?; + let mut out = Vec::new(); + for name in [name, stash] { + if let Some(reference) = repo.try_find_reference(name.as_ref())? { + out.push((name, reference.target().into_owned())); + } + } + Ok(out) +} + +pub(super) fn stash_reference(review: &BStr) -> Result<gix::refs::FullName> { + let number = history::review_number(review).context("review reference has no numeric identity")?; + format!( + "{}{}", + String::from_utf8_lossy(history::REVIEW_STASH_PREFIX), + number.to_str_lossy() + ) + .try_into() + .context("generated an invalid review stash reference") +} + +#[tracing::instrument(skip_all, fields(%tip, %base))] +pub(crate) fn start( + repository_path: &Path, + bare: bool, + graph: &history::HistoryGraph, + tip: ObjectId, + base: ObjectId, +) -> Result<Started> { + let repo = open_repository(repository_path, bare, false).context("could not open repository to start review")?; + let workdir = repo.workdir().context("review requires a worktree")?.to_owned(); + let head = repo.head().context("could not read HEAD before review")?; + let restore = ( + head.referent_name().map(ToOwned::to_owned), + head.id().map(gix::Id::detach), + ); + if tip == base || !graph.is_ancestor(base, tip) { + anyhow::bail!("the review base must be an ancestor of the reviewed commit"); + } + for (label, id) in [("reviewed commit", tip), ("review base", base)] { + let commit = repo + .find_commit(id) + .with_context(|| format!("could not find {label}"))? + .decode()? + .into_owned()?; + if super::rebase::is_pending(&commit) { + anyhow::bail!("{label} has a pending rebase"); + } + } + ensure_clean(&workdir)?; + + let departure_pin = match restore.1 { + Some(id) => { + let target = restore.0.clone().map_or(Target::Object(id), Target::Symbolic); + Some(( + super::time_travel::create_pin(&repo, target, id, "tix review departure")?, + true, + )) + } + None => None, + }; + + let name = next_reference(&repo)?; + let mut commit = gix::objs::Commit { + tree: repo.find_commit(base)?.tree_id()?.detach(), + parents: [base].into_iter().collect(), + author: repo + .author() + .context("no Git author is configured")? + .context("could not resolve the Git author")? + .to_owned()?, + committer: repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned()?, + encoding: None, + message: "review".into(), + extra_headers: Vec::new(), + }; + commit + .extra_headers + .push((HEADER.into(), format!("onto {name}").into())); + let return_to = departure_pin.as_ref().map(|(pin, _)| pin.name.clone()); + if let Some(return_to) = return_to { + commit + .extra_headers + .push((RETURN_TO.into(), return_to.as_bstr().to_owned())); + } + let id = repo + .write_object(&commit) + .context("could not write review commit")? + .detach(); + drop(repo); + + if let Err(err) = git(&workdir, ["checkout", "--quiet", "--detach", &tip.to_string()]) { + remove_new_departure_pin(repository_path, bare, departure_pin.as_ref())?; + return Err(err.context("could not check out the reviewed commit")); + } + let review_name = name.as_bstr().to_str_lossy(); + let create_ref = git(&workdir, ["update-ref", review_name.as_ref(), &tip.to_string()]); + if let Err(err) = create_ref { + restore_checkout(&workdir, &restore)?; + remove_new_departure_pin(repository_path, bare, departure_pin.as_ref())?; + return Err(err.context("could not create review reference")); + } + if let Err(err) = git( + &workdir, + ["update-ref", "--no-deref", "HEAD", &id.to_string(), &tip.to_string()], + ) { + let _ = git(&workdir, ["update-ref", "--no-deref", "-d", review_name.as_ref()]); + restore_checkout(&workdir, &restore)?; + remove_new_departure_pin(repository_path, bare, departure_pin.as_ref())?; + return Err(err.context("could not attach the worktree to the review commit")); + } + if let Err(err) = git(&workdir, ["read-tree", &id.to_string()]) { + let _ = git( + &workdir, + ["update-ref", "--no-deref", "HEAD", &tip.to_string(), &id.to_string()], + ); + let _ = git(&workdir, ["update-ref", "--no-deref", "-d", review_name.as_ref()]); + restore_checkout(&workdir, &restore)?; + remove_new_departure_pin(repository_path, bare, departure_pin.as_ref())?; + return Err(err.context("could not reset the index to the review base")); + } + Ok(Started { + commit: id, + reference: name, + }) +} + +#[tracing::instrument(skip_all, fields(%review))] +#[cfg(test)] +pub(crate) fn finish( + repo: gix::Repository, + graph: &history::HistoryGraph, + review: ObjectId, + fallback: Option<ObjectId>, +) -> Result<Finish> { + finish_with_progress(repo, graph, review, fallback, |_| {}) +} + +pub(crate) fn finish_with_progress( + repo: gix::Repository, + graph: &history::HistoryGraph, + review: ObjectId, + fallback: Option<ObjectId>, + report: impl FnMut(super::rebase::Progress), +) -> Result<Finish> { + let workdir = repo + .workdir() + .context("finishing review requires a worktree")? + .to_owned(); + let head = repo.head_id()?.detach(); + if !graph.is_ancestor(review, head) { + anyhow::bail!("HEAD must be the review commit or one of its successors before it can be finished"); + } + ensure_clean(&workdir)?; + let commit = repo.find_commit(review)?.decode()?.into_owned()?; + let review_ref = reference(&commit)?.context("the selected commit is not an active review")?; + let base = commit + .parents + .first() + .copied() + .context("a review commit must have a base")?; + let mut reference = repo + .find_reference(review_ref.as_ref()) + .context("the review reference is missing")?; + let legacy_reattach = reference.target().try_name().map(ToOwned::to_owned); + let tip = reference + .peel_to_id() + .context("the review reference does not resolve")? + .detach(); + let delete_refs = resources(&repo, review_ref.clone())?; + let return_name = return_to(&commit)?.or(legacy_reattach); + let has_return = return_name.is_some(); + let checkout = if let Some(id) = fallback { + if !graph.is_ancestor(tip, id) { + anyhow::bail!("the selected review return commit does not descend from the reviewed commit"); + } + Some((id, None)) + } else { + return_name + .map(|name| { + let Some(mut reference) = repo.try_find_reference(name.as_ref())? else { + return Ok(None); + }; + let checkout_reference = if name.as_bstr().starts_with(history::PIN_PREFIX) { + reference.target().try_name().map(ToOwned::to_owned) + } else { + Some(name) + }; + let id = reference + .peel_to_id() + .context("the review return reference does not resolve")? + .detach(); + if !graph.is_ancestor(tip, id) { + anyhow::bail!("the review return reference no longer descends from the reviewed commit"); + } + Ok(Some((id, checkout_reference))) + }) + .transpose()? + .flatten() + }; + if fallback.is_none() && has_return && checkout.is_none() { + return Ok(Finish::SelectReturn { tip }); + } + for (label, id) in [("reviewed commit", tip), ("review base", base)] { + let endpoint = repo.find_commit(id)?.decode()?.into_owned()?; + if super::rebase::is_pending(&endpoint) { + anyhow::bail!("{label} has a pending rebase"); + } + } + match super::rebase::finish_review_with_progress( + &repo, + graph, + review, + tip, + review_ref, + delete_refs, + checkout, + report, + )? { + super::rebase::Perform::Complete(outcome) => { + let finished = outcome + .map(review) + .context("finishing review did not produce a commit")?; + Ok(Finish::Complete(Finished { + commit: finished, + outcome, + })) + } + super::rebase::Perform::Conflict(conflict) => Ok(Finish::Conflict(conflict)), + } +} + +pub(super) fn ensure_clean(workdir: &Path) -> Result<()> { + if is_dirty(workdir)? { + anyhow::bail!("review requires a clean index and worktree"); + } + Ok(()) +} + +pub(super) fn is_dirty(workdir: &Path) -> Result<bool> { + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .output() + .context("could not inspect worktree status")?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + Ok(!output.stdout.is_empty()) +} + +fn next_reference(repo: &gix::Repository) -> Result<gix::refs::FullName> { + for number in 1_u64.. { + let name: gix::refs::FullName = format!("{}{number}", String::from_utf8_lossy(history::REVIEW_PREFIX)) + .try_into() + .context("generated an invalid review reference")?; + if repo.try_find_reference(name.as_ref())?.is_none() { + return Ok(name); + } + } + unreachable!("u64 review numbers cannot be exhausted") +} + +fn git<const N: usize>(workdir: &Path, args: [&str; N]) -> Result<()> { + let output = Command::new("git").arg("-C").arg(workdir).args(args).output()?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim()) + } +} + +fn remove_new_departure_pin(repository_path: &Path, bare: bool, pin: Option<&(history::Pin, bool)>) -> Result<()> { + let Some((pin, true)) = pin else { return Ok(()) }; + let repo = + open_repository(repository_path, bare, false).context("could not reopen repository to remove review pin")?; + super::time_travel::delete_pin(&repo, pin).context("could not remove review departure pin") +} + +fn restore_checkout(workdir: &Path, restore: &(Option<gix::refs::FullName>, Option<ObjectId>)) -> Result<()> { + let mut command = Command::new("git"); + command.arg("-C").arg(workdir).args(["checkout", "--quiet", "--force"]); + match restore { + (Some(name), _) => { + let name = gix::path::from_bstr(name.as_bstr()); + command.arg(name.as_ref()); + } + (None, Some(id)) => { + command.args(["--detach", &id.to_string()]); + } + (None, None) => anyhow::bail!("cannot restore an unborn checkout after review setup failed"), + } + let output = command + .output() + .context("could not restore checkout after review setup failed")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00 +0000") + .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00 +0000") + .output()?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) + } + + #[test] + fn starts_review_with_base_index_and_tip_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + run(fixture.path(), &["init", "-q", "-b", "main"])?; + run(fixture.path(), &["config", "user.name", "reviewer"])?; + run(fixture.path(), &["config", "user.email", "reviewer@example.com"])?; + std::fs::write(fixture.path().join("file"), "base\n")?; + run(fixture.path(), &["add", "file"])?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "base"], + )?; + let base = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + std::fs::write(fixture.path().join("file"), "tip\n")?; + run(fixture.path(), &["-c", "commit.gpgSign=false", "commit", "-qam", "tip"])?; + let tip = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + std::fs::write(fixture.path().join("natural"), "natural\n")?; + run(fixture.path(), &["add", "natural"])?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "natural descendant"], + )?; + run(fixture.path(), &["branch", "natural"])?; + run(fixture.path(), &["reset", "--hard", &tip.to_string()])?; + + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + )?; + let graph = super::super::loaded_graph(&repo)?; + drop(repo); + let started = start(fixture.path(), false, &graph, tip, base)?; + + let repo = crate::test_repository::open(fixture.path())?; + assert_eq!(repo.head_id()?, started.commit, "HEAD selects the review commit"); + assert_eq!( + repo.find_commit(started.commit)?.tree_id()?, + repo.find_commit(base)?.tree_id()? + ); + let review_ref = repo.find_reference(started.reference.as_ref())?; + assert_eq!( + review_ref.id(), + tip, + "the review resource remains anchored to the reviewed commit" + ); + let commit = repo.find_commit(started.commit)?.decode()?.into_owned()?; + assert_eq!(reference(&commit)?, Some(started.reference.clone())); + let pins = history::all_pins(&repo)?; + assert_eq!(pins.len(), 1, "review start preserves its departure with a pin"); + assert_eq!(pins[0].id, tip); + assert_eq!( + pins[0].target.try_name().expect("the pin is symbolic"), + "refs/heads/main", + "an attached departure uses a symbolic pin" + ); + assert_eq!( + return_to(&commit)?.expect("the review records a return"), + pins[0].name, + "the review commit records its departure pin" + ); + assert_eq!( + std::fs::read(fixture.path().join("file"))?, + b"tip\n", + "reviewed content stays in worktree" + ); + assert_eq!( + run(fixture.path(), &["diff", "--name-only"])?, + b"file\n", + "the reviewed change is unstaged" + ); + assert!(run(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty()); + + run(fixture.path(), &["add", "file"])?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + )?; + let graph = super::super::loaded_graph(&repo)?; + let amended = super::super::head::perform(repo, &graph, super::super::head::Kind::Amend, None)? + .expect("staging the reviewed delta amends it into the review commit"); + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + )?; + let amended_commit = repo.find_commit(amended)?.decode()?.into_owned()?; + assert!(is_review(&amended_commit)); + assert_eq!( + return_to(&amended_commit)?.expect("the review records a return"), + pins[0].name, + "review amendments preserve the return action" + ); + assert!(run(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?.is_empty()); + let child = run( + fixture.path(), + &[ + "-c", + "commit.gpgSign=false", + "commit-tree", + &format!("{amended}^{{tree}}"), + "-p", + &amended.to_string(), + "-m", + "review child", + ], + )?; + let child = ObjectId::from_hex(child.trim())?; + run( + fixture.path(), + &["update-ref", "refs/heads/review-child", &child.to_string()], + )?; + let stash_ref = stash_reference(started.reference.as_bstr())?; + run( + fixture.path(), + &[ + "update-ref", + stash_ref.as_bstr().to_str_lossy().as_ref(), + &tip.to_string(), + ], + )?; + drop(repo); + run( + fixture.path(), + &[ + "update-ref", + "--no-deref", + "-d", + pins[0].name.as_bstr().to_str_lossy().as_ref(), + ], + )?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + )?; + let graph = super::super::loaded_graph(&repo)?; + let Finish::SelectReturn { tip: return_tip } = finish(repo, &graph, amended, None)? else { + panic!("the deleted return pin requires replacement selection") + }; + assert_eq!(return_tip, tip, "fallback checkout must descend from the reviewed tip"); + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + )?; + assert!( + repo.try_find_reference(started.reference.as_ref())?.is_some(), + "asking for a replacement leaves the review untouched" + ); + let Finish::Complete(finished) = finish(repo, &graph, amended, Some(tip))? else { + panic!("the selected descendant completes the review") + }; + assert_eq!( + finished + .outcome + .checkout_reference + .as_ref() + .map(gix::refs::FullName::as_bstr), + None, + "a selected replacement commit is checked out detached" + ); + assert_eq!( + finished.outcome.selected, + Some(finished.commit), + "the reviewed tip maps to the newly finished review commit" + ); + super::super::time_travel::checkout_plan(fixture.path(), false, &finished.outcome, &[], false)?; + let finished = finished.commit; + let repo = crate::test_repository::open(fixture.path())?; + assert_eq!(repo.head_id()?, finished); + assert_eq!( + repo.head()?.referent_name().map(gix::refs::FullNameRef::as_bstr), + None, + "replacement commit selection deliberately leaves HEAD detached" + ); + assert_eq!( + repo.find_commit(finished)?.parent_ids().next().map(gix::Id::detach), + Some(tip) + ); + assert!(!is_review(&repo.find_commit(finished)?.decode()?.into_owned()?)); + assert!( + return_to(&repo.find_commit(finished)?.decode()?.into_owned()?)?.is_none(), + "finished commits contain no review return action" + ); + let child = repo.find_reference("refs/heads/review-child")?.id().detach(); + assert_eq!( + repo.find_commit(child)?.parent_ids().next().map(gix::Id::detach), + Some(finished) + ); + let natural = repo.find_reference("refs/heads/natural")?.id().detach(); + assert_eq!( + repo.find_commit(natural)?.parent_ids().next().map(gix::Id::detach), + Some(child), + "the natural descendants follow the review side's single leaf" + ); + assert!(super::super::rebase::has_marker( + &repo.find_commit(natural)?.decode()?.into_owned()? + )); + assert!( + repo.try_find_reference(started.reference.as_ref())?.is_none(), + "finishing removes the review resource" + ); + assert!( + repo.try_find_reference(stash_ref.as_ref())?.is_none(), + "finishing also removes saved review worktree state" + ); + Ok(()) + } + + #[test] + fn a_changed_review_and_its_successors_are_spliced_before_target_successors() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + run(fixture.path(), &["init", "-q", "-b", "main"])?; + crate::test_repository::disable_autocrlf(fixture.path())?; + run(fixture.path(), &["config", "user.name", "reviewer"])?; + run(fixture.path(), &["config", "user.email", "reviewer@example.com"])?; + run( + fixture.path(), + &["config", "gitoxide.commit.authorDate", "2001-01-01T00:00:00 +0000"], + )?; + run( + fixture.path(), + &["config", "gitoxide.commit.committerDate", "2001-01-01T00:00:00 +0000"], + )?; + std::fs::write(fixture.path().join("file"), "base\n")?; + run(fixture.path(), &["add", "file"])?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "base"], + )?; + let base = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + std::fs::write(fixture.path().join("file"), "B\n")?; + run(fixture.path(), &["-c", "commit.gpgSign=false", "commit", "-qam", "B"])?; + let reviewed = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + std::fs::write(fixture.path().join("successor"), "A\n")?; + run(fixture.path(), &["add", "successor"])?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "A"], + )?; + let old_successor = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + + let open = || { + crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + ) + }; + let repo = open()?; + let graph = super::super::loaded_graph(&repo)?; + drop(repo); + start(fixture.path(), false, &graph, reviewed, base)?; + let repo = open()?; + let pins = history::all_pins(&repo)?; + assert_eq!(pins.len(), 1, "review start preserves the checked-out descendant tip"); + assert_eq!(pins[0].id, old_successor); + assert_eq!( + pins[0].target.try_name().expect("the pin is symbolic"), + "refs/heads/main", + "an attached review departure remains attached through a symbolic pin" + ); + drop(repo); + + std::fs::write(fixture.path().join("reviewed"), "new review change\n")?; + run(fixture.path(), &["add", "file", "reviewed"])?; + let repo = open()?; + let graph = super::super::loaded_graph(&repo)?; + let review = super::super::head::perform(repo, &graph, super::super::head::Kind::Amend, None)? + .expect("the staged review change amends the review commit"); + let review_successor = ObjectId::from_hex( + run( + fixture.path(), + &[ + "-c", + "commit.gpgSign=false", + "commit-tree", + &format!("{review}^{{tree}}"), + "-p", + &review.to_string(), + "-m", + "review successor", + ], + )? + .trim(), + )?; + run( + fixture.path(), + &[ + "update-ref", + "refs/heads/review-successor", + &review_successor.to_string(), + ], + )?; + run(fixture.path(), &["checkout", "--detach", &review_successor.to_string()])?; + + let repo = open()?; + let graph = super::super::loaded_graph(&repo)?; + let Finish::Complete(finished) = finish(repo, &graph, review, None)? else { + panic!("the recorded return pin exists") + }; + super::super::time_travel::checkout_plan(fixture.path(), false, &finished.outcome, &[], false)?; + let finished = finished.commit; + let repo = open()?; + let successor = repo.find_reference("refs/heads/main")?.id().detach(); + assert_eq!( + repo.head()?.referent_name().expect("HEAD is attached"), + "refs/heads/main", + "finishing returns to the branch that contained the reviewed commit" + ); + assert_eq!(repo.head_id()?, successor); + assert!( + history::all_pins(&repo)?.is_empty(), + "returning consumes the departure pin" + ); + assert!( + run(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?.is_empty(), + "the restored branch has a matching index and worktree" + ); + assert_ne!(successor, old_successor, "the branch successor is rewritten"); + assert_eq!( + repo.find_commit(finished)?.parent_ids().next().map(gix::Id::detach), + Some(reviewed), + "the finished review is inserted directly after B" + ); + assert_eq!( + repo.find_commit(successor)?.parent_ids().next().map(gix::Id::detach), + Some(repo.find_reference("refs/heads/review-successor")?.id().detach()), + "A remains the branch tip and follows the review-side history" + ); + let review_successor = repo.find_reference("refs/heads/review-successor")?.id().detach(); + assert_eq!( + repo.find_commit(review_successor)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(finished), + "the review successor is inserted before the target history successor" + ); + assert_eq!( + repo.find_commit(successor)?.message_raw()?, + b"A\n".as_bstr(), + "the target history successor is retained" + ); + assert!( + !super::super::rebase::is_pending(&repo.find_commit(successor)?.decode()?.into_owned()?), + "the checked-out review return path is fully replayed" + ); + crate::test_repository::clear_autocrlf(fixture.path())?; + insta::assert_snapshot!( + "changed-review-with-successors", + gix_testtools::repository::snapshot_portable(fixture.path())?.to_string() + ); + Ok(()) + } + + #[test] + fn deleting_a_review_returns_to_the_preserved_departure() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + run(fixture.path(), &["init", "-q", "-b", "main"])?; + run(fixture.path(), &["config", "user.name", "reviewer"])?; + run(fixture.path(), &["config", "user.email", "reviewer@example.com"])?; + std::fs::write(fixture.path().join("file"), "base\n")?; + run(fixture.path(), &["add", "file"])?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "base"], + )?; + let base = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + std::fs::write(fixture.path().join("file"), "reviewed\n")?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-qam", "reviewed"], + )?; + let reviewed = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + std::fs::write(fixture.path().join("tip"), "tip\n")?; + run(fixture.path(), &["add", "tip"])?; + run( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "tip"], + )?; + let tip = ObjectId::from_hex(run(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=reviewer", "user.email=reviewer@example.com"], + )?; + let graph = super::super::loaded_graph(&repo)?; + drop(repo); + let started = start(fixture.path(), false, &graph, reviewed, base)?; + let repo = crate::test_repository::open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let forgotten = super::super::forget::perform(repo, &graph, started.commit)?; + let return_to = forgotten + .review_return + .context("review deletion has a return checkout")?; + let (returned, _) = + super::super::time_travel::checkout_review_return(fixture.path(), false, &return_to, &[], false)?; + + let repo = crate::test_repository::open(fixture.path())?; + assert_eq!(returned, tip); + assert_eq!(repo.head_id()?, tip); + assert_eq!( + repo.head()?.referent_name().expect("HEAD is attached"), + "refs/heads/main", + "cancelling reattaches the original branch" + ); + assert!(history::all_pins(&repo)?.is_empty(), "the return consumes its pin"); + assert!( + repo.try_find_reference(started.reference.as_ref())?.is_none(), + "the cancelled review resource is removed" + ); + assert!( + run(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?.is_empty(), + "cancelling restores the original checkout without review changes" + ); + + drop(repo); + run(fixture.path(), &["checkout", "-q", "--detach", &tip.to_string()])?; + let repo = crate::test_repository::open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + drop(repo); + let started = start(fixture.path(), false, &graph, reviewed, base)?; + let repo = crate::test_repository::open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let forgotten = super::super::forget::perform(repo, &graph, started.commit)?; + let return_to = forgotten + .review_return + .context("detached review deletion has a return checkout")?; + super::super::time_travel::checkout_review_return(fixture.path(), false, &return_to, &[], false)?; + let repo = crate::test_repository::open(fixture.path())?; + assert!(repo.head()?.is_detached(), "cancelling restores detached HEAD"); + assert_eq!(repo.head_id()?, tip); + assert!(history::all_pins(&repo)?.is_empty()); + Ok(()) + } +} diff --git a/gix-tix/src/edit/reword.rs b/gix-tix/src/edit/reword.rs new file mode 100644 index 00000000000..13cd9622c4c --- /dev/null +++ b/gix-tix/src/edit/reword.rs @@ -0,0 +1,965 @@ +use anyhow::{Context, Result}; +use gix::bstr::{BString, ByteSlice}; + +use super::rebase; + +const AUTHOR: &[u8] = b"Author: "; +const AUTHOR_DATE: &[u8] = b"AuthorDate: "; +const COMMITTER: &[u8] = b"Committer: "; +const COMMITTER_DATE: &[u8] = b"CommitterDate: "; +const COMMENT_CHAR: &[u8] = b"CommentChar: "; +const MESSAGE: &[u8] = b"Message:"; +const TODO: &[u8] = b"Todo"; +pub(super) const DEFAULT_COMMENT_CHAR: &[u8] = b";"; +pub(super) const ASSISTED_BY: &[u8] = b"Assisted-by: GPT 5.6"; +pub(super) const CO_AUTHORED_BY: &[u8] = b"Co-authored-by: GPT 5.6 <codex@openai.com>"; + +pub(super) struct Edit<'a> { + pub author: &'a [u8], + pub author_time: gix::date::Time, + pub committer: &'a [u8], + pub committer_time: gix::date::Time, + pub message: BString, + pub enrichment: crate::enrich::Headers, +} + +pub(crate) struct Outcome { + pub target: gix::ObjectId, + pub commit: Option<gix::ObjectId>, + pub enrichment: Option<crate::enrich::Enrichment>, + pub ref_rewrites: Vec<rebase::RefRewrite>, + pub ref_changes: Vec<super::undo::RefChange>, +} + +pub(crate) enum Perform { + Complete(Outcome), + Conflict(rebase::Conflict), +} + +impl Perform { + fn complete(self) -> Result<Outcome> { + match self { + Perform::Complete(outcome) => Ok(outcome), + Perform::Conflict(_) => anyhow::bail!("rewording the commit would cause a merge conflict"), + } + } +} + +pub(crate) fn relocate_after_editor( + repo: &gix::Repository, + revisions: &[std::ffi::OsString], + hidden_revisions: &[std::ffi::OsString], + change_id: gix::hash::ChangeId, +) -> Result<(crate::history::HistoryGraph, gix::ObjectId)> { + let graph = super::loaded_view_graph_with_hidden(repo, revisions, hidden_revisions)?; + let mut matches = Vec::new(); + for id in graph.stored_commit_ids() { + if crate::change_id::for_commit(repo, id)? == change_id { + matches.push(id); + } + } + match matches.as_slice() { + [target] => Ok((graph, *target)), + [] => anyhow::bail!("change ID {change_id} is no longer present in the Tix view"), + candidates => { + let candidates = candidates + .iter() + .map(|id| crate::change_id::display_short(repo, *id)) + .collect::<Result<Vec<_>>>()? + .join("\n "); + anyhow::bail!("change ID {change_id} is ambiguous in the Tix view; candidates:\n {candidates}") + } + } +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +pub(crate) fn document(repo: &gix::Repository, id: gix::ObjectId) -> Result<(std::ffi::OsString, Vec<u8>)> { + document_with_author(repo, id, None) +} + +pub(crate) fn document_with_author( + repo: &gix::Repository, + id: gix::ObjectId, + author: Option<&[u8]>, +) -> Result<(std::ffi::OsString, Vec<u8>)> { + let editor = repo.editor().context("no Git editor is available")?; + let mut commit = repo + .find_commit(id) + .context("could not find commit to reword")? + .decode() + .context("could not decode commit to reword")? + .into_owned() + .context("could not own commit to reword")?; + if let Some(author) = author { + commit.author = actor(author, commit.author.time, "author")?; + } + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned() + .context("could not own the Git committer")?; + let enrichment = crate::enrich::load(&mut crate::enrich::open(repo)?, crate::change_id::for_commit(repo, id)?)?; + + let mut out = Vec::new(); + write_headers(&mut out, &commit.author, &committer, &enrichment)?; + out.push(b'\n'); + out.extend_from_slice(&commit.message); + if !out.ends_with(b"\n") { + out.push(b'\n'); + } + let suggestions = missing_agent_trailers(&commit.message); + if suggestions.iter().any(Option::is_some) { + if !out.ends_with(b"\n\n") { + out.push(b'\n'); + } + for trailer in suggestions.into_iter().flatten() { + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + out.extend_from_slice(trailer); + out.push(b'\n'); + } + } + Ok((editor, out)) +} + +pub(super) fn missing_agent_trailers(message: &[u8]) -> [Option<&'static [u8]>; 2] { + let mut has_assisted_by = false; + let mut has_co_authored_by = false; + if let Some(body) = gix::objs::commit::MessageRef::from_bytes(message).body() { + for trailer in body.trailers() { + has_assisted_by |= trailer.is_assisted_by(); + has_co_authored_by |= trailer.is_co_authored_by(); + } + } + [ + (!has_assisted_by).then_some(ASSISTED_BY), + (!has_co_authored_by).then_some(CO_AUTHORED_BY), + ] +} + +#[tracing::instrument(skip_all, fields(commit_id = %old_id))] +pub(crate) fn apply( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + edited: &[u8], +) -> Result<Outcome> { + apply_conflict_reporting(repo, graph, old_id, edited, |_| {})?.complete() +} + +#[tracing::instrument(skip_all, fields(commit_id = %old_id))] +pub(crate) fn apply_conflict_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + edited: &[u8], + mut report: impl FnMut(rebase::Progress), +) -> Result<Perform> { + let edit = parse(edited)?; + if edit.message.is_empty() { + anyhow::bail!("the edited commit message is empty"); + } + + let mut commit = repo + .find_commit(old_id) + .context("could not find commit after editing")? + .decode() + .context("could not decode commit after editing")? + .into_owned() + .context("could not own commit after editing")?; + let author = actor(edit.author, edit.author_time, "author")?; + let commit_changed = author != commit.author || edit.message != commit.message; + let (rebased, enrichment, enrich_change) = if commit_changed { + commit.author = author; + commit.committer = actor(edit.committer, edit.committer_time, "committer")?; + commit.message = edit.message; + let (performed, enrichment) = + apply_commit_conflict_with_enrichment(&repo, graph, old_id, commit, &edit.enrichment, &mut report)?; + let outcome = match performed { + rebase::Perform::Complete(outcome) => outcome, + rebase::Perform::Conflict(conflict) => return Ok(Perform::Conflict(conflict)), + }; + (Some(outcome), enrichment, None) + } else { + let name: gix::refs::FullName = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); + let before = super::undo::state(&repo, name.as_ref())?; + let enrichment = crate::enrich::apply_headers(&repo, old_id, &edit.enrichment)?; + let after = super::undo::state(&repo, name.as_ref())?; + let change = (before != after).then_some(super::undo::RefChange { name, before, after }); + (None, enrichment, change) + }; + let commit = rebased + .as_ref() + .and_then(|outcome| outcome.selected) + .filter(|new_id| *new_id != old_id); + let (ref_rewrites, mut ref_changes) = rebased.map_or_else( + || (Vec::new(), Vec::new()), + |outcome| (outcome.ref_rewrites, outcome.ref_changes), + ); + if let Some(change) = enrich_change { + ref_changes.push(change); + } + Ok(Perform::Complete(Outcome { + target: old_id, + commit, + enrichment, + ref_rewrites, + ref_changes, + })) +} + +#[tracing::instrument(skip_all, fields(commit_id = %old_id))] +pub(crate) fn apply_message_reporting( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + message: &[u8], + author: Option<&[u8]>, +) -> Result<Outcome> { + let message = cleanup_message(message, None); + if message.is_empty() { + anyhow::bail!("the edited commit message is empty"); + } + let mut commit = repo + .find_commit(old_id) + .context("could not find commit to reword")? + .decode() + .context("could not decode commit to reword")? + .into_owned() + .context("could not own commit to reword")?; + let changed_author = author + .map(|author| actor(author, commit.author.time, "author")) + .transpose()?; + if commit.message == message && changed_author.as_ref().is_none_or(|author| *author == commit.author) { + return Ok(Outcome { + target: old_id, + commit: None, + enrichment: None, + ref_rewrites: Vec::new(), + ref_changes: Vec::new(), + }); + } + if let Some(author) = changed_author { + commit.author = author; + } + commit.message = message; + let outcome = apply_commit(&repo, graph, old_id, commit)?; + Ok(Outcome { + target: old_id, + commit: outcome.selected.filter(|new_id| *new_id != old_id), + enrichment: None, + ref_rewrites: outcome.ref_rewrites, + ref_changes: outcome.ref_changes, + }) +} + +fn apply_commit( + repo: &gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + commit: gix::objs::Commit, +) -> Result<rebase::Outcome> { + apply_commit_conflict(repo, graph, old_id, commit)?.complete() +} + +fn apply_commit_conflict( + repo: &gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + commit: gix::objs::Commit, +) -> Result<rebase::Perform> { + rebase::perform( + repo, + graph, + rebase::Edit::Replace { target: old_id, commit }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIsAndMark, + ) +} + +fn apply_commit_conflict_with_enrichment( + repo: &gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + commit: gix::objs::Commit, + headers: &crate::enrich::Headers, + report: impl FnMut(rebase::Progress), +) -> Result<(rebase::Perform, Option<crate::enrich::Enrichment>)> { + rebase::perform_with_enrichment_and_progress( + repo, + graph, + rebase::Edit::Replace { target: old_id, commit }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIsAndMark, + headers, + report, + ) +} + +pub(super) fn write_headers( + out: &mut Vec<u8>, + author: &gix::actor::Signature, + committer: &gix::actor::Signature, + enrichment: &crate::enrich::Enrichment, +) -> Result<()> { + write_actor(out, AUTHOR, author); + write_date(out, AUTHOR_DATE, author.time)?; + write_actor(out, COMMITTER, committer); + write_date(out, COMMITTER_DATE, committer.time)?; + out.extend_from_slice(COMMENT_CHAR); + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + out.push(b'\n'); + if !enrichment.todo { + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + } + out.extend_from_slice(TODO); + out.push(b'\n'); + if enrichment.note.is_none() { + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + } + out.extend_from_slice(MESSAGE); + if let Some(note) = enrichment.note.as_deref() { + out.push(b' '); + out.extend_from_slice(gix::objs::commit::MessageRef::from_bytes(note).summary().as_ref()); + } + out.push(b'\n'); + Ok(()) +} + +fn write_actor(out: &mut Vec<u8>, label: &[u8], actor: &gix::actor::Signature) { + out.extend_from_slice(label); + out.extend_from_slice(&actor.name); + out.extend_from_slice(b" <"); + out.extend_from_slice(&actor.email); + out.extend_from_slice(b">\n"); +} + +fn write_date(out: &mut Vec<u8>, label: &[u8], time: gix::date::Time) -> Result<()> { + out.extend_from_slice(label); + out.extend_from_slice( + time.format(gix::date::time::format::ISO8601) + .context("could not format commit date")? + .as_bytes(), + ); + out.push(b'\n'); + Ok(()) +} + +pub(super) fn parse(input: &[u8]) -> Result<Edit<'_>> { + let mut parts = input.splitn(6, |byte| *byte == b'\n'); + let author = header(parts.next(), AUTHOR)?; + let author_time = date(header(parts.next(), AUTHOR_DATE)?, "author")?; + let committer = header(parts.next(), COMMITTER)?; + let committer_time = date(header(parts.next(), COMMITTER_DATE)?, "committer")?; + let comment_char = header(parts.next(), COMMENT_CHAR)?; + if comment_char.contains(&b'\r') { + anyhow::bail!("CommentChar must not contain a line ending"); + } + let remainder = parts.next().context("the enrichment headers are missing")?; + let mut enrichment = crate::enrich::Headers::default(); + let mut todo_seen = false; + let mut message_seen = false; + let mut message_offset = None; + let mut consumed = 0; + for line in remainder.lines_with_terminator() { + consumed += line.len(); + let line = trim_cr(line.strip_suffix(b"\n").unwrap_or(line)); + if line.is_empty() { + message_offset = Some(consumed); + break; + } + if line.starts_with(comment_char) { + continue; + } + if line == TODO { + if std::mem::replace(&mut todo_seen, true) { + anyhow::bail!("duplicate Todo header"); + } + enrichment.todo = true; + } else if let Some(title) = line.strip_prefix(MESSAGE) { + if std::mem::replace(&mut message_seen, true) { + anyhow::bail!("duplicate Message header"); + } + let title = title.trim(); + enrichment.message = (!title.is_empty()).then(|| title.into()); + } else { + anyhow::bail!("unknown commit header: {}", line.as_bstr()); + } + } + let message_offset = message_offset.context("expected an empty line after the commit headers")?; + let message = cleanup_message( + remainder + .get(message_offset..) + .context("the commit message is missing")?, + Some(comment_char), + ); + Ok(Edit { + author, + author_time, + committer, + committer_time, + message, + enrichment, + }) +} + +pub(crate) fn cleanup_message(input: &[u8], comment_char: Option<&[u8]>) -> BString { + let mut out = Vec::new(); + let mut empty_lines = 0; + for line in input.lines_with_terminator() { + let line = trim_cr(line.strip_suffix(b"\n").unwrap_or(line)); + if comment_char.is_some_and(|comment_char| line.starts_with(comment_char)) { + continue; + } + let line = &line[..line + .iter() + .rposition(|byte| !byte.is_ascii_whitespace()) + .map_or(0, |pos| pos + 1)]; + if line.is_empty() { + empty_lines += 1; + continue; + } + if !out.is_empty() && empty_lines > 0 { + out.push(b'\n'); + } + empty_lines = 0; + out.extend_from_slice(line); + out.push(b'\n'); + } + out.into() +} + +fn header<'a>(line: Option<&'a [u8]>, prefix: &[u8]) -> Result<&'a [u8]> { + trim_cr(line.context("a commit header is missing")?) + .strip_prefix(prefix) + .filter(|value| !value.is_empty()) + .with_context(|| format!("expected a non-empty {} header", prefix[..prefix.len() - 2].as_bstr())) +} + +fn trim_cr(line: &[u8]) -> &[u8] { + line.strip_suffix(b"\r").unwrap_or(line) +} + +fn date(value: &[u8], field: &str) -> Result<gix::date::Time> { + let value = std::str::from_utf8(value).with_context(|| format!("{field} date is not UTF-8"))?; + gix::date::parse(value, None) + .map_err(|err| anyhow::Error::new(err.into_error())) + .with_context(|| format!("could not parse {field} date")) +} + +pub(super) fn actor(value: &[u8], time: gix::date::Time, field: &str) -> Result<gix::actor::Signature> { + let parsed = gix::actor::SignatureRef::from_bytes(value) + .with_context(|| format!("could not parse {field} identity"))? + .trim(); + if parsed.name.is_empty() || parsed.email.is_empty() || !parsed.time.is_empty() { + anyhow::bail!("{field} must be written as Name <email>"); + } + Ok(gix::actor::Signature { + name: parsed.name.into(), + email: parsed.email.into(), + time, + }) +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + + #[test] + fn parses_the_edit_document() -> gix_testtools::Result { + let input = b"Author: A U Thor <author@example.com>\n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: C O Mitter <committer@example.com>\n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: ;\n\ + \n\ + title\n\nbody\n"; + let edit = parse(input)?; + assert_eq!( + edit.author, b"A U Thor <author@example.com>", + "the author identity is preserved" + ); + assert_eq!(edit.author_time.offset, 7200, "the author timezone is parsed"); + assert_eq!( + edit.committer, b"C O Mitter <committer@example.com>", + "the committer identity is preserved" + ); + assert_eq!(edit.committer_time.offset, 7200, "the committer timezone is parsed"); + assert_eq!( + edit.message, b"title\n\nbody\n", + "the message is preserved byte-for-byte" + ); + Ok(()) + } + + #[test] + fn document_does_not_repeat_existing_agent_trailers() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = crate::test_repository::open_with( + fixture, + [ + "committer.name=Current Committer", + "committer.email=current@example.com", + ], + )?; + let topic = repository.find_reference("refs/heads/topic")?.id().detach(); + let (editor, document) = document(&repository, topic)?; + assert_eq!(editor, ":", "the configured editor is returned"); + let edit = parse(&document)?; + assert_eq!( + edit.committer, b"Current Committer <current@example.com>", + "the template shows the repository's current committer" + ); + assert_eq!( + edit.committer_time.seconds, 978_307_200, + "the configured committer date is shown" + ); + assert!( + document + .windows(b"CommentChar: ;\n;Todo\n;Message:\n\n".len()) + .any(|line| line == b"CommentChar: ;\n;Todo\n;Message:\n\n"), + "the template declares its comment prefix and inactive enrichments" + ); + assert!( + !document + .windows(b";Assisted-by:".len()) + .any(|line| line == b";Assisted-by:") + && !document + .windows(b";Co-authored-by:".len()) + .any(|line| line == b";Co-authored-by:"), + "existing trailer keys suppress model-specific suggestions regardless of their values" + ); + Ok(()) + } + + #[test] + fn parses_bare_todo_and_single_line_message_headers() -> gix_testtools::Result { + let document = |headers: &str| { + format!( + "Author: A <a@example.com>\n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: C <c@example.com>\n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: ;\n{headers}\n\n\ + title\n" + ) + }; + + let active_document = document("Todo\nMessage: enrichment title"); + let edit = parse(active_document.as_bytes())?; + assert_eq!( + edit.enrichment, + crate::enrich::Headers { + todo: true, + message: Some("enrichment title".into()), + } + ); + assert_eq!( + parse(document(";Todo\n;Message:").as_bytes())?.enrichment, + Default::default() + ); + assert_eq!(parse(document("Message:").as_bytes())?.enrichment, Default::default()); + assert!( + parse(document("Todo\nTodo").as_bytes()).is_err(), + "duplicate Todo is rejected" + ); + assert!( + parse(document("Message: one\nMessage: two").as_bytes()).is_err(), + "duplicate Message is rejected" + ); + assert!( + parse(document("Todo: true").as_bytes()).is_err(), + "Todo has no value syntax" + ); + Ok(()) + } + + #[test] + fn enrichment_only_edits_do_not_rewrite_the_commit() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open_with( + fixture.path(), + [ + "committer.name=Current Committer", + "committer.email=current@example.com", + ], + )?; + let id = repository.head_id()?.detach(); + crate::enrich::toggle(&repository, id)?; + crate::enrich::set_note(&repository, id, Some(b"Existing title\n\nexisting body\n"))?; + let (_, document) = document(&repository, id)?; + assert!( + document + .windows(b"Todo\nMessage: Existing title\n\n".len()) + .any(|window| { window == b"Todo\nMessage: Existing title\n\n" }), + "active enrichments are shown before the commit message" + ); + + let edited = document.replacen(b"Message: Existing title", b"Message: New title", 1); + let graph = super::super::loaded_graph(&repository)?; + let outcome = apply(repository.clone(), &graph, id, &edited)?; + assert_eq!(outcome.target, id, "the outcome identifies the edited commit"); + assert!( + outcome.commit.is_none(), + "enrichment changes leave the commit object untouched" + ); + assert_eq!(repository.head_id()?, id); + let enrichment = outcome.enrichment.expect("the enrichment changed"); + assert!(enrichment.todo); + assert_eq!( + enrichment.note.as_ref().map(|note| note.as_bstr()), + Some(b"New title\n\nexisting body\n".as_bstr()) + ); + Ok(()) + } + + #[test] + fn relocates_an_editor_reword_onto_a_concurrent_amend() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + let repository = crate::test_repository::open_with( + path, + [ + "committer.name=Current Committer", + "committer.email=current@example.com", + ], + )?; + let original = repository.head_id()?.detach(); + let change_id = crate::change_id::for_commit(&repository, original)?; + let (_, document) = document(&repository, original)?; + let edited = document.replacen(b"\ntip\n", b"\nreworded tip\n", 1); + + let mut outside = repository.find_commit(original)?.decode()?.into_owned()?; + outside.parents = [original].into_iter().collect(); + outside.message = "outside the view".into(); + crate::change_id::inherit(&repository, &mut outside, original)?; + let outside = repository.write_object(&outside)?.detach(); + repository.reference( + "refs/heads/outside", + outside, + gix::refs::transaction::PreviousValue::MustNotExist, + "test outside-view successor", + )?; + + std::fs::write(path.join("concurrent"), b"amended while editing\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(path) + .args(["add", "concurrent"]) + .status()? + .success(), + "the concurrent tree change is staged" + ); + let graph = super::super::loaded_view_graph(&repository)?; + let amended = + crate::edit::head::amend_index(repository.clone(), &graph)?.expect("the staged tree changes amend HEAD"); + let amended_commit = repository.find_commit(amended)?.decode()?.into_owned()?; + assert_eq!( + crate::change_id::for_commit(&repository, amended)?, + change_id, + "the concurrent amend preserves the stable identity" + ); + + let (graph, target) = relocate_after_editor(&repository, &[], &[], change_id)?; + assert_eq!(target, amended, "the fresh view resolves the amended successor"); + let outcome = apply(repository.clone(), &graph, target, &edited)?; + assert_eq!(outcome.target, amended, "the outcome reports the relocated target"); + let rewritten = outcome.commit.expect("the edited message rewrites the amended commit"); + let rewritten_commit = repository.find_commit(rewritten)?.decode()?.into_owned()?; + assert_eq!(repository.head_id()?, rewritten, "HEAD follows the reword"); + assert_eq!( + rewritten_commit.tree, amended_commit.tree, + "the reword retains the concurrently amended tree" + ); + assert_eq!( + rewritten_commit.parents, amended_commit.parents, + "the reword retains the current topology" + ); + assert_eq!(rewritten_commit.message, b"reworded tip\n".as_bstr()); + assert_eq!( + repository.find_reference("refs/heads/outside")?.id(), + outside, + "a duplicate identity outside the active view is neither selected nor rewritten" + ); + Ok(()) + } + + #[test] + fn editor_relocation_requires_one_visible_commit() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let path = fixture.path(); + let repository = crate::test_repository::open(path)?; + let head = repository.head_id()?.detach(); + let index_before = std::fs::read(repository.index_path())?; + let worktree_before = std::fs::read(path.join("tip"))?; + + let missing = gix::hash::ChangeId::from(gix::ObjectId::Sha1([42; 20])); + let err = relocate_after_editor(&repository, &[], &[], missing) + .expect_err("an identity outside the current view cannot be edited"); + assert!( + format!("{err:#}").contains("no longer present"), + "the missing identity is explained: {err:#}" + ); + + let change_id = crate::change_id::for_commit(&repository, head)?; + let mut colliding_change_id = change_id.to_reverse_hex().to_string().into_bytes(); + let last = colliding_change_id.last_mut().expect("a change ID is not empty"); + *last = if *last == b'k' { b'l' } else { b'k' }; + let colliding_change_id = gix::hash::ChangeId::from_reverse_hex(&colliding_change_id)?; + let mut prefix_collision = repository.find_commit(head)?.decode()?.into_owned()?; + prefix_collision.message = "prefix collision".into(); + prefix_collision + .extra_headers + .retain(|(name, _)| name.as_slice() != crate::change_id::HEADER.as_bytes()); + prefix_collision + .extra_headers + .push((crate::change_id::HEADER.into(), colliding_change_id.to_string().into())); + let prefix_collision = repository.write_object(&prefix_collision)?.detach(); + let prefix_pin = "refs/worktree/tix/pins/prefix-collision"; + repository.reference( + prefix_pin, + prefix_collision, + gix::refs::transaction::PreviousValue::MustNotExist, + "test change ID prefix collision", + )?; + let (_, resolved) = relocate_after_editor(&repository, &[], &[], change_id)?; + assert_eq!( + resolved, head, + "a later prefix collision cannot make the captured full identity ambiguous" + ); + + let mut duplicate = repository.find_commit(head)?.decode()?.into_owned()?; + duplicate.message = "duplicate identity".into(); + crate::change_id::inherit(&repository, &mut duplicate, head)?; + let duplicate = repository.write_object(&duplicate)?.detach(); + let pin = "refs/worktree/tix/pins/duplicate"; + repository.reference( + pin, + duplicate, + gix::refs::transaction::PreviousValue::MustNotExist, + "test duplicate identity", + )?; + + let err = relocate_after_editor(&repository, &[], &[], change_id) + .expect_err("two visible commits with the same identity are ambiguous"); + let message = format!("{err:#}"); + assert!(message.contains("ambiguous"), "ambiguity is explicit: {message}"); + for candidate in [head, duplicate] { + assert!( + message.contains(&candidate.to_hex_with_len(7).to_string()), + "the error lists candidate {candidate}: {message}" + ); + } + assert_eq!(repository.head_id()?, head, "failed relocation leaves HEAD alone"); + assert_eq!( + repository.find_reference(pin)?.id(), + duplicate, + "failed relocation leaves pins alone" + ); + assert_eq!( + repository.find_reference(prefix_pin)?.id(), + prefix_collision, + "failed relocation leaves prefix-collision pins alone" + ); + assert_eq!(std::fs::read(repository.index_path())?, index_before); + assert_eq!(std::fs::read(path.join("tip"))?, worktree_before); + Ok(()) + } + + #[test] + fn offers_only_missing_agent_trailer_keys() { + assert_eq!( + missing_agent_trailers(b"title\n\nASSISTED-BY: another agent\n"), + [None, Some(CO_AUTHORED_BY)], + "trailer keys are matched case-insensitively and independently of their values" + ); + assert_eq!( + missing_agent_trailers(b"title\n\nco-AUTHORED-by: Someone <someone@example.com>\n"), + [Some(ASSISTED_BY), None], + "either missing trailer remains available for opt-in" + ); + } + + #[test] + fn cleanup_honors_git_style_comment_prefixes_and_opted_in_trailers() -> gix_testtools::Result { + let input = b"Author: A <a@example.com>\n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: C <c@example.com>\n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: //\n\ + \n\ + \nsubject \n\n\ninline // stays \n // indented stays\n//removed\n\nAssisted-by: GPT 5.6\n//Co-authored-by: GPT 5.6 <codex@openai.com>\n"; + assert_eq!( + parse(input)?.message, + b"subject\n\ninline // stays\n // indented stays\n\nAssisted-by: GPT 5.6\n".as_bstr(), + "only column-zero comments are removed and Git whitespace cleanup is applied" + ); + + let empty_comment = input.replacen(b"CommentChar: //", b"CommentChar: ", 1); + assert!(parse(&empty_comment).is_err(), "the comment prefix cannot be empty"); + Ok(()) + } + + #[test] + fn eagerly_replays_checked_out_reword_descendants() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open_with( + fixture.path(), + [ + "committer.name=Current Committer", + "committer.email=current@example.com", + ], + )?; + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let (_, document) = document(&repository, middle)?; + let edited = document + .replacen( + b"Committer: Current Committer <current@example.com>", + b"Committer: Edited Committer <edited@example.com>", + 1, + ) + .replacen(b"\nmiddle\n", b"\nrewritten middle\n", 1); + let graph = super::super::loaded_graph(&repository)?; + let new_middle = apply(repository.clone(), &graph, middle, &edited)? + .commit + .expect("the message changed"); + let new_tip = repository.head_id()?.detach(); + let rewritten = repository.find_commit(new_middle)?.decode()?.into_owned()?; + assert_eq!(rewritten.committer.name, b"Current Committer".as_bstr()); + assert_eq!(rewritten.committer.email, b"current@example.com".as_bstr()); + assert_eq!( + rewritten.committer.time.seconds, 978_307_200, + "edited committer fields cannot override the current repository committer" + ); + assert!( + !rebase::is_pending(&repository.find_commit(new_middle)?.decode()?.into_owned()?), + "the reworded commit already has its final tree, parent, and signature" + ); + assert!( + !rebase::is_pending(&repository.find_commit(new_tip)?.decode()?.into_owned()?), + "the checked-out descendant is replayed eagerly" + ); + Ok(()) + } + + #[test] + fn signed_rewords_sign_eager_checked_out_descendants() -> gix_testtools::Result { + if !gix_testtools::signature::program_available("ssh-keygen") { + return Ok(()); + } + let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open_with( + fixture.path(), + [ + "commit.gpgSign=true".to_owned(), + "gpg.format=ssh".to_owned(), + format!("user.signingKey={}", key.display()), + format!( + "gpg.ssh.allowedSignersFile={}", + gix_testtools::signature::fixture("ssh-allowed-signers").display() + ), + ], + )?; + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let (_, document) = document(&repository, middle)?; + let edited = document.replacen(b"\nmiddle\n", b"\nrewritten middle\n", 1); + let graph = super::super::loaded_graph(&repository)?; + let rewritten = apply(repository.clone(), &graph, middle, &edited)? + .commit + .expect("the message changed"); + assert!( + repository + .find_commit(rewritten)? + .verify_signature()? + .expect("the edited commit is signed") + .is_valid(), + "the final edited commit receives its configured signature" + ); + + assert!( + repository + .find_commit(repository.head_id()?)? + .verify_signature()? + .expect("the eagerly replayed descendant is signed") + .is_valid(), + "the checked-out descendant receives its configured signature" + ); + Ok(()) + } + + #[test] + fn rewrites_direct_refs_except_tags_and_remotes_and_signs_when_enabled() -> gix_testtools::Result { + if !gix_testtools::signature::program_available("ssh-keygen") { + return Ok(()); + } + let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let old_id = crate::test_repository::open(fixture.path())?.head_id()?.detach(); + let git = |args: &[&str]| -> std::io::Result<std::process::ExitStatus> { + Command::new("git").arg("-C").arg(fixture.path()).args(args).status() + }; + for name in ["refs/patches/reword", "refs/tags/keep", "refs/remotes/origin/keep"] { + assert!( + git(&["update-ref", name, &old_id.to_string()])?.success(), + "the test reference is created" + ); + } + + let repository = crate::test_repository::open_with( + fixture.path(), + [ + "commit.gpgSign=true".to_owned(), + "gpg.format=ssh".to_owned(), + format!("user.signingKey={}", key.display()), + format!( + "gpg.ssh.allowedSignersFile={}", + gix_testtools::signature::fixture("ssh-allowed-signers").display() + ), + ], + )?; + let edited = b"Author: New Author <new-author@example.com>\n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: New Committer <new-committer@example.com>\n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: ;\n\ + \n\ + rewritten title\n\nrewritten body\n\nAssisted-by: GPT 5.6\n;Co-authored-by: GPT 5.6 <codex@openai.com>\n"; + let graph = super::super::loaded_graph(&repository)?; + let new_id = apply(repository.clone(), &graph, old_id, edited)? + .commit + .expect("the edited commit differs"); + let commit = repository.find_commit(new_id)?; + let decoded = commit.decode()?; + assert_eq!( + decoded.message, + b"rewritten title\n\nrewritten body\n\nAssisted-by: GPT 5.6\n".as_bstr() + ); + assert_eq!(decoded.author()?.name, b"New Author".as_bstr()); + assert!( + !rebase::is_pending(&decoded.into_owned()?), + "a signed reword with unchanged ancestry needs no later replay" + ); + assert!( + commit + .verify_signature()? + .expect("configured signing adds a signature") + .is_valid(), + "the rewritten commit has a valid configured signature" + ); + for name in ["refs/heads/main", "refs/patches/reword"] { + assert_eq!( + repository.find_reference(name)?.id(), + new_id, + "{name} follows the rewrite" + ); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!(repository.find_reference(name)?.id(), old_id, "{name} is not rewritten"); + } + Ok(()) + } +} diff --git a/gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap b/gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap new file mode 100644 index 00000000000..1f3ba110f45 --- /dev/null +++ b/gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap @@ -0,0 +1,56 @@ +--- +source: gix-tix/src/edit/rebase.rs +expression: "gix_testtools::repository::snapshot_portable(fixture.path())?.to_string()" +--- +HEAD refs/heads/main -> C2 + +[config] +[core] + bare = false +[user] + name = author + email = author@example.com +[commit] + gpgSign = false + +[refs] +refs/heads/main = C2 +refs/patches/middle = C1 +refs/patches/tip = C2 + +[commits] +C0 + tree T1 + author author <author@example.com> 946684800 +0000 + committer committer <committer@example.com> 946684800 +0000 + + base + +C1 + tree T0 + parent C0 + author author <author@example.com> 946771200 +0000 + committer rebasing committer <rebasing@example.com> 978307200 +0000 + change-id wtvpqoumovpvzypqrpsxxlkvxmltvtlxvomlzyxs + + rewritten middle + +C2 + tree T2 + parent C1 + author author <author@example.com> 946857600 +0000 + committer rebasing committer <rebasing@example.com> 978307200 +0000 + change-id zvoumlrryyvtnnxxqnmrnlxnvtlmnvwlynmuuvux + + tip + +[index] +tree = T2 +100644 B0 stage=0 "base" +100644 B1 stage=0 "middle" +100644 B2 stage=0 "tip" + +[worktree] +- file "base" = "base\n" +- file "middle" = "middle\n" +- file "tip" = "tip\n" diff --git a/gix-tix/src/edit/snapshots/gix_tix__edit__review__tests__changed-review-with-successors.snap b/gix-tix/src/edit/snapshots/gix_tix__edit__review__tests__changed-review-with-successors.snap new file mode 100644 index 00000000000..6285d37b2cd --- /dev/null +++ b/gix-tix/src/edit/snapshots/gix_tix__edit__review__tests__changed-review-with-successors.snap @@ -0,0 +1,73 @@ +--- +source: gix-tix/src/edit/review.rs +expression: "gix_testtools::repository::snapshot_portable(fixture.path())?.to_string()" +--- +HEAD refs/heads/main -> C4 + +[config] +[core] + bare = false +[user] + name = reviewer + email = reviewer@example.com +[gitoxide "commit"] + authorDate = 2001-01-01T00:00:00 +0000 + committerDate = 2001-01-01T00:00:00 +0000 + +[refs] +refs/heads/main = C4 +refs/heads/review-successor = C3 + +[commits] +C0 + tree T2 + author reviewer <reviewer@example.com> 978307200 +0000 + committer reviewer <reviewer@example.com> 978307200 +0000 + + base + +C1 + tree T1 + parent C0 + author reviewer <reviewer@example.com> 978307200 +0000 + committer reviewer <reviewer@example.com> 978307200 +0000 + + B + +C2 + tree T0 + parent C1 + author reviewer <reviewer@example.com> 978307200 +0000 + committer reviewer <reviewer@example.com> 978307200 +0000 + change-id oxzuvntpklkyxykvvmmuzqomsnzrrwwvtuoxwttx + + review + +C3 + tree T0 + parent C2 + author reviewer <reviewer@example.com> 978307200 +0000 + committer reviewer <reviewer@example.com> 978307200 +0000 + change-id tlynrtysuoqktszrqppwrnsxpvokwvoqoqvkvqsv + + review successor + +C4 + tree T3 + parent C3 + author reviewer <reviewer@example.com> 978307200 +0000 + committer reviewer <reviewer@example.com> 978307200 +0000 + change-id xlpouomvnxrqllnkynsusvqwkkwnlrttmrvxquso + + A + +[index] +tree = T3 +100644 B0 stage=0 "file" +100644 B1 stage=0 "reviewed" +100644 B2 stage=0 "successor" + +[worktree] +- file "file" = "B\n" +- file "reviewed" = "new review change\n" +- file "successor" = "A\n" diff --git a/gix-tix/src/edit/snapshots/gix_tix__edit__time_travel__tests__accepted-pending-rebase-conflict.snap b/gix-tix/src/edit/snapshots/gix_tix__edit__time_travel__tests__accepted-pending-rebase-conflict.snap new file mode 100644 index 00000000000..6a350a5492c --- /dev/null +++ b/gix-tix/src/edit/snapshots/gix_tix__edit__time_travel__tests__accepted-pending-rebase-conflict.snap @@ -0,0 +1,57 @@ +--- +source: gix-tix/src/edit/time_travel.rs +expression: "gix_testtools::repository::snapshot_portable(fixture.path())?\n.to_string().replace(\"\\n \\n\", \"\\n\\n\")" +--- +HEAD detached C1 + +[config] +[core] + bare = false +[user] + name = author + email = author@example.com +[commit] + gpgSign = false +[gitoxide "commit"] + committerDate = 2001-01-01T00:00:00 +0000 + +[refs] +refs/heads/main = C2 +refs/worktree/tix/pins/HEAD = -> refs/heads/main + +[commits] +C0 + tree T1 + author author <author@example.com> 946684800 +0000 + committer committer <committer@example.com> 946684800 +0000 + + base + +C1 + tree T1 + parent C0 + author author <author@example.com> 946684800 +0000 + committer author <author@example.com> 978307200 +0000 + change-id stnpqqrsmmvwyprorolxzpuxpllmuzttnmyzurkl + tix-rebase-parent 48dd951f6d729bd3debd703cc9a8392702bf9124 + + tip + +C2 + tree T0 + parent C1 + author author <author@example.com> 946944000 +0000 + committer author <author@example.com> 978307200 +0000 + change-id xuwvvlmlmymmmluzlqpxrrklslwlkmokswwxzool + tix-rebase-parent 76ca9987dd431a8b8be20a52aeed5066cd1058fe + + after + +[index] +tree = conflicted +100644 B0 stage=1 "file" +100644 B1 stage=2 "file" +100644 B2 stage=3 "file" + +[worktree] +- file "file" = "<<<<<<< rebased parent\nbase\n=======\ntip\n>>>>>>> commit\n" diff --git a/gix-tix/src/edit/split.rs b/gix-tix/src/edit/split.rs new file mode 100644 index 00000000000..83c9de5c762 --- /dev/null +++ b/gix-tix/src/edit/split.rs @@ -0,0 +1,308 @@ +use anyhow::{Context, Result}; +use gix::ObjectId; + +use super::{create, rebase}; +use crate::{ChangeGroup, ChangeKind, load_worktree_changes_without_lines}; + +pub(crate) struct Prepared { + pub editor: std::ffi::OsString, + pub document: Vec<u8>, + create: create::Prepared, + target: ObjectId, + source: gix::objs::Commit, + tree: ObjectId, +} + +#[tracing::instrument(skip_all)] +pub(crate) fn prepare(mut repo: gix::Repository, todo: bool) -> Result<Prepared> { + let target = repo + .head_id() + .context("splitting requires an existing HEAD commit")? + .detach(); + let changes = load_worktree_changes_without_lines(&repo)?; + if changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged) { + anyhow::bail!("cannot split with unresolved conflicts"); + } + if !changes.paths.iter().any(|change| change.group == ChangeGroup::Staged) + || !changes.paths.iter().any(|change| change.group == ChangeGroup::Unstaged) + { + anyhow::bail!("splitting requires both staged and worktree changes"); + } + + let mut source = repo + .find_commit(target) + .context("could not find HEAD commit")? + .decode() + .context("could not decode HEAD commit")? + .into_owned() + .context("could not own HEAD commit")?; + let mut create = create::prepare_from(repo.clone(), Some(target), create::Source::Default, None, todo)?; + repo.objects.set_object_memory(std::mem::take(&mut create.objects)); + + let head_tree = source.tree; + let index_tree = create.tree; + let index = repo + .find_tree(index_tree) + .context("could not load the prepared index tree")?; + let worktree_tree = create::worktree_tree_with_changes(&repo, &index, &changes)?; + drop(index); + let source_tree = rebase::cherry_pick_tree(&repo, index_tree, head_tree, worktree_tree) + .context("worktree changes conflict with the source commit")?; + let tree = rebase::cherry_pick_tree(&repo, head_tree, source_tree, index_tree) + .context("staged changes conflict with the rewritten source commit")?; + source.tree = source_tree; + create.objects = repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + + Ok(Prepared { + editor: create.editor.clone(), + document: create.document.clone(), + create, + target, + source, + tree, + }) +} + +#[cfg(test)] +#[tracing::instrument(skip_all, fields(target = %prepared.target))] +pub(crate) fn apply( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + prepared: Prepared, + edited: &[u8], +) -> Result<ObjectId> { + apply_reporting(repo, graph, prepared, edited, |_| {})? + .selected + .context("splitting HEAD did not produce a selection") +} + +pub(crate) fn apply_reporting( + mut repo: gix::Repository, + graph: &crate::history::HistoryGraph, + mut prepared: Prepared, + edited: &[u8], + report: impl FnMut(rebase::Progress), +) -> Result<rebase::Outcome> { + let repository_path = repo.git_dir().to_owned(); + let bare = repo.is_bare(); + repo.objects + .set_object_memory(std::mem::take(&mut prepared.create.objects)); + let (mut upper, enrichment) = create::commit_from_edit(&prepared.create, edited)?; + upper.tree = prepared.tree; + let mut outcome = rebase::perform_with_progress( + &repo, + graph, + rebase::Edit::Split { + target: prepared.target, + source: prepared.source, + upper, + }, + rebase::Signature::InvalidateExisting, + rebase::Tree::LeaveAsIsAndMark, + report, + )? + .complete()?; + let id = outcome.selected.context("splitting HEAD did not produce a selection")?; + drop(repo); + let repo = crate::open_repository(&repository_path, bare, false)?; + let name: gix::refs::FullName = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); + let before = super::undo::state(&repo, name.as_ref())?; + crate::enrich::apply_headers(&repo, id, &enrichment) + .context("the commit was split, but its enrichment could not be saved")?; + let after = super::undo::state(&repo, name.as_ref())?; + if before != after { + outcome.ref_changes.push(super::undo::RefChange { name, before, after }); + } + Ok(outcome) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result<gix::Repository> { + Ok(crate::test_repository::open_with( + path, + ["user.name=editor", "user.email=editor@example.com"], + )?) + } + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!("git {} failed: {}", args.join(" "), output.stderr.to_str_lossy()).into()); + } + Ok(output.stdout) + } + + #[test] + fn splits_staged_and_worktree_changes_without_touching_files_during_preparation() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("split_commit.sh")?; + let repository = open(fixture.path())?; + let old = repository.head_id()?.detach(); + let old_parent = repository.find_commit(old)?.parent_ids().next().map(gix::Id::detach); + for name in ["refs/patches/split", "refs/tags/keep", "refs/remotes/origin/keep"] { + git(fixture.path(), &["update-ref", name, &old.to_string()])?; + } + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare(open(fixture.path())?, false)?; + assert_eq!( + prepared + .document + .split(|byte| *byte == b'\n') + .filter(|line| line.strip_prefix(b";").unwrap_or(line).starts_with(b"Author: ")) + .count(), + 1, + "split editors contain only the configured author" + ); + assert!( + prepared + .document + .windows(b"staged".len()) + .any(|window| window == b"staged"), + "the upper commit editor describes staged paths" + ); + assert!( + !prepared + .document + .windows(b"unstaged |".len()) + .any(|window| window == b"unstaged |"), + "the upper commit editor excludes worktree-only paths" + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "preparing both cherry-picks leaves the repository unchanged" + ); + + let edited = prepared + .document + .replacen(b"what\n\nwhy", b"upper\n\nreason", 1) + .replacen(b";Todo\n;Message:", b"Todo\nMessage: split enrichment", 1); + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let repository = open(fixture.path())?; + let mut notes = repository.notes()?; + let notes_ref = notes + .default_ref() + .expect("the test repository has a default notes ref") + .to_owned(); + notes.replace_at_ref(notes_ref.as_ref(), old, b"source Git note")?; + let upper = apply(open(fixture.path())?, &graph, prepared, &edited)?; + let repository = open(fixture.path())?; + assert_eq!( + crate::enrich::load( + &mut crate::enrich::open(&repository)?, + crate::change_id::for_commit(&repository, upper)? + )?, + crate::enrich::Enrichment { + todo: true, + note: Some("split enrichment".into()), + } + ); + let source = repository + .find_commit(upper)? + .parent_ids() + .next() + .expect("the upper commit has the rewritten source") + .detach(); + assert_eq!( + repository.find_commit(source)?.parent_ids().next().map(gix::Id::detach), + old_parent, + "the source commit retains its original ancestry" + ); + assert_eq!( + git(fixture.path(), &["show", &format!("{source}:both")])?, + b"one\nbase staged\nmiddle\nworktree\nend\n" + ); + assert_eq!( + git(fixture.path(), &["show", &format!("{upper}:both")])?, + b"one\nstaged\nmiddle\nworktree\nend\n" + ); + assert_eq!( + git(fixture.path(), &["show", &format!("{source}:unstaged")])?, + b"worktree\n" + ); + assert_eq!( + git(fixture.path(), &["show", &format!("{source}:untracked")])?, + b"untracked\n" + ); + assert_eq!(git(fixture.path(), &["show", &format!("{upper}:staged")])?, b"staged\n"); + assert_eq!(repository.find_commit(source)?.message_raw()?, b"base\n".as_bstr()); + assert_eq!( + repository.find_commit(upper)?.message_raw()?, + b"upper\n\nreason\n".as_bstr() + ); + let source_commit = repository.find_commit(source)?.decode()?.into_owned()?; + assert_eq!( + crate::change_id::effective(source, source_commit.extra_headers().find_all(crate::change_id::HEADER)), + gix::hash::ChangeId::from(old), + "the rewritten lower commit inherits the original identity" + ); + let mut notes = repository.notes()?.with_refs([notes_ref.as_bstr()])?; + assert_eq!( + notes.get(source)?.first().map(|note| note.blob.data.as_slice()), + Some(b"source Git note".as_slice()), + "the rewritten lower commit receives the source Git note" + ); + assert!( + notes.get(upper)?.is_empty(), + "the newly inserted upper commit receives no source Git note" + ); + assert_eq!( + repository + .find_commit(upper)? + .decode()? + .extra_headers() + .find(crate::change_id::HEADER), + None, + "the newly created upper commit remains headerless" + ); + assert!( + !super::super::rebase::is_pending(&source_commit), + "the unsigned source already has its final tree and parent" + ); + assert!( + !super::super::rebase::is_pending(&repository.find_commit(upper)?.decode()?.into_owned()?), + "the new upper commit already has its final tree and parent" + ); + assert_eq!(git(fixture.path(), &["status", "--short"])?, b""); + for name in ["refs/heads/main", "refs/patches/split"] { + assert_eq!(repository.find_reference(name)?.id(), upper, "{name} follows the split"); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!(repository.find_reference(name)?.id(), old, "{name} is not edited"); + } + Ok(()) + } + + #[test] + fn conflicting_staged_and_worktree_hunks_abort_before_the_editor() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("split_commit.sh")?; + std::fs::write( + fixture.path().join("both"), + b"one\nworktree conflict\nmiddle\nbase worktree\nend\n", + )?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + let err = match prepare(open(fixture.path())?, false) { + Ok(_) => return Err("overlapping staged and worktree hunks should conflict".into()), + Err(err) => err, + }; + assert!( + format!("{err:#}").contains("worktree changes conflict with the source commit"), + "the error identifies the conflicting half of the split: {err:#}" + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "a conflicting split writes no observable state" + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/stash.rs b/gix-tix/src/edit/stash.rs new file mode 100644 index 00000000000..8b0eff13bc9 --- /dev/null +++ b/gix-tix/src/edit/stash.rs @@ -0,0 +1,442 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Write as _, + path::Path, + process::Command, +}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BStr, ByteSlice}, + refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +use crate::open_repository; + +pub(crate) fn reference(id: ObjectId) -> Result<gix::refs::FullName> { + format!( + "{}{}", + String::from_utf8_lossy(crate::history::STASH_PREFIX), + id.to_hex() + ) + .try_into() + .context("generated an invalid tix stash reference") +} + +pub(crate) fn associated_commit(name: &BStr) -> Result<Option<ObjectId>> { + let Some(suffix) = name.strip_prefix(crate::history::STASH_PREFIX) else { + return Ok(None); + }; + let id = ObjectId::from_hex(suffix).context("tix stash reference has an invalid commit ID")?; + if id.to_hex().to_string().as_bytes() != suffix { + anyhow::bail!("tix stash reference does not use a canonical full commit ID"); + } + Ok(Some(id)) +} + +pub(super) struct RewriteEdits { + pub forward: Vec<RefEdit>, + pub rollback: Vec<RefEdit>, +} + +pub(super) fn rewrite_edits( + repo: &gix::Repository, + rewritten: &HashMap<ObjectId, Option<ObjectId>>, + removed: &HashSet<ObjectId>, +) -> Result<RewriteEdits> { + let mut moves = Vec::new(); + let mut destinations = HashMap::<ObjectId, ObjectId>::new(); + for reference in repo.references()?.all()? { + let reference = match reference { + Ok(reference) => reference, + Err(err) => { + return Err(anyhow::anyhow!( + "could not inspect a stash reference before rebasing: {err}" + )); + } + }; + let old = match associated_commit(reference.name().as_bstr()) { + Ok(Some(id)) => id, + Ok(None) => continue, + Err(err) => { + tracing::warn!(name = %reference.name(), error = %err, "ignored malformed tix stash reference"); + continue; + } + }; + let Some(new) = rewritten.get(&old).copied() else { + continue; + }; + if removed.contains(&old) { + anyhow::bail!("cannot drop stashed commit {}", old.to_hex_with_len(7)); + } + let new = new.context("a stashed commit cannot disappear during a rewrite")?; + if new == old { + continue; + } + if let Some(other) = destinations.insert(new, old) { + anyhow::bail!( + "stashes at {} and {} would converge on {}", + other.to_hex_with_len(7), + old.to_hex_with_len(7), + new.to_hex_with_len(7) + ); + } + moves.push((reference.name().to_owned(), reference.target().into_owned(), old, new)); + } + + let mut forward = Vec::with_capacity(moves.len() * 2); + let mut rollback = Vec::with_capacity(moves.len() * 2); + for (old_name, target, old, new) in moves { + let new_name = reference(new)?; + if repo.try_find_reference(new_name.as_ref())?.is_some() { + anyhow::bail!( + "rewritten commit {} already has saved worktree state", + new.to_hex_with_len(7) + ); + } + forward.push(delete_edit(old_name.clone(), target.clone())); + forward.push(create_edit(new_name.clone(), target.clone())); + rollback.push(delete_edit(new_name, target.clone())); + rollback.push(create_edit(old_name, target)); + tracing::debug!(old = %old, new = %new, "prepared tix stash association rewrite"); + } + Ok(RewriteEdits { forward, rollback }) +} + +fn create_edit(name: gix::refs::FullName, target: Target) -> RefEdit { + RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "tix commit stash rewrite".into(), + }, + expected: PreviousValue::MustNotExist, + new: target, + }, + name, + deref: false, + } +} + +fn delete_edit(name: gix::refs::FullName, target: Target) -> RefEdit { + RefEdit { + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(target), + log: RefLog::AndReference, + }, + name, + deref: false, + } +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +pub(crate) fn save_manual(repository_path: &Path, bare: bool, id: ObjectId) -> Result<String> { + let repo = open_repository(repository_path, bare, false).context("could not open repository to stash changes")?; + let workdir = repo + .workdir() + .context("stashing changes requires a worktree")? + .to_owned(); + let head = repo + .head_id() + .context("stashing changes requires a born HEAD")? + .detach(); + if head != id { + anyhow::bail!("changes can only be stashed at the current HEAD"); + } + if repo + .index_or_empty() + .context("could not inspect the index before stashing")? + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("cannot stash changes with unresolved index conflicts"); + } + let name = reference(id)?; + if repo.try_find_reference(name.as_ref())?.is_some() { + anyhow::bail!("{} already has saved worktree state", id.to_hex_with_len(7)); + } + drop(repo); + if !super::review::is_dirty(&workdir)? { + anyhow::bail!("there are no worktree or index changes to stash"); + } + let saved = save( + repository_path, + bare, + &workdir, + name, + format!("tix {}", id.to_hex_with_len(7)), + "tix commit stash", + "commit state", + )?; + let mut notice = format!("stashed changes at {}", id.to_hex_with_len(7)); + if let Some(warning) = saved.warning { + write!(notice, "; {warning}").expect("writing to a string cannot fail"); + } + Ok(notice) +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +pub(crate) fn restore_manual(repository_path: &Path, bare: bool, id: ObjectId) -> Result<String> { + let repo = open_repository(repository_path, bare, false).context("could not open repository to unstash changes")?; + let workdir = repo + .workdir() + .context("unstashing changes requires a worktree")? + .to_owned(); + if repo + .head_id() + .context("unstashing changes requires a born HEAD")? + .detach() + != id + { + anyhow::bail!("changes can only be unstashed at the current HEAD"); + } + let name = reference(id)?; + drop(repo); + let saved = find(repository_path, bare, name)?.context("the selected commit has no saved worktree state")?; + apply(repository_path, bare, &workdir, saved) +} + +#[derive(Clone)] +pub(super) struct SavedStash { + pub name: gix::refs::FullName, + pub target: Target, + pub warning: Option<String>, +} + +#[tracing::instrument(skip_all, fields(stash = %name))] +pub(super) fn save( + repository_path: &Path, + bare: bool, + workdir: &Path, + name: gix::refs::FullName, + message: String, + reflog_message: &'static str, + state_label: &'static str, +) -> Result<SavedStash> { + let repo = open_repository(repository_path, bare, false) + .with_context(|| format!("could not open repository to save {state_label}"))?; + if repo.try_find_reference(name.as_ref())?.is_some() { + anyhow::bail!("{state_label} is already saved"); + } + let previous = repo + .try_find_reference("refs/stash")? + .and_then(|mut reference| reference.peel_to_id().ok().map(gix::Id::detach)); + drop(repo); + + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["stash", "push", "--include-untracked", "--quiet", "--message"]) + .arg(message) + .output() + .context("could not launch git stash push")?; + if !output.status.success() { + anyhow::bail!("git stash push failed: {}", output.stderr.trim().to_str_lossy()); + } + + let repo = open_repository(repository_path, bare, false).context("could not reopen repository after stashing")?; + let mut stash = repo + .try_find_reference("refs/stash")? + .context("git stash push did not create refs/stash")?; + let id = stash.peel_to_id()?.detach(); + if previous == Some(id) { + anyhow::bail!("git stash push did not create a new stash"); + } + let target = Target::Object(id); + if let Err(err) = repo.edit_references([RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: reflog_message.into(), + }, + expected: PreviousValue::MustNotExist, + new: target.clone(), + }, + name: name.clone(), + deref: false, + }]) { + drop(repo); + let restore = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["stash", "pop", "--index", "--quiet"]) + .output(); + return Err(anyhow::anyhow!(err)).context(match restore { + Ok(output) if output.status.success() => { + format!("could not retain {state_label}; original state was restored") + } + Ok(output) => format!( + "could not retain {state_label} and git stash pop failed: {}", + output.stderr.trim().to_str_lossy() + ), + Err(restore) => { + format!("could not retain {state_label} and could not launch git stash pop: {restore}") + } + }); + } + drop(repo); + + let warning = match current(repository_path, bare)? { + Some(current) if current == id => { + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["stash", "drop", "--quiet", "stash@{0}"]) + .output() + .context("could not launch git stash drop")?; + (!output.status.success()).then(|| { + format!( + "{state_label} was saved, but its ordinary stash entry remains: {}", + output.stderr.trim().to_str_lossy() + ) + }) + } + _ => Some(format!( + "{state_label} was saved, but refs/stash changed before its entry could be dropped" + )), + }; + tracing::info!(stash = %name, %id, "saved worktree state"); + Ok(SavedStash { name, target, warning }) +} + +fn current(repository_path: &Path, bare: bool) -> Result<Option<ObjectId>> { + let repo = open_repository(repository_path, bare, false).context("could not inspect refs/stash")?; + let Some(mut reference) = repo.try_find_reference("refs/stash")? else { + return Ok(None); + }; + Ok(Some(reference.peel_to_id()?.detach())) +} + +pub(super) fn find(repository_path: &Path, bare: bool, name: gix::refs::FullName) -> Result<Option<SavedStash>> { + let repo = open_repository(repository_path, bare, false).context("could not inspect saved worktree state")?; + let Some(reference) = repo.try_find_reference(name.as_ref())? else { + return Ok(None); + }; + Ok(Some(SavedStash { + name, + target: reference.target().into_owned(), + warning: None, + })) +} + +#[tracing::instrument(skip_all, fields(stash = %stash.name))] +pub(super) fn apply(repository_path: &Path, bare: bool, workdir: &Path, stash: SavedStash) -> Result<String> { + let repo = open_repository(repository_path, bare, false) + .context("could not open repository before applying saved worktree state")?; + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["stash", "apply", "--index", "--quiet"]) + .arg(stash.name.as_bstr().to_str_lossy().as_ref()) + .output() + .context("could not launch git stash apply")?; + let deletion = repo.edit_references([RefEdit { + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(stash.target), + log: RefLog::AndReference, + }, + name: stash.name.clone(), + deref: false, + }]); + let mut notice = if output.status.success() { + format!("restored {}", stash.name.shorten()) + } else { + format!( + "{} restore needs attention: {}", + stash.name.shorten(), + output.stderr.trim().to_str_lossy() + ) + }; + if let Err(err) = deletion { + write!(notice, "; stash reference remains: {err}").expect("writing to a string cannot fail"); + } + tracing::info!(stash = %stash.name, success = output.status.success(), "applied saved worktree state"); + Ok(notice) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(output.stdout) + } + + #[test] + fn manual_stashes_preserve_git_stashes_and_restore_index_and_worktree_state() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + git(fixture.path(), &["init", "-q", "-b", "main"])?; + crate::test_repository::disable_autocrlf(fixture.path())?; + git(fixture.path(), &["config", "user.name", "user"])?; + git(fixture.path(), &["config", "user.email", "user@example.com"])?; + std::fs::write(fixture.path().join("tracked"), "base\n")?; + std::fs::write(fixture.path().join(".gitignore"), "ignored\n")?; + git(fixture.path(), &["add", "."])?; + git(fixture.path(), &["-c", "commit.gpgSign=false", "commit", "-qm", "base"])?; + let head = ObjectId::from_hex(git(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + + std::fs::write(fixture.path().join("ordinary"), "ordinary stash\n")?; + git( + fixture.path(), + &["stash", "push", "--include-untracked", "-qm", "ordinary"], + )?; + let ordinary = ObjectId::from_hex(git(fixture.path(), &["rev-parse", "refs/stash"])?.trim())?; + + std::fs::write(fixture.path().join("staged"), "staged\n")?; + git(fixture.path(), &["add", "staged"])?; + std::fs::write(fixture.path().join("tracked"), "unstaged\n")?; + std::fs::write(fixture.path().join("untracked"), "untracked\n")?; + std::fs::write(fixture.path().join("ignored"), "ignored\n")?; + + let repo = crate::test_repository::open(fixture.path())?; + let repository_path = repo.git_dir().to_owned(); + drop(repo); + let notice = save_manual(&repository_path, false, head)?; + assert!(notice.contains("stashed changes")); + + let repo = crate::test_repository::open(fixture.path())?; + assert_eq!(repo.find_reference("refs/stash")?.id(), ordinary); + let name = reference(head)?; + assert!(repo.try_find_reference(name.as_ref())?.is_some()); + assert!( + git(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?.is_empty(), + "the tracked and untracked changes were stashed" + ); + assert_eq!(std::fs::read(fixture.path().join("ignored"))?, b"ignored\n"); + drop(repo); + + std::fs::write(fixture.path().join("local"), "existing worktree change\n")?; + restore_manual(&repository_path, false, head)?; + assert_eq!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.trim(), + b"staged" + ); + assert_eq!(git(fixture.path(), &["diff", "--name-only"])?.trim(), b"tracked"); + assert_eq!(std::fs::read(fixture.path().join("untracked"))?, b"untracked\n"); + assert_eq!( + std::fs::read(fixture.path().join("local"))?, + b"existing worktree change\n", + "unstashing leaves unrelated worktree changes intact" + ); + let repo = crate::test_repository::open(fixture.path())?; + assert!(repo.try_find_reference(name.as_ref())?.is_none()); + assert_eq!(repo.find_reference("refs/stash")?.id(), ordinary); + Ok(()) + } +} diff --git a/gix-tix/src/edit/time_travel.rs b/gix-tix/src/edit/time_travel.rs new file mode 100644 index 00000000000..72c934aa0bd --- /dev/null +++ b/gix-tix/src/edit/time_travel.rs @@ -0,0 +1,2666 @@ +use std::{ + collections::HashMap, + ffi::OsString, + fmt::Write as _, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::ByteSlice, + refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +use crate::{history, open_repository}; + +#[cfg(test)] +use super::stash::SavedStash; + +pub(crate) enum Perform { + Complete { + notice: Option<String>, + selected: ObjectId, + ref_rewrites: Vec<super::rebase::RefRewrite>, + ref_changes: Vec<super::undo::RefChange>, + }, + Conflict(Conflict), +} + +#[derive(Clone, Debug)] +struct RememberedBranch { + branch: gix::refs::FullName, + branch_tip: ObjectId, +} + +impl Perform { + #[cfg(test)] + pub(crate) fn complete(self) -> Result<Option<String>> { + match self { + Perform::Complete { notice, .. } => Ok(notice), + Perform::Conflict(_) => anyhow::bail!("time-travel unexpectedly suspended on a conflict"), + } + } +} + +pub(crate) struct Conflict { + rebase: super::rebase::Conflict, + repository_path: PathBuf, + bare: bool, + revisions: Vec<OsString>, + include_worktrees: bool, + ref_rewrites: Vec<super::rebase::RefRewrite>, + ref_changes: Vec<super::undo::RefChange>, +} + +impl Conflict { + pub(crate) fn from_rebase( + rebase: super::rebase::Conflict, + repository_path: &Path, + bare: bool, + revisions: &[OsString], + include_worktrees: bool, + ) -> Self { + Conflict { + rebase, + repository_path: repository_path.to_owned(), + bare, + revisions: revisions.to_vec(), + include_worktrees, + ref_rewrites: Vec::new(), + ref_changes: Vec::new(), + } + } + + pub(crate) fn original(&self) -> ObjectId { + self.rebase.original() + } + + pub(crate) fn prepend_ref_changes(&mut self, mut changes: Vec<super::undo::RefChange>) { + changes.append(&mut self.ref_changes); + self.ref_changes = changes; + } + + pub(crate) fn into_ref_changes(self) -> Vec<super::undo::RefChange> { + self.ref_changes + } + + #[tracing::instrument(skip_all, fields(commit_id = %self.rebase.original()))] + pub(crate) fn accept( + self, + ) -> Result<( + String, + ObjectId, + Vec<super::rebase::RefRewrite>, + Vec<super::undo::RefChange>, + )> { + let mut conflict = self.rebase.persist()?; + let mut ref_rewrites = self.ref_rewrites; + let mut ref_changes = self.ref_changes; + ref_rewrites.append(&mut conflict.ref_rewrites); + ref_changes.append(&mut conflict.ref_changes); + let (notice, mut checkout_changes) = move_head_to_reporting( + &self.repository_path, + self.bare, + conflict.commit, + None, + &self.revisions, + self.include_worktrees, + |id| conflict.map(id), + )?; + ref_changes.append(&mut checkout_changes); + let mut deletion_changes = + delete_deferred_refs(&self.repository_path, self.bare, &conflict.deferred_ref_deletions)?; + ref_changes.append(&mut deletion_changes); + conflict.materialize()?; + Ok(( + format!( + "{}; ready to resolve conflicts", + notice.unwrap_or_else(|| format!("checked out {}", conflict.commit.to_hex_with_len(7))) + ), + conflict.commit, + ref_rewrites, + ref_changes, + )) + } +} + +#[tracing::instrument(skip_all, fields(commit_id = %conflict.original()))] +pub(crate) fn materialize_plan_conflict_reporting( + conflict: super::rebase::PlanConflict, + repository_path: &Path, + bare: bool, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<( + String, + ObjectId, + Vec<super::rebase::RefRewrite>, + Vec<super::undo::RefChange>, +)> { + let original = conflict.original(); + let mapped_head = conflict + .repository() + .head()? + .id() + .map(gix::Id::detach) + .map(|id| (id, conflict.map(id))); + let mut conflict = conflict.into_conflict().persist()?; + let mut ref_changes = std::mem::take(&mut conflict.ref_changes); + let (notice, mut checkout_changes) = move_head_to_reporting( + repository_path, + bare, + conflict.commit, + None, + revisions, + include_worktrees, + |id| match mapped_head { + Some((head, mapped)) if head == id => mapped, + _ => conflict.map(id), + }, + )?; + ref_changes.append(&mut checkout_changes); + let mut deletion_changes = delete_deferred_refs(repository_path, bare, &conflict.deferred_ref_deletions)?; + ref_changes.append(&mut deletion_changes); + conflict.materialize()?; + tracing::warn!(commit_id = %original, rewritten_id = %conflict.commit, "materialized rebase-todo conflict"); + Ok(( + format!( + "{}; ready to resolve conflicts", + notice.unwrap_or_else(|| format!("checked out {}", conflict.commit.to_hex_with_len(7))) + ), + conflict.commit, + conflict.ref_rewrites, + ref_changes, + )) +} + +#[cfg(test)] +pub(crate) fn checkout_without_replay( + repository_path: &Path, + bare: bool, + selected: ObjectId, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<Option<String>> { + Ok(move_head_to_reporting( + repository_path, + bare, + selected, + None, + revisions, + include_worktrees, + Some, + )? + .0) +} + +#[cfg(test)] +pub(crate) fn checkout_review_return( + repository_path: &Path, + bare: bool, + name: &gix::refs::FullName, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<(ObjectId, Option<String>)> { + let (selected, notice, _) = + checkout_review_return_reporting(repository_path, bare, name, revisions, include_worktrees)?; + Ok((selected, notice)) +} + +pub(crate) fn checkout_review_return_reporting( + repository_path: &Path, + bare: bool, + name: &gix::refs::FullName, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<(ObjectId, Option<String>, Vec<super::undo::RefChange>)> { + let repository = open_repository(repository_path, bare, false).context("could not open review return checkout")?; + let workdir = repository + .workdir() + .context("review cancellation requires a worktree")? + .to_owned(); + let mut target = repository + .find_reference(name.as_ref()) + .context("the review return reference is missing")?; + let reference = if name.as_bstr().starts_with(history::PIN_PREFIX) { + target.target().try_name().map(ToOwned::to_owned) + } else { + Some(name.clone()) + }; + let selected = target + .peel_to_id() + .context("the review return reference does not resolve")? + .detach(); + drop(repository); + checkout(&workdir, [OsString::from("--force"), OsString::from("HEAD")]) + .context("could not discard the cancelled review checkout")?; + let (notice, ref_changes) = move_head_to_reporting( + repository_path, + bare, + selected, + reference.as_ref(), + revisions, + include_worktrees, + |_| None, + )?; + Ok((selected, notice, ref_changes)) +} + +#[cfg(test)] +pub(crate) fn checkout_plan( + repository_path: &Path, + bare: bool, + outcome: &super::rebase::Outcome, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<Option<String>> { + Ok(checkout_plan_reporting(repository_path, bare, outcome, revisions, include_worktrees)?.0) +} + +pub(crate) fn checkout_plan_reporting( + repository_path: &Path, + bare: bool, + outcome: &super::rebase::Outcome, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<(Option<String>, Vec<super::undo::RefChange>)> { + let selected = outcome.selected.context("the rebase plan does not select a checkout")?; + let mut ref_changes = outcome.ref_changes.clone(); + let (notice, mut checkout_changes) = move_head_to_reporting( + repository_path, + bare, + selected, + outcome.checkout_reference.as_ref(), + revisions, + include_worktrees, + |id| outcome.map(id), + )?; + ref_changes.append(&mut checkout_changes); + let mut deletion_changes = delete_deferred_refs(repository_path, bare, &outcome.deferred_ref_deletions)?; + ref_changes.append(&mut deletion_changes); + Ok((notice, ref_changes)) +} + +#[cfg(test)] +fn move_head_to<F>( + repository_path: &Path, + bare: bool, + selected: ObjectId, + reference: Option<&gix::refs::FullName>, + revisions: &[OsString], + include_worktrees: bool, + map_departure: F, +) -> Result<Option<String>> +where + F: FnOnce(ObjectId) -> Option<ObjectId>, +{ + Ok(move_head_to_reporting( + repository_path, + bare, + selected, + reference, + revisions, + include_worktrees, + map_departure, + )? + .0) +} + +fn move_head_to_reporting<F>( + repository_path: &Path, + bare: bool, + selected: ObjectId, + reference: Option<&gix::refs::FullName>, + revisions: &[OsString], + include_worktrees: bool, + map_departure: F, +) -> Result<(Option<String>, Vec<super::undo::RefChange>)> +where + F: FnOnce(ObjectId) -> Option<ObjectId>, +{ + let repository = open_repository(repository_path, bare, false).context("could not open repository for checkout")?; + let head_name: gix::refs::FullName = "HEAD".try_into().expect("valid reference name"); + let head_before = super::undo::state(&repository, head_name.as_ref())?; + let workdir = repository + .workdir() + .context("time-travel requires a worktree")? + .to_owned(); + let head = repository.head().context("could not read HEAD before time-travel")?; + let head_id = head + .id() + .map(gix::Id::detach) + .context("cannot time-travel from an unborn HEAD")?; + let head_ref = head.referent_name().map(ToOwned::to_owned); + let departure = map_departure(head_id); + drop(head); + if selected == head_id && head_ref.as_ref() == reference { + return Ok((None, Vec::new())); + } + let mut ref_changes = Vec::new(); + let pins = history::all_pins(&repository)?; + let destination_pin = selected_pin(&pins, selected); + let direct_head_pin = if reference.is_none() { + pins.into_iter().find(|pin| { + pin.is_head() + && pin.id == selected + && pin.target.try_name().is_some_and(|branch| { + branch.as_bstr().starts_with(b"refs/heads/") + && ensure_branch_is_available(&repository, branch).is_ok() + }) + }) + } else { + None + }; + let pin_for_checkout = direct_head_pin.as_ref().or(destination_pin.as_ref()); + let checkout_detaches = reference.is_none() && pin_for_checkout.is_none_or(|pin| pin.target.try_name().is_none()); + let head_pin = match head_ref + .as_ref() + .filter(|name| checkout_detaches && name.as_bstr().starts_with(b"refs/heads/")) + { + Some(name) => { + let (pin, mut changes) = create_or_update_head_pin_reporting(&repository, name, head_id)?; + ref_changes.append(&mut changes); + Some(pin) + } + None => None, + }; + let provisional = match head_pin + .is_none() + .then_some(departure) + .flatten() + .filter(|departure| *departure != selected) + .filter(|departure| !contains(&repository, *departure, selected)) + { + Some(departure) => { + let target = head_ref.clone().map_or(Target::Object(departure), Target::Symbolic); + let (pin, created, mut changes) = + create_or_reuse_pin_reporting(&repository, target, departure, "tix time-travel")?; + ref_changes.append(&mut changes); + Some((pin, created)) + } + None => None, + }; + let head_after_checkout = reference.map_or_else( + || match pin_for_checkout.map(|pin| &pin.target) { + Some(Target::Symbolic(name)) => super::undo::State::Symbolic(name.clone()), + Some(Target::Object(id)) => super::undo::State::Object(*id), + None => super::undo::State::Object(selected), + }, + |name| super::undo::State::Symbolic(name.clone()), + ); + drop(repository); + let checkout = match (reference, pin_for_checkout) { + (Some(reference), _) if reference.as_bstr().starts_with(b"refs/heads/") => { + checkout_branch(&workdir, reference.as_ref()) + } + (Some(reference), _) => checkout_reference(repository_path, bare, &workdir, selected, reference), + (None, Some(pin)) => checkout_pin(&workdir, pin), + (None, None) => checkout_detached(&workdir, selected), + }; + if let Err(checkout) = checkout { + let cleanup = open_repository(repository_path, bare, false) + .context("could not reopen repository to restore provisional references") + .and_then(|repository| super::undo::apply_reversed_changes(&repository, &ref_changes)); + if let Err(cleanup) = cleanup { + return Err(checkout.context(format!("checkout failed and provisional refs remain: {cleanup:#}"))); + } + return Err(checkout); + } + if head_before != head_after_checkout { + ref_changes.push(super::undo::RefChange { + name: head_name, + before: head_before, + after: head_after_checkout, + }); + } + let mut notice = match (reference, direct_head_pin.as_ref(), destination_pin.as_ref()) { + (Some(reference), _, _) => format!("checked out {}", reference.shorten()), + (None, Some(pin), _) => format!( + "returned to {}", + pin.target + .try_name() + .expect("a direct HEAD return is symbolic") + .shorten() + ), + (None, None, Some(pin)) => format!("returned from {}", pin_label(pin)), + (None, None, None) => format!("time-travelled to {}", selected.to_hex_with_len(7)), + }; + let repository = match open_repository(repository_path, bare, false) { + Ok(repository) => repository, + Err(err) => { + notice = format!("{notice}; post-checkout cleanup skipped: {err:#}"); + return Ok((Some(notice), ref_changes)); + } + }; + if let Some(pin) = destination_pin { + match delete_pin_reporting(&repository, &pin) { + Ok(mut changes) => ref_changes.append(&mut changes), + Err(err) => notice = format!("{notice}; destination pin remains: {err:#}"), + } + } + match reconcile_head_pin_reporting(&repository, &workdir) { + Ok((addition, mut changes)) => { + ref_changes.append(&mut changes); + if let Some(addition) = addition { + notice = format!("{notice}; {addition}"); + } + } + Err(err) => notice = format!("{notice}; HEAD-pin reconciliation failed: {err:#}"), + } + if let Some((provisional, _)) = provisional { + let snapshot = history::snapshot_ignoring_pin( + &repository, + revisions, + &[], + include_worktrees, + Some(provisional.name.as_bstr()), + ); + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(err) => { + notice = format!("{notice}; kept {}: {err:#}", pin_label(&provisional)); + return Ok((Some(notice), ref_changes)); + } + }; + if snapshot + .view_tips + .iter() + .copied() + .any(|tip| contains(&repository, provisional.id, tip)) + { + match delete_pin_reporting(&repository, &provisional) { + Ok(mut changes) => ref_changes.append(&mut changes), + Err(err) => notice = format!("{notice}; redundant {} remains: {err:#}", pin_label(&provisional)), + } + } else { + notice = format!("{notice}; saved {}", pin_label(&provisional)); + } + } + Ok((Some(notice), ref_changes)) +} + +fn remembered_branch(repository: &gix::Repository) -> Result<RememberedBranch> { + let pin = history::all_pins(repository)? + .into_iter() + .find(history::Pin::is_head) + .context("attaching requires a valid HEAD pin")?; + let branch = pin + .target + .try_name() + .context("the HEAD pin must point to a local branch")? + .to_owned(); + if !branch.as_bstr().starts_with(b"refs/heads/") { + anyhow::bail!("the HEAD pin must point to a local branch"); + } + Ok(RememberedBranch { + branch, + branch_tip: pin.id, + }) +} + +fn validate_attach(repository: &gix::Repository, head_id: ObjectId, remembered: &RememberedBranch) -> Result<()> { + let head = repository.head().context("could not read HEAD before attaching")?; + if !head.is_detached() || head.id().map(gix::Id::detach) != Some(head_id) { + anyhow::bail!("HEAD changed while preparing to attach"); + } + drop(head); + let pin = history::all_pins(repository)? + .into_iter() + .find(history::Pin::is_head) + .context("the HEAD pin disappeared while preparing to attach")?; + if pin.target.try_name() != Some(remembered.branch.as_ref()) || pin.id != remembered.branch_tip { + anyhow::bail!("the HEAD pin changed while preparing to attach"); + } + let branch_id = repository + .find_reference(remembered.branch.as_ref()) + .context("the remembered branch disappeared while preparing to attach")? + .try_id() + .context("the remembered branch must be a direct reference")? + .detach(); + if branch_id != remembered.branch_tip { + anyhow::bail!("the remembered branch changed while preparing to attach"); + } + ensure_branch_is_available(repository, remembered.branch.as_ref()) +} + +#[cfg(test)] +pub(crate) fn attach( + repository_path: &Path, + bare: bool, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<String> { + Ok(attach_reporting(repository_path, bare, revisions, include_worktrees)?.0) +} + +pub(crate) fn attach_reporting( + repository_path: &Path, + bare: bool, + revisions: &[OsString], + include_worktrees: bool, +) -> Result<(String, Vec<super::undo::RefChange>)> { + let repository = open_repository(repository_path, bare, false) + .context("could not open repository to attach the remembered branch")?; + repository.workdir().context("attaching requires a worktree")?; + let head = repository.head().context("could not read HEAD before attaching")?; + if !head.is_detached() { + anyhow::bail!("attaching requires detached HEAD"); + } + let head_id = head + .id() + .map(gix::Id::detach) + .context("attaching requires an existing HEAD commit")?; + drop(head); + let remembered = remembered_branch(&repository)?; + validate_attach(&repository, head_id, &remembered)?; + + let pins = history::all_pins(&repository)?; + let destination_pin = selected_pin(&pins, head_id); + let mut ref_changes = Vec::new(); + let provisional = if remembered.branch_tip != head_id && !contains(&repository, remembered.branch_tip, head_id) { + let (pin, created, mut changes) = create_or_reuse_pin_reporting( + &repository, + Target::Object(remembered.branch_tip), + remembered.branch_tip, + "tix attach departure", + )?; + ref_changes.append(&mut changes); + Some((pin, created)) + } else { + None + }; + let mut edits = Vec::with_capacity(2); + if remembered.branch_tip != head_id { + edits.push(checked_ref_edit( + remembered.branch.clone(), + Target::Object(remembered.branch_tip), + Target::Object(head_id), + "tix attach", + )); + } + edits.push(checked_ref_edit( + "HEAD".try_into().expect("valid reference name"), + Target::Object(head_id), + Target::Symbolic(remembered.branch.clone()), + "tix attach", + )); + let applied = match repository + .edit_references(edits) + .context("could not move and attach the remembered branch") + { + Ok(applied) => applied, + Err(err) => { + return Err(match provisional.as_ref() { + Some(pin) => cleanup_new_pins(&repository, std::slice::from_ref(pin), err), + None => err, + }); + } + }; + let mut attach_changes = super::undo::changes_from_edits(applied)?; + ref_changes.append(&mut attach_changes); + + let mut notice = format!( + "attached {} at {}", + remembered.branch.shorten(), + head_id.to_hex_with_len(7) + ); + if let Some(pin) = destination_pin { + match delete_pin_reporting(&repository, &pin) { + Ok(mut changes) => ref_changes.append(&mut changes), + Err(err) => notice = format!("{notice}; destination pin remains: {err:#}"), + } + } + if let Some((pin, _)) = provisional { + let snapshot = + history::snapshot_ignoring_pin(&repository, revisions, &[], include_worktrees, Some(pin.name.as_bstr())); + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(err) => { + notice = format!("{notice}; kept {}: {err:#}", pin_label(&pin)); + return Ok((notice, ref_changes)); + } + }; + if snapshot + .view_tips + .iter() + .copied() + .any(|tip| contains(&repository, pin.id, tip)) + { + match delete_pin_reporting(&repository, &pin) { + Ok(mut changes) => ref_changes.append(&mut changes), + Err(err) => notice = format!("{notice}; redundant {} remains: {err:#}", pin_label(&pin)), + } + } else { + notice = format!("{notice}; saved {}", pin_label(&pin)); + } + } + Ok((notice, ref_changes)) +} + +fn checked_ref_edit(name: gix::refs::FullName, old: Target, new: Target, message: &str) -> RefEdit { + RefEdit { + name, + deref: false, + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::MustExistAndMatch(old), + new, + }, + } +} + +fn cleanup_new_pins( + repository: &gix::Repository, + pins: &[(history::Pin, bool)], + mut cause: anyhow::Error, +) -> anyhow::Error { + let edits: Vec<_> = pins + .iter() + .filter(|(_, created)| *created) + .map(|(pin, _)| delete_pin_edit(pin)) + .collect(); + if !edits.is_empty() + && let Err(err) = repository + .edit_references(edits) + .context("could not remove provisional attach pins") + { + cause = cause.context(format!("provisional pin cleanup failed: {err:#}")); + } + cause +} + +fn ensure_branch_is_available(repository: &gix::Repository, branch: &gix::refs::FullNameRef) -> Result<()> { + let current = repository.worktree().context("attaching requires a current worktree")?; + let current_id = current.id().map(ToOwned::to_owned); + if current_id.is_some() { + ensure_worktree_does_not_own_branch( + repository + .main_repo() + .context("could not open the main worktree while checking the remembered branch")?, + branch, + )?; + } + for proxy in repository + .worktrees() + .context("could not enumerate worktrees while checking the remembered branch")? + { + if current_id + .as_ref() + .is_some_and(|current| current.as_slice() == proxy.id().as_bytes()) + { + continue; + } + ensure_worktree_does_not_own_branch( + proxy + .into_repo_with_possibly_inaccessible_worktree() + .context("could not inspect a linked worktree while checking the remembered branch")?, + branch, + )?; + } + Ok(()) +} + +fn ensure_worktree_does_not_own_branch(worktree: gix::Repository, branch: &gix::refs::FullNameRef) -> Result<()> { + let head = worktree + .head() + .context("could not inspect another worktree HEAD while checking the remembered branch")?; + if head.referent_name() == Some(branch) { + anyhow::bail!("{} is checked out in another worktree", branch.shorten()); + } + Ok(()) +} + +pub(crate) fn perform( + repository_path: &Path, + bare: bool, + selected: ObjectId, + graph: &history::HistoryGraph, + review_roots: &[ObjectId], + revisions: &[OsString], + include_worktrees: bool, +) -> Result<Perform> { + perform_reporting_rebased( + repository_path, + bare, + selected, + graph, + review_roots, + revisions, + include_worktrees, + |_| {}, + ) +} + +#[tracing::instrument(skip_all, fields(commit_id = %selected))] +#[expect( + clippy::too_many_arguments, + reason = "time travel context plus rebased-commit reporting" +)] +pub(crate) fn perform_reporting_rebased( + repository_path: &Path, + bare: bool, + mut selected: ObjectId, + graph: &history::HistoryGraph, + review_roots: &[ObjectId], + revisions: &[OsString], + include_worktrees: bool, + mut report: impl FnMut(ObjectId), +) -> Result<Perform> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for time-travel")?; + repository.workdir().context("time-travel requires a worktree")?; + let head = repository.head().context("could not read HEAD before time-travel")?; + let Some(mut head_id) = head.id().map(gix::Id::detach) else { + anyhow::bail!("cannot time-travel from an unborn HEAD"); + }; + let head_was_detached = head.is_detached(); + drop(head); + if repository + .index_or_empty() + .context("could not inspect the index before time-travel")? + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("cannot time-travel with unresolved index conflicts"); + } + let source_review = review_tree(&repository, graph, review_roots, head_id)?; + let destination_review = review_tree(&repository, graph, review_roots, selected)?; + let crosses_review_boundary = + source_review.as_ref().map(|review| review.root) != destination_review.as_ref().map(|review| review.root); + let mut completed_graph = None; + let mut original_ids = HashMap::new(); + let mut ref_rewrites = Vec::new(); + let mut ref_changes = Vec::new(); + let mut pending = pending_base(&repository, selected)?; + while let Some(base) = pending { + let graph = completed_graph.as_ref().unwrap_or(graph); + let mut rebased = Vec::new(); + let outcome = super::rebase::perform_reporting_rebased( + &repository, + graph, + super::rebase::Edit::Repeat { + base, + checkout: selected, + }, + super::rebase::Signature::RedoIfNeeded, + super::rebase::Tree::CherryPick, + |id| { + let original = original_ids.get(&id).copied().unwrap_or(id); + rebased.push((id, original)); + if graph.is_ancestor(id, selected) { + report(original); + } + }, + )?; + let outcome = match outcome { + super::rebase::Perform::Complete(outcome) => outcome, + super::rebase::Perform::Conflict(rebase) => { + return Ok(Perform::Conflict(Conflict { + rebase, + repository_path: repository_path.to_owned(), + bare, + revisions: revisions.to_vec(), + include_worktrees, + ref_rewrites, + ref_changes, + })); + } + }; + ref_rewrites.extend(outcome.ref_rewrites.iter().cloned()); + ref_changes.extend(outcome.ref_changes.iter().cloned()); + for &(old, original) in &rebased { + if let Some(new) = outcome.map(old) { + original_ids.insert(new, original); + } + } + selected = outcome + .map(selected) + .context("the time-travel destination disappeared while completing its rebase")?; + head_id = outcome + .map(head_id) + .context("HEAD disappeared while completing its rebase")?; + repository = open_repository(repository_path, bare, false) + .context("could not reopen repository after completing a pending rebase")?; + pending = pending_base(&repository, selected)?; + if pending.is_some() { + let affected = rebased + .into_iter() + .map(|(id, _original)| { + outcome + .map(id) + .context("a pending rebase commit disappeared while completing time-travel") + }) + .collect::<Result<Vec<_>>>()?; + completed_graph = Some(history::HistoryGraph::for_commits(&repository, &affected)?); + } + } + let workdir = repository + .workdir() + .context("time-travel requires a worktree")? + .to_owned(); + drop(repository); + + let saved = if crosses_review_boundary { + source_review + .as_ref() + .map(|review| save_review_stash(repository_path, bare, &workdir, review)) + .transpose()? + .flatten() + } else { + None + }; + let moved = move_head_to_reporting( + repository_path, + bare, + selected, + None, + revisions, + include_worktrees, + |actual| { + if head_was_detached { Some(head_id) } else { Some(actual) } + }, + ); + let (mut notice, mut checkout_changes) = match moved { + Ok(outcome) => outcome, + Err(err) => { + let err = match saved { + Some(stash) => match apply_review_stash(repository_path, bare, &workdir, stash) { + Ok(notice) => err.context(format!("source review stash restoration: {notice}")), + Err(restore) => err.context(format!("source review stash could not be restored: {restore:#}")), + }, + None => err, + }; + return Err(err); + } + }; + ref_changes.append(&mut checkout_changes); + if let Some(saved) = saved + && let Some(warning) = saved.warning + { + append_notice(&mut notice, warning); + } + if crosses_review_boundary && let Some(review) = destination_review { + match find_review_stash(repository_path, bare, &review) { + Ok(Some(stash)) => match apply_review_stash(repository_path, bare, &workdir, stash) { + Ok(message) => append_notice(&mut notice, message), + Err(err) => append_notice(&mut notice, format!("review stash remains: {err:#}")), + }, + Ok(None) => {} + Err(err) => append_notice(&mut notice, format!("could not inspect the review stash: {err:#}")), + } + } + match super::stash::reference(selected).and_then(|name| super::stash::find(repository_path, bare, name)) { + Ok(Some(stash)) => match super::stash::apply(repository_path, bare, &workdir, stash) { + Ok(message) => append_notice(&mut notice, message), + Err(err) => append_notice(&mut notice, format!("commit stash remains: {err:#}")), + }, + Ok(None) => {} + Err(err) => append_notice(&mut notice, format!("could not inspect the commit stash: {err:#}")), + } + Ok(Perform::Complete { + notice, + selected, + ref_rewrites, + ref_changes, + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ReviewTree { + root: ObjectId, + reference: gix::refs::FullName, +} + +fn review_tree( + repo: &gix::Repository, + graph: &history::HistoryGraph, + roots: &[ObjectId], + commit: ObjectId, +) -> Result<Option<ReviewTree>> { + let mut nearest = None; + for root in roots.iter().copied().filter(|root| graph.is_ancestor(*root, commit)) { + nearest = match nearest { + None => Some(root), + Some(current) if graph.is_ancestor(current, root) => Some(root), + Some(current) if graph.is_ancestor(root, current) => Some(current), + Some(_) => anyhow::bail!("commit belongs to multiple unrelated review trees"), + }; + } + let Some(root) = nearest else { return Ok(None) }; + let commit = repo.find_commit(root)?.decode()?.into_owned()?; + let reference = super::review::reference(&commit)?.context("review root lost its review identity")?; + Ok(Some(ReviewTree { root, reference })) +} + +#[tracing::instrument(skip_all, fields(review = %review.reference))] +fn save_review_stash( + repository_path: &Path, + bare: bool, + workdir: &Path, + review: &ReviewTree, +) -> Result<Option<super::stash::SavedStash>> { + if !super::review::is_dirty(workdir)? { + return Ok(None); + } + let name = super::review::stash_reference(review.reference.as_bstr())?; + super::stash::save( + repository_path, + bare, + workdir, + name, + format!("tix review {}", review.reference.shorten()), + "tix review auto-stash", + "review state", + ) + .map(Some) +} + +fn find_review_stash( + repository_path: &Path, + bare: bool, + review: &ReviewTree, +) -> Result<Option<super::stash::SavedStash>> { + let name = super::review::stash_reference(review.reference.as_bstr())?; + super::stash::find(repository_path, bare, name) +} + +#[tracing::instrument(skip_all, fields(stash = %stash.name))] +fn apply_review_stash( + repository_path: &Path, + bare: bool, + workdir: &Path, + stash: super::stash::SavedStash, +) -> Result<String> { + super::stash::apply(repository_path, bare, workdir, stash) +} + +fn append_notice(notice: &mut Option<String>, addition: String) { + match notice { + Some(notice) => write!(notice, "; {addition}").expect("writing to a string cannot fail"), + None => *notice = Some(addition), + } +} + +fn pending_base(repository: &gix::Repository, selected: ObjectId) -> Result<Option<ObjectId>> { + let mut current = selected; + let mut base = None; + loop { + let commit = repository + .find_commit(current) + .context("could not inspect a time-travel destination for a pending rebase")? + .decode()? + .into_owned()?; + if !super::rebase::is_pending(&commit) { + break; + } + base = Some(current); + let Some(parent) = commit.parents.first().copied() else { + break; + }; + current = parent; + } + Ok(base) +} + +fn selected_pin(pins: &[history::Pin], selected: ObjectId) -> Option<history::Pin> { + pins.iter() + .filter(|pin| !pin.is_head() && pin.id == selected) + .min_by(|a, b| { + a.target + .try_name() + .is_none() + .cmp(&b.target.try_name().is_none()) + .then_with(|| a.name.cmp(&b.name)) + }) + .cloned() +} + +#[cfg(test)] +fn create_or_update_head_pin( + repository: &gix::Repository, + branch: &gix::refs::FullName, + id: ObjectId, +) -> Result<history::Pin> { + Ok(create_or_update_head_pin_reporting(repository, branch, id)?.0) +} + +fn create_or_update_head_pin_reporting( + repository: &gix::Repository, + branch: &gix::refs::FullName, + id: ObjectId, +) -> Result<(history::Pin, Vec<super::undo::RefChange>)> { + let name: gix::refs::FullName = history::HEAD_PIN_NAME + .as_bstr() + .try_into() + .context("the HEAD pin name is valid")?; + let expected = repository + .try_find_reference(name.as_ref()) + .context("could not read the existing HEAD pin")? + .map_or(PreviousValue::MustNotExist, |reference| { + PreviousValue::MustExistAndMatch(reference.target().into_owned()) + }); + let target = Target::Symbolic(branch.clone()); + let edit = RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "tix remember HEAD branch".into(), + }, + expected, + new: target.clone(), + }, + name: name.clone(), + deref: false, + }; + let applied = repository + .edit_references([edit]) + .context("could not remember the branch HEAD was attached to")?; + let changes = super::undo::changes_from_edits(applied)?; + Ok((history::Pin { name, target, id }, changes)) +} + +fn reconcile_head_pin_reporting( + repository: &gix::Repository, + workdir: &Path, +) -> Result<(Option<String>, Vec<super::undo::RefChange>)> { + let Some(pin) = history::all_pins(repository)?.into_iter().find(history::Pin::is_head) else { + return Ok((None, Vec::new())); + }; + let head = repository.head().context("could not read HEAD after time-travel")?; + let detached = head.is_detached(); + let head_id = head.id().map(gix::Id::detach); + drop(head); + if !detached { + return Ok(match delete_pin_reporting(repository, &pin) { + Ok(changes) => (None, changes), + Err(err) => (Some(format!("HEAD pin remains: {err:#}")), Vec::new()), + }); + } + if head_id != Some(pin.id) { + return Ok((None, Vec::new())); + } + let branch = pin.target.try_name().context("the HEAD pin is not symbolic")?; + if let Err(err) = checkout_branch(workdir, branch) { + return Ok(( + Some(format!( + "could not reattach HEAD to {}: {err:#}; HEAD pin remains", + branch.shorten() + )), + Vec::new(), + )); + } + let mut head_change = vec![super::undo::RefChange { + name: "HEAD".try_into().expect("valid reference name"), + before: super::undo::State::Object(pin.id), + after: super::undo::State::Symbolic(branch.to_owned()), + }]; + Ok(match delete_pin_reporting(repository, &pin) { + Ok(mut changes) => { + head_change.append(&mut changes); + (Some(format!("reattached HEAD to {}", branch.shorten())), head_change) + } + Err(err) => ( + Some(format!( + "reattached HEAD to {}; HEAD pin remains: {err:#}", + branch.shorten() + )), + head_change, + ), + }) +} + +pub(crate) fn create_or_reuse_pin( + repository: &gix::Repository, + target: Target, + id: ObjectId, + reflog_message: &str, +) -> Result<(history::Pin, bool)> { + let (pin, created, _) = create_or_reuse_pin_reporting(repository, target, id, reflog_message)?; + Ok((pin, created)) +} + +pub(crate) fn create_or_reuse_pin_reporting( + repository: &gix::Repository, + target: Target, + id: ObjectId, + reflog_message: &str, +) -> Result<(history::Pin, bool, Vec<super::undo::RefChange>)> { + let pins = history::all_pins(repository)?; + if let Some(pin) = pins.iter().find(|pin| !pin.is_head() && pin.target == target) { + return Ok((pin.clone(), false, Vec::new())); + } + let (pin, changes) = create_pin_reporting(repository, target, id, reflog_message)?; + Ok((pin, true, changes)) +} + +pub(crate) fn create_pin( + repository: &gix::Repository, + target: Target, + id: ObjectId, + reflog_message: &str, +) -> Result<history::Pin> { + Ok(create_pin_reporting(repository, target, id, reflog_message)?.0) +} + +pub(crate) fn create_pin_reporting( + repository: &gix::Repository, + target: Target, + id: ObjectId, + reflog_message: &str, +) -> Result<(history::Pin, Vec<super::undo::RefChange>)> { + let hex = id.to_hex().to_string(); + let mut suffix_len = 8.min(hex.len()); + let mut number = 2; + let name = loop { + let suffix = if suffix_len <= hex.len() { + hex[..suffix_len].to_owned() + } else { + let suffix = format!("{hex}{number}"); + number += 1; + suffix + }; + let name: gix::refs::FullName = format!("{}{}", String::from_utf8_lossy(history::PIN_PREFIX), suffix) + .try_into() + .context("generated an invalid tix pin name")?; + if repository + .try_find_reference(name.as_ref()) + .context("could not check for a colliding tix pin")? + .is_none() + { + break name; + } + if suffix_len < hex.len() { + suffix_len += 1; + } else { + suffix_len = hex.len() + 1; + } + }; + let edit = RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: reflog_message.into(), + }, + expected: PreviousValue::MustNotExist, + new: target.clone(), + }, + name: name.clone(), + deref: false, + }; + let applied = repository.edit_references([edit]).context("could not create tix pin")?; + let changes = super::undo::changes_from_edits(applied)?; + Ok((history::Pin { name, target, id }, changes)) +} + +pub(super) fn delete_pin(repository: &gix::Repository, pin: &history::Pin) -> Result<()> { + delete_pin_reporting(repository, pin).map(drop) +} + +pub(crate) fn delete_pin_reporting( + repository: &gix::Repository, + pin: &history::Pin, +) -> Result<Vec<super::undo::RefChange>> { + let edit = delete_pin_edit(pin); + let applied = repository.edit_references([edit]).context("could not remove tix pin")?; + let changes = super::undo::changes_from_edits(applied)?; + Ok(changes) +} + +#[cfg(test)] +pub(crate) fn remove_pins(repository_path: &Path, bare: bool, selected: ObjectId) -> Result<usize> { + Ok(remove_pins_reporting(repository_path, bare, selected)?.0) +} + +pub(crate) fn remove_pins_reporting( + repository_path: &Path, + bare: bool, + selected: ObjectId, +) -> Result<(usize, Vec<super::undo::RefChange>)> { + let repository = + open_repository(repository_path, bare, false).context("could not open repository to remove pins")?; + let pins: Vec<_> = history::all_pins(&repository)? + .into_iter() + .filter(|pin| !pin.is_head() && pin.id == selected) + .collect(); + if pins.is_empty() { + return Ok((0, Vec::new())); + } + let edits: Vec<_> = pins.iter().map(delete_pin_edit).collect(); + let applied = repository.edit_references(edits).context("could not remove tix pins")?; + let changes = super::undo::changes_from_edits(applied)?; + Ok((pins.len(), changes)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PinToggle { + Created, + Removed(usize), +} + +pub(crate) fn toggle_pin_reporting( + repository_path: &Path, + bare: bool, + selected: ObjectId, +) -> Result<(PinToggle, Vec<super::undo::RefChange>)> { + let repository = + open_repository(repository_path, bare, false).context("could not open repository to toggle a pin")?; + let pins: Vec<_> = history::all_pins(&repository)? + .into_iter() + .filter(|pin| !pin.is_head() && pin.id == selected) + .collect(); + if pins.is_empty() { + let (_, _, changes) = + create_or_reuse_pin_reporting(&repository, Target::Object(selected), selected, "tix TUI pin")?; + return Ok((PinToggle::Created, changes)); + } + drop(repository); + let (removed, changes) = remove_pins_reporting(repository_path, bare, selected)?; + Ok((PinToggle::Removed(removed), changes)) +} + +fn delete_pin_edit(pin: &history::Pin) -> RefEdit { + RefEdit { + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(pin.target.clone()), + log: RefLog::AndReference, + }, + name: pin.name.clone(), + deref: false, + } +} + +fn delete_deferred_refs( + repository_path: &Path, + bare: bool, + refs: &[(gix::refs::FullName, ObjectId)], +) -> Result<Vec<super::undo::RefChange>> { + if refs.is_empty() { + return Ok(Vec::new()); + } + let repository = open_repository(repository_path, bare, false) + .context("could not reopen repository to finish reference deletions")?; + let edits: Vec<_> = refs + .iter() + .map(|(name, old)| RefEdit { + name: name.clone(), + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(*old)), + log: RefLog::AndReference, + }, + }) + .collect(); + let applied = repository + .edit_references(edits) + .context("could not delete the branch HEAD left during rebase")?; + let changes = super::undo::changes_from_edits(applied)?; + Ok(changes) +} + +fn checkout_branch(workdir: &Path, name: &gix::refs::FullNameRef) -> Result<()> { + let branch = name + .as_bstr() + .strip_prefix(b"refs/heads/") + .context("the rebase checkout target is not a local branch")?; + checkout( + workdir, + [ + OsString::from("--no-guess"), + gix::path::from_bstr(branch.as_bstr()).into_owned().into_os_string(), + ], + ) +} + +fn checkout_reference( + repository_path: &Path, + bare: bool, + workdir: &Path, + selected: ObjectId, + name: &gix::refs::FullName, +) -> Result<()> { + checkout_detached(workdir, selected)?; + open_repository(repository_path, bare, false) + .context("could not reopen repository to attach HEAD")? + .edit_reference(RefEdit { + name: "HEAD".try_into().expect("valid reference name"), + deref: false, + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "tix attach HEAD".into(), + }, + expected: PreviousValue::MustExistAndMatch(Target::Object(selected)), + new: Target::Symbolic(name.clone()), + }, + }) + .context("could not attach HEAD to the selected reference")?; + Ok(()) +} + +fn checkout_pin(workdir: &Path, pin: &history::Pin) -> Result<()> { + match pin.target.try_name() { + Some(name) => { + let branch = name + .as_bstr() + .strip_prefix(b"refs/heads/") + .context("a symbolic tix pin does not point to a local branch")?; + checkout( + workdir, + [ + OsString::from("--no-guess"), + gix::path::from_bstr(branch.as_bstr()).into_owned().into_os_string(), + ], + ) + } + None => checkout_detached(workdir, pin.id), + } +} + +pub(super) fn checkout_detached(workdir: &Path, id: ObjectId) -> Result<()> { + checkout( + workdir, + [OsString::from("--detach"), OsString::from(id.to_hex().to_string())], + ) +} + +pub(super) fn checkout(workdir: &Path, args: impl IntoIterator<Item = OsString>) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .arg("checkout") + .args(args) + .output() + .context("could not launch git checkout")?; + if output.status.success() { + return Ok(()); + } + let stderr = output.stderr.trim().to_str_lossy(); + if stderr.is_empty() { + anyhow::bail!("git checkout failed with {}", output.status) + } + anyhow::bail!("git checkout failed with {}: {}", output.status, stderr) +} + +fn contains(repository: &gix::Repository, ancestor: ObjectId, descendant: ObjectId) -> bool { + ancestor == descendant + || repository + .merge_base(ancestor, descendant) + .is_ok_and(|base| base.as_ref() == ancestor) +} + +pub(crate) fn pin_label(pin: &history::Pin) -> String { + format!( + "pin:{}", + pin.name + .as_bstr() + .strip_prefix(history::PIN_PREFIX) + .unwrap_or(pin.name.as_bstr()) + .to_str_lossy() + ) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicBool; + + use super::*; + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!("git {} failed: {}", args.join(" "), output.stderr.trim().to_str_lossy()).into()); + } + Ok(output.stdout) + } + + fn review_stash_fixture() -> gix_testtools::Result<(gix_testtools::tempfile::TempDir, PathBuf, SavedStash)> { + let fixture = gix_testtools::tempfile::tempdir()?; + git(fixture.path(), &["init", "-q", "-b", "main"])?; + git(fixture.path(), &["config", "user.name", "reviewer"])?; + git(fixture.path(), &["config", "user.email", "reviewer@example.com"])?; + std::fs::write(fixture.path().join("file"), "base\n")?; + git(fixture.path(), &["add", "file"])?; + git( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "base"], + )?; + std::fs::write(fixture.path().join("file"), "stashed\n")?; + git(fixture.path(), &["stash", "push", "-q", "-m", "review"])?; + let name: gix::refs::FullName = "refs/worktree/tix/review/stashes/1".try_into()?; + git( + fixture.path(), + &["update-ref", name.as_bstr().to_str_lossy().as_ref(), "refs/stash"], + )?; + git(fixture.path(), &["stash", "drop", "-q", "stash@{0}"])?; + let repo = crate::test_repository::open(fixture.path())?; + let repository_path = repo.git_dir().to_owned(); + let target = repo.find_reference(name.as_ref())?.target().into_owned(); + Ok(( + fixture, + repository_path, + SavedStash { + name, + target, + warning: None, + }, + )) + } + + fn loaded_graph(repository: &gix::Repository, revisions: &[OsString]) -> Result<history::HistoryGraph> { + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new( + history::Authors::default(), + )); + let mut graph = None; + history::load( + repository, + revisions, + &[], + false, + &authors, + &AtomicBool::new(false), + |event| { + if let history::Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + graph.context("history traversal did not produce a graph") + } + + fn pending_conflict_fixture() -> gix_testtools::Result<( + gix_testtools::tempfile::TempDir, + PathBuf, + ObjectId, + ObjectId, + history::HistoryGraph, + )> { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + git( + fixture.path(), + &["config", "gitoxide.commit.committerDate", "2001-01-01T00:00:00 +0000"], + )?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + std::fs::write(fixture.path().join("after"), "after\n")?; + git(fixture.path(), &["add", "after"])?; + let commit = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "-q", "-m", "after"]) + .env("GIT_AUTHOR_DATE", "2000-01-04T00:00:00 +0000") + .env("GIT_COMMITTER_DATE", "2000-01-04T00:00:00 +0000") + .status()?; + assert!(commit.success(), "the fixture descendant commit is created"); + let root = repository.rev_parse_single("HEAD~3")?.detach(); + git(fixture.path(), &["checkout", "-q", "--detach", &root.to_string()])?; + let graph = super::super::loaded_graph(&repository)?; + super::super::rebase::perform( + &repository, + &graph, + super::super::rebase::Edit::Remove { target: middle }, + super::super::rebase::Signature::RedoIfNeeded, + super::super::rebase::Tree::LeaveAsIsAndMark, + )? + .complete()?; + let graph = super::super::loaded_graph(&repository)?; + let tip = repository.find_reference("refs/heads/main")?.id().detach(); + drop(repository); + git(fixture.path(), &["checkout", "-q", "main"])?; + Ok((fixture, repository_path, root, tip, graph)) + } + + #[test] + fn review_state_is_stashed_only_when_crossing_its_tree_boundary() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + git(fixture.path(), &["init", "-q", "-b", "main"])?; + git(fixture.path(), &["config", "user.name", "reviewer"])?; + git(fixture.path(), &["config", "user.email", "reviewer@example.com"])?; + for (name, contents) in [("staged", "base\n"), ("unstaged", "base\n")] { + std::fs::write(fixture.path().join(name), contents)?; + } + git(fixture.path(), &["add", "."])?; + git( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "base"], + )?; + let base = ObjectId::from_hex(git(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + for name in ["staged", "unstaged"] { + std::fs::write(fixture.path().join(name), "tip\n")?; + } + git(fixture.path(), &["-c", "commit.gpgSign=false", "commit", "-qam", "tip"])?; + let tip = ObjectId::from_hex(git(fixture.path(), &["rev-parse", "HEAD"])?.trim())?; + + std::fs::write(fixture.path().join("existing"), "user stash\n")?; + git( + fixture.path(), + &["stash", "push", "--include-untracked", "-q", "-m", "existing"], + )?; + let existing_stash = ObjectId::from_hex(git(fixture.path(), &["rev-parse", "refs/stash"])?.trim())?; + let repo = crate::test_repository::open(fixture.path())?; + let graph = loaded_graph(&repo, &[])?; + drop(repo); + let started = super::super::review::start(fixture.path(), false, &graph, tip, base)?; + + git(fixture.path(), &["add", "staged"])?; + std::fs::write(fixture.path().join("untracked"), "new\n")?; + let before = git(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?; + let child = ObjectId::from_hex( + git( + fixture.path(), + &[ + "-c", + "commit.gpgSign=false", + "commit-tree", + &format!("{}^{{tree}}", started.commit), + "-p", + &started.commit.to_string(), + "-m", + "review child", + ], + )? + .trim(), + )?; + git( + fixture.path(), + &["update-ref", "refs/worktree/tix/pins/child", &child.to_string()], + )?; + let repo = crate::test_repository::open(fixture.path())?; + let repository_path = repo.git_dir().to_owned(); + let graph = loaded_graph(&repo, &[])?; + assert_eq!( + review_tree(&repo, &graph, &[started.commit], started.commit)?.map(|tree| tree.root), + Some(started.commit) + ); + assert_eq!( + review_tree(&repo, &graph, &[started.commit], child)?.map(|tree| tree.root), + Some(started.commit) + ); + drop(repo); + + perform(&repository_path, false, child, &graph, &[started.commit], &[], false)?.complete()?; + assert_eq!( + git(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?, + before, + "moving within one review tree leaves index and worktree handling to checkout" + ); + let stash_name = super::super::review::stash_reference(started.reference.as_bstr())?; + assert!( + crate::test_repository::open(fixture.path())? + .try_find_reference(stash_name.as_ref())? + .is_none(), + "no stash is created inside the review tree" + ); + + perform(&repository_path, false, tip, &graph, &[started.commit], &[], false)?.complete()?; + let repo = crate::test_repository::open(fixture.path())?; + assert_eq!( + repo.head_name()?.expect("HEAD is attached"), + "refs/heads/main", + "leaving the review returns to the attached branch" + ); + let snapshot = history::snapshot(&repo, &[], &[], false)?; + assert_eq!(snapshot.pins.len(), 1, "only the review-tree departure remains pinned"); + assert_eq!(snapshot.pins[0].id, child); + assert!( + snapshot.view_tips.contains(&child), + "a fresh attached-HEAD snapshot retains the review-tree leaf" + ); + assert!(repo.try_find_reference(stash_name.as_ref())?.is_some()); + assert_eq!(repo.find_reference("refs/stash")?.id(), existing_stash); + assert!( + git(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?.is_empty(), + "crossing out leaves the destination clean" + ); + drop(repo); + + perform(&repository_path, false, child, &graph, &[started.commit], &[], false)?.complete()?; + assert_eq!( + git(fixture.path(), &["status", "--porcelain=v1", "--untracked-files=all"])?, + before, + "returning through any review descendant restores exact review state" + ); + let repo = crate::test_repository::open(fixture.path())?; + assert!( + history::all_pins(&repo)?.iter().all(history::Pin::is_head), + "returning consumes the ordinary review-tree pin" + ); + assert!(repo.try_find_reference(stash_name.as_ref())?.is_none()); + assert_eq!(repo.find_reference("refs/stash")?.id(), existing_stash); + Ok(()) + } + + #[test] + fn review_stash_references_are_consumed_after_any_git_apply_result() -> gix_testtools::Result { + let (fixture, repository_path, stash) = review_stash_fixture()?; + std::fs::write(fixture.path().join("file"), "destination\n")?; + git( + fixture.path(), + &["-c", "commit.gpgSign=false", "commit", "-qam", "destination"], + )?; + let notice = apply_review_stash(&repository_path, false, fixture.path(), stash.clone())?; + assert!(notice.contains("needs attention"), "the conflict is reported: {notice}"); + let repo = crate::test_repository::open(fixture.path())?; + assert!(repo.try_find_reference(stash.name.as_ref())?.is_none()); + assert!( + repo.index_or_empty()? + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted), + "Git's ordinary stash conflict remains in the index" + ); + + let (fixture, repository_path, stash) = review_stash_fixture()?; + std::fs::write(fixture.path().join(".git/index.lock"), "locked")?; + let notice = apply_review_stash(&repository_path, false, fixture.path(), stash.clone())?; + assert!( + notice.contains("needs attention"), + "the fatal apply failure is reported: {notice}" + ); + assert!( + crate::test_repository::open(fixture.path())? + .try_find_reference(stash.name.as_ref())? + .is_none(), + "the review stash ref is consumed even when Git cannot apply it" + ); + Ok(()) + } + + #[test] + fn nested_review_trees_use_the_nearest_review_root() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let original = repo.head_id()?.detach(); + let mut commit = repo.find_commit(original)?.decode()?.into_owned()?; + commit.parents = [original].into_iter().collect(); + commit.message = "outer review".into(); + commit + .extra_headers + .push(("tix-rebase".into(), "onto refs/worktree/tix/review/1".into())); + let outer = repo.write_object(&commit)?.detach(); + commit.parents = [outer].into_iter().collect(); + commit.message = "middle".into(); + commit.extra_headers.clear(); + let middle = repo.write_object(&commit)?.detach(); + commit.parents = [middle].into_iter().collect(); + commit.message = "inner review".into(); + commit + .extra_headers + .push(("tix-rebase".into(), "onto refs/worktree/tix/review/2".into())); + let inner = repo.write_object(&commit)?.detach(); + commit.parents = [inner].into_iter().collect(); + commit.message = "tip".into(); + commit.extra_headers.clear(); + let tip = repo.write_object(&commit)?.detach(); + repo.reference( + "refs/heads/main", + tip, + PreviousValue::ExistingMustMatch(Target::Object(original)), + "prepare nested reviews", + )?; + let graph = loaded_graph(&repo, &[])?; + + assert_eq!( + review_tree(&repo, &graph, &[outer, inner], middle)?.map(|tree| tree.root), + Some(outer) + ); + assert_eq!( + review_tree(&repo, &graph, &[outer, inner], tip)?.map(|tree| tree.root), + Some(inner) + ); + Ok(()) + } + + #[test] + fn travels_with_symbolic_and_direct_pins_and_returns() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("main~2")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let topic = repository.rev_parse_single("topic")?.detach(); + let graph = loaded_graph(&repository, &[])?; + assert!(graph.is_ancestor(root, main), "the selected root is known ancestry"); + assert!(history::all_pins(&repository)?.is_empty()); + assert_eq!(open_repository(&repository_path, false, false)?.head_id()?, main); + assert!(!contains(&repository, main, root)); + drop(repository); + + let notice = perform(&repository_path, false, root, &graph, &[], &[], false)? + .complete()? + .context("time-travel changed HEAD")?; + assert!(notice.contains("time-travelled"), "{notice}"); + let repository = crate::test_repository::open(fixture.path())?; + assert!(repository.head()?.is_detached(), "travel detaches HEAD"); + assert_eq!(repository.head_id()?, root); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "the lost branch tip gets one pin"); + assert_eq!( + pins[0].name, "refs/worktree/tix/pins/HEAD", + "an attached departure uses the singleton HEAD pin" + ); + assert_eq!( + pins[0].target.try_name().expect("the pin is symbolic"), + "refs/heads/main" + ); + assert_eq!(pins[0].id, main); + assert!( + history::snapshot(&repository, &[], &[], false)? + .view_tips + .contains(&main) + ); + + let middle = repository.rev_parse_single("main~1")?.detach(); + drop(repository); + perform(&repository_path, false, middle, &graph, &[], &[], false)?.complete()?; + let repository = crate::test_repository::open(fixture.path())?; + assert!(repository.head()?.is_detached(), "further travel remains detached"); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "further travel keeps only the singleton HEAD pin"); + assert!(pins[0].is_head()); + + repository + .find_reference("refs/heads/main")? + .set_target_id(topic, "advance pinned branch")?; + let advanced = history::snapshot(&repository, &[], &[], false)?; + assert!(advanced.view_tips.contains(&topic), "a symbolic pin follows its branch"); + create_pin(&repository, Target::Object(topic), topic, "overlap with the HEAD pin")?; + assert_eq!(history::all_pins(&repository)?.len(), 2, "both return paths coexist"); + drop(repository); + + let reflog_entries = git(fixture.path(), &["reflog", "show", "--format=%H", "HEAD"])? + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .count(); + let returned = perform(&repository_path, false, topic, &graph, &[], &[], false)?; + let Perform::Complete { ref_changes, .. } = returned else { + return Err("returning through the HEAD pin must complete".into()); + }; + let returned_reflog_entries = git(fixture.path(), &["reflog", "show", "--format=%H", "HEAD"])? + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .count(); + assert_eq!( + returned_reflog_entries, + reflog_entries + 1, + "returning through the HEAD pin performs one checkout" + ); + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!( + repository.head_name()?.expect("HEAD is attached"), + "refs/heads/main", + "returning through a symbolic pin reattaches HEAD" + ); + assert!(history::all_pins(&repository)?.is_empty(), "the used pin is removed"); + super::super::undo::record(&repository, "return to branch", &ref_changes)?; + super::super::undo::plan_undo(&repository)? + .expect("the return can be undone") + .apply_with_worktrees(&repository)?; + assert!(repository.head()?.is_detached(), "undo restores the detached HEAD"); + assert_eq!(repository.head_id()?, middle); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 2, "undo restores both return paths"); + assert!( + pins.iter().any(history::Pin::is_head), + "undo restores the symbolic HEAD pin" + ); + super::super::undo::plan_redo(&repository)? + .expect("the return can be redone") + .apply_with_worktrees(&repository)?; + assert_eq!( + repository.head_name()?.expect("HEAD is attached"), + "refs/heads/main", + "redo reattaches HEAD" + ); + assert!(history::all_pins(&repository)?.is_empty(), "redo consumes the HEAD pin"); + + let detach = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "--detach", &main.to_hex().to_string()]) + .status()?; + assert!(detach.success()); + let graph = loaded_graph(&crate::test_repository::open(fixture.path())?, &[])?; + perform(&repository_path, false, root, &graph, &[], &[], false)?.complete()?; + let pin = history::all_pins(&crate::test_repository::open(fixture.path())?)? + .pop() + .context("direct pin is present")?; + assert_eq!(pin.target.try_id().map(ToOwned::to_owned), Some(main)); + perform(&repository_path, false, main, &graph, &[], &[], false)?.complete()?; + let repository = crate::test_repository::open(fixture.path())?; + assert!( + repository.head()?.is_detached(), + "a direct pin returns to detached HEAD" + ); + assert_eq!(repository.head_id()?, main); + assert!(history::all_pins(&repository)?.is_empty()); + Ok(()) + } + + #[test] + fn attach_moves_and_attaches_the_remembered_branch_without_touching_files() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let middle = repository.rev_parse_single("main~1")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + perform(&repository_path, false, middle, &graph, &[], &[], false)?.complete()?; + std::fs::write(fixture.path().join("root"), "dirty root\n")?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + + let notice = attach(&repository_path, false, &[], false)?; + assert!(notice.contains("attached main at"), "{notice}"); + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!( + repository.head_name()?.expect("HEAD is attached"), + "refs/heads/main", + "HEAD attaches to the remembered branch" + ); + assert_eq!(repository.head_id()?, middle); + assert_eq!(repository.find_reference("refs/heads/main")?.id(), middle); + let pins = history::all_pins(&repository)?; + assert!( + pins.iter().any(|pin| pin.is_head() && pin.id == middle), + "the symbolic HEAD pin remains and follows the moved branch" + ); + assert!( + pins.iter().any(|pin| !pin.is_head() && pin.id == main), + "the departed branch tip remains visible through an ordinary pin" + ); + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!(after.index_tree, before.index_tree, "attach does not update the index"); + assert_eq!(after.worktree, before.worktree, "attach does not update worktree files"); + Ok(()) + } + + #[test] + fn attach_does_not_pin_a_tip_retained_by_another_branch() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + git(fixture.path(), &["branch", "keep", "main"])?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let middle = repository.rev_parse_single("main~1")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + perform(&repository_path, false, middle, &graph, &[], &[], false)?.complete()?; + attach(&repository_path, false, &["keep".into()], false)?; + let repository = crate::test_repository::open(fixture.path())?; + assert!( + history::all_pins(&repository)? + .iter() + .all(|pin| pin.is_head() || pin.id != main), + "the keep branch makes an ordinary departure pin redundant" + ); + Ok(()) + } + + #[test] + fn attach_rejects_a_branch_owned_by_another_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let middle = repository.rev_parse_single("main~1")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + perform(&repository_path, false, middle, &graph, &[], &[], false)?.complete()?; + let linked = fixture.path().join("main-wt"); + let worktree = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q"]) + .arg(&linked) + .arg("main") + .status()?; + assert!(worktree.success(), "the remembered branch moves to another worktree"); + + let err = + attach(&repository_path, false, &[], false).expect_err("a branch cannot become attached in two worktrees"); + assert!( + format!("{err:#}").contains("checked out in another worktree"), + "{err:#}" + ); + let repository = crate::test_repository::open(fixture.path())?; + assert!(repository.head()?.is_detached(), "failed attach retains detached HEAD"); + assert_eq!(repository.head_id()?, middle, "failed attach retains its departure"); + assert_eq!( + repository.find_reference("refs/heads/main")?.id(), + main, + "the other worktree's branch is not moved" + ); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "failure creates no provisional pins"); + assert!(pins[0].is_head(), "the remembered branch remains available"); + Ok(()) + } + + #[test] + fn attach_accepts_the_branch_of_the_current_linked_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let linked = fixture.path().join("topic-wt"); + let worktree = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q"]) + .arg(&linked) + .arg("topic") + .status()?; + assert!(worktree.success(), "the linked worktree checks out topic"); + let git_dir = crate::test_repository::open(&linked)?.git_dir().to_owned(); + let repository = open_repository(&git_dir, false, false)?; + let branch = repository.find_reference("refs/heads/topic")?.name().to_owned(); + let root = repository.rev_parse_single("topic~1")?.detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + perform(&git_dir, false, root, &graph, &[], &[], false)?.complete()?; + let repository = open_repository(&git_dir, false, false)?; + assert!( + repository.head()?.is_detached(), + "ordinary travel detaches the linked worktree" + ); + assert!( + history::all_pins(&repository)?.iter().any(history::Pin::is_head), + "ordinary travel remembers the linked worktree branch" + ); + drop(repository); + + attach(&git_dir, false, &[], false)?; + let repository = open_repository(&git_dir, false, false)?; + assert_eq!( + repository.head_name()?.expect("HEAD is attached"), + branch, + "attach reattaches the current linked worktree branch" + ); + assert_eq!( + repository.find_reference(branch.as_ref())?.id(), + root, + "attach moves the linked worktree branch" + ); + Ok(()) + } + + #[test] + fn explicit_attachment_clears_the_head_pin() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("main~2")?.detach(); + let topic = repository.rev_parse_single("topic")?.detach(); + let topic_ref = repository.find_reference("refs/heads/topic")?.name().to_owned(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + perform(&repository_path, false, root, &graph, &[], &[], false)?.complete()?; + move_head_to(&repository_path, false, topic, Some(&topic_ref), &[], false, Some)?; + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!(repository.head_name()?.expect("HEAD is attached"), topic_ref); + assert!( + history::all_pins(&repository)?.iter().all(|pin| !pin.is_head()), + "an explicit attachment clears the remembered branch" + ); + Ok(()) + } + + #[test] + fn failed_reattachment_keeps_the_head_pin() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("main~2")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + perform(&repository_path, false, root, &graph, &[], &[], false)?.complete()?; + let linked = fixture.path().join("main-wt"); + let worktree = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q"]) + .arg(&linked) + .arg("main") + .status()?; + assert!(worktree.success(), "another worktree checks out the remembered branch"); + + let notice = perform(&repository_path, false, main, &graph, &[], &[], false)? + .complete()? + .context("travel reports the failed reattachment")?; + assert!(notice.contains("could not reattach HEAD to main"), "{notice}"); + let repository = crate::test_repository::open(fixture.path())?; + assert!( + repository.head()?.is_detached(), + "the successful detached checkout is retained" + ); + assert_eq!(repository.head_id()?, main); + assert!( + history::all_pins(&repository)?.iter().any(history::Pin::is_head), + "the HEAD pin remains available for a later retry" + ); + Ok(()) + } + + #[test] + fn returning_to_a_commit_restores_its_manual_stash() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let head = repository.head_id()?.detach(); + let parent = repository + .find_commit(head)? + .parent_ids() + .next() + .context("the history fixture has a parent")? + .detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + + std::fs::write(fixture.path().join("manual-stash"), "saved\n")?; + super::super::stash::save_manual(&repository_path, false, head)?; + perform(&repository_path, false, parent, &graph, &[], &[], false)?.complete()?; + assert!( + !fixture.path().join("manual-stash").exists(), + "leaving the stashed commit keeps its worktree clean" + ); + + perform(&repository_path, false, head, &graph, &[], &[], false)?.complete()?; + assert_eq!(std::fs::read(fixture.path().join("manual-stash"))?, b"saved\n"); + assert!( + crate::test_repository::open(fixture.path())? + .try_find_reference(super::super::stash::reference(head)?.as_ref())? + .is_none(), + "returning consumes the manual stash association" + ); + Ok(()) + } + + #[test] + fn removes_every_pin_at_the_selected_commit() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let selected = repository.rev_parse_single("main")?.detach(); + let other = repository.rev_parse_single("topic")?.detach(); + for (name, target) in [ + ("refs/worktree/tix/pins/first", selected), + ("refs/worktree/tix/pins/second", selected), + ("refs/worktree/tix/pins/other", other), + ] { + repository.reference(name, target, PreviousValue::MustNotExist, "test pin removal")?; + } + let main = repository.find_reference("refs/heads/main")?.name().to_owned(); + create_or_update_head_pin(&repository, &main, selected)?; + let (manual, created) = create_or_reuse_pin( + &repository, + Target::Symbolic(main), + selected, + "test ordinary symbolic pin", + )?; + assert!(created && !manual.is_head(), "manual pins never reuse the HEAD pin"); + drop(repository); + + assert_eq!(remove_pins(&repository_path, false, selected)?, 3); + let repository = crate::test_repository::open(fixture.path())?; + let pins = history::all_pins(&repository)?; + assert!(pins.iter().any(history::Pin::is_head), "unpin preserves the HEAD pin"); + assert!(pins.iter().any(|pin| pin.id == other), "pins on other commits remain"); + Ok(()) + } + + #[test] + fn toggles_one_direct_pin_without_touching_the_head_pin() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let selected = repository.rev_parse_single("main")?.detach(); + let main = repository.find_reference("refs/heads/main")?.name().to_owned(); + create_or_update_head_pin(&repository, &main, selected)?; + drop(repository); + + let (toggle, created_changes) = toggle_pin_reporting(&repository_path, false, selected)?; + assert_eq!(toggle, PinToggle::Created); + let [created] = created_changes.as_slice() else { + return Err("creating one pin must report one reference change".into()); + }; + assert_eq!(created.before, super::super::undo::State::Missing); + assert_eq!(created.after, super::super::undo::State::Object(selected)); + let pin_name = created.name.clone(); + let repository = crate::test_repository::open(fixture.path())?; + let pins = history::all_pins(&repository)?; + assert_eq!( + pins.iter().filter(|pin| !pin.is_head() && pin.id == selected).count(), + 1, + "pin creates one direct pin for the selected commit" + ); + drop(repository); + + let (toggle, removed_changes) = toggle_pin_reporting(&repository_path, false, selected)?; + assert_eq!(toggle, PinToggle::Removed(1)); + let [removed] = removed_changes.as_slice() else { + return Err("removing one pin must report one reference change".into()); + }; + assert_eq!(removed.name, pin_name); + assert_eq!(removed.before, super::super::undo::State::Object(selected)); + assert_eq!(removed.after, super::super::undo::State::Missing); + let pins = history::all_pins(&crate::test_repository::open(fixture.path())?)?; + assert!(pins.iter().any(history::Pin::is_head), "unpin preserves the HEAD pin"); + assert!( + pins.iter().all(|pin| pin.is_head() || pin.id != selected), + "unpin removes every ordinary pin at the selected commit" + ); + Ok(()) + } + + #[test] + fn explicitly_created_pins_have_independent_lifetimes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let selected = repository.rev_parse_single("main")?.detach(); + let target = Target::Object(selected); + let first = create_pin(&repository, target.clone(), selected, "first review")?; + let second = create_pin(&repository, target, selected, "second review")?; + + assert_ne!(first.name, second.name, "reviews never share ownership of a return pin"); + delete_pin(&repository, &first)?; + assert!( + repository.try_find_reference(second.name.as_ref())?.is_some(), + "consuming one review's pin leaves the other review's return pin intact" + ); + Ok(()) + } + + #[test] + fn sideways_travel_preserves_an_unreferenced_departure() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let main = repository.rev_parse_single("main")?.detach(); + let topic = repository.rev_parse_single("topic")?.detach(); + let graph = loaded_graph(&repository, &[])?; + drop(repository); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "--detach", &main.to_string()]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["branch", "-D", "main"]) + .status()? + .success() + ); + + perform(&repository_path, false, topic, &graph, &[], &[], false)?.complete()?; + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!(repository.head_id()?, topic); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "sideways travel retains the otherwise lost departure"); + assert_eq!(pins[0].id, main); + assert!( + pins[0].target.try_name().is_none(), + "the detached departure gets a direct pin" + ); + Ok(()) + } + + #[test] + fn explicit_tips_avoid_redundant_pins_and_failed_checkouts_clean_up() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("main~2")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let topic = repository.rev_parse_single("topic")?.detach(); + let revisions = [OsString::from("main")]; + let graph = loaded_graph(&repository, &revisions)?; + drop(repository); + + perform(&repository_path, false, root, &graph, &[], &revisions, false)?.complete()?; + let pins = history::all_pins(&crate::test_repository::open(fixture.path())?)?; + assert_eq!( + pins.len(), + 1, + "the singleton is retained even when the branch is an explicit tip" + ); + assert!(pins[0].is_head()); + + let checkout = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "--no-guess", "main"]) + .status()?; + assert!(checkout.success()); + git( + fixture.path(), + &["symbolic-ref", "refs/worktree/tix/pins/HEAD", "refs/heads/topic"], + )?; + let repository = crate::test_repository::open(fixture.path())?; + let head_pin_before = repository + .find_reference(history::HEAD_PIN_NAME.as_bstr())? + .target() + .into_owned(); + drop(repository); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", "refs/worktree/tix/pins/destination", &root.to_string(),]) + .status()? + .success() + ); + std::fs::write(fixture.path().join("main"), "dirty\n")?; + let err = perform(&repository_path, false, root, &graph, &[], &[], false) + .and_then(Perform::complete) + .expect_err("Git rejects a conflicting checkout"); + assert!(format!("{err:#}").contains("git checkout failed")); + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!(repository.head_id()?, main, "failed checkout retains HEAD"); + assert_eq!( + repository + .find_reference(history::HEAD_PIN_NAME.as_bstr())? + .target() + .into_owned(), + head_pin_before, + "failed checkout restores the existing HEAD pin exactly" + ); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 2, "failed checkout neither loses nor creates pins"); + assert!( + pins.iter().any(|pin| pin.is_head() && pin.id == topic), + "the reversed provisional update follows the original remembered branch" + ); + assert!( + pins.iter().any(|pin| !pin.is_head() && pin.id == root), + "the destination pin survives a failed checkout" + ); + Ok(()) + } + + #[test] + fn conflicting_pending_rebases_are_unobservable_until_accepted() -> gix_testtools::Result { + let (fixture, repository_path, root, tip, graph) = pending_conflict_fixture()?; + perform(&repository_path, false, root, &graph, &[], &[], false)?.complete()?; + let repository = crate::test_repository::open(fixture.path())?; + let graph = super::super::loaded_graph(&repository)?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + + let Perform::Conflict(conflict) = perform(&repository_path, false, tip, &graph, &[], &[], false)? else { + return Err("the pending rebase should suspend at its conflicting cherry-pick".into()); + }; + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "preparing the exact merge result changes no repository state" + ); + + let (_notice, conflict_id, _, _) = conflict.accept()?; + let repository = crate::test_repository::open(fixture.path())?; + assert_eq!( + repository.head_id()?, + conflict_id, + "the conflicting commit is checked out" + ); + let conflict_commit = repository.find_commit(conflict_id)?; + assert_eq!( + conflict_commit.tree_id()?, + conflict_commit + .parent_ids() + .next() + .expect("a cherry-picked commit has a parent") + .object()? + .peel_to_tree()? + .id, + "the conflicting commit records the ours tree" + ); + assert!(repository.head()?.is_detached(), "conflict resolution detaches HEAD"); + let branch = repository.find_reference("refs/heads/main")?.id().detach(); + assert_ne!( + branch, conflict_id, + "the remaining descendant stays on the saved branch" + ); + assert!( + super::super::rebase::has_marker(&repository.find_commit(branch)?.decode()?.into_owned()?), + "remaining descendants stay as lazy rewrites" + ); + let index = repository.index_or_empty()?; + assert!( + index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted), + "the retained merge outcome supplies unresolved index stages" + ); + assert!( + std::fs::read(fixture.path().join("file"))? + .as_bstr() + .contains_str("<<<<<<<"), + "the checked-out merge tree contains conflict markers" + ); + crate::test_repository::clear_autocrlf(fixture.path())?; + insta::assert_snapshot!( + "accepted-pending-rebase-conflict", + gix_testtools::repository::snapshot_portable(fixture.path())? + .to_string() + .replace("\n \n", "\n\n") + ); + let err = perform(&repository_path, false, root, &graph, &[], &[], false) + .and_then(Perform::complete) + .expect_err("time-travel is disabled until the index conflict is resolved"); + assert!(format!("{err:#}").contains("unresolved index conflicts")); + Ok(()) + } + + #[test] + fn pending_time_travel_does_not_load_unrelated_ref_history() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let graph = super::super::loaded_graph(&repository)?; + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let root = repository.rev_parse_single("HEAD~2")?.detach(); + let old_tip = repository.head_id()?.detach(); + let mut commit = repository.find_commit(middle)?.decode()?.into_owned()?; + commit.tree = repository.find_commit(root)?.tree_id()?.detach(); + git(fixture.path(), &["checkout", "-q", "--detach", &root.to_string()])?; + let marked = super::super::rebase::perform( + &repository, + &graph, + super::super::rebase::Edit::Replace { target: middle, commit }, + super::super::rebase::Signature::InvalidateExisting, + super::super::rebase::Tree::LeaveAsIsAndMark, + )? + .complete()?; + let pending_tip = marked.map(old_tip).context("the marked tip is retained")?; + assert!(super::super::rebase::is_pending( + &repository.find_commit(pending_tip)?.decode()?.into_owned()? + )); + + let missing_parent = ObjectId::Sha1([0x42; 20]); + let mut unrelated = repository.find_commit(pending_tip)?.decode()?.into_owned()?; + unrelated.parents = [missing_parent].into_iter().collect(); + unrelated.message = "unrelated incomplete history".into(); + let unrelated = repository.write_object(&unrelated)?.detach(); + drop(repository); + git( + fixture.path(), + &["update-ref", "refs/heads/unrelated", &unrelated.to_string()], + )?; + + let repository = crate::test_repository::open(fixture.path())?; + let graph = loaded_graph(&repository, &[OsString::from("main")])?; + drop(repository); + let mut rebased = Vec::new(); + let outcome = perform_reporting_rebased(&repository_path, false, pending_tip, &graph, &[], &[], false, |id| { + rebased.push(id); + })?; + let Perform::Complete { selected, .. } = outcome else { + return Err("the pending rebase must complete".into()); + }; + assert_eq!(rebased, [pending_tip], "only the selected pending path is replayed"); + + let repository = crate::test_repository::open(fixture.path())?; + assert!(!super::super::rebase::is_pending( + &repository.find_commit(selected)?.decode()?.into_owned()? + )); + assert_eq!( + repository.find_reference("refs/heads/unrelated")?.id(), + unrelated, + "time-travel leaves the unrelated incomplete history untouched" + ); + Ok(()) + } + + #[test] + fn time_travel_reports_only_the_completed_destination_path() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("HEAD~2")?.detach(); + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let common = repository.head_id()?.detach(); + drop(repository); + + git(fixture.path(), &["checkout", "-q", "-b", "sibling"])?; + std::fs::write(fixture.path().join("sibling"), "sibling\n")?; + git(fixture.path(), &["add", "sibling"])?; + git(fixture.path(), &["commit", "-q", "-m", "sibling"])?; + let repository = crate::test_repository::open(fixture.path())?; + let destination = repository.head_id()?.detach(); + drop(repository); + + git(fixture.path(), &["checkout", "-q", "main"])?; + std::fs::write(fixture.path().join("main"), "main\n")?; + git(fixture.path(), &["add", "main"])?; + git(fixture.path(), &["commit", "-q", "-m", "main"])?; + let repository = crate::test_repository::open(fixture.path())?; + let other_tip = repository.head_id()?.detach(); + let graph = super::super::loaded_graph(&repository)?; + let mut replacement = repository.find_commit(middle)?.decode()?.into_owned()?; + replacement.message = "rewritten middle".into(); + git(fixture.path(), &["checkout", "-q", "--detach", &root.to_string()])?; + let marked = super::super::rebase::perform( + &repository, + &graph, + super::super::rebase::Edit::Replace { + target: middle, + commit: replacement, + }, + super::super::rebase::Signature::InvalidateExisting, + super::super::rebase::Tree::LeaveAsIsAndMark, + )? + .complete()?; + let pending_common = marked.map(common).context("the shared commit is retained")?; + let pending_destination = marked.map(destination).context("the destination is retained")?; + let pending_other_tip = marked.map(other_tip).context("the sibling tip is retained")?; + drop(repository); + + let repository = crate::test_repository::open(fixture.path())?; + let graph = super::super::loaded_graph(&repository)?; + drop(repository); + let mut reported = Vec::new(); + perform_reporting_rebased( + &repository_path, + false, + pending_destination, + &graph, + &[], + &[], + false, + |id| reported.push(id), + )? + .complete()?; + + assert_eq!( + reported, + [pending_common, pending_destination], + "animation follows the completed path and omits lazy sibling rewrites" + ); + let repository = crate::test_repository::open(fixture.path())?; + let rewritten_other_tip = repository.find_reference("refs/heads/main")?.id().detach(); + assert_ne!( + rewritten_other_tip, pending_other_tip, + "the omitted sibling is still rewritten when its parent changes" + ); + assert!( + super::super::rebase::is_pending(&repository.find_commit(rewritten_other_tip)?.decode()?.into_owned()?), + "the sibling remains lazy" + ); + Ok(()) + } + + #[test] + fn time_travel_materializes_only_the_pending_path_to_the_destination() -> gix_testtools::Result { + if !gix_testtools::signature::program_available("ssh-keygen") { + return Ok(()); + } + let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let allowed_signers = gix_testtools::signature::fixture("ssh-allowed-signers"); + git(fixture.path(), &["config", "commit.gpgSign", "true"])?; + git(fixture.path(), &["config", "gpg.format", "ssh"])?; + git( + fixture.path(), + &["config", "user.signingKey", key.to_string_lossy().as_ref()], + )?; + git( + fixture.path(), + &[ + "config", + "gpg.ssh.allowedSignersFile", + allowed_signers.to_string_lossy().as_ref(), + ], + )?; + let open = || crate::test_repository::open_with(fixture.path(), ["commit.gpgSign=true"]); + let repository = open()?; + let repository_path = repository.git_dir().to_owned(); + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let root = repository.rev_parse_single("HEAD~2")?.detach(); + let graph = super::super::loaded_graph(&repository)?; + let commit = repository.find_commit(middle)?.decode()?.into_owned()?; + let signed_middle = super::super::rebase::perform( + &repository, + &graph, + super::super::rebase::Edit::Replace { target: middle, commit }, + super::super::rebase::Signature::RedoIfNeeded, + super::super::rebase::Tree::LeaveAsIs, + )? + .complete()? + .selected + .expect("signing rewrites the middle commit"); + drop(repository); + git( + fixture.path(), + &["checkout", "-q", "--detach", &signed_middle.to_string()], + )?; + + let repository = open()?; + let graph = super::super::loaded_graph(&repository)?; + let spilled_middle = + super::super::head::perform(repository.clone(), &graph, super::super::head::Kind::Spill, None)? + .expect("spilling changes the middle commit"); + let pending_tip = repository.find_reference("refs/heads/main")?.id().detach(); + let middle_commit = repository.find_commit(spilled_middle)?.decode()?.into_owned()?; + let tip_commit = repository.find_commit(pending_tip)?.decode()?.into_owned()?; + assert!( + !super::super::rebase::is_pending(&middle_commit), + "the fully spilled commit has its final tree and parent" + ); + assert!( + !super::super::rebase::has_marker(&middle_commit), + "the authoritative spilled tree needs no original-parent marker" + ); + assert!( + repository + .find_commit(spilled_middle)? + .verify_signature()? + .expect("the fully spilled commit is signed immediately") + .is_valid() + ); + assert!(super::super::rebase::has_marker(&tip_commit)); + drop(repository); + + let repository = open()?; + let graph = super::super::loaded_graph(&repository)?; + perform(&repository_path, false, root, &graph, &[], &[], false)?.complete()?; + let repository = open()?; + assert_eq!(repository.find_reference("refs/heads/main")?.id(), pending_tip); + assert!(!super::super::rebase::is_pending( + &repository.find_commit(spilled_middle)?.decode()?.into_owned()? + )); + assert!(super::super::rebase::is_pending( + &repository.find_commit(pending_tip)?.decode()?.into_owned()? + )); + let graph = super::super::loaded_graph(&repository)?; + drop(repository); + + let mut rebased = Vec::new(); + perform_reporting_rebased(&repository_path, false, spilled_middle, &graph, &[], &[], false, |id| { + rebased.push(id); + })? + .complete()?; + assert!( + rebased.is_empty(), + "travelling to a final commit does not replay its pending descendant" + ); + + let repository = open()?; + let materialized_middle = repository.head_id()?.detach(); + let still_pending_tip = repository.find_reference("refs/heads/main")?.id().detach(); + assert_eq!(materialized_middle, spilled_middle); + assert_eq!(still_pending_tip, pending_tip); + let middle_commit = repository.find_commit(materialized_middle)?; + assert!(!super::super::rebase::is_pending( + &middle_commit.decode()?.into_owned()? + )); + assert!( + middle_commit + .verify_signature()? + .expect("the fully spilled commit retained its configured signature") + .is_valid() + ); + drop(middle_commit); + let tip_commit = repository.find_commit(still_pending_tip)?.decode()?.into_owned()?; + assert_eq!(tip_commit.parents.first().copied(), Some(materialized_middle)); + assert!(super::super::rebase::is_pending(&tip_commit)); + assert!(super::super::rebase::has_marker(&tip_commit)); + assert!( + history::all_pins(&repository)? + .iter() + .all(|pin| pin.id != spilled_middle), + "travelling to a rewritten detached HEAD does not pin its predecessor" + ); + let graph = super::super::loaded_graph(&repository)?; + drop(repository); + + let mut rebased = Vec::new(); + perform_reporting_rebased( + &repository_path, + false, + still_pending_tip, + &graph, + &[], + &[], + false, + |id| rebased.push(id), + )? + .complete()?; + assert_eq!(rebased, [still_pending_tip]); + let repository = open()?; + let tip_commit = repository.find_commit(repository.head_id()?)?; + assert!(!super::super::rebase::is_pending(&tip_commit.decode()?.into_owned()?)); + assert!( + tip_commit + .verify_signature()? + .expect("travelling to the remaining descendant signs it") + .is_valid() + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/todo.rs b/gix-tix/src/edit/todo.rs new file mode 100644 index 00000000000..9e8e8b8ef8b --- /dev/null +++ b/gix-tix/src/edit/todo.rs @@ -0,0 +1,2079 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BString, ByteSlice}, + prelude::ObjectIdExt, +}; +use ratatui::text::Line; + +use super::rebase; + +const HELP: &str = r#" + +<!-- +# Rebase todo help + +- Read the editable plan from bottom to top. Each fork separator is the base of the stack above it. Blank lines are ignored. +- `pick <id>` keeps a commit. Delete its line to drop it, or move the line to reorder it. Each listed commit may be picked only once. +- `squash <id>` folds a commit into the following command below it in the same fork. Its full message is retained with a source heading, and additional authors become `Co-authored-by` trailers. +- A centered `fork <id>` separator starts the stack above it at an existing commit or a commit picked below it. The selected hidden boundary is labelled `(base)` with its title; a newer hidden tip used by rebase-update is `(updated-base)`, and an explicit command-line target is `(onto)`. Other fork separators stay terse. Delete a separator to continue its commits on the stack below; add one to create a fork. A listed commit must be picked below before it can be a fork target. +- `empty <title>` creates an empty commit with the text after the command as its title. +- Commands may be plain text or enclosed in backticks. Text after a backticked command and text after a fork ID is display-only context. +- Prefix `pick`, `squash`, or `empty` with `@` to choose the post-rebase checkout. Reference lines like `(main, topic)` point refs at the following separator or command below them; moving, adding, or removing names moves, creates, or deletes refs, including existing editable refs outside the generated todo. The current attached ref stays attached while it remains at the `@` command. Prefix one editable ref with `@` to attach HEAD to it explicitly; it must point to the `@` command. +- Saving an unchanged document in the history-view editor is a no-op unless the ancestry ending at `@` has a pending rebase. Explicit `tix rebase apply` and `--edit-and-apply` apply valid unchanged plans. Unchanged picks whose parent stays unchanged retain their IDs; replay starts at the first pending or structurally changed commit. Changed commits through `@` are cherry-picked and re-signed, while descendants and other stacks remain lazily rebased with invalidated signatures until time travel reaches them. +- Tix pins, stashes, and review refs, tags, remote-tracking refs, and symbolic refs stay unchanged and hidden. A ref checked out by another worktree may be moved but not deleted. New unreferenced leaves are pinned. +- A todo conflict changes nothing unless explicitly accepted. The TUI offers `<enter>` to materialize it; command-line apply requires `--materialize-conflicts [CONTINUE]` and writes a continuation todo. Resolve the ordinary unmerged index, then apply that todo. Concurrent ref changes still abort the update. +- Commit states are display-only and editing them has no effect: `🚧` means the commit is a todo, `📝` it has a note, `✔️` its tree passed checks, `↻` a lazy rebase is pending, `◌` an empty signature awaits signing, `◐` a signature is present but unverified, `○` means unsigned, and `🎁` means worktree state is stashed for that commit. Stashes follow rewritten commits automatically; dropping a stashed commit or combining multiple stashes into one result is rejected. +--> +"#; + +const STATE_START: &str = "<!-- tix-rebase-state-v2\n"; +const STATE_END: &str = "-->"; +const STATE_CLOSE: &str = "\n-->"; + +pub(crate) struct Commit { + pub id: ObjectId, + pub parents: Vec<ObjectId>, + pub info: String, +} + +#[derive(Debug)] +pub(crate) struct Prepared { + pub document: Vec<u8>, + pub apply_unchanged: bool, +} + +#[derive(Clone, Copy)] +pub(crate) enum OntoKind { + UpdatedBase, + Onto, +} + +struct State { + base: ObjectId, + onto: ObjectId, + tips: Vec<ObjectId>, + scope: Vec<ObjectId>, + marker_required: bool, + checkout_allowed: bool, + head_ref: Option<gix::refs::FullName>, + edit_refs: bool, + expected_refs: Vec<rebase::ExpectedRef>, + resolved: Option<ObjectId>, + continuation_sources: Vec<ObjectId>, +} + +#[derive(Debug)] +pub(crate) struct Parsed { + pub plan: rebase::Plan, + pub tips: Vec<ObjectId>, +} + +struct Section { + parent: ObjectId, + commits: Vec<ObjectId>, +} + +#[tracing::instrument(skip_all, fields(base = %base, commits = commits.len()))] +pub(crate) fn prepare( + repo: &gix::Repository, + base: ObjectId, + onto: ObjectId, + commits: &[Commit], + resolved_tips: &[ObjectId], + onto_kind: OntoKind, + show_change_ids: bool, +) -> Result<Prepared> { + repo.find_commit(base) + .context("could not find the selected rebase base")?; + repo.find_commit(onto).context("could not find the rebase target")?; + let head_state = repo.head()?; + let head = head_state + .id() + .map(gix::Id::detach) + .context("rebase todos require a born HEAD")?; + let head_ref = repo + .workdir() + .is_some() + .then(|| { + head_state + .referent_name() + .filter(|name| name.category() == Some(gix::refs::Category::LocalBranch)) + .map(ToOwned::to_owned) + }) + .flatten(); + let scope: Vec<_> = commits.iter().map(|commit| commit.id).collect(); + let scope_set: HashSet<_> = scope.iter().copied().collect(); + let by_id: HashMap<_, _> = commits.iter().map(|commit| (commit.id, commit)).collect(); + let marker_required = repo.workdir().is_some() && scope_set.contains(&head); + let mut tips = scope_set.clone(); + for commit in commits { + for parent in &commit.parents { + tips.remove(parent); + } + } + let tips = tips.into_iter().collect::<Vec<_>>(); + let mut cursor = marker_required.then_some(head); + let mut has_pending = false; + while let Some(id) = cursor { + let commit = by_id.get(&id).context("the checkout ancestry is incomplete")?; + if rebase::is_pending(&repo.find_commit(id)?.decode()?.into_owned()?) { + has_pending = true; + break; + } + cursor = commit + .parents + .first() + .copied() + .filter(|parent| scope_set.contains(parent)); + } + let apply_unchanged = base != onto || has_pending; + + let mut children = HashMap::<ObjectId, Vec<ObjectId>>::new(); + for commit in commits { + let parent = commit + .parents + .first() + .copied() + .context("an editable commit has no parent")?; + if parent != base && !scope_set.contains(&parent) { + anyhow::bail!("an editable commit is not connected to the selected base"); + } + children.entry(parent).or_default().push(commit.id); + } + let mut sections = Vec::new(); + for child in children.get(&base).into_iter().flatten().copied() { + let mut section = Section { + parent: onto, + commits: Vec::new(), + }; + let mut branches = Vec::new(); + walk(child, &children, &mut section, &mut branches); + sections.push(section); + sections.extend(branches); + } + let mut ref_points = scope.clone(); + ref_points.push(onto); + ref_points.extend(sections.iter().map(|section| section.parent)); + ref_points.sort_unstable(); + ref_points.dedup(); + let expected_refs = rebase::capture_refs(repo, &ref_points, &tips)?; + + let source = short(repo, base, show_change_ids)?; + let title = if base == onto { + format!("# Rebase from `{source}`") + } else { + format!( + "# Rebase from `{source}` onto `{}`", + short(repo, onto, show_change_ids)? + ) + }; + let state = State { + base, + onto, + tips: if resolved_tips.is_empty() { + tips + } else { + resolved_tips.to_vec() + }, + scope: scope.clone(), + marker_required, + checkout_allowed: repo.workdir().is_some(), + head_ref, + edit_refs: true, + expected_refs, + resolved: None, + continuation_sources: Vec::new(), + }; + let mut document = unchanged_notice(base != onto, has_pending).as_bytes().to_vec(); + document.push(b'\n'); + document.extend_from_slice(title.as_bytes()); + document.extend_from_slice(b"\n\n"); + let anchor_kind = if base == onto { + "base" + } else { + match onto_kind { + OntoKind::UpdatedBase => "updated-base", + OntoKind::Onto => "onto", + } + }; + let anchor_title = anchor_title(repo, onto)?; + let mut body = Vec::new(); + let mut enrichments = crate::enrich::open(repo)?; + let mut tree_enrichments = crate::enrich::open_tree(repo)?; + for (section_index, section) in sections.iter().enumerate() { + if section_index > 0 { + body.push(b'\n'); + } + write_fork_heading( + &mut body, + repo, + section.parent, + (section.parent == onto).then_some((anchor_kind, anchor_title.as_str())), + show_change_ids, + )?; + if !scope_set.contains(§ion.parent) { + write_refs_at(&mut body, &state.expected_refs, section.parent)?; + } + for id in §ion.commits { + let commit = by_id[id]; + let verb = if marker_required && *id == head { + "@pick" + } else { + "pick" + }; + let states = commit_states(repo, &mut enrichments, &mut tree_enrichments, *id)?; + body.extend_from_slice( + format!( + "`{verb} {}` {states}{}\n", + short(repo, *id, show_change_ids)?, + commit.info + ) + .as_bytes(), + ); + write_refs_at(&mut body, &state.expected_refs, *id)?; + } + } + if sections.is_empty() { + write_fork_heading( + &mut body, + repo, + onto, + Some((anchor_kind, anchor_title.as_str())), + show_change_ids, + )?; + write_refs_at(&mut body, &state.expected_refs, onto)?; + } + write_bottom_up(&mut document, &body)?; + document.extend_from_slice(HELP.as_bytes()); + write_state(&mut document, &state); + Ok(Prepared { + document, + apply_unchanged, + }) +} + +pub(crate) fn prepare_continuation( + repo: &gix::Repository, + plan: &rebase::Plan, + tips: Vec<ObjectId>, + show_change_ids: bool, +) -> Result<Prepared> { + let resolved = plan.steps.iter().find_map(|step| match step.commit { + rebase::PlanCommit::Resolved(id) => Some(id), + _ => None, + }); + let state = State { + base: plan.base, + onto: plan.base, + tips, + scope: plan.scope.clone(), + marker_required: plan.checkout.is_some(), + checkout_allowed: repo.workdir().is_some(), + head_ref: plan.checkout.as_ref().and_then(|checkout| checkout.reference.clone()), + edit_refs: true, + expected_refs: plan.expected_refs.clone(), + resolved, + continuation_sources: plan.steps.iter().flat_map(|step| step.squash.iter().copied()).collect(), + }; + let mut document = b"<!-- Rebase help follows. Saving unchanged continues the materialized rebase; empty this file or remove the tix-rebase-state-v2 comment to cancel. -->\n# Continue materialized rebase\n\n".to_vec(); + let mut body = Vec::new(); + let mut enrichments = crate::enrich::open(repo)?; + let mut tree_enrichments = crate::enrich::open_tree(repo)?; + for (index, step) in plan.steps.iter().enumerate() { + let continues = matches!(step.parent, rebase::PlanParent::Step(parent) if parent + 1 == index); + if !continues { + if index > 0 { + body.push(b'\n'); + } + let parent = match step.parent { + rebase::PlanParent::Existing(id) => id, + rebase::PlanParent::Step(parent) => match plan.steps[parent].commit { + rebase::PlanCommit::Pick(id) | rebase::PlanCommit::Copy(id) | rebase::PlanCommit::Resolved(id) => { + id + } + rebase::PlanCommit::Empty(_) => { + anyhow::bail!("a continuation fork cannot target an unwritten empty commit") + } + }, + }; + let base_title = (parent == plan.base) + .then(|| anchor_title(repo, parent)) + .transpose()? + .unwrap_or_default(); + write_fork_heading( + &mut body, + repo, + parent, + (parent == plan.base).then_some(("base", base_title.as_str())), + show_change_ids, + )?; + if matches!(step.parent, rebase::PlanParent::Existing(_)) { + write_plan_refs_at(&mut body, &plan.expected_refs, step.parent)?; + } + } + let marker = if plan + .checkout + .as_ref() + .is_some_and(|checkout| checkout.target == rebase::PlanParent::Step(index)) + { + "@" + } else { + "" + }; + match step.commit { + rebase::PlanCommit::Pick(id) | rebase::PlanCommit::Copy(id) | rebase::PlanCommit::Resolved(id) => { + let value = if matches!(step.commit, rebase::PlanCommit::Resolved(_)) { + let hash = ObjectId::null(id.kind()).to_string(); + if show_change_ids { + format!( + "{hash} {}", + crate::change_id::for_commit(repo, id)?.to_reverse_hex_with_len(hash.len()) + ) + } else { + hash + } + } else { + short(repo, id, show_change_ids)? + }; + let title = anchor_title(repo, id)?; + body.extend_from_slice( + format!( + "`{marker}pick {value}` {}{}\n", + commit_states(repo, &mut enrichments, &mut tree_enrichments, id)?, + title + ) + .as_bytes(), + ); + } + rebase::PlanCommit::Empty(ref title) => { + body.extend_from_slice(format!("`{marker}empty {}`\n", title.to_str_lossy()).as_bytes()); + } + } + for id in &step.squash { + let title = anchor_title(repo, *id)?; + body.extend_from_slice( + format!( + "`squash {}` {}{}\n", + short(repo, *id, show_change_ids)?, + commit_states(repo, &mut enrichments, &mut tree_enrichments, *id)?, + title + ) + .as_bytes(), + ); + } + write_plan_refs_at(&mut body, &plan.expected_refs, rebase::PlanParent::Step(index))?; + } + write_bottom_up(&mut document, &body)?; + document.extend_from_slice(HELP.as_bytes()); + write_state(&mut document, &state); + Ok(Prepared { + document, + apply_unchanged: true, + }) +} + +fn unchanged_notice(base_updated: bool, has_pending: bool) -> &'static str { + match (base_updated, has_pending) { + (false, false) => { + "<!-- Rebase help follows. Saving unchanged is a no-op; empty this file or remove the tix-rebase-state-v2 comment to cancel. -->" + } + (false, true) => { + "<!-- Rebase help follows. Pending commits on the @ ancestry make saving unchanged apply this todo: that ancestry is replayed now and other forks stay lazy. Empty this file or remove the tix-rebase-state-v2 comment to cancel. -->" + } + (true, false) => { + "<!-- Rebase help follows. Saving unchanged rebases onto the updated base; empty this file or remove the tix-rebase-state-v2 comment to cancel. -->" + } + (true, true) => { + "<!-- Rebase help follows. Saving unchanged rebases onto the updated base and applies pending commits on the @ ancestry: that ancestry is replayed now and other forks stay lazy. Empty this file or remove the tix-rebase-state-v2 comment to cancel. -->" + } + } +} + +fn write_state(out: &mut Vec<u8>, state: &State) { + out.extend_from_slice(STATE_START.as_bytes()); + out.extend_from_slice(format!("base {}\nonto {}\n", state.base, state.onto).as_bytes()); + for tip in &state.tips { + out.extend_from_slice(format!("tip {tip}\n").as_bytes()); + } + for id in &state.scope { + out.extend_from_slice(format!("scope {id}\n").as_bytes()); + } + out.extend_from_slice( + format!( + "marker-required {}\ncheckout-allowed {}\n", + state.marker_required, state.checkout_allowed + ) + .as_bytes(), + ); + if let Some(name) = &state.head_ref { + out.extend_from_slice(b"head-ref "); + out.extend_from_slice(gix::quote::ansi_c::quote(name.as_bstr()).as_ref()); + out.push(b'\n'); + } + out.extend_from_slice(format!("edit-refs {}\n", state.edit_refs).as_bytes()); + for reference in &state.expected_refs { + let name = gix::quote::ansi_c::quote(reference.name.as_bstr()); + let old = reference.old.map_or_else(|| "-".into(), |id| id.to_string()); + out.extend_from_slice( + format!( + "ref {} {} {} {} {}\n", + old, + reference.target, + reference.follows_tip, + reference.editable, + name.to_str_lossy() + ) + .as_bytes(), + ); + } + if let Some(id) = state.resolved { + out.extend_from_slice(format!("resolved {id}\n").as_bytes()); + } + for id in &state.continuation_sources { + out.extend_from_slice(format!("continuation-source {id}\n").as_bytes()); + } + out.extend_from_slice(STATE_END.as_bytes()); + out.push(b'\n'); +} + +fn write_refs_at(out: &mut Vec<u8>, refs: &[rebase::ExpectedRef], target: ObjectId) -> Result<()> { + let names = refs + .iter() + .filter(|reference| reference.editable && reference.target == target) + .map(|reference| &reference.name) + .collect::<Vec<_>>(); + write_ref_line(out, refs, names) +} + +fn write_plan_refs_at(out: &mut Vec<u8>, refs: &[rebase::ExpectedRef], target: rebase::PlanParent) -> Result<()> { + let names = refs + .iter() + .filter(|reference| reference.placement == Some(target)) + .map(|reference| &reference.name) + .collect::<Vec<_>>(); + write_ref_line(out, refs, names) +} + +fn write_ref_line(out: &mut Vec<u8>, refs: &[rebase::ExpectedRef], mut names: Vec<&gix::refs::FullName>) -> Result<()> { + if names.is_empty() { + return Ok(()); + } + names.sort(); + out.push(b'('); + for (index, name) in names.into_iter().enumerate() { + if index > 0 { + out.extend_from_slice(b", "); + } + let display = ref_display_name(name, refs); + out.extend_from_slice(gix::quote::ansi_c::quote(display.as_bstr()).as_ref()); + } + out.extend_from_slice(b")\n"); + Ok(()) +} + +fn ref_display_name(name: &gix::refs::FullName, refs: &[rebase::ExpectedRef]) -> BString { + let short = name.shorten(); + if refs + .iter() + .filter(|candidate| candidate.editable && candidate.name.shorten() == short) + .count() + > 1 + { + name.as_bstr().to_owned() + } else { + short.to_owned() + } +} + +fn commit_states( + repo: &gix::Repository, + enrichments: &mut gix::note::Platform, + tree_enrichments: &mut gix::note::Platform, + id: ObjectId, +) -> Result<String> { + let commit = repo + .find_commit(id) + .context("could not load a commit state for the rebase todo")? + .decode() + .context("could not decode a commit state for the rebase todo")? + .into_owned() + .context("could not own a commit state for the rebase todo")?; + let pending = commit.extra_headers.iter().any(|(name, _)| name == "tix-rebase-parent"); + let mut empty_signature = false; + let mut signature = false; + for (name, value) in &commit.extra_headers { + if name != "gpgsig" && name != "gpgsig-sha256" { + continue; + } + if value.is_empty() { + empty_signature = true; + } else { + signature = true; + } + } + let stashed = repo + .try_find_reference(super::stash::reference(id)?.as_ref())? + .is_some(); + let enrichment = + crate::change_id::for_commit(repo, id).and_then(|change_id| crate::enrich::load(enrichments, change_id)); + let enrichment = match enrichment { + Ok(enrichment) => enrichment, + Err(err) => { + tracing::warn!(commit_id = %id, error = %err, "ignored malformed tix enrichment"); + crate::enrich::Enrichment::default() + } + }; + let tree_enrichment = + crate::enrich::tree_id(repo, id).and_then(|tree_id| crate::enrich::load_tree(tree_enrichments, tree_id)); + let tree_enrichment = match tree_enrichment { + Ok(enrichment) => enrichment, + Err(err) => { + tracing::warn!(commit_id = %id, error = %err, "ignored malformed tix tree enrichment"); + crate::enrich::TreeEnrichment::default() + } + }; + let marker = crate::enrich::marker(enrichment.todo, enrichment.note.is_some(), tree_enrichment.checks_pass); + let mut out = Vec::with_capacity(6); + if !marker.is_empty() { + out.push(marker); + } + if pending { + out.push("↻"); + } + if empty_signature { + out.push("◌"); + } + if signature { + out.push("◐"); + } + if !empty_signature && !signature { + out.push("○"); + } + if stashed { + out.push("🎁"); + } + Ok(format!("{} ", out.join(" "))) +} + +fn anchor_title(repo: &gix::Repository, id: ObjectId) -> Result<String> { + let message = repo + .find_commit(id) + .context("could not load the rebase anchor")? + .message_raw() + .context("could not decode the rebase anchor message")? + .to_owned(); + let mut notes = repo.notes().context("could not open Git notes for the rebase anchor")?; + let has_notes = !notes.get(id).context("could not load rebase anchor notes")?.is_empty(); + let mut out = String::new(); + if crate::history::contains_agent_marker(&message) { + out.push_str("[A] "); + } + if has_notes { + out.push_str("[N] "); + } + out.push_str( + &gix::objs::commit::MessageRef::from_bytes(&message) + .summary() + .to_str_lossy(), + ); + Ok(out) +} + +fn write_fork_heading( + out: &mut Vec<u8>, + repo: &gix::Repository, + id: ObjectId, + annotation: Option<(&str, &str)>, + show_change_ids: bool, +) -> Result<()> { + out.extend_from_slice(format!("fork {}", short(repo, id, show_change_ids)?).as_bytes()); + if let Some((kind, title)) = annotation { + out.extend_from_slice(format!(" ({kind}) {title}").as_bytes()); + } + out.push(b'\n'); + Ok(()) +} + +fn write_bottom_up(out: &mut Vec<u8>, body: &[u8]) -> Result<()> { + let body = std::str::from_utf8(body).context("generated rebase todo is not UTF-8")?; + let width = body + .lines() + .map(|line| { + let width = Line::raw(line).width(); + if line.starts_with("fork ") { width + 10 } else { width } + }) + .max() + .unwrap_or_default(); + for line in body.lines().rev() { + if line.starts_with("fork ") { + let label_width = Line::raw(line).width(); + let rails = width.saturating_sub(label_width + 2).max(8); + let left = rails / 2; + let right = rails - left; + out.extend_from_slice(format!("{} {line} {}\n", "─".repeat(left), "─".repeat(right)).as_bytes()); + } else { + out.extend_from_slice(line.as_bytes()); + out.push(b'\n'); + } + } + Ok(()) +} + +fn walk(id: ObjectId, children: &HashMap<ObjectId, Vec<ObjectId>>, section: &mut Section, sections: &mut Vec<Section>) { + section.commits.push(id); + let Some(child_ids) = children.get(&id) else { return }; + if let Some(first) = child_ids.first() { + walk(*first, children, section, sections); + } + for child in child_ids.iter().skip(1) { + let mut branch = Section { + parent: id, + commits: Vec::new(), + }; + let mut nested = Vec::new(); + walk(*child, children, &mut branch, &mut nested); + sections.push(branch); + sections.extend(nested); + } +} + +fn short(repo: &gix::Repository, id: ObjectId, show_change_id: bool) -> Result<String> { + if show_change_id { + crate::change_id::display_short(repo, id).context("could not format a rebase todo ID") + } else { + Ok(id + .attach(repo) + .shorten() + .context("could not shorten a rebase todo ID")? + .to_string()) + } +} + +fn parse_state(repo: &gix::Repository, input: &str) -> Result<Option<State>> { + let Some(start) = input.find(STATE_START) else { + if input.contains("<!-- tix-rebase-state-") { + anyhow::bail!("the rebase todo uses an unsupported state version"); + } + return Ok(None); + }; + let body = &input[start + STATE_START.len()..]; + let end = body + .find(STATE_CLOSE) + .context("the rebase state anchor is not closed")?; + if body[end + STATE_CLOSE.len()..].contains(STATE_START) { + anyhow::bail!("the rebase todo contains more than one state anchor"); + } + let mut base = None; + let mut onto = None; + let mut tips = Vec::new(); + let mut scope = Vec::new(); + let mut marker_required = None; + let mut checkout_allowed = None; + let mut head_ref = None; + let mut edit_refs = false; + let mut expected_refs = Vec::new(); + let mut resolved = None; + let mut continuation_sources = Vec::new(); + for line in body[..end].lines() { + let (key, value) = line.split_once(' ').context("a rebase state line has no value")?; + match key { + "base" => { + if base.replace(ObjectId::from_hex(value.as_bytes())?).is_some() { + anyhow::bail!("the rebase state has more than one base"); + } + } + "onto" => { + if onto.replace(ObjectId::from_hex(value.as_bytes())?).is_some() { + anyhow::bail!("the rebase state has more than one onto target"); + } + } + "tip" => tips.push(ObjectId::from_hex(value.as_bytes())?), + "scope" => scope.push(ObjectId::from_hex(value.as_bytes())?), + "marker-required" => { + if marker_required.replace(value.parse()?).is_some() { + anyhow::bail!("the rebase state repeats marker-required"); + } + } + "checkout-allowed" => { + if checkout_allowed.replace(value.parse()?).is_some() { + anyhow::bail!("the rebase state repeats checkout-allowed"); + } + } + "head-ref" => { + let encoded = value.as_bytes().as_bstr(); + let (name, consumed) = gix::quote::ansi_c::undo(encoded) + .map_err(gix::Exn::into_error) + .context("could not unquote the recorded HEAD ref")?; + if !encoded[consumed..].trim().is_empty() { + anyhow::bail!("the recorded HEAD ref has trailing data"); + } + let name = gix::refs::FullName::try_from(name.as_ref()).context("the recorded HEAD ref is invalid")?; + if head_ref.replace(name).is_some() { + anyhow::bail!("the rebase state repeats its HEAD ref"); + } + } + "edit-refs" => edit_refs = value.parse()?, + "ref" => { + let (old, value) = value.split_once(' ').context("a captured ref has no target")?; + let (target, value) = value.split_once(' ').context("a captured ref has no follow mode")?; + let old = (old != "-").then(|| ObjectId::from_hex(old.as_bytes())).transpose()?; + let target = ObjectId::from_hex(target.as_bytes())?; + let (follows_tip, value) = value.split_once(' ').context("a captured ref has no name")?; + let follows_tip = follows_tip.parse()?; + let (editable, name) = value + .split_once(' ') + .and_then(|(editable, name)| editable.parse::<bool>().ok().map(|editable| (editable, name))) + .unwrap_or((false, value)); + let encoded_name = name.as_bytes().as_bstr(); + let (name, consumed) = gix::quote::ansi_c::undo(encoded_name) + .map_err(gix::Exn::into_error) + .context("could not unquote a captured ref name")?; + if !encoded_name[consumed..].trim().is_empty() { + anyhow::bail!("a captured ref name has trailing data"); + } + let name = gix::refs::FullName::try_from(name.as_ref()).context("a captured ref name is invalid")?; + expected_refs.push(rebase::ExpectedRef { + name, + old, + target, + new: old, + follows_tip, + editable, + placement: None, + }); + } + "resolved" => { + if resolved.replace(ObjectId::from_hex(value.as_bytes())?).is_some() { + anyhow::bail!("the rebase state repeats its resolved conflict"); + } + } + "continuation-source" => continuation_sources.push(ObjectId::from_hex(value.as_bytes())?), + _ => anyhow::bail!("unsupported rebase state field {key:?}"), + } + } + let state = State { + base: base.context("the rebase state has no base")?, + onto: onto.context("the rebase state has no onto target")?, + tips, + scope, + marker_required: marker_required.context("the rebase state has no marker requirement")?, + checkout_allowed: checkout_allowed.context("the rebase state has no checkout capability")?, + head_ref, + edit_refs, + expected_refs, + resolved, + continuation_sources, + }; + validate_state(repo, &state)?; + Ok(Some(state)) +} + +fn validate_state(repo: &gix::Repository, state: &State) -> Result<()> { + repo.find_commit(state.base) + .context("could not find the recorded rebase base")?; + repo.find_commit(state.onto) + .context("could not find the recorded rebase target")?; + let scope: HashSet<_> = state.scope.iter().copied().collect(); + let continuation_sources: HashSet<_> = state.continuation_sources.iter().copied().collect(); + if scope.len() != state.scope.len() { + anyhow::bail!("the rebase state contains duplicate scope commits"); + } + if state.tips.iter().copied().collect::<HashSet<_>>().len() != state.tips.len() { + anyhow::bail!("the rebase state contains duplicate tips"); + } + let mut refs = HashSet::new(); + for reference in &state.expected_refs { + if !refs.insert(reference.name.as_bstr()) { + anyhow::bail!("the rebase state contains duplicate refs"); + } + if !scope.contains(&reference.target) && reference.target != state.base && reference.target != state.onto { + anyhow::bail!("a captured ref does not logically point into the rebase scope"); + } + } + if let Some(name) = &state.head_ref + && !state + .expected_refs + .iter() + .any(|reference| reference.editable && reference.name == *name) + { + anyhow::bail!("the recorded HEAD ref is not editable"); + } + for tip in &state.tips { + repo.find_commit(*tip).context("could not find a recorded rebase tip")?; + } + for id in &state.scope { + let commit = repo + .find_commit(*id) + .context("could not find a recorded scope commit")?; + let parent = commit + .parent_ids() + .next() + .map(gix::Id::detach) + .context("a recorded scope commit has no parent")?; + if parent != state.base && !scope.contains(&parent) && !continuation_sources.contains(id) { + anyhow::bail!("a recorded scope commit is disconnected from the rebase base"); + } + } + if state.resolved.is_some_and(|id| !scope.contains(&id)) { + anyhow::bail!("the resolved conflict is outside the rebase scope"); + } + if !continuation_sources.is_subset(&scope) { + anyhow::bail!("a continuation source is outside the rebase scope"); + } + Ok(()) +} + +pub(crate) fn parse(repo: &gix::Repository, edited: &[u8]) -> Result<Option<Parsed>> { + repo.head()?.id().context("rebase todos require a born HEAD")?; + let input = std::str::from_utf8(edited).context("the rebase todo is not UTF-8")?; + let Some(mut state) = parse_state(repo, input)? else { + return Ok(None); + }; + let scope: HashSet<_> = state.scope.iter().copied().collect(); + let mut picked = HashMap::<ObjectId, usize>::new(); + let mut steps = Vec::<rebase::PlanStep>::new(); + let mut cursor = None; + let mut checkout_target = None; + let mut explicit_checkout_reference = None; + let mut command_marker = false; + let mut ref_targets = HashMap::new(); + let mut sections = 0; + let mut section_has_commit = false; + let mut section_last_step = None; + let mut in_comment = false; + let mut editable = Vec::new(); + for raw in input.lines() { + let line = raw.trim(); + if in_comment { + if line.contains("-->") { + in_comment = false; + } + continue; + } + if line.starts_with("<!--") { + in_comment = !line.contains("-->"); + continue; + } + if line.is_empty() || line.starts_with("# ") { + continue; + } + editable.push(line); + } + + for line in editable.into_iter().rev() { + if line.starts_with('─') && line.ends_with('─') { + let target = line + .trim_matches('─') + .trim() + .strip_prefix("fork ") + .context("a fork separator needs a fork ID")?; + if sections > 0 && !section_has_commit { + anyhow::bail!("a fork section contains no commits"); + } + let id = resolve_commit( + repo, + target + .split_whitespace() + .next() + .context("a fork heading needs a commit ID")?, + )?; + cursor = Some(if let Some(index) = picked.get(&id) { + rebase::PlanParent::Step(*index) + } else if scope.contains(&id) { + anyhow::bail!("a fork target must be picked before it is used"); + } else { + rebase::PlanParent::Existing(id) + }); + sections += 1; + section_has_commit = false; + section_last_step = None; + continue; + } + if line.starts_with('(') && line.ends_with(')') { + let target = cursor.context("a reference line must follow a fork or command")?; + for (marked, value) in parse_ref_line(line)? { + let name = resolve_ref_name(repo, &mut state.expected_refs, value.as_bstr())?; + if ref_targets.insert(name.clone(), target).is_some() { + anyhow::bail!("a reference is placed more than once"); + } + if marked { + if !state.checkout_allowed || repo.workdir().is_none() { + anyhow::bail!("the rebase todo cannot select a checkout without a worktree"); + } + if explicit_checkout_reference.replace((name, target)).is_some() { + anyhow::bail!("the rebase todo contains more than one @ reference"); + } + } + } + section_has_commit = true; + continue; + } + + let (command, tail) = if let Some(line) = line.strip_prefix('`') { + let (command, tail) = line + .split_once('`') + .context("a Markdown todo command has no closing backtick")?; + (command, tail.trim()) + } else { + (line, "") + }; + let (verb, value) = command.split_once(char::is_whitespace).unwrap_or((command, "")); + let marked = verb.starts_with('@'); + let verb = verb.strip_prefix('@').unwrap_or(verb); + if marked { + if std::mem::replace(&mut command_marker, true) { + anyhow::bail!("the rebase todo contains more than one @ command"); + } + if !state.checkout_allowed || repo.workdir().is_none() { + anyhow::bail!("the rebase todo cannot select a checkout without a worktree"); + } + } + if verb == "squash" { + let index = section_last_step.context("a squash must follow a command in the same fork")?; + let id = resolve_commit( + repo, + value.split_whitespace().next().context("a squash needs a commit ID")?, + )?; + if !scope.contains(&id) { + anyhow::bail!("a squash is outside the editable history"); + } + if picked.insert(id, index).is_some() { + anyhow::bail!("a commit is picked more than once"); + } + steps[index].squash.push(id); + if marked { + let target = rebase::PlanParent::Step(index); + if checkout_target.is_some_and(|checkout| checkout != target) { + anyhow::bail!("the @ command and @ reference point to different results"); + } + checkout_target = Some(target); + } + section_has_commit = true; + continue; + } + let parent = cursor.context("the first todo command must follow a fork heading")?; + let commit = match verb { + "pick" => { + let value = value.split_whitespace().next().context("a pick needs a commit ID")?; + let resolved_id = state.resolved; + let full_null = resolved_id.is_some_and(|id| { + value.len() == id.kind().len_in_bytes() * 2 && value.bytes().all(|byte| byte == b'0') + }); + let (id, resolved) = if full_null { + ( + resolved_id.context("a null pick has no materialized conflict state")?, + true, + ) + } else { + (resolve_commit(repo, value)?, false) + }; + if !scope.contains(&id) { + anyhow::bail!("a pick is outside the editable history"); + } + if picked.contains_key(&id) { + anyhow::bail!("a commit is picked more than once"); + } + if resolved { + rebase::PlanCommit::Resolved(id) + } else { + rebase::PlanCommit::Pick(id) + } + } + "empty" => { + let title = if value.trim().is_empty() { tail } else { value.trim() }; + if title.is_empty() { + anyhow::bail!("an empty commit needs a title"); + } + rebase::PlanCommit::Empty(BString::from(title)) + } + _ => anyhow::bail!("unsupported rebase todo command {verb:?}"), + }; + let index = steps.len(); + if let rebase::PlanCommit::Pick(id) | rebase::PlanCommit::Resolved(id) = commit { + picked.insert(id, index); + } + steps.push(rebase::PlanStep { + parent, + commit, + squash: Vec::new(), + }); + cursor = Some(rebase::PlanParent::Step(index)); + section_last_step = Some(index); + if marked { + let target = rebase::PlanParent::Step(index); + if checkout_target.is_some_and(|checkout| checkout != target) { + anyhow::bail!("the @ command and @ reference point to different results"); + } + checkout_target = Some(target); + } + section_has_commit = true; + } + if sections == 0 { + anyhow::bail!("the rebase todo has no fork heading"); + } + if sections > 1 && !section_has_commit { + anyhow::bail!("the last fork section contains no commits"); + } + if state.marker_required && checkout_target.is_none() { + anyhow::bail!("the current checkout marker must be retained"); + } + let checkout_reference = match (checkout_target, explicit_checkout_reference) { + (Some(target), Some((name, reference_target))) => { + if target != reference_target { + anyhow::bail!("the @ command and @ reference point to different results"); + } + Some(name) + } + (None, Some(_)) => anyhow::bail!("an @ reference requires an @ command at the same result"), + (Some(target), None) => state + .head_ref + .take() + .filter(|name| ref_targets.get(name) == Some(&target)), + (None, None) => None, + }; + if state.edit_refs { + for reference in &mut state.expected_refs { + if reference.editable { + reference.new = None; + reference.placement = ref_targets.remove(&reference.name); + } + } + } + let checkout = checkout_target.map(|target| rebase::PlanCheckout { + target, + reference: checkout_reference, + }); + Ok(Some(Parsed { + plan: rebase::Plan { + base: state.onto, + scope: state.scope, + steps, + checkout, + expected_refs: state.expected_refs, + }, + tips: state.tips, + })) +} + +fn resolve_commit(repo: &gix::Repository, value: &str) -> Result<ObjectId> { + if value.len() < 4 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + anyhow::bail!("{value:?} is not a commit ID prefix"); + } + let id = repo + .rev_parse_single(value) + .with_context(|| format!("could not resolve commit ID {value:?}"))?; + id.object() + .context("could not load a todo object")? + .try_into_commit() + .context("a todo ID does not name a commit")?; + Ok(id.detach()) +} + +fn parse_ref_line(line: &str) -> Result<Vec<(bool, BString)>> { + let body = line + .strip_prefix('(') + .and_then(|line| line.strip_suffix(')')) + .context("a reference line must be enclosed in parentheses")?; + let mut ranges = Vec::new(); + let mut start = 0; + let mut quoted = false; + let mut escaped = false; + for (index, byte) in body.bytes().enumerate() { + if escaped { + escaped = false; + continue; + } + match byte { + b'\\' if quoted => escaped = true, + b'"' => quoted = !quoted, + b',' if !quoted => { + ranges.push(&body[start..index]); + start = index + 1; + } + _ => {} + } + } + if quoted || escaped { + anyhow::bail!("a quoted reference name is not closed"); + } + ranges.push(&body[start..]); + let mut out = Vec::with_capacity(ranges.len()); + for item in ranges { + let item = item.trim(); + if item.is_empty() { + anyhow::bail!("a reference line contains an empty name"); + } + let (marked, item) = item.strip_prefix('@').map_or((false, item), |item| (true, item)); + let encoded = item.as_bytes().as_bstr(); + let (name, consumed) = gix::quote::ansi_c::undo(encoded) + .map_err(gix::Exn::into_error) + .context("could not unquote a reference name")?; + if !encoded[consumed..].trim().is_empty() { + anyhow::bail!("a reference name has trailing data"); + } + if name.is_empty() { + anyhow::bail!("a reference name is empty"); + } + out.push((marked, name.into_owned())); + } + Ok(out) +} + +fn resolve_ref_name( + repo: &gix::Repository, + refs: &mut Vec<rebase::ExpectedRef>, + input: &gix::bstr::BStr, +) -> Result<gix::refs::FullName> { + let mut matches = refs + .iter() + .filter(|reference| reference.editable && ref_display_name(&reference.name, refs).as_bstr() == input) + .map(|reference| reference.name.clone()); + if let Some(name) = matches.next() { + if matches.next().is_some() { + anyhow::bail!("the shortened reference name is ambiguous"); + } + return Ok(name); + } + let full = if input.starts_with(b"refs/") { + input.to_owned() + } else { + let mut full = BString::from("refs/heads/"); + full.extend_from_slice(input); + full + }; + let name = gix::refs::FullName::try_from(full).context("the todo contains an invalid reference name")?; + if name.as_bstr().starts_with(crate::history::PIN_PREFIX) + || name.as_bstr().starts_with(crate::history::STASH_PREFIX) + || name.as_bstr().starts_with(crate::history::REVIEW_PREFIX) + || super::undo::is_queue_ref(name.as_bstr()) + || matches!( + name.category(), + Some(gix::refs::Category::Tag | gix::refs::Category::RemoteBranch) + ) + { + anyhow::bail!("the todo cannot edit this reference namespace"); + } + if refs.iter().any(|reference| reference.name == name) { + anyhow::bail!("the todo cannot edit a hidden reference"); + } + let old = repo + .try_find_reference(name.as_ref())? + .map(|reference| { + reference + .try_id() + .map(gix::Id::detach) + .context("an existing symbolic reference outside the editable history cannot be moved") + }) + .transpose()?; + refs.push(rebase::ExpectedRef { + name: name.clone(), + old, + target: old.unwrap_or(repo.head_id()?.detach()), + new: old, + follows_tip: false, + editable: true, + placement: None, + }); + Ok(name) +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + + fn repo() -> gix_testtools::Result<(gix_testtools::tempfile::TempDir, gix::Repository)> { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], + )?; + Ok((fixture, repo)) + } + + #[test] + fn unchanged_notices_cover_every_reason_to_apply_or_cancel() { + for (updated, pending, expected) in [ + (false, false, "Saving unchanged is a no-op"), + ( + false, + true, + "Pending commits on the @ ancestry make saving unchanged apply this todo", + ), + (true, false, "Saving unchanged rebases onto the updated base"), + ( + true, + true, + "Saving unchanged rebases onto the updated base and applies pending commits on the @ ancestry", + ), + ] { + let notice = unchanged_notice(updated, pending); + assert!(notice.contains(expected), "the notice explains its execution mode"); + assert!( + notice.contains("remove the tix-rebase-state-v2 comment to cancel"), + "every notice explains explicit cancellation" + ); + } + } + + fn commits(repo: &gix::Repository) -> gix_testtools::Result<(ObjectId, ObjectId, ObjectId, Vec<Commit>)> { + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + Ok(( + base, + middle, + tip, + vec![ + Commit { + id: tip, + parents: vec![middle], + info: "2000-01-03 author tip".into(), + }, + Commit { + id: middle, + parents: vec![base], + info: "2000-01-02 author middle * _ [markdown] <view> `code` \\ raw".into(), + }, + ], + )) + } + + fn prepare_test( + repo: &gix::Repository, + base: ObjectId, + onto: ObjectId, + commits: &[Commit], + _head: Option<ObjectId>, + ) -> Result<Prepared> { + prepare(repo, base, onto, commits, &[], OntoKind::UpdatedBase, true) + } + + fn parse_plan(repo: &gix::Repository, document: &[u8]) -> Result<rebase::Plan> { + Ok(parse(repo, document)?.context("the test todo was cancelled")?.plan) + } + + fn with_state(prepared: &Prepared, commands: &str) -> Vec<u8> { + let document = std::str::from_utf8(&prepared.document).expect("generated todo is UTF-8"); + let start = document.find(STATE_START).expect("generated todo has state"); + let end = document[start..].find(STATE_CLOSE).expect("generated state is closed") + start + STATE_CLOSE.len(); + let mut bottom_up = Vec::new(); + write_bottom_up(&mut bottom_up, commands.as_bytes()).expect("test todo commands are UTF-8"); + let bottom_up = std::str::from_utf8(&bottom_up).expect("rendered test todo is UTF-8"); + format!("{}\n{bottom_up}", &document[start..end]).into_bytes() + } + + #[test] + fn markdown_flows_from_tip_to_base_and_uses_repository_abbreviations() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + repo.reference( + super::super::stash::reference(middle)?, + tip, + gix::refs::transaction::PreviousValue::MustNotExist, + "test todo stash marker", + )?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + assert!(!prepared.apply_unchanged); + let document = String::from_utf8(prepared.document.clone())?; + assert!( + document.starts_with(unchanged_notice(false, false)), + "the first line explains that saving unchanged is a no-op" + ); + assert!(document.contains(STATE_START), "the todo carries its transaction state"); + assert!(document.contains(&format!( + "# Rebase from `{}`", + crate::change_id::display_short(&repo, base)? + ))); + assert!(document.contains(&format!( + "fork {} (base) base", + crate::change_id::display_short(&repo, base)? + ))); + let middle = document.find("`pick ").expect("the oldest pick is shown"); + let tip = document.find("`@pick ").expect("HEAD is marked"); + let base = document.find("fork ").expect("the base separator is shown"); + assert!(tip < middle && middle < base, "the todo grows upward from its base"); + let separator = document + .lines() + .find(|line| line.contains("fork ")) + .expect("separator is present"); + assert!(separator.starts_with('─') && separator.ends_with('─')); + let plan = &document[..document.find("# Rebase todo help").expect("help is present")]; + let width = plan + .lines() + .filter(|line| line.starts_with('`') || line.starts_with('(') || line.starts_with('─')) + .map(|line| Line::raw(line).width()) + .max() + .expect("the editable plan has lines"); + assert_eq!( + Line::raw(separator).width(), + width, + "the separator spans the widest plan line" + ); + let left = separator.chars().take_while(|ch| *ch == '─').count(); + let right = separator.chars().rev().take_while(|ch| *ch == '─').count(); + assert!( + left >= 4 && right >= 4 && left.abs_diff(right) <= 1, + "the label is centered" + ); + assert!( + document.contains("middle * _ [markdown] <view> `code` \\ raw"), + "display metadata is emitted verbatim" + ); + assert!( + document.find("# Rebase todo help").expect("help is present") > tip, + "complete instructions follow the editable todo" + ); + assert!( + document.find(STATE_START).expect("state is present") + > document.find("# Rebase todo help").expect("help is present"), + "transaction state follows the complete help" + ); + assert!(document.ends_with("-->\n"), "the trailing state is a Markdown comment"); + assert!( + document.contains("○"), + "unsigned commits carry the documented status symbol" + ); + assert!( + document.contains("🎁"), + "stashed commits carry a display-only gift marker" + ); + let mut edited_symbols = document.clone(); + let marker = edited_symbols.find("🎁").expect("the command carries a stash marker"); + edited_symbols.replace_range(marker..marker + "🎁".len(), "changed-state"); + parse_plan(&repo, edited_symbols.as_bytes())?; + let plan = parse_plan(&repo, &prepared.document)?; + assert_eq!( + plan.checkout.as_ref().and_then(|checkout| checkout.reference.as_ref()), + Some(&"refs/heads/main".try_into()?), + "the generated @ command retains the implicitly attached branch" + ); + Ok(()) + } + + #[test] + fn enrichment_markers_precede_commit_states_in_initial_and_continuation_todos() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + crate::enrich::ensure_todo(&repo, middle, true)?; + crate::enrich::set_note(&repo, middle, Some(b"follow up"))?; + crate::enrich::ensure_checks_pass(&repo, middle, true)?; + + let id = crate::change_id::display_short(&repo, middle)?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let document = String::from_utf8(prepared.document)?; + assert!( + document.contains(&format!("`pick {id}` 🚧📝✔️ ○ 2000-01-02")), + "commit and tree enrichments precede the unsigned signature state" + ); + assert!( + document.contains("`🚧` means the commit is a todo, `📝` it has a note, `✔️` its tree passed checks"), + "the embedded legend explains enrichment states" + ); + + repo.reference( + super::super::stash::reference(middle)?, + tip, + gix::refs::transaction::PreviousValue::MustNotExist, + "test enriched todo stash ordering", + )?; + let prepared = prepare_continuation( + &repo, + &rebase::Plan { + base, + scope: vec![middle], + steps: vec![rebase::PlanStep { + parent: rebase::PlanParent::Existing(base), + commit: rebase::PlanCommit::Pick(middle), + squash: Vec::new(), + }], + checkout: None, + expected_refs: Vec::new(), + }, + vec![middle], + true, + )?; + let document = String::from_utf8(prepared.document)?; + assert!( + document.contains(&format!("`pick {id}` 🚧📝✔️ ○ 🎁 middle")), + "continuation todos retain enrichment ordering before stash state" + ); + Ok(()) + } + + #[test] + fn malformed_enrichments_do_not_prevent_todo_generation() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + let change_id = crate::change_id::for_commit(&repo, middle)?; + let reference: gix::refs::FullName = crate::enrich::REF_NAME.try_into()?; + repo.notes()? + .replace_at_ref(reference.as_ref(), ObjectId::from(change_id), b"[commit")?; + let tree_id = crate::enrich::tree_id(&repo, middle)?; + let reference: gix::refs::FullName = crate::enrich::TREE_REF_NAME.try_into()?; + repo.notes()?.replace_at_ref(reference.as_ref(), tree_id, b"[tree")?; + + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let document = String::from_utf8(prepared.document)?; + let line = document + .lines() + .find(|line| line.contains("2000-01-02 author middle")) + .expect("the malformed enrichment commit remains in the todo"); + assert!(line.contains(" ○ "), "ordinary commit states remain visible"); + for marker in ["🚧", "📝", "✔️"] { + assert!(!line.contains(marker), "malformed enrichments are ignored"); + } + Ok(()) + } + + #[test] + fn state_round_trips_non_utf8_ref_names_and_controls_cancellation() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, _tip, _commits) = commits(&repo)?; + let name = gix::refs::FullName::try_from(BString::from(vec![ + b'r', b'e', b'f', b's', b'/', b'h', b'e', b'a', b'd', b's', b'/', 0xff, + ]))?; + let state = State { + base, + onto: base, + tips: vec![middle], + scope: vec![middle], + marker_required: false, + checkout_allowed: true, + head_ref: Some(name.clone()), + edit_refs: true, + expected_refs: vec![rebase::ExpectedRef { + name: name.clone(), + old: Some(middle), + target: middle, + new: Some(middle), + follows_tip: true, + editable: true, + placement: None, + }], + resolved: None, + continuation_sources: Vec::new(), + }; + let mut document = Vec::new(); + write_state(&mut document, &state); + let document = String::from_utf8(document)?; + assert!( + document.contains(r#""refs/heads/\377""#), + "non-UTF-8 names use Git quoting" + ); + let parsed = parse_state(&repo, &document)?.context("state is present")?; + assert_eq!(parsed.expected_refs[0].name, name, "quoted names round-trip losslessly"); + let old = document.replacen("tix-rebase-state-v2", "tix-rebase-state-v1", 1); + let err = match parse_state(&repo, &old) { + Ok(_) => panic!("v1 order would be ambiguous under v2 semantics"), + Err(err) => err, + }; + assert!(format!("{err:#}").contains("unsupported state version")); + assert_eq!( + parsed.head_ref, + Some(name), + "the attached branch round-trips losslessly" + ); + + assert!(parse(&repo, b"")?.is_none(), "empty input cancels"); + assert!(parse(&repo, b"pick deadbeef")?.is_none(), "removing the anchor cancels"); + assert!( + parse(&repo, b"<!-- tix-rebase-state-v2\n-->").is_err(), + "an unsupported present anchor is rejected" + ); + Ok(()) + } + + #[test] + fn an_unchanged_todo_replays_pending_commits_with_normal_plan_semantics() -> gix_testtools::Result { + let (fixture, repo) = repo()?; + let (base, middle, old_tip, _) = commits(&repo)?; + let graph = super::super::loaded_graph(&repo)?; + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.tree = repo.find_commit(base)?.tree_id()?.detach(); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "--detach", &base.to_string()]) + .status()? + .success(), + "the pending stack is prepared away from the current checkout" + ); + let marked_outcome = rebase::perform( + &repo, + &graph, + rebase::Edit::Replace { target: middle, commit }, + rebase::Signature::InvalidateExisting, + rebase::Tree::LeaveAsIsAndMark, + )? + .complete()?; + let marked = marked_outcome + .selected + .expect("the pending replacement selects its rewritten commit"); + let tip = marked_outcome.map(old_tip).context("the pending tip is retained")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "main"]) + .status()? + .success(), + "the pending branch is checked out before preparing its todo" + ); + let commits = vec![ + Commit { + id: tip, + parents: vec![marked], + info: "tip".into(), + }, + Commit { + id: marked, + parents: vec![base], + info: "middle".into(), + }, + ]; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + assert!( + prepared.apply_unchanged, + "pending commits make an unchanged todo actionable" + ); + assert!( + prepared.document.starts_with(unchanged_notice(false, true).as_bytes()), + "the first line explains why the unchanged todo remains actionable" + ); + let document = prepared.document.clone(); + let plan = parse_plan(&repo, &document)?; + let graph = super::super::loaded_graph(&repo)?; + rebase::perform_plan(&repo, &graph, plan)?.complete()?; + + let mut current = Some(repo.head_id()?.detach()); + while let Some(id) = current { + let commit = repo.find_commit(id)?.decode()?.into_owned()?; + assert!(!rebase::has_marker(&commit), "the eager @ ancestry is replayed"); + current = commit.parents.first().copied(); + } + let files = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["ls-tree", "-r", "--name-only", "HEAD"]) + .output()?; + assert!(files.status.success()); + assert_eq!(files.stdout, b"base\ntip\n", "replay uses the recorded original parent"); + Ok(()) + } + + #[test] + fn pending_commits_outside_the_checkout_ancestry_do_not_apply_an_unchanged_todo() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, mut commits) = commits(&repo)?; + let mut sibling = repo.find_commit(tip)?.decode()?.into_owned()?; + sibling.parents = [middle].into_iter().collect(); + sibling.message = "pending sibling".into(); + sibling + .extra_headers + .push(("tix-rebase-parent".into(), middle.to_hex().to_string().into())); + let sibling = repo.write_object(&sibling)?.detach(); + commits.push(Commit { + id: sibling, + parents: vec![middle], + info: "pending sibling".into(), + }); + + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + assert!( + !prepared.apply_unchanged, + "pending commits on another fork must not replay the clean checkout ancestry" + ); + assert!( + prepared.document.starts_with(unchanged_notice(false, false).as_bytes()), + "the first line identifies an unchanged todo as a no-op" + ); + assert!( + prepared + .document + .windows("↻".len()) + .any(|window| window == "↻".as_bytes()), + "the pending sibling remains visible in the todo" + ); + Ok(()) + } + + #[test] + fn descendant_forks_stay_terse() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, mut commits) = commits(&repo)?; + let mut sibling = repo.find_commit(tip)?.decode()?.into_owned()?; + sibling.parents = [middle].into_iter().collect(); + sibling.message = "sibling".into(); + let sibling = repo.write_object(&sibling)?.detach(); + commits.insert( + 0, + Commit { + id: sibling, + parents: vec![middle], + info: "sibling title".into(), + }, + ); + + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let document = String::from_utf8(prepared.document.clone())?; + assert!(document.contains(&format!( + "fork {} (base) base", + crate::change_id::display_short(&repo, base)? + ))); + assert!( + document.contains(&format!("fork {} ", crate::change_id::display_short(&repo, middle)?)), + "a fork within the editable tree has no external-anchor annotation" + ); + let plan = parse_plan(&repo, document.as_bytes())?; + assert_eq!(plan.steps.len(), 3, "display annotations do not alter the plan"); + Ok(()) + } + + #[test] + fn update_todo_roots_the_stack_at_the_hidden_tip_and_labels_only_that_heading() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + let mut commit = repo.find_commit(base)?.decode()?.into_owned()?; + commit.parents = [base].into_iter().collect(); + commit.message = "updated * _ [hidden] <base> `raw` \\ base\n\n<!-- agent -->".into(); + let onto = repo.write_object(&commit)?.detach(); + repo.notes()?.replace("refs/notes/commits", onto, "anchor note")?; + + let prepared = prepare_test(&repo, base, onto, &commits, Some(tip))?; + assert!( + prepared.apply_unchanged, + "moving the base makes an unchanged editor document actionable" + ); + assert!( + prepared.document.starts_with(unchanged_notice(true, false).as_bytes()), + "the first line explains that the unchanged todo updates its base" + ); + let document = String::from_utf8(prepared.document.clone())?; + assert!( + document.contains(&format!( + "# Rebase from `{}` onto `{}`", + crate::change_id::display_short(&repo, base)?, + crate::change_id::display_short(&repo, onto)? + )), + "the update target is explicit in the document title" + ); + assert!( + document.contains(&format!( + "fork {} (updated-base) [A] [N] updated * _ [hidden] <base> `raw` \\ base", + crate::change_id::display_short(&repo, onto)? + )), + "the unfamiliar fork target carries its raw UI title" + ); + assert_eq!( + document.matches("updated * _ [hidden] <base> `raw` \\ base").count(), + 1, + "only the new update target is labelled" + ); + + let plan = parse_plan(&repo, document.as_bytes())?; + assert_eq!(plan.base, onto); + assert_eq!(plan.steps[0].parent, rebase::PlanParent::Existing(onto)); + let graph = super::super::loaded_graph(&repo)?; + let outcome = rebase::perform_plan(&repo, &graph, plan)?.complete()?; + let rewritten_middle = outcome.map(middle).expect("the middle commit is retained"); + assert_eq!( + repo.find_commit(rewritten_middle)? + .parent_ids() + .next() + .map(gix::Id::detach), + Some(onto), + "saving the unchanged update todo rebases the stack onto the hidden tip" + ); + Ok(()) + } + + #[test] + fn parses_reordering_forks_empty_commits_and_a_moved_checkout() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = format!( + "# Rebase\n\nfork {}\n`pick {}` ignored\n@empty a new checkpoint\n\nfork {}\n@pick {}\n", + base.to_hex_with_len(7), + tip.to_hex_with_len(7), + tip.to_hex_with_len(7), + middle.to_hex_with_len(7), + ); + let edited = with_state(&prepared, &edited); + let err = parse(&repo, &edited).expect_err("two checkout markers are invalid"); + assert!(format!("{err:#}").contains("more than one @")); + + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = format!( + "fork {}\npick {} ignored display metadata\nempty a new checkpoint\n\nfork {}\n@pick {}\n", + base.to_hex_with_len(7), + tip.to_hex_with_len(7), + tip.to_hex_with_len(7), + middle.to_hex_with_len(7), + ); + let edited = with_state(&prepared, &edited); + let plan = parse_plan(&repo, &edited)?; + assert_eq!(plan.steps.len(), 3); + assert_eq!( + plan.checkout.as_ref().map(|checkout| checkout.target), + Some(rebase::PlanParent::Step(2)) + ); + assert_eq!(plan.steps[2].parent, rebase::PlanParent::Step(0)); + assert!(matches!(&plan.steps[1].commit, rebase::PlanCommit::Empty(title) if title == b"a new checkpoint")); + Ok(()) + } + + #[test] + fn squash_above_a_command_folds_into_it_and_may_carry_checkout() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = with_state( + &prepared, + &format!( + "fork {}\npick {}\n`@squash {}` ignored display metadata\n\nfork {}\nempty side\n", + base.to_hex_with_len(7), + middle.to_hex_with_len(7), + tip.to_hex_with_len(7), + tip.to_hex_with_len(7), + ), + ); + let plan = parse_plan(&repo, &edited)?; + assert_eq!(plan.steps.len(), 2, "squash does not produce another commit"); + assert_eq!(plan.steps[0].squash, [tip]); + assert_eq!( + plan.checkout.as_ref().map(|checkout| checkout.target), + Some(rebase::PlanParent::Step(0)), + "the squash marker selects the folded result" + ); + assert_eq!( + plan.steps[1].parent, + rebase::PlanParent::Step(0), + "the squashed ID resolves to the folded result as a fork target" + ); + + let invalid = with_state( + &prepared, + &format!( + "fork {}\n@squash {}\npick {}\n", + base.to_hex_with_len(7), + tip.to_hex_with_len(7), + middle.to_hex_with_len(7), + ), + ); + let err = parse(&repo, &invalid).expect_err("a fork cannot begin with squash"); + assert!(format!("{err:#}").contains("same fork")); + Ok(()) + } + + #[test] + fn continuation_todos_round_trip_the_resolved_index_and_remaining_squashes() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, middle, tip, _) = commits(&repo)?; + let branch: gix::refs::FullName = "refs/heads/continued".try_into()?; + let prepared = prepare_continuation( + &repo, + &rebase::Plan { + base, + scope: vec![middle, tip], + steps: vec![rebase::PlanStep { + parent: rebase::PlanParent::Existing(base), + commit: rebase::PlanCommit::Resolved(middle), + squash: vec![tip], + }], + checkout: Some(rebase::PlanCheckout { + target: rebase::PlanParent::Step(0), + reference: Some(branch.clone()), + }), + expected_refs: vec![rebase::ExpectedRef { + name: branch.clone(), + old: None, + target: middle, + new: Some(middle), + follows_tip: false, + editable: true, + placement: Some(rebase::PlanParent::Step(0)), + }], + }, + vec![middle], + true, + )?; + assert!( + prepared + .document + .starts_with(b"<!-- Rebase help follows. Saving unchanged continues the materialized rebase"), + "the continuation explains that saving unchanged resumes it" + ); + let document = String::from_utf8(prepared.document.clone())?; + assert!(document.contains("(continued)"), "continuation refs are not marked"); + assert!( + !document.contains("(@continued)"), + "HEAD attachment stays in transaction state" + ); + assert!(document.contains(&"0".repeat(40)), "the conflict uses the full null ID"); + assert!( + document.contains(&format!("`squash {}`", crate::change_id::display_short(&repo, tip)?)), + "unapplied squash sources remain editable" + ); + let plan = parse_plan(&repo, &prepared.document)?; + assert!(matches!(plan.steps[0].commit, rebase::PlanCommit::Resolved(id) if id == middle)); + assert_eq!(plan.steps[0].squash, [tip]); + assert_eq!( + plan.checkout.as_ref().and_then(|checkout| checkout.reference.as_ref()), + Some(&branch), + "the continuation retains its attached checkout" + ); + assert!( + plan.expected_refs.iter().any(|reference| reference.name == branch + && reference.old.is_none() + && reference.placement == Some(rebase::PlanParent::Step(0))), + "a pending branch creation retains its nonexistence check and placement" + ); + Ok(()) + } + + #[test] + fn unchanged_checkout_marker_cannot_be_removed() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, _middle, tip, commits) = commits(&repo)?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = format!("fork {}\n", base.to_hex_with_len(7)); + let edited = with_state(&prepared, &edited); + let err = parse(&repo, &edited).expect_err("HEAD must be moved before its pick is dropped"); + assert!(format!("{err:#}").contains("checkout marker")); + + Ok(()) + } + + #[test] + fn reference_lines_move_create_delete_and_detach_head() -> gix_testtools::Result { + let (fixture, _) = repo()?; + crate::test_repository::disable_autocrlf(fixture.path())?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], + )?; + let (base, middle, tip, commits) = commits(&repo)?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let generated = String::from_utf8(prepared.document.clone())?; + assert!( + generated.contains("(main, refs/patches/tip)"), + "the generated todo shows the attached branch as an ordinary ref:\n{generated}" + ); + assert!(!generated.contains("@main"), "existing HEAD attachment is implicit"); + assert_eq!( + parse_plan(&repo, &prepared.document)? + .checkout + .and_then(|checkout| checkout.reference), + Some("refs/heads/main".try_into()?), + "an unchanged todo retains the original attachment" + ); + + let explicit = generated.replace("(main, refs/patches/tip)", "(@main, refs/patches/tip)"); + assert_eq!( + parse_plan(&repo, explicit.as_bytes())? + .checkout + .and_then(|checkout| checkout.reference), + Some("refs/heads/main".try_into()?), + "adding @ explicitly enforces the same attachment" + ); + + let mismatched = with_state( + &prepared, + &format!( + "fork {}\npick {}\n(@main)\n@pick {}\n", + base.to_hex_with_len(7), + middle.to_hex_with_len(7), + tip.to_hex_with_len(7), + ), + ); + let err = parse(&repo, &mismatched).expect_err("an explicit attachment must agree with @pick"); + assert!(format!("{err:#}").contains("different results")); + + let edited = with_state( + &prepared, + &format!( + "fork {}\npick {}\n(new-1, main)\n@pick {}\n", + base.to_hex_with_len(7), + middle.to_hex_with_len(7), + tip.to_hex_with_len(7), + ), + ); + let plan = parse_plan(&repo, &edited)?; + assert!( + plan.checkout + .as_ref() + .is_some_and(|checkout| checkout.reference.is_none()), + "moving the implicit HEAD branch away from @ requests a detached checkout" + ); + let graph = super::super::loaded_graph(&repo)?; + let outcome = rebase::perform_plan(&repo, &graph, plan)?.complete()?; + super::super::time_travel::checkout_plan(repo.git_dir(), false, &outcome, &[], false)?; + + assert!(repo.head()?.referent_name().is_none(), "HEAD is detached"); + assert_eq!( + repo.find_reference("refs/heads/new-1")?.id(), + outcome.map(middle).context("the middle commit is retained")?, + "the new branch line points at the following command below it" + ); + assert!( + repo.try_find_reference("refs/patches/middle")?.is_none() + && repo.try_find_reference("refs/patches/tip")?.is_none(), + "omitted generated refs are deleted" + ); + assert_eq!( + std::fs::read_to_string(fixture.path().join("tip"))?, + "tip\n", + "the detached checkout keeps the selected tree" + ); + Ok(()) + } + + #[test] + fn reference_lines_import_out_of_scope_refs_and_may_attach_head() -> gix_testtools::Result { + let (fixture, repo) = repo()?; + let (base, middle, tip, commits) = commits(&repo)?; + for name in ["refs/heads/outside", "refs/patches/attach"] { + repo.reference( + name, + base, + gix::refs::transaction::PreviousValue::MustNotExist, + "create out-of-scope todo ref", + )?; + } + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = with_state( + &prepared, + &format!( + "fork {}\npick {}\n(outside)\n@pick {}\n(@refs/patches/attach)\n", + base.to_hex_with_len(7), + middle.to_hex_with_len(7), + tip.to_hex_with_len(7), + ), + ); + let plan = parse_plan(&repo, &edited)?; + let graph = super::super::loaded_graph(&repo)?; + let outcome = rebase::perform_plan(&repo, &graph, plan)?.complete()?; + let selected = outcome.selected.context("the todo retains its checkout")?; + super::super::time_travel::checkout_plan(repo.git_dir(), false, &outcome, &[], false)?; + + assert_eq!( + repo.find_reference("refs/heads/outside")?.id(), + outcome.map(middle).context("the middle commit is retained")?, + "an unmarked out-of-scope ref moves like a generated ref" + ); + assert_eq!( + repo.find_reference("refs/patches/attach")?.id(), + selected, + "the marked out-of-scope ref moves to the selected result" + ); + assert_eq!( + repo.head()?.referent_name().expect("HEAD is attached"), + "refs/patches/attach", + "HEAD attaches to an editable ref outside refs/heads" + ); + assert_eq!( + std::fs::read_to_string(fixture.path().join("tip"))?, + "tip\n", + "the selected worktree tree remains checked out" + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?.index_tree, + Some(repo.find_commit(selected)?.tree_id()?.detach()), + "the index matches the attached commit" + ); + Ok(()) + } + + #[test] + fn rewritten_detached_head_is_not_pinned_before_checkout() -> gix_testtools::Result { + let (fixture, repo) = repo()?; + let (base, _middle, tip, commits) = commits(&repo)?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "--quiet", "--detach", &tip.to_string()]) + .status()? + .success(), + "the fixture HEAD can be detached" + ); + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = with_state( + &prepared, + &format!( + "fork {}\n@pick {}\n(main, refs/patches/tip)\n", + base.to_hex_with_len(7), + tip.to_hex_with_len(7), + ), + ); + let plan = parse_plan(&repo, &edited)?; + let graph = super::super::loaded_graph(&repo)?; + let outcome = rebase::perform_plan(&repo, &graph, plan)?.complete()?; + let selected = outcome.selected.context("the rewritten todo retains @")?; + assert_ne!(selected, tip, "dropping the middle commit rewrites the checked-out tip"); + + super::super::time_travel::checkout_plan(repo.git_dir(), false, &outcome, &[], false)?; + + assert_eq!(repo.head_id()?, selected, "HEAD reaches the rewritten successor"); + assert!( + crate::history::all_pins(&repo)?.iter().all(|pin| pin.id != tip), + "the superseded detached HEAD is not retained through a pin" + ); + Ok(()) + } + + #[test] + fn deleting_the_current_branch_is_deferred_until_head_detaches() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let (base, _middle, tip, commits) = commits(&repo)?; + let prepared = prepare_test(&repo, base, base, &commits, Some(tip))?; + let edited = with_state( + &prepared, + &format!("fork {}\n@pick {}\n", base.to_hex_with_len(7), tip.to_hex_with_len(7)), + ); + let plan = parse_plan(&repo, &edited)?; + let graph = super::super::loaded_graph(&repo)?; + let outcome = rebase::perform_plan(&repo, &graph, plan)?.complete()?; + assert!( + repo.try_find_reference("refs/heads/main")?.is_some(), + "the checked-out branch remains until checkout succeeds" + ); + super::super::time_travel::checkout_plan(repo.git_dir(), false, &outcome, &[], false)?; + assert!(repo.head()?.referent_name().is_none(), "HEAD is detached"); + assert!( + repo.try_find_reference("refs/heads/main")?.is_none(), + "the departed current branch is deleted" + ); + Ok(()) + } + + #[test] + fn todos_reject_an_unborn_head() -> gix_testtools::Result { + let (fixture, repo) = repo()?; + let (base, _middle, _tip, commits) = commits(&repo)?; + let prepared = prepare_test(&repo, base, base, &commits, None)?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["symbolic-ref", "HEAD", "refs/heads/unborn"]) + .status()? + .success(), + "HEAD becomes unborn while the todo is open" + ); + assert!( + prepare_test(&repo, base, base, &commits, None).is_err(), + "generation rejects an unborn HEAD" + ); + let err = parse(&repo, &prepared.document).expect_err("application rejects an unborn HEAD"); + assert!(format!("{err:#}").contains("born HEAD")); + Ok(()) + } +} diff --git a/gix-tix/src/edit/undo.rs b/gix-tix/src/edit/undo.rs new file mode 100644 index 00000000000..1316d446e74 --- /dev/null +++ b/gix-tix/src/edit/undo.rs @@ -0,0 +1,1283 @@ +use std::{ + collections::{BTreeMap, HashSet}, + path::PathBuf, +}; + +use anyhow::{Context, Result, bail, ensure}; +use gix::{ + ObjectId, + bstr::{BStr, BString, ByteSlice}, + config::File, + refs::{ + FullName, FullNameRef, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +pub(crate) const TIP_REF: &str = "refs/worktree/tix/undo"; +pub(crate) const CURSOR_REF: &str = "refs/worktree/tix/undo-cursor"; + +const VERSION: &str = "1"; +const START_TITLE: &str = "start of undo history"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum State { + Missing, + Object(ObjectId), + Symbolic(FullName), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RefChange { + pub name: FullName, + pub before: State, + pub after: State, +} + +impl RefChange { + pub(crate) fn from_edit(edit: &RefEdit) -> Result<Self> { + let (before, after) = match &edit.change { + Change::Update { expected, new, .. } => (state_from_expected(expected)?, state_from_target(new)), + Change::Delete { expected, .. } => (state_from_expected(expected)?, State::Missing), + }; + Ok(RefChange { + name: edit.name.clone(), + before, + after, + }) + } + + fn reversed(&self) -> Self { + RefChange { + name: self.name.clone(), + before: self.after.clone(), + after: self.before.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Position { + pub title: String, + pub undo: usize, + pub redo: usize, +} + +#[derive(Clone, Debug)] +pub(crate) struct Plan { + /// The operation crossed by this step. `position.title` is the title at the destination. + pub title: String, + /// Changes oriented from the current queue position to the destination. + pub changes: Vec<RefChange>, + /// Checked, non-dereferencing edits for `changes`, followed by the cursor edit. + pub edits: Vec<RefEdit>, + pub position: Position, +} + +impl Plan { + pub(crate) fn apply(self, repo: &gix::Repository) -> Result<()> { + self.apply_with_worktrees(repo) + } + + pub(crate) fn apply_with_worktrees(self, repo: &gix::Repository) -> Result<()> { + let transitions = worktree_transitions(repo, &self.changes)?; + for transition in &transitions { + super::forget::preflight_tree_transition( + &transition.repo, + &transition.workdir, + transition.old, + transition.new, + ) + .context("local changes prevent undo/redo; stash them manually and retry")?; + } + for (applied, transition) in transitions.iter().enumerate() { + if let Err(err) = super::forget::apply_tree_transition(&transition.workdir, transition.old, transition.new) + { + return Err(rollback_transitions( + &transitions[..=applied], + err.context("could not align a worktree with the undo queue"), + )); + } + } + if let Err(err) = repo.edit_references(self.edits) { + return Err(rollback_transitions( + &transitions, + anyhow::Error::new(err).context("could not atomically move references and the undo cursor"), + )); + } + Ok(()) + } +} + +pub(crate) fn is_queue_ref(name: &BStr) -> bool { + name.as_bytes() == TIP_REF.as_bytes() || name.as_bytes() == CURSOR_REF.as_bytes() +} + +pub(crate) fn ref_chain_reaches_queue(repo: &gix::Repository, name: &FullNameRef) -> Result<bool> { + let mut name = name.to_owned(); + let mut seen = HashSet::new(); + loop { + if is_queue_ref(name.as_bstr()) { + return Ok(true); + } + ensure!(seen.insert(name.clone()), "a symbolic reference chain contains a cycle"); + let Some(reference) = repo.try_find_reference(name.as_ref())? else { + return Ok(false); + }; + let target = reference.target(); + let Some(next) = target.try_name() else { + return Ok(false); + }; + name = next.to_owned(); + } +} + +pub(crate) fn is_queue_commit(repo: &gix::Repository, needle: ObjectId) -> Result<bool> { + let Some(mut id) = read_queue_ref(repo, TIP_REF)?.or(read_queue_ref(repo, CURSOR_REF)?) else { + return Ok(false); + }; + let mut seen = HashSet::new(); + loop { + ensure!(seen.insert(id), "the undo queue first-parent chain contains a cycle"); + let Ok(stored) = parse_commit(repo, id) else { + return Ok(false); + }; + if id == needle { + return Ok(true); + } + let Some(parent) = stored.parent else { + return Ok(false); + }; + id = parent; + } +} + +pub(crate) fn review_blocks_undo(repo: &gix::Repository) -> Result<bool> { + let references = repo.references().context("could not open review references")?; + for reference in references + .prefixed(crate::history::REVIEW_PREFIX.as_bstr()) + .context("could not iterate review references")? + { + let reference = match reference { + Ok(reference) => reference, + Err(err) if crate::history::is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read review reference: {err}")), + }; + if crate::history::review_number(reference.name().as_bstr()).is_some() { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn clear(repo: &gix::Repository) -> Result<()> { + let mut edits = Vec::new(); + for name in [TIP_REF, CURSOR_REF] { + let Some(reference) = repo + .try_find_reference(name) + .with_context(|| format!("could not read {name}"))? + else { + continue; + }; + edits.push(RefEdit { + name: reference.name().to_owned(), + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(reference.target().into_owned()), + log: RefLog::AndReference, + }, + }); + } + if edits.is_empty() { + return Ok(()); + } + repo.edit_references(edits) + .context("could not clear undo history") + .map(|_| ()) +} + +pub(crate) fn apply_reversed_changes(repo: &gix::Repository, changes: &[RefChange]) -> Result<()> { + let changes = normalize_changes(changes.iter().cloned())?; + if changes.is_empty() { + return Ok(()); + } + let edits = changes + .iter() + .map(RefChange::reversed) + .map(|change| checked_edit(&change)) + .collect::<Result<Vec<_>>>()?; + repo.edit_references(edits) + .context("could not roll back provisional reference changes") + .map(|_| ()) +} + +pub(crate) fn state(repo: &gix::Repository, name: &FullNameRef) -> Result<State> { + Ok(match repo.try_find_reference(name)? { + Some(reference) => state_from_target_ref(reference.target()), + None => State::Missing, + }) +} + +/// Convert checked transaction edits into one change per reference. +/// +/// Repeated edits must form a continuous sequence; the first before-state and final after-state +/// are retained, and resulting no-ops are omitted. +pub(crate) fn changes_from_edits(edits: impl IntoIterator<Item = RefEdit>) -> Result<Vec<RefChange>> { + normalize_changes( + edits + .into_iter() + .map(|edit| RefChange::from_edit(&edit)) + .collect::<Result<Vec<_>>>()?, + ) +} + +/// Record an already-successful operation and publish the new tip and cursor atomically. +/// +/// Returns `None` when all supplied changes cancel each other out. +pub(crate) fn record(repo: &gix::Repository, title: &str, changes: &[RefChange]) -> Result<Option<ObjectId>> { + validate_title(title)?; + if review_blocks_undo(repo)? { + clear(repo)?; + return Ok(None); + } + let changes = normalize_changes(changes.iter().cloned())?; + if changes.is_empty() { + return Ok(None); + } + + let queue = load(repo)?; + let (predecessor, old_tip, old_cursor) = match queue { + Some(queue) => (queue.cursor, Some(queue.tip), Some(queue.cursor)), + None => { + let config = serialize_config(&[])?; + let sentinel = write_commit(repo, START_TITLE, &config, &[])?; + (sentinel, None, None) + } + }; + let config = serialize_config(&changes)?; + let parents = retention_parents(repo, predecessor, &changes)?; + let entry = write_commit(repo, title, &config, &parents)?; + + repo.edit_references([ + queue_update(TIP_REF, old_tip, entry)?, + queue_update(CURSOR_REF, old_cursor, entry)?, + ]) + .context("could not publish the undo entry")?; + Ok(Some(entry)) +} + +pub(crate) fn position(repo: &gix::Repository) -> Result<Position> { + if review_blocks_undo(repo)? { + return Ok(empty_position()); + } + Ok(load(repo)?.map_or_else(empty_position, |queue| queue.position(queue.cursor_index))) +} + +pub(crate) fn plan_undo(repo: &gix::Repository) -> Result<Option<Plan>> { + if review_blocks_undo(repo)? { + return Ok(None); + } + let Some(queue) = load(repo)? else { return Ok(None) }; + if queue.cursor_index == 0 { + return Ok(None); + } + let entry = &queue.entries[queue.cursor_index - 1]; + let changes: Vec<_> = entry.changes.iter().map(RefChange::reversed).collect(); + let cursor = if queue.cursor_index == 1 { + queue.sentinel + } else { + queue.entries[queue.cursor_index - 2].id + }; + Ok(Some(make_plan( + &queue, + entry.title.clone(), + changes, + cursor, + queue.cursor_index - 1, + )?)) +} + +pub(crate) fn plan_redo(repo: &gix::Repository) -> Result<Option<Plan>> { + if review_blocks_undo(repo)? { + return Ok(None); + } + let Some(queue) = load(repo)? else { return Ok(None) }; + let Some(entry) = queue.entries.get(queue.cursor_index) else { + return Ok(None); + }; + Ok(Some(make_plan( + &queue, + entry.title.clone(), + entry.changes.clone(), + entry.id, + queue.cursor_index + 1, + )?)) +} + +fn make_plan( + queue: &Queue, + title: String, + changes: Vec<RefChange>, + cursor: ObjectId, + cursor_index: usize, +) -> Result<Plan> { + let mut edits = changes.iter().map(checked_edit).collect::<Result<Vec<_>>>()?; + edits.push(queue_update(CURSOR_REF, Some(queue.cursor), cursor)?); + Ok(Plan { + title, + changes, + edits, + position: queue.position(cursor_index), + }) +} + +fn empty_position() -> Position { + Position { + title: START_TITLE.into(), + undo: 0, + redo: 0, + } +} + +fn normalize_changes(changes: impl IntoIterator<Item = RefChange>) -> Result<Vec<RefChange>> { + let mut by_name = BTreeMap::<FullName, RefChange>::new(); + for change in changes { + ensure!( + !is_queue_ref(change.name.as_bstr()), + "the undo queue cannot record itself" + ); + match by_name.get_mut(&change.name) { + Some(existing) => { + ensure!( + existing.after == change.before, + "successive changes to {} are not continuous", + change.name + ); + existing.after = change.after; + } + None => { + by_name.insert(change.name.clone(), change); + } + } + } + Ok(by_name + .into_values() + .filter(|change| change.before != change.after) + .collect()) +} + +fn state_from_expected(expected: &PreviousValue) -> Result<State> { + match expected { + PreviousValue::MustNotExist => Ok(State::Missing), + PreviousValue::MustExistAndMatch(target) => Ok(state_from_target(target)), + PreviousValue::Any | PreviousValue::MustExist | PreviousValue::ExistingMustMatch(_) => { + bail!("an undo entry requires the exact previous reference value") + } + } +} + +fn state_from_target(target: &Target) -> State { + match target { + Target::Object(id) => State::Object(*id), + Target::Symbolic(name) => State::Symbolic(name.clone()), + } +} + +fn state_from_target_ref(target: gix::refs::TargetRef<'_>) -> State { + match target { + gix::refs::TargetRef::Object(id) => State::Object(id.to_owned()), + gix::refs::TargetRef::Symbolic(name) => State::Symbolic(name.to_owned()), + } +} + +struct WorktreeTransition { + repo: gix::Repository, + workdir: PathBuf, + old: ObjectId, + new: ObjectId, +} + +fn worktree_transitions(repo: &gix::Repository, changes: &[RefChange]) -> Result<Vec<WorktreeTransition>> { + let current_git_dir = gix::path::realpath(repo.git_dir()).context("could not resolve the current Git directory")?; + let mut repos = vec![ + repo.main_repo() + .context("could not open the main worktree repository")?, + ]; + for proxy in repo.worktrees().context("could not enumerate linked worktrees")? { + repos.push( + proxy + .into_repo_with_possibly_inaccessible_worktree() + .context("could not inspect a linked worktree")?, + ); + } + + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for worktree_repo in repos { + if !seen.insert(worktree_repo.git_dir().to_owned()) { + continue; + } + let head = worktree_repo.head().context("could not inspect a worktree HEAD")?; + let old_id = head.id().map(gix::Id::detach); + let raw_head = worktree_repo + .find_reference("HEAD") + .context("could not read a worktree HEAD reference")?; + let raw_head = state_from_target_ref(raw_head.target()); + let current = gix::path::realpath(worktree_repo.git_dir()) + .context("could not resolve an affected worktree Git directory")? + == current_git_dir; + let projected_head = projected_ref_state(&worktree_repo, b"HEAD".as_bstr(), &raw_head, changes, current)?; + ensure!( + projected_head != State::Missing, + "undo/redo cannot delete a worktree HEAD" + ); + let new_id = resolve_state(&worktree_repo, &projected_head, changes, current, &mut HashSet::new())?; + let old = tree_id(&worktree_repo, old_id).context("could not inspect the current worktree tree")?; + let new = tree_id(&worktree_repo, new_id).context("could not inspect the destination worktree tree")?; + if old == new || (worktree_repo.workdir().is_none() && worktree_repo.is_bare()) { + continue; + } + let workdir = worktree_repo + .workdir() + .filter(|path| path.is_dir()) + .context("an affected worktree is inaccessible")? + .to_owned(); + out.push(WorktreeTransition { + repo: worktree_repo, + workdir, + old, + new, + }); + } + Ok(out) +} + +fn projected_ref_state( + repo: &gix::Repository, + name: &BStr, + fallback: &State, + changes: &[RefChange], + include_head: bool, +) -> Result<State> { + if (include_head || name != b"HEAD") + && let Some(change) = changes.iter().find(|change| change.name.as_bstr() == name) + { + return Ok(change.after.clone()); + } + if name == b"HEAD" { + return Ok(fallback.clone()); + } + let name = FullName::try_from(name).context("a projected worktree reference name is invalid")?; + state(repo, name.as_ref()) +} + +fn resolve_state( + repo: &gix::Repository, + state: &State, + changes: &[RefChange], + include_head: bool, + seen: &mut HashSet<FullName>, +) -> Result<Option<ObjectId>> { + match state { + State::Missing => Ok(None), + State::Object(id) => Ok(Some(*id)), + State::Symbolic(name) => { + ensure!( + seen.insert(name.clone()), + "a projected worktree reference contains a symbolic cycle" + ); + let next = projected_ref_state(repo, name.as_bstr(), &State::Missing, changes, include_head)?; + resolve_state(repo, &next, changes, include_head, seen) + } + } +} + +fn tree_id(repo: &gix::Repository, commit: Option<ObjectId>) -> Result<ObjectId> { + commit.map_or_else( + || Ok(ObjectId::empty_tree(repo.object_hash())), + |commit| { + repo.find_commit(commit) + .context("a worktree HEAD target is not a commit")? + .tree_id() + .context("could not decode a worktree HEAD commit") + .map(gix::Id::detach) + }, + ) +} + +fn rollback_transitions(transitions: &[WorktreeTransition], mut cause: anyhow::Error) -> anyhow::Error { + for transition in transitions.iter().rev() { + if let Err(err) = super::forget::apply_tree_transition(&transition.workdir, transition.new, transition.old) { + cause = cause.context(format!("worktree rollback failed: {err:#}")); + } + } + cause +} + +fn checked_edit(change: &RefChange) -> Result<RefEdit> { + let expected = match &change.before { + State::Missing => PreviousValue::MustNotExist, + State::Object(id) => PreviousValue::MustExistAndMatch(Target::Object(*id)), + State::Symbolic(name) => PreviousValue::MustExistAndMatch(Target::Symbolic(name.clone())), + }; + let tx_change = match &change.after { + State::Missing => { + ensure!( + change.before != State::Missing, + "cannot delete an already-missing reference" + ); + Change::Delete { + expected, + log: RefLog::AndReference, + } + } + State::Object(id) => Change::Update { + expected, + new: Target::Object(*id), + log: log_change(), + }, + State::Symbolic(name) => Change::Update { + expected, + new: Target::Symbolic(name.clone()), + log: log_change(), + }, + }; + Ok(RefEdit { + name: change.name.clone(), + deref: false, + change: tx_change, + }) +} + +fn queue_update(name: &str, old: Option<ObjectId>, new: ObjectId) -> Result<RefEdit> { + Ok(RefEdit { + name: name.try_into().context("the undo queue reference name is invalid")?, + deref: false, + change: Change::Update { + expected: old.map_or(PreviousValue::MustNotExist, |id| { + PreviousValue::MustExistAndMatch(Target::Object(id)) + }), + new: Target::Object(new), + log: log_change(), + }, + }) +} + +fn log_change() -> LogChange { + LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "tix undo queue".into(), + } +} + +fn serialize_config(changes: &[RefChange]) -> Result<File> { + let mut config = File::default(); + config + .new_section("undo", None)? + .set("version", VERSION) + .context("could not serialize the undo version")?; + for change in changes { + let mut section = config + .new_section("ref", change.name.as_bstr()) + .context("could not serialize an undo reference")?; + section + .set("before", encode_state(&change.before)) + .context("could not serialize an undo before-state")?; + section + .set("after", encode_state(&change.after)) + .context("could not serialize an undo after-state")?; + } + Ok(config) +} + +fn encode_state(state: &State) -> BString { + match state { + State::Missing => "missing".into(), + State::Object(id) => format!("object:{id}").into(), + State::Symbolic(name) => { + let mut value = BString::from("symbolic:"); + value.extend_from_slice(name.as_bstr()); + value + } + } +} + +fn parse_config(repo: &gix::Repository, body: &BStr) -> Result<Vec<RefChange>> { + let config = File::try_from(body).context("could not parse undo metadata as Git config")?; + let mut sections = config.sections(); + let undo = sections.next().context("undo metadata has no version section")?; + ensure!( + undo.header().name() == b"undo" && undo.header().subsection_name().is_none(), + "undo metadata must start with [undo]" + ); + ensure_exact_keys(&undo, &["version"])?; + ensure!( + undo.value("version").as_ref().map(|value| value.as_slice()) == Some(VERSION.as_bytes()), + "unsupported undo metadata version" + ); + + let mut changes = Vec::new(); + let mut previous_name: Option<FullName> = None; + for section in sections { + ensure!( + section.header().name() == b"ref", + "undo metadata contains an unknown section" + ); + let subsection = section + .header() + .subsection_name() + .context("an undo ref section has no reference name")?; + let name = FullName::try_from(subsection).context("an undo entry contains an invalid reference name")?; + ensure!(!is_queue_ref(name.as_bstr()), "the undo queue records itself"); + if let Some(previous) = &previous_name { + ensure!( + previous < &name, + "undo reference sections are duplicated or out of order" + ); + } + previous_name = Some(name.clone()); + ensure_exact_keys(§ion, &["before", "after"])?; + let before = section.value("before").context("an undo ref has no before-state")?; + let before = parse_state(repo, before.as_bstr())?; + let after = section.value("after").context("an undo ref has no after-state")?; + let after = parse_state(repo, after.as_bstr())?; + ensure!(before != after, "an undo ref does not change"); + changes.push(RefChange { name, before, after }); + } + Ok(changes) +} + +fn ensure_exact_keys(section: &gix::config::file::SectionRef<'_>, expected: &[&str]) -> Result<()> { + let actual: Vec<_> = section.value_names().collect(); + ensure!( + actual == expected, + "undo metadata has missing, repeated, or unknown keys" + ); + Ok(()) +} + +fn parse_state(repo: &gix::Repository, value: &BStr) -> Result<State> { + if value == b"missing" { + return Ok(State::Missing); + } + if let Some(hex) = value.strip_prefix(b"object:") { + let id = ObjectId::from_hex(hex).context("an undo object ID is invalid")?; + ensure!( + id.kind() == repo.object_hash(), + "an undo object ID uses the wrong hash kind" + ); + return Ok(State::Object(id)); + } + if let Some(name) = value.strip_prefix(b"symbolic:") { + return FullName::try_from(name.as_bstr()) + .map(State::Symbolic) + .context("an undo symbolic target is invalid"); + } + bail!("an undo reference state has an unknown encoding") +} + +fn validate_title(title: &str) -> Result<()> { + ensure!(!title.is_empty(), "an undo operation title cannot be empty"); + ensure!( + !title.as_bytes().iter().any(|byte| matches!(byte, b'\n' | b'\r')), + "an undo operation title must be one line" + ); + Ok(()) +} + +fn retention_parents(repo: &gix::Repository, predecessor: ObjectId, changes: &[RefChange]) -> Result<Vec<ObjectId>> { + let mut parents = vec![predecessor]; + let mut seen = HashSet::from([predecessor]); + for id in changes + .iter() + .flat_map(|change| [&change.before, &change.after]) + .filter_map(|state| match state { + State::Object(id) => Some(*id), + State::Missing | State::Symbolic(_) => None, + }) + { + if seen.contains(&id) { + continue; + } + if repo + .find_header(id) + .with_context(|| format!("could not inspect retained undo object {id}"))? + .kind() + == gix::object::Kind::Commit + { + seen.insert(id); + parents.push(id); + } + } + Ok(parents) +} + +fn write_commit(repo: &gix::Repository, title: &str, config: &File, parents: &[ObjectId]) -> Result<ObjectId> { + validate_title(title)?; + let tree = repo + .write_object(gix::objs::Tree::empty()) + .context("could not write the undo queue's empty tree")? + .detach(); + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned()?; + let mut message = BString::from(title); + message.extend_from_slice(b"\n\n"); + message.extend_from_slice(&config.to_bstring()); + let commit = gix::objs::Commit { + tree, + parents: parents.iter().copied().collect(), + author: committer.clone(), + committer, + encoding: None, + message, + extra_headers: Vec::new(), + }; + repo.write_object(&commit) + .context("could not write an undo queue commit") + .map(gix::Id::detach) +} + +#[derive(Debug)] +struct StoredEntry { + id: ObjectId, + title: String, + changes: Vec<RefChange>, +} + +#[derive(Debug)] +struct Queue { + tip: ObjectId, + cursor: ObjectId, + sentinel: ObjectId, + entries: Vec<StoredEntry>, + cursor_index: usize, +} + +impl Queue { + fn position(&self, cursor_index: usize) -> Position { + Position { + title: cursor_index + .checked_sub(1) + .and_then(|index| self.entries.get(index)) + .map_or_else(|| START_TITLE.into(), |entry| entry.title.clone()), + undo: cursor_index, + redo: self.entries.len() - cursor_index, + } + } +} + +fn load(repo: &gix::Repository) -> Result<Option<Queue>> { + let tip = read_queue_ref(repo, TIP_REF)?; + let cursor = read_queue_ref(repo, CURSOR_REF)?; + let (tip, cursor) = match (tip, cursor) { + (None, None) => return Ok(None), + (Some(tip), Some(cursor)) => (tip, cursor), + _ => bail!("the undo queue has only one of its tip and cursor references"), + }; + + let mut id = tip; + let mut seen = HashSet::new(); + let mut entries = Vec::new(); + let sentinel; + loop { + ensure!(seen.insert(id), "the undo queue first-parent chain contains a cycle"); + let stored = parse_commit(repo, id)?; + match stored.parent { + Some(parent) => { + entries.push(StoredEntry { + id, + title: stored.title, + changes: stored.changes, + }); + id = parent; + } + None => { + sentinel = id; + break; + } + } + } + entries.reverse(); + let cursor_index = if cursor == sentinel { + 0 + } else { + entries + .iter() + .position(|entry| entry.id == cursor) + .map(|index| index + 1) + .context("the undo cursor is not on the tip's first-parent chain")? + }; + Ok(Some(Queue { + tip, + cursor, + sentinel, + entries, + cursor_index, + })) +} + +fn read_queue_ref(repo: &gix::Repository, name: &str) -> Result<Option<ObjectId>> { + let Some(reference) = repo + .try_find_reference(name) + .with_context(|| format!("could not read {name}"))? + else { + return Ok(None); + }; + match reference.target() { + gix::refs::TargetRef::Object(id) => Ok(Some(id.to_owned())), + gix::refs::TargetRef::Symbolic(_) => bail!("{name} must be a direct reference"), + } +} + +struct ParsedCommit { + parent: Option<ObjectId>, + title: String, + changes: Vec<RefChange>, +} + +fn parse_commit(repo: &gix::Repository, id: ObjectId) -> Result<ParsedCommit> { + let commit = repo + .find_commit(id) + .with_context(|| format!("could not find undo queue commit {id}"))? + .decode() + .context("could not decode an undo queue commit")? + .into_owned() + .context("could not own an undo queue commit")?; + ensure!( + commit.tree == ObjectId::empty_tree(repo.object_hash()), + "an undo queue commit does not use the empty tree" + ); + let message = gix::objs::commit::MessageRef::from_bytes(&commit.message); + let title = message + .title + .to_str() + .context("an undo operation title is not UTF-8")? + .to_owned(); + validate_title(&title)?; + let body = message.body.context("an undo queue commit has no metadata body")?; + let changes = parse_config(repo, body)?; + let parent = commit.parents.first().copied(); + match parent { + None => { + ensure!(title == START_TITLE, "the undo sentinel has the wrong title"); + ensure!(changes.is_empty(), "the undo sentinel contains reference changes"); + } + Some(parent) => { + ensure!(!changes.is_empty(), "an undo operation contains no reference changes"); + let expected = retention_parents(repo, parent, &changes)?; + ensure!( + commit.parents.as_slice() == expected, + "an undo commit has invalid retention parents" + ); + } + } + Ok(ParsedCommit { parent, title, changes }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo() -> gix_testtools::Result<(gix_testtools::tempfile::TempDir, gix::Repository)> { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + Ok((fixture, repository)) + } + + fn name(value: &str) -> gix_testtools::Result<FullName> { + Ok(value.try_into()?) + } + + fn set(repo: &gix::Repository, name: FullName, before: State, after: State) -> Result<()> { + checked_edit(&RefChange { name, before, after }).and_then(|edit| { + repo.edit_references([edit])?; + Ok(()) + }) + } + + fn child(repo: &gix::Repository, parent: ObjectId, title: &str) -> gix_testtools::Result<ObjectId> { + let mut commit = repo.find_commit(parent)?.decode()?.into_owned()?; + commit.parents = [parent].into_iter().collect(); + commit.message = title.into(); + Ok(repo.write_object(&commit)?.detach()) + } + + #[test] + fn config_round_trip_is_lossless_and_strict() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let id = repo.head_id()?.detach(); + let changes = vec![ + RefChange { + name: name("HEAD")?, + before: State::Symbolic(name("refs/heads/main")?), + after: State::Object(id), + }, + RefChange { + name: name("refs/heads/new")?, + before: State::Missing, + after: State::Object(id), + }, + ]; + let config = serialize_config(&changes)?; + assert_eq!( + parse_config(&repo, config.to_bstring().as_bstr())?, + changes, + "all supported reference states round-trip" + ); + assert!( + parse_config(&repo, b"[undo]\nversion = 1\nextra = nope\n".as_bstr()).is_err(), + "unknown metadata keys are rejected" + ); + assert!( + parse_config( + &repo, + b"[undo]\nversion = 1\n[ref \"refs/heads/a\"]\nbefore = missing\nafter = missing\n".as_bstr() + ) + .is_err(), + "no-op entries are rejected" + ); + Ok(()) + } + + #[test] + fn undo_and_redo_apply_checked_ref_and_cursor_edits() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let branch = name("refs/heads/undo-test")?; + let head = repo.head_id()?.detach(); + set(&repo, branch.clone(), State::Missing, State::Object(head))?; + let entry = record( + &repo, + "create branch", + &[RefChange { + name: branch.clone(), + before: State::Missing, + after: State::Object(head), + }], + )? + .expect("a change creates an entry"); + + let parents: Vec<_> = repo.find_commit(entry)?.parent_ids().map(gix::Id::detach).collect(); + assert_eq!( + parents.len(), + 2, + "the prior queue entry and changed commit are retained" + ); + assert_eq!(parents[1], head, "the changed commit is a retention parent"); + assert_eq!(position(&repo)?.undo, 1, "the operation is initially applied"); + + let undo = plan_undo(&repo)?.expect("one operation can be undone"); + assert_eq!(undo.title, "create branch"); + assert_eq!(undo.position.title, START_TITLE); + undo.apply(&repo)?; + assert!( + repo.try_find_reference(branch.as_ref())?.is_none(), + "undo deletes the created branch" + ); + assert_eq!(position(&repo)?.redo, 1, "the operation can be redone"); + + let redo = plan_redo(&repo)?.expect("one operation can be redone"); + assert_eq!(redo.position.title, "create branch"); + redo.apply(&repo)?; + assert_eq!(repo.find_reference(branch.as_ref())?.id(), head); + assert!(plan_redo(&repo)?.is_none(), "the tip has no redo operation"); + Ok(()) + } + + #[test] + fn recording_behind_tip_truncates_redo_and_merges_changes() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let branch = name("refs/heads/undo-test")?; + let first = repo.head_id()?.detach(); + let discarded = child(&repo, first, "discarded")?; + let replacement = child(&repo, first, "replacement")?; + + set(&repo, branch.clone(), State::Missing, State::Object(first))?; + record( + &repo, + "first", + &[RefChange { + name: branch.clone(), + before: State::Missing, + after: State::Object(first), + }], + )?; + set(&repo, branch.clone(), State::Object(first), State::Object(discarded))?; + record( + &repo, + "discarded", + &[RefChange { + name: branch.clone(), + before: State::Object(first), + after: State::Object(discarded), + }], + )?; + plan_undo(&repo)?.expect("second entry exists").apply(&repo)?; + + set(&repo, branch.clone(), State::Object(first), State::Object(replacement))?; + record( + &repo, + "replacement", + &[ + RefChange { + name: branch.clone(), + before: State::Object(first), + after: State::Object(discarded), + }, + RefChange { + name: branch, + before: State::Object(discarded), + after: State::Object(replacement), + }, + ], + )?; + let at_tip = position(&repo)?; + assert_eq!((at_tip.title.as_str(), at_tip.undo, at_tip.redo), ("replacement", 2, 0)); + assert!(plan_redo(&repo)?.is_none(), "the old redo tail is no longer reachable"); + Ok(()) + } + + #[test] + fn divergent_refs_fail_without_moving_the_cursor() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let branch = name("refs/heads/undo-test")?; + let expected = repo.head_id()?.detach(); + let external = child(&repo, expected, "external")?; + set(&repo, branch.clone(), State::Missing, State::Object(expected))?; + record( + &repo, + "create branch", + &[RefChange { + name: branch.clone(), + before: State::Missing, + after: State::Object(expected), + }], + )?; + let plan = plan_undo(&repo)?.expect("the operation can be planned"); + set(&repo, branch, State::Object(expected), State::Object(external))?; + assert!(plan.apply(&repo).is_err(), "the stale reference CAS is rejected"); + assert_eq!(position(&repo)?.undo, 1, "the cursor remains at the applied operation"); + Ok(()) + } + + #[test] + fn recording_accepts_a_ref_that_the_operation_deleted() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let branch = name("refs/heads/undo-test")?; + let head = repo.head_id()?.detach(); + set(&repo, branch.clone(), State::Missing, State::Object(head))?; + set(&repo, branch.clone(), State::Object(head), State::Missing)?; + + record( + &repo, + "delete branch", + &[RefChange { + name: branch.clone(), + before: State::Object(head), + after: State::Missing, + }], + )? + .expect("the deletion creates an entry"); + plan_undo(&repo)?.expect("the deletion can be undone").apply(&repo)?; + assert_eq!( + repo.find_reference(branch.as_ref())?.id(), + head, + "undo recreates the deleted reference" + ); + Ok(()) + } + + #[test] + fn an_active_review_blocks_and_discards_ref_only_undo_history() -> gix_testtools::Result { + let (_fixture, repo) = repo()?; + let head = repo.head_id()?.detach(); + let before_review = name("refs/heads/before-review")?; + set(&repo, before_review.clone(), State::Missing, State::Object(head))?; + record( + &repo, + "before review", + &[RefChange { + name: before_review, + before: State::Missing, + after: State::Object(head), + }], + )?; + + let review = name("refs/worktree/tix/review/1")?; + set(&repo, review, State::Missing, State::Object(head))?; + assert!( + review_blocks_undo(&repo)?, + "a valid review reference blocks the ref-only queue" + ); + assert!( + plan_undo(&repo)?.is_none(), + "review checkout state cannot be undone as refs alone" + ); + assert_eq!( + position(&repo)?, + empty_position(), + "the blocked queue is not presented as available" + ); + + let during_review = name("refs/heads/during-review")?; + set(&repo, during_review.clone(), State::Missing, State::Object(head))?; + assert!( + record( + &repo, + "during review", + &[RefChange { + name: during_review, + before: State::Missing, + after: State::Object(head), + }], + )? + .is_none(), + "operations during a review are deliberately not journalled" + ); + assert!( + repo.try_find_reference(TIP_REF)?.is_none(), + "the old queue tip is discarded" + ); + assert!( + repo.try_find_reference(CURSOR_REF)?.is_none(), + "the old queue cursor is discarded atomically" + ); + Ok(()) + } + + #[test] + fn attached_branch_checkout_follows_undo_and_redo() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("forget_commit.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + let repo = crate::test_repository::open(fixture.path())?; + let top = repo.head_id()?.detach(); + let parent = repo + .find_commit(top)? + .parent_ids() + .next() + .expect("the fixture tip has a parent") + .detach(); + let branch = repo + .head()? + .referent_name() + .expect("the fixture HEAD is attached") + .to_owned(); + let top_state = gix_testtools::repository::snapshot(fixture.path())?; + record( + &repo, + "restore tip", + &[RefChange { + name: branch, + before: State::Object(parent), + after: State::Object(top), + }], + )?; + + plan_undo(&repo)? + .expect("the tip operation can be undone") + .apply_with_worktrees(&repo)?; + let undone = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + undone.head, + gix_testtools::repository::Head::Symbolic { + name: b"refs/heads/main".into(), + id: parent, + } + ); + assert_eq!( + undone.index_tree, + Some(repo.find_commit(parent)?.tree_id()?.detach()), + "the index follows the undo destination" + ); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"base\n"); + assert!(!fixture.path().join("added").exists()); + + plan_redo(&repo)? + .expect("the tip operation can be redone") + .apply_with_worktrees(&repo)?; + let redone = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!(redone.head, top_state.head); + assert_eq!(redone.index_tree, top_state.index_tree); + assert_eq!(redone.worktree, top_state.worktree); + Ok(()) + } + + #[test] + fn linked_worktree_head_checkout_follows_undo_and_redo() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("forget_commit.sh")?; + crate::test_repository::disable_autocrlf(fixture.path())?; + let linked = fixture.path().join("linked"); + let status = std::process::Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q", "-b", "linked"]) + .arg(&linked) + .arg("HEAD") + .status()?; + assert!(status.success(), "git creates the linked worktree"); + + let repo = crate::test_repository::open(&linked)?; + let top = repo.head_id()?.detach(); + let parent = repo + .find_commit(top)? + .parent_ids() + .next() + .expect("the fixture tip has a parent") + .detach(); + let branch = repo + .head()? + .referent_name() + .expect("the linked worktree HEAD is attached") + .to_owned(); + let top_state = gix_testtools::repository::snapshot(&linked)?; + let status = std::process::Command::new("git") + .arg("-C") + .arg(&linked) + .args(["checkout", "-q", "--detach"]) + .arg(parent.to_string()) + .status()?; + assert!(status.success(), "git detaches the linked worktree at the parent"); + let detached_state = gix_testtools::repository::snapshot(&linked)?; + record( + &repo, + "detach linked worktree", + &[RefChange { + name: name("HEAD")?, + before: State::Symbolic(branch), + after: State::Object(parent), + }], + )?; + + plan_undo(&repo)? + .expect("the linked checkout can be undone") + .apply_with_worktrees(&repo)?; + let undone = gix_testtools::repository::snapshot(&linked)?; + assert_eq!(undone.head, top_state.head, "undo restores the attached HEAD"); + assert_eq!( + undone.index_tree, top_state.index_tree, + "the linked index follows the undo destination" + ); + assert_eq!( + undone.worktree, top_state.worktree, + "the linked worktree follows the undo destination" + ); + + plan_redo(&repo)? + .expect("the linked checkout can be redone") + .apply_with_worktrees(&repo)?; + let redone = gix_testtools::repository::snapshot(&linked)?; + assert_eq!(redone.head, detached_state.head, "redo restores the detached HEAD"); + assert_eq!( + redone.index_tree, detached_state.index_tree, + "the linked index follows the redo destination" + ); + assert_eq!( + redone.worktree, detached_state.worktree, + "the linked worktree follows the redo destination" + ); + Ok(()) + } +} diff --git a/gix-tix/src/enrich.rs b/gix-tix/src/enrich.rs new file mode 100644 index 00000000000..39513867774 --- /dev/null +++ b/gix-tix/src/enrich.rs @@ -0,0 +1,516 @@ +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BString, ByteSlice}, + config::File, + hash::ChangeId, + refs::FullName, +}; + +pub(crate) const REF_NAME: &str = "refs/worktree/tix/enrich"; +pub(crate) const TREE_REF_NAME: &str = "refs/worktree/tix/enrich-tree"; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct Enrichment { + pub todo: bool, + pub note: Option<BString>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct TreeEnrichment { + pub checks_pass: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct Headers { + pub todo: bool, + pub message: Option<BString>, +} + +pub(crate) fn marker(todo: bool, note: bool, checks_pass: bool) -> &'static str { + match (todo, note, checks_pass) { + (true, true, true) => "🚧📝✔️", + (true, true, false) => "🚧📝", + (true, false, true) => "🚧✔️", + (true, false, false) => "🚧", + (false, true, true) => "📝✔️", + (false, true, false) => "📝", + (false, false, true) => "✔️", + (false, false, false) => "", + } +} + +pub(crate) fn open(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> { + open_at(repo, REF_NAME) +} + +pub(crate) fn open_tree(repo: &gix::Repository) -> Result<gix::note::Platform<'_>> { + open_at(repo, TREE_REF_NAME) +} + +fn open_at<'repo>(repo: &'repo gix::Repository, reference: &str) -> Result<gix::note::Platform<'repo>> { + repo.notes() + .context("could not open tix enrichments")? + .with_refs([reference]) + .context("could not select the tix enrich reference") +} + +pub(crate) fn load(notes: &mut gix::note::Platform, change_id: ChangeId) -> Result<Enrichment> { + let config = load_config(notes, ObjectId::from(change_id))?; + let Some(config) = config else { + return Ok(Enrichment::default()); + }; + Ok(Enrichment { + todo: config + .boolean("commit.todo") + .context("commit.todo is not a boolean")? + .unwrap_or(false), + note: config.string("commit.note").filter(|note| !note.is_empty()), + }) +} + +pub(crate) fn load_tree(notes: &mut gix::note::Platform, tree_id: ObjectId) -> Result<TreeEnrichment> { + let Some(config) = load_config(notes, tree_id)? else { + return Ok(TreeEnrichment::default()); + }; + Ok(TreeEnrichment { + checks_pass: config + .boolean("tree.checks-pass") + .context("tree.checks-pass is not a boolean")? + .unwrap_or(false), + }) +} + +fn load_config(notes: &mut gix::note::Platform, object_id: ObjectId) -> Result<Option<File>> { + let found = notes.get(object_id).context("could not load the tix enrichment")?; + found + .first() + .map(|note| { + File::try_from(note.blob.data.as_bstr()).context("could not parse the tix enrichment as Git config") + }) + .transpose() +} + +pub(crate) fn tree_id(repo: &gix::Repository, commit_id: ObjectId) -> Result<ObjectId> { + repo.find_commit(commit_id) + .context("could not find the enriched commit")? + .tree_id() + .context("could not read the enriched commit tree") + .map(gix::Id::detach) +} + +pub(crate) fn toggle(repo: &gix::Repository, commit_id: ObjectId) -> Result<Enrichment> { + update(repo, commit_id, |config| { + let enabled = !config + .boolean("commit.todo") + .context("commit.todo is not a boolean")? + .unwrap_or(false); + set_todo(config, enabled) + }) +} + +pub(crate) fn ensure_todo(repo: &gix::Repository, commit_id: ObjectId, enabled: bool) -> Result<Enrichment> { + let current = load(&mut open(repo)?, crate::change_id::for_commit(repo, commit_id)?)?; + if current.todo == enabled { + return Ok(current); + } + update(repo, commit_id, |config| set_todo(config, enabled)) +} + +fn set_todo(config: &mut File, enabled: bool) -> Result<()> { + config + .section_mut_or_create_new("commit", None) + .context("could not create the commit enrichment section")? + .set("todo", if enabled { "true" } else { "false" }) + .context("could not update commit.todo")?; + Ok(()) +} + +pub(crate) fn set_note(repo: &gix::Repository, commit_id: ObjectId, note: Option<&[u8]>) -> Result<Enrichment> { + update(repo, commit_id, |config| { + let mut section = config + .section_mut_or_create_new("commit", None) + .context("could not create the commit enrichment section")?; + match note { + Some(note) => { + section.set("note", note).context("could not update commit.note")?; + } + None => { + section.remove("note"); + } + } + Ok(()) + }) +} + +pub(crate) fn toggle_checks_pass(repo: &gix::Repository, commit_id: ObjectId) -> Result<TreeEnrichment> { + let tree_id = tree_id(repo, commit_id)?; + update_tree(repo, tree_id, |config| { + let enabled = !config + .boolean("tree.checks-pass") + .context("tree.checks-pass is not a boolean")? + .unwrap_or(false); + set_checks_pass(config, enabled) + }) +} + +pub(crate) fn ensure_checks_pass(repo: &gix::Repository, commit_id: ObjectId, enabled: bool) -> Result<TreeEnrichment> { + let tree_id = tree_id(repo, commit_id)?; + let current = load_tree(&mut open_tree(repo)?, tree_id)?; + if current.checks_pass == enabled { + return Ok(current); + } + update_tree(repo, tree_id, |config| set_checks_pass(config, enabled)) +} + +fn set_checks_pass(config: &mut File, enabled: bool) -> Result<()> { + config + .section_mut_or_create_new("tree", None) + .context("could not create the tree enrichment section")? + .set("checks-pass", if enabled { "true" } else { "false" }) + .context("could not update tree.checks-pass")?; + Ok(()) +} + +pub(crate) fn apply_headers( + repo: &gix::Repository, + commit_id: ObjectId, + headers: &Headers, +) -> Result<Option<Enrichment>> { + let Some((object, data, desired)) = prepare_headers(repo, commit_id, headers)? else { + return Ok(None); + }; + let reference: FullName = REF_NAME.try_into().expect("the tix enrich reference is valid"); + open(repo)? + .replace_at_ref(reference.as_ref(), object, data) + .context("could not write the tix enrichment")?; + Ok(Some(desired)) +} + +pub(crate) fn prepare_headers( + repo: &gix::Repository, + commit_id: ObjectId, + headers: &Headers, +) -> Result<Option<(ObjectId, BString, Enrichment)>> { + let change_id = crate::change_id::for_commit(repo, commit_id)?; + let mut notes = open(repo)?; + let current = load(&mut notes, change_id)?; + let note = match ( + headers.message.as_ref().map(|message| message.as_bstr()), + current.note.as_ref().map(|note| note.as_bstr()), + ) { + (None, _) => None, + (Some(title), Some(existing)) => { + let parsed = gix::objs::commit::MessageRef::from_bytes(existing); + if parsed.summary().as_ref() == title { + Some(existing.to_owned()) + } else { + let mut message = BString::from(title); + if let Some(body) = parsed.body { + message.extend_from_slice(b"\n\n"); + message.extend_from_slice(body); + } + Some(message) + } + } + (Some(title), None) => Some(title.to_owned()), + }; + let desired = Enrichment { + todo: headers.todo, + note, + }; + if desired == current { + return Ok(None); + } + let object = ObjectId::from(change_id); + let mut config = load_config(&mut notes, object)?.unwrap_or_default(); + let mut section = config + .section_mut_or_create_new("commit", None) + .context("could not create the commit enrichment section")?; + section + .set("todo", if desired.todo { "true" } else { "false" }) + .context("could not update commit.todo")?; + match desired.note.as_ref().map(|note| note.as_bstr()) { + Some(note) => { + section.set("note", note).context("could not update commit.note")?; + } + None => { + section.remove("note"); + } + } + drop(section); + Ok(Some((object, config.to_bstring(), desired))) +} + +fn update( + repo: &gix::Repository, + commit_id: ObjectId, + edit: impl FnOnce(&mut File) -> Result<()>, +) -> Result<Enrichment> { + let change_id = crate::change_id::for_commit(repo, commit_id)?; + let mut notes = open(repo)?; + let mut config = load_config(&mut notes, ObjectId::from(change_id))?.unwrap_or_default(); + edit(&mut config)?; + let reference: FullName = REF_NAME.try_into().expect("the tix enrich reference is valid"); + notes + .replace_at_ref(reference.as_ref(), ObjectId::from(change_id), config.to_bstring()) + .context("could not write the tix enrichment")?; + load(&mut notes, change_id) +} + +fn update_tree( + repo: &gix::Repository, + tree_id: ObjectId, + edit: impl FnOnce(&mut File) -> Result<()>, +) -> Result<TreeEnrichment> { + let mut notes = open_tree(repo)?; + let mut config = load_config(&mut notes, tree_id)?.unwrap_or_default(); + edit(&mut config)?; + let reference: FullName = TREE_REF_NAME + .try_into() + .expect("the tix tree enrich reference is valid"); + notes + .replace_at_ref(reference.as_ref(), tree_id, config.to_bstring()) + .context("could not write the tix tree enrichment")?; + load_tree(&mut notes, tree_id) +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + + #[test] + fn toggling_preserves_other_fields() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + let id = repo.head_id()?.detach(); + let change_id = crate::change_id::for_commit(&repo, id)?; + let reference: FullName = REF_NAME.try_into()?; + repo.notes()?.replace_at_ref( + reference.as_ref(), + ObjectId::from(change_id), + b"[commit]\n\ttodo = true\n\towner = me\n", + )?; + + assert!(!toggle(&repo, id)?.todo); + let mut notes = open(&repo)?; + let note = notes + .get(ObjectId::from(change_id))? + .into_iter() + .next() + .expect("the toggled note exists"); + let config = File::try_from(note.blob.data.as_bstr())?; + assert_eq!(config.boolean("commit.todo")?, Some(false)); + assert_eq!( + config.string("commit.owner").as_ref().map(|value| value.as_bstr()), + Some(b"me".as_bstr()) + ); + Ok(()) + } + + #[test] + fn todo_follows_a_rewrite_by_change_id() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + let original = repo.head_id()?.detach(); + assert!(toggle(&repo, original)?.todo); + + let mut commit = repo.find_commit(original)?.decode()?.into_owned()?; + commit.message = "rewritten".into(); + crate::change_id::inherit(&repo, &mut commit, original)?; + let rewritten = repo.write_object(&commit)?.detach(); + let change_id = crate::change_id::for_commit(&repo, rewritten)?; + assert!( + load(&mut open(&repo)?, change_id)?.todo, + "the rewritten commit shares the todo" + ); + Ok(()) + } + + #[test] + fn checks_pass_follows_only_the_exact_tree_and_preserves_other_fields() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=checks author", "user.email=checks@example.com"], + )?; + let original = repo.head_id()?.detach(); + let tree = tree_id(&repo, original)?; + let reference: FullName = TREE_REF_NAME.try_into()?; + repo.notes()?.replace_at_ref( + reference.as_ref(), + tree, + b"[tree]\n\tchecks-pass = false\n\towner = me\n", + )?; + + assert!(toggle_checks_pass(&repo, original)?.checks_pass); + let mut notes = open_tree(&repo)?; + let note = notes.get(tree)?.into_iter().next().expect("the tree enrichment exists"); + let config = File::try_from(note.blob.data.as_bstr())?; + assert_eq!( + config.string("tree.owner").as_ref().map(|value| value.as_bstr()), + Some(b"me".as_bstr()) + ); + + let mut rewritten = repo.find_commit(original)?.decode()?.into_owned()?; + rewritten.message = "same tree".into(); + let rewritten = repo.write_object(&rewritten)?.detach(); + assert!( + load_tree(&mut open_tree(&repo)?, tree_id(&repo, rewritten)?)?.checks_pass, + "a message-only rewrite retains the tree marker" + ); + let changed_tree = tree_id(&repo, repo.rev_parse_single("HEAD~1")?.detach())?; + assert!( + !load_tree(&mut open_tree(&repo)?, changed_tree)?.checks_pass, + "a different tree has no marker" + ); + Ok(()) + } + + #[test] + fn notes_and_todos_are_independent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + let id = repo.head_id()?.detach(); + let message = b"Follow up\n\nExplain *why*.\n"; + + let enrichment = set_note(&repo, id, Some(message))?; + assert!(!enrichment.todo, "saving a note leaves todo disabled"); + assert_eq!( + enrichment.note.as_ref().map(|note| note.as_bstr()), + Some(message.as_bstr()) + ); + + let enrichment = toggle(&repo, id)?; + assert!(enrichment.todo, "the ordinary todo action enables todo"); + assert_eq!( + enrichment.note.as_ref().map(|note| note.as_bstr()), + Some(message.as_bstr()), + "toggling todo preserves its note" + ); + + let enrichment = set_note(&repo, id, None)?; + assert!(enrichment.todo, "emptying the editor preserves todo"); + assert!(enrichment.note.is_none(), "emptying the editor deletes the note"); + Ok(()) + } + + #[test] + fn commit_headers_edit_only_the_message_title() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=header author", "user.email=header@example.com"], + )?; + let id = repo.head_id()?.detach(); + set_note(&repo, id, Some(b"Old title\n\nbody stays byte-for-byte\n"))?; + + let unchanged = apply_headers( + &repo, + id, + &Headers { + todo: false, + message: Some("Old title".into()), + }, + )?; + assert!(unchanged.is_none(), "an unchanged title preserves the complete message"); + let changed = apply_headers( + &repo, + id, + &Headers { + todo: true, + message: Some("New title".into()), + }, + )? + .expect("the title and todo changed"); + assert!(changed.todo); + assert_eq!( + changed.note.as_ref().map(|note| note.as_bstr()), + Some(b"New title\n\nbody stays byte-for-byte\n".as_bstr()) + ); + + let removed = apply_headers(&repo, id, &Headers::default())?.expect("removing the title changes the message"); + assert!(!removed.todo); + assert!(removed.note.is_none(), "removing the title removes its body as well"); + Ok(()) + } + + #[test] + fn malformed_enrichments_are_not_overwritten() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=todo author", "user.email=todo@example.com"], + )?; + let id = repo.head_id()?.detach(); + let change_id = crate::change_id::for_commit(&repo, id)?; + let reference: FullName = REF_NAME.try_into()?; + repo.notes()? + .replace_at_ref(reference.as_ref(), ObjectId::from(change_id), b"[commit")?; + + assert!( + load(&mut open(&repo)?, change_id).is_err(), + "display can diagnose malformed enrichments" + ); + assert!( + toggle(&repo, id).is_err(), + "mutation does not replace malformed enrichments" + ); + let tree = tree_id(&repo, id)?; + let reference: FullName = TREE_REF_NAME.try_into()?; + repo.notes()?.replace_at_ref(reference.as_ref(), tree, b"[tree")?; + assert!( + toggle_checks_pass(&repo, id).is_err(), + "tree mutation does not replace malformed enrichments" + ); + Ok(()) + } + + #[test] + fn enrichments_are_private_to_each_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let linked_path = fixture.path().join("linked"); + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["worktree", "add", "-q", "--detach"]) + .arg(&linked_path) + .arg("HEAD") + .status()?; + assert!(status.success(), "git creates the linked worktree"); + let config = ["user.name=todo author", "user.email=todo@example.com"]; + let main = crate::test_repository::open_with(fixture.path(), config)?; + let linked = crate::test_repository::open_with(&linked_path, config)?; + let id = main.head_id()?.detach(); + let change_id = crate::change_id::for_commit(&main, id)?; + + assert!(toggle(&main, id)?.todo); + assert!( + !load(&mut open(&linked)?, change_id)?.todo, + "main enrichments do not leak to linked worktrees" + ); + assert!(toggle(&linked, id)?.todo); + assert!(!toggle(&main, id)?.todo); + assert!( + load(&mut open(&linked)?, change_id)?.todo, + "linked enrichments survive main-worktree changes" + ); + let tree = tree_id(&main, id)?; + assert!(toggle_checks_pass(&main, id)?.checks_pass); + assert!( + !load_tree(&mut open_tree(&linked)?, tree)?.checks_pass, + "tree enrichments are also worktree-local" + ); + Ok(()) + } +} diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 0b3d099dc9f..e32ac96dba6 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -1,5 +1,6 @@ use std::{ - collections::{HashMap, HashSet}, + cmp::Ordering as CmpOrdering, + collections::{BTreeSet, HashMap, HashSet}, ffi::OsString, sync::atomic::{AtomicBool, Ordering}, }; @@ -11,7 +12,7 @@ use gix::{ objs::commit::ref_iter::Token, }; -use crate::app::{Attribution, AttributionKind, Author, Commit, LoadedCommits, Metadata}; +use crate::app::{Attribution, AttributionKind, Author, Commit, LoadedCommits, Metadata, SignatureState}; pub(crate) type SharedAuthors = gix::features::threading::OwnShared<gix::features::threading::Mutable<Authors>>; static EMPTY_AUTHOR: std::sync::LazyLock<Author> = std::sync::LazyLock::new(|| Author { @@ -28,6 +29,14 @@ pub(crate) struct Decoration { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DecorationKind { Head, + Pin, + HeadPinBranch, + Stash, + Review, + CurrentWorktreeBranch, + CurrentWorktreeDetached, + WorktreeBranch, + WorktreeDetached, Local, Remote, Tag, @@ -36,95 +45,1570 @@ pub(crate) enum DecorationKind { } pub(crate) type Decorations = HashMap<ObjectId, Vec<Decoration>>; + +pub(crate) const PIN_PREFIX: &[u8] = b"refs/worktree/tix/pins/"; +pub(crate) const HEAD_PIN_NAME: &[u8] = b"refs/worktree/tix/pins/HEAD"; +pub(crate) const STASH_PREFIX: &[u8] = b"refs/tix/stash/"; +pub(crate) const REVIEW_PREFIX: &[u8] = b"refs/worktree/tix/review/"; +pub(crate) const REVIEW_STASH_PREFIX: &[u8] = b"refs/worktree/tix/review/stashes/"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Pin { + pub name: gix::refs::FullName, + pub target: gix::refs::Target, + pub id: ObjectId, +} + +impl Pin { + pub(crate) fn is_head(&self) -> bool { + self.name.as_bstr() == HEAD_PIN_NAME + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorktreeCheckout { + pub id: ObjectId, + pub label_id: ObjectId, + pub checkout_name: BString, + pub reference: Option<gix::refs::FullName>, + pub is_current: bool, + pub is_detached: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SelectionRef { + pub name: BString, + pub upstream: Option<Option<ObjectId>>, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct CommitIndex(u32); + +impl CommitIndex { + fn new(index: usize) -> Result<Self> { + Ok(CommitIndex( + index + .try_into() + .context("tix cannot index more than u32::MAX commits")?, + )) + } + + pub(crate) fn as_usize(self) -> usize { + self.0 as usize + } +} + +#[derive(Clone, Debug)] +struct GraphCommit { + id: ObjectId, + parents: std::ops::Range<u32>, + commit_time: gix::date::SecondsSinceUnixEpoch, + generation: u32, + state: u8, +} + +impl GraphCommit { + fn generation(&self) -> Option<gix::revwalk::graph::Generation> { + (self.generation != 0).then_some(self.generation) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct Node { + flags: u8, + expanded: u8, + emitted: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct HistoryGraph { + commits: Vec<GraphCommit>, + parents: Vec<CommitIndex>, + by_id: HashMap<ObjectId, CommitIndex>, + stored_order: Vec<CommitIndex>, + tracking: HashMap<CommitIndex, Vec<SelectionRef>>, + relations: HashMap<(CommitIndex, CommitIndex), (usize, usize)>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct GenThenTime { + generation: gix::revwalk::graph::Generation, + time: gix::date::SecondsSinceUnixEpoch, +} + +impl From<&GraphCommit> for GenThenTime { + fn from(commit: &GraphCommit) -> Self { + GenThenTime { + generation: commit + .generation() + .unwrap_or(gix::commitgraph::GENERATION_NUMBER_INFINITY), + time: commit.commit_time, + } + } +} + +impl Ord for GenThenTime { + fn cmp(&self, other: &Self) -> CmpOrdering { + self.generation.cmp(&other.generation).then(self.time.cmp(&other.time)) + } +} + +impl PartialOrd for GenThenTime { + fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> { + Some(self.cmp(other)) + } +} + +impl HistoryGraph { + #[cfg(test)] + pub(crate) fn from_test_commits(commits: &[(ObjectId, Vec<ObjectId>)]) -> Self { + let mut graph = HistoryGraph::default(); + for (id, _) in commits { + graph.intern(*id).expect("the test graph fits into u32"); + } + for (id, parents) in commits { + let index = graph.index(*id).expect("the test commit was interned"); + let start = graph.parents.len() as u32; + let parents: Vec<_> = parents + .iter() + .map(|parent| graph.index(*parent).expect("the test parent was interned")) + .collect(); + graph.parents.extend(parents); + let end = graph.parents.len() as u32; + graph.commits[index.as_usize()] = GraphCommit { + id: *id, + parents: start..end, + commit_time: index.as_usize() as i64, + generation: index.as_usize() as u32 + 1, + state: NODE_LOADED, + }; + } + graph.set_current_view(&commits.iter().map(|(id, _)| *id).collect::<Vec<_>>()); + graph + } + + pub(crate) fn for_commits(repo: &gix::Repository, ids: &[ObjectId]) -> Result<Self> { + let mut graph = HistoryGraph::default(); + let shallow: HashSet<_> = repo + .shallow_commits() + .context("could not read shallow commits")? + .into_iter() + .flat_map(|commits| commits.iter().copied().collect::<Vec<_>>()) + .collect(); + let commit_graph = repo + .commit_graph_if_enabled() + .context("could not open commit-graph for rebase")?; + let mut buf = Vec::new(); + for id in ids { + graph.ensure_commit(repo, commit_graph.as_ref(), &shallow, *id, &mut buf)?; + } + graph.set_current_view(ids); + Ok(graph) + } + + fn intern(&mut self, id: ObjectId) -> Result<CommitIndex> { + if let Some(index) = self.by_id.get(&id) { + return Ok(*index); + } + let index = CommitIndex::new(self.commits.len())?; + self.commits.push(GraphCommit { + id, + parents: 0..0, + commit_time: 0, + generation: 0, + state: 0, + }); + self.by_id.insert(id, index); + Ok(index) + } + + pub(crate) fn index(&self, id: ObjectId) -> Option<CommitIndex> { + self.by_id.get(&id).copied() + } + + pub(crate) fn id(&self, index: CommitIndex) -> ObjectId { + self.commits[index.as_usize()].id + } + + pub(crate) fn parents(&self, index: CommitIndex) -> &[CommitIndex] { + let range = self.commits[index.as_usize()].parents.clone(); + &self.parents[range.start as usize..range.end as usize] + } + + pub(crate) fn commit_count(&self) -> usize { + self.commits.len() + } + + pub(crate) fn stored_commit_ids(&self) -> impl Iterator<Item = ObjectId> + '_ { + self.stored_order.iter().map(|index| self.id(*index)) + } + + pub(crate) fn is_ancestor(&self, ancestor: ObjectId, descendant: ObjectId) -> bool { + let (Some(ancestor), Some(descendant)) = (self.index(ancestor), self.index(descendant)) else { + return false; + }; + let mut seen = vec![false; self.commits.len()]; + let mut pending = vec![descendant]; + while let Some(index) = pending.pop() { + if index == ancestor { + return true; + } + if std::mem::replace(&mut seen[index.as_usize()], true) { + continue; + } + pending.extend_from_slice(self.parents(index)); + } + false + } + + pub(crate) fn commits_with_descendants(&self) -> HashSet<ObjectId> { + self.commits + .iter() + .filter(|commit| commit.state & NODE_IN_VIEW != 0) + .flat_map(|commit| { + self.parents[commit.parents.start as usize..commit.parents.end as usize] + .iter() + .copied() + }) + .map(|parent| self.id(parent)) + .collect() + } + + pub(crate) fn parents_of(&self, id: ObjectId) -> Option<Vec<ObjectId>> { + let index = self.index(id)?; + Some(self.parents(index).iter().map(|parent| self.id(*parent)).collect()) + } + + pub(crate) fn commits_with_merge_descendants(&self) -> HashSet<ObjectId> { + let mut pending: Vec<_> = self + .commits + .iter() + .filter(|commit| commit.state & NODE_IN_VIEW != 0 && commit.parents.len() > 1) + .flat_map(|commit| { + let range = commit.parents.clone(); + self.parents[range.start as usize..range.end as usize].iter().copied() + }) + .collect(); + let mut ancestors = HashSet::new(); + while let Some(index) = pending.pop() { + if ancestors.insert(index) { + pending.extend( + self.parents(index) + .iter() + .copied() + .filter(|parent| self.commits[parent.as_usize()].state & NODE_IN_VIEW != 0), + ); + } + } + ancestors.into_iter().map(|index| self.id(index)).collect() + } + + pub(crate) fn descendants_in_parent_order(&self, root: ObjectId) -> Option<Vec<ObjectId>> { + let root = self.index(root)?; + if self.commits[root.as_usize()].state & NODE_IN_VIEW == 0 { + return None; + } + let mut included = HashSet::from([root]); + loop { + let mut changed = false; + for index in 0..self.commits.len() { + let index = CommitIndex::new(index).expect("an existing graph index fits into u32"); + if self.commits[index.as_usize()].state & NODE_IN_VIEW == 0 + || included.contains(&index) + || !self.parents(index).iter().any(|parent| included.contains(parent)) + { + continue; + } + included.insert(index); + changed = true; + } + if !changed { + break; + } + } + let mut out = Vec::with_capacity(included.len()); + while out.len() < included.len() { + let before = out.len(); + for index in &included { + if out.contains(index) + || self + .parents(*index) + .iter() + .any(|parent| included.contains(parent) && !out.contains(parent)) + { + continue; + } + out.push(*index); + } + if out.len() == before { + return None; + } + } + Some(out.into_iter().map(|index| self.id(index)).collect()) + } + + pub(crate) fn set_current_view(&mut self, tips: &[ObjectId]) { + for commit in &mut self.commits { + commit.state &= !NODE_IN_VIEW; + } + let mut pending: Vec<_> = tips.iter().filter_map(|id| self.index(*id)).collect(); + while let Some(index) = pending.pop() { + if self.commits[index.as_usize()].state & NODE_IN_VIEW != 0 { + continue; + } + self.commits[index.as_usize()].state |= NODE_IN_VIEW; + pending.extend_from_slice(self.parents(index)); + } + } + + fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds { + self.parents(index).iter().map(|parent| self.id(*parent)).collect() + } + + fn ensure_commit( + &mut self, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + shallow: &HashSet<ObjectId>, + id: ObjectId, + buf: &mut Vec<u8>, + ) -> Result<CommitIndex> { + let index = self.intern(id)?; + if self.commits[index.as_usize()].state & NODE_LOADED != 0 { + return Ok(index); + } + let commit = gix::traverse::commit::find(cache, &repo.objects, &id, buf) + .context("could not load commit for cached history traversal")?; + let (mut parents, commit_time, generation) = match commit { + gix::traverse::commit::Either::CommitRefIter(iter) => { + let mut parents = gix::traverse::commit::ParentIds::new(); + let mut commit_time = 0; + for token in iter { + match token.context("could not decode cached history commit")? { + Token::Tree { .. } => {} + Token::Parent { id } => parents.push(id), + Token::Committer { signature } => { + commit_time = signature.seconds(); + break; + } + _ => {} + } + } + (parents, commit_time, None) + } + gix::traverse::commit::Either::CachedCommit(commit) => { + let cache = cache.expect("cached commits originate from the provided commit-graph"); + let mut parents = gix::traverse::commit::ParentIds::new(); + for parent in commit.iter_parents() { + let parent = + parent.map_err(|err| anyhow::anyhow!("could not decode commit-graph parent: {err}"))?; + parents.push(cache.id_at(parent).to_owned()); + } + ( + parents, + commit.committer_timestamp() as gix::date::SecondsSinceUnixEpoch, + Some(commit.generation()), + ) + } + }; + if shallow.contains(&id) { + parents.clear(); + } + let parents: Vec<_> = parents + .into_iter() + .map(|parent| self.intern(parent)) + .collect::<Result<_>>()?; + let start: u32 = self + .parents + .len() + .try_into() + .context("tix cannot index more than u32::MAX parent edges")?; + self.parents.extend(parents); + let end: u32 = self + .parents + .len() + .try_into() + .context("tix cannot index more than u32::MAX parent edges")?; + let node = &mut self.commits[index.as_usize()]; + node.parents = start..end; + node.commit_time = commit_time; + node.generation = generation.unwrap_or_default(); + node.state |= NODE_LOADED; + Ok(index) + } + + #[expect(clippy::too_many_arguments)] + fn schedule_cached( + &mut self, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + shallow: &HashSet<ObjectId>, + states: &mut Vec<WalkState>, + queue: &mut gix::revwalk::PriorityQueue<gix::date::SecondsSinceUnixEpoch, CommitIndex>, + buf: &mut Vec<u8>, + id: ObjectId, + flags: u8, + ) -> Result<()> { + let index = self.ensure_commit(repo, cache, shallow, id, buf)?; + states.resize(self.commits.len(), WalkState::default()); + let state = &mut states[index.as_usize()]; + if state.flags & flags != flags { + state.flags |= flags; + queue.insert(self.commits[index.as_usize()].commit_time, index); + } + Ok(()) + } + + pub(crate) fn selection_refs(&self, id: ObjectId, decorations: &Decorations) -> Vec<SelectionRef> { + let tracked = self.index(id).and_then(|index| self.tracking.get(&index)); + let mut refs: Vec<_> = decorations + .get(&id) + .into_iter() + .flatten() + .map(|decoration| { + let upstream = if matches!( + decoration.kind, + DecorationKind::Local + | DecorationKind::HeadPinBranch + | DecorationKind::CurrentWorktreeBranch + | DecorationKind::WorktreeBranch + ) { + tracked + .into_iter() + .flatten() + .find(|reference| reference.name == decoration.name) + .and_then(|reference| reference.upstream) + } else { + None + }; + SelectionRef { + name: decoration.name.clone(), + upstream, + } + }) + .collect(); + refs.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.upstream.cmp(&b.upstream))); + refs + } + + pub(crate) fn selection_relation( + &mut self, + id: ObjectId, + refs: &[SelectionRef], + hidden: &[ObjectId], + ) -> Option<crate::app::SelectionRelation> { + let has_upstream = refs.iter().any(|reference| reference.upstream.is_some()); + for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { + let pair = self.index(id).zip(self.index(upstream))?; + let relation = if let Some(relation) = self.relations.get(&pair).copied() { + Some(relation) + } else { + let relation = self.paint(id, std::slice::from_ref(&upstream))?; + self.relations.insert(pair, relation); + Some(relation) + }; + if let Some((ahead, behind)) = relation { + return Some(crate::app::SelectionRelation::Tracking { ahead, behind }); + } + } + if has_upstream || refs.is_empty() || hidden.is_empty() { + return None; + } + self.paint(id, hidden) + .map(|(visible, _)| crate::app::SelectionRelation::Visible(visible)) + } + + pub(crate) fn hidden_branch_updates( + &self, + view_tips: &[ObjectId], + hidden_tips: impl IntoIterator<Item = ObjectId>, + ) -> HashMap<ObjectId, (usize, ObjectId)> { + let hidden_tips: HashSet<_> = hidden_tips.into_iter().collect(); + let mut out: HashMap<ObjectId, (usize, ObjectId)> = HashMap::new(); + for tip in hidden_tips { + let Some((ahead, _, bases)) = self.paint_with_bases(tip, view_tips) else { + continue; + }; + if ahead == 0 { + continue; + } + for base in bases { + out.entry(base) + .and_modify(|previous| *previous = (*previous).max((ahead, tip))) + .or_insert((ahead, tip)); + } + } + out + } + + fn paint(&self, first: ObjectId, others: &[ObjectId]) -> Option<(usize, usize)> { + self.paint_inner(first, others, false) + .map(|(ahead, behind, _)| (ahead, behind)) + } + + fn paint_with_bases(&self, first: ObjectId, others: &[ObjectId]) -> Option<(usize, usize, Vec<ObjectId>)> { + self.paint_inner(first, others, true) + } + + fn paint_inner( + &self, + first: ObjectId, + others: &[ObjectId], + collect_bases: bool, + ) -> Option<(usize, usize, Vec<ObjectId>)> { + let first = self.index(first)?; + let others: Vec<_> = others.iter().map(|id| self.index(*id)).collect::<Option<_>>()?; + let mut flags = vec![0u8; self.commits.len()]; + let mut queue = gix::revwalk::PriorityQueue::<GenThenTime, CommitIndex>::new(); + let mut queued = vec![false; self.commits.len()]; + let mut pending = 0usize; + let mut bases = Vec::new(); + for (index, flag) in std::iter::once((first, VISIBLE)).chain(others.into_iter().map(|index| (index, HIDDEN))) { + flags[index.as_usize()] |= flag; + if !queued[index.as_usize()] { + queued[index.as_usize()] = true; + queue.insert(GenThenTime::from(&self.commits[index.as_usize()]), index); + pending += 1; + } + } + while pending != 0 { + let Some((_priority, index)) = queue.pop() else { break }; + queued[index.as_usize()] = false; + let mut propagated = flags[index.as_usize()]; + if propagated & STALE == 0 { + pending -= 1; + } + if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { + if collect_bases && propagated & STALE == 0 { + bases.push(self.id(index)); + } + propagated |= STALE; + flags[index.as_usize()] = propagated; + } + for &parent in self.parents(index) { + let parent_flags = &mut flags[parent.as_usize()]; + let previous = *parent_flags; + if previous & propagated != propagated { + *parent_flags = previous | propagated; + if queued[parent.as_usize()] { + if previous & STALE == 0 && *parent_flags & STALE != 0 { + pending -= 1; + } + } else { + queued[parent.as_usize()] = true; + if *parent_flags & STALE == 0 { + pending += 1; + } + queue.insert(GenThenTime::from(&self.commits[parent.as_usize()]), parent); + } + } + } + } + let mut ahead = 0; + let mut behind = 0; + for flags in flags { + match flags & (VISIBLE | HIDDEN) { + VISIBLE => ahead += 1, + HIDDEN => behind += 1, + _ => {} + } + } + if collect_bases && bases.len() > 1 { + let candidates = bases.clone(); + bases.retain(|candidate| { + !candidates + .iter() + .any(|other| candidate != other && self.is_ancestor(*candidate, *other)) + }); + } + Some((ahead, behind, bases)) + } + + pub(crate) fn refresh( + &mut self, + repo: &gix::Repository, + revisions: &[OsString], + hidden_revisions: &[OsString], + include_worktrees: bool, + expand: &HashSet<ObjectId>, + authors: &SharedAuthors, + ) -> Result<Refresh> { + let refs = snapshot(repo, revisions, hidden_revisions, include_worktrees)?; + let hidden_only = refs.view_tips.is_empty() && !refs.hidden_tips.is_empty(); + let shallow: HashSet<_> = repo + .shallow_commits() + .context("could not read shallow commits")? + .into_iter() + .flat_map(|commits| commits.iter().copied().collect::<Vec<_>>()) + .collect(); + let cache = repo + .commit_graph_if_enabled() + .context("could not open commit-graph for history refresh")?; + let local_refs = local_refs_by_target(repo)?; + let mut tracking = HashMap::new(); + let mut states = vec![WalkState::default(); self.commits.len()]; + let mut queue = gix::revwalk::PriorityQueue::new(); + let mut buf = Vec::new(); + for id in refs.view_tips.iter().chain(&refs.hidden_tips).copied() { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + VISIBLE, + )?; + } + for &id in expand { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + EXPAND, + )?; + } + for (&id, names) in &local_refs { + let Some(index) = self.index(id) else { continue }; + if self.commits[index.as_usize()].state & NODE_STORED == 0 { + continue; + } + let tracked = resolve_tracking(repo, names)?; + if tracked.iter().any(|reference| reference.upstream.flatten().is_some()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + INTERNAL, + )?; + } + for upstream in tracked.iter().filter_map(|reference| reference.upstream.flatten()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + upstream, + INTERNAL, + )?; + } + tracking.insert(index, tracked); + } + + let mut rows = Vec::new(); + let mut attributions = Vec::new(); + while let Some((_time, index)) = queue.pop() { + let state = &mut states[index.as_usize()]; + let delta = state.flags & !state.expanded; + if delta == 0 { + continue; + } + state.expanded |= delta; + let id = self.id(index); + let commit = &self.commits[index.as_usize()]; + let was_stored = commit.state & NODE_STORED != 0; + let should_store = delta & (VISIBLE | EXPAND) != 0 && !was_stored; + let stop = !should_store + && commit.state & NODE_COMPLETE != 0 + && (delta & EXPAND == 0 || was_stored && !expand.contains(&id)); + let parent_indices = self.parents(index).to_vec(); + let parent_ids = self.parent_ids(index); + let generation = commit.generation(); + if should_store { + if let Some(names) = local_refs.get(&id) { + let tracked = resolve_tracking(repo, names)?; + if tracked.iter().any(|reference| reference.upstream.flatten().is_some()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + INTERNAL, + )?; + } + for upstream in tracked.iter().filter_map(|reference| reference.upstream.flatten()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + upstream, + INTERNAL, + )?; + } + tracking.insert(index, tracked); + } + let metadata = if generation.is_some() { + None + } else { + let object = repo.find_commit(id).context("could not read refreshed commit")?; + let mut authors = gix::features::threading::lock(authors); + Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) + }; + let metadata_loaded = metadata.is_some(); + let Metadata { + committer_time, + author_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + is_review, + signature, + } = metadata.unwrap_or_else(|| Metadata { + committer_time: Default::default(), + author_time: Default::default(), + author: &EMPTY_AUTHOR, + attributions: 0..0, + title: BString::default(), + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }); + rows.push(Commit { + id, + parent_ids: parent_ids.clone(), + committer_time, + author_time, + author, + attributions: row_attributions, + title, + metadata_loaded, + has_agent_marker, + is_review, + signature, + }); + self.commits[index.as_usize()].state |= NODE_STORED; + self.stored_order.push(index); + } + if stop { + continue; + } + if hidden_only { + continue; + } + for parent in parent_indices { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + self.id(parent), + delta & (VISIBLE | INTERNAL | EXPAND), + )?; + } + } + for (index, state) in states.into_iter().enumerate() { + if state.expanded & (VISIBLE | INTERNAL | EXPAND) != 0 { + self.commits[index].state |= NODE_COMPLETE; + } + } + self.tracking = tracking; + self.set_current_view(if hidden_only { + &refs.hidden_tips + } else { + &refs.view_tips + }); + let decorations = decorations(repo, &refs.pins, &refs.worktrees)?; + Ok(Refresh { + refs, + decorations, + commits: LoadedCommits { rows, attributions }, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RefSnapshot { + pub view: HashMap<BString, gix::refs::Target>, + pub hidden: HashMap<BString, gix::refs::Target>, + pub view_tips: Vec<ObjectId>, + pub hidden_tips: Vec<ObjectId>, + pub pins: Vec<Pin>, + pub worktrees: Vec<WorktreeCheckout>, +} + +#[derive(Debug)] +pub(crate) struct Refresh { + pub refs: RefSnapshot, + pub decorations: Decorations, + pub commits: LoadedCommits, +} #[derive(Default)] pub(crate) struct Authors { strings: HashSet<&'static [u8]>, authors: HashMap<(&'static BStr, &'static BStr), &'static Author>, } -const COMMIT_BATCH_SIZE: usize = 1024; +const COMMIT_BATCH_SIZE: usize = 1024; +const VISIBLE: u8 = 1 << 0; +const INTERNAL: u8 = 1 << 1; +const HIDDEN: u8 = 1 << 2; +const STALE: u8 = 1 << 3; +const EXPAND: u8 = 1 << 4; +const NODE_LOADED: u8 = 1 << 0; +const NODE_COMPLETE: u8 = 1 << 1; +const NODE_STORED: u8 = 1 << 2; +const NODE_IN_VIEW: u8 = 1 << 3; + +#[derive(Clone, Copy, Default)] +struct WalkState { + flags: u8, + expanded: u8, +} + +#[expect(clippy::too_many_arguments)] +fn schedule( + graph: &mut HistoryGraph, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + states: &mut Vec<Node>, + queue: &mut gix::revwalk::PriorityQueue<gix::date::SecondsSinceUnixEpoch, CommitIndex>, + shallow: &HashSet<ObjectId>, + buf: &mut Vec<u8>, + id: ObjectId, + flags: u8, +) -> Result<()> { + let index = graph.ensure_commit(repo, cache, shallow, id, buf)?; + states.resize(graph.commits.len(), Node::default()); + let state = &mut states[index.as_usize()]; + if state.flags & flags != flags { + state.flags |= flags; + queue.insert(graph.commits[index.as_usize()].commit_time, index); + } + Ok(()) +} + +fn hidden_frontier( + graph: &mut HistoryGraph, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + visible_tips: &[ObjectId], + hidden_tips: &[ObjectId], + shallow: &HashSet<ObjectId>, +) -> Result<HashSet<ObjectId>> { + if hidden_tips.is_empty() { + return Ok(HashSet::new()); + } + let mut flags = Vec::<u8>::new(); + let mut queue = gix::revwalk::PriorityQueue::<GenThenTime, CommitIndex>::new(); + let mut buf = Vec::new(); + for (tips, flag) in [(visible_tips, VISIBLE), (hidden_tips, HIDDEN)] { + for &id in tips { + let index = graph.ensure_commit(repo, cache, shallow, id, &mut buf)?; + flags.resize(graph.commits.len(), 0); + flags[index.as_usize()] |= flag; + queue.insert(GenThenTime::from(&graph.commits[index.as_usize()]), index); + } + } + while queue.iter_unordered().any(|index| flags[index.as_usize()] & STALE == 0) { + let Some((_priority, index)) = queue.pop() else { break }; + let mut propagated = flags[index.as_usize()]; + if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { + propagated |= STALE; + flags[index.as_usize()] = propagated; + } + let parents = graph.parents(index).to_vec(); + for parent in parents { + let parent_id = graph.id(parent); + let parent = graph.ensure_commit(repo, cache, shallow, parent_id, &mut buf)?; + flags.resize(graph.commits.len(), 0); + let parent_flags = &mut flags[parent.as_usize()]; + if *parent_flags & propagated != propagated { + *parent_flags |= propagated; + queue.insert(GenThenTime::from(&graph.commits[parent.as_usize()]), parent); + } + } + } + Ok(flags + .into_iter() + .enumerate() + .filter(|(_, flags)| flags & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN) + .map(|(index, _)| graph.id(CommitIndex(index as u32))) + .collect()) +} + +fn local_refs_by_target(repo: &gix::Repository) -> Result<HashMap<ObjectId, Vec<BString>>> { + let mut out = HashMap::<ObjectId, Vec<BString>>::new(); + let platform = repo.references().context("could not open references")?; + let refs = platform + .local_branches() + .context("could not iterate local branches")? + .peeled() + .context("could not prepare local branches for peeling")?; + for reference in refs { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read local branch: {err}")), + }; + out.entry(reference.id().detach()) + .or_default() + .push(reference.name().as_bstr().to_owned()); + } + Ok(out) +} + +fn resolve_tracking(repo: &gix::Repository, names: &[BString]) -> Result<Vec<SelectionRef>> { + let mut out = Vec::with_capacity(names.len()); + for full_name in names { + let Some(reference) = repo + .try_find_reference(full_name.as_bstr()) + .with_context(|| format!("could not read local branch {full_name}"))? + else { + continue; + }; + let upstream = reference + .remote_tracking_ref_name(gix::remote::Direction::Fetch) + .map(|name| { + let name = name.context("could not resolve remote-tracking branch name")?; + Ok::<_, anyhow::Error>( + repo.try_find_reference(name.as_bstr()) + .with_context(|| format!("could not read remote-tracking branch {name}"))? + .and_then(|mut reference| reference.peel_to_id().ok().map(gix::Id::detach)), + ) + }) + .transpose()?; + out.push(SelectionRef { + name: full_name + .strip_prefix(b"refs/heads/") + .unwrap_or(full_name.as_slice()) + .into(), + upstream, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.upstream.cmp(&b.upstream))); + out.dedup(); + Ok(out) +} + +#[derive(Debug)] +pub(crate) enum Event { + Decorations(Decorations), + Commits(LoadedCommits), + HiddenCommits(LoadedCommits), + VisibleComplete, + Complete(HistoryGraph), + Cancelled, +} + +pub(crate) fn load( + repo: &gix::Repository, + revisions: &[OsString], + hidden_revisions: &[OsString], + include_worktrees: bool, + authors: &SharedAuthors, + cancelled: &AtomicBool, + mut emit: impl FnMut(Event) -> bool, +) -> Result<()> { + let refs = snapshot(repo, revisions, hidden_revisions, include_worktrees)?; + let tips = refs.view_tips; + let hidden_tips = refs.hidden_tips; + + if !emit(Event::Decorations(decorations(repo, &refs.pins, &refs.worktrees)?)) { + return Ok(()); + } + if tips.is_empty() && hidden_tips.is_empty() { + emit(Event::VisibleComplete); + emit(Event::Complete(HistoryGraph::default())); + return Ok(()); + } + let shallow: HashSet<_> = repo + .shallow_commits() + .context("could not read shallow commits")? + .into_iter() + .flat_map(|commits| commits.iter().copied().collect::<Vec<_>>()) + .collect(); + let commit_graph = repo + .commit_graph_if_enabled() + .context("could not open commit-graph for history traversal")?; + let mut graph = HistoryGraph::default(); + if tips.is_empty() { + let mut rows = Vec::with_capacity(hidden_tips.len()); + let mut attributions = Vec::new(); + let mut authors = gix::features::threading::lock(authors); + let mut buf = Vec::new(); + for &id in &hidden_tips { + if cancelled.load(Ordering::Relaxed) { + emit(Event::Cancelled); + return Ok(()); + } + let index = graph.ensure_commit(repo, commit_graph.as_ref(), &shallow, id, &mut buf)?; + if graph.commits[index.as_usize()].state & NODE_STORED != 0 { + continue; + } + rows.push(decode_commit(repo, id, &mut authors, &mut attributions)?); + graph.commits[index.as_usize()].state |= NODE_STORED; + graph.stored_order.push(index); + } + drop(authors); + if !rows.is_empty() && !emit(Event::HiddenCommits(LoadedCommits { rows, attributions })) { + return Ok(()); + } + emit(Event::VisibleComplete); + graph.set_current_view(&hidden_tips); + emit(Event::Complete(graph)); + return Ok(()); + } + let hidden = hidden_frontier(&mut graph, repo, commit_graph.as_ref(), &tips, &hidden_tips, &shallow)?; + let local_refs = local_refs_by_target(repo)?; + let mut tracking = HashMap::new(); + let mut states = vec![Node::default(); graph.commits.len()]; + let mut queue = gix::revwalk::PriorityQueue::new(); + let mut buf = Vec::new(); + for &tip in &tips { + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + tip, + VISIBLE, + )?; + } + let mut rows = Vec::with_capacity(COMMIT_BATCH_SIZE); + let mut attributions = Vec::with_capacity(COMMIT_BATCH_SIZE); + let mut connected = Vec::new(); + let mut connected_seen = HashSet::new(); + while let Some((_time, index)) = queue.pop() { + if cancelled.load(Ordering::Relaxed) { + emit(Event::Cancelled); + return Ok(()); + } + let id = graph.id(index); + let (delta, should_emit) = { + let state = &mut states[index.as_usize()]; + let delta = state.flags & !state.expanded; + if delta == 0 { + continue; + } + state.expanded |= delta; + let should_emit = delta & VISIBLE != 0 && !state.emitted && !hidden.contains(&id); + state.emitted |= should_emit; + (delta, should_emit) + }; + graph.commits[index.as_usize()].state |= NODE_COMPLETE; + let parent_indices = graph.parents(index).to_vec(); + let parent_ids = graph.parent_ids(index); + let generation = graph.commits[index.as_usize()].generation(); + if should_emit && let Some(names) = local_refs.get(&id) { + let refs = resolve_tracking(repo, names)?; + if refs.iter().any(|reference| reference.upstream.flatten().is_some()) { + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + id, + INTERNAL, + )?; + } + for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + upstream, + INTERNAL, + )?; + } + tracking.insert(index, refs); + } + let metadata = if !should_emit || generation.is_some() { + None + } else { + let object = repo.find_commit(id).context("could not read commit")?; + let mut authors = gix::features::threading::lock(authors); + Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) + }; + if should_emit { + let metadata_loaded = metadata.is_some(); + let Metadata { + committer_time, + author_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + is_review, + signature, + } = metadata.unwrap_or_else(|| Metadata { + committer_time: Default::default(), + author_time: Default::default(), + author: &EMPTY_AUTHOR, + attributions: 0..0, + title: BString::default(), + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }); + if !hidden_revisions.is_empty() { + connected.extend(parent_ids.iter().copied().filter(|id| connected_seen.insert(*id))); + } + rows.push(Commit { + id, + parent_ids: parent_ids.clone(), + committer_time, + author_time, + author, + attributions: row_attributions, + title, + metadata_loaded, + has_agent_marker, + is_review, + signature, + }); + graph.commits[index.as_usize()].state |= NODE_STORED; + graph.stored_order.push(index); + if rows.len() == COMMIT_BATCH_SIZE + && !emit(Event::Commits(LoadedCommits { + rows: std::mem::replace(&mut rows, Vec::with_capacity(COMMIT_BATCH_SIZE)), + attributions: std::mem::replace(&mut attributions, Vec::with_capacity(COMMIT_BATCH_SIZE)), + })) + { + return Ok(()); + } + } + let propagated = if hidden.contains(&id) { + delta & INTERNAL + } else { + delta & (VISIBLE | INTERNAL) + }; + for parent in parent_indices { + let parent_id = graph.id(parent); + let parent_flags = if hidden.contains(&parent_id) { + propagated & !VISIBLE + } else { + propagated + }; + if parent_flags != 0 { + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + parent_id, + parent_flags, + )?; + } + } + } + if !rows.is_empty() && !emit(Event::Commits(LoadedCommits { rows, attributions })) { + return Ok(()); + } + if !hidden_revisions.is_empty() { + connected.retain(|id| graph.index(*id).is_none_or(|index| !states[index.as_usize()].emitted)); + let mut rows = Vec::with_capacity(connected.len()); + let mut attributions = Vec::new(); + let mut authors = gix::features::threading::lock(authors); + for id in connected { + if cancelled.load(Ordering::Relaxed) { + emit(Event::Cancelled); + return Ok(()); + } + rows.push(decode_commit(repo, id, &mut authors, &mut attributions)?); + let index = graph.ensure_commit(repo, commit_graph.as_ref(), &shallow, id, &mut buf)?; + if graph.commits[index.as_usize()].state & NODE_STORED == 0 { + graph.commits[index.as_usize()].state |= NODE_STORED; + graph.stored_order.push(index); + } + } + if !rows.is_empty() && !emit(Event::HiddenCommits(LoadedCommits { rows, attributions })) { + return Ok(()); + } + } + emit(Event::VisibleComplete); + graph.tracking = tracking; + graph.set_current_view(&tips); + emit(Event::Complete(graph)); + Ok(()) +} + +pub(crate) fn snapshot( + repo: &gix::Repository, + revisions: &[OsString], + hidden: &[OsString], + include_worktrees: bool, +) -> Result<RefSnapshot> { + snapshot_ignoring_pin(repo, revisions, hidden, include_worktrees, None) +} + +pub(crate) fn ref_tree_revisions(repo: &gix::Repository, include_tags: bool) -> Result<Vec<OsString>> { + let mut out = Vec::new(); + for reference in repo + .references() + .context("could not open references")? + .all() + .context("could not iterate references")? + { + let mut reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read reference: {err}")), + }; + let name = reference.name().as_bstr().to_owned(); + let kind = decoration_kind(&name); + if matches!(kind, DecorationKind::Special | DecorationKind::Pin) + || matches!(kind, DecorationKind::Tag) && !include_tags + || name.starts_with(STASH_PREFIX) + || name.starts_with(REVIEW_STASH_PREFIX) + { + continue; + } + let Ok(id) = reference.peel_to_id() else { continue }; + if repo.find_header(id)?.kind() != gix::object::Kind::Commit { + continue; + } + out.push(gix::path::from_bstr(&name).into_owned().into_os_string()); + } + if repo.head().is_ok_and(|head| head.referent_name().is_none()) { + out.push("HEAD".into()); + } + Ok(out) +} + +pub(crate) fn snapshot_ignoring_pin( + repo: &gix::Repository, + revisions: &[OsString], + hidden: &[OsString], + include_worktrees: bool, + ignored_pin: Option<&BStr>, +) -> Result<RefSnapshot> { + let pins = applicable_pins(repo)? + .into_iter() + .filter(|pin| ignored_pin != Some(pin.name.as_bstr())) + .collect::<Vec<_>>(); + let worktrees = worktree_checkouts(repo); + let mut view = referenced_refs(repo, revisions)?; + for pin in &pins { + insert_ref_chain(repo, pin.name.as_bstr(), &mut view)?; + } + let mut view_tips = resolve_tips(repo, revisions)?.unwrap_or_default(); + view_tips.extend(pins.iter().map(|pin| pin.id)); + if include_worktrees { + for worktree in &worktrees { + view_tips.extend([worktree.id, worktree.label_id]); + if let Some(reference) = &worktree.reference { + insert_ref_chain(repo, reference.as_bstr(), &mut view)?; + } + } + } + let mut seen = HashSet::new(); + view_tips.retain(|id| seen.insert(*id)); + Ok(RefSnapshot { + view, + hidden: referenced_refs(repo, hidden)?, + view_tips, + hidden_tips: resolve_revisions(repo, hidden, "hidden ")?, + pins, + worktrees, + }) +} + +pub(crate) fn worktree_checkouts(repo: &gix::Repository) -> Vec<WorktreeCheckout> { + let mut out = Vec::new(); + let current_worktree = repo.worktree().map(|worktree| worktree.id().map(ToOwned::to_owned)); + match repo.main_repo() { + Ok(main) if !main.is_bare() => { + let name = main.workdir().and_then(worktree_basename); + add_worktree_checkout(&main, name, b"main".as_bstr(), current_worktree == Some(None), &mut out); + } + Ok(_) => {} + Err(err) => tracing::warn!(error = %err, "ignoring inaccessible main worktree"), + } + match repo.worktrees() { + Ok(worktrees) => { + for proxy in worktrees { + let worktree = proxy.id().to_owned(); + let name = proxy.base().ok().as_deref().and_then(worktree_basename); + let is_current = current_worktree + .as_ref() + .and_then(Option::as_ref) + .is_some_and(|current| current == &worktree); + match proxy.into_repo_with_possibly_inaccessible_worktree() { + Ok(repository) => { + add_worktree_checkout(&repository, name, worktree.as_bstr(), is_current, &mut out); + } + Err(err) => { + tracing::warn!(worktree = %worktree, error = %err, "ignoring inaccessible linked worktree"); + } + } + } + } + Err(err) => tracing::warn!(error = %err, "ignoring unreadable linked worktree list"), + } + out.sort_by(|a, b| { + a.id.cmp(&b.id) + .then_with(|| a.label_id.cmp(&b.label_id)) + .then_with(|| a.checkout_name.cmp(&b.checkout_name)) + .then_with(|| a.reference.cmp(&b.reference)) + }); + out.dedup(); + out +} + +fn add_worktree_checkout( + repo: &gix::Repository, + checkout_name: Option<BString>, + worktree: &BStr, + is_current: bool, + out: &mut Vec<WorktreeCheckout>, +) { + let mut head = match repo.head() { + Ok(head) => head, + Err(err) => { + tracing::warn!(%worktree, error = %err, "ignoring worktree with unreadable HEAD"); + return; + } + }; + let is_detached = head.is_detached(); + let head_reference = head.referent_name().map(ToOwned::to_owned); + let id = match head.try_peel_to_id() { + Ok(Some(id)) => id.detach(), + Ok(None) => return, + Err(err) => { + tracing::warn!(%worktree, error = %err, "ignoring worktree with unresolved HEAD"); + return; + } + }; + let remembered = is_detached + .then(|| remembered_worktree_branch(repo, worktree)) + .flatten(); + let (reference, label_id) = remembered + .or_else(|| head_reference.map(|reference| (reference, id))) + .map_or((None, id), |(reference, id)| (Some(reference), id)); + let checkout_name = checkout_name.unwrap_or_else(|| worktree.to_owned()); + out.push(WorktreeCheckout { + id, + label_id, + checkout_name, + reference, + is_current, + is_detached, + }); +} + +fn remembered_worktree_branch(repo: &gix::Repository, worktree: &BStr) -> Option<(gix::refs::FullName, ObjectId)> { + let mut pin = match repo.try_find_reference(HEAD_PIN_NAME.as_bstr()) { + Ok(Some(pin)) => pin, + Ok(None) => return None, + Err(err) => { + tracing::warn!(%worktree, error = %err, "ignoring unreadable worktree HEAD pin"); + return None; + } + }; + let Some(reference) = pin.target().try_name().map(ToOwned::to_owned) else { + tracing::warn!(%worktree, "ignoring direct worktree HEAD pin"); + return None; + }; + if !reference.as_bstr().starts_with(b"refs/heads/") { + tracing::warn!(%worktree, name = %reference, "ignoring non-branch worktree HEAD pin"); + return None; + } + let id = match pin.peel_to_id() { + Ok(id) => id.detach(), + Err(err) => { + tracing::warn!(%worktree, name = %reference, error = %err, "ignoring unresolved worktree HEAD pin"); + return None; + } + }; + match repo.find_header(id) { + Ok(header) if header.kind() == gix::object::Kind::Commit => Some((reference, id)), + Ok(_) => { + tracing::warn!(%worktree, name = %reference, "ignoring worktree HEAD pin with a non-commit target"); + None + } + Err(err) => { + tracing::warn!(%worktree, name = %reference, error = %err, "ignoring unreadable worktree HEAD pin target"); + None + } + } +} + +fn worktree_basename(path: &std::path::Path) -> Option<BString> { + path.file_name() + .and_then(|name| gix::path::os_str_into_bstr(name).ok()) + .map(ToOwned::to_owned) +} + +pub(crate) fn all_pins(repo: &gix::Repository) -> Result<Vec<Pin>> { + refs_with_commit_targets(repo, PIN_PREFIX, "pin") +} + +pub(crate) fn all_reviews(repo: &gix::Repository) -> Result<Vec<Pin>> { + refs_with_commit_targets(repo, REVIEW_PREFIX, "review") +} + +pub(crate) fn review_number(name: &BStr) -> Option<&BStr> { + let suffix = name.strip_prefix(REVIEW_PREFIX)?; + (suffix.first().is_some_and(|digit| matches!(digit, b'1'..=b'9')) && suffix.iter().all(u8::is_ascii_digit)) + .then_some(suffix.as_bstr()) +} + +fn refs_with_commit_targets(repo: &gix::Repository, prefix: &[u8], label: &str) -> Result<Vec<Pin>> { + let mut out = Vec::new(); + let references = repo.references().context("could not open references")?; + for reference in references + .prefixed(prefix.as_bstr()) + .with_context(|| format!("could not iterate tix {label}s"))? + { + let mut reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read tix {label}: {err}")), + }; + let suffix = reference.name().as_bstr().strip_prefix(prefix).unwrap_or_default(); + let valid_suffix = if prefix == REVIEW_PREFIX { + review_number(reference.name().as_bstr()).is_some() + } else { + suffix.len() >= 4 && suffix.iter().all(u8::is_ascii_alphanumeric) + }; + if !valid_suffix { + tracing::warn!(name = %reference.name(), %label, "ignoring malformed tix reference"); + continue; + } + let name = reference.name().to_owned(); + let target = reference.target().into_owned(); + if let Some(target_name) = target.try_name() + && crate::edit::undo::ref_chain_reaches_queue(repo, target_name)? + { + tracing::warn!(name = %name, %label, "ignoring tix reference into the undo queue"); + continue; + } + if name.as_bstr() == HEAD_PIN_NAME + && !target + .try_name() + .is_some_and(|name| name.as_bstr().starts_with(b"refs/heads/")) + { + tracing::warn!(name = %name, "ignoring malformed HEAD pin"); + continue; + } + let id = match reference.peel_to_id() { + Ok(id) => id.detach(), + Err(err) => { + tracing::warn!(name = %name, error = %err, %label, "ignoring unresolved tix reference"); + continue; + } + }; + if crate::edit::undo::is_queue_commit(repo, id)? { + tracing::warn!(name = %name, %label, "ignoring tix reference to an undo queue commit"); + continue; + } + match repo.find_header(id) { + Ok(header) if header.kind() == gix::object::Kind::Commit => {} + Ok(_) => { + tracing::warn!(name = %name, %label, "ignoring tix reference that does not resolve to a commit"); + continue; + } + Err(err) => { + tracing::warn!(name = %name, error = %err, %label, "ignoring unreadable tix reference target"); + continue; + } + } + out.push(Pin { name, target, id }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} -#[derive(Debug)] -pub(crate) enum Event { - Decorations(Decorations), - Commits(LoadedCommits), - Complete, - Cancelled, +pub(crate) fn applicable_pins(repo: &gix::Repository) -> Result<Vec<Pin>> { + let head = repo.head().context("could not read HEAD while resolving tix pins")?; + let detached = head.is_detached(); + let Some(head_id) = head.id().map(gix::Id::detach) else { + return Ok(Vec::new()); + }; + drop(head); + let pins = all_pins(repo)?; + if detached { + return Ok(pins); + } + Ok(pins + .into_iter() + .filter(|pin| !pin.is_head() && pin.id != head_id) + .collect()) } -pub(crate) fn load( +pub(crate) fn referenced_refs( repo: &gix::Repository, revisions: &[OsString], - hidden_revisions: &[OsString], - authors: &SharedAuthors, - cancelled: &AtomicBool, - mut emit: impl FnMut(Event) -> bool, -) -> Result<()> { - let Some(tips) = resolve_tips(repo, revisions)? else { - emit(Event::Decorations(decorations(repo)?)); - emit(Event::Complete); - return Ok(()); +) -> Result<HashMap<BString, gix::refs::Target>> { + if revisions.is_empty() && repo.head()?.is_unborn() { + return Ok(HashMap::new()); + } + let implicit_head = OsString::from("HEAD"); + let revisions = if revisions.is_empty() { + std::slice::from_ref(&implicit_head) + } else { + revisions }; - let hidden_tips = resolve_revisions(repo, hidden_revisions, "hidden ")?; - - if !emit(Event::Decorations(decorations(repo)?)) { - return Ok(()); + let mut out = HashMap::new(); + for revision in revisions { + let revision = gix::path::os_str_into_bstr(revision) + .with_context(|| format!("revision {} is not valid UTF-8", revision.to_string_lossy()))?; + let spec = repo + .rev_parse(revision) + .with_context(|| format!("could not parse revision {revision}"))?; + for reference in [spec.first_reference(), spec.second_reference()].into_iter().flatten() { + anyhow::ensure!( + !crate::edit::undo::ref_chain_reaches_queue(repo, reference.name.as_ref())?, + "the undo queue is not a selectable revision" + ); + insert_ref_chain(repo, reference.name.as_bstr(), &mut out)?; + } } - let walk = repo - .rev_walk(tips) - .with_hidden(hidden_tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())) - .all() - .context("could not start revision walk")?; - let mut rows = Vec::with_capacity(COMMIT_BATCH_SIZE); - let mut attributions = Vec::with_capacity(COMMIT_BATCH_SIZE); - for info in walk { - if cancelled.load(Ordering::Relaxed) { - emit(Event::Cancelled); + Ok(out) +} + +fn insert_ref_chain(repo: &gix::Repository, name: &BStr, out: &mut HashMap<BString, gix::refs::Target>) -> Result<()> { + let mut name = name.to_owned(); + loop { + if out.contains_key(&name) { return Ok(()); } - let info = info.context("could not traverse revision history")?; - let metadata = if info.generation.is_some() { - None - } else { - let object = info.object().context("could not read commit")?; - let mut authors = gix::features::threading::lock(authors); - Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) + let reference = match repo.try_find_reference(name.as_bstr()) { + Ok(reference) => reference, + Err(err) if is_missing_ref(&err) => return Ok(()), + Err(err) => return Err(err).with_context(|| format!("could not read reference {name}")), }; - let metadata_loaded = metadata.is_some(); - let Metadata { - committer_time, - author, - attributions: row_attributions, - title, - } = metadata.unwrap_or_else(|| Metadata { - committer_time: Default::default(), - author: &EMPTY_AUTHOR, - attributions: 0..0, - title: BString::default(), - }); - rows.push(Commit { - id: info.id, - parent_ids: info.parent_ids, - committer_time, - author, - attributions: row_attributions, - title, - metadata_loaded, - }); - if rows.len() == COMMIT_BATCH_SIZE - && !emit(Event::Commits(LoadedCommits { - rows: std::mem::replace(&mut rows, Vec::with_capacity(COMMIT_BATCH_SIZE)), - attributions: std::mem::replace(&mut attributions, Vec::with_capacity(COMMIT_BATCH_SIZE)), - })) - { + let Some(reference) = reference else { return Ok(()); - } - } - if !rows.is_empty() && !emit(Event::Commits(LoadedCommits { rows, attributions })) { - return Ok(()); + }; + let target = reference.target().into_owned(); + let next = target.try_name().map(|name| name.as_bstr().to_owned()); + out.insert(name, target); + let Some(next) = next else { return Ok(()) }; + name = next; } - emit(Event::Complete); - Ok(()) } pub(crate) fn load_metadata( @@ -139,18 +1623,56 @@ pub(crate) fn load_metadata( Ok((metadata, attributions)) } +fn decode_commit( + repo: &gix::Repository, + id: ObjectId, + authors: &mut Authors, + attributions: &mut Vec<Attribution>, +) -> Result<Commit<BString>> { + let object = repo.find_commit(id).context("could not read commit")?; + let parent_ids = object.parent_ids().map(gix::Id::detach).collect(); + let Metadata { + committer_time, + author_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + is_review, + signature, + } = decode_metadata(object.iter(), authors, attributions)?; + Ok(Commit { + id, + parent_ids, + committer_time, + author_time, + author, + attributions: row_attributions, + title, + metadata_loaded: true, + has_agent_marker, + is_review, + signature, + }) +} + fn decode_metadata<'a>( tokens: impl Iterator<Item = Result<Token<'a>, gix::objs::decode::Error>>, authors: &mut Authors, attributions: &mut Vec<Attribution>, ) -> Result<Metadata<BString>> { let mut committer_time = None; + let mut author_time = None; let mut author = None; let attribution_start = attributions.len(); let mut title = None; + let mut has_agent_marker = false; + let mut is_review = false; + let mut signature = SignatureState::Unsigned; for token in tokens { match token.context("could not decode commit")? { Token::Author { signature } => { + author_time = Some(signature.time().context("could not decode author time")?); let signature = signature.trim(); author = Some(authors.intern_author(signature.name, signature.email)); } @@ -158,6 +1680,7 @@ fn decode_metadata<'a>( committer_time = Some(signature.time().context("could not decode committer time")?); } Token::Message(message) => { + has_agent_marker = contains_agent_marker(message); let message = gix::objs::commit::MessageRef::from_bytes(message); title = Some(message.summary().into_owned()); if let Some(body) = message.body() { @@ -183,39 +1706,40 @@ fn decode_metadata<'a>( } } } + Token::ExtraHeader((name, _)) if name == "tix-rebase-parent" => { + signature = SignatureState::PendingRebase; + } + Token::ExtraHeader((name, value)) + if name == "tix-rebase" && value.as_ref().starts_with(b"onto refs/worktree/tix/review/") => + { + is_review = true; + } + Token::ExtraHeader((name, value)) if name == "gpgsig" || name == "gpgsig-sha256" => { + if value.is_empty() { + signature = SignatureState::PendingRebase; + } else if signature != SignatureState::PendingRebase { + signature = SignatureState::Unverified; + } + } _ => {} } } Ok(Metadata { committer_time: committer_time.context("commit has no committer time")?, + author_time: author_time.context("commit has no author time")?, author: author.context("commit has no author")?, attributions: attribution_start..attributions.len(), title: title.context("commit has no message")?, + has_agent_marker, + is_review, + signature, }) } -pub(crate) fn count_up_to( - repo: &gix::Repository, - revisions: &[OsString], - hidden_revisions: &[OsString], - limit: usize, -) -> Result<usize> { - let Some(tips) = resolve_tips(repo, revisions)? else { - return Ok(0); - }; - let hidden_tips = resolve_revisions(repo, hidden_revisions, "hidden ")?; - let walk = repo - .rev_walk(tips) - .with_hidden(hidden_tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())) - .all() - .context("could not start revision walk")?; - let mut count = 0; - for info in walk.take(limit) { - info.context("could not traverse revision history")?; - count += 1; - } - Ok(count) +pub(crate) fn contains_agent_marker(message: &[u8]) -> bool { + [b"--- agent".as_slice(), b"<!-- agent -->".as_slice()] + .iter() + .any(|marker| message.windows(marker.len()).any(|window| window == *marker)) } fn resolve_tips(repo: &gix::Repository, revisions: &[OsString]) -> Result<Option<Vec<ObjectId>>> { @@ -230,6 +1754,73 @@ fn resolve_tips(repo: &gix::Repository, revisions: &[OsString]) -> Result<Option } } +#[expect( + clippy::type_complexity, + reason = "both successful revisions and warning text are returned" +)] +pub(crate) fn available_hidden_revisions( + repo: &gix::Repository, + revisions: &[OsString], + auto_hide: bool, +) -> Result<(Vec<OsString>, Vec<(OsString, String)>)> { + let mut revisions = revisions.to_vec(); + if auto_hide { + revisions.extend(auto_hidden_revisions(repo)?); + } + let mut available = Vec::new(); + let mut unavailable = Vec::new(); + let mut first_error = None; + for revision in &revisions { + match resolve_revisions(repo, std::slice::from_ref(revision), "hidden ") { + Ok(_) => available.push(revision.clone()), + Err(err) => { + unavailable.push((revision.clone(), format!("{err:#}"))); + first_error.get_or_insert(err); + } + } + } + if available.is_empty() + && let Some(err) = first_error + { + return Err(err); + } + Ok((available, unavailable)) +} + +fn auto_hidden_revisions(repo: &gix::Repository) -> Result<Vec<OsString>> { + let mut branches = BTreeSet::new(); + for remote_name in repo.remote_names() { + let mut head = BString::from("refs/remotes/"); + head.extend_from_slice(&remote_name); + head.extend_from_slice(b"/HEAD"); + let Ok(Some(reference)) = repo.try_find_reference(head.as_bstr()) else { + continue; + }; + let target = reference.target(); + let Some(tracking) = target.try_name() else { continue }; + let Ok(Some((upstream, remote))) = repo.upstream_branch_and_remote_for_tracking_branch(tracking) else { + continue; + }; + if remote.name().map(gix::remote::Name::as_bstr) != Some(remote_name.as_bstr()) { + continue; + } + let Ok(Some(mut local)) = repo.try_find_reference(upstream.as_ref()) else { + continue; + }; + let Ok(id) = local.peel_to_id() else { continue }; + if repo + .find_header(id) + .is_ok_and(|header| header.kind() == gix::object::Kind::Commit) + { + branches.insert(upstream); + } + } + Ok(branches + .into_iter() + .map(|name| gix::path::from_bstr(name.as_bstr()).into_owned().into_os_string()) + .collect()) +} + fn attribution_kind(trailer: &gix::objs::commit::message::body::TrailerRef<'_>) -> Option<AttributionKind> { if trailer.is_co_authored_by() { Some(AttributionKind::CoAuthor) @@ -254,17 +1845,40 @@ fn resolve_revisions(repo: &gix::Repository, revisions: &[OsString], kind: &str) .map(|revision| { let revision = gix::path::os_str_into_bstr(revision) .with_context(|| format!("{kind}revision {} is not valid UTF-8", revision.to_string_lossy()))?; - repo.rev_parse_single(revision) - .with_context(|| format!("could not resolve {kind}revision {revision}"))? - .object() - .with_context(|| format!("could not read {kind}revision"))? - .peel_to_kind(gix::object::Kind::Commit) - .with_context(|| format!("{kind}revision does not resolve to a commit")) - .map(|object| object.id) + resolve_revision(repo, revision) + .with_context(|| format!("could not resolve {kind}revision {revision}")) + .map(|(id, _reference)| id) }) .collect() } +pub(crate) fn resolve_revision( + repo: &gix::Repository, + revision: &BStr, +) -> Result<(ObjectId, Option<gix::refs::FullName>)> { + let spec = repo.rev_parse(revision)?; + let first_reference = spec.first_reference().map(|reference| reference.name.clone()); + for reference in [spec.first_reference(), spec.second_reference()].into_iter().flatten() { + anyhow::ensure!( + !crate::edit::undo::ref_chain_reaches_queue(repo, reference.name.as_ref())?, + "the undo queue is not a selectable revision" + ); + } + let id = spec + .single() + .context("revision does not name a single object")? + .object() + .context("could not read revision")? + .peel_to_kind(gix::object::Kind::Commit) + .context("revision does not resolve to a commit")? + .id; + anyhow::ensure!( + !crate::edit::undo::is_queue_commit(repo, id)?, + "the undo queue is not a selectable revision" + ); + Ok((id, first_reference)) +} + impl Authors { fn intern_author(&mut self, name: &[u8], email: &[u8]) -> &'static Author { let name = self.intern_string(name); @@ -287,16 +1901,83 @@ impl Authors { } } -fn decorations(repo: &gix::Repository) -> Result<Decorations> { +pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin], worktrees: &[WorktreeCheckout]) -> Result<Decorations> { + decorations_excluding(repo, pins, worktrees, &HashSet::new()) +} + +pub(crate) fn decorations_excluding( + repo: &gix::Repository, + pins: &[Pin], + worktrees: &[WorktreeCheckout], + excluded: &HashSet<BString>, +) -> Result<Decorations> { let mut out = Decorations::new(); + let head_pin_branch = pins + .iter() + .find(|pin| pin.is_head()) + .and_then(|pin| pin.target.try_name()) + .map(ToOwned::to_owned); + let pins: HashSet<_> = pins.iter().map(|pin| pin.name.as_bstr()).collect(); + let review_count = all_reviews(repo)?.len(); for reference in repo .references() .context("could not open references")? .all() .context("could not iterate references")? { - let mut reference = reference.map_err(|err| anyhow::anyhow!("could not read reference: {err}"))?; - let mut kind = decoration_kind(reference.name().as_bstr()); + let mut reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read reference: {err}")), + }; + let full_name = reference.name().to_owned(); + if excluded.contains(full_name.as_bstr()) || crate::edit::undo::is_queue_ref(full_name.as_bstr()) { + continue; + } + if full_name.as_bstr() == HEAD_PIN_NAME { + continue; + } + if full_name.as_bstr().starts_with(REVIEW_STASH_PREFIX) { + let leaf: Result<Option<ObjectId>> = (|| { + let stash = repo.find_commit(reference.peel_to_id()?)?; + Ok(stash.parent_ids().next().map(gix::Id::detach)) + })(); + let leaf = match leaf { + Ok(leaf) => leaf, + Err(err) => { + tracing::warn!(name = %full_name, error = %err, "ignored malformed review stash reference"); + None + } + }; + if let Some(id) = leaf { + out.entry(id).or_default().push(Decoration { + name: "stash".into(), + kind: DecorationKind::Stash, + }); + } + continue; + } + if full_name.as_bstr().starts_with(STASH_PREFIX) { + let id = match crate::edit::stash::associated_commit(full_name.as_bstr()) { + Ok(Some(id)) => id, + Ok(None) => unreachable!("the stash prefix was checked"), + Err(err) => { + tracing::warn!(name = %full_name, error = %err, "ignored malformed tix stash reference"); + continue; + } + }; + out.entry(id).or_default().push(Decoration { + name: "stash".into(), + kind: DecorationKind::Stash, + }); + continue; + } + let pin_suffix = full_name.as_bstr().strip_prefix(PIN_PREFIX).map(BString::from); + let review_suffix = review_number(full_name.as_bstr()).map(BString::from); + if pin_suffix.is_some() && !pins.contains(full_name.as_bstr()) { + continue; + } + let mut kind = decoration_kind(full_name.as_bstr()); if kind == DecorationKind::Tag { let annotated = match reference.try_id() { Some(id) => id.header().context("could not inspect tag")?.kind() == gix::objs::Kind::Tag, @@ -310,17 +1991,88 @@ fn decorations(repo: &gix::Repository) -> Result<Decorations> { continue; }; let id = id.detach(); - let mut name = reference.name().shorten().to_owned(); + if worktrees.iter().any(|worktree| { + worktree.is_detached && worktree.label_id == id && worktree.reference.as_ref() == Some(&full_name) + }) { + kind = DecorationKind::HeadPinBranch; + } else if worktrees.iter().any(|worktree| { + worktree.is_current && worktree.label_id == id && worktree.reference.as_ref() == Some(&full_name) + }) { + kind = DecorationKind::CurrentWorktreeBranch; + } else if worktrees.iter().any(|worktree| { + !worktree.is_current && worktree.label_id == id && worktree.reference.as_ref() == Some(&full_name) + }) { + kind = DecorationKind::WorktreeBranch; + } + if head_pin_branch.as_ref() == Some(&full_name) { + kind = DecorationKind::HeadPinBranch; + } + let mut name = pin_suffix.map_or_else( + || { + review_suffix.as_ref().map_or_else( + || full_name.shorten().to_owned(), + |suffix| { + if review_count == 1 { + "review".into() + } else { + format!("review:{}", suffix.to_str_lossy()).into() + } + }, + ) + }, + |suffix| format!("pin:{}", suffix.to_str_lossy()).into(), + ); if matches!(kind, DecorationKind::Tag | DecorationKind::AnnotatedTag) { name.insert_str(0, "tag: "); } out.entry(id).or_default().push(Decoration { name, kind }); } - if let Some(id) = repo - .head() - .context("could not read HEAD")? - .try_peel_to_id() - .context("could not peel HEAD")? + for worktree in worktrees + .iter() + .filter(|worktree| !worktree.is_current && !worktree.is_detached) + { + let Some(reference) = &worktree.reference else { continue }; + if excluded.contains(reference.as_bstr()) { + continue; + } + let name = reference.shorten().to_owned(); + let decorations = out.entry(worktree.id).or_default(); + if !decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == name) + { + decorations.push(Decoration { + name, + kind: DecorationKind::WorktreeBranch, + }); + } + } + for worktree in worktrees + .iter() + .filter(|worktree| worktree.reference.is_none() || worktree.is_detached && !worktree.is_current) + { + let kind = if worktree.is_current { + DecorationKind::CurrentWorktreeDetached + } else { + DecorationKind::WorktreeDetached + }; + let decorations = out.entry(worktree.id).or_default(); + if !decorations + .iter() + .any(|decoration| decoration.kind == kind && decoration.name == worktree.checkout_name) + { + decorations.push(Decoration { + name: worktree.checkout_name.clone(), + kind, + }); + } + } + if !excluded.contains(b"HEAD".as_bstr()) + && let Some(id) = repo + .head() + .context("could not read HEAD")? + .try_peel_to_id() + .context("could not peel HEAD")? { out.entry(id.detach()).or_default().push(Decoration { name: "HEAD".into(), @@ -330,8 +2082,27 @@ fn decorations(repo: &gix::Repository) -> Result<Decorations> { Ok(out) } -fn decoration_kind(name: &[u8]) -> DecorationKind { - if name.starts_with(b"refs/heads/") { +pub(crate) fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { + loop { + if err + .downcast_ref::<std::io::Error>() + .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + return true; + } + let Some(source) = err.source() else { return false }; + err = source; + } +} + +pub(crate) fn decoration_kind(name: &[u8]) -> DecorationKind { + if name.starts_with(PIN_PREFIX) { + DecorationKind::Pin + } else if name.starts_with(STASH_PREFIX) { + DecorationKind::Stash + } else if name.starts_with(REVIEW_PREFIX) { + DecorationKind::Review + } else if name.starts_with(b"refs/heads/") { DecorationKind::Local } else if name.starts_with(b"refs/tags/") { DecorationKind::Tag @@ -353,15 +2124,40 @@ mod tests { gix_testtools::scripted_fixture_read_only_needs_archive("history.sh") } + fn id(n: u8) -> ObjectId { + let mut bytes = [0; 20]; + bytes[19] = n; + ObjectId::Sha1(bytes) + } + + fn insert_commit(graph: &mut HistoryGraph, n: u8, parents: &[u8], generation: u32) { + let index = graph.intern(id(n)).expect("the small test graph fits in u32"); + let parents: Vec<_> = parents + .iter() + .map(|parent| graph.intern(id(*parent)).expect("the small test graph fits in u32")) + .collect(); + let start = graph.parents.len() as u32; + graph.parents.extend(parents); + let end = graph.parents.len() as u32; + graph.commits[index.as_usize()] = GraphCommit { + id: id(n), + parents: start..end, + commit_time: generation.into(), + generation, + state: NODE_LOADED, + }; + } + fn loaded(path: &std::path::Path, revisions: &[&str], hidden_revisions: &[&str]) -> Result<Vec<Event>> { let mut events = Vec::new(); let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let repo = gix::open(path)?; + let repo = crate::test_repository::open(path)?; load( &repo, &revisions.iter().map(OsString::from).collect::<Vec<_>>(), &hidden_revisions.iter().map(OsString::from).collect::<Vec<_>>(), + false, &authors, &AtomicBool::new(false), |event| { @@ -372,6 +2168,151 @@ mod tests { Ok(events) } + #[test] + fn only_missing_ref_reads_are_ignored() { + let ref_error = |kind| gix::refs::file::iter::loose_then_packed::Error::ReadFileContents { + source: std::io::Error::from(kind), + path: "refs/heads/racing".into(), + }; + assert!( + is_missing_ref(&ref_error(std::io::ErrorKind::NotFound)), + "a ref removed after iteration began is transient" + ); + assert!( + !is_missing_ref(&ref_error(std::io::ErrorKind::PermissionDenied)), + "unrelated ref read errors remain actionable" + ); + } + + #[test] + fn paints_criss_cross_relations_from_cached_parents() { + let mut graph = HistoryGraph::default(); + for (n, parents, generation) in [ + (1, vec![], 1), + (2, vec![1], 2), + (3, vec![1], 2), + (4, vec![2, 3], 3), + (5, vec![3, 2], 3), + (6, vec![4], 4), + (7, vec![5], 4), + ] { + insert_commit(&mut graph, n, &parents, generation); + } + + assert_eq!( + graph.paint(id(6), &[id(7)]), + Some((2, 2)), + "both merge tips stop at the shared criss-cross ancestry" + ); + } + + #[test] + fn marks_visible_views_behind_hidden_branches_at_their_base() { + let mut graph = HistoryGraph::default(); + for (n, parents, generation) in [ + (1, vec![], 1), + (2, vec![1], 2), + (3, vec![1], 2), + (4, vec![3], 3), + (5, vec![], 1), + ] { + insert_commit(&mut graph, n, &parents, generation); + } + + assert_eq!( + graph.hidden_branch_updates(&[id(2), id(5)], [id(4)]), + HashMap::from([(id(1), (2, id(4)))]), + "only the fork point in the hidden branch's past reports its missing commits" + ); + + insert_commit(&mut graph, 7, &[1], 2); + insert_commit(&mut graph, 6, &[7], 3); + assert_eq!( + graph.hidden_branch_updates(&[id(2), id(5)], [id(4), id(6)]), + HashMap::from([(id(1), (2, id(6)))]), + "equal distances choose a deterministic hidden tip" + ); + } + + #[test] + fn ignores_missing_hidden_revisions_only_if_another_one_resolves() -> gix_testtools::Result { + let fixture = fixture()?; + let repo = crate::test_repository::open(&fixture)?; + let revisions = ["unknown".into(), "main".into()]; + + let (available, unavailable) = available_hidden_revisions(&repo, &revisions, false)?; + assert_eq!(available, [OsString::from("main")]); + assert_eq!(unavailable.len(), 1); + assert!( + available_hidden_revisions(&repo, &[OsString::from("unknown")], false).is_err(), + "all missing hidden revisions retain the previous fatal behavior" + ); + Ok(()) + } + + #[test] + fn remote_heads_infer_existing_local_hidden_branches() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + let git = |args: &[&str]| -> gix_testtools::Result { + let output = Command::new("git").current_dir(path).args(args).output()?; + assert!( + output.status.success(), + "git {args:?} prepares remote HEADs: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }; + git(&["branch", "trunk", "main"])?; + for (remote, fetch, tracking) in [ + ( + "origin", + "+refs/heads/*:refs/remotes/origin/*", + "refs/remotes/origin/main", + ), + ( + "backup", + "+refs/heads/*:refs/remotes/backup/*", + "refs/remotes/backup/main", + ), + ( + "team", + "+refs/heads/trunk:refs/remotes/team/default", + "refs/remotes/team/default", + ), + ( + "stale", + "+refs/heads/missing:refs/remotes/stale/default", + "refs/remotes/stale/default", + ), + ] { + git(&["config", &format!("remote.{remote}.url"), "https://example.com/repo"])?; + git(&["config", &format!("remote.{remote}.fetch"), fetch])?; + git(&["update-ref", tracking, "main"])?; + git(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD"), tracking])?; + } + git(&["config", "remote.direct.url", "https://example.com/repo"])?; + git(&["config", "remote.direct.fetch", "+refs/heads/*:refs/remotes/direct/*"])?; + git(&["update-ref", "refs/remotes/direct/HEAD", "main"])?; + + let repo = crate::test_repository::open(path)?; + assert_eq!( + auto_hidden_revisions(&repo)?, + [OsString::from("refs/heads/main"), OsString::from("refs/heads/trunk")], + "remote defaults are reverse-mapped, deduplicated, and limited to existing local branches" + ); + assert_eq!( + available_hidden_revisions(&repo, &[OsString::from("topic")], true)?.0, + [ + OsString::from("topic"), + OsString::from("refs/heads/main"), + OsString::from("refs/heads/trunk") + ], + "explicit and inferred hidden revisions are combined" + ); + Ok(()) + } + #[test] fn walks_the_same_reachable_set_as_git_for_multiple_tips() -> gix_testtools::Result { let fixture = fixture()?; @@ -394,7 +2335,7 @@ mod tests { ); let expected = String::from_utf8(output.stdout)?.lines().map(str::to_owned).collect(); assert_eq!(actual, expected, "all commits reachable from either tip are shown once"); - assert!(matches!(events.last(), Some(Event::Complete)), "the walk completes"); + assert!(matches!(events.last(), Some(Event::Complete(_))), "the walk completes"); let (topic, attributions) = events .iter() .filter_map(|event| match event { @@ -411,32 +2352,311 @@ mod tests { topic.author.name, "Codex", "history loading retains the raw name despite the configured mailmap" ); - assert_eq!(topic.author.email, "Codex@OpenAI.com", "the author email is retained"); + assert_eq!(topic.author.email, "Codex@OpenAI.com", "the author email is retained"); + assert!( + topic.author.is_bot(), + "well-known bot email addresses identify bot authors" + ); + assert!(topic.has_agent_marker, "history loading recognizes the agent marker"); + assert_eq!( + attributions[topic.attributions.clone()] + .iter() + .map(|attribution| { (attribution.kind, attribution.author.name, attribution.is_agent(),) }) + .collect::<Vec<_>>(), + [ + (AttributionKind::CoAuthor, b"Human Coauthor".as_bstr(), false), + (AttributionKind::CoAuthor, b"Claude".as_bstr(), true), + (AttributionKind::Assisted, b"Opus 4.7".as_bstr(), true), + (AttributionKind::Reviewed, b"Reviewer".as_bstr(), false), + (AttributionKind::Acked, b"Acknowledger".as_bstr(), false), + (AttributionKind::Tested, b"Tester".as_bstr(), false), + (AttributionKind::SignedOff, b"Signer".as_bstr(), false), + ], + "known attribution trailers retain their order and malformed identities are omitted" + ); + assert_eq!( + topic.committer_time.format_or_unix(gix::date::time::format::SHORT), + "2000-01-04", + "the committer date is retained" + ); + Ok(()) + } + + #[test] + fn recognizes_supported_agent_markers() { + assert!(contains_agent_marker(b"subject\n\n--- agent\n")); + assert!(contains_agent_marker(b"subject\n\n<!-- agent -->\n")); + assert!(!contains_agent_marker(b"subject\n\nagent")); + } + + #[test] + fn snapshots_references_and_symbolic_targets_from_revisions() -> gix_testtools::Result { + let fixture = fixture()?; + let repo = crate::test_repository::open(fixture)?; + let implicit = snapshot(&repo, &[], &[], false)?; + assert!( + implicit.view.contains_key(b"HEAD".as_bstr()), + "an implicit revision watches HEAD" + ); + assert!( + implicit.view.contains_key(b"refs/heads/main".as_bstr()), + "the symbolic target of HEAD is watched as well" + ); + + let explicit = snapshot(&repo, &[OsString::from("main")], &[OsString::from("topic")], false)?; + assert!(explicit.view.contains_key(b"refs/heads/main".as_bstr())); + assert!(explicit.hidden.contains_key(b"refs/heads/topic".as_bstr())); + Ok(()) + } + + #[test] + fn undo_queue_revisions_are_private_but_retained_commits_remain_selectable() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let retained = repo.head_id()?.detach(); + let retained_ref: gix::refs::FullName = "refs/heads/undo-retained".try_into()?; + repo.reference( + retained_ref.clone(), + retained, + gix::refs::transaction::PreviousValue::MustNotExist, + "prepare retained undo commit", + )?; + let entry = crate::edit::undo::record( + &repo, + "retain commit", + &[crate::edit::undo::RefChange { + name: retained_ref, + before: crate::edit::undo::State::Missing, + after: crate::edit::undo::State::Object(retained), + }], + )? + .expect("the reference change creates an undo entry"); + let sentinel = repo + .find_commit(entry)? + .parent_ids() + .next() + .context("the undo entry has its queue predecessor")? + .detach(); + + for revision in [ + crate::edit::undo::TIP_REF.to_owned(), + crate::edit::undo::CURSOR_REF.to_owned(), + entry.to_string(), + sentinel.to_string(), + ] { + let revision = BString::from(revision); + assert!( + resolve_revision(&repo, revision.as_bstr()).is_err(), + "queue revision {revision} is not selectable" + ); + assert!( + snapshot(&repo, &[revision.to_os_str()?.to_owned()], &[], false).is_err(), + "queue revision {revision} cannot enter a history view" + ); + } + + let retained_revision = BString::from(retained.to_string()); + assert_eq!( + resolve_revision(&repo, retained_revision.as_bstr())?.0, + retained, + "a commit retained as a non-first queue parent stays selectable" + ); + assert_eq!( + snapshot(&repo, &[retained_revision.to_os_str()?.to_owned()], &[], false)?.view_tips, + [retained], + "retention does not make an ordinary commit private" + ); + Ok(()) + } + + #[test] + fn discovers_worktree_decorations_and_optionally_adds_their_tips() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + for args in [ + ["worktree", "add", "-q", "topic-wt", "topic"].as_slice(), + ["worktree", "add", "-q", "--detach", "detached-wt", "main~2"].as_slice(), + ["worktree", "add", "-q", "--detach", "broken-wt", "main~2"].as_slice(), + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git creates the worktree fixture"); + } + let remembered_branch = Command::new("git") + .current_dir(fixture.path()) + .args(["branch", "remembered", "main~1"]) + .status()?; + assert!(remembered_branch.success(), "git creates the remembered branch"); + let remembered_worktree = Command::new("git") + .current_dir(fixture.path()) + .args(["worktree", "add", "-q", "remembered-wt", "remembered"]) + .status()?; + assert!( + remembered_worktree.success(), + "git checks out the branch remembered by another worktree" + ); + let remembered_pin = Command::new("git") + .current_dir(fixture.path().join("detached-wt")) + .args(["symbolic-ref", "refs/worktree/tix/pins/HEAD", "refs/heads/remembered"]) + .status()?; + assert!(remembered_pin.success(), "the foreign worktree remembers its branch"); + std::fs::remove_dir_all(fixture.path().join("detached-wt"))?; + std::fs::write( + fixture.path().join(".git/worktrees/broken-wt/HEAD"), + "not a ref or object id\n", + )?; + + let repo = crate::test_repository::open(fixture.path())?; + let main = repo.rev_parse_single("main")?.detach(); + let topic = repo.rev_parse_single("topic")?.detach(); + let root = repo.rev_parse_single("main~2")?.detach(); + let remembered = repo.rev_parse_single("remembered")?.detach(); + let worktrees = worktree_checkouts(&repo); + assert!(worktrees.iter().any(|worktree| { + worktree.id == main + && worktree.label_id == main + && worktree.is_current + && !worktree.is_detached + && worktree + .reference + .as_ref() + .is_some_and(|name| name == "refs/heads/main") + })); + assert!(worktrees.iter().any(|worktree| { + worktree.id == topic + && worktree.label_id == topic + && worktree.checkout_name == "topic-wt" + && !worktree.is_current + && !worktree.is_detached + && worktree + .reference + .as_ref() + .is_some_and(|name| name == "refs/heads/topic") + })); + assert!(worktrees.iter().any(|worktree| { + worktree.id == root + && worktree.label_id == remembered + && worktree.checkout_name == "detached-wt" + && worktree + .reference + .as_ref() + .is_some_and(|name| name == "refs/heads/remembered") + && !worktree.is_current + && worktree.is_detached + })); + assert_eq!(worktrees.len(), 4, "the malformed worktree is ignored"); + + let main_repo_decorations = decorations(&repo, &[], &worktrees)?; + let main_decorations = main_repo_decorations.get(&main).expect("main is decorated"); + assert!(main_decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::CurrentWorktreeBranch && decoration.name == "main" + })); + assert!( + !main_decorations + .iter() + .any(|decoration| { decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "main" }) + ); + assert!(main_repo_decorations.get(&topic).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "topic") + })); + assert!(main_repo_decorations.get(&root).is_some_and(|decorations| { + decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::WorktreeDetached && decoration.name == "detached-wt" + }) + })); assert!( - topic.author.is_bot(), - "well-known bot email addresses identify bot authors" + main_repo_decorations.get(&remembered).is_some_and(|decorations| { + decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::HeadPinBranch && decoration.name == "remembered" + }) && decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "remembered" + }) + }), + "a remembered branch can simultaneously identify an attached foreign worktree" ); - assert_eq!( - attributions[topic.attributions.clone()] + + let explicit = [OsString::from("main")]; + let without = snapshot(&repo, &explicit, &[], false)?; + assert_eq!(without.view_tips, [main], "explicit revisions are unchanged by default"); + assert!(!without.view.contains_key(b"refs/heads/remembered".as_bstr())); + let with = snapshot(&repo, &explicit, &[], true)?; + assert!(with.view_tips.contains(&main)); + assert!(with.view_tips.contains(&topic)); + assert!(with.view_tips.contains(&root)); + assert!(with.view_tips.contains(&remembered)); + assert!(with.view.contains_key(b"refs/heads/remembered".as_bstr())); + + let linked_path = fixture.path().join("topic-wt"); + let linked_repo = crate::test_repository::open(&linked_path)?; + let linked_worktrees = worktree_checkouts(&linked_repo); + assert!( + linked_worktrees .iter() - .map(|attribution| { (attribution.kind, attribution.author.name, attribution.is_agent(),) }) - .collect::<Vec<_>>(), - [ - (AttributionKind::CoAuthor, b"Human Coauthor".as_bstr(), false), - (AttributionKind::CoAuthor, b"Claude".as_bstr(), true), - (AttributionKind::Assisted, b"Opus 4.7".as_bstr(), true), - (AttributionKind::Reviewed, b"Reviewer".as_bstr(), false), - (AttributionKind::Acked, b"Acknowledger".as_bstr(), false), - (AttributionKind::Tested, b"Tester".as_bstr(), false), - (AttributionKind::SignedOff, b"Signer".as_bstr(), false), - ], - "known attribution trailers retain their order and malformed identities are omitted" + .any(|worktree| worktree.id == topic && worktree.is_current) ); - assert_eq!( - topic.committer_time.format_or_unix(gix::date::time::format::SHORT), - "2000-01-04", - "the committer date is retained" + let linked_decorations = decorations(&linked_repo, &[], &linked_worktrees)?; + assert!(linked_decorations.get(&topic).is_some_and(|decorations| { + decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::CurrentWorktreeBranch && decoration.name == "topic" + }) && !decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "topic") + })); + assert!(linked_decorations.get(&main).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "main") + })); + + let status = Command::new("git") + .current_dir(&linked_path) + .args(["checkout", "-q", "--detach", "main~1"]) + .status()?; + assert!(status.success(), "git detaches the current linked worktree"); + let detached_repo = crate::test_repository::open(&linked_path)?; + let detached_worktrees = worktree_checkouts(&detached_repo); + let current = detached_worktrees + .iter() + .find(|worktree| worktree.is_current) + .expect("the current detached worktree is discovered"); + assert!(current.reference.is_none()); + assert!(current.is_detached); + assert_eq!(current.label_id, current.id); + let detached_decorations = decorations(&detached_repo, &[], &detached_worktrees)?; + assert!(detached_decorations.get(¤t.id).is_some_and(|decorations| { + decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::CurrentWorktreeDetached && decoration.name == current.checkout_name + }) + })); + + let symbolic = Command::new("git") + .current_dir(&linked_path) + .args(["symbolic-ref", "refs/worktree/tix/pins/HEAD", "refs/heads/topic"]) + .status()?; + assert!(symbolic.success(), "git remembers the detached worktree's branch"); + let remembered_repo = crate::test_repository::open(&linked_path)?; + let remembered_worktrees = worktree_checkouts(&remembered_repo); + let current = remembered_worktrees + .iter() + .find(|worktree| worktree.is_current) + .expect("the remembered detached worktree is discovered"); + assert_ne!(current.id, topic, "the worktree remains detached away from its branch"); + assert_eq!(current.label_id, topic, "the label follows the remembered branch tip"); + assert_eq!(current.checkout_name, "topic-wt"); + assert!(current.is_detached); + assert!( + current + .reference + .as_ref() + .is_some_and(|name| name == "refs/heads/topic") ); + let remembered_pins = applicable_pins(&remembered_repo)?; + let remembered_decorations = decorations(&remembered_repo, &remembered_pins, &remembered_worktrees)?; + assert!(remembered_decorations.get(&topic).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::HeadPinBranch && decoration.name == "topic") + })); Ok(()) } @@ -459,8 +2679,8 @@ mod tests { let commit = Command::new("git") .current_dir(fixture_path) .env("GIT_AUTHOR_DATE", "2000-01-05T00:00:00 +0000") - .env("GIT_COMMITTER_DATE", "2000-01-05T00:00:00 +0000") - .args(["commit", "-q", "-m", "new"]) + .env("GIT_COMMITTER_DATE", "2000-01-06T00:00:00 +0000") + .args(["-c", "commit.gpgSign=false", "commit", "-q", "-m", "new"]) .status()?; assert!(commit.success(), "a commit newer than the graph is created"); @@ -476,12 +2696,22 @@ mod tests { let newest = rows.first().expect("the new commit is walked first"); assert!(newest.metadata_loaded, "ODB commits are decoded during the walk"); assert_eq!(newest.title, "new"); + assert_eq!( + newest.author_time.format_or_unix(gix::date::time::format::SHORT), + "2000-01-05", + "author dates are retained independently" + ); + assert_eq!( + newest.committer_time.format_or_unix(gix::date::time::format::SHORT), + "2000-01-06", + "committer dates remain available" + ); let deferred = rows .iter() .find(|row| !row.metadata_loaded) .expect("older graph commits defer metadata"); - let repo = gix::open(fixture_path)?; + let repo = crate::test_repository::open(fixture_path)?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); let (metadata, _) = load_metadata(&repo, deferred.id, &authors)?; @@ -492,6 +2722,412 @@ mod tests { Ok(()) } + #[test] + fn unborn_views_show_only_the_hidden_tips() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let main = repo.rev_parse_single("main")?.detach(); + let topic = repo.rev_parse_single("topic")?.detach(); + drop(repo); + assert!( + Command::new("git") + .current_dir(fixture.path()) + .args(["symbolic-ref", "HEAD", "refs/heads/unborn"]) + .status()? + .success(), + "the fixture enters an unborn branch" + ); + + for (hidden, expected) in [ + (&["main"][..], HashSet::from([main])), + (&["main", "topic"][..], HashSet::from([main, topic])), + ] { + let events = loaded(fixture.path(), &[], hidden)?; + let visible: Vec<_> = events + .iter() + .filter_map(|event| match event { + Event::Commits(commits) => Some(commits.rows.iter().map(|row| row.id)), + _ => None, + }) + .flatten() + .collect(); + let boundaries: HashSet<_> = events + .iter() + .filter_map(|event| match event { + Event::HiddenCommits(commits) => Some(commits.rows.iter().map(|row| row.id)), + _ => None, + }) + .flatten() + .collect(); + let graph = events + .iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("the hidden-only history completes"); + + assert!(visible.is_empty(), "hidden ancestry is not exposed as visible history"); + assert_eq!(boundaries, expected, "each hidden tip is emitted as a boundary"); + assert_eq!( + graph.stored_commit_ids().collect::<HashSet<_>>(), + expected, + "only hidden tips are retained as rows" + ); + } + Ok(()) + } + + #[test] + fn hidden_only_refresh_stops_after_the_new_tip() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + assert!( + Command::new("git") + .current_dir(path) + .args(["symbolic-ref", "HEAD", "refs/heads/unborn"]) + .status()? + .success() + ); + let mut graph = loaded(path, &[], &["main"])? + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("the hidden-only history completes"); + + for args in [ + &["symbolic-ref", "HEAD", "refs/heads/main"][..], + &[ + "-c", + "commit.gpgSign=false", + "commit", + "--allow-empty", + "-q", + "-m", + "new", + ][..], + &["symbolic-ref", "HEAD", "refs/heads/unborn"][..], + ] { + assert!(Command::new("git").current_dir(path).args(args).status()?.success()); + } + let repo = crate::test_repository::open(path)?; + let new_tip = repo.rev_parse_single("main")?.detach(); + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let refresh = graph.refresh(&repo, &[], &["main".into()], false, &HashSet::new(), &authors)?; + + assert_eq!( + refresh.commits.rows.iter().map(|row| row.id).collect::<Vec<_>>(), + [new_tip], + "refresh emits the advanced hidden tip without walking its ancestry" + ); + Ok(()) + } + + #[test] + fn refresh_stops_at_the_persistent_graph() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let events = loaded(fixture.path(), &["main"], &[])?; + let mut graph = events + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("history loading returns the persistent graph"); + + std::fs::write(fixture.path().join("new"), "new\n")?; + for args in [ + &["add", "new"][..], + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "new"], + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git prepares one new commit"); + } + let repo = crate::test_repository::open(fixture.path())?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let first = graph.refresh(&repo, &["main".into()], &[], false, &HashSet::new(), &authors)?; + assert_eq!(first.commits.rows.len(), 1, "only the new descendant is loaded"); + let second = graph.refresh(&repo, &["main".into()], &[], false, &HashSet::new(), &authors)?; + assert!( + second.commits.rows.is_empty(), + "an unchanged tip stops immediately at complete cached ancestry" + ); + Ok(()) + } + + #[test] + fn refresh_excludes_replaced_commits_from_descendant_queries() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let events = loaded(fixture.path(), &["main"], &[])?; + let mut graph = events + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("history loading returns the persistent graph"); + let repo = crate::test_repository::open(fixture.path())?; + let old_tip = repo.rev_parse_single("main")?.detach(); + let parent = repo + .find_commit(old_tip)? + .parent_ids() + .next() + .expect("the main tip has a parent") + .detach(); + drop(repo); + + let amend = Command::new("git") + .current_dir(fixture.path()) + .args([ + "-c", + "commit.gpgSign=false", + "commit", + "--amend", + "-q", + "-m", + "replacement", + ]) + .status()?; + assert!(amend.success(), "git replaces the visible tip"); + + let repo = crate::test_repository::open(fixture.path())?; + let replacement = repo.rev_parse_single("main")?.detach(); + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + graph.refresh(&repo, &["main".into()], &[], false, &HashSet::new(), &authors)?; + let descendants = graph + .descendants_in_parent_order(parent) + .expect("the shared parent remains in the current view"); + assert!(descendants.contains(&replacement), "the replacement tip is editable"); + assert!( + !descendants.contains(&old_tip), + "the obsolete tip is excluded from edits" + ); + Ok(()) + } + + #[test] + fn refresh_stops_at_cached_tracking_ancestry() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let main = crate::test_repository::open(fixture.path())? + .rev_parse_single("main")? + .detach(); + for args in [ + &["config", "remote.origin.url", "https://example.com/repo"][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + &["config", "branch.topic.remote", "origin"][..], + &["config", "branch.topic.merge", "refs/heads/main"][..], + &["update-ref", "refs/remotes/origin/main", &main.to_hex().to_string()][..], + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git configures a tracking branch"); + } + let events = loaded(fixture.path(), &["topic"], &[])?; + let mut graph = events + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("history loading returns the persistent graph"); + let repo = crate::test_repository::open(fixture.path())?; + let index = graph.index(main).expect("the tracking tip was scheduled"); + let fake_parent = graph.intern(id(255)).expect("the small test graph fits in u32"); + let mut parents = graph.parents(index).to_vec(); + parents.push(fake_parent); + let start = graph.parents.len() as u32; + graph.parents.extend(parents); + let end = graph.parents.len() as u32; + let cached = &mut graph.commits[index.as_usize()]; + assert!(cached.state & NODE_COMPLETE != 0 && cached.state & NODE_STORED == 0); + cached.parents = start..end; + + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let refresh = graph.refresh(&repo, &["topic".into()], &[], false, &HashSet::new(), &authors)?; + assert!( + refresh.commits.rows.is_empty(), + "an unchanged tracking tip stops before revisiting its cached parents" + ); + Ok(()) + } + + #[test] + fn refresh_walks_cached_tracking_ancestry_when_a_symbolic_pin_makes_it_visible() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let main = crate::test_repository::open(fixture.path())? + .rev_parse_single("main")? + .detach(); + let merged = crate::test_repository::open(fixture.path())? + .rev_parse_single("merged")? + .detach(); + for args in [ + &["config", "remote.origin.url", "https://example.com/repo"][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + &["config", "branch.topic.remote", "origin"][..], + &["config", "branch.topic.merge", "refs/heads/main"][..], + &["update-ref", "refs/remotes/origin/main", &main.to_hex().to_string()][..], + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git configures a tracking branch"); + } + let events = loaded(fixture.path(), &["topic"], &[])?; + let mut graph = events + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("history loading returns the persistent graph"); + let cached = graph.index(main).expect("the tracking tip was scheduled"); + assert!( + graph.commits[cached.as_usize()].state & NODE_COMPLETE != 0 + && graph.commits[cached.as_usize()].state & NODE_STORED == 0, + "the tracking tip is cached without being visible" + ); + + assert!( + Command::new("git") + .current_dir(fixture.path()) + .args(["switch", "-q", "topic"]) + .status()? + .success(), + "git moves HEAD away from the pin target" + ); + let repo = crate::test_repository::open(fixture.path())?; + crate::edit::time_travel::create_or_reuse_pin( + &repo, + gix::refs::Target::Symbolic("refs/remotes/origin/main".try_into()?), + main, + "test symbolic ref-tree pin", + )?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let refresh = graph.refresh(&repo, &["topic".into()], &[], false, &HashSet::new(), &authors)?; + let refreshed: HashSet<_> = refresh.commits.rows.iter().map(|row| row.id).collect(); + assert!( + refreshed.contains(&main) && refreshed.contains(&merged), + "a newly visible cached tip is loaded together with its missing ancestry: {refreshed:?}" + ); + Ok(()) + } + + #[test] + fn hidden_history_keeps_tracking_relations_complete_and_can_be_expanded() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + let git = |args: &[&str]| -> gix_testtools::Result { + let output = Command::new("git").current_dir(path).args(args).output()?; + assert!( + output.status.success(), + "git {args:?} prepares the hidden tracking fixture: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }; + let commit = |name: &str| -> gix_testtools::Result { + std::fs::write(path.join(name), format!("{name}\n"))?; + git(&["add", name])?; + git(&["commit", "-q", "-m", name]) + }; + + git(&["config", "commit.gpgsign", "false"])?; + git(&["switch", "-q", "-c", "relation-base", "main"])?; + commit("base-0")?; + let base = crate::test_repository::open(path)?.rev_parse_single("HEAD")?.detach(); + for name in ["base-1", "base-2", "base-3"] { + commit(name)?; + } + git(&["switch", "-q", "-c", "hidden"])?; + commit("hidden-only")?; + let hidden_only = crate::test_repository::open(path)?.rev_parse_single("HEAD")?.detach(); + git(&["switch", "-q", "-c", "local", "relation-base"])?; + commit("local-only")?; + let local = crate::test_repository::open(path)?.rev_parse_single("HEAD")?.detach(); + git(&["switch", "-q", "--detach", &base.to_hex().to_string()])?; + commit("upstream-only")?; + let upstream = crate::test_repository::open(path)?.rev_parse_single("HEAD")?.detach(); + git(&["switch", "-q", "local"])?; + for args in [ + &["config", "remote.origin.url", "https://example.com/repo"][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + &["config", "branch.local.remote", "origin"][..], + &["config", "branch.local.merge", "refs/heads/local"][..], + &[ + "update-ref", + "refs/remotes/origin/local", + &upstream.to_hex().to_string(), + ][..], + ] { + git(args)?; + } + + let mut decorations = Decorations::new(); + let mut visible = HashSet::new(); + let mut boundary = HashSet::new(); + let mut graph = None; + for event in loaded(path, &["local"], &["hidden"])? { + match event { + Event::Decorations(value) => decorations = value, + Event::Commits(batch) => visible.extend(batch.rows.into_iter().map(|row| row.id)), + Event::HiddenCommits(batch) => { + boundary.extend(batch.rows.into_iter().map(|row| row.id)); + visible.extend(boundary.iter().copied()); + } + Event::Complete(value) => graph = Some(value), + Event::VisibleComplete | Event::Cancelled => {} + } + } + let mut graph = graph.expect("history loading returns the persistent graph"); + let refs = graph.selection_refs(local, &decorations); + let counts = Command::new("git") + .current_dir(path) + .args([ + "rev-list", + "--left-right", + "--count", + "local...refs/remotes/origin/local", + ]) + .output()?; + assert!(counts.status.success(), "git computes the expected tracking relation"); + let expected: Vec<_> = String::from_utf8(counts.stdout)? + .split_whitespace() + .map(str::parse::<usize>) + .collect::<Result<_, _>>()?; + assert_eq!( + graph.selection_relation(local, &refs, &[]), + Some(crate::app::SelectionRelation::Tracking { + ahead: expected[0], + behind: expected[1], + }), + "hidden tips do not truncate either side of the tracking relation" + ); + + let repo = crate::test_repository::open(path)?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let refresh = graph.refresh(&repo, &["local".into()], &[], false, &boundary, &authors)?; + visible.extend(refresh.commits.rows.into_iter().map(|row| row.id)); + let expected: HashSet<_> = repo + .rev_walk([local]) + .all()? + .map(|info| info.map(|info| info.id)) + .collect::<Result<_, _>>()?; + assert_eq!( + visible, expected, + "showing hidden materializes the original view ancestry" + ); + assert!( + !visible.contains(&hidden_only), + "showing hidden does not add commits reachable only from a hidden tip" + ); + Ok(()) + } + #[test] fn hides_tips_and_every_commit_reachable_from_them() -> gix_testtools::Result { let fixture = fixture()?; @@ -514,21 +3150,21 @@ mod tests { ); let expected = String::from_utf8(output.stdout)?.lines().map(str::to_owned).collect(); assert_eq!(actual, expected, "hidden tips use Git's exclusion semantics"); - let repo = gix::open(&fixture)?; - let revisions = [OsString::from("topic")]; - let hidden = [OsString::from("main")]; - assert_eq!( - count_up_to(&repo, &revisions, &hidden, 1)?, - actual.len().min(1), - "the screen-size probe stops at its limit" - ); + let repo = crate::test_repository::open(&fixture)?; + let connected: Vec<_> = events + .iter() + .flat_map(|event| match event { + Event::HiddenCommits(batch) => batch.rows.iter().map(|row| row.id).collect(), + _ => Vec::new(), + }) + .collect(); assert_eq!( - count_up_to(&repo, &revisions, &hidden, usize::MAX)?, - actual.len(), - "the screen-size probe uses the same hidden history" + connected, + [repo.rev_parse_single("topic^")?], + "only the excluded parent directly connected to visible history is retained" ); assert!( - matches!(events.last(), Some(Event::Complete)), + matches!(events.last(), Some(Event::Complete(_))), "the filtered walk completes" ); Ok(()) @@ -559,8 +3195,8 @@ mod tests { let mut cancelled = Vec::new(); let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let repo = gix::open(&fixture)?; - load(&repo, &[], &[], &authors, &AtomicBool::new(true), |event| { + let repo = crate::test_repository::open(&fixture)?; + load(&repo, &[], &[], false, &authors, &AtomicBool::new(true), |event| { cancelled.push(event); true })?; @@ -573,6 +3209,12 @@ mod tests { #[test] fn classifies_reference_kinds() { + assert_eq!(decoration_kind(b"refs/worktree/tix/pins/abcd"), DecorationKind::Pin); + assert_eq!( + decoration_kind(b"refs/tix/stash/0123456789012345678901234567890123456789"), + DecorationKind::Stash + ); + assert_eq!(decoration_kind(b"refs/worktree/tix/review/1"), DecorationKind::Review); assert_eq!(decoration_kind(b"refs/heads/main"), DecorationKind::Local); assert_eq!(decoration_kind(b"refs/tags/v1"), DecorationKind::Tag); assert_eq!(decoration_kind(b"refs/remotes/origin/main"), DecorationKind::Remote); @@ -580,6 +3222,237 @@ mod tests { assert_eq!(decoration_kind(b"refs/stash"), DecorationKind::Special); } + #[test] + fn ref_tree_revisions_cover_normal_refs_and_optionally_tags() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + repo.reference( + "refs/worktree/tix/pins/test", + repo.head_id()?, + gix::refs::transaction::PreviousValue::MustNotExist, + "test tree revisions", + )?; + for name in [crate::edit::undo::TIP_REF, crate::edit::undo::CURSOR_REF] { + repo.reference( + name, + repo.head_id()?, + gix::refs::transaction::PreviousValue::MustNotExist, + "test undo reference filtering", + )?; + } + let names = |include_tags| { + ref_tree_revisions(&repo, include_tags).map(|revisions| { + revisions + .into_iter() + .map(|revision| revision.to_string_lossy().into_owned()) + .collect::<Vec<_>>() + }) + }; + + let with_tags = names(true)?; + let without_tags = names(false)?; + assert!( + with_tags.iter().any(|name| name.starts_with("refs/heads/")), + "branches are default traversal tips" + ); + assert!( + with_tags.iter().any(|name| name.starts_with("refs/tags/")), + "tags are included by default" + ); + assert!( + without_tags.iter().all(|name| !name.starts_with("refs/tags/")), + "tagless rendering does not traverse tag-only history" + ); + assert!( + with_tags + .iter() + .all(|name| !name.starts_with("refs/worktree/tix/pins/")), + "ordinary pins never become tree traversal tips" + ); + assert!( + with_tags + .iter() + .all(|name| !crate::edit::undo::is_queue_ref(name.as_bytes().into())), + "undo queue refs never become tree traversal tips" + ); + Ok(()) + } + + #[test] + fn ignores_malformed_and_non_commit_pins() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let head = repo.head_id()?.detach(); + let blob = repo.write_blob(b"not a commit")?.detach(); + repo.reference( + "refs/worktree/tix/pins/a", + head, + gix::refs::transaction::PreviousValue::MustNotExist, + "test malformed pin", + )?; + repo.reference( + "refs/worktree/tix/pins/abcd", + blob, + gix::refs::transaction::PreviousValue::MustNotExist, + "test non-commit pin", + )?; + repo.reference( + HEAD_PIN_NAME.as_bstr(), + head, + gix::refs::transaction::PreviousValue::MustNotExist, + "test non-symbolic HEAD pin", + )?; + + assert!(all_pins(&repo)?.is_empty(), "invalid pins never enter history"); + Ok(()) + } + + #[test] + fn head_pin_marks_its_branch_without_an_ordinary_pin_decoration() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let symbolic = Command::new("git") + .current_dir(fixture.path()) + .args(["symbolic-ref", "refs/worktree/tix/pins/HEAD", "refs/heads/main"]) + .status()?; + assert!(symbolic.success(), "git creates the symbolic HEAD pin"); + let detached = Command::new("git") + .current_dir(fixture.path()) + .args(["checkout", "-q", "--detach", "main~2"]) + .status()?; + assert!(detached.success(), "git detaches HEAD below the remembered branch"); + + let repo = crate::test_repository::open(fixture.path())?; + let main = repo.rev_parse_single("main")?.detach(); + let snapshot = snapshot(&repo, &[], &[], false)?; + assert!(snapshot.view_tips.contains(&main), "the HEAD pin retains its branch"); + let decorations = decorations(&repo, &snapshot.pins, &snapshot.worktrees)?; + let main = decorations.get(&main).expect("the remembered branch is decorated"); + assert!( + main.iter() + .any(|decoration| { decoration.kind == DecorationKind::HeadPinBranch && decoration.name == "main" }) + ); + assert!( + main.iter().all(|decoration| decoration.kind != DecorationKind::Pin), + "the HEAD pin has no ordinary pin marker" + ); + Ok(()) + } + + #[test] + fn review_resources_are_not_history_tips_and_stashes_decorate_the_saved_leaf() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let review = repo.rev_parse_single("topic")?.detach(); + let mut stash = repo.find_commit(review)?.decode()?.into_owned()?; + stash.parents = [review].into_iter().collect(); + stash.message = "review stash".into(); + let stash = repo.write_object(&stash)?.detach(); + repo.reference( + "refs/worktree/tix/review/1", + review, + gix::refs::transaction::PreviousValue::MustNotExist, + "test review", + )?; + repo.reference( + "refs/worktree/tix/review/stashes/1", + stash, + gix::refs::transaction::PreviousValue::MustNotExist, + "test review stash", + )?; + + assert_eq!(all_reviews(&repo)?.len(), 1); + assert!( + !snapshot(&repo, &[], &[], false)?.view_tips.contains(&review), + "review resources do not retain history" + ); + let decorations = decorations(&repo, &[], &worktree_checkouts(&repo))?; + assert!( + decorations.get(&review).is_some_and(|decorations| decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Stash)), + "saved review state decorates the review leaf" + ); + assert!( + decorations.get(&stash).is_none_or(|decorations| decorations + .iter() + .all(|decoration| decoration.kind != DecorationKind::Stash)), + "the internal stash commit is not decorated" + ); + Ok(()) + } + + #[test] + fn commit_stashes_decorate_the_associated_commit_without_becoming_tips() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let head = repo.head_id()?.detach(); + let stash = repo.rev_parse_single("topic")?.detach(); + let name = super::super::edit::stash::reference(head)?; + repo.reference( + name, + stash, + gix::refs::transaction::PreviousValue::MustNotExist, + "test commit stash", + )?; + + assert!(!snapshot(&repo, &[], &[], false)?.view_tips.contains(&stash)); + let decorations = decorations(&repo, &[], &worktree_checkouts(&repo))?; + assert!( + decorations.get(&head).is_some_and(|decorations| decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Stash)), + "the ref-name suffix associates the stash with HEAD" + ); + assert!( + decorations.get(&stash).is_none_or(|decorations| decorations + .iter() + .all(|decoration| decoration.kind != DecorationKind::Stash)), + "the stash object itself is not decorated" + ); + Ok(()) + } + + #[test] + fn pins_are_private_to_the_current_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let linked = gix_testtools::tempfile::tempdir()?; + let linked_path = linked.path().join("linked"); + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["worktree", "add", "-q", "--detach"]) + .arg(&linked_path) + .arg("topic") + .status()? + .success(), + "the linked-worktree fixture is created" + ); + let main = crate::test_repository::open(fixture.path())?; + let linked = crate::test_repository::open(&linked_path)?; + let main_id = main.head_id()?.detach(); + let linked_id = linked.head_id()?.detach(); + for (repo, id) in [(&main, main_id), (&linked, linked_id)] { + repo.reference( + "refs/worktree/tix/pins/abcd", + id, + gix::refs::transaction::PreviousValue::MustNotExist, + "test private pin", + )?; + } + assert_eq!( + all_pins(&main)?.into_iter().map(|pin| pin.id).collect::<Vec<_>>(), + [main_id], + "the main worktree sees only its private pin" + ); + assert_eq!( + all_pins(&linked)?.into_iter().map(|pin| pin.id).collect::<Vec<_>>(), + [linked_id], + "the linked worktree sees only its private pin" + ); + Ok(()) + } + #[test] fn interns_raw_author_identities() { let mut authors = Authors::default(); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 61454821e58..302b907201a 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -3,12 +3,25 @@ #![forbid(unsafe_code)] mod app; +mod change_id; +pub mod command; +mod command_menu; +mod edit; +mod enrich; mod history; +mod logging; +mod menu; +mod ref_tree; +#[cfg(test)] +mod test_repository; mod ui; use std::{ + collections::{HashMap, HashSet, VecDeque}, ffi::OsString, - path::Path, + io::{self, Write}, + path::{Path, PathBuf}, + process::{Command, ExitStatus, Stdio}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -18,876 +31,10283 @@ use std::{ }; use anyhow::{Context, Result}; -use app::{Action, App, CommitRow, Effect, State}; +use app::{ + Action, App, ChangeGroup, ChangeKind, ChangePane, Changes, ChangesMode, ComparedParent, Effect, PathChange, + SelectionRelation, SharedCommitRow, State, +}; +use command_menu::{Command as MenuCommand, CommandId}; use crossterm::{ clipboard::CopyToClipboard, cursor, event::{ - self, Event as TerminalEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, - ModifierKeyCode, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, EnableFocusChange, + EnableMouseCapture, Event as TerminalEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, + KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, - style::Print, - terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, + style::ResetColor, + terminal::{self, Clear, ClearType}, }; -use gix::bstr::{BString, ByteSlice}; -use history::{Authors, Decorations, Event, SharedAuthors}; -use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend}; +use gix::{ + bstr::{BString, ByteSlice}, + prelude::TreeDiffChangeExt, +}; +use history::{Authors, Decorations, Event, HistoryGraph, SelectionRef, SharedAuthors}; +use menu::{Item as MenuItem, Menu}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, layout::Position, text::Line}; const EVENT_BATCH_SIZE: usize = 256; const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024; const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); +const TODO_PROGRESS_DELAY: Duration = Duration::from_millis(300); +const HISTORY_STATUS_DELAY: Duration = Duration::from_millis(500); +const REPEAT_IDLE: Duration = Duration::from_millis(75); +const REF_EVENT_IDLE: Duration = Duration::from_millis(100); +const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); +const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); +const WATCH_RETRY_INTERVAL: Duration = Duration::from_secs(5); +const LINE_DIFF_POOL_IDLE: Duration = Duration::from_secs(10); +const THEME_QUERY_TIMEOUT: Duration = Duration::from_millis(100); +const WORKTREE_STATUS_CURRENT: usize = 0; +const WORKTREE_STATUS_PARTIAL: usize = usize::MAX - 1; +const WORKTREE_STATUS_FULL: usize = usize::MAX; -struct FillRepository<'a> { - path: &'a Path, +struct FillRepository { + path: PathBuf, + bare: bool, retained: Option<gix::Repository>, retain: bool, } -/// Options for [`run()`]. -#[derive(Clone, Debug, Default)] -pub struct Options { - /// Exit once all commits and graph lanes have been computed. - pub quit_on_finish: bool, - /// Revisions whose reachable commits should initially be hidden. - pub hide: Vec<OsString>, - /// How much of the terminal to use. - pub screen: Screen, +struct PendingConflictResolution { + commit: gix::ObjectId, + head: Option<ConflictHead>, + ref_changes: Vec<edit::undo::RefChange>, + record_undo: bool, } -/// How `gix-tix` occupies the terminal. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum Screen { - /// Use the main screen for short histories, otherwise the alternate screen. - #[default] - Auto, - /// Always use the alternate screen. - Always, - /// Use half of the main screen. - Half, +struct ConflictHead { + reference: Option<gix::refs::FullName>, + parents: Vec<gix::ObjectId>, } -/// Run the interactive commit graph for `repository`. -pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec<OsString>, options: Options) -> Result<()> { - let terminal_height = match options.screen { - Screen::Always => 0, - Screen::Auto | Screen::Half => terminal::size().context("could not determine terminal size")?.1, - }; - let visible_commits = match options.screen { - Screen::Auto | Screen::Half => history::count_up_to( - &repository.to_thread_local(), - &revisions, - &options.hide, - half_height(terminal_height) as usize, - )?, - Screen::Always => 0, - }; - let inline_height = inline_height(options.screen, terminal_height, visible_commits); - let mut terminal = match inline_height { - Some(height) => ratatui::try_init_with_options(TerminalOptions { - viewport: Viewport::Inline(height), - }), - None => ratatui::try_init(), - } - .context("could not initialize terminal")?; - let enhanced_keyboard = terminal::supports_keyboard_enhancement().unwrap_or(false); - let keyboard_setup = if enhanced_keyboard { - execute!( - terminal.backend_mut(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES - ) - ) - } else { - Ok(()) - }; - let result = keyboard_setup - .context("could not enable enhanced keyboard events") - .and_then(|()| event_loop(&mut terminal, repository, revisions, options, inline_height.is_some())); - let keyboard_restore = enhanced_keyboard - .then(|| execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags)) - .transpose(); - let restore = restore_terminal(&mut terminal, inline_height.is_some()); - let lane_time = result?; - keyboard_restore.context("could not restore keyboard events")?; - restore?; - if let Some(lane_time) = lane_time { - eprintln!("lane computation: {:.3}s", lane_time.as_secs_f64()); +#[derive(Clone, Debug, Eq, PartialEq)] +struct WorktreeStatusHead { + reference: Option<gix::refs::FullName>, + target: Option<gix::ObjectId>, +} + +#[derive(Default)] +struct WorktreeStatusParts { + staged: bool, + scopes: HashSet<BString>, +} + +enum ExternalConflictResolution { + Current, + Changed, + Complete(gix::ObjectId, Vec<edit::undo::RefChange>, bool), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConflictReconcileStatus { + Inactive, + Amend, + Blocked, + Complete, +} + +struct WorktreeWatcher { + watcher: RecommendedWatcher, + events: mpsc::Receiver<notify::Result<notify::Event>>, + directories: HashSet<PathBuf>, + index_projection: Vec<IndexWatchEntry>, + workdir: PathBuf, + dot_git: PathBuf, + git_dir: PathBuf, + index: PathBuf, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct IndexWatchEntry { + path: BString, + mode: u32, + flags: u32, +} + +#[derive(Default)] +struct WorktreeWatchRefresh { + full: bool, + index: bool, + scopes: HashSet<PathBuf>, +} + +impl WorktreeWatchRefresh { + fn add_scope(&mut self, scope: &Path, workdir: &Path) { + if scope == workdir { + self.full = true; + self.scopes.clear(); + } else if !self.full && scope.starts_with(workdir) { + self.scopes.insert(scope.to_owned()); + } + } + + fn observe(&mut self, event: ¬ify::Event, workdir: &Path, index: &Path, directories: &HashSet<PathBuf>) { + if self.full { + return; + } + if event.need_rescan() || event.paths.is_empty() || matches!(event.kind, notify::EventKind::Any) { + self.full = true; + self.scopes.clear(); + return; + } + for path in &event.paths { + if path == index { + self.index = true; + } else if path.file_name().is_some_and(|name| name == ".gitignore") { + if let Some(parent) = path.parent() { + self.add_scope(parent, workdir); + } else { + self.full = true; + self.scopes.clear(); + return; + } + } + } + let is_directory = |path: &Path| { + directories.contains(path) + || std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) + }; + match event.kind { + notify::EventKind::Create(notify::event::CreateKind::Folder) + | notify::EventKind::Remove(notify::event::RemoveKind::Folder) => { + for path in &event.paths { + self.add_scope(path, workdir); + } + } + notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) + if event.paths.iter().any(|path| is_directory(path)) => + { + for path in &event.paths { + if let Some(parent) = path.parent() { + self.add_scope(parent, workdir); + } + } + } + notify::EventKind::Create(notify::event::CreateKind::Any | notify::event::CreateKind::Other) + | notify::EventKind::Remove(notify::event::RemoveKind::Any | notify::event::RemoveKind::Other) + | notify::EventKind::Modify(notify::event::ModifyKind::Any) => { + for path in event.paths.iter().filter(|path| is_directory(path)) { + self.add_scope(path, workdir); + } + } + notify::EventKind::Other => { + self.full = true; + self.scopes.clear(); + } + _ => {} + } + } + + fn is_empty(&self) -> bool { + !self.full && !self.index && self.scopes.is_empty() } - Ok(()) } -fn half_height(terminal_height: u16) -> u16 { - (terminal_height / 2).max(1) +struct RefWatcher { + _watcher: RecommendedWatcher, + events: mpsc::Receiver<notify::Result<notify::Event>>, + git_dir: PathBuf, + worktrees_dir: PathBuf, } -fn inline_height(screen: Screen, terminal_height: u16, visible_commits: usize) -> Option<u16> { - let half = half_height(terminal_height); - let compact = u16::try_from(visible_commits).unwrap_or(u16::MAX).saturating_add(3); - match screen { - Screen::Always => None, - Screen::Half => Some(compact.min(half)), - Screen::Auto if visible_commits < half as usize => Some(compact), - Screen::Auto => None, +impl WorktreeWatcher { + fn event_is_relevant(&self, event: ¬ify::Event) -> bool { + worktree_event_is_relevant(event, &self.workdir, &self.dot_git, &self.git_dir, &self.index) } } -fn restore_terminal(terminal: &mut ratatui::DefaultTerminal, inline: bool) -> Result<()> { - if !inline { - return ratatui::try_restore().context("could not restore terminal"); +impl RefWatcher { + fn event_is_relevant(&self, event: ¬ify::Event) -> bool { + reference_event_is_relevant(event, &self.git_dir, &self.worktrees_dir) } - let cursor = (|| { - let area = terminal.get_frame().area(); - let terminal_height = terminal.size()?.height; - execute!( - terminal.backend_mut(), - cursor::MoveTo(0, area.bottom().saturating_sub(1)), - Clear(ClearType::CurrentLine) - )?; - if area.bottom() < terminal_height { - execute!(terminal.backend_mut(), cursor::MoveTo(0, area.bottom())) + fn watch_set_may_change(&self, event: ¬ify::Event) -> bool { + reference_watch_set_may_change(event, &self.worktrees_dir) + } +} + +#[derive(Default)] +struct WorktreeDirectories { + root: PathBuf, + paths: HashSet<PathBuf>, +} + +impl gix::dir::walk::Delegate for WorktreeDirectories { + fn emit( + &mut self, + _entry: gix::dir::EntryRef<'_>, + _collapsed_directory_status: Option<gix::dir::entry::Status>, + ) -> gix::dir::walk::Action { + std::ops::ControlFlow::Continue(()) + } + + fn can_recurse( + &mut self, + entry: gix::dir::EntryRef<'_>, + for_deletion: Option<gix::dir::walk::ForDeletionMode>, + worktree_root_is_repository: bool, + ) -> bool { + let recurse = entry.status.can_recurse( + entry.disk_kind, + entry.pathspec_match, + for_deletion, + worktree_root_is_repository, + ); + if recurse { + self.paths + .insert(self.root.join(gix::path::from_bstr(entry.rela_path.as_ref()))); + } + recurse + } +} + +fn worktree_event_is_relevant( + event: ¬ify::Event, + workdir: &Path, + dot_git: &Path, + git_dir: &Path, + index: &Path, +) -> bool { + event.need_rescan() + || (!matches!(event.kind, notify::EventKind::Access(_)) + && (event.paths.is_empty() + || event.paths.iter().any(|path| { + path == index + || (path.starts_with(workdir) && !path.starts_with(dot_git) && !path.starts_with(git_dir)) + }))) +} + +fn worktree_status_event_scopes( + event: ¬ify::Event, + workdir: &Path, + dot_git: &Path, + git_dir: &Path, + index: &Path, +) -> Option<Vec<BString>> { + if event.need_rescan() || event.paths.is_empty() || event.paths.iter().any(|path| path == index) { + return None; + } + let mut out = Vec::new(); + for path in &event.paths { + if !path.starts_with(workdir) || path.starts_with(dot_git) || path.starts_with(git_dir) { + continue; + } + if path + .file_name() + .is_some_and(|name| name == ".gitattributes" || name == ".gitmodules") + { + return None; + } + let scope = if path.file_name().is_some_and(|name| name == ".gitignore") { + path.parent()? } else { - execute!( - terminal.backend_mut(), - cursor::MoveTo(0, terminal_height.saturating_sub(1)), - Print("\r\n") - ) + path + }; + let relative = scope.strip_prefix(workdir).ok()?; + if relative.as_os_str().is_empty() + || !relative + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))) + { + return None; } - .and_then(|()| terminal.show_cursor()) - })(); - let raw_mode = terminal::disable_raw_mode(); - cursor.context("could not restore terminal cursor")?; - raw_mode.context("could not disable terminal raw mode")?; - Ok(()) + let relative = gix::path::try_into_bstr(relative).ok()?; + out.push(gix::path::to_unix_separators_on_windows(relative).into_owned()); + } + (!out.is_empty()).then_some(out) } -fn enter_alternate_screen(terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<ratatui::DefaultTerminal> { - let alternate = ratatui::Terminal::new(CrosstermBackend::new(std::io::stdout()))?; - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - Ok(std::mem::replace(terminal, alternate)) +fn notification_is_actionable(event: ¬ify::Event) -> bool { + event.need_rescan() + || (!matches!(event.kind, notify::EventKind::Access(_)) + && (event.paths.is_empty() + || matches!( + event.kind, + notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) + ) + || event.paths.iter().any(|path| { + !path + .file_name() + .is_some_and(|name| name.as_encoded_bytes().ends_with(b".lock")) + }))) } -fn leave_alternate_screen( - terminal: &mut ratatui::DefaultTerminal, - inline: ratatui::DefaultTerminal, -) -> std::io::Result<()> { - drop(std::mem::replace(terminal, inline)); - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.hide_cursor() +fn reference_event_is_relevant(event: ¬ify::Event, git_dir: &Path, worktrees_dir: &Path) -> bool { + let common_dir = worktrees_dir.parent(); + notification_is_actionable(event) + && (event.need_rescan() + || event.paths.is_empty() + || event.paths.iter().any(|path| { + let is_index = [Some(git_dir), common_dir] + .into_iter() + .flatten() + .any(|git_dir| path == &git_dir.join("index") || path == &git_dir.join("index.lock")); + if is_index { + return false; + } + if let Ok(relative) = path.strip_prefix(git_dir) + && (relative.components().count() <= 1 || relative.starts_with("refs")) + { + return true; + } + let Ok(relative) = path.strip_prefix(worktrees_dir) else { + return true; + }; + let mut components = relative.components(); + let Some(_) = components.next() else { return true }; + match components.next() { + None => true, + Some(name) => matches!(name.as_os_str().as_encoded_bytes(), b"HEAD" | b"gitdir"), + } + })) } -fn should_switch_screen(started_inline: bool, needs_alternate_screen: bool, in_alternate_screen: bool) -> bool { - started_inline && needs_alternate_screen != in_alternate_screen +fn reference_event_changes_status_configuration(event: ¬ify::Event, git_dir: &Path, worktrees_dir: &Path) -> bool { + event.need_rescan() + || event.paths.is_empty() + || event.paths.iter().any(|path| { + [Some(git_dir), worktrees_dir.parent()] + .into_iter() + .flatten() + .any(|dir| path == &dir.join("config") || path == &dir.join("config.worktree")) + }) } -fn history_needs_alternate_screen(screen: Screen, terminal_height: u16, commits: usize) -> bool { - screen == Screen::Auto && inline_height(screen, terminal_height, commits).is_none() +fn reference_watch_set_may_change(event: ¬ify::Event, worktrees_dir: &Path) -> bool { + event.need_rescan() + || event.paths.iter().any(|path| { + path.strip_prefix(worktrees_dir) + .is_ok_and(|relative| relative.components().count() <= 1) + }) } -fn resize_inline_screen(terminal: &mut ratatui::DefaultTerminal, height: u16) -> std::io::Result<()> { - if terminal.get_frame().area().height == height { - return Ok(()); +fn unseen_filesystem_redraw(current: bool, focused: bool, filesystem_frame: bool) -> bool { + !focused && (current || filesystem_frame) +} + +fn worktree_watcher_needed(repository_is_bare: bool, mode: Option<ChangesMode>) -> bool { + !repository_is_bare && mode == Some(ChangesMode::Both) +} + +fn schedule_once(deadline: &mut Option<Instant>, now: Instant, delay: Duration) -> bool { + if deadline.is_some() { + false + } else { + *deadline = Some(now + delay); + true } - let resized = ratatui::Terminal::with_options( - CrosstermBackend::new(std::io::stdout()), - TerminalOptions { - viewport: Viewport::Inline(height), - }, - )?; - drop(std::mem::replace(terminal, resized)); - terminal.hide_cursor() } -fn sync_screen( - terminal: &mut ratatui::DefaultTerminal, - app: &mut App, - screen: Screen, - started_inline: bool, - history_requires_alternate_screen: bool, - resize_inline: bool, - inline_terminal: &mut Option<ratatui::DefaultTerminal>, -) -> Result<()> { - let needs_alternate_screen = app.show_commit || history_requires_alternate_screen; - if !should_switch_screen(started_inline, needs_alternate_screen, inline_terminal.is_some()) { - if started_inline && app.inline && resize_inline { - let height = inline_height(screen, terminal::size()?.1, app.rows.len()) - .expect("an inline history always has an inline height"); - resize_inline_screen(terminal, height).context("could not resize the inline history")?; +fn take_due(deadline: &mut Option<Instant>, now: Instant) -> bool { + if deadline.is_some_and(|deadline| now >= deadline) { + *deadline = None; + true + } else { + false + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SelectionRelationCache { + id: gix::ObjectId, + refs: Vec<SelectionRef>, + relation: Option<SelectionRelation>, +} + +const TREE_CHANGES_CACHE_SIZE: usize = 8; +type TreeChangesEntry = (app::TreeDiffTarget, Changes); + +#[derive(Default)] +struct TreeChangesCache(VecDeque<TreeChangesEntry>); + +impl TreeChangesCache { + fn as_ref(&self) -> Option<&TreeChangesEntry> { + self.0.front() + } + + fn activate(&mut self, target: app::TreeDiffTarget) -> bool { + let Some(position) = self.0.iter().position(|(cached, _)| *cached == target) else { + return false; + }; + if position != 0 { + let entry = self.0.remove(position).expect("the cached position exists"); + self.0.push_front(entry); } - return Ok(()); + true } - if needs_alternate_screen { - *inline_terminal = Some(enter_alternate_screen(terminal).context("could not enter the alternate screen")?); - app.inline = false; - } else if let Some(inline) = inline_terminal.take() { - leave_alternate_screen(terminal, inline).context("could not leave the alternate screen")?; - app.inline = true; - let height = inline_height(screen, terminal::size()?.1, app.rows.len()) - .expect("an inline history always has an inline height"); - resize_inline_screen(terminal, height).context("could not resize the inline history")?; + + fn insert(&mut self, entry: TreeChangesEntry) { + self.0.push_front(entry); + self.0.truncate(TREE_CHANGES_CACHE_SIZE); + } + + fn clear(&mut self) { + self.0.clear(); } - Ok(()) } -fn event_loop( - terminal: &mut ratatui::DefaultTerminal, - repository: gix::ThreadSafeRepository, - revisions: Vec<OsString>, - options: Options, - started_inline: bool, -) -> Result<Option<Duration>> { - let Options { - quit_on_finish, - hide, - screen, - } = options; - let repository_path = repository.git_dir().to_owned(); - let mailmap = gix::open(&repository_path) - .context("could not open repository for mailmap")? - .open_mailmap(); - let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let (mut cancelled, mut receiver) = start_history( - repository, - &revisions, - &hide, - gix::features::threading::OwnShared::clone(&authors), - ); +type LineCounts = Option<(u32, u32)>; - let mut app = App::new(1); - let mut lane_receiver = None; - let mut commit_message = None; - let mut fill_repository = FillRepository { - path: &repository_path, - retained: None, - retain: false, +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct DiffResource { + id: gix::ObjectId, + mode: gix::objs::tree::EntryMode, + path: BString, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FileChange { + Tree(gix::object::tree::diff::ChangeDetached), + Worktree { + old: Option<DiffResource>, + new: Option<DiffResource>, + }, + Unavailable(&'static str), +} + +struct LineDiffJob { + index: usize, + change: FileChange, +} + +enum LineDiffMessage { + Job(LineDiffJob), + FinishBatch, +} + +enum LineDiffResult { + Change(usize, FileChange, Result<LineCounts>), + BatchFinished, +} + +struct LineDiffPool { + repository_path: PathBuf, + bare: bool, + parallelism: usize, + active: Option<LineDiffWorkers>, + last_used: Option<Instant>, +} + +struct LineDiffWorkers { + jobs: Vec<mpsc::Sender<LineDiffMessage>>, + results: mpsc::Receiver<LineDiffResult>, + workers: Vec<std::thread::JoinHandle<()>>, +} + +type LineDiffState = (gix::diff::blob::Platform, Option<gix::diff::blob::Platform>); + +fn worktree_diff_cache( + repository: &gix::Repository, + mode: gix::diff::blob::pipeline::Mode, +) -> Result<Option<gix::diff::blob::Platform>> { + let Some(workdir) = repository.workdir() else { + return Ok(None); }; - app.inline = started_inline; - app.has_hidden_filter = !hide.is_empty(); - let mut decorations = Decorations::new(); - draw( - terminal, - &mut app, - &decorations, - &mailmap, - &authors, - &mut fill_repository, - &mut commit_message, - )?; - let mut last_draw = Instant::now(); - let mut dirty = false; - let mut urgent = false; - let mut inline_terminal = None; - let mut history_requires_alternate_screen = false; - let result: Result<Option<Duration>> = (|| loop { - if let Some(result) = lane_receiver.as_ref().map(mpsc::Receiver::try_recv) { - match result { - Ok((rows, graph, lane_time)) => { - app.finish_lane_computation(rows, graph, lane_time); - lane_receiver = None; - dirty = true; - if quit_on_finish { - return Ok(app.lane_time); - } - } - Err(mpsc::TryRecvError::Empty) => {} - Err(mpsc::TryRecvError::Disconnected) => { - anyhow::bail!("lane worker stopped unexpectedly") - } - } + repository + .diff_resource_cache( + mode, + gix::diff::blob::pipeline::WorktreeRoots { + old_root: None, + new_root: Some(workdir.to_owned()), + }, + ) + .map(Some) + .context("could not initialize worktree diff resources") +} + +fn set_worktree_resources( + repository: &gix::Repository, + cache: &mut gix::diff::blob::Platform, + old: Option<&DiffResource>, + new: Option<&DiffResource>, +) -> Result<()> { + let fallback = old.or(new).context("a file diff needs at least one resource")?; + let old_resource = old.unwrap_or(fallback); + cache + .set_resource( + old.map_or_else(|| repository.object_hash().null(), |resource| resource.id), + old_resource.mode.kind(), + old_resource.path.as_bstr(), + gix::diff::blob::ResourceKind::OldOrSource, + repository, + ) + .context("could not prepare old worktree diff resource")?; + let new_resource = new.unwrap_or(fallback); + cache + .set_resource( + new.map_or_else(|| repository.object_hash().null(), |resource| resource.id), + new_resource.mode.kind(), + new_resource.path.as_bstr(), + gix::diff::blob::ResourceKind::NewOrDestination, + repository, + ) + .context("could not prepare new worktree diff resource")?; + Ok(()) +} + +fn line_counts_for_change( + repository: &gix::Repository, + change: &FileChange, + tree_cache: &mut gix::diff::blob::Platform, + worktree_cache: Option<&mut gix::diff::blob::Platform>, +) -> Result<LineCounts> { + let counts = match change { + FileChange::Tree(change) => change + .attach(repository, repository) + .diff(tree_cache) + .context("could not prepare line diff")? + .line_counts() + .context("could not count changed lines")?, + FileChange::Worktree { old, new } => { + let cache = worktree_cache.context("a working tree is required to count changed lines")?; + set_worktree_resources(repository, cache, old.as_ref(), new.as_ref())?; + gix::object::blob::diff::Platform { resource_cache: cache } + .line_counts() + .context("could not count worktree changed lines")? } - if urgent { - draw( - terminal, - &mut app, - &decorations, - &mailmap, - &authors, - &mut fill_repository, - &mut commit_message, - )?; - last_draw = Instant::now(); - dirty = false; - urgent = false; - continue; + FileChange::Unavailable(_) => None, + }; + Ok(counts.map(|counts| (counts.insertions, counts.removals))) +} + +fn line_diff_state(repository: &gix::Repository) -> Result<LineDiffState> { + let tree_cache = repository + .diff_resource_cache_for_tree_diff() + .context("could not initialize parallel line diffs")?; + let worktree_cache = worktree_diff_cache(repository, gix::diff::blob::pipeline::Mode::ToGit)?; + Ok((tree_cache, worktree_cache)) +} + +impl LineDiffPool { + fn new(repository_path: &Path, bare: bool, parallelism: usize) -> Self { + LineDiffPool { + repository_path: repository_path.to_owned(), + bare, + parallelism: parallelism.max(1), + active: None, + last_used: None, } - let mut events = 0; - let mut resize_inline = false; - while events < EVENT_BATCH_SIZE { - let message = match receiver.try_recv() { - Ok(message) => message, - Err(mpsc::TryRecvError::Empty) => break, - Err(mpsc::TryRecvError::Disconnected) - if matches!(app.state, State::Computing | State::Complete | State::Cancelled) => - { - break; - } - Err(mpsc::TryRecvError::Disconnected) => { - anyhow::bail!("history worker stopped unexpectedly") - } - }; - events += 1; - dirty = true; - match message? { - Event::Decorations(value) => decorations = value, - Event::Commits(rows) => { - app.extend_commits(rows); - if history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()) { - history_requires_alternate_screen = true; + } + + fn line_counts(&mut self, changes: Vec<FileChange>) -> Result<Vec<(FileChange, LineCounts)>> { + if self.active.is_none() { + self.active = Some(LineDiffWorkers::new( + &self.repository_path, + self.bare, + self.parallelism, + )?); + } + let result = self + .active + .as_mut() + .expect("line diff workers were just initialized") + .line_counts(changes); + self.last_used = Some(Instant::now()); + result + } + + fn expire(&mut self, now: Instant) -> bool { + let expired = self + .last_used + .is_some_and(|last_used| now.saturating_duration_since(last_used) >= LINE_DIFF_POOL_IDLE); + if expired { + self.active = None; + self.last_used = None; + } + expired + } + + fn idle_timeout(&self, now: Instant) -> Option<Duration> { + self.last_used + .map(|last_used| LINE_DIFF_POOL_IDLE.saturating_sub(now.saturating_duration_since(last_used))) + } +} + +impl LineDiffWorkers { + fn new(repository_path: &Path, bare: bool, parallelism: usize) -> Result<Self> { + let repository = open_repository(repository_path, bare, false) + .context("could not open repository for parallel line diffs")?; + drop(line_diff_state(&repository)?); + let repository = repository.into_sync(); + let (result_sender, results) = mpsc::channel(); + let mut jobs = Vec::with_capacity(parallelism); + let workers = (0..parallelism) + .map(|_| { + let (job_sender, job_receiver) = mpsc::channel(); + jobs.push(job_sender); + let result_sender = result_sender.clone(); + let repository = repository.clone(); + std::thread::spawn(move || { + let mut repository = repository.to_thread_local(); + repository.object_cache_size(OBJECT_CACHE_SIZE); + let mut state: Option<LineDiffState> = None; + while let Ok(message) = job_receiver.recv() { + match message { + LineDiffMessage::Job(job) => { + let result = (|| { + if state.is_none() { + state = Some(line_diff_state(&repository)?); + } + let (tree_cache, worktree_cache) = + state.as_mut().expect("line diff state was just initialized"); + let result = line_counts_for_change( + &repository, + &job.change, + tree_cache, + worktree_cache.as_mut(), + ); + tree_cache.clear_resource_cache_keep_allocation(); + if let Some(cache) = worktree_cache.as_mut() { + cache.clear_resource_cache_keep_allocation(); + } + result + })(); + if result_sender + .send(LineDiffResult::Change(job.index, job.change, result)) + .is_err() + { + break; + } + } + LineDiffMessage::FinishBatch => { + state = None; + if result_sender.send(LineDiffResult::BatchFinished).is_err() { + break; + } + } + } } + }) + }) + .collect(); + Ok(LineDiffWorkers { jobs, results, workers }) + } + + fn line_counts(&mut self, changes: Vec<FileChange>) -> Result<Vec<(FileChange, LineCounts)>> { + let len = changes.len(); + let worker_count = self.jobs.len(); + for (index, change) in changes.into_iter().enumerate() { + self.jobs[index % worker_count] + .send(LineDiffMessage::Job(LineDiffJob { index, change })) + .context("line diff workers stopped unexpectedly")?; + } + for jobs in &self.jobs { + jobs.send(LineDiffMessage::FinishBatch) + .context("line diff workers stopped unexpectedly")?; + } + + let mut out: Vec<_> = std::iter::repeat_with(|| None).take(len).collect(); + let mut first_error = None; + let mut completed = 0; + let mut finished = 0; + while completed < len || finished < worker_count { + match self.results.recv().context("line diff workers stopped unexpectedly")? { + LineDiffResult::Change(index, change, Ok(lines)) => { + *out.get_mut(index).expect("jobs preserve their original result index") = Some((change, lines)); + completed += 1; } - Event::Complete => { - resize_inline = true; - history_requires_alternate_screen = - history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); - if let Some(rows) = app.start_lane_computation() { - lane_receiver = Some(start_lane_worker(rows)); + LineDiffResult::Change(_, _, Err(err)) => { + if first_error.is_none() { + first_error = Some(err); } + completed += 1; } - Event::Cancelled => drop(app.update(Action::Cancelled)), + LineDiffResult::BatchFinished => finished += 1, } } - sync_screen( - terminal, - &mut app, - screen, - started_inline, - history_requires_alternate_screen, - resize_inline, - &mut inline_terminal, - )?; - let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing); - if should_draw(dirty, streaming, last_draw.elapsed()) { - draw( - terminal, - &mut app, - &decorations, - &mailmap, - &authors, - &mut fill_repository, - &mut commit_message, - )?; - last_draw = Instant::now(); - dirty = false; + if let Some(err) = first_error { + return Err(err); } - let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed()) { - Some(timeout) if event::poll(timeout)? => Some(event::read()?), - Some(_) => None, - None => Some(event::read()?), - }; - let Some(terminal_event) = terminal_event else { - continue; - }; - let key = match terminal_event { - TerminalEvent::Key(key) => key, - TerminalEvent::Resize(_, _) => { - dirty = true; - urgent = true; - continue; - } - _ => continue, - }; - let action = action(key); - fill_repository.retain = retains_fill_repository(key.kind, action.as_ref()); - if !fill_repository.retain { - fill_repository.retained = None; - } - let Some(action) = action else { - continue; - }; - dirty = true; - urgent = true; - let effects = app.update(action); - for effect in effects { - match effect { - Effect::Cancel => cancelled.store(true, Ordering::Relaxed), - Effect::CopyId(id) => execute!( - terminal.backend_mut(), - CopyToClipboard::to_clipboard_from(id.to_hex().to_string()) - )?, - Effect::CopyAuthor(author) => { - let actor = actor_bytes(author); - execute!(terminal.backend_mut(), CopyToClipboard::to_clipboard_from(actor))?; - } - Effect::Reload(show_hidden) => { - cancelled.store(true, Ordering::Relaxed); - app.reload(show_hidden); - decorations.clear(); - let hidden = if show_hidden { &[][..] } else { hide.as_slice() }; - (cancelled, receiver) = start_history( - gix::ThreadSafeRepository::open_opts(&repository_path, gix::open::Options::isolated()) - .context("could not reopen repository for history reload")?, - &revisions, - hidden, - gix::features::threading::OwnShared::clone(&authors), - ); - } - Effect::Quit => { - if app.inline { - app.show_selection_tail = false; - draw( - terminal, - &mut app, - &decorations, - &mailmap, - &authors, - &mut fill_repository, - &mut commit_message, - )?; - } - return Ok(None); - } - } + out.into_iter() + .map(|entry| entry.context("line diff worker omitted a result")) + .collect() + } +} + +impl Drop for LineDiffWorkers { + fn drop(&mut self) { + self.jobs.clear(); + for worker in self.workers.drain(..) { + drop(worker.join()); } - sync_screen( - terminal, - &mut app, - screen, - started_inline, - history_requires_alternate_screen, - false, - &mut inline_terminal, - )?; - })(); - let restore = inline_terminal - .map(|inline| leave_alternate_screen(terminal, inline)) - .transpose(); - let outcome = result?; - restore.context("could not restore the inline terminal")?; - Ok(outcome) + } } -fn start_lane_worker(rows: Vec<CommitRow>) -> mpsc::Receiver<(Vec<CommitRow>, app::Graph, Duration)> { - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let _ = sender.send(app::compute_lanes(rows)); - }); - receiver +fn sync_line_diff_pool( + pool: &mut Option<LineDiffPool>, + visible: bool, + repository_path: &Path, + bare: bool, + parallelism: usize, +) { + if visible && pool.is_none() { + *pool = Some(LineDiffPool::new(repository_path, bare, parallelism)); + } else if !visible { + *pool = None; + } } -fn start_history( - repository: gix::ThreadSafeRepository, - revisions: &[OsString], - hidden_revisions: &[OsString], - authors: SharedAuthors, -) -> (Arc<AtomicBool>, mpsc::Receiver<Result<Event>>) { - let cancelled = Arc::new(AtomicBool::new(false)); - let worker_cancelled = Arc::clone(&cancelled); - let (sender, receiver) = mpsc::channel(); - let revisions = revisions.to_vec(); - let hidden_revisions = hidden_revisions.to_vec(); - std::thread::spawn(move || { - let mut repository = repository.to_thread_local(); - repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); - let result = history::load( - &repository, - &revisions, - &hidden_revisions, - &authors, - &worker_cancelled, - |event| sender.send(Ok(event)).is_ok(), - ); - if let Err(err) = result { - let _ = sender.send(Err(err)); - } - }); - (cancelled, receiver) +enum FileDiff { + External(gix::diff::blob::platform::prepare_diff_command::Command), + Pager { command: Command, diff: BuiltInDiff }, + BuiltIn(BuiltInDiff), } -fn draw( - terminal: &mut ratatui::DefaultTerminal, - app: &mut App, - decorations: &Decorations, - mailmap: &gix::mailmap::Snapshot, - authors: &SharedAuthors, - fill_repository: &mut FillRepository<'_>, - commit_message: &mut Option<(gix::ObjectId, BString)>, -) -> Result<()> { - app.viewport_rows = terminal - .get_frame() - .area() - .height - .saturating_sub(1 + 2 * u16::from(app.inline)) as usize; - app.ensure_visible(); - let start = app.offset.min(app.rows.len()); - let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); - let selected = app - .show_commit - .then(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id)) - .flatten(); - let message_to_load = selected.filter(|id| commit_message.as_ref().map(|(cached, _)| cached) != Some(id)); - if selected.is_none() { - *commit_message = None; +enum PreparedFileDiff { + External(gix::diff::blob::platform::prepare_diff_command::Command, LineCounts), + BuiltIn(BuiltInDiff, LineCounts), +} + +struct CommitDiff { + external: Vec<gix::diff::blob::platform::prepare_diff_command::Command>, + internal: FileDiff, +} + +pub(crate) struct BuiltInDiff { + title: BString, + summary: Option<Vec<Line<'static>>>, + lines: Vec<BString>, + max_width: usize, +} + +impl BuiltInDiff { + fn new(title: BString, lines: Vec<BString>) -> Self { + let max_width = lines + .iter() + .map(|line| Line::from(line.to_str_lossy()).width()) + .max() + .unwrap_or_default(); + BuiltInDiff { + title, + summary: None, + lines, + max_width, + } } - if app.rows[start..end].iter().any(|row| !row.metadata_loaded) || message_to_load.is_some() { - let mut one_shot_repository = None; - let repository = if fill_repository.retain { - match &mut fill_repository.retained { - Some(repository) => repository, - slot @ None => slot.insert(open_fill_repository(fill_repository.path)?), - } - } else { - one_shot_repository.insert(open_fill_repository(fill_repository.path)?) - }; - for index in start..end { - if app.rows[index].metadata_loaded { - continue; + + fn with_summary(mut self, summary: Vec<Line<'static>>) -> Self { + self.max_width = self + .max_width + .max(summary.iter().map(Line::width).max().unwrap_or_default()); + self.summary = Some(summary); + self + } + + fn display_line_count(&self) -> usize { + self.lines.len() + self.summary.as_ref().map_or(0, |summary| summary.len() + 1) + } + + fn write_to(&self, mut out: impl Write) -> io::Result<()> { + if let Some(summary) = &self.summary { + out.write_all(&self.title)?; + out.write_all(b"\n")?; + for line in summary { + for span in &line.spans { + out.write_all(span.content.as_bytes())?; + } + out.write_all(b"\n")?; } - let (metadata, attributions) = history::load_metadata(repository, app.rows[index].id, authors) - .context("could not load visible commit")?; - app.set_metadata(index, metadata, attributions); + out.write_all(b"\n")?; } - if let Some(id) = message_to_load { - *commit_message = Some((id, load_commit_message(repository, id)?)); + for line in &self.lines { + out.write_all(line)?; + out.write_all(b"\n")?; } + Ok(()) } - let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); - terminal.draw(|frame| ui::draw(frame, app, decorations, mailmap, message))?; - Ok(()) -} - -fn open_fill_repository(repository_path: &Path) -> Result<gix::Repository> { - let mut repository = gix::open(repository_path).context("could not open repository for history view")?; - repository.object_cache_size(None); - Ok(repository) } -fn load_commit_message(repository: &gix::Repository, id: gix::ObjectId) -> Result<BString> { - let commit = repository.find_commit(id).context("could not load commit message")?; - Ok(commit.message_raw_sloppy().to_owned()) +/// Options for [`run()`]. +#[derive(Clone, Debug, Default)] +pub struct Options { + /// Draw interactively on the normal screen instead of the alternate screen. + pub no_alt_screen: bool, + /// Exit after the final frame, optionally replaying read-only keyboard input first. + pub quit_on_finish: Option<String>, + /// Revisions whose reachable commits should initially be hidden. + pub hide: Vec<OsString>, } -fn actor_bytes(author: &app::Author) -> Vec<u8> { - let mut out = Vec::with_capacity(author.name.len() + author.email.len() + 3); - out.extend_from_slice(author.name); - out.extend_from_slice(b" <"); - out.extend_from_slice(author.email); - out.push(b'>'); - out +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshKind { + History, + RefTree { enter: bool }, } -fn should_draw(dirty: bool, streaming: bool, since_draw: Duration) -> bool { - dirty && (!streaming || since_draw >= FRAME_INTERVAL) +fn detect_commit_pane_background() -> Option<(u8, u8, u8)> { + let mut options = terminal_colorsaurus::QueryOptions::default(); + options.timeout = THEME_QUERY_TIMEOUT; + match terminal_colorsaurus::background_color(options) { + Ok(background) => { + let color = background.scale_to_8bit(); + let shaded = shade_terminal_background(color, background.perceived_lightness() <= 0.5); + tracing::debug!(?color, ?shaded, "detected terminal background"); + Some(shaded) + } + Err(err) => { + tracing::debug!(error = %err, "terminal background detection unavailable"); + None + } + } } -fn poll_timeout(streaming: bool, events: usize, dirty: bool, since_draw: Duration) -> Option<Duration> { - streaming.then(|| { - if events == EVENT_BATCH_SIZE { - Duration::ZERO - } else if dirty { - FRAME_INTERVAL.saturating_sub(since_draw) +fn shade_terminal_background((red, green, blue): (u8, u8, u8), dark: bool) -> (u8, u8, u8) { + let shade = |channel: u8| { + if dark { + channel + (u8::MAX - channel) / 16 } else { - FRAME_INTERVAL + channel - channel / 16 } - }) + }; + (shade(red), shade(green), shade(blue)) } -fn action(key: KeyEvent) -> Option<Action> { - if key.kind == KeyEventKind::Release - && !matches!( - key.code, - KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift) - ) - { - return None; +/// Run the interactive commit graph for `repository`. +pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec<OsString>, mut options: Options) -> Result<()> { + let _log_guard = logging::init(); + let mut repository_path = repository.git_dir().to_owned(); + let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; + let (hide, unavailable) = validate_hidden_revisions(&mut repository_path, &common_dir, &options.hide)?; + options.hide = hide; + for (revision, err) in unavailable { + eprintln!( + "warning: ignoring unavailable hidden revision {}: {err}", + revision.to_string_lossy() + ); } - match key.code { - KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift) => { - Some(Action::PreviewAuthorCopy(key.kind != KeyEventKind::Release)) - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit), - KeyCode::Char('q') => Some(Action::Quit), - KeyCode::Esc => Some(Action::Cancel), - KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), - KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown), - KeyCode::Char('h') => Some(Action::ScrollLeft), - KeyCode::Char('l') => Some(Action::ScrollRight), - KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::PageUp), - KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::PageDown), - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::HalfPageUp), - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::HalfPageDown), - KeyCode::PageUp => Some(Action::PageUp), - KeyCode::PageDown => Some(Action::PageDown), - KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Last), - KeyCode::Home | KeyCode::Char('g') => Some(Action::First), - KeyCode::End | KeyCode::Char('G') => Some(Action::Last), - KeyCode::Char('d') => Some(Action::ToggleDate), - KeyCode::Char('n') => Some(Action::ToggleName), - KeyCode::Char('t') => Some(Action::ToggleTrailers), - KeyCode::Char('m') => Some(Action::ToggleMailmap), - KeyCode::Char('r') => Some(Action::ToggleRefs), - KeyCode::Char('v') => Some(Action::ToggleHidden), - KeyCode::Char('[') => Some(Action::ToggleAlign), - KeyCode::Char(']' | 'o') => Some(Action::ToggleCommit), - KeyCode::Char('Y') => Some(Action::CopyAuthor), - KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::CopyAuthor), - KeyCode::Char('y') => Some(Action::Copy), - _ => None, + tracing::info!( + revision_count = revisions.len(), + hidden_revision_count = options.hide.len(), + "starting tix" + ); + let commit_pane_background = detect_commit_pane_background(); + let quit_on_finish = options.quit_on_finish.is_some(); + let inline = quit_on_finish || options.no_alt_screen; + let terminal_result = if inline { + let (_, height) = terminal::size().context("could not determine terminal size")?; + ratatui::try_init_with_options(TerminalOptions { + viewport: Viewport::Inline(height), + }) + } else { + ratatui::try_init() + }; + let mut terminal = terminal_result + .inspect_err(|_| { + if inline { + let _ = terminal::disable_raw_mode(); + } else { + ratatui::restore(); + } + }) + .context("could not initialize terminal")?; + let enhanced_keyboard = !quit_on_finish && terminal::supports_keyboard_enhancement().unwrap_or(false); + let keyboard_setup = if quit_on_finish { + Ok(()) + } else { + enable_input(terminal.backend_mut(), enhanced_keyboard) + }; + let result = keyboard_setup + .context("could not enable enhanced keyboard events") + .and_then(|()| { + if !quit_on_finish { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let mut backend = CrosstermBackend::new(std::io::stdout()); + let _ = disable_input(&mut backend, enhanced_keyboard); + let _ = execute!(backend, cursor::Show); + hook(info); + })); + } + event_loop( + &mut terminal, + repository, + revisions, + options, + enhanced_keyboard, + commit_pane_background, + ) + }); + let keyboard_restore = if quit_on_finish { + Ok(()) + } else { + disable_input(terminal.backend_mut(), enhanced_keyboard) + }; + let cursor_restore = if inline { + let area = terminal.get_frame().area(); + terminal + .set_cursor_position(Position::new(area.x, area.bottom().saturating_sub(1))) + .and_then(|()| terminal.show_cursor()) + } else { + Ok(()) + }; + drop(terminal); + let restore = if inline { + terminal::disable_raw_mode() + } else { + ratatui::try_restore() + } + .context("could not restore terminal"); + if inline { + eprintln!(); + } + let lane_time = result?; + keyboard_restore.context("could not restore keyboard events")?; + cursor_restore.context("could not restore terminal cursor")?; + restore?; + if let Some(lane_time) = lane_time { + eprintln!("lane computation: {:.3}s", lane_time.as_secs_f64()); } + Ok(()) } -fn repeats_viewport(action: &Action) -> bool { - matches!( - action, - Action::MoveUp - | Action::MoveDown - | Action::HalfPageUp - | Action::HalfPageDown - | Action::PageUp - | Action::PageDown - | Action::First - | Action::Last - ) +#[expect(clippy::type_complexity, reason = "forward the hidden-revision result unchanged")] +fn validate_hidden_revisions( + repository_path: &mut PathBuf, + common_dir: &Path, + hide: &[OsString], +) -> Result<(Vec<OsString>, Vec<(OsString, String)>)> { + let (repository, _) = open_history_repository(repository_path, common_dir)?; + history::available_hidden_revisions(&repository, hide, false) } -fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>) -> bool { - kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) +fn enable_input(backend: &mut CrosstermBackend<std::io::Stdout>, enhanced_keyboard: bool) -> std::io::Result<()> { + execute!(backend, EnableFocusChange, EnableMouseCapture, EnableBracketedPaste)?; + if enhanced_keyboard { + execute!(backend, PushKeyboardEnhancementFlags(keyboard_enhancement_flags()))?; + } + Ok(()) } -#[cfg(test)] -mod tests { - use super::*; +fn keyboard_enhancement_flags() -> KeyboardEnhancementFlags { + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES +} - #[test] - fn loads_commit_messages_from_an_existing_repository() -> gix_testtools::Result { - let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; - let repository = gix::open(&fixture)?; - let id = repository.rev_parse_single("topic")?.detach(); +fn disable_input(backend: &mut CrosstermBackend<std::io::Stdout>, enhanced_keyboard: bool) -> std::io::Result<()> { + if enhanced_keyboard { + execute!(backend, PopKeyboardEnhancementFlags)?; + } + execute!(backend, DisableBracketedPaste, DisableMouseCapture, DisableFocusChange) +} - assert!( - load_commit_message(&repository, id)?.starts_with(b"topic\n\nCo-authored-by:"), - "on-demand loading retains the full commit message" +fn is_key_press(event: &TerminalEvent) -> bool { + matches!(event, TerminalEvent::Key(key) if key.kind != KeyEventKind::Release) +} + +fn event_loop( + terminal: &mut ratatui::DefaultTerminal, + mut repository: gix::ThreadSafeRepository, + revisions: Vec<OsString>, + options: Options, + enhanced_keyboard: bool, + commit_pane_background: Option<(u8, u8, u8)>, +) -> Result<Option<Duration>> { + let Options { + quit_on_finish, hide, .. + } = options; + let mut quit_inputs: VecDeque<_> = quit_on_finish + .as_deref() + .unwrap_or_default() + .chars() + .map(diagnostic_key) + .collect(); + let quit_on_finish = quit_on_finish.is_some(); + let mut repository_path = repository.git_dir().to_owned(); + let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; + let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; + view_repository.object_cache_size(None); + let (mut repository_is_bare, mut mailmap, mut ref_snapshot, mut worktree_head_unborn) = { + let bare = view_repository.workdir().is_none(); + let mailmap = view_repository.open_mailmap(); + let refs = history::snapshot(&view_repository, &revisions, &hide, false)?; + let unborn = !bare && view_repository.head()?.is_unborn(); + (bare, mailmap, refs, unborn) + }; + if recovered_at_startup { + repository = view_repository.into_sync(); + repository_is_bare = true; + } else { + drop(view_repository); + } + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut watcher_retry_deadline = None; + let mut ref_watcher = match start_ref_watcher(&repository_path, &common_dir) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "reference watcher startup failed"); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + None + } + }; + let mut ref_watch_set_changed = false; + let mut ref_status_config_changed = false; + let (cancelled, receiver) = start_history( + repository, + &revisions, + &hide, + false, + gix::features::threading::OwnShared::clone(&authors), + ); + + let mut app = App::new(1); + app.set_view_tips(&ref_snapshot.view_tips); + app.set_worktree_head_unborn(worktree_head_unborn); + app.set_worktree_branch(current_worktree_branch(&ref_snapshot)); + app.commit_pane_background = commit_pane_background; + if recovered_at_startup { + app.leave_attention("worktree removed; using the common repository without worktree changes"); + } + let mut lane_receiver: Option<mpsc::Receiver<(Vec<SharedCommitRow>, app::Graph, Duration)>> = None; + let mut refresh_receiver: Option<mpsc::Receiver<(RefreshKind, HistoryGraph, Result<history::Refresh>)>> = None; + let mut refresh_pending = false; + let mut ref_tree_refresh_pending = false; + let mut return_to_history_after_refresh = None; + let mut ref_refresh_deadline: Option<Instant> = None; + let mut refresh_expand_hidden = false; + let mut verification_receiver = None; + let mut commit_message = None; + let mut tree_changes = TreeChangesCache::default(); + let mut worktree_changes = None; + let mut cached_status_head = None; + let mut worktree_status_parts = WorktreeStatusParts::default(); + let mut worktree_watcher: Option<WorktreeWatcher> = None; + let mut worktree_refresh_deadline: Option<Instant> = None; + let mut worktree_watch_refresh = WorktreeWatchRefresh::default(); + let mut queued_worktree_status_full = false; + let mut queued_worktree_status_scopes = HashSet::new(); + let mut selection_relation = None; + let mut history_graph = None; + let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); + let mut line_diff_pool = None; + let mut fill_repository = FillRepository { + path: repository_path.clone(), + bare: repository_is_bare, + retained: None, + retain: false, + }; + app.set_worktree_changes_available(!repository_is_bare); + app.configure_hidden_filter(!hide.is_empty()); + sync_line_diff_pool( + &mut line_diff_pool, + app.changes_mode.is_some(), + &repository_path, + repository_is_bare, + line_diff_parallelism, + ); + if worktree_watcher_needed(repository_is_bare, app.changes_mode) { + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher startup failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } + let mut decorations = Decorations::new(); + let mut ref_tree = ref_tree::Tree::default(); + let mut command_picker = Menu::default(); + let mut command_picker_key = None; + let mut filesystem_responses = logging::FilesystemResponses::default(); + let mut focused = true; + draw( + terminal, + &mut app, + &mut command_picker, + &decorations, + &mailmap, + &authors, + &mut fill_repository, + &mut commit_message, + &mut tree_changes, + &mut worktree_changes, + &mut cached_status_head, + &mut worktree_status_parts, + &mut history_graph, + &mut selection_relation, + &mut line_diff_pool, + focused, + &mut ref_tree, + &mut filesystem_responses, + )?; + let mut last_draw = Instant::now(); + let mut dirty = false; + let mut urgent = false; + let mut history_finished = false; + let mut repeat_deadline: Option<Instant> = None; + let mut history_status_deadline: Option<Instant> = None; + let mut pending_terminal_event = None; + let mut pending_rebase_conflict: Option<edit::time_travel::Conflict> = None; + let mut pending_conflict_clear_undo_on_accept = false; + let mut pending_todo_rebase_conflict: Option<edit::rebase::PlanConflict> = None; + let mut pending_todo_rebase_plan: Option<edit::rebase::Plan> = None; + let mut pending_todo_ref_changes = Vec::new(); + let mut pending_conflict_resolution: Option<PendingConflictResolution> = None; + let result: Result<Option<Duration>> = (|| loop { + if let Some(pool) = line_diff_pool.as_mut() { + pool.expire(Instant::now()); + } + if let Some(mut recovered) = + recover_event_loop_repository(&mut repository_path, &common_dir, &mut repository_is_bare)? + { + recovered.object_cache_size(None); + mailmap = recovered.open_mailmap(); + fill_repository.path.clone_from(&repository_path); + fill_repository.bare = true; + fill_repository.retain = false; + fill_repository.retained = None; + app.set_worktree_changes_available(false); + app.set_worktree_branch(None); + worktree_watcher = None; + worktree_refresh_deadline = None; + worktree_watch_refresh = WorktreeWatchRefresh::default(); + queued_worktree_status_full = false; + queued_worktree_status_scopes.clear(); + filesystem_responses.cancel_pending_worktree("worktree-unavailable"); + worktree_changes = None; + cached_status_head = None; + worktree_status_parts = WorktreeStatusParts::default(); + line_diff_pool = None; + sync_line_diff_pool( + &mut line_diff_pool, + app.changes_mode.is_some(), + &repository_path, + true, + line_diff_parallelism, + ); + tracing::warn!(common_dir = %repository_path.display(), "worktree disappeared; recovered with common repository"); + ref_watcher = match start_ref_watcher(&repository_path, &repository_path) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "reference watcher recovery failed"); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + None + } + }; + ref_watch_set_changed = false; + ref_status_config_changed = false; + app.leave_attention("worktree removed; using the common repository without worktree changes"); + if history_graph.is_some() { + refresh_pending = true; + } + dirty = true; + urgent = true; + } + let mut conflict_refresh_due = false; + let mut worktree_watch_error = None; + let mut worktree_events_drained = true; + if let Some(watcher) = worktree_watcher.as_mut() { + let mut received = 0; + let mut relevant = 0; + let mut rescans = 0; + while received < EVENT_BATCH_SIZE { + match watcher.events.try_recv() { + Ok(Ok(event)) => { + received += 1; + rescans += usize::from(event.need_rescan()); + if watcher.event_is_relevant(&event) { + relevant += 1; + worktree_watch_refresh.observe( + &event, + &watcher.workdir, + &watcher.index, + &watcher.directories, + ); + filesystem_responses.observe_worktree(&event, &watcher.workdir, &watcher.index); + if !queued_worktree_status_full { + match worktree_status_event_scopes( + &event, + &watcher.workdir, + &watcher.dot_git, + &watcher.git_dir, + &watcher.index, + ) { + Some(scopes) => queued_worktree_status_scopes.extend(scopes), + None => { + queued_worktree_status_full = true; + queued_worktree_status_scopes.clear(); + } + } + } + schedule_once(&mut worktree_refresh_deadline, Instant::now(), Duration::ZERO); + } + } + Ok(Err(err)) => { + worktree_watch_error = Some(err); + break; + } + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } + if received > 0 { + if relevant > 0 { + filesystem_responses.note_worktree_batch(); + } + tracing::debug!(received, relevant, rescans, "processed worktree event batch"); + } + worktree_events_drained = received < EVENT_BATCH_SIZE; + } + if let Some(err) = worktree_watch_error { + tracing::warn!(error = %err, "worktree watcher failed"); + filesystem_responses.fail_pending_worktree(); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; + worktree_refresh_deadline = None; + worktree_watch_refresh = WorktreeWatchRefresh::default(); + queued_worktree_status_full = false; + queued_worktree_status_scopes.clear(); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + dirty = true; + urgent = true; + } + if worktree_events_drained && take_due(&mut worktree_refresh_deadline, Instant::now()) { + conflict_refresh_due = true; + let watch_refresh = std::mem::take(&mut worktree_watch_refresh); + let watch_result = worktree_watcher + .as_mut() + .filter(|_| !watch_refresh.is_empty()) + .map(|watcher| reconcile_worktree_watcher(watcher, &repository_path, repository_is_bare, watch_refresh)) + .transpose(); + if let Err(err) = watch_result { + tracing::warn!(error = %err, "worktree watcher update failed"); + queued_worktree_status_full = true; + queued_worktree_status_scopes.clear(); + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher rebuild failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } + let invalidated = if std::mem::take(&mut queued_worktree_status_full) { + queued_worktree_status_scopes.clear(); + worktree_status_parts = WorktreeStatusParts::default(); + invalidate_worktree_changes(&mut worktree_changes) + } else { + invalidate_worktree_status_parts( + &mut worktree_changes, + &mut worktree_status_parts, + false, + queued_worktree_status_scopes.drain(), + ) + }; + filesystem_responses.worktree_due(invalidated); + tracing::debug!(invalidated, "worktree event deadline elapsed"); + dirty = true; + urgent = true; + } + let mut ref_watch_error = None; + if let Some(watcher) = ref_watcher.as_mut() { + let mut received = 0; + let mut actionable = 0; + let mut rescans = 0; + while received < EVENT_BATCH_SIZE { + match watcher.events.try_recv() { + Ok(Ok(event)) => { + received += 1; + rescans += usize::from(event.need_rescan()); + if watcher.event_is_relevant(&event) { + actionable += 1; + ref_watch_set_changed |= watcher.watch_set_may_change(&event); + ref_status_config_changed |= reference_event_changes_status_configuration( + &event, + &watcher.git_dir, + &watcher.worktrees_dir, + ); + filesystem_responses.observe_references(&event, &repository_path, &common_dir); + ref_refresh_deadline = Some(Instant::now() + REF_EVENT_IDLE); + } + } + Ok(Err(err)) => { + ref_watch_error = Some(err); + break; + } + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } + if received > 0 { + if actionable > 0 { + filesystem_responses.note_reference_batch(); + } + tracing::debug!(received, actionable, rescans, "processed reference event batch"); + } + } + if let Some(err) = ref_watch_error { + tracing::warn!(error = %err, "reference watcher failed"); + filesystem_responses.fail_pending_references(); + ref_watcher = None; + ref_refresh_deadline = None; + ref_watch_set_changed = false; + ref_status_config_changed = false; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + if take_due(&mut ref_refresh_deadline, Instant::now()) { + conflict_refresh_due = true; + app.clear_enrichments(); + if std::mem::take(&mut ref_watch_set_changed) { + match start_ref_watcher(&repository_path, &common_dir) { + Ok(watcher) => { + ref_watcher = Some(watcher); + } + Err(err) => { + tracing::warn!(error = %err, "reference watcher rebuild failed"); + ref_watcher = None; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } + let response_ids = filesystem_responses.references_due(); + refresh_pending = true; + let status_config_changed = std::mem::take(&mut ref_status_config_changed); + if status_config_changed { + fill_repository.retain = false; + fill_repository.retained = None; + } + let head_changed = status_config_changed + || worktree_changes + .as_ref() + .is_some_and(|(marker, _)| *marker != WORKTREE_STATUS_FULL) + && match cached_status_head.as_ref() { + Some(previous) => open_repository(&repository_path, repository_is_bare, false) + .context("could not reopen repository to compare HEAD after a reference change") + .and_then(|repository| worktree_status_head(&repository)) + .map_or_else( + |err| { + tracing::warn!(error = %err, "could not compare HEAD after a reference change"); + true + }, + |current| current != *previous, + ), + None => true, + }; + let invalidated = if status_config_changed { + worktree_status_parts = WorktreeStatusParts::default(); + invalidate_worktree_changes(&mut worktree_changes) + } else { + head_changed + && invalidate_worktree_status_parts( + &mut worktree_changes, + &mut worktree_status_parts, + true, + std::iter::empty(), + ) + }; + filesystem_responses.phase(&response_ids, "reference-worktree-cache-invalidation"); + if invalidated { + filesystem_responses.queue_frame(&response_ids, "reference-worktree-cache-invalidation"); + dirty = true; + urgent = true; + } + } + if conflict_refresh_due + && reconcile_external_conflict_reporting( + &mut app, + &repository_path, + repository_is_bare, + &mut pending_conflict_resolution, + ) == ConflictReconcileStatus::Complete + { + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + dirty = true; + urgent = true; + } + if take_due(&mut watcher_retry_deadline, Instant::now()) { + let mut retry = false; + if ref_watcher.is_none() { + match start_ref_watcher(&repository_path, &common_dir) { + Ok(watcher) => { + tracing::info!("reference watcher recovered"); + ref_watcher = Some(watcher); + } + Err(err) => { + tracing::warn!(error = %err, "reference watcher retry failed"); + retry = true; + } + } + } + if worktree_watcher_needed(repository_is_bare, app.changes_mode) && worktree_watcher.is_none() { + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => { + tracing::info!("worktree watcher recovered"); + worktree_watcher = Some(watcher); + if app + .worktree_changes + .error + .as_deref() + .is_some_and(|message| message.starts_with("worktree watch:")) + { + app.worktree_changes.error = None; + } + invalidate_worktree_changes(&mut worktree_changes); + dirty = true; + urgent = true; + } + Err(err) => { + tracing::warn!(error = %err, "worktree watcher retry failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + retry = true; + } + } + } + if retry { + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + if take_due(&mut history_status_deadline, Instant::now()) { + app.deferred_history_state = None; + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.queue_frame(&response_ids, "delayed-history-status"); + dirty = true; + urgent = true; + } + if repeat_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + repeat_deadline = None; + if app.changes_suppressed { + app.changes_suppressed = false; + dirty = true; + urgent = true; + } else { + fill_repository.retain = false; + fill_repository.retained = None; + } + } + if let Some(result) = verification_receiver.as_ref().map(mpsc::Receiver::try_recv) { + match result { + Ok(results) => { + app.finish_signature_verification(results); + verification_receiver = None; + dirty = true; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + anyhow::bail!("signature verification worker stopped unexpectedly") + } + } + } + if let Some(result) = lane_receiver.as_ref().map(mpsc::Receiver::try_recv) { + match result { + Ok((rows, graph, lane_time)) => { + let scan = + scan_change_ids(&repository_path, repository_is_bare, change_id_scan_needed(&app), &rows) + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "change ID scan failed"); + change_id::Scan::default() + }); + app.finish_lane_computation(rows, graph, lane_time); + app.set_change_ids(scan.overrides, scan.duplicates); + ref_tree.set_history_commits(app.rows.iter().map(|row| row.id)); + if return_to_history_after_refresh.take().is_some() { + ref_tree.leave(); + } + update_hidden_branch_updates(&mut app, history_graph.as_ref(), &ref_snapshot); + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.phase(&response_ids, "lane-computation-completed"); + filesystem_responses.queue_frame(&response_ids, "lane-computation-completed"); + filesystem_responses.finish_after_frame(&response_ids, "completed"); + history_status_deadline = None; + app.deferred_history_state = None; + selection_relation = None; + app.selection_relation = None; + lane_receiver = None; + dirty = true; + if quit_on_finish { + urgent = true; + } + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + anyhow::bail!("lane worker stopped unexpectedly") + } + } + } + if let Some(result) = refresh_receiver.as_ref().map(mpsc::Receiver::try_recv) { + match result { + Ok((kind, mut graph, result)) => { + let result = result?; + if matches!(kind, RefreshKind::RefTree { .. }) { + graph.set_current_view(&ref_snapshot.view_tips); + } + app.set_known_descendants(graph.commits_with_descendants()); + app.set_known_merge_descendants(graph.commits_with_merge_descendants()); + app.set_worktree_branch( + (!repository_is_bare) + .then(|| current_worktree_branch(&result.refs)) + .flatten(), + ); + ref_tree.rebuild(&graph, &result.refs, &result.decorations); + history_graph = Some(graph); + tracing::info!(commit_count = result.commits.rows.len(), "history refresh completed"); + if let RefreshKind::RefTree { enter } = kind { + let hidden_tips = if app.show_hidden { + &[][..] + } else { + ref_snapshot.hidden_tips.as_slice() + }; + if let Some(rows) = + app.start_refresh(result.commits, &ref_snapshot.view_tips, hidden_tips, false) + { + lane_receiver = Some(start_lane_worker(rows)); + } + if enter { + command_picker.close(); + ref_tree.toggle(); + app.history_display_expanded = false; + } + refresh_receiver = None; + dirty = true; + continue; + } + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.phase(&response_ids, "history-refresh-completed"); + filesystem_responses.queue_frame(&response_ids, "history-refresh-completed"); + let decorated_successor = app + .selected + .and_then(|index| app.rows.get(index)) + .and_then(|row| decoration_successor(row.id, &decorations, &result.decorations)); + app.set_worktree_head( + (!repository_is_bare) + .then(|| decoration_head(&result.decorations)) + .flatten(), + false, + ); + if let Some(successor) = decorated_successor { + app.select_commit_after_refresh(successor); + } + if let Some(id) = return_to_history_after_refresh { + app.select_commit_after_refresh(id); + } + worktree_head_unborn = !repository_is_bare + && open_repository(&repository_path, false, false) + .and_then(|repo| Ok(repo.head()?.is_unborn())) + .unwrap_or(false); + app.set_worktree_head_unborn(worktree_head_unborn); + decorations = result.decorations; + selection_relation = None; + app.selection_relation = None; + let hidden_tips = if app.show_hidden { + &[][..] + } else { + result.refs.hidden_tips.as_slice() + }; + if let Some(rows) = app.start_refresh(result.commits, &result.refs.view_tips, hidden_tips, false) { + lane_receiver = Some(start_lane_worker(rows)); + } + refresh_receiver = None; + dirty = true; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => anyhow::bail!("history refresh worker stopped unexpectedly"), + } + } + if ref_tree_refresh_pending + && refresh_receiver.is_none() + && lane_receiver.is_none() + && history_graph.is_some() + && matches!(app.state, State::Complete | State::Cancelled) + { + ref_tree_refresh_pending = false; + refresh_receiver = Some(start_history_refresh( + repository_path.clone(), + repository_is_bare, + revisions.clone(), + if app.show_hidden { Vec::new() } else { hide.clone() }, + true, + Default::default(), + gix::features::threading::OwnShared::clone(&authors), + history_graph + .take() + .expect("ref-tree refresh starts only with a cached history graph"), + RefreshKind::RefTree { enter: true }, + )); + app.deferred_history_state = Some(app.state); + app.state = State::Loading; + } + if refresh_pending + && refresh_receiver.is_none() + && lane_receiver.is_none() + && history_graph.is_some() + && matches!(app.state, State::Complete | State::Cancelled) + { + let refresh_started = Instant::now(); + let response_ids = filesystem_responses.begin_reference_refresh(); + let repository = match open_repository(&repository_path, repository_is_bare, true) { + Ok(repository) => repository, + Err(_err) if worktree_repository_is_gone(&repository_path) => continue, + Err(err) => return Err(err).context("could not inspect changed references"), + }; + let next = history::snapshot(&repository, &revisions, &hide, false)?; + let hidden_changed = next.hidden != ref_snapshot.hidden; + let worktree_tips_changed = ref_tree.is_active() && next.worktrees != ref_snapshot.worktrees; + let tips_changed = next.view != ref_snapshot.view || hidden_changed || worktree_tips_changed; + tracing::debug!( + ?response_ids, + tips_changed, + hidden_changed, + "compared reference snapshot" + ); + ref_snapshot = next; + app.set_worktree_branch(current_worktree_branch(&ref_snapshot)); + refresh_pending = false; + let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; + let expand = if refresh_expand_hidden || hidden_changed { + app.hidden_ids() + } else { + Default::default() + }; + refresh_receiver = Some(start_history_refresh( + repository_path.clone(), + repository_is_bare, + revisions.clone(), + hidden, + false, + expand, + gix::features::threading::OwnShared::clone(&authors), + history_graph + .take() + .expect("refresh starts only with a cached history graph"), + RefreshKind::History, + )); + refresh_expand_hidden = false; + app.deferred_history_state = Some(app.state); + history_status_deadline = Some(refresh_started + HISTORY_STATUS_DELAY); + app.state = State::Loading; + filesystem_responses.phase(&response_ids, "history-refresh-started"); + tracing::info!(?response_ids, "started history refresh"); + } + if urgent { + draw( + terminal, + &mut app, + &mut command_picker, + &decorations, + &mailmap, + &authors, + &mut fill_repository, + &mut commit_message, + &mut tree_changes, + &mut worktree_changes, + &mut cached_status_head, + &mut worktree_status_parts, + &mut history_graph, + &mut selection_relation, + &mut line_diff_pool, + focused, + &mut ref_tree, + &mut filesystem_responses, + )?; + last_draw = Instant::now(); + dirty = false; + urgent = false; + if repeat_deadline.is_none() { + fill_repository.retain = false; + fill_repository.retained = None; + } + if quit_on_finish + && quit_inputs.is_empty() + && matches!(app.state, State::Complete) + && lane_receiver.is_none() + { + return Ok(app.lane_time); + } + continue; + } + let mut events = 0; + while !history_finished && events < EVENT_BATCH_SIZE { + let message = match receiver.try_recv() { + Ok(message) => message, + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + anyhow::bail!("history worker stopped unexpectedly") + } + }; + events += 1; + dirty = true; + match message? { + Event::Decorations(value) => { + app.set_worktree_head((!repository_is_bare).then(|| decoration_head(&value)).flatten(), true); + decorations = value; + } + Event::Commits(rows) => app.extend_commits(rows), + Event::HiddenCommits(rows) => app.extend_hidden_commits(rows), + Event::VisibleComplete => { + if let Some(rows) = app.start_lane_computation() { + lane_receiver = Some(start_lane_worker(rows)); + } + } + Event::Complete(graph) => { + history_finished = true; + app.set_known_descendants(graph.commits_with_descendants()); + app.set_known_merge_descendants(graph.commits_with_merge_descendants()); + ref_tree.rebuild(&graph, &ref_snapshot, &decorations); + history_graph = Some(graph); + update_hidden_branch_updates(&mut app, history_graph.as_ref(), &ref_snapshot); + selection_relation = None; + app.selection_relation = None; + } + Event::Cancelled => { + history_finished = true; + drop(app.update(Action::Cancelled)); + } + } + } + let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing) + || verification_receiver.is_some() + || repeat_deadline.is_some(); + if should_draw(dirty, streaming, last_draw.elapsed()) { + draw( + terminal, + &mut app, + &mut command_picker, + &decorations, + &mailmap, + &authors, + &mut fill_repository, + &mut commit_message, + &mut tree_changes, + &mut worktree_changes, + &mut cached_status_head, + &mut worktree_status_parts, + &mut history_graph, + &mut selection_relation, + &mut line_diff_pool, + focused, + &mut ref_tree, + &mut filesystem_responses, + )?; + last_draw = Instant::now(); + dirty = false; + } + let repeat_timeout = repeat_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let watcher_timeout = ref_watcher.as_ref().map(|_| REF_EVENT_INTERVAL); + let ref_refresh_timeout = + ref_refresh_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let worktree_timeout = worktree_refresh_deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())) + .or_else(|| worktree_watcher.as_ref().map(|_| REF_EVENT_INTERVAL)); + let retry_timeout = watcher_retry_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let history_status_timeout = + history_status_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let line_diff_timeout = line_diff_pool + .as_ref() + .and_then(|pool| pool.idle_timeout(Instant::now())); + let wake_after = [ + repeat_timeout, + watcher_timeout, + ref_refresh_timeout, + worktree_timeout, + retry_timeout, + history_status_timeout, + line_diff_timeout, + ] + .into_iter() + .flatten() + .min(); + let (terminal_event, diagnostic_input) = match pending_terminal_event.take() { + Some(event) => (Some(event), false), + None => match next_diagnostic_input(&mut quit_inputs, app.state, lane_receiver.is_some()) { + Some(key) => (Some(TerminalEvent::Key(key)), true), + None => ( + match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { + Some(timeout) if event::poll(timeout)? => Some(event::read()?), + Some(_) => None, + None => Some(event::read()?), + }, + false, + ), + }, + }; + let Some(terminal_event) = terminal_event else { + continue; + }; + if swallow_command_menu_key_event(&terminal_event, &mut command_picker_key) { + continue; + } + if ref_tree.is_active() { + if matches!( + &terminal_event, + TerminalEvent::Key(KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) | TerminalEvent::Mouse(_) + ) { + app.dismiss_undo_position(); + } + match &terminal_event { + TerminalEvent::Key(key) if key.kind != KeyEventKind::Release => match ref_tree.handle_key(*key) { + ref_tree::Input::Handled => { + dirty = true; + urgent = true; + continue; + } + ref_tree::Input::PinReferences { id, kinds } => { + let result = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository to pin ref-tree references") + .and_then(|repository| ref_tree::pin_references_reporting(&repository, id, &kinds)); + match result { + Ok((pins, changes)) if !pins.is_empty() => { + app.select_commit_after_refresh(pins[0].id); + return_to_history_after_refresh = Some(pins[0].id); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "pin references", + &changes, + if pins.len() == 1 { + "pinned selected reference".into() + } else { + format!("pinned {} selected references", pins.len()) + }, + ); + refresh_pending = true; + refresh_expand_hidden = true; + } + Ok(_) => { + ref_tree.leave_attention("selected references changed before they could be pinned"); + } + Err(err) => ref_tree.leave_error(format!("pin selected references: {err:#}")), + } + dirty = true; + urgent = true; + continue; + } + ref_tree::Input::ResolveRemoteReferences(names) => { + match open_repository(&repository_path, repository_is_bare, false) { + Ok(repository) => { + ref_tree.set_remote_deletions(ref_tree::resolve_remote_deletions(&repository, names)); + } + Err(err) => ref_tree.leave_error(format!("remote edit: {err:#}")), + } + dirty = true; + urgent = true; + continue; + } + ref_tree::Input::DeleteLocalBranches { names, fallback } => { + let label = { + let names = names + .iter() + .map(|name| name.shorten().to_str_lossy().into_owned()) + .collect::<Vec<_>>(); + if names.len() == 1 { + format!("branch {}", names[0]) + } else { + format!("branches {}", names.join(", ")) + } + }; + let mut deleted = false; + match open_repository(&repository_path, repository_is_bare, false) { + Ok(mut repository) => { + let before = names + .iter() + .map(|name| { + edit::undo::state(&repository, name.as_ref()).map(|state| (name.clone(), state)) + }) + .collect::<Result<Vec<_>>>(); + let result = repository.delete_local_branches(names); + let changed = result.is_ok() + || result.as_ref().is_err_and(|err| { + matches!(err, gix::repository::branch::delete::Error::Cleanup { .. }) + }); + if changed { + let recorded = before + .and_then(|before| { + before + .into_iter() + .map(|(name, before)| { + edit::undo::state(&repository, name.as_ref()) + .map(|after| edit::undo::RefChange { name, before, after }) + }) + .collect::<Result<Vec<_>>>() + }) + .map(|changes| { + changes + .into_iter() + .filter(|change| change.before != change.after) + .collect::<Vec<_>>() + }) + .and_then(|changes| { + edit::undo::record(&repository, "delete local branches", &changes) + .map(|_| ()) + }); + let message = result.as_ref().map_or_else( + |err| format!("delete {label}: {err}"), + |()| format!("deleted {label}"), + ); + match (result, recorded) { + (Ok(()), Ok(())) => ref_tree.leave_success(message), + (_, Ok(())) => ref_tree.leave_attention(message), + (_, Err(err)) => { + ref_tree.leave_attention(format!("{message}; undo history: {err:#}")); + } + } + deleted = true; + refresh_pending = true; + } else if let Err(err) = result { + ref_tree.leave_error(format!("delete {label}: {err}")); + } + } + Err(err) => ref_tree.leave_error(format!("delete {label}: {err:#}")), + } + if deleted { + ref_tree.select_after_reference_deletion(fallback); + } + dirty = true; + urgent = true; + continue; + } + ref_tree::Input::DeleteRemoteReferences { groups, fallback } => { + match with_suspended_terminal(terminal, enhanced_keyboard, || { + Ok(push_remote_deletions(&repository_path, &groups)) + }) { + Ok(outcome) => { + if outcome.deleted != 0 { + ref_tree.select_after_reference_deletion(fallback); + refresh_pending = true; + } + let deleted = format!( + "deleted {} remote reference{}", + outcome.deleted, + if outcome.deleted == 1 { "" } else { "s" } + ); + if outcome.failures.is_empty() { + ref_tree.leave_success(deleted); + } else if outcome.deleted == 0 { + ref_tree.leave_error(format!("delete on remote: {}", outcome.failures.join("; "))); + } else { + ref_tree + .leave_attention(format!("{deleted}; failed: {}", outcome.failures.join("; "))); + } + } + Err(err) => ref_tree.leave_error(format!("delete on remote: {err:#}")), + } + dirty = true; + urgent = true; + continue; + } + ref_tree::Input::Quit => return Ok(None), + }, + TerminalEvent::Mouse(mouse) if ref_tree.handle_mouse(mouse.kind, mouse.modifiers, 1) => { + dirty = true; + urgent = true; + continue; + } + TerminalEvent::Paste(_) => { + ref_tree.leave_attention("commit paste is available only in history"); + dirty = true; + urgent = true; + continue; + } + _ => {} + } + } + if focused + && !diagnostic_input + && opens_command_menu(&terminal_event, app.actions_expanded, command_picker.is_open()) + { + let commands = command_menu::commands(&app, &decorations, app.has_verifiable_signatures()); + command_picker.open(&command_picker_items(&commands)); + app.close_shortcut_groups(); + dirty = true; + urgent = true; + continue; + } + let command_action = if focused && command_picker.is_open() && !diagnostic_input { + let commands = command_menu::commands(&app, &decorations, app.has_verifiable_signatures()); + let input = command_menu_input(&terminal_event, &mut command_picker, &commands); + if !command_picker.is_open() + && let TerminalEvent::Key(key) = &terminal_event + { + command_picker_key = Some(key.code); + } + match input { + CommandMenuInput::Pass => None, + CommandMenuInput::Handled => { + dirty = true; + urgent = true; + continue; + } + CommandMenuInput::Submit(action) => Some(action), + } + } else { + None + }; + let key_pressed = is_key_press(&terminal_event); + let (mut action, repeats_history, is_repeat, throttles_draw) = if let Some(action) = command_action { + (Some(action), false, false, false) + } else { + match terminal_event { + TerminalEvent::Key(key) => { + let action = if diagnostic_input { + diagnostic_action(key, &app) + } else { + app_action(key, &app) + }; + let repeats_history = + retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); + (action, repeats_history, key.kind == KeyEventKind::Repeat, false) + } + TerminalEvent::Mouse(mouse) => { + let kind = mouse.kind; + let modifiers = mouse.modifiers; + let mut distance = 1; + if matches!(kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown) { + while distance < EVENT_BATCH_SIZE && event::poll(Duration::ZERO)? { + let next = event::read()?; + match next { + TerminalEvent::Mouse(next) if next.kind == kind && next.modifiers == modifiers => { + distance += 1; + } + next => { + pending_terminal_event = Some(next); + break; + } + } + } + } + let Some(action) = mouse_scroll_action(kind, modifiers, distance, app.changes_focus.is_some()) + else { + continue; + }; + let repeats_history = app.changes_focus.is_none() && repeats_viewport(&action); + (Some(action), repeats_history, true, true) + } + TerminalEvent::Paste(pasted) => { + let action = (|| { + anyhow::ensure!(!repository_is_bare, "copy-insert requires a worktree"); + let target = app + .paste_insert_target() + .context("copy-insert paste requires an editable history selection")?; + let repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository for pasted commit")?; + let source = resolve_pasted_commit(&repository, &pasted)?; + Ok::<_, anyhow::Error>(Action::PasteInsert { source, target }) + })(); + match action { + Ok(action) => (Some(action), false, false, false), + Err(err) => { + app.leave_attention(format!("paste: {err:#}")); + dirty = true; + urgent = true; + continue; + } + } + } + TerminalEvent::FocusLost => { + focused = false; + app.changes_suppressed = false; + repeat_deadline = None; + dirty = true; + urgent = true; + continue; + } + TerminalEvent::FocusGained => { + focused = true; + if app.unseen_filesystem_redraw { + dirty = true; + urgent = true; + } + continue; + } + TerminalEvent::Resize(_, _) => { + dirty = true; + urgent = true; + continue; + } + } + }; + if !focused { + continue; + } + if diagnostic_input && action.is_none() { + dirty = true; + urgent = true; + continue; + } + if repeats_history || throttles_draw { + repeat_deadline = Some(Instant::now() + REPEAT_IDLE); + } + if repeats_history { + fill_repository.retain = true; + } else if !is_repeat { + fill_repository.retain = false; + fill_repository.retained = None; + } + if repeats_history && app.changes_mode.is_some() { + app.changes_suppressed = true; + } else if !is_repeat && app.changes_suppressed { + app.changes_suppressed = false; + repeat_deadline = None; + dirty = true; + urgent = true; + } + let conflict_reconcile = if action == Some(Action::ForceQuit) { + ConflictReconcileStatus::Inactive + } else { + reconcile_external_conflict_reporting( + &mut app, + &repository_path, + repository_is_bare, + &mut pending_conflict_resolution, + ) + }; + if conflict_reconcile == ConflictReconcileStatus::Complete { + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + dirty = true; + urgent = true; + } + if key_pressed + && action == Some(Action::OpenDiff) + && app.changes_focus.is_none() + && pending_conflict_resolution.is_some() + && conflict_reconcile == ConflictReconcileStatus::Amend + { + action = Some(Action::Amend); + } + if key_pressed + && action == Some(Action::Cancel) + && pending_rebase_conflict.is_none() + && pending_todo_rebase_conflict.is_none() + && app.has_rebase_conflict() + { + let recorded = pending_conflict_resolution.as_mut().and_then(|pending| { + pending.record_undo.then(|| { + record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "materialize time-travel conflict", + &mut pending.ref_changes, + ) + }) + }); + pending_conflict_resolution = None; + app.clear_rebase_conflict(); + if let Some(Err(err)) = recorded { + app.leave_attention(format!("cancelled conflict; undo history: {err:#}")); + } + dirty = true; + urgent = true; + continue; + } + if key_pressed && pending_rebase_conflict.is_some() { + if action == Some(Action::OpenDiff) && app.changes_focus.is_none() { + let clear_undo_on_accept = std::mem::take(&mut pending_conflict_clear_undo_on_accept); + let record_undo = !clear_undo_on_accept; + let conflict = pending_rebase_conflict + .take() + .expect("a pending conflict was checked before accepting it"); + let original = conflict.original(); + match conflict.accept() { + Ok((mut notice, id, _, ref_changes)) => { + if clear_undo_on_accept + && let Err(err) = clear_undo_history(&repository_path, repository_is_bare) + { + notice = format!("{notice}; undo history: {err:#}"); + } + let head = match conflict_head(&repository_path, repository_is_bare, id) { + Ok(head) => Some(head), + Err(err) => { + notice = format!("{notice}; external-amend detection is unavailable: {err:#}"); + None + } + }; + pending_conflict_resolution = Some(PendingConflictResolution { + commit: id, + head, + ref_changes, + record_undo, + }); + tracing::info!(commit_id = %original, rewritten_id = %id, "accepted suspended rebase conflict"); + app.begin_conflict_resolution(); + app.leave_attention(notice); + app.select_commit_after_refresh(id); + } + Err(err) => { + pending_conflict_resolution = None; + tracing::warn!(commit_id = %original, error = %err, "suspended rebase conflict checkout failed"); + app.clear_rebase_conflict(); + app.leave_error(format!("conflict checkout: {err:#}")); + } + } + sync_line_diff_pool( + &mut line_diff_pool, + app.changes_mode.is_some(), + &repository_path, + repository_is_bare, + line_diff_parallelism, + ); + if worktree_watcher.is_none() { + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher startup after conflict failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + dirty = true; + urgent = true; + continue; + } + if action == Some(Action::Cancel) && app.changes_focus.is_none() { + let record_undo = !std::mem::take(&mut pending_conflict_clear_undo_on_accept); + let conflict = pending_rebase_conflict + .take() + .expect("a pending conflict was checked before discarding it"); + tracing::info!(commit_id = %conflict.original(), "discarded suspended rebase conflict"); + let mut changes = conflict.into_ref_changes(); + let recorded = record_undo.then(|| { + record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "time travel before conflict", + &mut changes, + ) + }); + app.clear_rebase_conflict(); + if let Some(Err(err)) = recorded { + app.leave_attention(format!("cancelled conflict; undo history: {err:#}")); + } + dirty = true; + urgent = true; + continue; + } + } + if key_pressed && pending_todo_rebase_conflict.is_some() { + if action == Some(Action::OpenDiff) && app.changes_focus.is_none() { + let conflict = pending_todo_rebase_conflict + .take() + .expect("a pending todo conflict was checked before accepting it"); + let plan = conflict.continuation_plan(); + match edit::time_travel::materialize_plan_conflict_reporting( + conflict, + &repository_path, + repository_is_bare, + &revisions, + false, + ) { + Ok((notice, id, _, mut ref_changes)) => { + pending_todo_ref_changes.append(&mut ref_changes); + pending_todo_rebase_plan = Some(plan); + app.begin_conflict_resolution(); + app.arm_rebase_continuation(); + app.leave_attention(format!("{notice}; resolve the index, then press <enter>")); + app.select_commit_after_refresh(id); + } + Err(err) => { + pending_todo_ref_changes.clear(); + app.clear_rebase_conflict(); + app.leave_error(format!("conflict checkout: {err:#}")); + } + } + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + dirty = true; + urgent = true; + continue; + } + if action == Some(Action::Cancel) && app.changes_focus.is_none() { + let conflict = pending_todo_rebase_conflict + .take() + .expect("a pending todo conflict was checked before discarding it"); + tracing::info!(commit_id = %conflict.original(), "discarded suspended todo rebase conflict"); + let recorded = record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "materialize rebase conflict", + &mut pending_todo_ref_changes, + ); + app.clear_rebase_conflict(); + if let Err(err) = recorded { + app.leave_attention(format!("cancelled rebase conflict; undo history: {err:#}")); + } + refresh_pending = true; + dirty = true; + urgent = true; + continue; + } + } + if (pending_rebase_conflict.is_some() || pending_todo_rebase_conflict.is_some()) + && !action_allowed_during_rebase_continuation(action.as_ref(), app.changes_focus.is_some()) + { + dirty = true; + urgent = true; + continue; + } + if key_pressed + && action == Some(Action::Cancel) + && app.changes_focus.is_none() + && pending_todo_rebase_plan.is_some() + { + drop(pending_todo_rebase_plan.take()); + let recorded = record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "materialize rebase conflict", + &mut pending_todo_ref_changes, + ); + app.clear_rebase_continuation(); + let message = "stopped rebase continuation; the partially applied repository remains unchanged"; + app.leave_attention(match recorded { + Ok(()) => message.into(), + Err(err) => format!("{message}; undo history: {err:#}"), + }); + tracing::info!("stopped materialized rebase continuation without rolling back repository state"); + dirty = true; + urgent = true; + continue; + } + if key_pressed + && action == Some(Action::OpenDiff) + && app.changes_focus.is_none() + && pending_todo_rebase_plan.is_some() + { + let plan = pending_todo_rebase_plan + .take() + .expect("a pending continuation plan was checked before resuming it"); + let result = (|| { + let mut repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not reopen repository to continue the rebase")?; + repository.object_cache_size(None); + stage_resolved_conflict_paths(&repository)?; + let graph = HistoryGraph::for_commits(&repository, &plan.scope)?; + run_rebase_plan(terminal, repository.into_sync(), &graph, plan.clone()) + })(); + match result { + Ok(edit::rebase::PlanPerform::Complete(outcome)) => { + let checkout = if outcome.selected.is_some() { + edit::time_travel::checkout_plan_reporting( + &repository_path, + repository_is_bare, + &outcome, + &revisions, + false, + ) + } else { + Ok((None, outcome.ref_changes.clone())) + }; + app.clear_rebase_conflict(); + app.clear_rebase_continuation(); + app.set_worktree_conflicted(false); + let mut changes = std::mem::take(&mut pending_todo_ref_changes); + let message = match checkout { + Ok((notice, mut outcome_changes)) => { + changes.append(&mut outcome_changes); + notice.unwrap_or_else(|| "rebased history".into()) + } + Err(err) => { + changes.extend(outcome.ref_changes.iter().cloned()); + format!("rebase applied, checkout failed: {err:#}") + } + }; + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "rebase history", + &changes, + message, + ); + refresh_pending = true; + } + Ok(edit::rebase::PlanPerform::Conflict(conflict)) => { + let id = conflict.commit(); + preview_todo_rebase_conflict( + &mut app, + &conflict, + &authors, + &ref_snapshot.view_tips, + &ref_snapshot.hidden_tips, + )?; + app.arm_rebase_conflict(id); + app.select_commit(id); + pending_todo_rebase_conflict = Some(conflict); + } + Err(err) => { + pending_todo_rebase_plan = Some(plan); + app.leave_error(format!("continue rebase: {err:#}")); + } + } + invalidate_worktree_changes(&mut worktree_changes); + dirty = true; + urgent = true; + continue; + } + if pending_todo_rebase_plan.is_some() + && !action_allowed_during_rebase_continuation(action.as_ref(), app.changes_focus.is_some()) + { + dirty = true; + urgent = true; + continue; + } + if pending_conflict_resolution.is_some() + && !(action == Some(Action::Amend) && conflict_reconcile == ConflictReconcileStatus::Amend) + && !action_allowed_during_rebase_continuation(action.as_ref(), app.changes_focus.is_some()) + { + if conflict_reconcile == ConflictReconcileStatus::Amend { + app.leave_attention("resolve the checked-out conflict, then press <enter> to amend"); + } + dirty = true; + urgent = true; + continue; + } + let Some(action) = action else { + continue; + }; + if action == Action::ToggleRefTree { + app.dismiss_undo_position(); + if ref_tree.is_active() { + ref_tree.leave(); + } else { + ref_tree_refresh_pending = !ref_tree_refresh_pending; + } + dirty = true; + urgent = true; + continue; + } + let action = copy_selected_path_action( + action, + &app, + tree_changes.as_ref().map(|(_, changes)| changes), + worktree_changes.as_ref().map(|(_, changes)| changes), + ); + let force_quit = action == Action::ForceQuit; + dirty = true; + urgent |= !throttles_draw; + let previous_changes_mode = app.changes_mode; + let toggles_changes = action == Action::ToggleChanges; + let refreshes_worktree = action == Action::Refresh && app.changes_mode == Some(ChangesMode::Both); + let effects = app.update(action); + if refreshes_worktree { + invalidate_worktree_changes(&mut worktree_changes); + worktree_watch_refresh = WorktreeWatchRefresh::default(); + queued_worktree_status_full = false; + queued_worktree_status_scopes.clear(); + worktree_refresh_deadline = None; + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher refresh failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } + if toggles_changes { + sync_line_diff_pool( + &mut line_diff_pool, + app.changes_mode.is_some(), + &repository_path, + repository_is_bare, + line_diff_parallelism, + ); + if app.changes_mode == Some(ChangesMode::Both) { + invalidate_worktree_changes(&mut worktree_changes); + worktree_watch_refresh = WorktreeWatchRefresh::default(); + queued_worktree_status_full = false; + queued_worktree_status_scopes.clear(); + worktree_refresh_deadline = None; + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => { + worktree_watcher = Some(watcher); + if app + .worktree_changes + .error + .as_deref() + .is_some_and(|message| message.starts_with("worktree watch:")) + { + app.worktree_changes.error = None; + } + } + Err(err) => { + tracing::warn!(error = %err, "worktree watcher startup failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } else if previous_changes_mode == Some(ChangesMode::Both) { + worktree_watcher = None; + worktree_refresh_deadline = None; + worktree_watch_refresh = WorktreeWatchRefresh::default(); + queued_worktree_status_full = false; + queued_worktree_status_scopes.clear(); + filesystem_responses.cancel_pending_worktree("watcher-disabled"); + } + } + for effect in effects { + match effect { + Effect::Cancel => cancelled.store(true, Ordering::Relaxed), + direction @ (Effect::Undo | Effect::Redo) => { + let undoing = matches!(direction, Effect::Undo); + fill_repository.retain = false; + fill_repository.retained = None; + let repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository for undo")?; + match edit::undo::review_blocks_undo(&repository) { + Ok(true) => { + app.dismiss_undo_position(); + app.leave_attention("undo and redo are unavailable during a review"); + continue; + } + Ok(false) => {} + Err(err) => { + app.dismiss_undo_position(); + app.leave_error(format!("undo: {err:#}")); + continue; + } + } + let current = edit::undo::position(&repository); + let planned = if undoing { + edit::undo::plan_undo(&repository) + } else { + edit::undo::plan_redo(&repository) + }; + match planned { + Ok(Some(plan)) => { + let crossed = plan.title.clone(); + let position = plan.position.clone(); + match plan.apply(&repository) { + Ok(()) => { + pending_conflict_resolution = None; + app.show_undo_position( + position.undo, + position.undo + position.redo, + position.title, + ); + if let Ok(id) = repository.head_id() { + app.select_commit_after_refresh(id.detach()); + } + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Err(err) => { + if let Ok(position) = current { + app.show_undo_position( + position.undo, + position.undo + position.redo, + position.title, + ); + } + app.leave_error(format!( + "{} {crossed}: {err:#}", + if undoing { "undo" } else { "redo" } + )); + } + } + } + Ok(None) => { + if let Ok(position) = current { + app.show_undo_position(position.undo, position.undo + position.redo, position.title); + } + app.leave_attention(if undoing { "nothing to undo" } else { "nothing to redo" }); + } + Err(err) => app.leave_error(format!("{}: {err:#}", if undoing { "undo" } else { "redo" })), + } + } + Effect::CopyId(id) => execute!( + terminal.backend_mut(), + CopyToClipboard::to_clipboard_from(id.to_hex().to_string()) + )?, + Effect::CopyPath(path) => execute!(terminal.backend_mut(), CopyToClipboard::to_clipboard_from(path))?, + Effect::CopyAuthor(author) => { + let actor = actor_bytes(author); + execute!(terminal.backend_mut(), CopyToClipboard::to_clipboard_from(actor))?; + } + Effect::Reload(show_hidden) => { + app.show_hidden = show_hidden; + refresh_pending = true; + refresh_expand_hidden = true; + } + Effect::OpenDiff(pane, index) => { + let changes = match pane { + ChangePane::Tree => tree_changes.as_ref().map(|(_, changes)| changes), + ChangePane::Worktree => worktree_changes.as_ref().map(|(_, changes)| changes), + }; + let result = changes + .and_then(|changes| changes.diffs.get(index).zip(changes.paths.get(index))) + .context("selected path no longer has diff resources") + .and_then(|(change, path)| { + prepare_file_diff(&repository_path, repository_is_bare, change, path) + }) + .and_then(|diff| show_file_diff(terminal, diff, enhanced_keyboard)); + match result { + Ok(true) => app.focus_history(), + Err(err) => app.changes_mut(pane).error = Some(format!("{err:#}")), + Ok(false) => {} + } + } + Effect::OpenCommitDiff(target) => { + let title = match target { + app::TreeDiffTarget::Commit { id, .. } => app + .rows + .iter() + .find(|row| row.id == id) + .map(|row| { + ui::commit_diff_title(row, app.title(row), &mailmap, app.use_mailmap, app.show_emails) + }) + .context("selected commit is no longer available")?, + app::TreeDiffTarget::Branch { base, tip } => { + format!("{}..{}", base.to_hex_with_len(7), tip.to_hex_with_len(7)).into() + } + }; + let cached = tree_changes + .as_ref() + .filter(|(cached_target, _)| *cached_target == target) + .map(|(_, changes)| changes); + let result = prepare_commit_diff(&repository_path, repository_is_bare, target, cached, title) + .and_then(|diff| show_commit_diff(terminal, diff, enhanced_keyboard)); + match result { + Ok(true) => app.focus_history(), + Err(err) => app.leave_error(format!("diff: {err:#}")), + Ok(false) => {} + } + } + Effect::Reword(id) => { + let hidden = if app.show_hidden { &[][..] } else { hide.as_slice() }; + let result = reword_commit( + terminal, + &repository_path, + repository_is_bare, + &revisions, + hidden, + id, + enhanced_keyboard, + ); + match result { + Ok(Some(edit::reword::Perform::Complete(edit::reword::Outcome { + target, + commit: Some(new_id), + ref_changes, + .. + }))) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "reword commit", + &ref_changes, + format!( + "reworded {} as {}", + target.to_hex_with_len(7), + new_id.to_hex_with_len(7) + ), + ); + app.select_commit_after_refresh(new_id); + refresh_pending = true; + } + Ok(Some(edit::reword::Perform::Complete(edit::reword::Outcome { + target, + enrichment: Some(enrichment), + ref_changes, + .. + }))) => { + app.clear_enrichments(); + app.set_enrichment(target, enrichment); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "edit commit enrichment", + &ref_changes, + "updated enrichment", + ); + if target != id { + app.select_commit_after_refresh(target); + refresh_pending = true; + } + } + Ok(Some(edit::reword::Perform::Conflict(rebase))) => { + let conflict = edit::time_travel::Conflict::from_rebase( + rebase, + &repository_path, + repository_is_bare, + &revisions, + false, + ); + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = false; + pending_rebase_conflict = Some(conflict); + } + Ok(None) => {} + Ok(Some(edit::reword::Perform::Complete(outcome))) => { + if outcome.target != id { + app.select_commit_after_refresh(outcome.target); + refresh_pending = true; + } + } + Err(err) => app.leave_error(format!("reword: {err:#}")), + } + } + Effect::NewCommit { parent, empty } => { + let result = history_graph + .as_ref() + .context("creating a commit requires a completed history graph") + .and_then(|graph| { + create_commit( + terminal, + &repository_path, + repository_is_bare, + graph, + parent, + if empty { + CreateMode::InsertEmpty + } else { + CreateMode::Insert + }, + enhanced_keyboard, + ) + }); + match result { + Ok(Some(edit::rebase::Perform::Complete(outcome))) => { + let new_id = outcome.selected.context("creating a commit did not select it")?; + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + if empty { "create empty commit" } else { "create commit" }, + &outcome.ref_changes, + format!("created {}", new_id.to_hex_with_len(7)), + ); + app.select_commit_after_refresh(new_id); + refresh_pending = true; + } + Ok(Some(edit::rebase::Perform::Conflict(rebase))) => { + let conflict = edit::time_travel::Conflict::from_rebase( + rebase, + &repository_path, + repository_is_bare, + &revisions, + false, + ); + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = false; + pending_rebase_conflict = Some(conflict); + } + Ok(None) => app.leave_attention("no commit created: no input was provided"), + Err(err) => app.leave_error(format!("new commit: {err:#}")), + } + } + Effect::ForkCommit(parent) => { + fill_repository.retain = false; + fill_repository.retained = None; + let created = history_graph + .as_ref() + .context("creating a fork requires a completed history graph") + .and_then(|graph| { + create_commit( + terminal, + &repository_path, + repository_is_bare, + graph, + Some(parent), + CreateMode::Fork, + enhanced_keyboard, + ) + }); + match created { + Ok(Some(edit::rebase::Perform::Complete(outcome))) => { + let new_id = outcome.selected.context("creating a fork did not select it")?; + let ref_changes = outcome.ref_changes; + let review_roots: Vec<_> = + app.rows.iter().filter(|row| row.is_review).map(|row| row.id).collect(); + let travel = open_repository(&repository_path, repository_is_bare, false) + .context("could not reopen repository before travelling to fork") + .and_then(|repository| edit::loaded_graph(&repository)) + .and_then(|graph| { + edit::time_travel::perform( + &repository_path, + repository_is_bare, + new_id, + &graph, + &review_roots, + &revisions, + false, + ) + }); + match travel { + Ok(edit::time_travel::Perform::Complete { + notice, + selected, + ref_changes: mut travel_changes, + .. + }) => { + let mut changes = ref_changes; + changes.append(&mut travel_changes); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "fork commit", + &changes, + notice.map_or_else( + || format!("created fork {}", new_id.to_hex_with_len(7)), + |notice| format!("created fork {}; {notice}", new_id.to_hex_with_len(7)), + ), + ); + app.select_commit_after_refresh(selected); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(edit::time_travel::Perform::Conflict(mut conflict)) => { + conflict.prepend_ref_changes(ref_changes); + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = false; + pending_rebase_conflict = Some(conflict); + } + Err(err) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "fork commit", + &ref_changes, + format!( + "created fork {}, but checkout failed: {err:#}", + new_id.to_hex_with_len(7) + ), + ); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + } + } + Ok(Some(edit::rebase::Perform::Conflict(rebase))) => { + let conflict = edit::time_travel::Conflict::from_rebase( + rebase, + &repository_path, + repository_is_bare, + &revisions, + false, + ); + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = false; + pending_rebase_conflict = Some(conflict); + } + Ok(None) => app.leave_attention("no fork created: no input was provided"), + Err(err) => app.leave_error(format!("fork: {err:#}")), + } + } + Effect::Split(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = history_graph + .as_ref() + .context("splitting HEAD requires a completed history graph") + .and_then(|graph| { + split_commit(terminal, &repository_path, repository_is_bare, graph, enhanced_keyboard) + }); + match result { + Ok(Some(outcome)) => { + let new_id = outcome.selected.context("splitting HEAD did not select its result")?; + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "split commit", + &outcome.ref_changes, + format!("split {} as {}", id.to_hex_with_len(7), new_id.to_hex_with_len(7)), + ); + invalidate_worktree_changes(&mut worktree_changes); + app.select_commit_after_refresh(new_id); + refresh_pending = true; + } + Ok(None) => app.leave_attention("no split performed: no input was provided"), + Err(err) => app.leave_error(format!("split: {err:#}")), + } + } + edit @ (Effect::Amend(id) | Effect::Spill(id)) => { + fill_repository.retain = false; + fill_repository.retained = None; + let kind = if matches!(edit, Effect::Amend(_)) { + edit::head::Kind::Amend + } else { + edit::head::Kind::Spill + }; + let verb = if kind == edit::head::Kind::Amend { + "amend" + } else { + "spill" + }; + let path = match (kind, app.changes_focus) { + (edit::head::Kind::Spill, Some(ChangePane::Tree)) => Some( + tree_changes + .as_ref() + .filter(|(target, _)| target.selected() == id) + .and_then(|(_, changes)| { + changes + .paths + .get(app.tree_changes.selected) + .cloned() + .map(|path| (path, changes.parent.map(|parent| parent.id))) + }) + .context("selected tree path is no longer available"), + ), + (edit::head::Kind::Amend, Some(ChangePane::Worktree)) => Some( + worktree_changes + .as_ref() + .and_then(|(_, changes)| changes.paths.get(app.worktree_changes.selected)) + .cloned() + .map(|path| (path, None)) + .context("selected worktree path is no longer available"), + ), + _ => None, + } + .transpose(); + let resolving_conflict = pending_conflict_resolution.is_some(); + let result = history_graph + .as_ref() + .context("editing HEAD requires a completed history graph") + .and_then(|graph| { + path.and_then(|path| { + run_with_todo_progress(terminal, |report| { + let repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository for HEAD edit")?; + if kind == edit::head::Kind::Amend && resolving_conflict { + stage_resolved_conflict_paths(&repository)?; + } + edit::head::perform_with_changes( + repository, + graph, + kind, + path.as_ref() + .map(|(path, parent)| (std::slice::from_ref(path), *parent)), + resolving_conflict, + report, + ) + }) + }) + }); + match result { + Ok(Some(outcome)) => { + let new_id = outcome.selected.context("editing HEAD did not select its result")?; + let pending = if kind == edit::head::Kind::Amend { + pending_conflict_resolution.take() + } else { + None + }; + let resolved_conflict = pending.is_some(); + let record_undo = pending.as_ref().is_none_or(|pending| pending.record_undo); + let mut changes = pending.map(|pending| pending.ref_changes).unwrap_or_default(); + changes.extend(outcome.ref_changes.iter().cloned()); + let message = + format!("{verb}ed {} as {}", id.to_hex_with_len(7), new_id.to_hex_with_len(7)); + if record_undo { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + if resolved_conflict { + "resolve rebase conflict" + } else { + verb + }, + &changes, + message, + ); + } else { + app.leave_success(message); + } + invalidate_worktree_changes(&mut worktree_changes); + app.select_commit_after_refresh(new_id); + refresh_pending = true; + } + Ok(None) => app.leave_attention(format!("nothing to {verb}")), + Err(err) => app.leave_error(format!("{verb}: {err:#}")), + } + } + Effect::Stash(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + match edit::stash::save_manual(&repository_path, repository_is_bare, id) { + Ok(notice) => { + app.leave_success(notice); + app.select_commit_after_refresh(id); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Err(err) => app.leave_error(format!("stash: {err:#}")), + } + } + Effect::Unstash(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + match edit::stash::restore_manual(&repository_path, repository_is_bare, id) { + Ok(notice) => { + app.leave_success(notice); + app.select_commit_after_refresh(id); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Err(err) => app.leave_error(format!("unstash: {err:#}")), + } + } + Effect::Forget(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + let cancels_review = app.rows.iter().any(|row| row.id == id && row.is_review); + if cancels_review { + app.dismiss_undo_position(); + } + let result = history_graph + .as_ref() + .context("forget requires a completed history graph") + .and_then(|graph| { + if cancels_review { + clear_undo_history(&repository_path, repository_is_bare) + .context("could not clear undo history before cancelling review")?; + } + forget_commit(terminal, &repository_path, repository_is_bare, graph, id) + }); + match result { + Ok(edit::forget::Perform::Complete(outcome)) => { + let ref_changes = outcome.ref_changes.clone(); + let returned = outcome.review_return.as_ref().map(|name| { + edit::time_travel::checkout_review_return_reporting( + &repository_path, + repository_is_bare, + name, + &revisions, + false, + ) + }); + match returned.transpose() { + Ok(Some((selected, _, mut checkout_changes))) => { + let mut changes = ref_changes; + changes.append(&mut checkout_changes); + if cancels_review { + app.leave_success("cancelled review"); + } else { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "cancel review", + &changes, + "cancelled review", + ); + } + app.select_commit_after_refresh(selected); + } + Err(err) => { + let message = format!("review cancelled, return checkout failed: {err:#}"); + if cancels_review { + app.leave_attention(message); + } else { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "forget commit", + &ref_changes, + message, + ); + } + } + Ok(None) => { + let message = format!("forgot {}", id.to_hex_with_len(7)); + if cancels_review { + app.leave_success(message); + } else { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "forget commit", + &ref_changes, + message, + ); + } + if let Some(selected) = outcome.selected { + app.select_commit(selected); + } + } + } + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(edit::forget::Perform::Conflict(conflict)) => { + let conflict = edit::time_travel::Conflict::from_rebase( + conflict.into_rebase(), + &repository_path, + repository_is_bare, + &revisions, + false, + ); + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = false; + pending_rebase_conflict = Some(conflict); + } + Err(err) => app.leave_error(format!("forget: {err:#}")), + } + } + Effect::Rebase { base, onto, commits } => { + fill_repository.retain = false; + fill_repository.retained = None; + let todo_commits = (|| { + let mut repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository before formatting the rebase todo")?; + repository.object_cache_size(None); + load_rebase_todo_commits(&repository, &mut app, &authors, &commits) + })(); + let result = history_graph + .as_ref() + .context("rebasing requires a completed history graph") + .and_then(|graph| { + rebase_history( + terminal, + &repository_path, + repository_is_bare, + graph, + base, + onto, + todo_commits?, + enhanced_keyboard, + ) + }); + match result { + Ok(Some(edit::rebase::PlanPerform::Complete(outcome))) => { + let notice = if outcome.selected.is_some() { + edit::time_travel::checkout_plan_reporting( + &repository_path, + repository_is_bare, + &outcome, + &revisions, + false, + ) + } else { + Ok((None, outcome.ref_changes.clone())) + }; + match notice { + Ok((notice, changes)) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "rebase history", + &changes, + notice.unwrap_or_else(|| "rebased history".to_owned()), + ); + app.select_commit_after_refresh(base); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Err(err) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "rebase history", + &outcome.ref_changes, + format!("rebase applied, checkout failed: {err:#}"), + ); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + } + } + Ok(Some(edit::rebase::PlanPerform::Conflict(conflict))) => { + let id = conflict.commit(); + preview_todo_rebase_conflict( + &mut app, + &conflict, + &authors, + &ref_snapshot.view_tips, + &ref_snapshot.hidden_tips, + )?; + app.arm_rebase_conflict(id); + app.select_commit(id); + pending_todo_rebase_conflict = Some(conflict); + } + Ok(None) => app.leave_attention("no rebase performed: the todo was unchanged"), + Err(err) => app.leave_error(format!("rebase: {err:#}")), + } + } + Effect::Squash { source, target } => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = (|| { + let graph = history_graph + .as_ref() + .context("squashing requires a completed history graph")?; + let mut repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository to squash commits")?; + repository.object_cache_size(None); + let plan = edit::rebase::squash_plan(&repository, graph, source, target)?; + run_rebase_plan(terminal, repository.into_sync(), graph, plan) + })(); + match result { + Ok(edit::rebase::PlanPerform::Complete(outcome)) => { + let combined = outcome.map(target).unwrap_or(target); + let notice = if outcome.selected.is_some() { + edit::time_travel::checkout_plan_reporting( + &repository_path, + repository_is_bare, + &outcome, + &revisions, + false, + ) + } else { + Ok((None, outcome.ref_changes.clone())) + }; + let (message, changes) = notice.map_or_else( + |err| { + ( + format!("squash applied, checkout failed: {err:#}"), + outcome.ref_changes.clone(), + ) + }, + |(notice, changes)| { + ( + notice.unwrap_or_else(|| { + format!( + "squashed {} into {}", + source.to_hex_with_len(7), + combined.to_hex_with_len(7) + ) + }), + changes, + ) + }, + ); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "squash commits", + &changes, + message, + ); + app.select_commit_after_refresh(combined); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(edit::rebase::PlanPerform::Conflict(conflict)) => { + let id = conflict.commit(); + preview_todo_rebase_conflict( + &mut app, + &conflict, + &authors, + &ref_snapshot.view_tips, + &ref_snapshot.hidden_tips, + )?; + app.arm_rebase_conflict(id); + app.select_commit(id); + pending_todo_rebase_conflict = Some(conflict); + } + Err(err) => app.leave_error(format!("squash: {err:#}")), + } + } + effect @ (Effect::Insert { .. } | Effect::PasteInsert { .. }) => { + let (source, base, target, copy, pasted) = match effect { + Effect::Insert { + source, + base, + target, + copy, + } => (source, base, target, copy, false), + Effect::PasteInsert { source, target } => (source, source, target, true, true), + _ => unreachable!("the match arm accepts only insertion effects"), + }; + fill_repository.retain = false; + fill_repository.retained = None; + let result = (|| { + let mut repository = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository to insert commits")?; + repository.object_cache_size(None); + let loaded_graph; + let graph = if pasted { + let graph_revisions = [ + OsString::from("HEAD"), + OsString::from(source.to_string()), + OsString::from(target.to_string()), + ]; + loaded_graph = edit::loaded_view_graph_with(&repository, &graph_revisions)?; + &loaded_graph + } else { + history_graph + .as_ref() + .context("inserting commits requires a completed history graph")? + }; + let plan = if copy { + edit::rebase::copy_insert_plan(&repository, graph, source, target)? + } else if base == source { + edit::rebase::move_insert_plan(&repository, graph, source, target)? + } else { + edit::rebase::stack_insert_plan(&repository, graph, base, source, target)? + }; + run_rebase_plan(terminal, repository.into_sync(), graph, plan) + })(); + match result { + Ok(edit::rebase::PlanPerform::Complete(outcome)) => { + let inserted = if copy { + outcome.selected.expect("copy-insert selects the copied commit") + } else { + outcome.map(source).unwrap_or(source) + }; + let notice = edit::time_travel::checkout_plan_reporting( + &repository_path, + repository_is_bare, + &outcome, + &revisions, + false, + ); + let (message, changes) = notice.map_or_else( + |err| { + ( + format!("insert applied, checkout failed: {err:#}"), + outcome.ref_changes.clone(), + ) + }, + |(notice, changes)| { + ( + notice.unwrap_or_else(|| { + if copy { + format!( + "copied {} as {} above {}", + source.to_hex_with_len(7), + inserted.to_hex_with_len(7), + target.to_hex_with_len(7) + ) + } else { + format!( + "inserted {} above {}", + inserted.to_hex_with_len(7), + target.to_hex_with_len(7) + ) + } + }), + changes, + ) + }, + ); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + if copy { + "copy-insert commit" + } else if base == source { + "move-insert commit" + } else { + "stack-insert commits" + }, + &changes, + message, + ); + app.select_commit_after_refresh(inserted); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(edit::rebase::PlanPerform::Conflict(conflict)) => { + let id = conflict.commit(); + preview_todo_rebase_conflict( + &mut app, + &conflict, + &authors, + &ref_snapshot.view_tips, + &ref_snapshot.hidden_tips, + )?; + app.arm_rebase_conflict(id); + app.select_commit(id); + pending_todo_rebase_conflict = Some(conflict); + } + Err(err) => app.leave_error(format!("insert: {err:#}")), + } + } + Effect::StartReview { tip, base } => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = history_graph + .as_ref() + .context("review requires a completed history graph") + .and_then(|graph| edit::review::start(&repository_path, repository_is_bare, graph, tip, base)); + match result { + Ok(started) => { + app.dismiss_undo_position(); + let message = format!( + "started review {} at {}", + started.reference.shorten(), + started.commit.to_hex_with_len(7) + ); + match clear_undo_history(&repository_path, repository_is_bare) { + Ok(()) => app.leave_success(message), + Err(err) => app.leave_attention(format!("{message}; undo history: {err:#}")), + } + app.select_commit_after_refresh(started.commit); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Err(err) => app.leave_error(format!("review: {err:#}")), + } + } + Effect::FinishReview { review: id, return_to } => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = history_graph + .as_ref() + .context("finishing review requires a completed history graph") + .and_then(|graph| { + run_with_todo_progress(terminal, |report| { + let mut repo = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository to finish review")?; + repo.object_cache_size(None); + edit::review::finish_with_progress(repo, graph, id, return_to, report) + }) + }); + match result { + Ok(edit::review::Finish::Complete(finished)) => { + let undo_cleared = clear_undo_history(&repository_path, repository_is_bare); + let checkout = edit::time_travel::checkout_plan_reporting( + &repository_path, + repository_is_bare, + &finished.outcome, + &revisions, + false, + ); + let mut message = checkout.map_or_else( + |err| format!("review applied, return checkout failed: {err:#}"), + |(_, _changes)| format!("finished review as {}", finished.commit.to_hex_with_len(7)), + ); + if let Err(err) = undo_cleared { + message = format!("{message}; undo history: {err:#}"); + } + app.dismiss_undo_position(); + app.leave_success(message); + app.select_commit_after_refresh(finished.outcome.selected.unwrap_or(finished.commit)); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(edit::review::Finish::SelectReturn { tip }) => { + if !app.select_review_return(id, tip) { + app.leave_error( + "finish review: no visible return commit descends from the reviewed tip", + ); + } + } + Ok(edit::review::Finish::Conflict(rebase)) => { + let conflict = edit::time_travel::Conflict::from_rebase( + rebase, + &repository_path, + repository_is_bare, + &revisions, + false, + ); + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = true; + pending_rebase_conflict = Some(conflict); + } + Err(err) => app.leave_error(format!("finish review: {err:#}")), + } + } + Effect::Attach => { + fill_repository.retain = false; + fill_repository.retained = None; + match edit::time_travel::attach_reporting(&repository_path, repository_is_bare, &revisions, false) { + Ok((notice, changes)) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "attach HEAD", + &changes, + notice, + ); + refresh_pending = true; + } + Err(err) => app.leave_error(format!("attach: {err:#}")), + } + } + Effect::TimeTravel(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + let review_roots: Vec<_> = app.rows.iter().filter(|row| row.is_review).map(|row| row.id).collect(); + app.begin_time_travel_animation(); + let result = history_graph + .as_ref() + .context("time-travel requires a completed history graph") + .and_then(|graph| { + let repository = open_fill_repository(&repository_path, repository_is_bare) + .context("could not open repository for time-travel animation")?; + run_with_rebase_selection( + |report| { + edit::time_travel::perform_reporting_rebased( + &repository_path, + repository_is_bare, + id, + graph, + &review_roots, + &revisions, + false, + report, + ) + }, + |id| { + app.select_commit_for_time_travel(id); + let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize; + load_visible_history_metadata(&repository, &mut app, &authors, render_rows)?; + let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); + let tree = tree_changes.as_ref().map(|(_, changes)| changes); + let worktree = worktree_changes + .as_ref() + .filter(|(marker, _)| *marker == WORKTREE_STATUS_CURRENT) + .map(|(_, changes)| changes); + terminal + .draw(|frame| { + ui::draw_with_worktree( + frame, + &mut app, + &decorations, + &mailmap, + message, + tree, + worktree, + ); + }) + .context("could not draw time-travel animation")?; + Ok(()) + }, + ) + }); + app.finish_time_travel_animation(); + match result { + Ok(edit::time_travel::Perform::Complete { + notice: Some(notice), + selected, + ref_changes, + .. + }) => { + tracing::info!(selected = %id, %notice, "completed time-travel action"); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "time travel", + &ref_changes, + notice, + ); + app.select_commit_after_refresh(selected); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(edit::time_travel::Perform::Complete { + notice: None, + selected, + ref_changes, + .. + }) => { + if !ref_changes.is_empty() { + app.select_commit_after_refresh(selected); + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "time travel", + &ref_changes, + "time-travelled", + ); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + } + Ok(edit::time_travel::Perform::Conflict(conflict)) => { + let original = conflict.original(); + app.arm_rebase_conflict(original); + app.select_commit(original); + pending_conflict_clear_undo_on_accept = false; + pending_rebase_conflict = Some(conflict); + } + Err(err) => { + app.leave_error(format!("time-travel: {err:#}")); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + } + } + Effect::TogglePin(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + match edit::time_travel::toggle_pin_reporting(&repository_path, repository_is_bare, id) { + Ok((edit::time_travel::PinToggle::Removed(count), changes)) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "unpin commit", + &changes, + format!("removed {count} pin{}", if count == 1 { "" } else { "s" }), + ); + app.select_commit_after_refresh(id); + refresh_pending = true; + } + Ok((edit::time_travel::PinToggle::Created, changes)) => { + leave_recorded_success( + &mut app, + &repository_path, + repository_is_bare, + "pin commit", + &changes, + "pinned selected commit", + ); + app.select_commit_after_refresh(id); + refresh_pending = true; + } + Err(err) => app.leave_error(format!("toggle pin: {err:#}")), + } + } + Effect::ToggleTodo(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository to update the enrichment") + .and_then(|repo| tracked_ref_update(&repo, enrich::REF_NAME, |repo| enrich::toggle(repo, id))); + match result { + Ok((enrichment, changes)) => { + let enabled = enrichment.todo; + app.clear_enrichments(); + app.set_enrichment(id, enrichment); + leave_tracked_success( + &mut app, + &repository_path, + repository_is_bare, + "toggle todo", + changes, + if enabled { + "marked commit todo" + } else { + "cleared commit todo" + }, + ); + } + Err(err) => app.leave_error(format!("todo: {err:#}")), + } + } + Effect::ToggleChecksPass(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository to update the tree enrichment") + .and_then(|repo| { + tracked_ref_update(&repo, enrich::TREE_REF_NAME, |repo| { + enrich::toggle_checks_pass(repo, id) + }) + }); + match result { + Ok((enrichment, changes)) => { + let enabled = enrichment.checks_pass; + app.clear_enrichments(); + app.set_tree_enrichment(id, enrichment); + leave_tracked_success( + &mut app, + &repository_path, + repository_is_bare, + "toggle checks-pass", + changes, + if enabled { + "marked tree checks-pass" + } else { + "cleared tree checks-pass" + }, + ); + } + Err(err) => app.leave_error(format!("checks-pass: {err:#}")), + } + } + Effect::EditNote(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + match edit_note(terminal, &repository_path, repository_is_bare, id, enhanced_keyboard) { + Ok(Some((enrichment, changes))) => { + let has_note = enrichment.note.is_some(); + app.clear_enrichments(); + app.set_enrichment(id, enrichment); + leave_tracked_success( + &mut app, + &repository_path, + repository_is_bare, + "edit commit note", + changes, + if has_note { "saved note" } else { "cleared note" }, + ); + } + Ok(None) => {} + Err(err) => app.leave_error(format!("note: {err:#}")), + } + } + Effect::EditGitNote(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + match edit_git_note(terminal, &repository_path, repository_is_bare, id, enhanced_keyboard) { + Ok(Some((saved, changes))) => { + app.clear_notes(id); + leave_tracked_success( + &mut app, + &repository_path, + repository_is_bare, + "edit Git note", + changes, + if saved { "saved Git note" } else { "cleared Git note" }, + ); + } + Ok(None) => {} + Err(err) => app.leave_error(format!("Git note: {err:#}")), + } + } + Effect::VerifySignatures(ids) => { + verification_receiver = Some(start_signature_verification( + repository_path.clone(), + repository_is_bare, + ids, + )); + } + Effect::Quit if force_quit => return Ok(None), + Effect::Quit => { + if let Some(conflict) = pending_rebase_conflict.take() { + let mut changes = conflict.into_ref_changes(); + if !std::mem::take(&mut pending_conflict_clear_undo_on_accept) + && let Err(err) = record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "time travel before conflict", + &mut changes, + ) + { + tracing::warn!(error = %err, "could not record suspended conflict before exit"); + } + } + if let Some(mut pending) = pending_conflict_resolution.take() + && pending.record_undo + && let Err(err) = record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "materialize time-travel conflict", + &mut pending.ref_changes, + ) + { + tracing::warn!(error = %err, "could not record materialized conflict before exit"); + } + let todo_pending = + pending_todo_rebase_conflict.take().is_some() || pending_todo_rebase_plan.take().is_some(); + if todo_pending + && let Err(err) = record_and_clear_pending_undo( + &repository_path, + repository_is_bare, + "materialize rebase conflict", + &mut pending_todo_ref_changes, + ) + { + tracing::warn!(error = %err, "could not record materialized rebase before exit"); + } + return Ok(None); + } + } + } + })(); + result +} + +fn start_lane_worker(rows: Vec<SharedCommitRow>) -> mpsc::Receiver<(Vec<SharedCommitRow>, app::Graph, Duration)> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(app::compute_lanes(rows)); + }); + receiver +} + +fn scan_change_ids( + repository_path: &Path, + bare: bool, + enabled: bool, + rows: &[SharedCommitRow], +) -> Result<change_id::Scan> { + if !enabled { + return Ok(change_id::Scan::default()); + } + let mut repository = open_repository(repository_path, bare, false)?; + repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); + change_id::scan(&repository, &rows.iter().map(|row| row.id).collect::<Vec<_>>()) +} + +fn change_id_scan_needed(app: &App) -> bool { + app.has_hidden_filter && !app.show_hidden +} + +type SignatureVerification = (gix::ObjectId, bool); + +fn start_signature_verification( + repository_path: PathBuf, + bare: bool, + ids: Vec<gix::ObjectId>, +) -> mpsc::Receiver<Vec<SignatureVerification>> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let results = match open_repository(&repository_path, bare, false) { + Ok(mut repository) => { + repository.object_cache_size(None); + ids.into_iter() + .map(|id| { + let result = repository + .find_commit(id) + .context("could not read signed commit") + .and_then(|commit| { + commit + .verify_signature() + .context("could not verify commit signature") + .and_then(|outcome| outcome.context("commit no longer has a signature")) + }); + match result { + Ok(outcome) if outcome.is_valid() => (id, true), + Ok(_) | Err(_) => (id, false), + } + }) + .collect() + } + Err(_) => ids.into_iter().map(|id| (id, false)).collect(), + }; + let _ = sender.send(results); + }); + receiver +} + +fn start_history( + repository: gix::ThreadSafeRepository, + revisions: &[OsString], + hidden_revisions: &[OsString], + include_worktrees: bool, + authors: SharedAuthors, +) -> (Arc<AtomicBool>, mpsc::Receiver<Result<Event>>) { + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let (sender, receiver) = mpsc::channel(); + let revisions = revisions.to_vec(); + let hidden_revisions = hidden_revisions.to_vec(); + std::thread::spawn(move || { + let mut repository = repository.to_thread_local(); + repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); + let result = history::load( + &repository, + &revisions, + &hidden_revisions, + include_worktrees, + &authors, + &worker_cancelled, + |event| sender.send(Ok(event)).is_ok(), + ); + if let Err(err) = result { + let _ = sender.send(Err(err)); + } + }); + (cancelled, receiver) +} + +#[expect( + clippy::too_many_arguments, + reason = "the worker owns each independent refresh input" +)] +fn start_history_refresh( + repository_path: PathBuf, + bare: bool, + revisions: Vec<OsString>, + hidden_revisions: Vec<OsString>, + include_worktrees: bool, + expand: std::collections::HashSet<gix::ObjectId>, + authors: SharedAuthors, + mut graph: HistoryGraph, + kind: RefreshKind, +) -> mpsc::Receiver<(RefreshKind, HistoryGraph, Result<history::Refresh>)> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let result = open_repository(&repository_path, bare, true) + .context("could not reopen repository for history refresh") + .and_then(|mut repository| { + repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); + graph.refresh( + &repository, + &revisions, + &hidden_revisions, + include_worktrees, + &expand, + &authors, + ) + }); + let _ = sender.send((kind, graph, result)); + }); + receiver +} + +fn start_ref_watcher(git_dir: &Path, common_dir: &Path) -> Result<RefWatcher> { + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .context("could not initialize reference watcher")?; + let worktrees_dir = common_dir.join("worktrees"); + let linked_git_dir_is_covered = worktrees_dir.is_dir() && git_dir.starts_with(&worktrees_dir); + let mut roots = vec![(common_dir.to_owned(), RecursiveMode::NonRecursive)]; + if git_dir != common_dir && !linked_git_dir_is_covered { + roots.push((git_dir.to_owned(), RecursiveMode::NonRecursive)); + } + for root in [common_dir.join("refs"), git_dir.join("refs")] { + if root.is_dir() + && !(linked_git_dir_is_covered && root.starts_with(&worktrees_dir)) + && !roots.iter().any(|(path, _)| path == &root) + { + roots.push((root, RecursiveMode::Recursive)); + } + } + if worktrees_dir.is_dir() { + roots.push((worktrees_dir.clone(), RecursiveMode::Recursive)); + } + for (path, mode) in &roots { + watcher + .watch(path, *mode) + .with_context(|| format!("could not watch references at {}", path.display()))?; + } + tracing::info!(?roots, "watching references"); + Ok(RefWatcher { + _watcher: watcher, + events, + git_dir: git_dir.to_owned(), + worktrees_dir, + }) +} + +fn start_worktree_watcher(repository_path: &Path, bare: bool) -> Result<WorktreeWatcher> { + let started = Instant::now(); + let repository = open_repository(repository_path, bare, false) + .context("could not open repository for worktree watcher setup")?; + let workdir = repository + .workdir() + .context("cannot watch a bare repository")? + .to_owned(); + let index_path = repository.index_path(); + let git_dir = repository.git_dir().to_owned(); + let dot_git = workdir.join(gix::discover::DOT_GIT_DIR); + let dirwalk_started = Instant::now(); + let index = repository + .index_or_empty() + .context("could not open index for worktree watcher")?; + let mut directories = worktree_watch_directories_with_index(&repository, &index)?; + let index_projection = index_watch_projection(&index); + let dirwalk_ms = dirwalk_started.elapsed().as_millis(); + let registration_started = Instant::now(); + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .context("could not initialize worktree watcher")?; + let index_parent = index_path.parent().context("index path has no parent")?; + directories.insert(index_parent.to_owned()); + { + let mut paths = watcher.paths_mut(); + for directory in &directories { + paths + .add(directory, RecursiveMode::NonRecursive) + .with_context(|| format!("could not watch worktree directory at {}", directory.display()))?; + } + paths.commit().context("could not apply worktree watches")?; + } + tracing::info!( + workdir = %workdir.display(), + index = %index_path.display(), + directories = directories.len(), + dirwalk_ms, + registration_ms = registration_started.elapsed().as_millis(), + setup_ms = started.elapsed().as_millis(), + "watching worktree changes" + ); + Ok(WorktreeWatcher { + watcher, + events, + directories, + index_projection, + workdir, + dot_git, + git_dir, + index: index_path, + }) +} + +fn worktree_status_head(repository: &gix::Repository) -> Result<WorktreeStatusHead> { + let mut head = repository.head().context("could not read HEAD")?; + let reference = head.referent_name().map(ToOwned::to_owned); + let target = head + .try_peel_to_id() + .context("could not peel HEAD")? + .map(gix::Id::detach); + Ok(WorktreeStatusHead { reference, target }) +} + +fn remember_worktree_status_head( + cached: &mut Option<WorktreeStatusHead>, + refreshes_staged: bool, + scanned: Result<WorktreeStatusHead>, +) { + if refreshes_staged { + *cached = match scanned { + Ok(head) => Some(head), + Err(err) => { + tracing::warn!(error = %err, "could not remember HEAD for worktree status"); + None + } + }; + } +} + +#[cfg(test)] +fn worktree_watch_directories(repository: &gix::Repository) -> Result<HashSet<PathBuf>> { + let index = repository + .index_or_empty() + .context("could not open index for worktree watcher")?; + worktree_watch_directories_with_index(repository, &index) +} + +fn worktree_watch_directories_with_index( + repository: &gix::Repository, + index: &gix::index::State, +) -> Result<HashSet<PathBuf>> { + let root = repository + .workdir() + .context("cannot walk a bare repository")? + .to_owned(); + let options = repository + .dirwalk_options() + .context("could not configure worktree directory walk")?; + let mut directories = WorktreeDirectories { + root: root.clone(), + paths: HashSet::from([root]), + }; + repository + .dirwalk(index, None::<&str>, &AtomicBool::default(), options, &mut directories) + .context("could not enumerate worktree directories")?; + Ok(directories.paths) +} + +fn index_watch_projection(index: &gix::index::State) -> Vec<IndexWatchEntry> { + let mut out: Vec<_> = index + .entries() + .iter() + .map(|entry| IndexWatchEntry { + path: entry.path(index).to_owned(), + mode: entry.mode.bits(), + flags: entry.flags.bits(), + }) + .collect(); + out.sort_unstable(); + out +} + +fn changed_index_watch_scopes( + before: &[IndexWatchEntry], + after: &[IndexWatchEntry], + workdir: &Path, +) -> HashSet<PathBuf> { + fn add_scope(entry: &IndexWatchEntry, workdir: &Path, out: &mut HashSet<PathBuf>) { + let path = entry.path.as_bstr(); + let scope = path + .find_byte(b'/') + .map(|pos| &path[..pos]) + .or_else(|| (entry.mode == gix::index::entry::Mode::DIR.bits()).then_some(path.as_ref())); + if let Some(scope) = scope { + out.insert(workdir.join(gix::path::from_bstr(scope))); + } + } + + let mut out = HashSet::new(); + let (mut left, mut right) = (0, 0); + while left < before.len() || right < after.len() { + match (before.get(left), after.get(right)) { + (Some(a), Some(b)) => match a.cmp(b) { + std::cmp::Ordering::Less => { + add_scope(a, workdir, &mut out); + left += 1; + } + std::cmp::Ordering::Greater => { + add_scope(b, workdir, &mut out); + right += 1; + } + std::cmp::Ordering::Equal => { + left += 1; + right += 1; + } + }, + (Some(a), None) => { + add_scope(a, workdir, &mut out); + left += 1; + } + (None, Some(b)) => { + add_scope(b, workdir, &mut out); + right += 1; + } + (None, None) => break, + } + } + out +} + +fn minimize_worktree_scopes(scopes: HashSet<PathBuf>) -> Vec<PathBuf> { + let mut scopes: Vec<_> = scopes.into_iter().collect(); + scopes.sort_by(|a, b| { + a.components() + .count() + .cmp(&b.components().count()) + .then_with(|| a.cmp(b)) + }); + let mut out: Vec<PathBuf> = Vec::new(); + for scope in scopes { + if !out.iter().any(|parent| scope.starts_with(parent)) { + out.push(scope); + } + } + out +} + +fn reconcile_worktree_watcher( + watcher: &mut WorktreeWatcher, + repository_path: &Path, + bare: bool, + mut refresh: WorktreeWatchRefresh, +) -> Result<(usize, usize)> { + let repository = open_repository(repository_path, bare, false) + .context("could not reopen repository to update worktree watches")?; + let index = repository + .index_or_empty() + .context("could not open index to update worktree watches")?; + let next_projection = index_watch_projection(&index); + let update_projection = refresh.index || refresh.full; + if refresh.index { + refresh.scopes.extend(changed_index_watch_scopes( + &watcher.index_projection, + &next_projection, + &watcher.workdir, + )); + } + if !refresh.full && refresh.scopes.is_empty() { + if update_projection { + watcher.index_projection = next_projection; + } + return Ok((0, 0)); + } + + let all_desired = worktree_watch_directories_with_index(&repository, &index)?; + let scopes = minimize_worktree_scopes(refresh.scopes); + let mut desired = if refresh.full { + all_desired + } else { + watcher + .directories + .iter() + .filter(|path| !scopes.iter().any(|scope| path.starts_with(scope))) + .cloned() + .chain( + all_desired + .into_iter() + .filter(|path| scopes.iter().any(|scope| path.starts_with(scope))), + ) + .collect() + }; + desired.insert(watcher.index.parent().context("index path has no parent")?.to_owned()); + let changed = update_worktree_watch_paths(watcher, desired)?; + if update_projection { + watcher.index_projection = next_projection; + } + Ok(changed) +} + +fn update_worktree_watch_paths(watcher: &mut WorktreeWatcher, desired: HashSet<PathBuf>) -> Result<(usize, usize)> { + let mut remove: Vec<_> = watcher.directories.difference(&desired).cloned().collect(); + let mut add: Vec<_> = desired.difference(&watcher.directories).cloned().collect(); + if remove.is_empty() && add.is_empty() { + return Ok((0, 0)); + } + remove.sort_by(|a, b| { + b.components() + .count() + .cmp(&a.components().count()) + .then_with(|| a.cmp(b)) + }); + add.sort_by(|a, b| { + a.components() + .count() + .cmp(&b.components().count()) + .then_with(|| a.cmp(b)) + }); + let removed = remove.len(); + let added = add.len(); + let mut first_error = None; + let mut paths = watcher.watcher.paths_mut(); + for path in remove { + if let Err(err) = paths.remove(&path) + && !matches!(&err.kind, notify::ErrorKind::WatchNotFound) + && first_error.is_none() + { + first_error = Some(err); + } + } + for path in add { + if let Err(err) = paths.add(&path, RecursiveMode::NonRecursive) + && first_error.is_none() + { + first_error = Some(err); + } + } + if let Err(err) = paths.commit() + && first_error.is_none() + { + first_error = Some(err); + } + if let Some(err) = first_error { + return Err(err).context("could not update worktree watches"); + } + tracing::debug!(removed, added, "updated worktree watches"); + watcher.directories = desired; + Ok((removed, added)) +} + +fn invalidate_worktree_changes(changes: &mut Option<(usize, Changes)>) -> bool { + if let Some((marker, _)) = changes { + if *marker == WORKTREE_STATUS_FULL { + return false; + } + *marker = WORKTREE_STATUS_FULL; + return true; + } + false +} + +fn invalidate_worktree_status_parts( + changes: &mut Option<(usize, Changes)>, + parts: &mut WorktreeStatusParts, + staged: bool, + scopes: impl IntoIterator<Item = BString>, +) -> bool { + let Some((marker, _)) = changes else { + return false; + }; + if *marker == WORKTREE_STATUS_FULL { + return false; + } + parts.staged |= staged; + parts.scopes.extend(scopes); + *marker = WORKTREE_STATUS_PARTIAL; + true +} + +fn leave_recorded_success( + app: &mut App, + repository_path: &Path, + bare: bool, + title: &str, + changes: &[edit::undo::RefChange], + message: impl Into<String>, +) { + let message = message.into(); + match record_undo(repository_path, bare, title, changes) { + Ok(()) => app.leave_success(message), + Err(err) => app.leave_attention(format!("{message}; undo history: {err:#}")), + } +} + +fn record_undo(repository_path: &Path, bare: bool, title: &str, changes: &[edit::undo::RefChange]) -> Result<()> { + open_repository(repository_path, bare, false) + .context("could not reopen repository for undo history") + .and_then(|repo| edit::undo::record(&repo, title, changes).map(|_| ())) +} + +fn clear_undo_history(repository_path: &Path, bare: bool) -> Result<()> { + open_repository(repository_path, bare, false) + .context("could not reopen repository to clear undo history") + .and_then(|repo| edit::undo::clear(&repo)) +} + +fn record_and_clear_pending_undo( + repository_path: &Path, + bare: bool, + title: &str, + changes: &mut Vec<edit::undo::RefChange>, +) -> Result<()> { + let result = record_undo(repository_path, bare, title, changes); + changes.clear(); + result +} + +fn conflict_head(repository_path: &Path, bare: bool, commit: gix::ObjectId) -> Result<ConflictHead> { + let repository = open_repository(repository_path, bare, false) + .context("could not reopen the repository after checking out a conflict")?; + let head = repository.head().context("could not inspect the conflicted HEAD")?; + let id = head + .id() + .map(gix::Id::detach) + .context("the conflicted HEAD is unborn")?; + anyhow::ensure!(id == commit, "the conflict checkout did not leave HEAD at {commit}"); + let reference = head.referent_name().map(ToOwned::to_owned); + drop(head); + let name = reference + .clone() + .unwrap_or_else(|| "HEAD".try_into().expect("valid reference name")); + anyhow::ensure!( + edit::undo::state(&repository, name.as_ref())? == edit::undo::State::Object(commit), + "the conflicted HEAD attachment does not directly reference {commit}" + ); + let parents = repository + .find_commit(commit) + .context("could not find the checked-out conflict commit")? + .parent_ids() + .map(gix::Id::detach) + .collect(); + Ok(ConflictHead { reference, parents }) +} + +fn reconcile_external_conflict( + repository_path: &Path, + bare: bool, + pending: &mut Option<PendingConflictResolution>, +) -> Result<ExternalConflictResolution> { + let state = pending + .as_ref() + .context("external conflict reconciliation requires pending state")?; + let Some(expected) = state.head.as_ref() else { + return Ok(ExternalConflictResolution::Current); + }; + let repository = + open_repository(repository_path, bare, false).context("could not inspect external conflict resolution")?; + let head = repository.head().context("could not inspect HEAD after the conflict")?; + let reference = head.referent_name().map(ToOwned::to_owned); + anyhow::ensure!( + reference == expected.reference, + "HEAD attachment changed while resolving the conflict; return to the conflict checkout or exit" + ); + let replacement = head + .id() + .map(gix::Id::detach) + .context("HEAD became unborn while resolving the conflict")?; + drop(head); + if replacement == state.commit { + return Ok(ExternalConflictResolution::Current); + } + + let replacement_commit = repository + .find_commit(replacement) + .context("the replacement HEAD is not a commit")? + .decode() + .context("could not decode the replacement HEAD commit")? + .into_owned() + .context("could not own the replacement HEAD commit")?; + anyhow::ensure!( + replacement_commit.parents.as_slice() == expected.parents, + "HEAD moved to an unrelated commit while resolving the conflict; return to the conflict checkout or exit" + ); + let index = repository + .open_index() + .context("could not inspect the conflict index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + return Ok(ExternalConflictResolution::Changed); + } + if edit::create::index_tree(&repository, &index)? != replacement_commit.tree { + return Ok(ExternalConflictResolution::Changed); + } + + let name = expected + .reference + .clone() + .unwrap_or_else(|| "HEAD".try_into().expect("valid reference name")); + anyhow::ensure!( + edit::undo::state(&repository, name.as_ref())? == edit::undo::State::Object(replacement), + "HEAD no longer directly references its replacement commit" + ); + let finalized = if edit::rebase::is_pending(&replacement_commit) { + drop(index); + let graph = edit::loaded_graph(&repository).context("could not load history to finalize the external amend")?; + let outcome = edit::head::amend_index_reporting(repository, &graph) + .context("could not finalize the externally amended pending commit")? + .context("the externally amended pending commit was not finalized")?; + let selected = outcome + .selected + .context("finalizing the externally amended conflict did not select its result")?; + Some((selected, outcome.ref_changes)) + } else { + None + }; + let accepted = state.commit; + let mut state = pending + .take() + .expect("the pending conflict was inspected immediately before completion"); + state.ref_changes.push(edit::undo::RefChange { + name, + before: edit::undo::State::Object(accepted), + after: edit::undo::State::Object(replacement), + }); + let replacement = match finalized { + Some((selected, changes)) => { + state.ref_changes.extend(changes); + selected + } + None => replacement, + }; + Ok(ExternalConflictResolution::Complete( + replacement, + state.ref_changes, + state.record_undo, + )) +} + +fn reconcile_external_conflict_reporting( + app: &mut App, + repository_path: &Path, + bare: bool, + pending: &mut Option<PendingConflictResolution>, +) -> ConflictReconcileStatus { + if pending.is_none() { + return ConflictReconcileStatus::Inactive; + } + match reconcile_external_conflict(repository_path, bare, pending) { + Ok(ExternalConflictResolution::Complete(replacement, changes, record_undo)) => { + let message = format!("resolved rebase conflict as {}", replacement.to_hex_with_len(7)); + if record_undo { + leave_recorded_success(app, repository_path, bare, "resolve rebase conflict", &changes, message); + } else { + app.leave_success(message); + } + app.clear_rebase_conflict(); + app.set_worktree_conflicted(false); + app.select_commit_after_refresh(replacement); + tracing::info!(commit_id = %replacement, "recognized externally amended conflict resolution"); + ConflictReconcileStatus::Complete + } + Ok(ExternalConflictResolution::Current) => ConflictReconcileStatus::Amend, + Ok(ExternalConflictResolution::Changed) => { + app.leave_attention( + "external conflict resolution is incomplete; finish it, return to the conflict checkout, or press q", + ); + ConflictReconcileStatus::Blocked + } + Err(err) => { + app.leave_attention(format!("conflict resolution remains pending: {err:#}")); + ConflictReconcileStatus::Blocked + } + } +} + +fn tracked_ref_update<T>( + repo: &gix::Repository, + name: &str, + update: impl FnOnce(&gix::Repository) -> Result<T>, +) -> Result<(T, Result<Vec<edit::undo::RefChange>>)> { + let name: gix::refs::FullName = name.try_into().context("tracked reference name is invalid")?; + let before = edit::undo::state(repo, name.as_ref()); + let value = update(repo)?; + let changes = before.and_then(|before| { + edit::undo::state(repo, name.as_ref()).map(|after| { + (before != after) + .then_some(edit::undo::RefChange { name, before, after }) + .into_iter() + .collect() + }) + }); + Ok((value, changes)) +} + +fn leave_tracked_success( + app: &mut App, + repository_path: &Path, + bare: bool, + title: &str, + changes: Result<Vec<edit::undo::RefChange>>, + message: impl Into<String>, +) { + let message = message.into(); + match changes { + Ok(changes) => leave_recorded_success(app, repository_path, bare, title, &changes, message), + Err(err) => app.leave_attention(format!("{message}; undo history: {err:#}")), + } +} + +fn remembered_change_selection(view: &app::ChangesView, changes: Option<&Changes>) -> Option<(BString, usize)> { + changes.and_then(|changes| { + changes + .paths + .get(view.selected) + .map(|change| (change.path.clone(), view.selected.saturating_sub(view.offset))) + }) +} + +fn decoration_head(decorations: &Decorations) -> Option<gix::ObjectId> { + decorations.iter().find_map(|(id, decorations)| { + decorations + .iter() + .any(|decoration| decoration.kind == history::DecorationKind::Head) + .then_some(*id) + }) +} + +fn current_worktree_branch(refs: &history::RefSnapshot) -> Option<(gix::ObjectId, bool)> { + refs.worktrees + .iter() + .find(|worktree| { + worktree.is_current + && worktree + .reference + .as_ref() + .is_some_and(|reference| reference.as_bstr().starts_with(b"refs/heads/")) + }) + .map(|worktree| (worktree.label_id, worktree.is_detached)) +} + +fn decoration_successor(selected: gix::ObjectId, current: &Decorations, next: &Decorations) -> Option<gix::ObjectId> { + let selected = current.get(&selected)?; + let mut matches = next.iter().filter_map(|(id, decorations)| { + decorations + .iter() + .any(|decoration| selected.contains(decoration)) + .then_some(*id) + }); + let successor = matches.next()?; + matches.all(|candidate| candidate == successor).then_some(successor) +} + +fn update_hidden_branch_updates(app: &mut App, graph: Option<&HistoryGraph>, refs: &history::RefSnapshot) { + let updates = graph.map_or_else(HashMap::new, |graph| { + graph.hidden_branch_updates( + &refs.view_tips, + refs.hidden + .iter() + .filter(|(name, _)| name.starts_with(b"refs/heads/")) + .filter_map(|(_, target)| target.try_id().map(ToOwned::to_owned)), + ) + }); + app.set_hidden_branch_updates(updates); +} + +fn restore_change_selection(view: &mut app::ChangesView, changes: &Changes, remembered: Option<(BString, usize)>) { + let Some((path, viewport_row)) = remembered else { + return; + }; + if let Some(selected) = changes.paths.iter().position(|change| change.path == path) { + view.selected = selected; + view.offset = selected.saturating_sub(viewport_row); + } +} + +fn load_visible_history_metadata( + repository: &gix::Repository, + app: &mut App, + authors: &SharedAuthors, + render_rows: usize, +) -> Result<()> { + let start = app.offset.min(app.history_len()); + let end = start.saturating_add(render_rows).min(app.history_len()); + for index in app.visible_history_indices(start..end) { + if app.rows[index].metadata_loaded { + continue; + } + let (metadata, attributions) = + history::load_metadata(repository, app.rows[index].id, authors).context("could not load visible commit")?; + app.set_metadata(index, metadata, attributions); + } + Ok(()) +} + +#[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] +fn draw( + terminal: &mut ratatui::DefaultTerminal, + app: &mut App, + command_picker: &mut Menu<CommandId>, + decorations: &Decorations, + mailmap: &gix::mailmap::Snapshot, + authors: &SharedAuthors, + fill_repository: &mut FillRepository, + commit_message: &mut Option<(gix::ObjectId, BString)>, + tree_changes: &mut TreeChangesCache, + worktree_changes: &mut Option<(usize, Changes)>, + status_head: &mut Option<WorktreeStatusHead>, + status_parts: &mut WorktreeStatusParts, + history_graph: &mut Option<HistoryGraph>, + selection_cache: &mut Option<SelectionRelationCache>, + line_diff_pool: &mut Option<LineDiffPool>, + focused: bool, + ref_tree: &mut ref_tree::Tree, + filesystem_responses: &mut logging::FilesystemResponses, +) -> Result<()> { + let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize; + if !history_is_ready_to_draw(app.state, app.rows.len()) { + return Ok(()); + } + if ref_tree.is_active() { + terminal + .autoresize() + .context("could not resize the terminal before drawing")?; + { + let mut frame = terminal.get_frame(); + ref_tree.draw(&mut frame, history_graph.as_ref()); + } + terminal + .apply_buffer_with_cursor(None) + .context("could not draw ref-tree overview")?; + filesystem_responses.frame_presented(); + return Ok(()); + } + app.unseen_filesystem_redraw = unseen_filesystem_redraw( + app.unseen_filesystem_redraw, + focused, + filesystem_responses.has_queued_frame(), + ); + if let Some((_, changes)) = worktree_changes.as_ref() { + app.set_worktree_conflicted(changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged)); + } + app.viewport_rows = app.viewport_rows.min(render_rows.max(1)); + app.prepare_history_viewport(); + let start = app.offset.min(app.history_len()); + let end = start.saturating_add(render_rows).min(app.history_len()); + let visible_indices = app.visible_history_indices(start..end); + let repository_fill_allowed = !app.has_rebase_conflict(); + let notes_to_load: Vec<_> = visible_indices + .iter() + .map(|index| app.rows[*index].id) + .filter(|id| repository_fill_allowed && !app.notes_loaded(*id)) + .collect(); + let enrichments_to_load: Vec<_> = visible_indices + .iter() + .map(|index| app.rows[*index].id) + .filter(|id| repository_fill_allowed && !app.enrichment_loaded(*id)) + .collect(); + let tree_enrichments_to_load: Vec<_> = visible_indices + .iter() + .map(|index| app.rows[*index].id) + .filter(|id| repository_fill_allowed && !app.tree_enrichment_loaded(*id)) + .collect(); + let changes_visible = app.changes_visible(); + let selected_id = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); + app.selection_relation = selection_cache + .as_ref() + .filter(|cached| Some(cached.id) == selected_id) + .and_then(|cached| cached.relation); + let relation_to_load = matches!(app.state, State::Complete | State::Cancelled) + .then_some(selected_id) + .flatten() + .filter(|id| selection_cache.as_ref().is_none_or(|cached| cached.id != *id)); + let selected = (app.show_commit || app.changes_mode.is_some()) + .then_some(selected_id) + .flatten(); + let message_to_load = app + .show_commit + .then_some(selected) + .flatten() + .filter(|_| repository_fill_allowed) + .filter(|id| commit_message.as_ref().map(|(cached, _)| cached) != Some(id)); + if message_to_load.is_some() { + app.reset_commit_view(); + } + if changes_visible && selected.is_some() && tree_changes.as_ref().map(|(target, _)| target.selected()) != selected { + app.changes_parent = 0; + } + let desired_tree_changes = (changes_visible && app.changes_mode.is_some()) + .then(|| app.selected_tree_diff_target()) + .flatten(); + let tree_changes_changed = + desired_tree_changes.is_some_and(|target| tree_changes.as_ref().is_none_or(|(cached, _)| *cached != target)); + let tree_selection = tree_changes_changed + .then(|| remembered_change_selection(&app.tree_changes, tree_changes.as_ref().map(|(_, changes)| changes))) + .flatten(); + let tree_changes_to_load = desired_tree_changes.filter(|target| !tree_changes.activate(*target)); + if tree_changes_changed + && tree_changes_to_load.is_none() + && let Some(changes) = tree_changes.as_ref().map(|(_, changes)| changes) + { + restore_change_selection(&mut app.tree_changes, changes, tree_selection.clone()); + } + let worktree_changes_to_load = changes_visible + && app.changes_mode == Some(ChangesMode::Both) + && worktree_changes + .as_ref() + .is_none_or(|(marker, _)| *marker != WORKTREE_STATUS_CURRENT); + let worktree_selection = worktree_changes_to_load + .then(|| { + remembered_change_selection( + &app.worktree_changes, + worktree_changes.as_ref().map(|(_, changes)| changes), + ) + }) + .flatten(); + if !app.show_commit + || selected.is_none() + || (!repository_fill_allowed && commit_message.as_ref().map(|(id, _)| *id) != selected) + { + *commit_message = None; + } + if app.changes_mode.is_none() { + tree_changes.clear(); + *worktree_changes = None; + *status_head = None; + *status_parts = WorktreeStatusParts::default(); + } + if let Some(id) = relation_to_load + && let Some(graph) = history_graph + { + let refs = graph.selection_refs(id, decorations); + let hidden: Vec<_> = app.hidden_ids().into_iter().collect(); + let relation = graph.selection_relation(id, &refs, &hidden); + *selection_cache = Some(SelectionRelationCache { id, refs, relation }); + app.selection_relation = relation; + } + if !notes_to_load.is_empty() + || !enrichments_to_load.is_empty() + || !tree_enrichments_to_load.is_empty() + || visible_indices.iter().any(|index| !app.rows[*index].metadata_loaded) + || message_to_load.is_some() + || tree_changes_to_load.is_some() + || worktree_changes_to_load + { + let mut one_shot_repository = None; + let repository = if fill_repository.retain { + match &mut fill_repository.retained { + Some(repository) => repository, + slot @ None => slot.insert(open_fill_repository(&fill_repository.path, fill_repository.bare)?), + } + } else { + one_shot_repository.insert(open_fill_repository(&fill_repository.path, fill_repository.bare)?) + }; + if !notes_to_load.is_empty() { + let mut notes = repository.notes().context("could not open Git notes")?; + for id in notes_to_load { + let loaded = notes + .get(id) + .context("could not load visible commit notes")? + .into_iter() + .map(|note| { + let mut blob = note.blob; + BString::from(blob.take_data()) + }) + .collect(); + app.set_notes(id, loaded); + } + } + if !enrichments_to_load.is_empty() { + let mut notes = enrich::open(repository)?; + for id in enrichments_to_load { + let loaded = crate::change_id::for_commit(repository, id) + .and_then(|change_id| enrich::load(&mut notes, change_id)); + match loaded { + Ok(enrichment) => app.set_enrichment(id, enrichment), + Err(err) => { + tracing::warn!(commit_id = %id, error = %err, "ignored malformed tix enrichment"); + app.set_enrichment(id, enrich::Enrichment::default()); + } + } + } + } + if !tree_enrichments_to_load.is_empty() { + let mut notes = enrich::open_tree(repository)?; + for id in tree_enrichments_to_load { + let loaded = enrich::tree_id(repository, id).and_then(|tree_id| enrich::load_tree(&mut notes, tree_id)); + match loaded { + Ok(enrichment) => app.set_tree_enrichment(id, enrichment), + Err(err) => { + tracing::warn!(commit_id = %id, error = %err, "ignored malformed tix tree enrichment"); + app.set_tree_enrichment(id, enrich::TreeEnrichment::default()); + } + } + } + } + if let Some(id) = message_to_load { + *commit_message = Some((id, load_commit_message(repository, id)?)); + } + if let Some(target) = tree_changes_to_load { + repository.object_cache_size(OBJECT_CACHE_SIZE); + let loaded = load_changes( + repository, + target, + line_diff_pool + .as_mut() + .context("line diff pool is missing while the changes pane is visible")?, + ); + repository.object_cache_size(None); + let loaded = loaded?; + app.changes_parent = loaded.parent.map_or(0, |parent| parent.index); + restore_change_selection(&mut app.tree_changes, &loaded, tree_selection); + tree_changes.insert((target, loaded)); + } + if worktree_changes_to_load { + let started = Instant::now(); + repository.object_cache_size(OBJECT_CACHE_SIZE); + let line_diff_pool = line_diff_pool + .as_mut() + .context("line diff pool is missing while the changes pane is visible")?; + let partial = worktree_changes + .as_ref() + .is_some_and(|(marker, _)| *marker == WORKTREE_STATUS_PARTIAL); + let refreshes_staged = !partial || status_parts.staged; + let status_head_before = worktree_status_head(repository); + let loaded = if partial { + let mut updated = worktree_changes + .as_ref() + .map(|(_, changes)| changes.clone()) + .expect("partial status requires cached changes"); + update_worktree_changes(repository, &mut updated, status_parts, line_diff_pool) + .map(|full| (updated, refreshes_staged || full)) + } else { + load_worktree_changes(repository, line_diff_pool).map(|loaded| (loaded, true)) + }; + repository.object_cache_size(None); + *status_parts = WorktreeStatusParts::default(); + match loaded { + Ok((loaded, refreshes_staged)) => { + tracing::debug!( + partial, + path_count = loaded.paths.len(), + elapsed_ms = started.elapsed().as_millis(), + "loaded worktree changes" + ); + if !app + .worktree_changes + .error + .as_deref() + .is_some_and(|message| message.starts_with("worktree watch:")) + { + app.worktree_changes.error = None; + } + app.set_worktree_conflicted(loaded.paths.iter().any(|change| change.kind == ChangeKind::Unmerged)); + restore_change_selection(&mut app.worktree_changes, &loaded, worktree_selection); + *worktree_changes = Some((WORKTREE_STATUS_CURRENT, loaded)); + remember_worktree_status_head(status_head, refreshes_staged, status_head_before); + } + Err(err) => { + tracing::warn!(error = %err, "could not load worktree changes"); + app.worktree_changes.error = Some(format!("status: {err:#}")); + if let Some((marker, _)) = worktree_changes.as_mut() { + *marker = WORKTREE_STATUS_CURRENT; + } else { + *worktree_changes = Some((WORKTREE_STATUS_CURRENT, Changes::default())); + } + } + } + } + load_visible_history_metadata(repository, app, authors, render_rows)?; + } + let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); + let tree_changes = tree_changes.as_ref().map(|(_, changes)| changes); + let worktree_changes = worktree_changes + .as_ref() + .filter(|(marker, _)| *marker == WORKTREE_STATUS_CURRENT) + .map(|(_, changes)| changes); + terminal + .autoresize() + .context("could not resize the terminal before drawing")?; + let cursor = { + let mut frame = terminal.get_frame(); + ui::draw_with_worktree( + &mut frame, + app, + decorations, + mailmap, + message, + tree_changes, + worktree_changes, + ); + if command_picker.is_open() { + let commands = command_menu::commands(app, decorations, app.has_verifiable_signatures()); + let items = command_picker_items(&commands); + command_picker.sync(&items); + ui::draw_command_menu(&mut frame, command_picker, &commands) + } else { + None + } + }; + if matches!(app.state, State::Complete | State::Cancelled) { + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.finish_after_frame(&response_ids, "completed"); + } + terminal + .apply_buffer_with_cursor(cursor) + .context("could not draw terminal frame")?; + filesystem_responses.frame_presented(); + Ok(()) +} + +fn open_repository(repository_path: &Path, bare: bool, isolated: bool) -> Result<gix::Repository> { + let options = if isolated { + gix::open::Options::isolated() + } else { + gix::open::Options::default() + } + .open_path_as_is(bare); + let options = if bare { + options.cli_overrides(["core.bare=true"]) + } else { + options + }; + Ok(gix::open_opts(repository_path, options)?) +} + +fn open_history_repository(repository_path: &mut PathBuf, common_dir: &Path) -> Result<(gix::Repository, bool)> { + match gix::open(&*repository_path) { + Ok(repository) => Ok((repository, false)), + Err(_err) if worktree_repository_is_gone(repository_path) => { + let repository = recover_common_repository(common_dir) + .context("could not recover before history traversal after the worktree repository disappeared")?; + common_dir.clone_into(repository_path); + Ok((repository, true)) + } + Err(err) => Err(err).context("could not open repository for history view"), + } +} + +fn recover_common_repository(common_dir: &Path) -> Result<gix::Repository> { + std::env::set_current_dir(common_dir).with_context(|| { + format!( + "could not change directory to common repository at {}", + common_dir.display() + ) + })?; + open_repository(common_dir, true, false) + .with_context(|| format!("could not open common repository at {} as bare", common_dir.display())) +} + +fn recover_event_loop_repository( + repository_path: &mut PathBuf, + common_dir: &Path, + bare: &mut bool, +) -> Result<Option<gix::Repository>> { + if *bare || !worktree_repository_is_gone(repository_path) { + return Ok(None); + } + let repository = + recover_common_repository(common_dir).context("could not recover after the worktree repository disappeared")?; + common_dir.clone_into(repository_path); + *bare = true; + Ok(Some(repository)) +} + +fn normalize_common_dir(common_dir: PathBuf) -> Result<PathBuf> { + let current_dir = std::env::current_dir().context("could not obtain current directory")?; + gix::path::normalize(common_dir.into(), ¤t_dir) + .map(Into::into) + .context("common repository path could not be normalized") +} + +fn worktree_repository_is_gone(repository_path: &Path) -> bool { + !repository_path.is_dir() || std::env::current_dir().is_err() +} + +fn open_fill_repository(repository_path: &Path, bare: bool) -> Result<gix::Repository> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for history view")?; + repository.object_cache_size(None); + Ok(repository) +} + +fn prepare_file_diff(repository_path: &Path, bare: bool, change: &FileChange, path: &PathChange) -> Result<FileDiff> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for file diff")?; + repository.object_cache_size(OBJECT_CACHE_SIZE); + prepare_file_diff_with_repository(&repository, change, path) +} + +fn prepare_commit_diff( + repository_path: &Path, + bare: bool, + target: app::TreeDiffTarget, + cached: Option<&Changes>, + title: BString, +) -> Result<CommitDiff> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for commit diff")?; + repository.object_cache_size(OBJECT_CACHE_SIZE); + prepare_commit_diff_with_repository(&repository, target, cached, title) +} + +fn prepare_commit_diff_with_repository( + repository: &gix::Repository, + target: app::TreeDiffTarget, + cached: Option<&Changes>, + title: BString, +) -> Result<CommitDiff> { + let loaded = cached + .is_none() + .then(|| load_changes_without_lines(repository, target)) + .transpose()?; + let changes = cached + .or(loaded.as_ref()) + .context("commit diff changes were neither cached nor loaded")?; + let mut external = Vec::new(); + let mut lines = Vec::new(); + let mut lines_added = 0u64; + let mut lines_removed = 0u64; + let mut line_counts = Vec::with_capacity(changes.paths.len()); + for (change, path) in changes.diffs.iter().zip(&changes.paths) { + let counts = match prepare_file_diff_content(repository, change, path, true)? { + PreparedFileDiff::External(command, counts) => { + external.push(command); + counts + } + PreparedFileDiff::BuiltIn(diff, counts) => { + lines.extend(diff.lines); + counts + } + }; + if let Some((added, removed)) = counts { + lines_added += u64::from(added); + lines_removed += u64::from(removed); + } + line_counts.push(counts); + } + let summary = ui::commit_diff_summary(changes, &line_counts, lines_added, lines_removed); + let internal = prepare_pager(repository, BuiltInDiff::new(title, lines).with_summary(summary))?; + Ok(CommitDiff { external, internal }) +} + +fn prepare_file_diff_with_repository( + repository: &gix::Repository, + change: &FileChange, + path: &PathChange, +) -> Result<FileDiff> { + match prepare_file_diff_content(repository, change, path, false)? { + PreparedFileDiff::External(command, _) => Ok(FileDiff::External(command)), + PreparedFileDiff::BuiltIn(diff, _) => prepare_pager(repository, diff), + } +} + +fn prepare_file_diff_content( + repository: &gix::Repository, + change: &FileChange, + path: &PathChange, + count_lines: bool, +) -> Result<PreparedFileDiff> { + if let FileChange::Unavailable(message) = change { + anyhow::bail!("{message}"); + } + let global_command = repository + .config_snapshot() + .trusted_program(gix::config::tree::Diff::EXTERNAL) + .map(gix::path::os_string_into_bstring) + .transpose() + .context("external diff command is not representable on this platform")?; + let mut resources = match change { + FileChange::Tree(_) => repository + .diff_resource_cache( + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, + Default::default(), + ) + .context("could not initialize file diff")?, + FileChange::Worktree { .. } => worktree_diff_cache( + repository, + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, + )? + .context("a working tree is required to show this diff")?, + FileChange::Unavailable(_) => unreachable!("handled above"), + }; + resources.options.skip_internal_diff_if_external_is_configured = true; + match change { + FileChange::Tree(change) => { + change + .attach(repository, repository) + .diff(&mut resources) + .context("could not prepare selected file")?; + } + FileChange::Worktree { old, new } => { + set_worktree_resources(repository, &mut resources, old.as_ref(), new.as_ref())?; + } + FileChange::Unavailable(_) => unreachable!("handled above"), + } + let prepared = resources.prepare_diff().context("could not prepare selected diff")?; + match prepared.operation { + gix::diff::blob::platform::prepare_diff::Operation::ExternalCommand { command } => { + let counts = count_lines + .then(|| { + let input = prepared.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics( + repository.diff_algorithm().context("could not obtain diff algorithm")?, + &input, + ); + Ok::<_, anyhow::Error>((diff.count_additions(), diff.count_removals())) + }) + .transpose()?; + let command = command.to_owned(); + prepare_external_diff(repository, &resources, command) + .map(|command| PreparedFileDiff::External(command, counts)) + } + gix::diff::blob::platform::prepare_diff::Operation::InternalDiff { algorithm } => { + if let Some(command) = global_command { + let counts = count_lines.then(|| { + let input = prepared.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + (diff.count_additions(), diff.count_removals()) + }); + return prepare_external_diff(repository, &resources, command) + .map(|command| PreparedFileDiff::External(command, counts)); + } + let input = prepared.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + let counts = Some((diff.count_additions(), diff.count_removals())); + let rendered = gix::diff::blob::UnifiedDiff::new( + &diff, + &input, + gix::diff::blob::unified_diff::ConsumeBinaryHunk::new(BString::default(), "\n"), + gix::diff::blob::unified_diff::ContextSize::symmetrical(3), + ) + .consume() + .context("could not render selected diff")?; + Ok(PreparedFileDiff::BuiltIn( + built_in_diff(path, change, Some(rendered), false), + counts, + )) + } + gix::diff::blob::platform::prepare_diff::Operation::SourceOrDestinationIsBinary => { + Ok(PreparedFileDiff::BuiltIn(built_in_diff(path, change, None, true), None)) + } + } +} + +fn prepare_pager(repository: &gix::Repository, diff: BuiltInDiff) -> Result<FileDiff> { + let Some(program) = repository.config_snapshot().trusted_program("core.pager") else { + return Ok(FileDiff::BuiltIn(diff)); + }; + if program.is_empty() || program == "cat" { + return Ok(FileDiff::BuiltIn(diff)); + } + let command = gix::command::prepare(program) + .command_may_be_shell_script_disallow_manual_argument_splitting() + .with_context( + repository + .command_context() + .context("could not prepare pager environment")?, + ) + .env("GIT_PAGER_IN_USE", "true") + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .into(); + Ok(FileDiff::Pager { command, diff }) +} + +fn prepare_external_diff( + repository: &gix::Repository, + resources: &gix::diff::blob::Platform, + command: BString, +) -> Result<gix::diff::blob::platform::prepare_diff_command::Command> { + resources + .prepare_diff_command( + command, + repository + .command_context() + .context("could not prepare external diff environment")?, + 0, + 1, + ) + .context("could not prepare external diff command") +} + +fn built_in_diff(path: &PathChange, change: &FileChange, rendered: Option<BString>, binary: bool) -> BuiltInDiff { + let (old_path, new_path, old_mode, new_mode) = match change { + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Addition { entry_mode, .. }) => { + (None, Some(path.path.as_bstr()), None, Some(*entry_mode)) + } + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Deletion { entry_mode, .. }) => { + (Some(path.path.as_bstr()), None, Some(*entry_mode), None) + } + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Modification { + previous_entry_mode, + entry_mode, + .. + }) => ( + Some(path.path.as_bstr()), + Some(path.path.as_bstr()), + Some(*previous_entry_mode), + Some(*entry_mode), + ), + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Rewrite { + source_entry_mode, + entry_mode, + .. + }) => ( + path.source.as_ref().map(|path| path.as_bstr()), + Some(path.path.as_bstr()), + Some(*source_entry_mode), + Some(*entry_mode), + ), + FileChange::Worktree { old, new } => ( + old.as_ref().map(|resource| resource.path.as_bstr()), + new.as_ref().map(|resource| resource.path.as_bstr()), + old.as_ref().map(|resource| resource.mode), + new.as_ref().map(|resource| resource.mode), + ), + FileChange::Unavailable(_) => unreachable!("unavailable diffs aren't rendered"), + }; + let display_path = |path: Option<&gix::bstr::BStr>, prefix: &str| -> BString { + path.map_or_else( + || "/dev/null".into(), + |path| format!("{prefix}{}", path.to_str_lossy()).into(), + ) + }; + let mut lines = vec![ + format!("--- {}", display_path(old_path, "a/").to_str_lossy()).into(), + format!("+++ {}", display_path(new_path, "b/").to_str_lossy()).into(), + ]; + if old_mode != new_mode { + if let Some(mode) = old_mode { + lines.push(format!("old mode {}", mode.kind().as_octal_str()).into()); + } + if let Some(mode) = new_mode { + lines.push(format!("new mode {}", mode.kind().as_octal_str()).into()); + } + } + if binary { + lines.push("Binary files differ".into()); + } else if let Some(rendered) = rendered { + lines.extend(rendered.lines().map(BString::from)); + } + BuiltInDiff::new( + format!("{} {}", path.kind.letter(), path.path.to_str_lossy()).into(), + lines, + ) +} + +fn show_file_diff(terminal: &mut ratatui::DefaultTerminal, diff: FileDiff, enhanced_keyboard: bool) -> Result<bool> { + match diff { + FileDiff::External(command) => run_external_diff(terminal, command, enhanced_keyboard).map(|()| false), + FileDiff::Pager { command, diff } => run_pager(terminal, command, &diff, enhanced_keyboard).map(|()| false), + FileDiff::BuiltIn(diff) => show_builtin_diff(terminal, &diff), + } +} + +fn show_commit_diff( + terminal: &mut ratatui::DefaultTerminal, + diff: CommitDiff, + enhanced_keyboard: bool, +) -> Result<bool> { + if show_file_diff(terminal, diff.internal, enhanced_keyboard)? { + return Ok(true); + } + for command in diff.external { + run_external_diff(terminal, command, enhanced_keyboard)?; + } + Ok(false) +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +fn edit_note( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + id: gix::ObjectId, + enhanced_keyboard: bool, +) -> Result<Option<(enrich::Enrichment, Result<Vec<edit::undo::RefChange>>)>> { + let (editor, enrichment, document) = { + let repository = + open_repository(repository_path, bare, false).context("could not open repository before editing note")?; + let change_id = change_id::for_commit(&repository, id)?; + let enrichment = enrich::load(&mut enrich::open(&repository)?, change_id)?; + let document = enrichment.note.clone().unwrap_or_default(); + let editor = repository.editor().context("no Git editor is available")?; + (editor, enrichment, document) + }; + let edited = edit::edit_document( + terminal, + &editor, + &document, + &format!("tix-note-{}.md", std::process::id()), + enhanced_keyboard, + )?; + let cleaned = edit::reword::cleanup_message(edited.as_deref().unwrap_or(&document), None); + let desired_note = (!cleaned.is_empty()).then_some(cleaned.as_bstr()); + if enrichment.note.as_ref().map(|note| note.as_bstr()) == desired_note { + return Ok(None); + } + + let repository = + open_repository(repository_path, bare, false).context("could not reopen repository after editing note")?; + let name: gix::refs::FullName = enrich::REF_NAME.try_into().expect("valid enrich ref"); + let before = edit::undo::state(&repository, name.as_ref()); + let enrichment = enrich::set_note(&repository, id, desired_note.map(AsRef::as_ref))?; + let changes = before.and_then(|before| { + edit::undo::state(&repository, name.as_ref()).map(|after| { + (before != after) + .then_some(edit::undo::RefChange { name, before, after }) + .into_iter() + .collect() + }) + }); + Ok(Some((enrichment, changes))) +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +fn edit_git_note( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + id: gix::ObjectId, + enhanced_keyboard: bool, +) -> Result<Option<(bool, Result<Vec<edit::undo::RefChange>>)>> { + let (editor, reference, document) = { + let repository = open_repository(repository_path, bare, false) + .context("could not open repository before editing Git note")?; + let editor = repository.editor().context("no Git editor is available")?; + let notes = repository.notes()?; + let reference = notes + .default_ref() + .context("no default Git notes reference is configured")? + .to_owned(); + let mut notes = notes.with_refs([reference.as_bstr()])?; + let document = notes + .get(id)? + .first() + .map(|note| note.blob.data.clone()) + .unwrap_or_default(); + (editor, reference, document) + }; + let edited = edit::edit_document( + terminal, + &editor, + &document, + &format!("tix-git-note-{}.md", std::process::id()), + enhanced_keyboard, + )?; + let cleaned = edit::reword::cleanup_message(edited.as_deref().unwrap_or(&document), None); + if cleaned == document { + return Ok(None); + } + + let repository = + open_repository(repository_path, bare, false).context("could not reopen repository after editing Git note")?; + let changes = set_git_note_reporting( + &repository, + reference.as_ref(), + id, + (!cleaned.is_empty()).then_some(cleaned.as_ref()), + )?; + Ok(Some((!cleaned.is_empty(), changes))) +} + +fn set_git_note_reporting( + repository: &gix::Repository, + reference: &gix::refs::FullNameRef, + id: gix::ObjectId, + data: Option<&[u8]>, +) -> Result<Result<Vec<edit::undo::RefChange>>> { + let name = reference.to_owned(); + let before = edit::undo::state(repository, reference); + let mut notes = repository.notes()?; + match data { + Some(data) => { + notes + .replace_at_ref(reference, id, data) + .context("could not save Git note")?; + } + None => { + notes + .remove(reference.as_partial_name().to_owned(), id) + .context("could not remove Git note")?; + } + } + Ok(before.and_then(|before| { + edit::undo::state(repository, reference).map(|after| { + (before != after) + .then_some(edit::undo::RefChange { name, before, after }) + .into_iter() + .collect() + }) + })) +} + +#[cfg(test)] +pub(crate) fn set_git_note( + repository: &gix::Repository, + reference: &gix::refs::FullNameRef, + id: gix::ObjectId, + data: Option<&[u8]>, +) -> Result<Vec<edit::undo::RefChange>> { + set_git_note_reporting(repository, reference, id, data)? +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +fn reword_commit( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + revisions: &[OsString], + hidden_revisions: &[OsString], + id: gix::ObjectId, + enhanced_keyboard: bool, +) -> Result<Option<edit::reword::Perform>> { + let (editor, document, change_id) = { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before editing commit")?; + repository.object_cache_size(None); + let change_id = change_id::for_commit(&repository, id)?; + let (editor, document) = edit::reword::document(&repository, id)?; + (editor, document, change_id) + }; + let Some(edited) = edit::edit_document( + terminal, + &editor, + &document, + &format!("tix-reword-{}.md", std::process::id()), + enhanced_keyboard, + )? + else { + return Ok(None); + }; + + run_with_todo_progress(terminal, move |report| { + let mut repository = open_repository(repository_path, bare, false) + .context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + let (graph, id) = edit::reword::relocate_after_editor(&repository, revisions, hidden_revisions, change_id)?; + edit::reword::apply_conflict_reporting(repository, &graph, id, &edited, report) + }) + .map(Some) +} + +pub(crate) fn load_rebase_todo_commits( + repository: &gix::Repository, + app: &mut App, + authors: &SharedAuthors, + scope: &[gix::ObjectId], +) -> Result<Vec<edit::todo::Commit>> { + let row_indices: HashMap<_, _> = app + .rows + .iter() + .enumerate() + .map(|(index, row)| (row.id, index)) + .collect(); + let mut notes = repository.notes().context("could not open Git notes")?; + for id in scope { + let index = row_indices + .get(id) + .copied() + .context("an editable commit disappeared from the history view")?; + if !app.rows[index].metadata_loaded { + let (metadata, attributions) = + history::load_metadata(repository, *id, authors).context("could not load editable commit metadata")?; + app.set_metadata(index, metadata, attributions); + } + let loaded = notes + .get(*id) + .context("could not load commit notes")? + .into_iter() + .map(|note| { + let mut blob = note.blob; + BString::from(blob.take_data()) + }) + .collect(); + app.set_notes(*id, loaded); + } + let mailmap = repository.open_mailmap(); + scope + .iter() + .map(|id| { + let row = row_indices + .get(id) + .and_then(|index| app.rows.get(*index)) + .context("an editable commit disappeared while formatting the todo")?; + Ok(edit::todo::Commit { + id: *id, + parents: row.parent_ids.to_vec(), + info: ui::todo_metadata(app, row, &mailmap), + }) + }) + .collect() +} + +#[tracing::instrument(skip_all, fields(base = %base, commits = commits.len()))] +#[expect( + clippy::too_many_arguments, + reason = "the editor bridges terminal, repository, and selected view state" +)] +fn rebase_history( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + graph: &HistoryGraph, + base: gix::ObjectId, + onto: gix::ObjectId, + commits: Vec<edit::todo::Commit>, + enhanced_keyboard: bool, +) -> Result<Option<edit::rebase::PlanPerform>> { + let (prepared, editor) = { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before rebasing")?; + repository.object_cache_size(None); + let editor = repository.editor().context("no Git editor is available")?; + let prepared = edit::todo::prepare( + &repository, + base, + onto, + &commits, + &[], + edit::todo::OntoKind::UpdatedBase, + false, + )?; + (prepared, editor) + }; + let edited = edit::edit_document( + terminal, + &editor, + &prepared.document, + &format!("tix-rebase-{}.md", std::process::id()), + enhanced_keyboard, + )?; + let edited = match edited { + Some(edited) => edited, + None if prepared.apply_unchanged => prepared.document.clone(), + None => return Ok(None), + }; + let mut repository = + open_repository(repository_path, bare, false).context("could not reopen repository after editing rebase")?; + repository.object_cache_size(None); + let Some(parsed) = edit::todo::parse(&repository, &edited)? else { + return Ok(None); + }; + run_rebase_plan(terminal, repository.into_sync(), graph, parsed.plan).map(Some) +} + +fn stage_resolved_conflict_paths(repository: &gix::Repository) -> Result<()> { + let index = repository + .open_index() + .context("could not inspect the conflict index")?; + let mut paths: Vec<BString> = index + .entries() + .iter() + .filter(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + .map(|entry| entry.path(&index).to_owned()) + .collect(); + paths.sort(); + paths.dedup(); + if paths.is_empty() { + return Ok(()); + } + + let workdir = repository + .workdir() + .context("cannot resolve a conflict without a worktree")?; + let mut command = Command::new("git"); + command + .arg("--literal-pathspecs") + .arg("-C") + .arg(workdir) + .args(["add", "-A", "--"]); + for path in &paths { + command.arg(gix::path::from_bstr(path.as_bstr()).as_ref()); + } + let output = command + .output() + .context("could not launch git add for resolved paths")?; + if !output.status.success() { + let stderr = output.stderr.trim().to_str_lossy(); + if stderr.is_empty() { + anyhow::bail!("git add for resolved paths failed with {}", output.status); + } + anyhow::bail!("git add for resolved paths failed with {}: {stderr}", output.status); + } + + let index = repository + .open_index() + .context("could not verify the resolved conflict index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("the conflict index still has unresolved entries"); + } + Ok(()) +} + +fn preview_todo_rebase_conflict( + app: &mut App, + conflict: &edit::rebase::PlanConflict, + authors: &SharedAuthors, + view_tips: &[gix::ObjectId], + hidden_tips: &[gix::ObjectId], +) -> Result<()> { + let repo = conflict.repository(); + let mut rows = Vec::with_capacity(conflict.produced().len()); + let mut attributions = Vec::new(); + for id in conflict.produced().iter().rev().copied() { + let commit = repo + .find_commit(id) + .context("could not load an in-memory rebase result")?; + let parent_ids = commit.parent_ids().map(gix::Id::detach).collect(); + let (metadata, mut row_attributions) = history::load_metadata(repo, id, authors)?; + let attribution_start = attributions.len(); + let attribution_len = metadata.attributions.len(); + attributions.append(&mut row_attributions); + rows.push(app::Commit { + id, + parent_ids, + committer_time: metadata.committer_time, + author_time: metadata.author_time, + author: metadata.author, + attributions: attribution_start..attribution_start + attribution_len, + title: metadata.title, + metadata_loaded: true, + has_agent_marker: metadata.has_agent_marker, + is_review: metadata.is_review, + signature: metadata.signature, + }); + } + let view_tips = view_tips.iter().filter_map(|id| conflict.map(*id)).collect::<Vec<_>>(); + let hidden_tips = hidden_tips + .iter() + .filter_map(|id| conflict.map(*id)) + .collect::<Vec<_>>(); + if let Some(rows) = app.start_refresh( + app::LoadedCommits { rows, attributions }, + &view_tips, + &hidden_tips, + false, + ) { + let (rows, graph, elapsed) = app::compute_lanes(rows); + app.finish_lane_computation(rows, graph, elapsed); + } + Ok(()) +} + +enum RebaseWorkerEvent<T> { + Progress(edit::rebase::Progress), + Complete(Result<T>), +} + +enum TravelWorkerEvent<T> { + Rebased(gix::ObjectId), + Complete(Result<T>), +} + +fn run_with_rebase_selection<T: Send>( + operation: impl FnOnce(&mut dyn FnMut(gix::ObjectId)) -> Result<T> + Send, + mut render: impl FnMut(gix::ObjectId) -> Result<()>, +) -> Result<T> { + std::thread::scope(|scope| { + let (sender, receiver) = mpsc::channel(); + let worker = scope.spawn(move || { + let mut report = |id| { + let _ = sender.send(TravelWorkerEvent::Rebased(id)); + }; + let result = operation(&mut report); + let _ = sender.send(TravelWorkerEvent::Complete(result)); + }); + let mut last_draw: Option<Instant> = None; + let mut latest = None; + let mut rendered = None; + let mut complete = None; + let mut render_error = None; + let result = loop { + let event = if complete.is_some() { + None + } else if render_error.is_none() + && latest != rendered + && let Some(last_draw) = last_draw + { + match receiver.recv_timeout(FRAME_INTERVAL.saturating_sub(last_draw.elapsed())) { + Ok(event) => Some(event), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => { + break Err(anyhow::anyhow!("time-travel worker stopped unexpectedly")); + } + } + } else { + match receiver.recv() { + Ok(event) => Some(event), + Err(_) => break Err(anyhow::anyhow!("time-travel worker stopped unexpectedly")), + } + }; + match event { + Some(TravelWorkerEvent::Rebased(id)) => latest = Some(id), + Some(TravelWorkerEvent::Complete(result)) => complete = Some(result), + None => {} + } + if render_error.is_none() + && latest != rendered + && (last_draw.is_none() + || last_draw.is_some_and(|last_draw| last_draw.elapsed() >= FRAME_INTERVAL) + || complete.is_some()) + { + if complete.is_some() + && let Some(last_draw) = last_draw + { + std::thread::sleep(FRAME_INTERVAL.saturating_sub(last_draw.elapsed())); + } + let id = latest.expect("a changed rebased commit is available"); + if let Err(err) = render(id) { + render_error = Some(err); + continue; + } + rendered = latest; + last_draw = Some(Instant::now()); + } + if let Some(result) = complete.take() { + break result; + } + }; + if let Some(err) = render_error { + tracing::warn!(error = %err, "time-travel animation stopped"); + } + if worker.join().is_err() { + return Err(anyhow::anyhow!("time-travel worker panicked")); + } + result + }) +} + +fn run_rebase_plan( + terminal: &mut ratatui::DefaultTerminal, + repository: gix::ThreadSafeRepository, + graph: &HistoryGraph, + plan: edit::rebase::Plan, +) -> Result<edit::rebase::PlanPerform> { + run_with_todo_progress(terminal, move |report| { + let mut repository = repository.to_thread_local(); + repository.object_cache_size(None); + edit::rebase::perform_plan_with_progress(&repository, graph, plan, report) + }) +} + +fn run_with_todo_progress<T: Send>( + terminal: &mut ratatui::DefaultTerminal, + operation: impl FnOnce(&mut dyn FnMut(edit::rebase::Progress)) -> Result<T> + Send, +) -> Result<T> { + std::thread::scope(|scope| { + let (sender, receiver) = mpsc::sync_channel(1); + let worker = scope.spawn(move || { + let mut report = |progress| { + let _ = sender.try_send(RebaseWorkerEvent::Progress(progress)); + }; + let result = operation(&mut report); + let _ = sender.send(RebaseWorkerEvent::Complete(result)); + }); + let started = Instant::now(); + let mut last_draw = started; + let mut latest = None; + let mut rendered = None; + let result = loop { + let now = Instant::now(); + let timeout = if now.duration_since(started) < TODO_PROGRESS_DELAY { + Some(TODO_PROGRESS_DELAY.saturating_sub(now.duration_since(started))) + } else if latest != rendered { + Some(FRAME_INTERVAL.saturating_sub(now.duration_since(last_draw))) + } else { + None + }; + let event = match timeout { + Some(timeout) => match receiver.recv_timeout(timeout) { + Ok(event) => Some(event), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => { + break Err(anyhow::anyhow!("rebase worker stopped unexpectedly")); + } + }, + None => match receiver.recv() { + Ok(event) => Some(event), + Err(_) => break Err(anyhow::anyhow!("rebase worker stopped unexpectedly")), + }, + }; + match event { + Some(RebaseWorkerEvent::Progress(progress)) => latest = Some(progress), + Some(RebaseWorkerEvent::Complete(result)) => break result, + None => {} + } + let now = Instant::now(); + if todo_progress_visible(now.duration_since(started)) + && latest != rendered + && now.duration_since(last_draw) >= FRAME_INTERVAL + { + let progress = latest.expect("a changed progress snapshot is available"); + if let Err(err) = terminal.draw(|frame| ui::draw_todo_progress(frame, progress)) { + break Err(err).context("could not draw rebase progress"); + } + rendered = latest; + last_draw = now; + } + }; + if worker.join().is_err() { + return Err(anyhow::anyhow!("rebase worker panicked")); + } + result + }) +} + +fn todo_progress_visible(elapsed: Duration) -> bool { + elapsed >= TODO_PROGRESS_DELAY +} + +#[derive(Clone, Copy)] +enum CreateMode { + Insert, + InsertEmpty, + Fork, +} + +#[tracing::instrument(skip_all, fields(parent = ?parent, fork = matches!(mode, CreateMode::Fork)))] +fn create_commit( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + graph: &HistoryGraph, + parent: Option<gix::ObjectId>, + mode: CreateMode, + enhanced_keyboard: bool, +) -> Result<Option<edit::rebase::Perform>> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before creating commit")?; + repository.object_cache_size(None); + let prepared = if matches!(mode, CreateMode::InsertEmpty) { + edit::create::prepare_empty(repository, parent)? + } else { + edit::create::prepare(repository, parent)? + }; + if matches!(mode, CreateMode::Insert) && prepared.is_empty { + anyhow::bail!("the new commit would be empty; use new-empty instead"); + } + let Some(edited) = edit::edit_document( + terminal, + &prepared.editor, + &prepared.document, + &format!("tix-commit-{}.md", std::process::id()), + enhanced_keyboard, + )? + else { + return Ok(None); + }; + let outcome = match mode { + CreateMode::Insert | CreateMode::InsertEmpty => run_with_todo_progress(terminal, move |report| { + let mut repository = open_repository(repository_path, bare, false) + .context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + edit::create::apply_conflict_reporting(repository, graph, prepared, &edited, report) + }), + CreateMode::Fork => { + let mut repository = open_repository(repository_path, bare, false) + .context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + edit::create::apply_fork_reporting(repository, graph, prepared, &edited) + .map(edit::rebase::Perform::Complete) + } + }?; + Ok(Some(outcome)) +} + +#[tracing::instrument(skip_all)] +fn split_commit( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + graph: &HistoryGraph, + enhanced_keyboard: bool, +) -> Result<Option<edit::rebase::Outcome>> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before splitting HEAD")?; + repository.object_cache_size(None); + let prepared = edit::split::prepare(repository, false)?; + let Some(edited) = edit::edit_document( + terminal, + &prepared.editor, + &prepared.document, + &format!("tix-split-{}.md", std::process::id()), + enhanced_keyboard, + )? + else { + return Ok(None); + }; + run_with_todo_progress(terminal, move |report| { + let mut repository = + open_repository(repository_path, bare, false).context("could not reopen repository after editing split")?; + repository.object_cache_size(None); + edit::split::apply_reporting(repository, graph, prepared, &edited, report) + }) + .map(Some) +} + +#[tracing::instrument(skip_all, fields(commit_id = %id))] +fn forget_commit( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + graph: &HistoryGraph, + id: gix::ObjectId, +) -> Result<edit::forget::Perform> { + run_with_todo_progress(terminal, move |report| { + let mut repository = open_repository(repository_path, bare, false) + .context("could not open repository before forgetting commit")?; + repository.object_cache_size(None); + edit::forget::perform_conflict(repository, graph, id, report) + }) +} + +fn run_external_diff( + terminal: &mut ratatui::DefaultTerminal, + mut command: gix::diff::blob::platform::prepare_diff_command::Command, + enhanced_keyboard: bool, +) -> Result<()> { + with_suspended_terminal(terminal, enhanced_keyboard, || { + let status = command.status().context("could not launch external diff")?; + external_diff_status(status) + }) +} + +fn run_pager( + terminal: &mut ratatui::DefaultTerminal, + mut command: Command, + diff: &BuiltInDiff, + enhanced_keyboard: bool, +) -> Result<()> { + with_suspended_terminal(terminal, enhanced_keyboard, || { + let start = Instant::now(); + let mut child = command.spawn().context("could not launch diff pager")?; + let write_result = child.stdin.take().map_or_else( + || Err(io::Error::other("pager stdin was not piped")), + |mut stdin| diff.write_to(&mut stdin), + ); + let status = child.wait().context("could not wait for diff pager"); + pager_write_result(write_result)?; + pager_status(status?)?; + if pager_needs_acknowledgement(start.elapsed()) { + wait_for_keypress()?; + } + Ok(()) + }) +} + +fn wait_for_keypress() -> Result<()> { + terminal::enable_raw_mode().context("could not read pager acknowledgement")?; + loop { + if matches!( + event::read().context("could not read pager acknowledgement")?, + TerminalEvent::Key(KeyEvent { + kind: KeyEventKind::Press, + .. + }) + ) { + return Ok(()); + } + } +} + +fn with_suspended_terminal<T>( + terminal: &mut ratatui::DefaultTerminal, + enhanced_keyboard: bool, + operation: impl FnOnce() -> Result<T>, +) -> Result<T> { + let suspend = disable_input(terminal.backend_mut(), enhanced_keyboard) + .and_then(|()| terminal.show_cursor()) + .and_then(|()| terminal::disable_raw_mode()) + .and_then(|()| { + execute!( + terminal.backend_mut(), + ResetColor, + cursor::MoveTo(0, 0), + Clear(ClearType::All) + ) + }); + if let Err(err) = suspend { + let _ = terminal::enable_raw_mode(); + let _ = enable_input(terminal.backend_mut(), enhanced_keyboard); + let _ = terminal.hide_cursor(); + return Err(err).context("could not suspend terminal for external program"); + } + + let result = operation(); + let restore = terminal::enable_raw_mode() + .and_then(|()| enable_input(terminal.backend_mut(), enhanced_keyboard)) + .and_then(|()| terminal.hide_cursor()) + .and_then(|()| terminal.clear()); + let value = result?; + restore.context("could not restore terminal after external program")?; + Ok(value) +} + +struct RemoteDeleteOutcome { + deleted: usize, + failures: Vec<String>, +} + +fn push_remote_deletions(repository_path: &Path, groups: &[ref_tree::RemoteDeletion]) -> RemoteDeleteOutcome { + let mut outcome = RemoteDeleteOutcome { + deleted: 0, + failures: Vec::new(), + }; + for group in groups { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(repository_path) + .arg("push") + .arg(gix::path::from_bstr(group.remote.as_bstr()).as_ref()); + for reference in &group.references { + let mut refspec = b":".to_vec(); + refspec.extend_from_slice(reference.as_bstr()); + command.arg(gix::path::from_bstr(refspec.as_bstr()).as_ref()); + } + match command.status() { + Ok(status) if status.success() => outcome.deleted += group.references.len(), + Ok(status) => outcome.failures.push(format!("{} exited with {status}", group.remote)), + Err(err) => outcome.failures.push(format!("{}: {err}", group.remote)), + } + } + outcome +} + +fn external_diff_status(status: ExitStatus) -> Result<()> { + if status.success() || status.code() == Some(1) { + Ok(()) + } else { + anyhow::bail!("external diff exited with {status}") + } +} + +fn pager_write_result(result: io::Result<()>) -> Result<()> { + match result { + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()), + result => result.context("could not write diff to pager"), + } +} + +fn pager_status(status: ExitStatus) -> Result<()> { + if status.success() { + Ok(()) + } else { + anyhow::bail!("diff pager exited with {status}") + } +} + +fn pager_needs_acknowledgement(elapsed: Duration) -> bool { + elapsed <= IMMEDIATE_PAGER_EXIT +} + +fn show_builtin_diff(terminal: &mut ratatui::DefaultTerminal, diff: &BuiltInDiff) -> Result<bool> { + let mut offset = 0usize; + let mut horizontal_offset = 0usize; + let mut focused = true; + loop { + let size = terminal.size().context("could not determine diff viewport")?; + let page = usize::from(size.height.saturating_sub(2)).max(1); + let max = diff.display_line_count().saturating_sub(page); + let horizontal_page = usize::from(size.width).max(1); + let horizontal_max = diff.max_width.saturating_sub(horizontal_page); + offset = offset.min(max); + horizontal_offset = horizontal_offset.min(horizontal_max); + terminal + .draw(|frame| ui::draw_file_diff(frame, diff, offset, horizontal_offset)) + .context("could not draw file diff")?; + let event = event::read().context("could not read file diff input")?; + let key = match event { + TerminalEvent::FocusLost => { + focused = false; + continue; + } + TerminalEvent::FocusGained => { + focused = true; + continue; + } + TerminalEvent::Resize(_, _) => continue, + TerminalEvent::Key(key) if focused && key.kind != KeyEventKind::Release => key, + _ => continue, + }; + match action(key) { + Some(Action::OpenDiff) => return Ok(false), + Some(Action::ForceQuit | Action::Quit | Action::Cancel) => return Ok(true), + Some(Action::MoveUp) => offset = offset.saturating_sub(1), + Some(Action::MoveDown) => offset = offset.saturating_add(1).min(max), + Some(Action::PageUp) => offset = offset.saturating_sub(page), + Some(Action::PageDown) => offset = offset.saturating_add(page).min(max), + Some(Action::HalfPageUp) => offset = offset.saturating_sub((page / 2).max(1)), + Some(Action::HalfPageDown) => offset = offset.saturating_add((page / 2).max(1)).min(max), + Some(Action::First) => offset = 0, + Some(Action::Last) => offset = max, + Some(Action::ScrollLeft) => horizontal_offset = horizontal_offset.saturating_sub(horizontal_page), + Some(Action::ScrollRight) => { + horizontal_offset = horizontal_offset.saturating_add(horizontal_page).min(horizontal_max); + } + _ => {} + } + } +} + +fn load_commit_message(repository: &gix::Repository, id: gix::ObjectId) -> Result<BString> { + let commit = repository.find_commit(id).context("could not load commit message")?; + Ok(commit.message_raw_sloppy().to_owned()) +} + +fn load_changes( + repository: &gix::Repository, + target: app::TreeDiffTarget, + line_diff_pool: &mut LineDiffPool, +) -> Result<Changes> { + let mut out = load_changes_without_lines(repository, target)?; + let diffs = std::mem::take(&mut out.diffs); + for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { + path.lines = lines; + if let Some((insertions, removals)) = lines { + out.lines_added += u64::from(insertions); + out.lines_removed += u64::from(removals); + } + out.diffs.push(change); + } + Ok(out) +} + +fn load_changes_without_lines(repository: &gix::Repository, target: app::TreeDiffTarget) -> Result<Changes> { + let app::TreeDiffTarget::Commit { + id, + parent: requested_parent, + } = target + else { + let app::TreeDiffTarget::Branch { base, tip } = target else { + unreachable!("all tree diff targets are covered") + }; + let old_tree = repository + .find_commit(base) + .context("could not load branch base")? + .tree() + .context("could not load branch base tree")?; + let new_tree = repository + .find_commit(tip) + .context("could not load branch tip")? + .tree() + .context("could not load branch tip tree")?; + let mut changes = load_tree_changes_without_lines(repository, Some(&old_tree), &new_tree, None)?; + changes.range = Some(app::ComparedRange { base, tip }); + return Ok(changes); + }; + let commit = repository.find_commit(id).context("could not load changed paths")?; + let marked_parent = { + let decoded = commit.decode().context("could not decode changed commit")?; + edit::rebase::marked_parent_ref(&decoded)? + }; + let parents: Vec<_> = commit.parent_ids().map(gix::Id::detach).collect(); + let parent_index = requested_parent.checked_rem(parents.len()).unwrap_or_default(); + let selected_parent = parents.get(parent_index).copied(); + let (parent, compared_parent) = match marked_parent { + Some(parent) => (parent, None), + None => ( + selected_parent, + (parents.len() > 1).then(|| ComparedParent { + index: parent_index, + total: parents.len(), + id: selected_parent.expect("a merge has parents"), + }), + ), + }; + let new_tree = commit.tree().context("could not load changed commit tree")?; + let old_tree = match parent { + Some(parent) => Some( + repository + .find_commit(parent) + .context("could not load parent commit")? + .tree() + .context("could not load parent commit tree")?, + ), + None => None, + }; + load_tree_changes_without_lines(repository, old_tree.as_ref(), &new_tree, compared_parent) +} + +fn load_tree_changes_without_lines( + repository: &gix::Repository, + old_tree: Option<&gix::Tree<'_>>, + new_tree: &gix::Tree<'_>, + parent: Option<ComparedParent>, +) -> Result<Changes> { + let changes = repository + .diff_tree_to_tree(old_tree, Some(new_tree), None) + .context("could not diff commit trees")?; + let mut out = Changes { + parent, + ..Changes::default() + }; + for change in changes { + use gix::object::tree::diff::ChangeDetached; + let (kind, source, path, is_tree) = match &change { + ChangeDetached::Addition { + entry_mode, location, .. + } => (ChangeKind::Added, None, location.clone(), entry_mode.is_tree()), + ChangeDetached::Deletion { + entry_mode, location, .. + } => (ChangeKind::Deleted, None, location.clone(), entry_mode.is_tree()), + ChangeDetached::Modification { + previous_entry_mode, + entry_mode, + location, + .. + } => ( + if previous_entry_mode.kind() == entry_mode.kind() { + ChangeKind::Modified + } else { + ChangeKind::TypeChanged + }, + None, + location.clone(), + previous_entry_mode.is_tree() && entry_mode.is_tree(), + ), + ChangeDetached::Rewrite { + source_location, + source_entry_mode, + entry_mode, + location, + copy, + .. + } => ( + if *copy { ChangeKind::Copied } else { ChangeKind::Renamed }, + Some(source_location.clone()), + location.clone(), + source_entry_mode.is_tree() || entry_mode.is_tree(), + ), + }; + if is_tree { + continue; + } + out.paths.push(PathChange { + kind, + group: ChangeGroup::Tree, + source, + path, + lines: None, + }); + out.diffs.push(FileChange::Tree(change)); + } + Ok(out) +} + +fn add_line_counts(repository: &gix::Repository, changes: &mut Changes) -> Result<Vec<LineCounts>> { + let mut cache = repository + .diff_resource_cache_for_tree_diff() + .context("could not initialize commit diff summary")?; + let mut counts = Vec::with_capacity(changes.diffs.len()); + for (path, change) in changes.paths.iter_mut().zip(&changes.diffs) { + let lines = line_counts_for_change(repository, change, &mut cache, None)?; + path.lines = lines; + if let Some((added, removed)) = lines { + changes.lines_added += u64::from(added); + changes.lines_removed += u64::from(removed); + } + counts.push(lines); + cache.clear_resource_cache_keep_allocation(); + } + Ok(counts) +} + +fn entry_mode(mode: gix::index::entry::Mode) -> Result<gix::objs::tree::EntryMode> { + mode.to_tree_entry_mode() + .context("status entry cannot be represented in a tree") +} + +fn staged_change(change: gix::diff::index::Change) -> Result<(PathChange, FileChange)> { + use gix::diff::index::Change; + use gix::object::tree::diff::ChangeDetached; + + let (kind, source, path, diff) = match change { + Change::Addition { + location, + entry_mode: mode, + id, + .. + } => { + let entry_mode = entry_mode(mode)?; + let path = location.into_owned(); + let diff = ChangeDetached::Addition { + location: path.clone(), + entry_mode, + relation: None, + id: id.into_owned(), + }; + (ChangeKind::Added, None, path, diff) + } + Change::Deletion { + location, + entry_mode: mode, + id, + .. + } => { + let entry_mode = entry_mode(mode)?; + let path = location.into_owned(); + let diff = ChangeDetached::Deletion { + location: path.clone(), + entry_mode, + relation: None, + id: id.into_owned(), + }; + (ChangeKind::Deleted, None, path, diff) + } + Change::Modification { + location, + previous_entry_mode, + previous_id, + entry_mode: mode, + id, + .. + } => { + let previous_entry_mode = entry_mode(previous_entry_mode)?; + let current_entry_mode = entry_mode(mode)?; + let path = location.into_owned(); + let kind = if previous_entry_mode.kind() == current_entry_mode.kind() { + ChangeKind::Modified + } else { + ChangeKind::TypeChanged + }; + let diff = ChangeDetached::Modification { + location: path.clone(), + previous_entry_mode, + previous_id: previous_id.into_owned(), + entry_mode: current_entry_mode, + id: id.into_owned(), + }; + (kind, None, path, diff) + } + Change::Rewrite { + source_location, + source_entry_mode, + source_id, + location, + entry_mode: mode, + id, + copy, + .. + } => { + let source_entry_mode = entry_mode(source_entry_mode)?; + let current_entry_mode = entry_mode(mode)?; + let source = source_location.into_owned(); + let path = location.into_owned(); + let diff = ChangeDetached::Rewrite { + source_location: source.clone(), + source_entry_mode, + source_relation: None, + source_id: source_id.into_owned(), + diff: None, + entry_mode: current_entry_mode, + id: id.into_owned(), + location: path.clone(), + relation: None, + copy, + }; + ( + if copy { ChangeKind::Copied } else { ChangeKind::Renamed }, + Some(source), + path, + diff, + ) + } + }; + let unavailable = matches!(diff, ChangeDetached::Addition { entry_mode, .. } if entry_mode.is_commit()) + || matches!(diff, ChangeDetached::Deletion { entry_mode, .. } if entry_mode.is_commit()) + || matches!(diff, ChangeDetached::Modification { previous_entry_mode, entry_mode, .. } if previous_entry_mode.is_commit() || entry_mode.is_commit()) + || matches!(diff, ChangeDetached::Rewrite { source_entry_mode, entry_mode, .. } if source_entry_mode.is_commit() || entry_mode.is_commit()); + Ok(( + PathChange { + kind, + group: ChangeGroup::Staged, + source, + path, + lines: None, + }, + if unavailable { + FileChange::Unavailable("submodule changes don't have a file diff") + } else { + FileChange::Tree(diff) + }, + )) +} + +fn worktree_resource(entry: &gix::index::Entry, path: &gix::bstr::BStr) -> Result<DiffResource> { + Ok(DiffResource { + id: entry.id, + mode: entry_mode(entry.mode)?, + path: path.to_owned(), + }) +} + +fn unstaged_change( + item: gix::status::index_worktree::Item, + object_hash: gix::hash::Kind, +) -> Result<Option<(PathChange, FileChange, bool)>> { + use gix::status::index_worktree::Item; + use gix::status::plumbing::index_as_worktree::{Change, EntryStatus}; + + let (kind, source, path, diff, tracked) = match item { + Item::Modification { + entry, + rela_path, + status, + .. + } => { + let old = worktree_resource(&entry, rela_path.as_bstr())?; + match status { + EntryStatus::Conflict { .. } => ( + ChangeKind::Unmerged, + None, + rela_path, + FileChange::Unavailable("an unmerged path has no single file diff"), + true, + ), + EntryStatus::IntentToAdd => ( + ChangeKind::Added, + None, + rela_path.clone(), + FileChange::Worktree { + old: None, + new: Some(DiffResource { + id: entry.id.kind().null(), + mode: old.mode, + path: rela_path, + }), + }, + true, + ), + EntryStatus::NeedsUpdate(_) => return Ok(None), + EntryStatus::Change(Change::Removed) => ( + ChangeKind::Deleted, + None, + rela_path, + FileChange::Worktree { + old: Some(old), + new: None, + }, + true, + ), + EntryStatus::Change(Change::Type { worktree_mode }) => { + let new_mode = entry_mode(worktree_mode)?; + ( + ChangeKind::TypeChanged, + None, + rela_path.clone(), + FileChange::Worktree { + old: Some(old), + new: Some(DiffResource { + id: entry.id.kind().null(), + mode: new_mode, + path: rela_path, + }), + }, + true, + ) + } + EntryStatus::Change(Change::Modification { + executable_bit_changed, .. + }) => { + let mode = if executable_bit_changed { + if old.mode.is_executable() { + gix::objs::tree::EntryKind::Blob + } else { + gix::objs::tree::EntryKind::BlobExecutable + } + .into() + } else { + old.mode + }; + ( + ChangeKind::Modified, + None, + rela_path.clone(), + FileChange::Worktree { + old: Some(old), + new: Some(DiffResource { + id: entry.id.kind().null(), + mode, + path: rela_path, + }), + }, + true, + ) + } + EntryStatus::Change(Change::SubmoduleModification(_)) => ( + ChangeKind::Modified, + None, + rela_path, + FileChange::Unavailable("submodule changes don't have a file diff"), + true, + ), + } + } + Item::DirectoryContents { entry, .. } => { + let mode = match entry.disk_kind { + Some(gix::dir::entry::Kind::File) => gix::objs::tree::EntryKind::Blob.into(), + Some(gix::dir::entry::Kind::Symlink) => gix::objs::tree::EntryKind::Link.into(), + _ => return Ok(None), + }; + let path = entry.rela_path; + ( + ChangeKind::Added, + None, + path.clone(), + FileChange::Worktree { + old: None, + new: Some(DiffResource { + id: object_hash.null(), + mode, + path, + }), + }, + false, + ) + } + Item::Rewrite { + source, + dirwalk_entry, + copy, + .. + } => { + let source = source.rela_path().to_owned(); + let path = dirwalk_entry.rela_path; + ( + if copy { ChangeKind::Copied } else { ChangeKind::Renamed }, + Some(source), + path, + FileChange::Unavailable("unstaged rewrite diffs aren't available"), + true, + ) + } + }; + Ok(Some(( + PathChange { + kind, + group: ChangeGroup::Unstaged, + source, + path, + lines: None, + }, + diff, + tracked, + ))) +} + +fn load_worktree_changes_without_lines(repository: &gix::Repository) -> Result<Changes> { + let mut status = repository + .status(gix::progress::Discard) + .context("could not initialize worktree status")? + .untracked_files(gix::status::UntrackedFiles::Files) + .index_worktree_options_mut(|options| { + options.sorting = Some(gix::status::plumbing::index_as_worktree_with_renames::Sorting::ByPathCaseSensitive); + }) + .into_iter(Vec::<BString>::new()) + .context("could not start worktree status")?; + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut has_tracked_changes = false; + for item in status.by_ref() { + match item.context("could not obtain worktree status")? { + gix::status::Item::TreeIndex(change) => { + has_tracked_changes = true; + staged.push(staged_change(change)?); + } + gix::status::Item::IndexWorktree(item) => { + if let Some((path, diff, tracked)) = unstaged_change(item, repository.object_hash())? { + has_tracked_changes |= tracked; + unstaged.push((path, diff)); + } + } + } + } + drop(status); + staged.sort_by(|(a, _), (b, _)| a.path.cmp(&b.path)); + unstaged.sort_by(|(a, _), (b, _)| a.path.cmp(&b.path)); + staged.extend(unstaged); + + let (paths, diffs): (Vec<_>, Vec<_>) = staged.into_iter().unzip(); + Ok(Changes { + paths, + diffs, + has_tracked_changes, + ..Changes::default() + }) +} + +fn load_unstaged_changes_without_lines(repository: &gix::Repository, patterns: Vec<BString>) -> Result<Changes> { + let mut status = repository + .status(gix::progress::Discard) + .context("could not initialize incremental worktree status")? + .untracked_files(gix::status::UntrackedFiles::Files) + .into_index_worktree_iter(patterns) + .context("could not start incremental worktree status")?; + let mut unstaged = Vec::new(); + let mut has_tracked_changes = false; + for item in status.by_ref() { + if let Some((path, diff, tracked)) = unstaged_change( + item.context("could not obtain incremental worktree status")?, + repository.object_hash(), + )? { + has_tracked_changes |= tracked; + unstaged.push((path, diff)); + } + } + drop(status); + unstaged.sort_by(|(a, _), (b, _)| a.path.cmp(&b.path)); + let (paths, diffs) = unstaged.into_iter().unzip(); + Ok(Changes { + paths, + diffs, + has_tracked_changes, + ..Changes::default() + }) +} + +fn load_staged_changes_without_lines(repository: &gix::Repository) -> Result<Changes> { + let head_tree = repository + .head_tree_id_or_empty() + .context("could not resolve HEAD tree for staged status")?; + let index = repository + .index_or_empty() + .context("could not open index for staged status")?; + let mut pathspec = repository + .pathspec( + false, + None::<&str>, + false, + &index, + gix::worktree::stack::state::attributes::Source::IdMapping, + ) + .context("could not initialize staged status pathspec")?; + let mut raw = Vec::new(); + repository + .tree_index_status( + &head_tree, + &index, + Some(&mut pathspec), + gix::status::tree_index::TrackRenames::AsConfigured, + |change, _, _| { + raw.push(change.into_owned()); + Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Continue(())) + }, + ) + .context("could not obtain staged status")?; + let mut staged = raw.into_iter().map(staged_change).collect::<Result<Vec<_>>>()?; + let has_tracked_changes = !staged.is_empty(); + staged.sort_by(|(a, _), (b, _)| a.path.cmp(&b.path)); + let (paths, diffs) = staged.into_iter().unzip(); + Ok(Changes { + paths, + diffs, + has_tracked_changes, + ..Changes::default() + }) +} + +fn add_worktree_line_counts(mut out: Changes, line_diff_pool: &mut LineDiffPool) -> Result<Changes> { + let diffs = std::mem::take(&mut out.diffs); + for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { + path.lines = lines; + if let Some((insertions, removals)) = lines { + out.lines_added += u64::from(insertions); + out.lines_removed += u64::from(removals); + } + out.diffs.push(change); + } + Ok(out) +} + +fn load_worktree_changes(repository: &gix::Repository, line_diff_pool: &mut LineDiffPool) -> Result<Changes> { + add_worktree_line_counts(load_worktree_changes_without_lines(repository)?, line_diff_pool) +} + +fn literal_status_patterns(repository: &gix::Repository, scopes: &HashSet<BString>) -> Result<Option<Vec<BString>>> { + let defaults = repository + .pathspec_defaults() + .context("could not load pathspec defaults for incremental status")?; + if defaults.literal || defaults.signature.contains(gix::pathspec::MagicSignature::ICASE) { + return Ok(None); + } + Ok(Some( + scopes + .iter() + .map(|scope| { + gix::pathspec::Pattern::from_literal(scope.as_slice(), gix::pathspec::MagicSignature::TOP).to_bstring() + }) + .collect(), + )) +} + +fn path_is_in_status_scope(path: &BString, scope: &BString, ignore_case: bool) -> bool { + let Some(prefix) = path.get(..scope.len()) else { + return false; + }; + let prefix_matches = if ignore_case { + prefix.eq_ignore_ascii_case(scope.as_slice()) + } else { + prefix == scope.as_slice() + }; + prefix_matches && (path.len() == scope.len() || path.get(scope.len()) == Some(&b'/')) +} + +fn replace_cached_changes( + repository: &gix::Repository, + cached: &mut Changes, + replacement: Changes, + mut replace: impl FnMut(&PathChange) -> bool, +) -> Result<()> { + let mut pairs: Vec<_> = std::mem::take(&mut cached.paths) + .into_iter() + .zip(std::mem::take(&mut cached.diffs)) + .filter(|(change, _)| !replace(change)) + .collect(); + pairs.extend(replacement.paths.into_iter().zip(replacement.diffs)); + pairs.sort_by(|(a, _), (b, _)| { + let rank = |group| match group { + ChangeGroup::Staged => 0, + ChangeGroup::Unstaged => 1, + ChangeGroup::Tree => 2, + }; + rank(a.group).cmp(&rank(b.group)).then_with(|| a.path.cmp(&b.path)) + }); + (cached.paths, cached.diffs) = pairs.into_iter().unzip(); + (cached.lines_added, cached.lines_removed) = cached + .paths + .iter() + .filter_map(|change| change.lines) + .fold((0, 0), |(added, removed), (a, r)| { + (added + u64::from(a), removed + u64::from(r)) + }); + let index = repository + .index_or_empty() + .context("could not open index after incremental status")?; + cached.has_tracked_changes = cached.paths.iter().any(|change| { + change.group == ChangeGroup::Staged + || change.group == ChangeGroup::Unstaged + && (index.entry_by_path(change.path.as_bstr()).is_some() + || change + .source + .as_ref() + .is_some_and(|source| index.entry_by_path(source.as_bstr()).is_some())) + }); + Ok(()) +} + +fn update_worktree_changes( + repository: &gix::Repository, + cached: &mut Changes, + parts: &WorktreeStatusParts, + line_diff_pool: &mut LineDiffPool, +) -> Result<bool> { + if parts.staged { + let staged = add_worktree_line_counts(load_staged_changes_without_lines(repository)?, line_diff_pool)?; + replace_cached_changes(repository, cached, staged, |change| change.group == ChangeGroup::Staged)?; + } + if !parts.scopes.is_empty() { + let Some(patterns) = literal_status_patterns(repository, &parts.scopes)? else { + *cached = load_worktree_changes(repository, line_diff_pool)?; + return Ok(true); + }; + let unstaged = add_worktree_line_counts( + load_unstaged_changes_without_lines(repository, patterns)?, + line_diff_pool, + )?; + let ignore_case = repository.filesystem_options()?.ignore_case; + replace_cached_changes(repository, cached, unstaged, |change| { + change.group == ChangeGroup::Unstaged + && parts + .scopes + .iter() + .any(|scope| path_is_in_status_scope(&change.path, scope, ignore_case)) + })?; + } + Ok(false) +} + +fn actor_bytes(author: &app::Author) -> Vec<u8> { + let mut out = Vec::with_capacity(author.name.len() + author.email.len() + 3); + out.extend_from_slice(author.name); + out.extend_from_slice(b" <"); + out.extend_from_slice(author.email); + out.push(b'>'); + out +} + +fn should_draw(dirty: bool, streaming: bool, since_draw: Duration) -> bool { + dirty && (!streaming || since_draw >= FRAME_INTERVAL) +} + +fn history_is_ready_to_draw(state: State, commits: usize) -> bool { + commits != 0 || state != State::Loading +} + +fn poll_timeout( + streaming: bool, + events: usize, + dirty: bool, + since_draw: Duration, + wake_after: Option<Duration>, +) -> Option<Duration> { + let frame_timeout = streaming.then(|| { + if events == EVENT_BATCH_SIZE { + Duration::ZERO + } else if dirty { + FRAME_INTERVAL.saturating_sub(since_draw) + } else { + FRAME_INTERVAL + } + }); + match (frame_timeout, wake_after) { + (Some(frame), Some(wake_after)) => Some(frame.min(wake_after)), + (Some(frame), None) => Some(frame), + (None, wake_after) => wake_after, + } +} + +fn action(key: KeyEvent) -> Option<Action> { + action_with_shortcut_groups(key, false, false, false, false) +} + +#[derive(Debug, Eq, PartialEq)] +enum CommandMenuInput { + Pass, + Handled, + Submit(Action), +} + +fn command_picker_items(commands: &[MenuCommand]) -> Vec<MenuItem<'_, CommandId>> { + commands + .iter() + .map(|command| { + MenuItem::with_search_prefix(command.label, command.group.label(), command.group.prefix(), command.id) + }) + .collect() +} + +fn opens_command_menu(event: &TerminalEvent, actions_expanded: bool, command_menu_open: bool) -> bool { + !actions_expanded + && !command_menu_open + && matches!( + event, + TerminalEvent::Key(KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + }) + ) +} + +fn swallow_command_menu_key_event(event: &TerminalEvent, suppressed: &mut Option<KeyCode>) -> bool { + let Some(expected) = suppressed.take() else { + return false; + }; + match event { + TerminalEvent::Key(key) if key.code == expected && key.kind == KeyEventKind::Repeat => { + *suppressed = Some(expected); + true + } + TerminalEvent::Key(key) if key.code == expected && key.kind == KeyEventKind::Release => true, + _ => false, + } +} + +fn command_menu_input(event: &TerminalEvent, menu: &mut Menu<CommandId>, commands: &[MenuCommand]) -> CommandMenuInput { + let items = command_picker_items(commands); + match event { + TerminalEvent::FocusGained | TerminalEvent::FocusLost | TerminalEvent::Resize(_, _) => CommandMenuInput::Pass, + TerminalEvent::Mouse(_) => CommandMenuInput::Handled, + TerminalEvent::Paste(text) => { + menu.paste(text, &items); + CommandMenuInput::Handled + } + TerminalEvent::Key(key) if key.kind == KeyEventKind::Release => CommandMenuInput::Handled, + TerminalEvent::Key(key) if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) => { + CommandMenuInput::Pass + } + TerminalEvent::Key(key) => { + let selected = match key.code { + KeyCode::Esc => { + menu.close(); + return CommandMenuInput::Handled; + } + KeyCode::Enter => menu.submit_selected(&items), + KeyCode::Up => { + menu.up(&items); + None + } + KeyCode::Down => { + menu.down(&items); + None + } + KeyCode::Left => { + menu.left(); + None + } + KeyCode::Right => { + menu.right(); + None + } + KeyCode::Home => { + menu.home(); + None + } + KeyCode::End => { + menu.end(); + None + } + KeyCode::Backspace => { + menu.backspace(&items); + None + } + KeyCode::Delete => { + menu.delete(&items); + None + } + KeyCode::Char('p') if key.kind == KeyEventKind::Repeat && menu.query().is_empty() => None, + KeyCode::Char(digit) + if digit.is_ascii_digit() + && !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + menu.submit_digit(digit, &items) + } + KeyCode::Char(character) if !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + menu.insert(character, &items); + None + } + _ => None, + }; + selected.map_or(CommandMenuInput::Handled, |id| { + CommandMenuInput::Submit( + commands + .iter() + .find(|command| command.id == id) + .expect("a submitted command came from the current catalog") + .action + .clone(), + ) + }) + } + } +} + +fn resolve_pasted_commit(repository: &gix::Repository, pasted: &str) -> Result<gix::ObjectId> { + let hash = pasted.trim(); + anyhow::ensure!( + !hash.is_empty() && hash.bytes().all(|byte| byte.is_ascii_hexdigit()), + "expected exactly one hexadecimal commit ID" + ); + let object = repository + .rev_parse(hash.as_bytes().as_bstr()) + .context("could not resolve pasted commit ID")? + .single() + .context("pasted commit ID is ambiguous")? + .object() + .context("could not read pasted object")?; + anyhow::ensure!( + object.kind == gix::object::Kind::Commit, + "pasted object is not a commit" + ); + Ok(object.id) +} + +fn diagnostic_key(character: char) -> KeyEvent { + let code = match character { + '\t' => KeyCode::Tab, + '\n' | '\r' => KeyCode::Enter, + '\u{1b}' => KeyCode::Esc, + character => KeyCode::Char(character), + }; + let modifiers = if character.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + KeyEvent::new(code, modifiers) +} + +fn next_diagnostic_input(inputs: &mut VecDeque<KeyEvent>, state: State, lane_computing: bool) -> Option<KeyEvent> { + (state == State::Complete && !lane_computing) + .then(|| inputs.pop_front()) + .flatten() +} + +fn diagnostic_action(key: KeyEvent, app: &App) -> Option<Action> { + app_action(key, app).filter(|action| { + (action_allowed_during_rebase_continuation(Some(action), app.changes_focus.is_some()) + || matches!(action, Action::ToggleActions | Action::ToggleEnrich)) + && !matches!( + action, + Action::Copy | Action::CopyPath(_) | Action::CopyAuthor | Action::ForceQuit | Action::Quit + ) + }) +} + +fn app_action(key: KeyEvent, app: &App) -> Option<Action> { + if key.kind != KeyEventKind::Release { + let shifted = + key.modifiers.contains(KeyModifiers::SHIFT) || matches!(key.code, KeyCode::Char('H' | 'J' | 'K' | 'L')); + if shifted { + if app.changes_focus.is_none() { + match key.code { + KeyCode::Up | KeyCode::Char('k' | 'K') => return Some(Action::TopologicalUp), + KeyCode::Down | KeyCode::Char('j' | 'J') => return Some(Action::TopologicalDown), + KeyCode::Left | KeyCode::Char('h' | 'H') => return Some(Action::PreviousChild), + KeyCode::Right | KeyCode::Char('l' | 'L') => return Some(Action::NextChild), + _ => {} + } + } else { + match key.code { + KeyCode::Up | KeyCode::Char('k' | 'K') => return Some(Action::MoveUp), + KeyCode::Down | KeyCode::Char('j' | 'J') => return Some(Action::MoveDown), + KeyCode::Left | KeyCode::Char('h' | 'H') => return Some(Action::ScrollLeft), + KeyCode::Right | KeyCode::Char('l' | 'L') => return Some(Action::ScrollRight), + _ => {} + } + } + } + + let paging = match key.code { + KeyCode::PageUp => Some((Action::PageUp, app.viewport_rows.max(1), false)), + KeyCode::PageDown => Some((Action::PageDown, app.viewport_rows.max(1), true)), + KeyCode::Char('u' | 'U') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some((Action::HalfPageUp, (app.viewport_rows / 2).max(1), false)) + } + KeyCode::Char('d' | 'D') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some((Action::HalfPageDown, (app.viewport_rows / 2).max(1), true)) + } + KeyCode::Char('b' | 'B') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some((Action::PageUp, app.viewport_rows.max(1), false)) + } + KeyCode::Char('f' | 'F') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some((Action::PageDown, app.viewport_rows.max(1), true)) + } + _ => None, + }; + if let Some((cursor_action, distance, down)) = paging { + let shifted = + key.modifiers.contains(KeyModifiers::SHIFT) || matches!(key.code, KeyCode::Char('U' | 'D' | 'B' | 'F')); + if !shifted || app.changes_focus.is_some() || app.commit_paging_active() { + return Some(cursor_action); + } + return Some(if down { + Action::PanDownBy(distance) + } else { + Action::PanUpBy(distance) + }); + } + } + action_with_shortcut_groups( + key, + app.history_display_expanded, + app.actions_expanded, + app.enrich_expanded, + app.information_expanded || app.changes_focus.is_some(), + ) +} + +fn action_allowed_during_rebase_continuation(action: Option<&Action>, changes_focused: bool) -> bool { + matches!( + action, + None | Some( + Action::MoveUp + | Action::MoveDown + | Action::MoveUpBy(_) + | Action::MoveDownBy(_) + | Action::PanUpBy(_) + | Action::PanDownBy(_) + | Action::TopologicalUp + | Action::TopologicalDown + | Action::PreviousChild + | Action::NextChild + | Action::CycleDuplicate + | Action::ScrollLeft + | Action::ScrollRight + | Action::HalfPageUp + | Action::HalfPageDown + | Action::PageUp + | Action::PageDown + | Action::First + | Action::Last + | Action::ToggleDate + | Action::CycleIds + | Action::ToggleName + | Action::ToggleEmail + | Action::ToggleTrailers + | Action::ToggleMailmap + | Action::CycleRefs + | Action::ToggleRefs + | Action::ToggleHistoryDisplay + | Action::ToggleInformation + | Action::ToggleAlign + | Action::ToggleCommit + | Action::ToggleChanges + | Action::ToggleChangesFocus + | Action::CycleChangesParent + | Action::Copy + | Action::CopyPath(_) + | Action::CopyAuthor + | Action::ForceQuit + | Action::Quit + ) + ) || (changes_focused && matches!(action, Some(Action::OpenDiff | Action::Cancel | Action::Quit))) +} + +fn action_with_shortcut_groups( + key: KeyEvent, + history_display_expanded: bool, + actions_expanded: bool, + enrich_expanded: bool, + information_expanded: bool, +) -> Option<Action> { + if key.kind == KeyEventKind::Release { + return None; + } + match key.code { + KeyCode::Tab => Some(Action::ToggleChangesFocus), + KeyCode::Enter => Some(Action::OpenDiff), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::ForceQuit), + KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), + KeyCode::Char('a') => Some(Action::ToggleActions), + KeyCode::Char('?') => Some(Action::ToggleInformation), + KeyCode::Char('/') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::ToggleInformation), + KeyCode::Char('b') if actions_expanded && !key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Action::Rebase) + } + KeyCode::Char('U') => Some(Action::Redo), + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Redo), + KeyCode::Char('u') + if actions_expanded && !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::SHIFT) => + { + Some(Action::RebaseUpdate) + } + KeyCode::Char('u') if !key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Undo), + KeyCode::Char('r') if actions_expanded => Some(Action::Review), + KeyCode::Char('s') if actions_expanded => Some(Action::Squash), + KeyCode::Char('y') if actions_expanded => Some(Action::CopyInsert), + KeyCode::Char('m') if actions_expanded => Some(Action::MoveInsert), + KeyCode::Char('t') if actions_expanded => Some(Action::StackInsert), + KeyCode::Char('f') if actions_expanded && !key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Action::ForkCommit) + } + KeyCode::Char('h') if actions_expanded => Some(Action::Attach), + KeyCode::Char('z') if actions_expanded => Some(Action::Stash), + KeyCode::Char('o') if actions_expanded => Some(Action::Reword), + KeyCode::Char('w') if actions_expanded => Some(Action::NewCommit), + KeyCode::Char('n') if actions_expanded => Some(Action::NewEmptyCommit), + KeyCode::Char('e') if actions_expanded => Some(Action::Amend), + KeyCode::Char('l') if actions_expanded => Some(Action::Spill), + KeyCode::Char('p') if actions_expanded && !key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Split), + KeyCode::Char('d') if actions_expanded => Some(Action::Forget), + KeyCode::Char('i') if actions_expanded => Some(Action::TogglePin), + KeyCode::Char('n') => Some(Action::ToggleEnrich), + KeyCode::Char('P') => Some(Action::CycleChangesParent), + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::CycleChangesParent), + KeyCode::Char('q') => Some(Action::Quit), + KeyCode::Esc => Some(Action::Cancel), + KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), + KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown), + KeyCode::Char('x') => Some(Action::CycleDuplicate), + KeyCode::Char('h') if history_display_expanded => Some(Action::ToggleHidden), + KeyCode::Char('h') => Some(Action::ScrollLeft), + KeyCode::Char('l') => Some(Action::ScrollRight), + KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::PageUp), + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::PageDown), + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::HalfPageUp), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::HalfPageDown), + KeyCode::PageUp => Some(Action::PageUp), + KeyCode::PageDown => Some(Action::PageDown), + KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Last), + KeyCode::Char('g') if enrich_expanded => Some(Action::EditGitNote), + KeyCode::Home | KeyCode::Char('g') => Some(Action::First), + KeyCode::End | KeyCode::Char('G') => Some(Action::Last), + KeyCode::Char('d') if history_display_expanded => Some(Action::ToggleDate), + KeyCode::Char('i') if history_display_expanded => Some(Action::CycleIds), + KeyCode::Char('s') if history_display_expanded => Some(Action::ToggleEmail), + KeyCode::Char('e') if history_display_expanded => Some(Action::ToggleName), + KeyCode::Char('t') if history_display_expanded => Some(Action::ToggleTrailers), + KeyCode::Char('m') if history_display_expanded => Some(Action::ToggleMailmap), + KeyCode::Char('R') => Some(Action::Refresh), + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), + KeyCode::Char('r') if history_display_expanded => Some(Action::CycleRefs), + KeyCode::Char('e') if enrich_expanded => Some(Action::ToggleChecksPass), + KeyCode::Char('t') if enrich_expanded => Some(Action::ToggleTodo), + KeyCode::Char('o') if enrich_expanded => Some(Action::EditNote), + KeyCode::Char('e') if information_expanded => Some(Action::ToggleChanges), + KeyCode::Char('@') => Some(Action::TimeTravel), + KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::TimeTravel), + KeyCode::Char('m') => Some(Action::ToggleCommit), + KeyCode::Char('r') => Some(Action::ToggleRefs), + KeyCode::Char('s') => Some(Action::VerifySignatures), + KeyCode::Char('t') => Some(Action::ToggleRefTree), + KeyCode::Char('[') => Some(Action::ToggleAlign), + KeyCode::Char(']') => Some(Action::ToggleCommit), + KeyCode::Char('Y') => Some(Action::CopyAuthor), + KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::CopyAuthor), + KeyCode::Char('y') => Some(Action::Copy), + _ => None, + } +} + +fn copy_selected_path_action( + action: Action, + app: &App, + tree_changes: Option<&Changes>, + worktree_changes: Option<&Changes>, +) -> Action { + if action != Action::Copy { + return action; + } + let (pane, changes) = match app.changes_focus { + Some(pane @ ChangePane::Tree) => (pane, tree_changes), + Some(pane @ ChangePane::Worktree) => (pane, worktree_changes), + None => return action, + }; + changes + .and_then(|changes| changes.paths.get(app.changes(pane).selected)) + .map_or(action, |change| Action::CopyPath(change.path.clone())) +} + +fn repeats_viewport(action: &Action) -> bool { + matches!( + action, + Action::MoveUp + | Action::MoveDown + | Action::MoveUpBy(_) + | Action::MoveDownBy(_) + | Action::PanUpBy(_) + | Action::PanDownBy(_) + | Action::TopologicalUp + | Action::TopologicalDown + | Action::HalfPageUp + | Action::HalfPageDown + | Action::PageUp + | Action::PageDown + | Action::First + | Action::Last + ) +} + +fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>, changes_focused: bool) -> bool { + !changes_focused && kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) +} + +fn mouse_scroll_action( + kind: MouseEventKind, + modifiers: KeyModifiers, + distance: usize, + changes_focused: bool, +) -> Option<Action> { + let shifted = modifiers.contains(KeyModifiers::SHIFT); + match kind { + MouseEventKind::ScrollUp if shifted || changes_focused => Some(Action::MoveUpBy(distance.max(1))), + MouseEventKind::ScrollDown if shifted || changes_focused => Some(Action::MoveDownBy(distance.max(1))), + MouseEventKind::ScrollUp => Some(Action::PanUpBy(distance.max(1))), + MouseEventKind::ScrollDown => Some(Action::PanDownBy(distance.max(1))), + MouseEventKind::ScrollLeft if shifted => Some(Action::PreviousChild), + MouseEventKind::ScrollRight if shifted => Some(Action::NextChild), + MouseEventKind::ScrollLeft => Some(Action::ScrollLeft), + MouseEventKind::ScrollRight => Some(Action::ScrollRight), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pasted_commit_ids_are_hex_only_and_must_name_commit_objects() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = test_repository::open(fixture.path())?; + let commit = repository.rev_parse_single("topic")?.detach(); + let abbreviated = commit.to_hex_with_len(8).to_string(); + + assert_eq!( + resolve_pasted_commit(&repository, &format!("\n{abbreviated}\n"))?, + commit + ); + assert_eq!(resolve_pasted_commit(&repository, &commit.to_string())?, commit); + for invalid in ["topic", "dead beef", ""] { + assert!( + resolve_pasted_commit(&repository, invalid).is_err(), + "{invalid:?} is not exactly one hexadecimal object ID" + ); + } + let blob = repository.write_blob(b"not a commit")?.detach(); + assert!( + resolve_pasted_commit(&repository, &blob.to_string()).is_err(), + "an existing non-commit object is rejected" + ); + Ok(()) + } + + #[test] + fn command_menu_intercepts_text_paste_and_recalls_exact_submissions() { + let app = App::new(1); + let commands = command_menu::commands(&app, &Decorations::default(), false); + let items = command_picker_items(&commands); + let mut menu = Menu::default(); + menu.open(&items); + + assert_eq!( + command_menu_input( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut menu, + &commands, + ), + CommandMenuInput::Handled, + "the first opening has no default command" + ); + assert_eq!( + command_menu_input(&TerminalEvent::Paste("r\nf\tt".into()), &mut menu, &commands), + CommandMenuInput::Handled, + "paste edits the query instead of reaching commit paste" + ); + assert_eq!(menu.query(), "rft"); + assert_eq!( + command_menu_input( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut menu, + &commands, + ), + CommandMenuInput::Submit(Action::ToggleRefTree) + ); + + menu.open(&items); + assert_eq!( + command_menu_input( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut menu, + &commands, + ), + CommandMenuInput::Submit(Action::ToggleRefTree), + "reopening recalls the exact submitted command" + ); + menu.open(&items); + assert_eq!( + command_menu_input( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)), + &mut menu, + &commands, + ), + CommandMenuInput::Submit(Action::ToggleAlign), + "digits execute their visible row" + ); + } + + #[test] + fn command_menu_opener_does_not_steal_prefixed_or_shifted_p() { + assert!(opens_command_menu( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), + false, + false, + )); + assert!(!opens_command_menu( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), + true, + false, + )); + assert!(!opens_command_menu( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), + false, + true, + )); + assert!(!opens_command_menu( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::SHIFT)), + false, + false, + )); + assert!(!opens_command_menu( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)), + false, + false, + )); + assert!(!opens_command_menu( + &TerminalEvent::Key(KeyEvent::new_with_kind( + KeyCode::Char('p'), + KeyModifiers::NONE, + KeyEventKind::Repeat, + )), + false, + false, + )); + + let app = App::new(1); + let commands = command_menu::commands(&app, &Decorations::default(), false); + let items = command_picker_items(&commands); + let mut menu = Menu::default(); + menu.open(&items); + assert_eq!( + command_menu_input( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), + &mut menu, + &commands, + ), + CommandMenuInput::Handled + ); + assert_eq!(menu.query(), "p", "an open command menu receives p as query text"); + } + + #[test] + fn command_menu_closing_key_repeats_do_not_reach_the_main_view() { + let mut suppressed = Some(KeyCode::Enter); + assert!(swallow_command_menu_key_event( + &TerminalEvent::Key(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Repeat, + )), + &mut suppressed, + )); + assert_eq!(suppressed, Some(KeyCode::Enter)); + assert!(swallow_command_menu_key_event( + &TerminalEvent::Key(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + KeyEventKind::Release, + )), + &mut suppressed, + )); + assert_eq!(suppressed, None); + + let mut suppressed = Some(KeyCode::Esc); + assert!(!swallow_command_menu_key_event( + &TerminalEvent::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), + &mut suppressed, + )); + assert_eq!(suppressed, None, "another key ends suppression and remains actionable"); + } + + #[test] + fn shades_terminal_background_by_one_sixteenth() { + assert_eq!(shade_terminal_background((0, 0, 0), true), (15, 15, 15)); + assert_eq!(shade_terminal_background((255, 255, 255), false), (240, 240, 240)); + assert_eq!(shade_terminal_background((32, 64, 128), true), (45, 75, 135)); + assert_eq!(shade_terminal_background((32, 64, 128), false), (30, 60, 120)); + } + + #[test] + fn scans_change_ids_only_for_the_current_filtered_view() { + let mut app = App::new(1); + assert!(!change_id_scan_needed(&app), "unrestricted history needs no scan"); + + app.configure_hidden_filter(true); + assert!( + change_id_scan_needed(&app), + "excluded hidden tips make the view limited" + ); + + app.show_hidden = true; + assert!( + !change_id_scan_needed(&app), + "expanding hidden history makes the current view unrestricted" + ); + } + + #[test] + fn pending_changes_are_recorded_before_they_are_cleared() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = test_repository::open(fixture.path())?; + let id = repository.rev_parse_single("topic")?.detach(); + let name: gix::refs::FullName = "refs/heads/cancelled-conflict".try_into()?; + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["update-ref", name.as_bstr().to_str_lossy().as_ref(), &id.to_string()]) + .status()?; + assert!( + status.success(), + "the cancelled operation has an applied reference change" + ); + let mut changes = vec![edit::undo::RefChange { + name: name.clone(), + before: edit::undo::State::Missing, + after: edit::undo::State::Object(id), + }]; + + record_and_clear_pending_undo(fixture.path(), false, "materialize rebase conflict", &mut changes)?; + assert!(changes.is_empty(), "the cancellation releases its accumulator"); + + let repository = test_repository::open(fixture.path())?; + edit::undo::plan_undo(&repository)? + .expect("the cancelled operation was journalled") + .apply(&repository)?; + assert!( + repository.try_find_reference(name.as_ref())?.is_none(), + "the recorded cancellation remains undoable" + ); + Ok(()) + } + + #[test] + fn recognizes_an_external_conflict_amend_and_keeps_it_undoable() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repository = test_repository::open(fixture.path())?; + let original = repository.head_id()?.detach(); + let mut commit = repository.find_commit(original)?.decode()?.into_owned()?; + let original_parent = commit + .parents + .first() + .copied() + .context("the fixture tip has a parent")?; + commit + .extra_headers + .push(("tix-rebase-parent".into(), original_parent.to_string().into())); + let accepted = repository.write_object(&commit)?.detach(); + let status = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args([ + "update-ref", + "refs/heads/main", + &accepted.to_string(), + &original.to_string(), + ]) + .status()?; + assert!(status.success(), "the fixture checks out a materialized pending commit"); + let head = conflict_head(fixture.path(), false, accepted)?; + let mut pending = Some(PendingConflictResolution { + commit: accepted, + head: Some(head), + ref_changes: Vec::new(), + record_undo: true, + }); + let status = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["commit", "--amend", "-qm", "externally resolved"]) + .status()?; + assert!(status.success(), "git performs the external amend"); + let replacement = test_repository::open(fixture.path())?.head_id()?.detach(); + assert_ne!(replacement, accepted, "the external amend replaces HEAD"); + let replacement_commit = test_repository::open(fixture.path())? + .find_commit(replacement)? + .decode()? + .into_owned()?; + assert!( + edit::rebase::is_pending(&replacement_commit), + "git preserves the pending marker while amending" + ); + + let mut app = App::new(1); + assert_eq!( + reconcile_external_conflict_reporting(&mut app, fixture.path(), false, &mut pending), + ConflictReconcileStatus::Complete, + "a clean same-parent replacement completes conflict resolution" + ); + assert!(pending.is_none(), "completed conflict state is released"); + + let repository = test_repository::open(fixture.path())?; + let finalized = repository.head_id()?.detach(); + assert_ne!(finalized, replacement, "Tix strips the preserved pending marker"); + assert!( + !edit::rebase::is_pending(&repository.find_commit(finalized)?.decode()?.into_owned()?), + "the recognized resolution is fully materialized" + ); + edit::undo::plan_undo(&repository)? + .expect("the external amend was added to the undo queue") + .apply(&repository)?; + assert_eq!( + repository.head_id()?, + accepted, + "undo restores the materialized conflict commit" + ); + Ok(()) + } + + #[test] + fn external_conflict_resolution_requires_a_commit_and_rejects_unrelated_head_moves() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let accepted = test_repository::open(fixture.path())?.head_id()?.detach(); + let head = conflict_head(fixture.path(), false, accepted)?; + let mut pending = Some(PendingConflictResolution { + commit: accepted, + head: Some(head), + ref_changes: Vec::new(), + record_undo: true, + }); + std::fs::write(fixture.path().join("file"), "resolved but not committed\n")?; + let status = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "file"]) + .status()?; + assert!(status.success(), "the resolution is staged"); + let mut app = App::new(1); + assert_eq!( + reconcile_external_conflict_reporting(&mut app, fixture.path(), false, &mut pending), + ConflictReconcileStatus::Amend, + "staging alone still requires an amend" + ); + assert!(pending.is_some(), "staged state remains mandatory"); + + let status = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["reset", "--hard", "HEAD~1"]) + .status()?; + assert!(status.success(), "git moves HEAD away from the conflict checkout"); + assert_eq!( + reconcile_external_conflict_reporting(&mut app, fixture.path(), false, &mut pending), + ConflictReconcileStatus::Blocked, + "an unrelated external HEAD move is not mistaken for conflict resolution" + ); + assert!( + pending.is_some(), + "invalid external movement remains explicit until exit" + ); + Ok(()) + } + + #[test] + fn retains_unseen_filesystem_redraws_until_focus_returns() { + assert!(!unseen_filesystem_redraw(false, false, false)); + assert!(unseen_filesystem_redraw(false, false, true)); + assert!(unseen_filesystem_redraw(true, false, false)); + assert!(!unseen_filesystem_redraw(true, true, true)); + } + + #[test] + fn follows_a_reference_across_a_rewrite() { + let old = gix::ObjectId::Sha1([1; 20]); + let new = gix::ObjectId::Sha1([2; 20]); + let decoration = history::Decoration { + name: "refs/patches/topic/selected".into(), + kind: history::DecorationKind::Special, + }; + let current = Decorations::from([(old, vec![decoration.clone()])]); + let next = Decorations::from([(new, vec![decoration])]); + + assert_eq!(decoration_successor(old, ¤t, &next), Some(new)); + } + + #[test] + fn finds_the_current_worktree_branch_in_attached_and_remembered_states() { + let id = gix::ObjectId::Sha1([1; 20]); + let mut refs = history::RefSnapshot { + view: Default::default(), + hidden: Default::default(), + view_tips: Vec::new(), + hidden_tips: Vec::new(), + pins: Vec::new(), + worktrees: vec![history::WorktreeCheckout { + id, + label_id: id, + checkout_name: "main".into(), + reference: Some("refs/heads/main".try_into().expect("valid branch name")), + is_current: true, + is_detached: false, + }], + }; + assert_eq!(current_worktree_branch(&refs), Some((id, false))); + + refs.worktrees[0].is_detached = true; + assert_eq!(current_worktree_branch(&refs), Some((id, true))); + refs.worktrees[0].reference = None; + assert_eq!( + current_worktree_branch(&refs), + None, + "manual detachment has no branch to move" + ); + } + + #[test] + fn reference_watcher_observes_new_loose_refs() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = test_repository::open(fixture.path())?; + let watcher = start_ref_watcher(repository.git_dir(), repository.common_dir())?; + let topic = repository.rev_parse_single("topic")?.detach(); + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["update-ref", "refs/heads/watched", &topic.to_hex().to_string()]) + .status()?; + assert!(status.success(), "git updates a loose reference"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut paths = Vec::new(); + let watched = repository.git_dir().join("refs/heads/watched"); + while Instant::now() < deadline { + let event = watcher + .events + .recv_timeout(deadline.saturating_duration_since(Instant::now()))??; + if !notification_is_actionable(&event) { + continue; + } + paths.extend(event.paths); + if watched.is_file() { + break; + } + } + assert!( + watched.is_file(), + "the completed loose-reference transaction is actionable: {paths:?}" + ); + Ok(()) + } + + #[test] + fn worktree_status_head_changes_only_with_head() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = test_repository::open(fixture.path())?; + let baseline = worktree_status_head(&repository)?; + let main = repository.rev_parse_single("main")?.detach(); + let topic = repository.rev_parse_single("topic")?.detach(); + drop(repository); + + let update_ref = |name: &str, target: gix::ObjectId| -> gix_testtools::Result { + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["update-ref", name, &target.to_string()]) + .status()?; + assert!(status.success(), "git updates {name}"); + Ok(()) + }; + update_ref("refs/heads/unrelated", topic)?; + assert_eq!( + worktree_status_head(&test_repository::open(fixture.path())?)?, + baseline, + "an unrelated ref does not affect worktree status" + ); + + update_ref("refs/heads/alias", main)?; + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["symbolic-ref", "HEAD", "refs/heads/alias"]) + .status()?; + assert!(status.success(), "git reattaches HEAD to the alias"); + let alias = worktree_status_head(&test_repository::open(fixture.path())?)?; + assert_ne!(alias, baseline, "the symbolic referent is part of the status baseline"); + assert_eq!( + alias.target, baseline.target, + "the alias initially names the same commit" + ); + let mut cached = Some(baseline.clone()); + remember_worktree_status_head(&mut cached, false, Ok(alias.clone())); + assert_eq!( + cached, + Some(baseline.clone()), + "an unstaged-only refresh preserves the HEAD baseline" + ); + remember_worktree_status_head(&mut cached, true, Ok(alias.clone())); + assert_eq!(cached, Some(alias.clone()), "a staged refresh advances the baseline"); + + update_ref("refs/heads/alias", topic)?; + let moved = worktree_status_head(&test_repository::open(fixture.path())?)?; + assert_ne!( + moved.target, alias.target, + "moving the checked-out ref changes the baseline" + ); + + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["symbolic-ref", "HEAD", "refs/heads/unborn"]) + .status()?; + assert!(status.success(), "git makes HEAD unborn"); + let unborn = worktree_status_head(&test_repository::open(fixture.path())?)?; + assert_eq!( + unborn.reference.as_ref().map(gix::refs::FullName::as_bstr), + Some("refs/heads/unborn".into()) + ); + assert_eq!(unborn.target, None, "an unborn HEAD has no peeled target"); + Ok(()) + } + + #[test] + fn caches_recent_tree_changes_by_commit_and_parent() { + let id = |value| { + let mut bytes = [0; 20]; + bytes[19] = value; + gix::ObjectId::Sha1(bytes) + }; + let mut cache = TreeChangesCache::default(); + cache.insert(( + app::TreeDiffTarget::Commit { id: id(42), parent: 0 }, + Changes::default(), + )); + cache.insert(( + app::TreeDiffTarget::Commit { id: id(42), parent: 1 }, + Changes { + lines_added: 42, + ..Changes::default() + }, + )); + assert!(cache.activate(app::TreeDiffTarget::Commit { id: id(42), parent: 0 })); + assert_eq!( + cache.as_ref().map(|(target, _)| *target), + Some(app::TreeDiffTarget::Commit { id: id(42), parent: 0 }) + ); + assert!(cache.activate(app::TreeDiffTarget::Commit { id: id(42), parent: 1 })); + assert_eq!( + cache.as_ref().map(|(_, changes)| changes.lines_added), + Some(42), + "each merge parent retains its own diff result" + ); + cache.insert(( + app::TreeDiffTarget::Branch { + base: id(42), + tip: id(43), + }, + Changes { + lines_removed: 43, + ..Changes::default() + }, + )); + assert!(cache.activate(app::TreeDiffTarget::Commit { id: id(42), parent: 1 })); + assert_eq!( + cache.as_ref().map(|(_, changes)| changes.lines_added), + Some(42), + "a branch range cannot replace the base commit's ordinary diff" + ); + cache.clear(); + + for value in 0..=TREE_CHANGES_CACHE_SIZE as u8 { + cache.insert(( + app::TreeDiffTarget::Commit { + id: id(value), + parent: usize::from(value), + }, + Changes { + lines_added: u64::from(value), + ..Changes::default() + }, + )); + } + + assert!( + cache.activate(app::TreeDiffTarget::Commit { id: id(1), parent: 1 }), + "a recently viewed commit and parent restores its computed diff" + ); + assert_eq!(cache.as_ref().map(|(_, changes)| changes.lines_added), Some(1)); + assert!( + !cache.activate(app::TreeDiffTarget::Commit { id: id(0), parent: 0 }), + "the oldest entry is evicted at the bound" + ); + cache.clear(); + assert!( + cache.as_ref().is_none(), + "closing the changes view releases cached diffs" + ); + } + + #[test] + fn copies_the_selected_path_from_the_focused_changes_block() { + let mut app = App::new(1); + app.changes_focus = Some(ChangePane::Tree); + app.set_changes_bounds(ChangePane::Tree, 2, 2, None, 80, 0); + drop(app.update(Action::MoveDown)); + let changes = Changes { + paths: ["first", "dir/second"] + .into_iter() + .map(|path| app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: path.into(), + lines: None, + }) + .collect(), + ..Changes::default() + }; + + assert_eq!( + copy_selected_path_action(Action::Copy, &app, Some(&changes), None), + Action::CopyPath("dir/second".into()) + ); + app.changes_focus = None; + assert_eq!( + copy_selected_path_action(Action::Copy, &app, Some(&changes), None), + Action::Copy, + "history retains commit-id copying" + ); + } + + #[test] + fn loads_commit_messages_from_an_existing_repository() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = test_repository::open(&fixture)?; + let id = repository.rev_parse_single("topic")?.detach(); + + assert!( + load_commit_message(&repository, id)?.starts_with(b"topic\n\n--- agent\n\nCo-authored-by:"), + "on-demand loading retains the full commit message" + ); + Ok(()) + } + + #[test] + fn creates_replaces_and_removes_a_git_note() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = test_repository::open(fixture.path())?; + let id = repository.rev_parse_single("topic")?.detach(); + let notes = repository.notes()?; + let reference = notes + .default_ref() + .expect("the test repository has a default notes ref") + .to_owned(); + + for expected in [b"first".as_slice(), b"second".as_slice()] { + set_git_note(&repository, reference.as_ref(), id, Some(expected))?; + let mut notes = repository.notes()?.with_refs([reference.as_bstr()])?; + assert_eq!( + notes.get(id)?.first().map(|note| note.blob.data.as_slice()), + Some(expected), + "the default note is created or replaced" + ); + } + + set_git_note(&repository, reference.as_ref(), id, None)?; + let mut notes = repository.notes()?.with_refs([reference.as_bstr()])?; + assert!(notes.get(id)?.is_empty(), "empty editor content removes the note"); + Ok(()) + } + + #[test] + fn selection_relation_prefers_tracking_counts_and_handles_missing_upstreams() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = test_repository::open(&fixture)?; + let topic = repository.rev_parse_single("topic")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut graph = None; + history::load( + &repository, + &[OsString::from("topic"), OsString::from("main")], + &[], + false, + &authors, + &AtomicBool::new(false), + |event| { + if let Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + let mut graph = graph.expect("history traversal returns its graph"); + let tracking = SelectionRef { + name: "topic".into(), + upstream: Some(Some(main)), + }; + assert_eq!( + graph.selection_relation(topic, &[tracking.clone(), tracking], &[]), + Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }), + "one upstream comparison wins over the visible-history fallback" + ); + assert_eq!( + graph.selection_relation( + topic, + &[SelectionRef { + name: "topic".into(), + upstream: Some(None), + }], + &[], + ), + None, + "a configured but missing tracking ref does not masquerade as an untracked branch" + ); + assert_eq!( + graph.selection_relation( + topic, + &[SelectionRef { + name: "tag: topic".into(), + upstream: None, + }], + &[main], + ), + Some(SelectionRelation::Visible(1)) + ); + Ok(()) + } + + #[test] + fn selection_refs_resolve_the_configured_fetch_tracking_branch() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + for args in [ + ["config", "remote.origin.url", "https://example.com/repo"], + ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], + ["config", "branch.topic.remote", "origin"], + ["config", "branch.topic.merge", "refs/heads/main"], + ] { + let status = std::process::Command::new("git") + .current_dir(path) + .args(args) + .status()?; + assert!(status.success(), "git config prepares the tracking relationship"); + } + let repository = test_repository::open(path)?; + let topic = repository.rev_parse_single("topic")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let status = std::process::Command::new("git") + .current_dir(path) + .args(["update-ref", "refs/remotes/origin/main", &main.to_hex().to_string()]) + .status()?; + assert!(status.success(), "the configured tracking ref exists"); + let repository = test_repository::open(path)?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut graph = None; + history::load( + &repository, + &[OsString::from("topic")], + &[], + false, + &authors, + &AtomicBool::new(false), + |event| { + if let Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + let mut graph = graph.expect("history traversal returns its graph"); + let refs = graph.selection_refs(topic, &history::decorations(&repository, &[], &[])?); + assert_eq!(refs[0].upstream, Some(Some(main))); + assert_eq!( + graph.selection_relation(topic, &refs, &[]), + Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }), + "the dynamically scheduled upstream has enough cached ancestry for comparison" + ); + Ok(()) + } + + #[test] + fn pending_rebase_changes_use_the_recorded_parent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = crate::test_repository::open(fixture.path())?; + let actual_parent = repository.rev_parse_single("main")?.detach(); + + let topic = repository.rev_parse_single("topic")?.detach(); + let mut marked = repository.find_commit(topic)?.decode()?.into_owned()?; + let original_parent = marked.parents[0]; + marked.parents = [actual_parent].into_iter().collect(); + marked + .extra_headers + .push(("tix-rebase-parent".into(), original_parent.to_hex().to_string().into())); + let marked_topic = repository.write_object(&marked)?.detach(); + let changes = load_changes_without_lines( + &repository, + app::TreeDiffTarget::Commit { + id: marked_topic, + parent: 0, + }, + )?; + assert_eq!( + changes + .paths + .iter() + .map(|change| change.path.as_bstr()) + .collect::<Vec<_>>(), + ["topic", "topic-extra"], + "the recorded parent preserves the commit's original changes" + ); + assert_eq!( + changes.parent, None, + "the actual parent isn't presented as the comparison base" + ); + + let root = repository.rev_parse_single("v1^{}")?.detach(); + let mut marked_root = repository.find_commit(root)?.decode()?.into_owned()?; + marked_root.parents = [actual_parent].into_iter().collect(); + marked_root.extra_headers.push(( + "tix-rebase-parent".into(), + gix::ObjectId::null(repository.object_hash()) + .to_hex() + .to_string() + .into(), + )); + let marked_root = repository.write_object(&marked_root)?.detach(); + let changes = load_changes_without_lines( + &repository, + app::TreeDiffTarget::Commit { + id: marked_root, + parent: 0, + }, + )?; + assert_eq!( + changes + .paths + .iter() + .map(|change| change.path.as_bstr()) + .collect::<Vec<_>>(), + ["root"], + "a recorded null parent compares the pending root to the empty tree" + ); + Ok(()) + } + + #[test] + fn loads_changes_against_each_merge_parent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = crate::test_repository::open(&fixture)?; + let mut line_diff_pool_slot = None; + sync_line_diff_pool(&mut line_diff_pool_slot, true, &fixture, false, 2); + let line_diff_pool = line_diff_pool_slot.as_mut().expect("changes enable the line diff pool"); + assert!( + line_diff_pool.active.is_none(), + "workers start only with an uncached diff" + ); + + let root = load_changes( + &repository, + app::TreeDiffTarget::Commit { + id: repository.rev_parse_single("v1^{}")?.detach(), + parent: 0, + }, + line_diff_pool, + )?; + assert_eq!( + line_diff_pool.active.as_ref().map(|active| active.workers.len()), + Some(2), + "an uncached diff creates the requested workers" + ); + let started = Instant::now(); + line_diff_pool.last_used = Some(started); + let just_before_expiry = started + LINE_DIFF_POOL_IDLE.saturating_sub(Duration::from_nanos(1)); + assert_eq!( + line_diff_pool.idle_timeout(just_before_expiry), + Some(Duration::from_nanos(1)), + "the event loop can wake exactly when workers expire" + ); + assert!( + !line_diff_pool.expire(just_before_expiry), + "workers remain available until their idle timeout" + ); + assert!( + line_diff_pool.expire(started + LINE_DIFF_POOL_IDLE), + "workers expire exactly at their idle timeout" + ); + assert!(line_diff_pool.active.is_none(), "expiry releases every worker"); + assert_eq!( + root.paths, + [PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "root".into(), + lines: Some((1, 0)), + }], + "root commits are compared to the empty tree" + ); + assert_eq!((root.parent, root.lines_added, root.lines_removed), (None, 1, 0)); + assert_eq!(root.diffs.len(), 1, "the original change is retained for file diffs"); + match prepare_file_diff_with_repository(&repository, &root.diffs[0], &root.paths[0])? { + FileDiff::BuiltIn(diff) => { + assert_eq!(diff.title, "A root"); + assert!(diff.lines.iter().any(|line| line == "+root")); + } + FileDiff::External(_) => unreachable!("isolated repositories have no external diff"), + FileDiff::Pager { .. } => unreachable!("isolated repositories have no pager"), + } + + let external_repository = crate::test_repository::open_with(&fixture, ["diff.external=test --flag"])?; + match prepare_file_diff_with_repository(&external_repository, &root.diffs[0], &root.paths[0])? { + FileDiff::External(command) => assert!( + command + .get_args() + .any(|arg| arg.to_string_lossy().contains("test --flag")), + "the configured helper is prepared with shell semantics" + ), + FileDiff::BuiltIn(_) => unreachable!("configured external diffs take precedence"), + FileDiff::Pager { .. } => unreachable!("configured external diffs take precedence"), + } + + let pager_repository = crate::test_repository::open_with(&fixture, ["core.pager=delta --dark"])?; + match prepare_file_diff_with_repository(&pager_repository, &root.diffs[0], &root.paths[0])? { + FileDiff::Pager { command, diff } => { + assert!( + command + .get_args() + .any(|arg| arg.to_string_lossy().contains("delta --dark")), + "the configured pager is prepared with shell semantics" + ); + let mut patch = Vec::new(); + diff.write_to(&mut patch)?; + assert!(patch.starts_with(b"--- /dev/null\n+++ b/root\n")); + assert!(patch.ends_with(b"\n"), "pagers receive a complete final line"); + } + FileDiff::BuiltIn(_) | FileDiff::External(_) => { + unreachable!("configured pagers receive built-in diffs") + } + } + + for setting in ["core.pager=", "core.pager=cat"] { + let repository = test_repository::open_with(&fixture, [setting])?; + assert!( + matches!( + prepare_file_diff_with_repository(&repository, &root.diffs[0], &root.paths[0])?, + FileDiff::BuiltIn(_) + ), + "disabled pagers retain the built-in viewer" + ); + } + + let topic_id = repository.rev_parse_single("topic")?.detach(); + let topic_target = app::TreeDiffTarget::Commit { + id: topic_id, + parent: 0, + }; + let topic = load_changes(&repository, topic_target, line_diff_pool)?; + assert!( + line_diff_pool.active.is_some(), + "the next uncached diff recreates workers" + ); + assert_eq!( + topic.paths, + [ + PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "topic".into(), + lines: Some((1, 0)), + }, + PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "topic-extra".into(), + lines: Some((1, 0)), + } + ], + "parallel line diffs retain tree-diff order and status" + ); + assert_eq!((topic.lines_added, topic.lines_removed), (2, 0)); + let title: BString = format!("{} author topic", topic_id.to_hex_with_len(7)).into(); + let commit_diff = prepare_commit_diff_with_repository(&repository, topic_target, None, title.clone())?; + assert!(commit_diff.external.is_empty()); + let FileDiff::BuiltIn(diff) = commit_diff.internal else { + unreachable!("an isolated repository uses the built-in commit viewer") + }; + assert_eq!(diff.title, title); + let summary = diff + .summary + .as_ref() + .expect("whole-commit diffs have a summary") + .last() + .expect("the aggregate follows path statistics") + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(); + assert!( + summary.contains("A 2 · +2"), + "the existing diff pass supplies aggregate counts" + ); + let topic_position = diff + .lines + .iter() + .position(|line| line == "+++ b/topic") + .expect("the first path is present"); + let extra_position = diff + .lines + .iter() + .position(|line| line == "+++ b/topic-extra") + .expect("the second path is present"); + assert!( + topic_position < extra_position, + "whole-commit patches retain tree-diff order" + ); + let base = repository.rev_parse_single("v1^{}")?.detach(); + let branch_target = app::TreeDiffTarget::Branch { base, tip: topic_id }; + let branch = load_changes(&repository, branch_target, line_diff_pool)?; + assert_eq!(branch.range, Some(app::ComparedRange { base, tip: topic_id })); + assert_eq!( + branch + .paths + .iter() + .map(|change| change.path.as_bstr()) + .collect::<Vec<_>>(), + ["main", "topic", "topic-extra"], + "branch diffs compare the boundary tree directly to its unique leaf" + ); + let branch_diff = + prepare_commit_diff_with_repository(&repository, branch_target, Some(&branch), "branch".into())?; + let FileDiff::BuiltIn(branch_diff) = branch_diff.internal else { + unreachable!("an isolated repository uses the built-in branch viewer") + }; + assert!( + branch_diff + .summary + .expect("branch diffs have a summary") + .last() + .expect("the branch aggregate follows path statistics") + .to_string() + .contains(&format!("{}..{}", base.to_hex_with_len(7), topic_id.to_hex_with_len(7))), + "the whole-diff viewer identifies the compared range" + ); + let empty = + prepare_commit_diff_with_repository(&repository, topic_target, Some(&Changes::default()), title.clone())?; + let FileDiff::BuiltIn(empty) = empty.internal else { + unreachable!("empty commits retain the built-in viewer") + }; + assert!(empty.lines.is_empty(), "an empty commit opens an empty patch"); + assert!( + empty + .summary + .expect("empty commits have a summary") + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("No changes")), + "empty commits explain the absent patch" + ); + + let pager_diff = + prepare_commit_diff_with_repository(&pager_repository, topic_target, Some(&topic), title.clone())?; + assert!(pager_diff.external.is_empty()); + let FileDiff::Pager { diff, .. } = pager_diff.internal else { + unreachable!("one configured pager receives the aggregate commit patch") + }; + let mut streamed = Vec::new(); + diff.write_to(&mut streamed)?; + assert!( + streamed.starts_with( + format!("{title}\n topic | 1 + +1\n topic-extra | 1 + +1\nroot · A 2 · +2 \n\n").as_bytes() + ), + "the pager receives path statistics and the aggregate before the patch" + ); + let external_diff = + prepare_commit_diff_with_repository(&external_repository, topic_target, Some(&topic), title.clone())?; + assert_eq!( + external_diff.external.len(), + 2, + "external diff commands remain per-path" + ); + let FileDiff::BuiltIn(summary) = external_diff.internal else { + unreachable!("an all-external commit still shows its summary") + }; + assert!( + summary.lines.is_empty(), + "external patches aren't duplicated internally" + ); + + let merge = repository.rev_parse_single("main")?.detach(); + let first_parent_target = app::TreeDiffTarget::Commit { id: merge, parent: 0 }; + let first_parent = load_changes(&repository, first_parent_target, line_diff_pool)?; + assert_eq!( + first_parent.parent, + Some(ComparedParent { + index: 0, + total: 2, + id: repository.rev_parse_single("main^1")?.detach(), + }) + ); + assert_eq!( + first_parent.paths, + [PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "merged".into(), + lines: Some((1, 0)), + }], + "the default merge diff compares the result to its first parent" + ); + + let second_parent_target = app::TreeDiffTarget::Commit { id: merge, parent: 1 }; + let second_parent = load_changes(&repository, second_parent_target, line_diff_pool)?; + assert_eq!( + second_parent.parent, + Some(ComparedParent { + index: 1, + total: 2, + id: repository.rev_parse_single("main^2")?.detach(), + }) + ); + assert_eq!( + second_parent.paths, + [PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "main".into(), + lines: Some((1, 0)), + }], + "later parents can be selected independently" + ); + let second_parent_diff = prepare_commit_diff_with_repository( + &repository, + second_parent_target, + Some(&second_parent), + "merge title".into(), + )?; + let FileDiff::BuiltIn(diff) = second_parent_diff.internal else { + unreachable!("an isolated repository uses the built-in commit viewer") + }; + assert!( + diff.summary + .expect("merge diff has a summary") + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("vs parent 2/2")), + "the commit viewer identifies the selected merge parent" + ); + assert_eq!( + load_changes( + &repository, + app::TreeDiffTarget::Commit { id: merge, parent: 2 }, + line_diff_pool, + )? + .parent, + first_parent.parent, + "parent selection wraps around" + ); + sync_line_diff_pool(&mut line_diff_pool_slot, false, &fixture, false, 2); + assert!( + line_diff_pool_slot.is_none(), + "hiding changes immediately releases the pool" + ); + Ok(()) + } + + #[test] + fn configures_a_common_repository_as_bare_for_tree_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let git_dir = test_repository::open(&fixture)?.git_dir().to_owned(); + let repository = open_repository(&git_dir, true, false)?; + + assert_eq!( + repository.config_snapshot().boolean("core.bare"), + Some(true), + "repository configuration suppresses worktree operations" + ); + let mut line_diff_pool = LineDiffPool::new(&git_dir, true, 1); + let root = repository.rev_parse_single("v1^{}")?.detach(); + assert_eq!( + load_changes( + &repository, + app::TreeDiffTarget::Commit { id: root, parent: 0 }, + &mut line_diff_pool, + )? + .paths + .len(), + 1, + "tree changes remain available without a worktree" + ); + Ok(()) + } + + #[test] + fn detects_a_removed_per_worktree_repository_even_if_the_current_directory_resolves() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + assert!( + std::env::current_dir().is_ok(), + "the process directory remains available" + ); + let missing = fixture.join("missing-worktree-git-dir"); + assert!(worktree_repository_is_gone(&missing)); + let Err(err) = recover_common_repository(&missing) else { + panic!("a missing common repository cannot be recovered") + }; + assert!( + format!("{err:#}").contains("could not change directory to common repository"), + "recovery failures retain actionable context" + ); + Ok(()) + } + + #[test] + fn normalizes_a_common_directory_through_a_missing_per_worktree_directory() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let git_dir = test_repository::open(&fixture)?.git_dir().to_owned(); + let indirect = git_dir.join("worktrees/missing/../.."); + assert!( + !git_dir.join("worktrees/missing").exists(), + "the intermediate path is absent" + ); + assert_eq!(normalize_common_dir(indirect)?, git_dir); + Ok(()) + } + + #[test] + fn startup_validation_returns_only_detached_hidden_revision_data() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = test_repository::open(&fixture)?; + let mut git_dir = repository.git_dir().to_owned(); + let common_dir = repository.common_dir().to_owned(); + drop(repository); + + let (hide, unavailable) = validate_hidden_revisions(&mut git_dir, &common_dir, &[OsString::from("main")])?; + assert_eq!(hide, [OsString::from("main")]); + assert!(unavailable.is_empty(), "the fixture's main branch resolves"); + Ok(()) + } + + #[test] + fn opens_the_common_repository_when_the_initial_worktree_is_already_gone() -> gix_testtools::Result { + const COMMON_DIR: &str = "GIX_TIX_TEST_REMOVED_WORKTREE_COMMON_DIR"; + if let Some(git_dir) = std::env::var_os(COMMON_DIR).map(PathBuf::from) { + let mut stale_git_dir = git_dir.join("worktrees/deleted"); + let (repository, recovered) = open_history_repository(&mut stale_git_dir, &git_dir)?; + + assert!( + recovered, + "a missing per-worktree repository uses the common repository" + ); + assert_eq!(stale_git_dir, git_dir, "future opens use the surviving repository"); + assert_eq!( + repository.config_snapshot().boolean("core.bare"), + Some(true), + "recovery configures the common repository as bare" + ); + + let mut stale_git_dir = git_dir.join("worktrees/deleted-during-event-loop"); + let mut bare = false; + assert!( + recover_event_loop_repository(&mut stale_git_dir, &git_dir, &mut bare)?.is_some(), + "the event-loop boundary recovers before its next action" + ); + assert_eq!( + stale_git_dir, git_dir, + "future event-loop opens use the common repository" + ); + assert!(bare, "future event-loop opens treat the common repository as bare"); + return Ok(()); + } + + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let git_dir = test_repository::open(&fixture)?.git_dir().canonicalize()?; + let status = Command::new(std::env::current_exe()?) + .env(COMMON_DIR, git_dir) + .args([ + "--exact", + "tests::opens_the_common_repository_when_the_initial_worktree_is_already_gone", + ]) + .status()?; + assert!(status.success(), "the isolated recovery process completes successfully"); + Ok(()) + } + + #[test] + fn loads_staged_and_unstaged_worktree_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + let git = |args: &[&str]| -> std::io::Result<std::process::ExitStatus> { + std::process::Command::new("git") + .current_dir(path) + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .status() + }; + + assert!(git(&["switch", "-q", "-c", "conflict-other"])?.success()); + std::fs::write(path.join("root"), "other\n")?; + assert!(git(&["commit", "-qam", "other"])?.success()); + assert!(git(&["switch", "-q", "main"])?.success()); + std::fs::write(path.join("root"), "ours\n")?; + assert!(git(&["commit", "-qam", "ours"])?.success()); + assert!( + !git(&["merge", "--no-edit", "conflict-other"])?.success(), + "the fixture deliberately leaves an unresolved path" + ); + + std::fs::write(path.join("staged"), "staged\n")?; + std::fs::write(path.join("both"), "index\n")?; + assert!(git(&["add", "staged", "both"])?.success()); + std::fs::write(path.join("both"), "index\nworktree\n")?; + std::fs::write(path.join("untracked"), "untracked\n")?; + std::fs::write(path.join(".git/info/exclude"), "ignored\n")?; + std::fs::write(path.join("ignored"), "ignored\n")?; + + let repository = test_repository::open(path)?; + let mut line_diff_pool = LineDiffPool::new(path, false, 2); + let changes = load_worktree_changes(&repository, &mut line_diff_pool)?; + let rows: Vec<_> = changes + .paths + .iter() + .map(|change| (change.group, change.kind, change.path.to_string())) + .collect(); + assert_eq!( + rows, + [ + (ChangeGroup::Staged, ChangeKind::Added, "both".into()), + (ChangeGroup::Staged, ChangeKind::Added, "staged".into()), + (ChangeGroup::Unstaged, ChangeKind::Added, ".mailmap".into()), + (ChangeGroup::Unstaged, ChangeKind::Modified, "both".into()), + (ChangeGroup::Unstaged, ChangeKind::Unmerged, "root".into()), + (ChangeGroup::Unstaged, ChangeKind::Added, "untracked".into()), + ], + "status is partitioned, path-sorted, includes conflicts and untracked files, and excludes ignored files" + ); + assert!(changes.lines_added > 0, "available file diffs contribute line counts"); + assert!( + changes.has_tracked_changes, + "staged and tracked worktree changes are classified once" + ); + for (path, diff) in changes.paths.iter().zip(&changes.diffs) { + if path.kind != ChangeKind::Unmerged { + prepare_file_diff_with_repository(&repository, diff, path) + .with_context(|| format!("{} should produce a staged or worktree diff", path.path))?; + } + } + let conflict = changes + .paths + .iter() + .position(|change| change.kind == ChangeKind::Unmerged) + .expect("the conflict is visible"); + assert!( + prepare_file_diff_with_repository(&repository, &changes.diffs[conflict], &changes.paths[conflict]) + .err() + .expect("conflicts cannot produce a single file diff") + .to_string() + .contains("no single file diff"), + "opening an unresolved path produces actionable feedback" + ); + Ok(()) + } + + #[test] + fn incremental_worktree_status_matches_a_full_refresh() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + test_repository::disable_autocrlf(fixture.path())?; + let path = fixture.path(); + let repository = test_repository::open(path)?; + let mut pool = LineDiffPool::new(path, false, 2); + let mut cached = load_worktree_changes(&repository, &mut pool)?; + + std::fs::write(path.join("main"), "changed in worktree\n")?; + std::fs::write(path.join("literal[brackets]"), "untracked\n")?; + let parts = WorktreeStatusParts { + staged: false, + scopes: HashSet::from([BString::from("main"), BString::from("literal[brackets]")]), + }; + update_worktree_changes(&repository, &mut cached, &parts, &mut pool)?; + assert_eq!( + cached, + load_worktree_changes(&repository, &mut pool)?, + "path-limited changes equal a fresh status" + ); + + let status = Command::new("git").current_dir(path).args(["add", "main"]).status()?; + assert!(status.success(), "git stages the tracked change"); + std::fs::write(path.join("main"), "changed in index\nand worktree\n")?; + let parts = WorktreeStatusParts { + staged: true, + scopes: HashSet::from([BString::from("main")]), + }; + update_worktree_changes(&repository, &mut cached, &parts, &mut pool)?; + assert_eq!( + cached, + load_worktree_changes(&repository, &mut pool)?, + "combined staged and worktree replacement equals a fresh status" + ); + + std::fs::create_dir_all(path.join("nested"))?; + std::fs::write(path.join("nested/untracked"), "untracked\n")?; + let parts = WorktreeStatusParts { + staged: false, + scopes: HashSet::from([BString::from("nested")]), + }; + update_worktree_changes(&repository, &mut cached, &parts, &mut pool)?; + assert_eq!( + cached, + load_worktree_changes(&repository, &mut pool)?, + "directory scopes include their descendants" + ); + + std::fs::write(path.join("nested/.gitignore"), "untracked\n")?; + update_worktree_changes(&repository, &mut cached, &parts, &mut pool)?; + assert_eq!( + cached, + load_worktree_changes(&repository, &mut pool)?, + "a scoped ignore change removes newly ignored cache rows" + ); + + let topic = repository.rev_parse_single("topic")?.detach(); + drop(repository); + let status = Command::new("git") + .current_dir(path) + .args(["update-ref", "refs/heads/main", &topic.to_string()]) + .status()?; + assert!(status.success(), "git moves the checked-out branch"); + let repository = test_repository::open(path)?; + update_worktree_changes( + &repository, + &mut cached, + &WorktreeStatusParts { + staged: true, + scopes: HashSet::new(), + }, + &mut pool, + )?; + assert_eq!( + cached, + load_worktree_changes(&repository, &mut pool)?, + "a staged-only replacement follows the new HEAD tree" + ); + Ok(()) + } + + #[test] + fn streams_diff_bytes_and_accepts_early_pager_exit() -> gix_testtools::Result { + let diff = BuiltInDiff::new( + "M file".into(), + vec![BString::from("--- a/file"), BString::from(vec![b'+', 0xff])], + ); + let mut patch = Vec::new(); + + diff.write_to(&mut patch)?; + + assert_eq!(patch, b"--- a/file\n+\xff\n", "patch bytes reach the pager unchanged"); + pager_write_result(Err(io::Error::new(io::ErrorKind::BrokenPipe, "pager quit"))) + .expect("an early pager exit is normal"); + assert!( + pager_write_result(Err(io::Error::other("write failed"))).is_err(), + "other write failures remain visible" + ); + #[cfg(unix)] + assert!( + pager_status(std::os::unix::process::ExitStatusExt::from_raw(1 << 8)).is_err(), + "a failing pager remains visible" + ); + assert!( + pager_needs_acknowledgement(Duration::ZERO), + "an immediately closing pager leaves its output visible" + ); + assert!( + pager_needs_acknowledgement(Duration::from_millis(250)), + "the threshold is inclusive" + ); + assert!( + !pager_needs_acknowledgement(Duration::from_millis(251)), + "longer-running pagers restore tix immediately" + ); + Ok(()) + } + + #[test] + fn maps_navigation_and_control_c() { + assert_eq!( + action(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + Some(Action::ToggleChangesFocus) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(Action::OpenDiff) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)), + Some(Action::PageUp) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)), + Some(Action::PageUp) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), + Some(Action::PageDown) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), + Some(Action::HalfPageUp) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)), + Some(Action::Undo) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::NONE)), + Some(Action::Redo) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::SHIFT)), + Some(Action::Redo) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Some(Action::HalfPageDown) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)), + Some(Action::ScrollLeft) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)), + Some(Action::ScrollRight) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT)), + Some(Action::Last), + "terminals that report shifted letters in lowercase still map Shift-G to the first commit" + ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)), None); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), + Some(Action::ToggleActions) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), + Some(Action::ToggleEnrich) + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + false, + false, + true, + false + ), + Some(Action::ToggleChecksPass) + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + false, + false, + true, + false + ), + Some(Action::ToggleTodo) + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), + false, + false, + true, + false + ), + Some(Action::EditNote) + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + false, + false, + true, + false + ), + Some(Action::EditGitNote) + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + false, + false, + true, + false + ), + Some(Action::ToggleEnrich) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), + Some(Action::ToggleCommit) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + Some(Action::ToggleRefs) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), + Some(Action::ToggleRefTree) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE)), + Some(Action::TimeTravel), + "the terminal's direct at-sign event invokes time travel" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::SHIFT)), + Some(Action::TimeTravel), + "terminals which preserve the base character map Shift-2 to time travel" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)), + None, + "an unshifted 2 has no time-travel behavior" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), + Some(Action::Refresh), + "terminals which preserve lowercase shifted letters map Shift-R to refresh" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)), + Some(Action::Refresh), + "terminals which encode Shift-R as an uppercase letter map it to refresh" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)), + Some(Action::ToggleHistoryDisplay) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)), + Some(Action::ToggleInformation) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::SHIFT)), + Some(Action::ToggleInformation), + "terminals which preserve the base character map Shift-/ to information" + ); + assert_eq!( + action(KeyEvent::new( + KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift), + KeyModifiers::SHIFT, + )), + None, + "standalone Shift has no application behavior" + ); + for (key, expected) in [ + ('d', Action::ToggleDate), + ('i', Action::CycleIds), + ('s', Action::ToggleEmail), + ('e', Action::ToggleName), + ('t', Action::ToggleTrailers), + ('m', Action::ToggleMailmap), + ('r', Action::CycleRefs), + ('h', Action::ToggleHidden), + ] { + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), + true, + false, + false, + false + ), + Some(expected), + "{key} is available after the view prefix" + ); + } + for (history, actions, enrich, information) in [ + (true, false, false, false), + (false, true, false, false), + (false, false, true, false), + (false, false, false, true), + ] { + for (key, expected) in [ + ('v', Action::ToggleHistoryDisplay), + ('a', Action::ToggleActions), + ('?', Action::ToggleInformation), + ] { + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), + history, + actions, + enrich, + information, + ), + Some(expected), + "{key} switches prefix menus regardless of the active menu" + ); + } + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + history, + actions, + enrich, + information, + ), + Some(if actions { + Action::NewEmptyCommit + } else { + Action::ToggleEnrich + }), + "the actions shortcut takes priority over the enrich prefix" + ); + } + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), + true, + false, + false, + false + ), + Some(Action::ToggleHistoryDisplay), + "v closes the view shortcut group" + ); + for (key, expected) in [ + ('o', Action::Reword), + ('w', Action::NewCommit), + ('n', Action::NewEmptyCommit), + ('e', Action::Amend), + ('l', Action::Spill), + ('p', Action::Split), + ('d', Action::Forget), + ('i', Action::TogglePin), + ] { + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), + false, + true, + false, + false + ), + Some(expected), + "{key} is available on the commit line after the actions prefix" + ); + } + for (key, expected) in [ + ('b', Action::Rebase), + ('u', Action::RebaseUpdate), + ('r', Action::Review), + ('s', Action::Squash), + ('y', Action::CopyInsert), + ('m', Action::MoveInsert), + ('t', Action::StackInsert), + ('f', Action::ForkCommit), + ('h', Action::Attach), + ('z', Action::Stash), + ] { + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), + false, + true, + false, + false + ), + Some(expected), + "{key} is available after the actions prefix" + ); + } + for (history, actions, enrich, expected) in [ + (true, false, false, Action::ToggleName), + (false, true, false, Action::Amend), + (false, false, true, Action::ToggleChecksPass), + ] { + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + history, + actions, + enrich, + true, + ), + Some(expected), + "an open submenu takes priority over the focused changes shortcut" + ); + } + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + false, + true, + false, + false + ), + Some(Action::StackInsert), + "the actions shortcut takes priority over the direct ref-tree key" + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE), + false, + true, + false, + false + ), + Some(Action::TimeTravel), + "the direct time-travel key remains available while actions are expanded" + ); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + false, + true, + false, + false + ), + Some(Action::PageUp), + "navigation keeps priority over the actions shortcut" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), + Some(Action::VerifySignatures) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE)), + Some(Action::ToggleAlign) + ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('\\'), KeyModifiers::NONE)), None); + assert_eq!( + action(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)), + Some(Action::ToggleCommit) + ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), None); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + false, + true, + false, + false + ), + Some(Action::Review), + "the actions shortcut takes priority over the direct reference toggle" + ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), None); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)), + Some(Action::CycleChangesParent) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::SHIFT)), + Some(Action::CycleChangesParent) + ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)), None); + assert_eq!( + action_with_shortcut_groups( + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + false, + false, + false, + true + ), + Some(Action::ToggleChanges), + "e changes is scoped to the information prefix" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)), + Some(Action::CopyAuthor) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Some(Action::ForceQuit) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + Some(Action::CycleDuplicate) + ); + } + + #[test] + fn diagnostic_inputs_replay_only_read_only_actions() { + let mut app = App::new(1); + assert_eq!(diagnostic_action(diagnostic_key('j'), &app), Some(Action::MoveDown)); + assert_eq!(diagnostic_action(diagnostic_key('l'), &app), Some(Action::ScrollRight)); + assert_eq!(diagnostic_action(diagnostic_key('G'), &app), Some(Action::Last)); + assert_eq!( + diagnostic_action(diagnostic_key('u'), &app), + None, + "undo is not replayed" + ); + + app.actions_expanded = true; + assert_eq!( + diagnostic_action(diagnostic_key('b'), &app), + None, + "repository-changing submenu actions are not replayed" + ); + } + + #[test] + fn shift_applies_topology_to_directions_and_viewport_movement_to_pages() { + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + let shifted = |code| KeyEvent::new(code, KeyModifiers::SHIFT); + let mut app = App::new(8); + app.information_expanded = true; + assert_eq!( + app_action(key(KeyCode::Char('o')), &app), + None, + "the topo toggle is gone" + ); + app.information_expanded = false; + + for (key, expected) in [ + (shifted(KeyCode::Up), Action::TopologicalUp), + (shifted(KeyCode::Char('k')), Action::TopologicalUp), + (key(KeyCode::Char('K')), Action::TopologicalUp), + (shifted(KeyCode::Down), Action::TopologicalDown), + (shifted(KeyCode::Char('j')), Action::TopologicalDown), + (key(KeyCode::Char('J')), Action::TopologicalDown), + (shifted(KeyCode::Left), Action::PreviousChild), + (shifted(KeyCode::Char('h')), Action::PreviousChild), + (key(KeyCode::Char('H')), Action::PreviousChild), + (shifted(KeyCode::Right), Action::NextChild), + (shifted(KeyCode::Char('l')), Action::NextChild), + (key(KeyCode::Char('L')), Action::NextChild), + ] { + assert_eq!(app_action(key, &app), Some(expected)); + } + + assert_eq!(app_action(key(KeyCode::Up), &app), Some(Action::MoveUp)); + app.history_display_expanded = true; + assert_eq!(app_action(key(KeyCode::Char('h')), &app), Some(Action::ToggleHidden)); + assert_eq!( + app_action(shifted(KeyCode::Char('h')), &app), + Some(Action::PreviousChild), + "shifted navigation outranks an open shortcut group" + ); + app.history_display_expanded = false; + + let control = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL); + let control_shift = + |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL | KeyModifiers::SHIFT); + for (key, expected) in [ + (key(KeyCode::PageUp), Action::PageUp), + (key(KeyCode::PageDown), Action::PageDown), + (control('u'), Action::HalfPageUp), + (control('d'), Action::HalfPageDown), + (control('b'), Action::PageUp), + (control('f'), Action::PageDown), + (shifted(KeyCode::PageUp), Action::PanUpBy(8)), + (shifted(KeyCode::PageDown), Action::PanDownBy(8)), + (control_shift('u'), Action::PanUpBy(4)), + (control_shift('d'), Action::PanDownBy(4)), + (control_shift('b'), Action::PanUpBy(8)), + (control_shift('f'), Action::PanDownBy(8)), + ] { + assert_eq!(app_action(key, &app), Some(expected)); + } + assert_eq!( + app_action(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::CONTROL), &app), + Some(Action::PanUpBy(4)), + "uppercase Ctrl paging retains its Shift meaning instead of invoking redo" + ); + + app.changes_focus = Some(ChangePane::Tree); + app.history_display_expanded = true; + assert_eq!( + app_action(key(KeyCode::Char('H')), &app), + Some(Action::ScrollLeft), + "shifted directions remain pane-local while changes are focused" + ); + assert_eq!( + app_action(key(KeyCode::PageUp), &app), + Some(Action::PageUp), + "focused panes retain their own paging" + ); + + app.changes_focus = None; + app.show_commit = true; + app.set_commit_bounds(2, 1); + assert_eq!( + app_action(key(KeyCode::PageUp), &app), + Some(Action::PageUp), + "overflowing commit messages retain their own paging" + ); + } + + #[test] + fn diagnostic_inputs_wait_for_completed_lanes() { + let key = diagnostic_key('j'); + let mut inputs = VecDeque::from([key]); + assert!(next_diagnostic_input(&mut inputs, State::Loading, false).is_none()); + assert!(next_diagnostic_input(&mut inputs, State::Complete, true).is_none()); + assert_eq!(inputs.len(), 1, "premature checks do not consume input"); + assert_eq!(next_diagnostic_input(&mut inputs, State::Complete, false), Some(key)); + } + + #[test] + fn tree_is_direct_while_trailers_remain_scoped_to_the_view_prefix() { + let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE); + assert_eq!( + action_with_shortcut_groups(key, true, false, false, false), + Some(Action::ToggleTrailers), + "v t retains its trailer action" + ); + assert_eq!( + action_with_shortcut_groups(key, false, false, false, false), + Some(Action::ToggleRefTree), + "plain t toggles the ref-tree" + ); + } + + #[test] + fn retains_the_fill_repository_only_for_repeated_viewport_navigation() { + for action in [Action::MoveDown, Action::PageDown] { + assert!(retains_fill_repository(KeyEventKind::Repeat, Some(&action), false)); + } + assert!(!retains_fill_repository( + KeyEventKind::Repeat, + Some(&Action::MoveDown), + true + )); + assert!(!retains_fill_repository( + KeyEventKind::Press, + Some(&Action::MoveDown), + false + )); + assert!(!retains_fill_repository( + KeyEventKind::Release, + Some(&Action::MoveDown), + false + )); + assert!(!retains_fill_repository( + KeyEventKind::Repeat, + Some(&Action::ScrollRight), + false + )); + assert!(!retains_fill_repository( + KeyEventKind::Repeat, + Some(&Action::ToggleDate), + false + )); + } + + #[test] + fn enhanced_keyboard_reports_repeats_for_printable_navigation_keys() { + let flags = keyboard_enhancement_flags(); + assert!( + flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES), + "enhanced input distinguishes presses, repeats, and releases" + ); + assert!( + flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES), + "printable j/k keys must use enhanced input for repeat events" + ); + } + + #[test] + fn key_releases_do_not_cancel_suspended_operations() { + let key = |kind| TerminalEvent::Key(KeyEvent::new_with_kind(KeyCode::Char('t'), KeyModifiers::NONE, kind)); + assert!(is_key_press(&key(KeyEventKind::Press))); + assert!(is_key_press(&key(KeyEventKind::Repeat))); + assert!(!is_key_press(&key(KeyEventKind::Release))); + } + + #[test] + fn materialized_rebases_allow_inspection_but_block_repository_changes() { + for action in [ + Action::MoveDown, + Action::CycleDuplicate, + Action::ToggleChangesFocus, + Action::ToggleCommit, + Action::Copy, + Action::ForceQuit, + ] { + assert!(action_allowed_during_rebase_continuation(Some(&action), false)); + } + for action in [ + Action::Undo, + Action::Redo, + Action::Refresh, + Action::ToggleHidden, + Action::ToggleRefTree, + Action::ToggleActions, + Action::Amend, + Action::Spill, + Action::Rebase, + Action::TimeTravel, + Action::VerifySignatures, + ] { + assert!( + !action_allowed_during_rebase_continuation(Some(&action), false), + "{action:?} cannot invalidate a materialized rebase" + ); + } + assert!( + action_allowed_during_rebase_continuation(Some(&Action::Quit), false), + "history q always remains available" + ); + for action in [Action::OpenDiff, Action::Cancel, Action::Quit] { + assert!( + action_allowed_during_rebase_continuation(Some(&action), true), + "{action:?} retains its changes-pane behavior" + ); + } + assert!( + !action_allowed_during_rebase_continuation(Some(&Action::OpenDiff), false), + "history Enter is reserved for continuation" + ); + assert!( + !action_allowed_during_rebase_continuation(Some(&Action::Cancel), false), + "history Escape is reserved for stopping" + ); + } + + #[test] + fn shift_switches_mouse_scrolling_from_viewport_to_cursor_navigation() { + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollUp, KeyModifiers::NONE, 4, false), + Some(Action::PanUpBy(4)) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollDown, KeyModifiers::SHIFT, 3, false), + Some(Action::MoveDownBy(3)) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollLeft, KeyModifiers::NONE, 1, false), + Some(Action::ScrollLeft) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollRight, KeyModifiers::SHIFT, 1, false), + Some(Action::NextChild) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollUp, KeyModifiers::NONE, 2, true), + Some(Action::MoveUpBy(2)), + "focused changes retain cursor navigation" + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::Moved, KeyModifiers::NONE, 1, false), + None + ); + assert!(repeats_viewport( + &mouse_scroll_action(MouseEventKind::ScrollDown, KeyModifiers::NONE, 2, false) + .expect("vertical scrolling has an action") + )); + assert!(!repeats_viewport( + &mouse_scroll_action(MouseEventKind::ScrollRight, KeyModifiers::NONE, 1, false) + .expect("horizontal scrolling has an action") + )); + } + + #[test] + fn copies_parsed_author_bytes_without_validation() { + let author = app::Author { + name: b"Author > Name".as_bstr(), + email: b"author<@example.com".as_bstr(), + }; + + assert_eq!( + actor_bytes(&author), + b"Author > Name <author<@example.com>", + "parsed author bytes are copied even if they aren't valid serialization tokens" ); - Ok(()) } #[test] - fn chooses_screen_from_terminal_and_history_height() { - assert_eq!( - inline_height(Screen::Auto, 20, 7), - Some(10), - "short histories occupy only their rows, spacers, and footer" + fn rendering_is_reactive_and_capped_while_streaming() { + assert!( + !history_is_ready_to_draw(State::Loading, 0), + "the initial empty frame remains outside terminal scrollback" ); - assert_eq!( - inline_height(Screen::Auto, 20, 8), - Some(11), - "spacers do not force an otherwise short history into the alternate screen" + assert!( + history_is_ready_to_draw(State::Loading, 1), + "the first commit makes loading history renderable" + ); + assert!( + history_is_ready_to_draw(State::Computing, 0), + "an empty completed traversal remains renderable" + ); + assert!( + !should_draw(false, false, Duration::MAX), + "clean frames are never redrawn" + ); + assert!( + should_draw(true, false, Duration::ZERO), + "idle changes redraw immediately" + ); + assert!( + !should_draw(true, true, FRAME_INTERVAL.saturating_sub(Duration::from_nanos(1))), + "streaming frames wait for the 60 fps deadline" + ); + assert!( + should_draw(true, true, FRAME_INTERVAL), + "streaming frames draw at the deadline" ); assert_eq!( - inline_height(Screen::Auto, 20, 10), + poll_timeout(false, 0, false, Duration::ZERO, None), None, - "the auto cutoff remains half the terminal height" + "idle waits reactively for terminal input" ); assert_eq!( - inline_height(Screen::Half, 21, 3), - Some(6), - "half mode shrinks to the rows, spacers, and footer needed by short histories" + poll_timeout(true, EVENT_BATCH_SIZE, true, Duration::ZERO, None), + Some(Duration::ZERO), + "saturated history batches keep processing" ); assert_eq!( - inline_height(Screen::Half, 21, 10), - Some(10), - "half mode is capped at half the terminal, rounded down" + poll_timeout(true, 1, true, Duration::from_millis(10), None), + Some(FRAME_INTERVAL.saturating_sub(Duration::from_millis(10))), + "dirty streaming frames wait only until their deadline" ); assert_eq!( - inline_height(Screen::Half, 21, 0), - Some(3), - "an empty history only needs its spacers and footer" + poll_timeout(false, 0, false, Duration::ZERO, Some(REPEAT_IDLE)), + Some(REPEAT_IDLE), + "repeat-idle restoration wakes an otherwise idle event loop" ); assert_eq!( - inline_height(Screen::Always, 20, 0), - None, - "always mode uses the alternate screen" + poll_timeout(true, 1, true, Duration::from_millis(10), Some(REPEAT_IDLE)), + Some(FRAME_INTERVAL.saturating_sub(Duration::from_millis(10))), + "the earlier frame deadline takes precedence over repeat-idle restoration" ); } #[test] - fn switches_screens_for_inline_commit_panes_and_large_histories() { - assert!( - should_switch_screen(true, true, false), - "opening the commit pane from inline mode enters the alternate screen" - ); - assert!( - should_switch_screen(true, false, true), - "closing the commit pane returns to inline mode" - ); - assert!( - !should_switch_screen(false, true, true), - "a session that started in the alternate screen stays there" - ); + fn filters_worktree_watch_events_and_invalidates_cached_status() { + use notify::event::{AccessKind, CreateKind, Flag, ModifyKind, RemoveKind, RenameMode}; + + let workdir = Path::new("/repo"); + let dot_git = workdir.join(".git"); + let git_dir = dot_git.clone(); + let index = git_dir.join("index"); + let modified = + |path: &Path| notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)).add_path(path.to_owned()); + assert!(worktree_event_is_relevant( + &modified(&workdir.join("src/lib.rs")), + workdir, + &dot_git, + &git_dir, + &index + )); + assert!(worktree_event_is_relevant( + &modified(&index), + workdir, + &dot_git, + &git_dir, + &index + )); + assert!(!worktree_event_is_relevant( + &modified(&git_dir.join("HEAD")), + workdir, + &dot_git, + &git_dir, + &index + )); + let access = + notify::Event::new(notify::EventKind::Access(AccessKind::Any)).add_path(workdir.join("src/lib.rs")); + assert!(!worktree_event_is_relevant( + &access, workdir, &dot_git, &git_dir, &index + )); + assert!(!notification_is_actionable(&access)); + let lock_only = modified(&git_dir.join("index.lock")); + assert!(!notification_is_actionable(&lock_only)); + let completed_lock_rename = notify::Event::new(notify::EventKind::Modify(ModifyKind::Name(RenameMode::Any))) + .add_path(git_dir.join("index.lock")); + assert!(notification_is_actionable(&completed_lock_rename)); + let completed_lock_update = lock_only.add_path(index.clone()); + assert!(notification_is_actionable(&completed_lock_update)); + let rescan = notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan); + assert!(worktree_event_is_relevant(&rescan, workdir, &dot_git, &git_dir, &index)); + assert!(notification_is_actionable(&rescan)); + let empty = notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)); assert!( - !should_switch_screen(true, true, true), - "an already-active alternate screen is not re-entered" + worktree_event_is_relevant(&empty, workdir, &dot_git, &git_dir, &index), + "an event without paths conservatively refreshes all status" ); - assert!(!history_needs_alternate_screen(Screen::Auto, 20, 7)); - assert!(!history_needs_alternate_screen(Screen::Auto, 20, 8)); - assert!(history_needs_alternate_screen(Screen::Auto, 20, 10)); + + let worktrees = git_dir.join("worktrees"); + let linked = worktrees.join("linked"); + assert!(reference_event_is_relevant( + &modified(&linked.join("HEAD")), + &git_dir, + &worktrees + )); + assert!(reference_event_is_relevant( + &modified(&linked.join("gitdir")), + &git_dir, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&linked.join("index")), + &git_dir, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&linked.join("logs/HEAD")), + &git_dir, + &worktrees + )); + let current_linked = worktrees.join("current"); + assert!(!reference_event_is_relevant( + &modified(¤t_linked.join("index")), + ¤t_linked, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&git_dir.join("index")), + ¤t_linked, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&git_dir.join("index")), + &git_dir, + &worktrees + )); + assert!(reference_event_is_relevant( + &modified(&git_dir.join("refs/heads/other")), + &git_dir, + &worktrees + )); + assert!(reference_event_changes_status_configuration( + &modified(&git_dir.join("config")), + &git_dir, + &worktrees + )); + assert!(reference_event_changes_status_configuration( + &modified(¤t_linked.join("config.worktree")), + ¤t_linked, + &worktrees + )); assert!( - !history_needs_alternate_screen(Screen::Half, 20, usize::MAX), - "half-screen mode never switches because history grows" + !reference_event_changes_status_configuration( + &modified(&git_dir.join("refs/heads/other")), + &git_dir, + &worktrees + ), + "unrelated refs don't invalidate worktree status through configuration" ); - } + assert!(reference_event_is_relevant( + &modified(¤t_linked.join("refs/worktree/tix/pins/abcd")), + ¤t_linked, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&linked.join("refs/worktree/tix/pins/abcd")), + ¤t_linked, + &worktrees + )); + assert!(reference_watch_set_may_change( + &modified(&worktrees.join("new-linked")), + &worktrees + )); + assert!(!reference_watch_set_may_change( + &modified(&linked.join("HEAD")), + &worktrees + )); - #[test] - fn maps_navigation_and_control_c() { - assert_eq!( - action(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)), - Some(Action::PageUp) + let directories = HashSet::from([workdir.join("src")]); + let mut watch_refresh = WorktreeWatchRefresh::default(); + watch_refresh.observe(&modified(&workdir.join("src/lib.rs")), workdir, &index, &directories); + assert!(watch_refresh.is_empty(), "ordinary file changes don't touch watches"); + let file_rename = notify::Event::new(notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both))) + .add_path(workdir.join("src/old")) + .add_path(workdir.join("src/new")); + watch_refresh.observe(&file_rename, workdir, &index, &directories); + assert!(watch_refresh.is_empty(), "ordinary file renames don't touch watches"); + watch_refresh.observe(&modified(&index), workdir, &index, &directories); + assert!(watch_refresh.index, "index changes request a projection comparison"); + + let mut watch_refresh = WorktreeWatchRefresh::default(); + watch_refresh.observe( + &modified(&workdir.join("src/.gitignore")), + workdir, + &index, + &directories, ); + assert_eq!(watch_refresh.scopes, directories, "nested ignores rescan their parent"); + watch_refresh.observe(&modified(&workdir.join(".gitignore")), workdir, &index, &directories); + assert!(watch_refresh.full, "a root ignore change affects the whole worktree"); + + let create_directory = + notify::Event::new(notify::EventKind::Create(CreateKind::Folder)).add_path(workdir.join("new")); + let remove_directory = + notify::Event::new(notify::EventKind::Remove(RemoveKind::Folder)).add_path(workdir.join("src")); + let mut watch_refresh = WorktreeWatchRefresh::default(); + watch_refresh.observe(&create_directory, workdir, &index, &directories); + watch_refresh.observe(&remove_directory, workdir, &index, &directories); assert_eq!( - action(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)), - Some(Action::PageUp) + watch_refresh.scopes, + HashSet::from([workdir.join("new"), workdir.join("src")]), + "directory topology is reconciled by scope" ); + watch_refresh.observe(&rescan, workdir, &index, &directories); + assert!(watch_refresh.full, "rescans compare the complete desired watch set"); + assert_eq!( - action(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), - Some(Action::PageDown) + worktree_status_event_scopes( + &modified(&workdir.join("src/lib.rs")), + workdir, + &dot_git, + &git_dir, + &index + ), + Some(vec!["src/lib.rs".into()]), + "file events become literal repository-relative scopes" ); assert_eq!( - action(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), - Some(Action::HalfPageUp) + worktree_status_event_scopes( + &modified(&workdir.join("src/.gitignore")), + workdir, + &dot_git, + &git_dir, + &index + ), + Some(vec!["src".into()]), + "ignore changes refresh their subtree" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)), - Some(Action::HalfPageDown) + assert!( + worktree_status_event_scopes( + &modified(&workdir.join("src/.gitattributes")), + workdir, + &dot_git, + &git_dir, + &index + ) + .is_none(), + "attribute changes require full status, including staged line counts" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)), - Some(Action::ScrollLeft) + assert!( + worktree_status_event_scopes( + &modified(&workdir.join(".gitmodules")), + workdir, + &dot_git, + &git_dir, + &index + ) + .is_none(), + "submodule configuration changes require full status" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)), - Some(Action::ScrollRight) + assert!( + worktree_status_event_scopes(&modified(&index), workdir, &dot_git, &git_dir, &index).is_none(), + "index events require full status" ); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let raw = OsString::from_vec(vec![b'n', 0xff]); + assert_eq!( + worktree_status_event_scopes(&modified(&workdir.join(raw)), workdir, &dot_git, &git_dir, &index), + Some(vec![BString::from(vec![b'n', 0xff])]), + "event paths remain byte-preserving" + ); + } + + let mut changes = Some((WORKTREE_STATUS_CURRENT, Changes::default())); + let mut parts = WorktreeStatusParts::default(); + assert!(invalidate_worktree_status_parts( + &mut changes, + &mut parts, + false, + [BString::from("src/lib.rs")] + )); assert_eq!( - action(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT)), - Some(Action::Last), - "terminals that report shifted letters in lowercase still map Shift-G to the first commit" + changes.as_ref().map(|(marker, _)| *marker), + Some(WORKTREE_STATUS_PARTIAL) ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), - Some(Action::ToggleDate) + assert!(invalidate_worktree_changes(&mut changes)); + assert_eq!(changes.as_ref().map(|(marker, _)| *marker), Some(WORKTREE_STATUS_FULL)); + assert!(!invalidate_worktree_changes(&mut changes)); + } + + #[test] + fn worktree_watch_directories_follow_git_ignores() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let root = fixture.path(); + std::fs::create_dir_all(root.join("visible/nested"))?; + std::fs::create_dir_all(root.join("visible/ignored/nested"))?; + std::fs::create_dir_all(root.join("target/nested"))?; + std::fs::write(root.join(".gitignore"), "target/\nvisible/ignored/\n")?; + + let repository = test_repository::open(root)?; + let directories = worktree_watch_directories(&repository)?; + let root = repository.workdir().expect("the fixture has a worktree"); + assert!(directories.contains(root), "the worktree root is always watched"); + assert!( + directories.contains(&root.join("visible")), + "visible directories are watched" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), - Some(Action::ToggleName) + assert!( + directories.contains(&root.join("visible/nested")), + "visible descendants are watched" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), - Some(Action::ToggleTrailers) + assert!( + !directories.contains(&root.join("target")), + "ignored directories aren't watched" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), - Some(Action::ToggleMailmap) + assert!( + !directories.contains(&root.join("target/nested")), + "ignored descendants aren't traversed" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), - Some(Action::ToggleRefs) + assert!( + !directories.contains(&root.join("visible/ignored")), + "nested ignore rules are honored" ); + Ok(()) + } + + #[test] + fn worktree_watches_apply_only_directory_set_differences() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let root = fixture.path(); + let mut watcher = start_worktree_watcher(root, false)?; + let initial_projection = watcher.index_projection.clone(); + std::fs::create_dir_all(root.join("new/nested"))?; + std::fs::create_dir_all(root.join("staged"))?; + std::fs::write(root.join("staged/tracked"), "new\n")?; + let status = Command::new("git") + .current_dir(root) + .args(["add", "staged/tracked"]) + .status()?; + assert!(status.success(), "git adds a path outside the refresh scope"); + + let refresh = WorktreeWatchRefresh { + scopes: HashSet::from([root.join("new")]), + ..WorktreeWatchRefresh::default() + }; assert_eq!( - action(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)), - Some(Action::ToggleHidden) + reconcile_worktree_watcher(&mut watcher, root, false, refresh)?, + (0, 2), + "the new subtree adds exactly its two directories" ); + assert!(watcher.directories.contains(&root.join("new/nested"))); assert_eq!( - action(KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE)), - Some(Action::ToggleAlign) + watcher.index_projection, initial_projection, + "a scoped worktree refresh doesn't consume a pending index change" ); + assert_eq!( - action(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)), - Some(Action::ToggleCommit) + reconcile_worktree_watcher( + &mut watcher, + root, + false, + WorktreeWatchRefresh { + index: true, + ..WorktreeWatchRefresh::default() + } + )?, + (0, 1), + "the later index refresh still adds its directory watch" ); + assert!(watcher.directories.contains(&root.join("staged"))); + + let refresh = WorktreeWatchRefresh { + scopes: HashSet::from([root.join("new")]), + ..WorktreeWatchRefresh::default() + }; assert_eq!( - action(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), - Some(Action::ToggleCommit) + reconcile_worktree_watcher(&mut watcher, root, false, refresh)?, + (0, 0), + "an unchanged desired set never mutates the watcher" ); + + std::fs::write(root.join(".gitignore"), "new/\n")?; assert_eq!( - action(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)), - Some(Action::CopyAuthor) + reconcile_worktree_watcher( + &mut watcher, + root, + false, + WorktreeWatchRefresh { + full: true, + ..WorktreeWatchRefresh::default() + } + )?, + (2, 0), + "new ignore rules remove only the newly ignored subtree" ); - assert_eq!( - action(KeyEvent::new_with_kind( - KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift), - KeyModifiers::SHIFT, - KeyEventKind::Press, - )), - Some(Action::PreviewAuthorCopy(true)) + assert!(!watcher.directories.contains(&root.join("new"))); + assert!( + watcher + .directories + .contains(watcher.index.parent().expect("index path has a parent")), + "the index directory always remains watched" ); + Ok(()) + } + + #[test] + fn index_watch_projection_ignores_content_but_tracks_topology() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let root = fixture.path(); + let repository = test_repository::open(root)?; + let index = repository.index_or_empty()?; + let before = index_watch_projection(&index); + drop(index); + drop(repository); + + std::fs::write(root.join("main"), "new contents\n")?; + let status = Command::new("git").current_dir(root).args(["add", "main"]).status()?; + assert!(status.success(), "git stages new contents for an existing path"); + let repository = test_repository::open(root)?; + let index = repository.index_or_empty()?; + let after_content = index_watch_projection(&index); + drop(index); + drop(repository); assert_eq!( - action(KeyEvent::new_with_kind( - KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift), - KeyModifiers::NONE, - KeyEventKind::Release, - )), - Some(Action::PreviewAuthorCopy(false)) + before, after_content, + "object and stat changes do not affect directory watches" ); + + std::fs::create_dir_all(root.join("new"))?; + std::fs::write(root.join("new/tracked"), "new\n")?; + let status = Command::new("git") + .current_dir(root) + .args(["add", "new/tracked"]) + .status()?; + assert!(status.success(), "git adds a path in a new directory"); + let repository = test_repository::open(root)?; + let index = repository.index_or_empty()?; + let after_path = index_watch_projection(&index); assert_eq!( - action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - Some(Action::Quit) + changed_index_watch_scopes(&after_content, &after_path, root), + HashSet::from([root.join("new")]), + "index topology changes identify the affected top-level directory" ); - assert_eq!(action(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), None); + Ok(()) } #[test] - fn retains_the_fill_repository_only_for_repeated_viewport_navigation() { - assert!(retains_fill_repository(KeyEventKind::Repeat, Some(&Action::MoveDown))); - assert!(!retains_fill_repository(KeyEventKind::Press, Some(&Action::MoveDown))); - assert!(!retains_fill_repository(KeyEventKind::Release, Some(&Action::MoveDown))); - assert!(!retains_fill_repository( - KeyEventKind::Repeat, - Some(&Action::ScrollRight) - )); - assert!(!retains_fill_repository( - KeyEventKind::Repeat, - Some(&Action::ToggleDate) - )); + fn starts_worktree_watching_for_the_combined_view() { + assert!(worktree_watcher_needed(false, Some(ChangesMode::Both))); + assert!(!worktree_watcher_needed(false, Some(ChangesMode::Tree))); + assert!(!worktree_watcher_needed(false, None)); + assert!(!worktree_watcher_needed(true, Some(ChangesMode::Both))); } #[test] - fn copies_parsed_author_bytes_without_validation() { - let author = app::Author { - name: b"Author > Name".as_bstr(), - email: b"author<@example.com".as_bstr(), + fn restores_changed_path_selection_after_reordering() { + let path = |path: &str| PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: path.into(), + lines: None, + }; + let previous = Changes { + paths: ["a", "b", "selected"].into_iter().map(path).collect(), + ..Changes::default() + }; + let mut view = app::ChangesView::default(); + view.selected = 2; + view.offset = 1; + let remembered = remembered_change_selection(&view, Some(&previous)); + let refreshed = Changes { + paths: ["x", "y", "z", "selected"].into_iter().map(path).collect(), + ..Changes::default() }; - assert_eq!( - actor_bytes(&author), - b"Author > Name <author<@example.com>", - "parsed author bytes are copied even if they aren't valid serialization tokens" - ); + restore_change_selection(&mut view, &refreshed, remembered); + + assert_eq!(view.selected, 3, "the same path remains selected"); + assert_eq!(view.offset, 2, "the path retains its relative viewport row"); } #[test] - fn rendering_is_reactive_and_capped_while_streaming() { + fn event_deadlines_coalesce_without_extending_and_can_be_retried() { + let now = Instant::now(); + let mut deadline = None; + assert!(schedule_once(&mut deadline, now, Duration::ZERO)); + let first = deadline; + assert!(!schedule_once( + &mut deadline, + now + Duration::from_millis(50), + Duration::ZERO + )); + assert_eq!(deadline, first, "queued worktree events share an immediate deadline"); + assert!(take_due(&mut deadline, now)); + assert_eq!(deadline, None); + + assert!(schedule_once(&mut deadline, now, WATCH_RETRY_INTERVAL)); + assert!(!take_due(&mut deadline, now + Duration::from_secs(4))); + assert!(take_due(&mut deadline, now + WATCH_RETRY_INTERVAL)); + assert!( - !should_draw(false, false, Duration::MAX), - "clean frames are never redrawn" + schedule_once(&mut deadline, now, HISTORY_STATUS_DELAY), + "background progress gets its own deadline" ); assert!( - should_draw(true, false, Duration::ZERO), - "idle changes redraw immediately" + !take_due(&mut deadline, now + Duration::from_millis(499)), + "the completed footer remains visible before 500 ms" ); assert!( - !should_draw(true, true, FRAME_INTERVAL.saturating_sub(Duration::from_nanos(1))), - "streaming frames wait for the 60 fps deadline" + take_due(&mut deadline, now + HISTORY_STATUS_DELAY), + "background progress becomes visible at 500 ms" ); + + let last_event = now + Duration::from_millis(75); + deadline = Some(last_event + REF_EVENT_IDLE); assert!( - should_draw(true, true, FRAME_INTERVAL), - "streaming frames draw at the deadline" + !take_due(&mut deadline, now + REF_EVENT_IDLE), + "reference inspection waits for the final transaction event" ); - assert_eq!( - poll_timeout(false, 0, false, Duration::ZERO), - None, - "idle waits reactively for terminal input" + assert!(take_due(&mut deadline, last_event + REF_EVENT_IDLE)); + } + + #[test] + fn todo_progress_appears_at_three_hundred_milliseconds() { + assert!(!todo_progress_visible(Duration::from_millis(299))); + assert!(todo_progress_visible(TODO_PROGRESS_DELAY)); + } + + #[test] + fn fast_time_travel_draws_the_first_and_latest_rebased_commits() -> Result<()> { + let ids = [ + gix::ObjectId::Sha1([1; 20]), + gix::ObjectId::Sha1([2; 20]), + gix::ObjectId::Sha1([3; 20]), + ]; + let mut rendered = Vec::new(); + + run_with_rebase_selection( + |report| { + for id in ids { + report(id); + } + Ok(()) + }, + |id| { + rendered.push(id); + Ok(()) + }, + )?; + + assert_eq!(rendered.first(), Some(&ids[0]), "the first completed rebase is drawn"); + assert_eq!(rendered.last(), Some(&ids[2]), "the latest completed rebase is drawn"); + Ok(()) + } + + #[test] + fn slow_time_travel_draws_every_rebased_selection() -> Result<()> { + let ids = [gix::ObjectId::Sha1([1; 20]), gix::ObjectId::Sha1([2; 20])]; + let mut rendered = Vec::new(); + + run_with_rebase_selection( + |report| { + for id in ids { + report(id); + std::thread::sleep(FRAME_INTERVAL * 2); + } + Ok(()) + }, + |id| { + rendered.push(id); + Ok(()) + }, + )?; + + assert_eq!(rendered, ids, "slow travel renders each completed rebase"); + Ok(()) + } + + #[test] + fn failed_animation_frame_preserves_completed_time_travel() -> Result<()> { + let completed = run_with_rebase_selection( + |report| { + report(gix::ObjectId::Sha1([1; 20])); + Ok(42) + }, + |_| Err(anyhow::anyhow!("frame failed")), + )?; + + assert_eq!(completed, 42, "animation failure cannot hide a completed mutation"); + Ok(()) + } + + #[test] + fn continuing_a_conflict_commits_the_complete_resolved_index() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let git = |args: &[&str]| Command::new("git").arg("-C").arg(fixture.path()).args(args).output(); + #[cfg(unix)] + let conflict_path = ":(glob)*"; + #[cfg(not(unix))] + let conflict_path = "file"; + let deleted_path = "deleted-conflict"; + std::fs::write(fixture.path().join(conflict_path), b"conflict base\n")?; + std::fs::write(fixture.path().join(deleted_path), b"delete base\n")?; + assert!( + git(&["--literal-pathspecs", "add", "--", conflict_path, deleted_path,])? + .status + .success() + ); + assert!(git(&["commit", "-qm", "conflict base"])?.status.success()); + assert!(git(&["checkout", "-q", "-b", "conflict-side"])?.status.success()); + std::fs::write(fixture.path().join(conflict_path), b"side\n")?; + std::fs::write(fixture.path().join(deleted_path), b"side\n")?; + assert!(git(&["commit", "-qam", "side"])?.status.success()); + assert!(git(&["checkout", "-q", "main"])?.status.success()); + std::fs::write(fixture.path().join(conflict_path), b"main\n")?; + std::fs::write(fixture.path().join(deleted_path), b"main\n")?; + assert!(git(&["commit", "-qam", "main"])?.status.success()); + assert!( + !git(&["merge", "--no-edit", "conflict-side"])?.status.success(), + "the fixture produces an ordinary unmerged index" + ); + std::fs::write(fixture.path().join(conflict_path), b"resolved\n")?; + std::fs::remove_file(fixture.path().join(deleted_path))?; + std::fs::write(fixture.path().join("already-staged"), b"staged\n")?; + assert!(git(&["add", "already-staged"])?.status.success()); + std::fs::write(fixture.path().join("unrelated"), b"unstaged\n")?; + + let repository = test_repository::open(fixture.path())?; + stage_resolved_conflict_paths(&repository)?; + + let index = repository.index_or_empty()?; + assert!( + index + .entries() + .iter() + .all(|entry| entry.stage() == gix::index::entry::Stage::Unconflicted), + "resolved paths no longer retain conflict stages" ); + let staged = git(&["diff", "--cached", "--name-only"])?.stdout; assert_eq!( - poll_timeout(true, EVENT_BATCH_SIZE, true, Duration::ZERO), - Some(Duration::ZERO), - "saturated history batches keep processing" + staged.as_bstr().lines().collect::<HashSet<_>>(), + HashSet::from([ + b"already-staged".as_slice(), + conflict_path.as_bytes(), + deleted_path.as_bytes(), + ]), + "edited and deleted resolutions join changes that were already staged" + ); + + let head = repository.head_id()?.detach(); + let parent = repository + .find_commit(head)? + .parent_ids() + .next() + .map(gix::Id::detach) + .context("the conflicted commit has a parent")?; + let plan = edit::rebase::Plan { + base: parent, + scope: vec![head], + steps: vec![edit::rebase::PlanStep { + parent: edit::rebase::PlanParent::Existing(parent), + commit: edit::rebase::PlanCommit::Resolved(head), + squash: Vec::new(), + }], + checkout: Some(edit::rebase::PlanCheckout { + target: edit::rebase::PlanParent::Step(0), + reference: None, + }), + expected_refs: Vec::new(), + }; + let graph = HistoryGraph::for_commits(&repository, &plan.scope)?; + let outcome = edit::rebase::perform_plan(&repository, &graph, plan)?.complete()?; + let resolved = outcome.map(head).context("the conflicted commit is retained")?; + edit::time_travel::checkout_plan(fixture.path(), false, &outcome, &[], false)?; + + let repository = test_repository::open(fixture.path())?; + assert_eq!(repository.head_id()?, resolved, "HEAD selects the resolved commit"); + for (path, expected) in [ + (conflict_path, b"resolved\n".as_slice()), + ("already-staged", b"staged\n"), + ] { + let object = format!("{resolved}:{path}"); + assert_eq!( + git(&["show", &object])?.stdout, + expected, + "{path} is absorbed into HEAD" + ); + } + let deleted = format!("{resolved}:{deleted_path}"); + assert!( + !git(&["cat-file", "-e", &deleted])?.status.success(), + "a deleted conflict is absent from HEAD" + ); + assert!( + git(&["diff", "--cached", "--quiet"])?.status.success(), + "the resolved commit leaves no staged changes" + ); + assert!( + repository + .index_or_empty()? + .entries() + .iter() + .all(|entry| entry.stage() == gix::index::entry::Stage::Unconflicted), + "the final index has no conflict stages" + ); + assert!( + history::all_pins(&repository)?.iter().all(history::Pin::is_head), + "continuing a resolved plan creates no ordinary pin for its superseded conflict checkout" + ); + assert!( + !git(&["ls-files", "--error-unmatch", "unrelated"])?.status.success(), + "unrelated unstaged paths remain untouched" ); + Ok(()) + } + + #[test] + fn rebase_todo_loads_metadata_for_the_entire_editable_scope() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repository = test_repository::open(fixture.path())?; + let scope = ["HEAD", "HEAD~1", "HEAD~2"] + .into_iter() + .map(|revision| Ok(repository.rev_parse_single(revision)?.detach())) + .collect::<gix_testtools::Result<Vec<_>>>()?; + let unloaded_author = Box::leak(Box::new(app::Author { + name: b"".as_bstr(), + email: b"".as_bstr(), + })); + let rows = scope + .iter() + .map(|id| { + let commit = repository.find_commit(*id)?; + Ok(app::Commit { + id: *id, + parent_ids: commit.parent_ids().map(gix::Id::detach).collect(), + committer_time: gix::date::Time::default(), + author_time: gix::date::Time::default(), + author: unloaded_author, + attributions: 0..0, + title: BString::default(), + metadata_loaded: false, + has_agent_marker: false, + is_review: false, + signature: app::SignatureState::Unsigned, + }) + }) + .collect::<gix_testtools::Result<Vec<_>>>()?; + let mut app = App::new(1); + app.extend_commits(rows); + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + + let commits = load_rebase_todo_commits(&repository, &mut app, &authors, &scope)?; + + assert!(app.rows.iter().all(|row| row.metadata_loaded)); + for (commit, title) in commits.iter().zip(["tip", "middle", "base"]) { + assert!( + commit.info.contains("author"), + "author metadata is loaded: {}", + commit.info + ); + assert!(commit.info.contains(title), "the real title is loaded: {}", commit.info); + assert!( + !commit.info.contains("1970-01-01"), + "missing dates never leak into the editor: {}", + commit.info + ); + } + Ok(()) + } + + #[test] + fn todo_conflict_preview_selects_the_partial_result_in_memory() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = crate::test_repository::open_with( + fixture.path(), + ["user.name=preview author", "user.email=preview@example.com"], + )?; + let graph = edit::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tip = repo.head_id()?.detach(); + let edit::rebase::PlanPerform::Conflict(conflict) = edit::rebase::perform_plan( + &repo, + &graph, + edit::rebase::Plan { + base, + scope: vec![middle, tip], + steps: vec![edit::rebase::PlanStep { + parent: edit::rebase::PlanParent::Existing(base), + commit: edit::rebase::PlanCommit::Pick(tip), + squash: Vec::new(), + }], + checkout: Some(edit::rebase::PlanCheckout { + target: edit::rebase::PlanParent::Step(0), + reference: None, + }), + expected_refs: edit::rebase::capture_refs(&repo, &[middle, tip], &[tip])?, + }, + )? + else { + return Err("the reordered history should conflict".into()); + }; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut app = App::new(usize::MAX); + preview_todo_rebase_conflict(&mut app, &conflict, &authors, &[tip], &[])?; + app.arm_rebase_conflict(conflict.commit()); + app.select_commit(conflict.commit()); + assert_eq!( - poll_timeout(true, 1, true, Duration::from_millis(10)), - Some(FRAME_INTERVAL.saturating_sub(Duration::from_millis(10))), - "dirty streaming frames wait only until their deadline" + app.rows + .get(app.selected.expect("the conflict is selected")) + .map(|row| row.id), + Some(conflict.commit()), + "the displayed conflict row is the prepared result, not its original source" ); + Ok(()) } } diff --git a/gix-tix/src/logging.rs b/gix-tix/src/logging.rs new file mode 100644 index 00000000000..8853841efab --- /dev/null +++ b/gix-tix/src/logging.rs @@ -0,0 +1,562 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use anyhow::{Context, Result}; +use tracing_subscriber::{filter::Targets, prelude::*}; + +const FILE_PREFIX: &str = "tix.log"; +const RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const MAX_TRIGGER_PATHS: usize = 16; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Trigger { + Head, + Index, + PackedRefs, + Refs, + Rescan, + Worktree, + GitMetadata, +} + +#[derive(Clone, Copy, Debug)] +enum WatcherKind { + References, + Worktree, +} + +struct Response { + id: u64, + watcher: WatcherKind, + started: std::time::Instant, + batches: usize, + events: usize, + rescans: usize, + kinds: BTreeMap<&'static str, usize>, + triggers: BTreeSet<Trigger>, + seen_paths: HashSet<PathBuf>, + paths: Vec<PathBuf>, + omitted_paths: usize, + presentations: usize, +} + +impl Response { + fn new(id: u64, watcher: WatcherKind) -> Self { + Response { + id, + watcher, + started: std::time::Instant::now(), + batches: 0, + events: 0, + rescans: 0, + kinds: BTreeMap::new(), + triggers: BTreeSet::new(), + seen_paths: HashSet::new(), + paths: Vec::new(), + omitted_paths: 0, + presentations: 0, + } + } + + fn observe(&mut self, event: ¬ify::Event, classify: impl Fn(&Path) -> Trigger) { + self.events += 1; + self.rescans += usize::from(event.need_rescan()); + *self.kinds.entry(event_kind(&event.kind)).or_default() += 1; + if event.need_rescan() { + self.triggers.insert(Trigger::Rescan); + } + for path in &event.paths { + self.triggers.insert(classify(path)); + if !self.seen_paths.insert(path.clone()) { + continue; + } + if self.paths.len() < MAX_TRIGGER_PATHS { + self.paths.push(path.clone()); + } else { + self.omitted_paths += 1; + } + } + } + + fn log_trigger(&self) { + tracing::debug!( + response_id = self.id, + watcher = ?self.watcher, + batches = self.batches, + events = self.events, + rescans = self.rescans, + event_kinds = ?self.kinds, + triggers = ?self.triggers, + paths = ?self.paths, + omitted_paths = self.omitted_paths, + "filesystem UI response triggered" + ); + } +} + +#[derive(Default)] +pub(crate) struct FilesystemResponses { + next_id: u64, + responses: HashMap<u64, Response>, + pending_worktree: Option<u64>, + pending_references: Option<u64>, + queued_references: Vec<u64>, + active_references: Vec<u64>, + frame_causes: Vec<(u64, &'static str)>, + finish_after_frame: Vec<(u64, &'static str)>, +} + +impl FilesystemResponses { + pub(crate) fn observe_worktree(&mut self, event: ¬ify::Event, workdir: &Path, index: &Path) -> u64 { + let id = self.ensure_pending(WatcherKind::Worktree); + self.responses + .get_mut(&id) + .expect("a pending response is registered") + .observe(event, |path| { + if path == index { + Trigger::Index + } else if path.starts_with(workdir) { + Trigger::Worktree + } else { + Trigger::GitMetadata + } + }); + id + } + + pub(crate) fn observe_references(&mut self, event: ¬ify::Event, git_dir: &Path, common_dir: &Path) -> u64 { + let id = self.ensure_pending(WatcherKind::References); + self.responses + .get_mut(&id) + .expect("a pending response is registered") + .observe(event, |path| classify_reference_path(path, git_dir, common_dir)); + id + } + + pub(crate) fn note_worktree_batch(&mut self) { + self.note_batch(self.pending_worktree); + } + + pub(crate) fn note_reference_batch(&mut self) { + self.note_batch(self.pending_references); + } + + pub(crate) fn worktree_due(&mut self, invalidated: bool) -> Vec<u64> { + let Some(id) = self.pending_worktree.take() else { + return Vec::new(); + }; + self.log_trigger(id); + tracing::debug!(response_id = id, invalidated, action = "worktree-cache-invalidation"); + self.queue_frame(&[id], "worktree-cache-invalidation"); + self.finish_after_frame(&[id], "completed"); + vec![id] + } + + pub(crate) fn references_due(&mut self) -> Vec<u64> { + let Some(id) = self.pending_references.take() else { + return Vec::new(); + }; + self.log_trigger(id); + self.queued_references.push(id); + tracing::debug!(response_id = id, action = "reference-refresh-queued"); + vec![id] + } + + pub(crate) fn begin_reference_refresh(&mut self) -> Vec<u64> { + self.active_references = std::mem::take(&mut self.queued_references); + self.active_references.clone() + } + + pub(crate) fn active_reference_ids(&self) -> &[u64] { + &self.active_references + } + + pub(crate) fn phase(&self, ids: &[u64], action: &'static str) { + if !ids.is_empty() { + tracing::debug!(response_ids = ?ids, action); + } + } + + pub(crate) fn queue_frame(&mut self, ids: &[u64], reason: &'static str) { + for id in ids { + if !self.frame_causes.contains(&(*id, reason)) { + self.frame_causes.push((*id, reason)); + } + } + } + + pub(crate) fn finish_after_frame(&mut self, ids: &[u64], outcome: &'static str) { + for id in ids { + if !self.finish_after_frame.iter().any(|(candidate, _)| candidate == id) { + self.finish_after_frame.push((*id, outcome)); + } + } + } + + pub(crate) fn has_queued_frame(&self) -> bool { + !self.frame_causes.is_empty() + } + + pub(crate) fn fail_pending_worktree(&mut self) { + self.cancel_pending_worktree("watcher-failure"); + } + + pub(crate) fn cancel_pending_worktree(&mut self, outcome: &'static str) { + if let Some(id) = self.pending_worktree.take() { + self.log_trigger(id); + self.finish(&[id], outcome); + } + } + + pub(crate) fn fail_pending_references(&mut self) { + if let Some(id) = self.pending_references.take() { + self.log_trigger(id); + self.finish(&[id], "watcher-failure"); + } + } + + pub(crate) fn frame_presented(&mut self) { + if self.frame_causes.is_empty() { + return; + } + let causes = std::mem::take(&mut self.frame_causes); + let mut ids = causes.iter().map(|(id, _)| *id).collect::<Vec<_>>(); + ids.sort_unstable(); + ids.dedup(); + for id in &ids { + if let Some(response) = self.responses.get_mut(id) { + response.presentations += 1; + } + } + tracing::debug!(response_ids = ?ids, ?causes, "filesystem-triggered UI frame presented"); + + let finishing = std::mem::take(&mut self.finish_after_frame); + for (id, outcome) in finishing { + self.finish(&[id], outcome); + } + } + + pub(crate) fn finish(&mut self, ids: &[u64], outcome: &'static str) { + for id in ids { + let Some(response) = self.responses.remove(id) else { + continue; + }; + tracing::debug!( + response_id = response.id, + watcher = ?response.watcher, + outcome, + presentations = response.presentations, + elapsed_ms = response.started.elapsed().as_millis(), + "filesystem UI response finished" + ); + } + self.active_references.retain(|id| !ids.contains(id)); + self.queued_references.retain(|id| !ids.contains(id)); + self.frame_causes.retain(|(id, _)| !ids.contains(id)); + self.finish_after_frame.retain(|(id, _)| !ids.contains(id)); + } + + fn ensure_pending(&mut self, watcher: WatcherKind) -> u64 { + let slot = match watcher { + WatcherKind::References => &mut self.pending_references, + WatcherKind::Worktree => &mut self.pending_worktree, + }; + if let Some(id) = *slot { + return id; + } + self.next_id += 1; + let id = self.next_id; + self.responses.insert(id, Response::new(id, watcher)); + *slot = Some(id); + id + } + + fn log_trigger(&self, id: u64) { + if let Some(response) = self.responses.get(&id) { + response.log_trigger(); + } + } + + fn note_batch(&mut self, id: Option<u64>) { + if let Some(response) = id.and_then(|id| self.responses.get_mut(&id)) { + response.batches += 1; + } + } +} + +fn event_kind(kind: ¬ify::EventKind) -> &'static str { + match kind { + notify::EventKind::Access(_) => "access", + notify::EventKind::Create(_) => "create", + notify::EventKind::Modify(_) => "modify", + notify::EventKind::Remove(_) => "remove", + notify::EventKind::Other => "other", + notify::EventKind::Any => "any", + } +} + +fn classify_reference_path(path: &Path, git_dir: &Path, common_dir: &Path) -> Trigger { + let head = git_dir.join("HEAD"); + let common_head = common_dir.join("HEAD"); + let linked_head = path + .strip_prefix(common_dir.join("worktrees")) + .ok() + .is_some_and(|relative| { + let mut components = relative.components(); + let name = components + .nth(1) + .map(|component| component.as_os_str().as_encoded_bytes()); + matches!(name, Some(b"HEAD" | b"HEAD.lock")) && components.next().is_none() + }); + let index = git_dir.join("index"); + let packed_refs = common_dir.join("packed-refs"); + if path == head + || path == head.with_extension("lock") + || path == common_head + || path == common_head.with_extension("lock") + || linked_head + { + Trigger::Head + } else if path == index || path == index.with_extension("lock") { + Trigger::Index + } else if path == packed_refs || path == packed_refs.with_extension("lock") { + Trigger::PackedRefs + } else if path.starts_with(git_dir.join("refs")) || path.starts_with(common_dir.join("refs")) { + Trigger::Refs + } else { + Trigger::GitMetadata + } +} + +pub(crate) fn init() -> Option<tracing::subscriber::DefaultGuard> { + try_init().ok() +} + +fn try_init() -> Result<tracing::subscriber::DefaultGuard> { + let directory = log_directory().context("could not determine the platform log directory")?; + fs::create_dir_all(&directory) + .with_context(|| format!("could not create log directory at {}", directory.display()))?; + let cleanup_errors = prune(&directory, SystemTime::now()); + let appender = tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(FILE_PREFIX) + .build(&directory) + .context("could not open the daily diagnostic log")?; + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_target(false) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) + .with_writer(appender) + .with_filter( + Targets::new() + .with_default(tracing::Level::WARN) + .with_target("gix_tix", tracing::Level::DEBUG), + ), + ); + let guard = tracing::subscriber::set_default(subscriber); + tracing::info!(path = %directory.display(), "initialized diagnostics"); + for error in cleanup_errors { + tracing::warn!(%error, "could not prune an old diagnostic log"); + } + Ok(guard) +} + +#[cfg(target_os = "macos")] +fn log_directory() -> Option<PathBuf> { + directories::BaseDirs::new().map(|dirs| dirs.home_dir().join("Library/Logs/org.GitoxideLabs.tix")) +} + +#[cfg(target_os = "linux")] +fn log_directory() -> Option<PathBuf> { + directories::ProjectDirs::from("org", "GitoxideLabs", "tix").and_then(|dirs| dirs.state_dir().map(Path::to_owned)) +} + +#[cfg(target_os = "windows")] +fn log_directory() -> Option<PathBuf> { + directories::BaseDirs::new().map(|dirs| dirs.data_local_dir().join("GitoxideLabs/tix/logs")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn log_directory() -> Option<PathBuf> { + directories::ProjectDirs::from("org", "GitoxideLabs", "tix").map(|dirs| dirs.data_local_dir().join("logs")) +} + +fn prune(directory: &Path, now: SystemTime) -> Vec<String> { + let mut errors = Vec::new(); + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) => { + errors.push(err.to_string()); + return errors; + } + }; + for entry in entries { + let result = (|| -> std::io::Result<()> { + let entry = entry?; + let name = entry.file_name(); + if !name.to_string_lossy().starts_with(&format!("{FILE_PREFIX}.")) { + return Ok(()); + } + let age = now.duration_since(entry.metadata()?.modified()?).unwrap_or_default(); + if age > RETENTION { + fs::remove_file(entry.path())?; + } + Ok(()) + })(); + if let Err(err) = result { + errors.push(err.to_string()); + } + } + errors +} + +#[cfg(test)] +mod tests { + use std::{fs::File, time::UNIX_EPOCH}; + + use notify::event::{Flag, ModifyKind}; + + use super::*; + + fn modified(path: impl Into<PathBuf>) -> notify::Event { + notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)).add_path(path.into()) + } + + #[test] + fn classifies_reference_triggers() { + let common = Path::new("/repo/.git"); + let linked = common.join("worktrees/topic"); + assert_eq!( + classify_reference_path(&linked.join("HEAD"), &linked, common), + Trigger::Head + ); + assert_eq!( + classify_reference_path(&linked.join("HEAD.lock"), &linked, common), + Trigger::Head, + "transaction lock files retain their semantic trigger" + ); + assert_eq!( + classify_reference_path(&common.join("worktrees/other/HEAD"), &linked, common), + Trigger::Head, + "other linked worktree HEADs retain their semantic trigger" + ); + assert_eq!( + classify_reference_path(&linked.join("index"), &linked, common), + Trigger::Index + ); + assert_eq!( + classify_reference_path(&common.join("packed-refs"), &linked, common), + Trigger::PackedRefs + ); + assert_eq!( + classify_reference_path(&common.join("refs/heads/main"), &linked, common), + Trigger::Refs + ); + assert_eq!( + classify_reference_path(&common.join("config"), &linked, common), + Trigger::GitMetadata + ); + } + + #[test] + fn coalesces_batches_and_bounds_deduplicated_trigger_paths() { + let common = Path::new("/repo/.git"); + let mut responses = FilesystemResponses::default(); + let first = responses.observe_references(&modified(common.join("HEAD")), common, common); + responses.note_reference_batch(); + let second = responses.observe_references(&modified(common.join("refs/heads/main")), common, common); + let rescan = notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan); + let third = responses.observe_references(&rescan, common, common); + responses.note_reference_batch(); + assert_eq!(first, second, "events before the deadline share one response"); + assert_eq!(first, third, "rescans join the pending response"); + let response = responses + .responses + .get(&first) + .expect("the response is retained until acted upon"); + assert_eq!(response.events, 3); + assert_eq!(response.batches, 2); + assert_eq!(response.kinds, [("modify", 2), ("other", 1)].into_iter().collect()); + assert_eq!( + response.triggers, + [Trigger::Head, Trigger::Refs, Trigger::Rescan].into_iter().collect() + ); + + assert_eq!(responses.references_due(), [first]); + let next = responses.observe_references(&modified(common.join("HEAD")), common, common); + assert_ne!(next, first, "activity after the deadline starts another response"); + + let mut many = notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)); + for index in 0..MAX_TRIGGER_PATHS + 4 { + many = many.add_path(common.join(format!("refs/heads/{index}"))); + } + many = many.add_path(common.join(format!("refs/heads/{}", MAX_TRIGGER_PATHS + 3))); + responses.observe_references(&many, common, common); + let response = responses.responses.get(&next).expect("the new response is pending"); + assert_eq!(response.paths.len(), MAX_TRIGGER_PATHS); + assert_eq!( + response.omitted_paths, 5, + "only unique paths beyond the cap are counted" + ); + } + + #[test] + fn attributes_overlapping_responses_to_one_presented_frame() { + let common = Path::new("/repo/.git"); + let workdir = Path::new("/repo"); + let mut responses = FilesystemResponses::default(); + assert!(!responses.has_queued_frame()); + let worktree = responses.observe_worktree(&modified(workdir.join("file")), workdir, &common.join("index")); + responses.worktree_due(true); + assert!(responses.has_queued_frame()); + + let references = responses.observe_references(&modified(common.join("HEAD")), common, common); + responses.references_due(); + assert_eq!(responses.begin_reference_refresh(), [references]); + responses.queue_frame(&[references], "lane-computation-completed"); + responses.finish_after_frame(&[references], "completed"); + + assert_eq!( + responses.frame_causes, + [ + (worktree, "worktree-cache-invalidation"), + (references, "lane-computation-completed") + ], + "one frame retains both filesystem causes" + ); + responses.frame_presented(); + assert!(!responses.has_queued_frame()); + assert!( + responses.responses.is_empty(), + "both responses finish after the shared frame" + ); + } + + #[test] + fn prunes_only_expired_daily_logs() -> gix_testtools::Result { + let directory = std::env::temp_dir().join(format!( + "gix-tix-log-prune-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() + )); + fs::create_dir(&directory)?; + let old = directory.join("tix.log.older"); + let recent = directory.join("tix.log.recent"); + let unrelated = directory.join("other.log.old"); + File::create(&old)?.set_modified(UNIX_EPOCH)?; + File::create(&recent)?; + File::create(&unrelated)?.set_modified(UNIX_EPOCH)?; + + assert!(prune(&directory, SystemTime::now()).is_empty()); + assert!(!old.exists(), "expired tix logs are removed"); + assert!(recent.exists(), "recent tix logs are retained"); + assert!(unrelated.exists(), "unrelated files are retained"); + fs::remove_dir_all(directory)?; + Ok(()) + } +} diff --git a/gix-tix/src/main.rs b/gix-tix/src/main.rs index 4c544e70c53..96bbe72c74a 100644 --- a/gix-tix/src/main.rs +++ b/gix-tix/src/main.rs @@ -1,118 +1,11 @@ #![forbid(unsafe_code)] -use std::ffi::OsString; - use anyhow::{Context, Result}; fn main() -> Result<()> { - let (revisions, options, help) = arguments(gix::env::args_os().skip(1))?; - if help { - println!( - "Usage: tix [--quit-on-finish] [--screen MODE] [-h|--hide REVSPEC] [REVISION]...\n\nBrowse commits reachable from HEAD or the given revisions.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n --screen MODE Use auto, always, or half screen mode [default: auto]\n --help Print help" - ); - return Ok(()); - } - + let command = gix_tix::command::parse(); let current_dir = std::env::current_dir().context("could not determine current directory")?; let repository = gix::ThreadSafeRepository::discover_with_environment_overrides(current_dir) .context("could not discover repository")?; - gix_tix::run(repository, revisions, options) -} - -fn arguments(mut args: impl Iterator<Item = OsString>) -> Result<(Vec<OsString>, gix_tix::Options, bool)> { - let mut revisions = Vec::new(); - let mut options = gix_tix::Options::default(); - let mut help = false; - while let Some(arg) = args.next() { - if arg == "--help" { - help = true; - break; - } else if arg == "--quit-on-finish" { - options.quit_on_finish = true; - } else if arg == "--screen" { - let value = args.next().context("--screen requires one of: auto, always, half")?; - if value == "--help" { - help = true; - break; - } - options.screen = match value { - value if value == "auto" => gix_tix::Screen::Auto, - value if value == "always" => gix_tix::Screen::Always, - value if value == "half" => gix_tix::Screen::Half, - value => anyhow::bail!( - "invalid --screen value {}; expected auto, always, or half", - value.display() - ), - }; - } else if arg == "-h" || arg == "--hide" { - let revision = args.next().context("-h/--hide requires a revision to hide")?; - if revision == "--help" { - help = true; - break; - } - if revision == "-h" || revision == "--hide" || revision == "--quit-on-finish" { - anyhow::bail!("-h/--hide requires a revision to hide"); - } - options.hide.push(revision); - } else { - revisions.push(arg); - } - } - Ok((revisions, options, help)) -} - -#[cfg(test)] -mod tests { - use std::ffi::OsString; - - use super::*; - - #[test] - fn separates_options_from_revisions() -> Result<()> { - let (revisions, options, help) = arguments( - [ - "--quit-on-finish", - "--screen", - "half", - "-h", - "main", - "--hide", - "tag", - "topic", - "--help", - ] - .into_iter() - .map(OsString::from), - )?; - - assert!(options.quit_on_finish); - assert_eq!(options.screen, gix_tix::Screen::Half); - assert_eq!(options.hide, ["main", "tag"], "both hide options are retained"); - assert_eq!(revisions, ["topic"], "only positional revisions remain"); - assert!(help, "--help remains available without claiming -h"); - assert!( - arguments(["-h"].into_iter().map(OsString::from)).is_err(), - "a missing hidden revision is rejected" - ); - for args in [["--help", "-h"], ["-h", "--help"]] { - assert!( - arguments(args.into_iter().map(OsString::from))?.2, - "--help wins regardless of its position" - ); - } - assert_eq!( - arguments(std::iter::empty())?.1.screen, - gix_tix::Screen::Auto, - "auto is the default" - ); - assert!( - arguments(["--screen"].into_iter().map(OsString::from)).is_err(), - "a missing screen mode is rejected" - ); - assert!( - arguments(["--screen", "other"].into_iter().map(OsString::from)).is_err(), - "an unknown screen mode is rejected" - ); - Ok(()) - } + command.run(repository) } diff --git a/gix-tix/src/menu.rs b/gix-tix/src/menu.rs new file mode 100644 index 00000000000..10a3656ed77 --- /dev/null +++ b/gix-tix/src/menu.rs @@ -0,0 +1,480 @@ +pub(crate) const MAX_VISIBLE_ROWS: usize = 9; + +#[derive(Debug)] +pub(crate) struct Item<'a, T> { + pub(crate) label: &'a str, + search_prefix: Option<&'a str>, + scope: Option<char>, + pub(crate) value: T, +} + +impl<'a, T> Item<'a, T> { + pub(crate) fn new(label: &'a str, value: T) -> Self { + Item { + label, + search_prefix: None, + scope: None, + value, + } + } + + pub(crate) fn with_search_prefix(label: &'a str, search_prefix: &'a str, scope: char, value: T) -> Self { + let mut item = Item::new(label, value); + item.search_prefix = Some(search_prefix); + item.scope = Some(scope); + item + } +} + +#[derive(Debug)] +pub(crate) struct Menu<T> { + open: bool, + query: String, + cursor: usize, + matches: Vec<usize>, + selection: Option<usize>, + selected_value: Option<T>, + window: usize, + visible_rows: usize, + last_submitted: Option<T>, +} + +impl<T> Default for Menu<T> { + fn default() -> Self { + Menu { + open: false, + query: String::new(), + cursor: 0, + matches: Vec::new(), + selection: None, + selected_value: None, + window: 0, + visible_rows: MAX_VISIBLE_ROWS, + last_submitted: None, + } + } +} + +impl<T: Clone + Eq> Menu<T> { + pub(crate) fn open(&mut self, items: &[Item<'_, T>]) { + self.open = true; + self.query.clear(); + self.cursor = 0; + self.matches = (0..items.len()).collect(); + self.selection = self + .last_submitted + .as_ref() + .and_then(|last| items.iter().position(|item| &item.value == last)); + self.selected_value = self.selection.map(|selection| items[selection].value.clone()); + self.window = 0; + self.keep_selection_visible(); + } + + pub(crate) fn close(&mut self) { + self.open = false; + } + + pub(crate) fn is_open(&self) -> bool { + self.open + } + + pub(crate) fn query(&self) -> &str { + &self.query + } + + /// The cursor position in Unicode scalar values, not bytes. + pub(crate) fn cursor(&self) -> usize { + self.cursor + } + + pub(crate) fn visible_indices(&self) -> &[usize] { + let end = (self.window + self.visible_rows).min(self.matches.len()); + &self.matches[self.window..end] + } + + pub(crate) fn set_visible_rows(&mut self, rows: usize) { + self.visible_rows = rows.min(MAX_VISIBLE_ROWS); + self.keep_selection_visible(); + } + + #[cfg(test)] + pub(crate) fn selected_index(&self) -> Option<usize> { + self.selection + .and_then(|selection| self.matches.get(selection).copied()) + } + + pub(crate) fn selected_visible_row(&self) -> Option<usize> { + self.selection + .filter(|selection| *selection >= self.window) + .map(|selection| selection - self.window) + .filter(|row| *row < self.visible_rows) + } + + /// Re-filters after the caller replaces the dynamically supplied items. + pub(crate) fn sync(&mut self, items: &[Item<'_, T>]) { + let selected_value = self.selected_value.clone(); + self.refilter(items); + self.selection = selected_value + .as_ref() + .and_then(|value| self.matches.iter().position(|index| items[*index].value == *value)) + .or_else(|| (!self.query.is_empty() && !self.matches.is_empty()).then_some(0)); + self.selected_value = self + .selection + .map(|selection| items[self.matches[selection]].value.clone()); + if self.selection.is_none() { + self.window = 0; + } + self.keep_selection_visible(); + } + + pub(crate) fn insert(&mut self, ch: char, items: &[Item<'_, T>]) { + if ch.is_control() { + return; + } + self.query.insert(self.cursor_byte(), ch); + self.cursor += 1; + self.after_edit(items); + } + + pub(crate) fn paste(&mut self, text: &str, items: &[Item<'_, T>]) { + let text: String = text.chars().filter(|ch| !ch.is_control()).collect(); + if text.is_empty() { + return; + } + self.query.insert_str(self.cursor_byte(), &text); + self.cursor += text.chars().count(); + self.after_edit(items); + } + + pub(crate) fn left(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub(crate) fn right(&mut self) { + self.cursor = (self.cursor + 1).min(self.query.chars().count()); + } + + pub(crate) fn home(&mut self) { + self.cursor = 0; + } + + pub(crate) fn end(&mut self) { + self.cursor = self.query.chars().count(); + } + + pub(crate) fn backspace(&mut self, items: &[Item<'_, T>]) { + if self.cursor == 0 { + return; + } + let start = byte_at(&self.query, self.cursor - 1); + let end = byte_at(&self.query, self.cursor); + self.query.replace_range(start..end, ""); + self.cursor -= 1; + self.after_edit(items); + } + + pub(crate) fn delete(&mut self, items: &[Item<'_, T>]) { + if self.cursor == self.query.chars().count() { + return; + } + let start = self.cursor_byte(); + let end = byte_at(&self.query, self.cursor + 1); + self.query.replace_range(start..end, ""); + self.after_edit(items); + } + + pub(crate) fn up(&mut self, items: &[Item<'_, T>]) { + self.sync(items); + self.selection = match self.selection { + Some(selection) => Some(selection.saturating_sub(1)), + None if !self.matches.is_empty() => Some(self.matches.len() - 1), + None => None, + }; + self.remember_selection(items); + self.keep_selection_visible(); + } + + pub(crate) fn down(&mut self, items: &[Item<'_, T>]) { + self.sync(items); + self.selection = match self.selection { + Some(selection) => Some((selection + 1).min(self.matches.len().saturating_sub(1))), + None if !self.matches.is_empty() => Some(0), + None => None, + }; + self.remember_selection(items); + self.keep_selection_visible(); + } + + pub(crate) fn submit_selected(&mut self, items: &[Item<'_, T>]) -> Option<T> { + self.sync(items); + let row = self.selected_visible_row()?; + let item = *self.visible_indices().get(row)?; + self.submit(&items[item]) + } + + pub(crate) fn submit_digit(&mut self, digit: char, items: &[Item<'_, T>]) -> Option<T> { + let row = digit.to_digit(10)?.checked_sub(1)? as usize; + if row >= self.visible_rows { + return None; + } + self.sync(items); + let item = *self.matches.get(self.window + row)?; + self.submit(&items[item]) + } + + fn submit(&mut self, item: &Item<'_, T>) -> Option<T> { + self.last_submitted = Some(item.value.clone()); + self.open = false; + self.last_submitted.clone() + } + + fn after_edit(&mut self, items: &[Item<'_, T>]) { + self.refilter(items); + self.selection = (!self.query.is_empty() && !self.matches.is_empty()).then_some(0); + self.remember_selection(items); + self.window = 0; + } + + fn refilter(&mut self, items: &[Item<'_, T>]) { + let scope = scoped_query(&self.query); + self.matches = items + .iter() + .enumerate() + .filter_map(|(idx, item)| { + let matches = match scope { + Some((scope, query)) => item.scope.is_some_and(|item_scope| { + item_scope.eq_ignore_ascii_case(&scope) && fuzzy_match(query, item.label) + }), + None => { + fuzzy_match(&self.query, item.label) + || item + .search_prefix + .is_some_and(|prefix| fuzzy_match(&self.query, prefix)) + } + }; + matches.then_some(idx) + }) + .collect(); + } + + fn keep_selection_visible(&mut self) { + let Some(selection) = self.selection else { + self.window = self.window.min(self.matches.len().saturating_sub(self.visible_rows)); + return; + }; + if selection < self.window { + self.window = selection; + } else if selection >= self.window + self.visible_rows { + self.window = selection + 1 - self.visible_rows; + } + self.window = self.window.min(self.matches.len().saturating_sub(self.visible_rows)); + } + + fn remember_selection(&mut self, items: &[Item<'_, T>]) { + self.selected_value = self + .selection + .map(|selection| items[self.matches[selection]].value.clone()); + } + + fn cursor_byte(&self) -> usize { + byte_at(&self.query, self.cursor) + } +} + +fn byte_at(input: &str, char_index: usize) -> usize { + input + .char_indices() + .nth(char_index) + .map_or(input.len(), |(byte, _)| byte) +} + +fn fuzzy_match(query: &str, candidate: &str) -> bool { + let query = query.to_lowercase(); + let candidate = candidate.to_lowercase(); + let mut candidate = candidate.chars(); + query.chars().all(|needle| candidate.by_ref().any(|hay| hay == needle)) +} + +fn scoped_query(query: &str) -> Option<(char, &str)> { + let mut chars = query.chars(); + let scope = chars.next()?; + (chars.next() == Some(' ')).then_some((scope, chars.as_str())) +} + +#[cfg(test)] +mod tests { + use super::{Item, Menu}; + + fn item<T>(label: &str, value: T) -> Item<'_, T> { + Item::new(label, value) + } + + #[test] + fn opening_and_recalling_use_exact_stable_values() { + let items = [item("Stash", "stash"), item("Unstash", "unstash")]; + let mut menu = Menu::default(); + + menu.open(&items); + assert_eq!(menu.selected_index(), None, "the first open has no default"); + menu.close(); + assert!(!menu.is_open()); + menu.open(&items); + assert_eq!(menu.submit_digit('2', &items), Some("unstash")); + assert!(!menu.is_open()); + + menu.open(&items); + assert_eq!(menu.selected_index(), Some(1), "the exact command is recalled"); + let changed = [item("Stash", "stash")]; + menu.open(&changed); + assert_eq!(menu.selected_index(), None, "an unavailable command is not recalled"); + assert_eq!(menu.last_submitted.as_ref(), Some(&"unstash")); + } + + #[test] + fn editing_fuzzy_filters_in_order_and_clearing_removes_the_default() { + let items = [ + item("Git Commit", 1), + item("Go To Child", 2), + item("Commit Info", 3), + item("Ångström", 4), + ]; + let mut menu = Menu::default(); + menu.open(&items); + + menu.insert('G', &items); + menu.insert('c', &items); + assert_eq!(menu.query(), "Gc"); + assert_eq!(menu.visible_indices(), &[0, 1]); + assert_eq!(menu.selected_index(), Some(0), "typing selects the first match"); + + menu.home(); + menu.delete(&items); + menu.delete(&items); + assert_eq!(menu.query(), ""); + assert_eq!(menu.visible_indices(), &[0, 1, 2, 3]); + assert_eq!(menu.selected_index(), None, "an edited-empty query has no default"); + + menu.insert('å', &items); + menu.insert('S', &items); + assert_eq!(menu.visible_indices(), &[3], "matching is Unicode case-insensitive"); + } + + #[test] + fn filtering_matches_search_prefixes() { + let items = [ + Item::with_search_prefix("Date", "View", 'v', 1), + Item::with_search_prefix("Reword", "Actions", 'a', 2), + Item::with_search_prefix("Squash", "Actions", 'a', 3), + ]; + let mut menu = Menu::default(); + menu.open(&items); + menu.paste("actions", &items); + + assert_eq!(menu.visible_indices(), &[1, 2]); + } + + #[test] + fn a_prefix_and_space_scopes_the_query_until_the_space_is_removed() { + let items = [ + Item::with_search_prefix("Date", "View", 'v', 1), + Item::with_search_prefix("Reword", "Actions", 'a', 2), + Item::with_search_prefix("Squash", "Actions", 'a', 3), + ]; + let mut menu = Menu::default(); + menu.open(&items); + + menu.paste("A ", &items); + assert_eq!(menu.query(), "A ", "the literal scoped query remains visible"); + assert_eq!(menu.visible_indices(), &[1, 2], "an empty suffix shows the whole group"); + menu.paste("sq", &items); + assert_eq!( + menu.visible_indices(), + &[2], + "the suffix fuzzy-filters labels in the group" + ); + + menu.backspace(&items); + menu.backspace(&items); + menu.backspace(&items); + assert_eq!(menu.query(), "A"); + assert_eq!( + menu.visible_indices(), + &[0, 1, 2], + "removing the space restores ordinary label and group search" + ); + + menu.open(&items); + menu.paste("x ", &items); + assert!(menu.visible_indices().is_empty(), "an unknown scope has no matches"); + + menu.open(&items[..1]); + menu.paste("a ", &items[..1]); + assert!(menu.visible_indices().is_empty(), "an unavailable scope has no matches"); + } + + #[test] + fn unicode_editing_and_paste_never_split_characters() { + let items = [item("aβ🙂c", ())]; + let mut menu = Menu::default(); + menu.open(&items); + + menu.paste("aβ\r\n\t\u{7}🙂c", &items); + assert_eq!(menu.query(), "aβ🙂c", "paste ignores control characters"); + assert_eq!(menu.cursor(), 4); + menu.left(); + menu.left(); + menu.delete(&items); + assert_eq!(menu.query(), "aβc"); + menu.backspace(&items); + assert_eq!(menu.query(), "ac"); + assert_eq!(menu.cursor(), 1); + menu.home(); + menu.left(); + assert_eq!(menu.cursor(), 0, "left clamps at the start"); + menu.end(); + menu.right(); + assert_eq!(menu.cursor(), 2, "right clamps at the end"); + } + + #[test] + fn navigation_window_digits_and_dynamic_clamping_work_together() { + let labels: Vec<_> = (0..12).map(|idx| format!("Command {idx}")).collect(); + let items: Vec<_> = labels.iter().enumerate().map(|(idx, label)| item(label, idx)).collect(); + let mut menu = Menu::default(); + menu.open(&items); + assert_eq!(menu.visible_indices(), &[0, 1, 2, 3, 4, 5, 6, 7, 8]); + + menu.up(&items); + assert_eq!(menu.selected_index(), Some(11)); + assert_eq!(menu.selected_visible_row(), Some(8)); + assert_eq!(menu.visible_indices(), &[3, 4, 5, 6, 7, 8, 9, 10, 11]); + menu.down(&items); + assert_eq!(menu.selected_index(), Some(11), "down clamps at the end"); + assert_eq!(menu.submit_digit('1', &items), Some(3)); + + menu.open(&items); + assert_eq!(menu.selected_index(), Some(3)); + assert_eq!(menu.submit_selected(&items), Some(3)); + menu.open(&items); + let shorter = &items[..2]; + menu.sync(shorter); + assert_eq!(menu.selected_index(), None, "a vanished command is never substituted"); + assert_eq!( + menu.visible_indices(), + &[0, 1], + "a vanished selection resets the window" + ); + assert_eq!(menu.submit_digit('0', shorter), None); + + let mut scrolled = Menu::default(); + scrolled.open(&items); + scrolled.up(&items); + scrolled.sync(shorter); + assert_eq!(scrolled.selected_index(), None); + assert_eq!( + scrolled.visible_indices(), + &[0, 1], + "a vanished tail selection returns to the start" + ); + } +} diff --git a/gix-tix/src/ref_tree.rs b/gix-tix/src/ref_tree.rs new file mode 100644 index 00000000000..0d9b9f1e059 --- /dev/null +++ b/gix-tix/src/ref_tree.rs @@ -0,0 +1,2686 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + ffi::OsString, + fmt::Write, + sync::atomic::AtomicBool, +}; + +use anyhow::Context; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; +use gix::{ObjectId, bstr::ByteSlice}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Clear, Paragraph}, +}; + +use crate::{ + app::{LaneState, Notice, NoticeKind}, + history::{CommitIndex, Decoration, DecorationKind, Decorations, HistoryGraph, RefSnapshot}, + ui::{decoration_style, notice_area, render_notice}, +}; + +#[derive(Clone, Copy, Debug, Default)] +struct Offset { + x: usize, + y: usize, + page_width: usize, + page_height: usize, + max_x: usize, + max_y: usize, +} + +#[derive(Clone, Debug)] +struct Node { + commit: CommitIndex, + id: ObjectId, + parent: Option<usize>, + children: Vec<usize>, + decorations: Vec<Decoration>, + is_head: bool, + is_anchor: bool, + is_detached_worktree: bool, + raw_tip: bool, + sort_key: String, +} + +#[derive(Clone, Debug)] +struct Edge { + child: usize, + parent: usize, + hidden: Vec<CommitIndex>, +} + +#[derive(Clone, Debug, Default)] +struct Overview { + nodes: Vec<Node>, + edges: Vec<Edge>, + roots: Vec<usize>, + by_commit: HashMap<CommitIndex, usize>, + commit_count: usize, +} + +#[derive(Clone, Debug)] +struct Overlay { + selected: CommitIndex, + reachable: Vec<bool>, + first_parent: Vec<bool>, + counts: Vec<Option<usize>>, + boundaries: Vec<Option<ObjectId>>, + seen: Vec<u32>, + stamp: u32, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct Point { + x: usize, + y: usize, +} + +#[derive(Default)] +struct Placed { + nodes: Vec<Point>, + boundaries: Vec<Option<Point>>, + rail_rows: Vec<RailRow>, + rail_width: usize, + width: usize, + height: usize, +} + +struct RailRow { + lane: String, + kind: RailRowKind, + edge: Option<usize>, +} + +#[derive(Clone, Copy)] +enum RailRowKind { + Node(usize), + Boundary(usize), + NodeConnector, + BoundaryConnector, +} + +#[derive(Clone, Copy)] +enum Direction { + Up, + Down, + Left, + Right, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum Input { + Handled, + PinReferences { + id: ObjectId, + kinds: Vec<DecorationKind>, + }, + ResolveRemoteReferences(Vec<gix::refs::FullName>), + DeleteLocalBranches { + names: Vec<gix::refs::FullName>, + fallback: SelectionFallback, + }, + DeleteRemoteReferences { + groups: Vec<RemoteDeletion>, + fallback: SelectionFallback, + }, + Quit, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RemoteDeletion { + pub remote: gix::bstr::BString, + pub references: Vec<gix::refs::FullName>, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct SelectionFallback { + selected: ObjectId, + candidates: Vec<ObjectId>, +} + +#[derive(Default)] +pub(crate) struct Tree { + active: bool, + overview: Option<Overview>, + alternate_overview: Option<Overview>, + overlay: Option<Overlay>, + selected: Option<usize>, + count_anchor: Option<ObjectId>, + selection_after_reference_deletion: Option<SelectionFallback>, + choices: Vec<usize>, + offset: Offset, + placed: Option<Placed>, + ensure_visible: bool, + hide_tags: bool, + edit_expanded: bool, + remote_deletions: Vec<RemoteDeletion>, + notice: Option<Notice>, + history_commits: HashSet<ObjectId>, +} + +impl Tree { + pub(crate) fn is_active(&self) -> bool { + self.active + } + + pub(crate) fn toggle(&mut self) -> bool { + if !self.active && self.overview.as_ref().is_none_or(|overview| overview.nodes.is_empty()) { + return false; + } + self.active = !self.active; + self.ensure_visible = true; + true + } + + pub(crate) fn leave(&mut self) { + self.active = false; + self.edit_expanded = false; + } + + pub(crate) fn leave_attention(&mut self, message: impl Into<String>) { + self.leave_notice(NoticeKind::Attention, message); + } + + pub(crate) fn leave_success(&mut self, message: impl Into<String>) { + self.leave_notice(NoticeKind::Success, message); + } + + pub(crate) fn leave_error(&mut self, message: impl Into<String>) { + self.leave_notice(NoticeKind::Error, message); + } + + fn leave_notice(&mut self, kind: NoticeKind, message: impl Into<String>) { + self.notice = Some(Notice { + kind, + text: message.into(), + }); + } + + pub(crate) fn set_history_commits(&mut self, commits: impl IntoIterator<Item = ObjectId>) { + self.history_commits.clear(); + self.history_commits.extend(commits); + } + + pub(crate) fn rebuild(&mut self, graph: &HistoryGraph, refs: &RefSnapshot, decorations: &Decorations) { + let selected = self + .selected + .and_then(|selected| self.overview.as_ref()?.nodes.get(selected)) + .map(|node| node.id); + let with_tags = Overview::new(graph, refs, decorations, true); + let without_tags = Overview::new(graph, refs, decorations, false); + let (overview, alternate_overview) = if self.hide_tags { + (without_tags, with_tags) + } else { + (with_tags, without_tags) + }; + let fallback = self + .selection_after_reference_deletion + .take() + .filter(|fallback| Some(fallback.selected) == selected); + let head = overview + .nodes + .iter() + .position(|node| node.is_head) + .or_else(|| { + selected.and_then(|id| { + graph + .index(id) + .and_then(|index| overview.by_commit.get(&index).copied()) + }) + }) + .or_else(|| { + refs.view_tips.iter().chain(&refs.hidden_tips).find_map(|id| { + graph + .index(*id) + .and_then(|index| overview.by_commit.get(&index).copied()) + }) + }) + .or((!overview.nodes.is_empty()).then_some(0)); + self.selected = selected + .and_then(|id| { + graph + .index(id) + .and_then(|index| overview.by_commit.get(&index).copied()) + }) + .or_else(|| { + fallback.and_then(|fallback| { + fallback + .candidates + .into_iter() + .find_map(|id| overview.nodes.iter().position(|node| node.id == id)) + }) + }) + .or(head); + if self + .count_anchor + .is_some_and(|id| !overview.nodes.iter().any(|node| node.id == id)) + { + self.count_anchor = None; + } + self.choices = vec![0; overview.nodes.len()]; + self.overview = Some(overview); + self.alternate_overview = Some(alternate_overview); + self.overlay = None; + self.placed = None; + self.ensure_visible = true; + } + + pub(crate) fn handle_key(&mut self, key: KeyEvent) -> Input { + self.notice = None; + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) + || key.code == KeyCode::Char('q') + { + return Input::Quit; + } + if self.edit_expanded { + self.edit_expanded = false; + if key.code == KeyCode::Char('r') && key.modifiers.is_empty() && !self.remote_deletions.is_empty() { + return Input::DeleteRemoteReferences { + groups: std::mem::take(&mut self.remote_deletions), + fallback: self + .reference_deletion_fallback() + .expect("a selected remote reference has a deletion fallback"), + }; + } + self.remote_deletions.clear(); + if key.code == KeyCode::Char('d') && key.modifiers.is_empty() { + let branches = self.selected_local_branches(); + if branches.is_empty() { + self.leave_attention("no deletable local branches at the selected node"); + return Input::Handled; + } + return Input::DeleteLocalBranches { + names: branches, + fallback: self + .reference_deletion_fallback() + .expect("a selected branch has a deletion fallback"), + }; + } + if key.code == KeyCode::Esc { + return Input::Handled; + } + } else if key.code == KeyCode::Char('e') && key.modifiers.is_empty() { + self.edit_expanded = true; + self.remote_deletions.clear(); + let references = self.selected_remote_references(); + return if references.is_empty() { + Input::Handled + } else { + Input::ResolveRemoteReferences(references) + }; + } + if key.code == KeyCode::Esc { + self.leave(); + return Input::Handled; + } + if key.code == KeyCode::Enter || key.code == KeyCode::Char('p') && key.modifiers.is_empty() { + return self + .selected_pin_target() + .map_or(Input::Handled, |(id, kinds)| Input::PinReferences { id, kinds }); + } + if key.code == KeyCode::Char(' ') && key.modifiers.is_empty() { + self.toggle_count_anchor(); + return Input::Handled; + } + if key.code == KeyCode::Char('G') + || key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::SHIFT) + { + self.jump_to_root(); + return Input::Handled; + } + if key.code == KeyCode::Char('g') && key.modifiers.is_empty() { + self.jump_to_top(); + return Input::Handled; + } + if key.code == KeyCode::Char('t') && key.modifiers.is_empty() { + self.leave(); + return Input::Handled; + } + if key.code == KeyCode::Char('T') + || key.code == KeyCode::Char('t') && key.modifiers.contains(KeyModifiers::SHIFT) + { + self.toggle_tags(); + return Input::Handled; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + let amount = self.offset().page_height.max(1); + let (direction, amount) = match key.code { + KeyCode::Char('u' | 'U') => (Direction::Up, (amount / 2).max(1)), + KeyCode::Char('d' | 'D') => (Direction::Down, (amount / 2).max(1)), + KeyCode::Char('b' | 'B') => (Direction::Up, amount), + KeyCode::Char('f' | 'F') => (Direction::Down, amount), + _ => return Input::Handled, + }; + if key.modifiers.contains(KeyModifiers::SHIFT) || matches!(key.code, KeyCode::Char('U' | 'D' | 'B' | 'F')) { + self.pan(direction, amount); + } else { + self.page(direction, amount); + } + return Input::Handled; + } + match key.code { + KeyCode::PageUp => { + self.page_or_pan(Direction::Up, key.modifiers); + return Input::Handled; + } + KeyCode::PageDown => { + self.page_or_pan(Direction::Down, key.modifiers); + return Input::Handled; + } + _ => {} + } + let Some(direction) = direction(key.code) else { + return Input::Handled; + }; + let topological = + key.modifiers.contains(KeyModifiers::SHIFT) || matches!(key.code, KeyCode::Char('H' | 'J' | 'K' | 'L')); + self.navigate(direction, topological); + Input::Handled + } + + pub(crate) fn handle_mouse(&mut self, kind: MouseEventKind, modifiers: KeyModifiers, distance: usize) -> bool { + let direction = match kind { + MouseEventKind::ScrollUp => Direction::Up, + MouseEventKind::ScrollDown => Direction::Down, + MouseEventKind::ScrollLeft => Direction::Left, + MouseEventKind::ScrollRight => Direction::Right, + _ => return false, + }; + if modifiers.contains(KeyModifiers::SHIFT) { + self.navigate(direction, false); + } else { + self.pan(direction, distance.max(1)); + } + true + } + + pub(crate) fn draw(&mut self, frame: &mut Frame<'_>, graph: Option<&HistoryGraph>) { + let overlay_selected = self + .count_anchor + .and_then(|id| self.overview.as_ref()?.nodes.iter().position(|node| node.id == id)) + .or(self.selected); + if self.overlay.as_ref().map(|overlay| overlay.selected) + != self + .overview + .as_ref() + .and_then(|overview| overlay_selected.and_then(|selected| overview.nodes.get(selected))) + .map(|node| node.commit) + && let (Some(graph), Some(selected)) = (graph, overlay_selected) + { + self.overlay = self + .overview + .as_ref() + .map(|overview| Overlay::new(graph, overview, selected)); + } + let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); + frame.render_widget(Clear, frame.area()); + let notice = self.notice.clone(); + let notice_area = notice + .as_ref() + .and_then(|notice| notice_area(notice, body, body.y, body.bottom())); + if let Some(notice_area) = notice_area { + body.height = notice_area.y.saturating_sub(body.y); + } + let Some(overview) = self.overview.as_ref() else { + frame.render_widget(Paragraph::new("ref-tree overview unavailable"), body); + return; + }; + if self.placed.is_none() { + self.placed = Some(place_rail(overview, self.overlay.as_ref())); + } + let placed = self.placed.as_ref().expect("rail placement was just populated"); + let selected = self.selected; + let ensure_visible = self.ensure_visible; + let selected_point = selected.and_then(|selected| placed.nodes.get(selected)).copied(); + let offset = { + let offset = &mut self.offset; + offset.page_width = usize::from(body.width); + offset.page_height = usize::from(body.height); + offset.max_x = placed.width.saturating_sub(offset.page_width); + offset.max_y = placed.height.saturating_sub(offset.page_height); + offset.x = offset.x.min(offset.max_x); + offset.y = offset.y.min(offset.max_y); + if ensure_visible && let Some(point) = selected_point { + ensure_point_visible(offset, point); + } + *offset + }; + self.ensure_visible = false; + let overview = self.overview.as_ref().expect("the overview was checked"); + if let (Some(graph), Some(overlay)) = (graph, self.overlay.as_mut()) { + overlay.compute_visible_counts( + graph, + overview, + placed, + offset.y..offset.y.saturating_add(usize::from(body.height)), + ); + } + draw_rail_edges( + frame, + body, + overview, + self.overlay.as_ref(), + placed, + offset, + selected, + &self.choices, + ); + draw_nodes( + frame, + body, + overview, + self.overlay.as_ref(), + placed, + offset, + selected, + &self.choices, + &self.history_commits, + ); + let footer_text = if self.edit_expanded { + let branches = self.selected_local_branches(); + if branches.is_empty() && self.remote_deletions.is_empty() { + "ref-tree · e edit (no actions)".into() + } else { + let mut actions = Vec::new(); + if !branches.is_empty() { + actions.push(format!("d delete {}", short_branch_list(&branches))); + } + if !self.remote_deletions.is_empty() { + actions.push(format!( + "r delete on remote {}", + short_remote_deletion_list(&self.remote_deletions) + )); + } + format!("ref-tree · e edit ({})", actions.join(" · ")) + } + } else { + let tags = if self.hide_tags { "off" } else { "on" }; + let counts = self.count_anchor.map_or_else( + || "Space counts:auto".into(), + |id| format!("Space counts:{}", self.node_label(id)), + ); + format!( + "ref-tree · {counts} · g top · G root · T tags:{tags} · Shift+directions topo · mouse pan · Shift+mouse cursor · pages cursor · Shift+pages pan · p/<enter> pin · e edit · t/Esc history" + ) + }; + frame.render_widget( + Paragraph::new(footer_text).style(Style::default().add_modifier(Modifier::DIM)), + footer, + ); + if let (Some(area), Some(notice)) = (notice_area, notice.as_ref()) { + render_notice(frame, area, notice); + } + } + + fn selected_local_branches(&self) -> Vec<gix::refs::FullName> { + self.selected + .and_then(|selected| self.overview.as_ref()?.nodes.get(selected)) + .into_iter() + .flat_map(|node| &node.decorations) + .filter(|decoration| decoration.kind == DecorationKind::Local) + .map(|decoration| { + let mut name = b"refs/heads/".to_vec(); + name.extend_from_slice(&decoration.name); + gix::bstr::BString::from(name) + .try_into() + .expect("local decorations originate from valid local branch names") + }) + .collect() + } + + fn selected_pin_target(&self) -> Option<(ObjectId, Vec<DecorationKind>)> { + let node = self + .selected + .and_then(|selected| self.overview.as_ref()?.nodes.get(selected))?; + let mut kinds = Vec::new(); + for decoration in &node.decorations { + let Some(kind) = pinnable_kind(decoration.kind) else { + continue; + }; + if !kinds.contains(&kind) { + kinds.push(kind); + } + } + (!kinds.is_empty()).then_some((node.id, kinds)) + } + + fn selected_remote_references(&self) -> Vec<gix::refs::FullName> { + self.selected + .and_then(|selected| self.overview.as_ref()?.nodes.get(selected)) + .into_iter() + .flat_map(|node| &node.decorations) + .filter(|decoration| decoration.kind == DecorationKind::Remote) + .map(|decoration| { + let mut name = b"refs/remotes/".to_vec(); + name.extend_from_slice(&decoration.name); + gix::bstr::BString::from(name) + .try_into() + .expect("remote decorations originate from valid remote-tracking names") + }) + .collect() + } + + pub(crate) fn set_remote_deletions(&mut self, deletions: Vec<RemoteDeletion>) { + if self.edit_expanded { + self.remote_deletions = deletions; + } + } + + pub(crate) fn select_after_reference_deletion(&mut self, fallback: SelectionFallback) { + self.selection_after_reference_deletion = Some(fallback); + } + + fn selected_id(&self) -> Option<ObjectId> { + self.selected + .and_then(|selected| self.overview.as_ref()?.nodes.get(selected)) + .map(|node| node.id) + } + + fn reference_deletion_fallback(&self) -> Option<SelectionFallback> { + let overview = self.overview.as_ref()?; + let selected = self.selected?; + let temporary; + let placed = match self.placed.as_ref() { + Some(placed) => placed, + None => { + temporary = place_rail(overview, self.overlay.as_ref()); + &temporary + } + }; + let mut rows: Vec<_> = overview + .nodes + .iter() + .enumerate() + .map(|(index, node)| (placed.nodes[index].y, node.id)) + .collect(); + rows.sort_by_key(|(row, _)| *row); + let position = rows.iter().position(|(_, id)| *id == overview.nodes[selected].id)?; + let candidates = rows[position + 1..] + .iter() + .map(|(_, id)| *id) + .chain(rows[..position].iter().rev().map(|(_, id)| *id)) + .collect(); + Some(SelectionFallback { + selected: overview.nodes[selected].id, + candidates, + }) + } + + fn toggle_count_anchor(&mut self) { + let Some(selected) = self.selected_id() else { return }; + let previous = self.count_anchor.unwrap_or(selected); + self.count_anchor = (self.count_anchor != Some(selected)).then_some(selected); + let current = self.count_anchor.unwrap_or(selected); + if previous != current { + self.overlay = None; + self.placed = None; + } + } + + fn node_label(&self, id: ObjectId) -> String { + self.overview + .as_ref() + .and_then(|overview| overview.nodes.iter().find(|node| node.id == id)) + .map(node_label) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| id.to_hex_with_len(7).to_string()) + } + + fn navigate(&mut self, direction: Direction, topological: bool) { + let Some(overview) = self.overview.as_ref() else { return }; + let Some(selected) = self.selected else { return }; + let next = if topological { + match direction { + Direction::Up => overview.nodes[selected] + .children + .get(self.choices[selected].min(overview.nodes[selected].children.len().saturating_sub(1))) + .copied(), + Direction::Down => overview.nodes[selected].parent, + Direction::Left | Direction::Right => { + let last = overview.nodes[selected].children.len().saturating_sub(1); + let choice = self.choices[selected].min(last); + let next = if matches!(direction, Direction::Left) { + choice.saturating_sub(1) + } else { + choice.saturating_add(1).min(last) + }; + self.choices[selected] = next; + None + } + } + } else { + self.placed + .as_ref() + .and_then(|placed| nearest(&placed.nodes, selected, direction)) + }; + if let Some(next) = next { + self.selected = Some(next); + self.selection_changed(); + self.ensure_visible = true; + } + } + + fn jump_to_root(&mut self) { + let (Some(overview), Some(mut selected)) = (self.overview.as_ref(), self.selected) else { + return; + }; + while let Some(parent) = overview.nodes[selected].parent { + selected = parent; + } + self.selected = Some(selected); + self.selection_changed(); + self.ensure_visible = true; + } + + fn jump_to_top(&mut self) { + let Some(overview) = self.overview.as_ref() else { + return; + }; + let Some(mut selected) = overview.roots.first().copied() else { + return; + }; + while let Some(child) = overview.nodes[selected].children.first().copied() { + selected = child; + } + self.selected = Some(selected); + self.selection_changed(); + self.ensure_visible = true; + } + + fn toggle_tags(&mut self) { + let selected = self + .selected + .and_then(|selected| self.overview.as_ref()?.nodes.get(selected)) + .map(|node| node.id); + let Some(alternate) = self.alternate_overview.as_mut() else { + return; + }; + let overview = self.overview.get_or_insert_with(Overview::default); + std::mem::swap(overview, alternate); + self.selected = selected + .and_then(|id| overview.nodes.iter().position(|node| node.id == id)) + .or_else(|| overview.nodes.iter().position(|node| node.is_head)) + .or_else(|| overview.nodes.iter().position(|node| node.raw_tip)) + .or((!overview.nodes.is_empty()).then_some(0)); + self.hide_tags = !self.hide_tags; + if self + .count_anchor + .is_some_and(|id| !overview.nodes.iter().any(|node| node.id == id)) + { + self.count_anchor = None; + } + self.choices = vec![0; overview.nodes.len()]; + self.overlay = None; + self.placed = None; + self.ensure_visible = true; + } + + fn selection_changed(&mut self) { + if self.count_anchor.is_none() { + self.overlay = None; + self.placed = None; + } + } + + fn pan(&mut self, direction: Direction, amount: usize) { + let offset = &mut self.offset; + match direction { + Direction::Up => offset.y = offset.y.saturating_sub(amount), + Direction::Down => offset.y = offset.y.saturating_add(amount).min(offset.max_y), + Direction::Left => offset.x = offset.x.saturating_sub(amount), + Direction::Right => offset.x = offset.x.saturating_add(amount).min(offset.max_x), + } + self.ensure_visible = false; + } + + fn page(&mut self, direction: Direction, amount: usize) { + let (Some(overview), Some(selected)) = (self.overview.as_ref(), self.selected) else { + return; + }; + let temporary; + let placed = match self.placed.as_ref() { + Some(placed) => placed, + None => { + temporary = place_rail(overview, self.overlay.as_ref()); + &temporary + } + }; + let source = placed.nodes[selected]; + let target = match direction { + Direction::Up => source.y.saturating_sub(amount), + Direction::Down => source.y.saturating_add(amount), + Direction::Left | Direction::Right => return, + }; + let next = placed + .nodes + .iter() + .copied() + .enumerate() + .filter(|(index, point)| { + *index != selected + && match direction { + Direction::Up => point.y < source.y, + Direction::Down => point.y > source.y, + Direction::Left | Direction::Right => false, + } + }) + .min_by_key(|(index, point)| (point.y.abs_diff(target), point.x.abs_diff(source.x), *index)) + .map(|(index, _)| index); + if let Some(next) = next { + self.selected = Some(next); + self.selection_changed(); + self.ensure_visible = true; + } + } + + fn page_or_pan(&mut self, direction: Direction, modifiers: KeyModifiers) { + let amount = self.offset().page_height.max(1); + if modifiers.contains(KeyModifiers::SHIFT) { + self.pan(direction, amount); + } else { + self.page(direction, amount); + } + } + + fn offset(&self) -> &Offset { + &self.offset + } +} + +fn pinnable_kind(kind: DecorationKind) -> Option<DecorationKind> { + match kind { + DecorationKind::Local + | DecorationKind::CurrentWorktreeBranch + | DecorationKind::WorktreeBranch + | DecorationKind::HeadPinBranch => Some(DecorationKind::Local), + DecorationKind::Tag | DecorationKind::AnnotatedTag => Some(DecorationKind::Tag), + DecorationKind::Remote | DecorationKind::Review => Some(kind), + _ => None, + } +} + +#[cfg(test)] +pub(crate) fn pin_references( + repository: &gix::Repository, + id: ObjectId, + kinds: &[DecorationKind], +) -> anyhow::Result<Vec<crate::history::Pin>> { + pin_references_reporting(repository, id, kinds).map(|(pins, _changes)| pins) +} + +pub(crate) fn pin_references_reporting( + repository: &gix::Repository, + id: ObjectId, + kinds: &[DecorationKind], +) -> anyhow::Result<(Vec<crate::history::Pin>, Vec<crate::edit::undo::RefChange>)> { + let mut names = Vec::new(); + for reference in repository + .references() + .context("could not open references while pinning the ref-tree selection")? + .all() + .context("could not iterate references while pinning the ref-tree selection")? + { + let mut reference = match reference { + Ok(reference) => reference, + Err(err) if crate::history::is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read reference to pin: {err}")), + }; + let name = reference.name().to_owned(); + let Some(kind) = pinnable_kind(crate::history::decoration_kind(name.as_bstr())) else { + continue; + }; + if !kinds.contains(&kind) { + continue; + } + let Ok(target) = reference.peel_to_id() else { + continue; + }; + if target.as_ref() != id { + continue; + } + names.push(name); + } + names.sort(); + names.dedup(); + let mut pins = Vec::new(); + let mut changes = Vec::new(); + for name in names { + let (pin, created) = crate::edit::time_travel::create_or_reuse_pin( + repository, + gix::refs::Target::Symbolic(name), + id, + "tix ref-tree", + )?; + if created { + changes.push(crate::edit::undo::RefChange { + name: pin.name.clone(), + before: crate::edit::undo::State::Missing, + after: crate::edit::undo::State::Symbolic( + pin.target.try_name().expect("a ref-tree pin is symbolic").to_owned(), + ), + }); + } + pins.push(pin); + } + Ok((pins, changes)) +} + +impl Overview { + fn new(graph: &HistoryGraph, refs: &RefSnapshot, decorations: &Decorations, show_tags: bool) -> Self { + let mut labels = HashMap::<CommitIndex, Vec<Decoration>>::new(); + let mut heads = HashSet::new(); + let mut anchors = HashSet::new(); + let mut detached_worktrees = HashSet::new(); + for (id, decorations) in decorations { + let Some(index) = graph.index(*id) else { continue }; + for decoration in decorations { + if !show_tags && matches!(decoration.kind, DecorationKind::Tag | DecorationKind::AnnotatedTag) { + continue; + } + if decoration.kind == DecorationKind::Head { + heads.insert(index); + anchors.insert(index); + } else if decoration.kind == DecorationKind::Pin { + continue; + } else if decoration.kind != DecorationKind::Special { + labels.entry(index).or_default().push(decoration.clone()); + anchors.insert(index); + } + } + } + for worktree in refs + .worktrees + .iter() + .filter(|worktree| worktree.is_current && worktree.is_detached) + { + let Some(index) = graph.index(worktree.id) else { + continue; + }; + detached_worktrees.insert(index); + anchors.insert(index); + } + let pin_tips: HashSet<_> = refs.pins.iter().map(|pin| pin.id).collect(); + let raw: HashSet<_> = refs + .view_tips + .iter() + .chain(&refs.hidden_tips) + .filter(|id| !pin_tips.contains(*id)) + .filter_map(|id| graph.index(*id)) + .inspect(|index| { + anchors.insert(*index); + }) + .collect(); + if anchors.is_empty() { + return Overview::default(); + } + for decorations in labels.values_mut() { + decorations.sort_by(|a, b| a.name.cmp(&b.name)); + decorations.dedup(); + } + let mut included = HashSet::new(); + for anchor in anchors.iter().copied() { + let mut current = Some(anchor); + while let Some(index) = current { + if !included.insert(index) { + break; + } + current = graph.parents(index).first().copied(); + } + } + let mut children = vec![Vec::new(); graph.commit_count()]; + for child in included.iter().copied() { + if let Some(parent) = graph + .parents(child) + .first() + .copied() + .filter(|parent| included.contains(parent)) + { + children[parent.as_usize()].push(child); + } + } + let structural: HashSet<_> = included + .iter() + .copied() + .filter(|index| { + anchors.contains(index) + || children[index.as_usize()].len() != 1 + || graph + .parents(*index) + .first() + .is_none_or(|parent| !included.contains(parent)) + }) + .collect(); + let mut structural_order: Vec<_> = structural.iter().copied().collect(); + structural_order.sort_by_key(|index| index.as_usize()); + let by_commit: HashMap<_, _> = structural_order + .iter() + .enumerate() + .map(|(node, commit)| (*commit, node)) + .collect(); + let mut nodes: Vec<_> = structural_order + .iter() + .copied() + .map(|commit| { + let decorations = labels.remove(&commit).unwrap_or_default(); + let sort_key = decorations.first().map_or_else( + || graph.id(commit).to_hex().to_string(), + |decoration| decoration.name.to_str_lossy().into_owned(), + ); + Node { + commit, + id: graph.id(commit), + parent: None, + children: Vec::new(), + decorations, + is_head: heads.contains(&commit), + is_anchor: anchors.contains(&commit), + is_detached_worktree: detached_worktrees.contains(&commit), + raw_tip: raw.contains(&commit), + sort_key, + } + }) + .collect(); + let mut edges = Vec::new(); + let mut roots = Vec::new(); + for child in 0..nodes.len() { + let mut hidden = Vec::new(); + let mut parent = graph.parents(nodes[child].commit).first().copied(); + while let Some(index) = parent.filter(|index| included.contains(index)) { + if let Some(parent_node) = by_commit.get(&index).copied() { + nodes[child].parent = Some(parent_node); + nodes[parent_node].children.push(child); + edges.push(Edge { + child, + parent: parent_node, + hidden, + }); + break; + } + hidden.push(index); + parent = graph.parents(index).first().copied(); + } + if nodes[child].parent.is_none() { + roots.push(child); + } + } + fn subtree_key(node: usize, nodes: &mut [Node]) -> String { + let children = nodes[node].children.clone(); + let mut key = nodes[node].sort_key.clone(); + for child in children { + key = key.min(subtree_key(child, nodes)); + } + nodes[node].sort_key.clone_from(&key); + key + } + for root in roots.iter().copied() { + subtree_key(root, &mut nodes); + } + for node in 0..nodes.len() { + let keys: Vec<_> = nodes[node] + .children + .iter() + .map(|child| (*child, nodes[*child].sort_key.clone(), graph.id(nodes[*child].commit))) + .collect(); + let mut keys = keys; + keys.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2))); + nodes[node].children = keys.into_iter().map(|(child, _, _)| child).collect(); + } + fn contains_head(node: usize, nodes: &[Node]) -> bool { + nodes[node].is_head || nodes[node].children.iter().any(|child| contains_head(*child, nodes)) + } + roots.sort_by(|a, b| { + (!contains_head(*a, &nodes)) + .cmp(&(!contains_head(*b, &nodes))) + .then(nodes[*a].sort_key.cmp(&nodes[*b].sort_key)) + }); + Overview { + nodes, + edges, + roots, + by_commit, + commit_count: graph.commit_count(), + } + } +} + +impl Overlay { + fn new(graph: &HistoryGraph, overview: &Overview, selected: usize) -> Self { + let selected_commit = overview.nodes[selected].commit; + let mut reachable = vec![false; graph.commit_count()]; + let mut pending = vec![selected_commit]; + let mut total = 0; + while let Some(index) = pending.pop() { + if std::mem::replace(&mut reachable[index.as_usize()], true) { + continue; + } + total += 1; + pending.extend_from_slice(graph.parents(index)); + } + let mut first_parent = vec![false; graph.commit_count()]; + let mut current = Some(selected_commit); + while let Some(index) = current { + first_parent[index.as_usize()] = true; + current = graph.parents(index).first().copied(); + } + let mut counts = vec![None; overview.nodes.len()]; + counts[selected] = Some(total); + let boundaries = overview + .edges + .iter() + .map(|edge| { + (!reachable[overview.nodes[edge.child].commit.as_usize()]).then(|| { + edge.hidden + .iter() + .copied() + .find(|index| reachable[index.as_usize()]) + .map(|index| graph.id(index)) + })? + }) + .collect(); + Overlay { + selected: selected_commit, + reachable, + first_parent, + counts, + boundaries, + seen: vec![0; graph.commit_count()], + stamp: 0, + } + } + + fn compute_visible_counts( + &mut self, + graph: &HistoryGraph, + overview: &Overview, + placed: &Placed, + rows: std::ops::Range<usize>, + ) { + for (node, value) in overview.nodes.iter().enumerate() { + if !value.is_anchor || self.counts[node].is_some() || !rows.contains(&placed.nodes[node].y) { + continue; + } + self.stamp = self.stamp.wrapping_add(1); + if self.stamp == 0 { + self.seen.fill(0); + self.stamp = 1; + } + let mut count = 0; + let mut pending = vec![value.commit]; + while let Some(index) = pending.pop() { + if self.reachable[index.as_usize()] + || std::mem::replace(&mut self.seen[index.as_usize()], self.stamp) == self.stamp + { + continue; + } + count += 1; + pending.extend_from_slice(graph.parents(index)); + } + self.counts[node] = Some(count); + } + } +} + +fn place_rail(overview: &Overview, overlay: Option<&Overlay>) -> Placed { + struct Item { + id: ObjectId, + parent: Option<ObjectId>, + kind: RailRowKind, + marker: char, + } + + let mut edge_by_child = vec![None; overview.nodes.len()]; + for (edge, value) in overview.edges.iter().enumerate() { + edge_by_child[value.child] = Some(edge); + } + fn collect( + node: usize, + parent: Option<ObjectId>, + overview: &Overview, + overlay: Option<&Overlay>, + edge_by_child: &[Option<usize>], + out: &mut Vec<Item>, + ) { + for child in overview.nodes[node].children.iter().copied() { + let edge = edge_by_child[child].expect("non-root nodes have an edge"); + let boundary = overlay.and_then(|overlay| overlay.boundaries[edge]); + collect( + child, + Some(boundary.unwrap_or(overview.nodes[node].id)), + overview, + overlay, + edge_by_child, + out, + ); + if let Some(id) = boundary { + out.push(Item { + id, + parent: Some(overview.nodes[node].id), + kind: RailRowKind::Boundary(edge), + marker: '●', + }); + } + } + out.push(Item { + id: overview.nodes[node].id, + parent, + kind: RailRowKind::Node(node), + marker: if overview.nodes[node].is_head { '@' } else { '●' }, + }); + } + + let mut items = Vec::new(); + for root in overview.roots.iter().copied() { + collect(root, None, overview, overlay, &edge_by_child, &mut items); + } + let mut state = LaneState::default(); + let mut placed = Placed { + nodes: vec![Point::default(); overview.nodes.len()], + boundaries: vec![None; overview.edges.len()], + ..Placed::default() + }; + for item in items { + let node_lane = state.node_line(item.id, item.marker); + let mut transition = String::new(); + state.advance_ids(item.id, item.parent, Some(&mut transition), item.marker); + let rounded_transition = transition.contains('─'); + let lane = if rounded_transition { + node_lane + } else { + std::mem::take(&mut transition) + }; + let x = lane + .chars() + .position(|symbol| symbol == item.marker) + .expect("a rendered lane contains its node marker"); + let y = placed.rail_rows.len(); + match item.kind { + RailRowKind::Node(node) => placed.nodes[node] = Point { x, y }, + RailRowKind::Boundary(edge) => placed.boundaries[edge] = Some(Point { x, y }), + RailRowKind::NodeConnector | RailRowKind::BoundaryConnector => { + unreachable!("items are always node rows") + } + } + placed.rail_width = placed.rail_width.max(lane.chars().count()); + let edge = match item.kind { + RailRowKind::Node(node) => edge_by_child[node], + RailRowKind::Boundary(edge) => Some(edge), + RailRowKind::NodeConnector | RailRowKind::BoundaryConnector => unreachable!("items are node rows"), + }; + placed.rail_rows.push(RailRow { + lane, + kind: item.kind, + edge, + }); + if rounded_transition { + let mut connector: Vec<_> = transition.chars().collect(); + connector[x] = if x > 0 && connector[x - 1] == '─' { + '╯' + } else if connector.get(x + 1) == Some(&'─') { + '╰' + } else { + '│' + }; + let kind = match item.kind { + RailRowKind::Node(_) => RailRowKind::NodeConnector, + RailRowKind::Boundary(_) => RailRowKind::BoundaryConnector, + RailRowKind::NodeConnector | RailRowKind::BoundaryConnector => unreachable!("items are node rows"), + }; + let lane: String = connector.into_iter().collect(); + placed.rail_width = placed.rail_width.max(lane.chars().count()); + placed.rail_rows.push(RailRow { lane, kind, edge }); + } + } + placed.height = placed.rail_rows.len(); + let label_width = overview + .nodes + .iter() + .map(|node| rail_label(node, Some(overview.commit_count)).chars().count()) + .max() + .unwrap_or_default(); + placed.width = placed.rail_width.saturating_add(label_width).max(1); + placed +} + +#[expect( + clippy::too_many_arguments, + reason = "drawing receives detached layout and style state" +)] +fn draw_rail_edges( + frame: &mut Frame<'_>, + area: Rect, + overview: &Overview, + overlay: Option<&Overlay>, + placed: &Placed, + offset: Offset, + selected: Option<usize>, + choices: &[usize], +) { + for (y, row) in placed.rail_rows.iter().enumerate() { + if y < offset.y || y >= offset.y.saturating_add(usize::from(area.height)) { + continue; + } + let style = match row.kind { + RailRowKind::Boundary(_) | RailRowKind::BoundaryConnector => Style::default().add_modifier(Modifier::DIM), + RailRowKind::Node(_) | RailRowKind::NodeConnector => row + .edge + .map(|edge| &overview.edges[edge]) + .map_or_else(Style::default, |edge| { + let chosen = selected == Some(edge.parent) + && overview.nodes[edge.parent].children.get( + choices[edge.parent].min(overview.nodes[edge.parent].children.len().saturating_sub(1)), + ) == Some(&edge.child); + if chosen { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + edge_style(overview, overlay, edge) + } + }), + }; + draw_text(frame, area, offset, Point { x: 0, y }, &row.lane, style); + } +} + +#[expect( + clippy::too_many_arguments, + reason = "drawing receives detached layout and style state" +)] +fn draw_nodes( + frame: &mut Frame<'_>, + area: Rect, + overview: &Overview, + overlay: Option<&Overlay>, + placed: &Placed, + offset: Offset, + selected: Option<usize>, + choices: &[usize], + history_commits: &HashSet<ObjectId>, +) { + for (index, node) in overview.nodes.iter().enumerate() { + let point = placed.nodes[index]; + if point.y < offset.y + || point.y >= offset.y.saturating_add(usize::from(area.height)) + || point.x >= offset.x.saturating_add(usize::from(area.width)) + { + continue; + } + let count = overlay.and_then(|overlay| overlay.counts[index]); + let mut text = rail_label(node, count); + if selected == Some(index) && node.children.len() > 1 { + write!( + text, + " {}/{}", + choices[index].min(node.children.len() - 1) + 1, + node.children.len() + ) + .expect("writing to a string cannot fail"); + } + let mut style = if node.is_anchor && history_commits.contains(&node.id) { + Style::default().fg(Color::Cyan) + } else if node.decorations.iter().any(|decoration| { + matches!( + decoration.kind, + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached + ) + }) { + Style::default().fg(Color::Green) + } else { + node.decorations.first().map_or_else( + || Style::default().fg(Color::LightBlue), + |decoration| decoration_style(decoration.kind), + ) + }; + if selected == Some(index) { + style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD); + } else if let Some(overlay) = overlay { + let commit = node.commit.as_usize(); + if overlay.first_parent[commit] { + style = style.add_modifier(Modifier::BOLD); + } else if overlay.reachable[commit] { + style = style.add_modifier(Modifier::DIM); + } + } + put(frame, area, offset, point, if node.is_head { '@' } else { '●' }, style); + draw_text( + frame, + area, + offset, + Point { + x: placed.rail_width, + y: point.y, + }, + &text, + style, + ); + if let Some(pin) = text.chars().position(|symbol| symbol == '📌') { + put( + frame, + area, + offset, + Point { + x: placed.rail_width + pin, + y: point.y, + }, + '📌', + decoration_style(DecorationKind::Pin), + ); + } + } +} + +fn rail_label(node: &Node, count: Option<usize>) -> String { + let mut out = count.map_or_else(String::new, |count| format!("{count}•")); + let suffix = node_label(node); + if !out.is_empty() && !suffix.is_empty() { + out.push(' '); + } + out.push_str(&suffix); + if node.is_detached_worktree { + if !out.is_empty() { + out.push(' '); + } + out.push('📌'); + } + out +} + +fn node_label(node: &Node) -> String { + let labels: Vec<_> = node + .decorations + .iter() + .map(|decoration| { + let name = decoration.name.to_str_lossy(); + match decoration.kind { + DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached => { + format!("@{name}") + } + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => format!("{name}@"), + DecorationKind::HeadPinBranch => format!("★{name}"), + _ => name.into_owned(), + } + }) + .collect(); + if !labels.is_empty() { + labels.join(", ") + } else if node.raw_tip { + node.id.to_hex_with_len(7).to_string() + } else if node.parent.is_none() { + "<root>".into() + } else { + String::new() + } +} + +fn short_branch_list(names: &[gix::refs::FullName]) -> String { + names + .iter() + .map(|name| name.shorten().to_str_lossy().into_owned()) + .collect::<Vec<_>>() + .join(", ") +} + +fn short_remote_deletion_list(groups: &[RemoteDeletion]) -> String { + groups + .iter() + .flat_map(|group| { + group + .references + .iter() + .map(|reference| format!("{}/{}", group.remote.to_str_lossy(), reference.shorten().to_str_lossy())) + }) + .collect::<Vec<_>>() + .join(", ") +} + +pub(crate) fn resolve_remote_deletions( + repository: &gix::Repository, + tracking_references: Vec<gix::refs::FullName>, +) -> Vec<RemoteDeletion> { + let mut groups = BTreeMap::<gix::bstr::BString, Vec<gix::refs::FullName>>::new(); + for tracking in tracking_references { + match repository.upstream_branch_and_remote_for_tracking_branch(tracking.as_ref()) { + Ok(Some((upstream, remote))) => { + let Some(name) = remote.name() else { continue }; + groups.entry(name.as_bstr().to_owned()).or_default().push(upstream); + } + Ok(None) => {} + Err(err) => tracing::warn!(name = %tracking, error = %err, "remote reference is not editable"), + } + } + groups + .into_iter() + .map(|(remote, mut references)| { + references.sort(); + references.dedup(); + RemoteDeletion { remote, references } + }) + .collect() +} + +fn edge_style(overview: &Overview, overlay: Option<&Overlay>, edge: &Edge) -> Style { + let Some(overlay) = overlay else { + return Style::default(); + }; + let child = overview.nodes[edge.child].commit.as_usize(); + let parent = overview.nodes[edge.parent].commit.as_usize(); + if overlay.first_parent[child] && overlay.first_parent[parent] { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else if overlay.reachable[child] { + Style::default().add_modifier(Modifier::DIM) + } else { + Style::default() + } +} + +pub(crate) fn render_full( + repository: &gix::Repository, + revisions: &[OsString], + hidden: &[OsString], + show_tags: bool, + unicode: bool, +) -> anyhow::Result<String> { + let hidden_refs = if hidden.is_empty() { + HashMap::new() + } else { + crate::history::referenced_refs(repository, hidden)? + }; + let mut visible_revisions = Vec::with_capacity(revisions.len()); + for revision in revisions { + let referenced = crate::history::referenced_refs(repository, std::slice::from_ref(revision))?; + if referenced.keys().all(|name| !hidden_refs.contains_key(name)) { + visible_revisions.push(revision.clone()); + } + } + let mut refs = crate::history::snapshot(repository, &visible_revisions, hidden, true)?; + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new( + crate::history::Authors::default(), + )); + let mut graph = None; + crate::history::load( + repository, + &visible_revisions, + hidden, + true, + &authors, + &AtomicBool::new(false), + |event| { + if let crate::history::Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + let graph = graph.ok_or_else(|| anyhow::anyhow!("history traversal did not produce a graph"))?; + let hidden_refs = hidden_refs.into_keys().collect(); + let decorations = crate::history::decorations_excluding(repository, &refs.pins, &refs.worktrees, &hidden_refs)?; + refs.hidden_tips.clear(); + let overview = Overview::new(&graph, &refs, &decorations, show_tags); + let labels = overview + .nodes + .iter() + .filter(|node| node.raw_tip && node.decorations.is_empty()) + .map(|node| Ok((node.id, crate::change_id::display(repository, node.id, 7)?))) + .collect::<anyhow::Result<HashMap<_, _>>>()?; + Ok(render_overview(&overview, unicode, &labels)) +} + +fn render_overview(overview: &Overview, unicode: bool, commit_labels: &HashMap<ObjectId, String>) -> String { + if overview.nodes.is_empty() { + return String::new(); + } + let placed = place_rail(overview, None); + let mut out = String::new(); + for row in &placed.rail_rows { + let node = match row.kind { + RailRowKind::Node(node) => Some(node), + RailRowKind::NodeConnector => None, + RailRowKind::Boundary(_) | RailRowKind::BoundaryConnector => continue, + }; + let lane = if unicode { + row.lane.clone() + } else { + row.lane + .chars() + .map(|symbol| match symbol { + '●' => 'o', + '│' => '|', + '─' => '-', + ' ' | '@' => symbol, + _ => '+', + }) + .collect() + }; + let width = lane.chars().count(); + out.push_str(&lane); + out.extend(std::iter::repeat_n(' ', placed.rail_width.saturating_sub(width))); + if let Some(node) = node { + let node = &overview.nodes[node]; + let label = commit_labels + .get(&node.id) + .cloned() + .unwrap_or_else(|| rail_label(node, None)); + if unicode { + out.push_str(&label); + } else { + out.push_str(&label.replace('📌', "[pin]")); + } + } + while out.ends_with(' ') { + out.pop(); + } + out.push('\n'); + } + out +} + +fn draw_text(frame: &mut Frame<'_>, area: Rect, offset: Offset, point: Point, text: &str, style: Style) { + for (index, symbol) in text.chars().enumerate() { + put( + frame, + area, + offset, + Point { + x: point.x + index, + y: point.y, + }, + symbol, + style, + ); + } +} + +fn put(frame: &mut Frame<'_>, area: Rect, offset: Offset, point: Point, symbol: char, style: Style) { + let Some(x) = point.x.checked_sub(offset.x) else { return }; + let Some(y) = point.y.checked_sub(offset.y) else { return }; + if x >= usize::from(area.width) || y >= usize::from(area.height) { + return; + } + frame.buffer_mut()[(area.x + x as u16, area.y + y as u16)] + .set_char(symbol) + .set_style(style); +} + +fn ensure_point_visible(offset: &mut Offset, point: Point) { + if point.x < offset.x { + offset.x = point.x; + } else if point.x >= offset.x.saturating_add(offset.page_width) { + offset.x = point.x.saturating_sub(offset.page_width.saturating_sub(1)); + } + if point.y < offset.y { + offset.y = point.y; + } else if point.y >= offset.y.saturating_add(offset.page_height) { + offset.y = point.y.saturating_sub(offset.page_height.saturating_sub(1)); + } + offset.x = offset.x.min(offset.max_x); + offset.y = offset.y.min(offset.max_y); +} + +fn nearest(points: &[Point], selected: usize, direction: Direction) -> Option<usize> { + let source = *points.get(selected)?; + points + .iter() + .copied() + .enumerate() + .filter(|(index, point)| { + *index != selected + && match direction { + Direction::Up => point.y < source.y, + Direction::Down => point.y > source.y, + Direction::Left => point.x < source.x, + Direction::Right => point.x > source.x, + } + }) + .min_by_key(|(index, point)| { + let dx = point.x.abs_diff(source.x); + let dy = point.y.abs_diff(source.y); + let perpendicular = match direction { + Direction::Up | Direction::Down => dx, + Direction::Left | Direction::Right => dy, + }; + (dx * dx + dy * dy, perpendicular, *index) + }) + .map(|(index, _)| index) +} + +fn direction(code: KeyCode) -> Option<Direction> { + match code { + KeyCode::Up | KeyCode::Char('k' | 'K') => Some(Direction::Up), + KeyCode::Down | KeyCode::Char('j' | 'J') => Some(Direction::Down), + KeyCode::Left | KeyCode::Char('h' | 'H') => Some(Direction::Left), + KeyCode::Right | KeyCode::Char('l' | 'L') => Some(Direction::Right), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use ratatui::{Terminal, backend::TestBackend}; + + use super::*; + + fn id(n: u8) -> ObjectId { + let mut bytes = [0; 20]; + bytes[19] = n; + ObjectId::Sha1(bytes) + } + + fn fixture() -> (HistoryGraph, RefSnapshot, Decorations) { + let graph = HistoryGraph::from_test_commits(&[ + (id(1), vec![]), + (id(2), vec![id(1)]), + (id(3), vec![id(2)]), + (id(4), vec![id(2)]), + (id(5), vec![id(4)]), + (id(6), vec![id(3), id(4)]), + ]); + let refs = RefSnapshot { + view: HashMap::new(), + hidden: HashMap::new(), + view_tips: vec![id(6), id(5)], + hidden_tips: Vec::new(), + pins: Vec::new(), + worktrees: Vec::new(), + }; + let decorations = Decorations::from([ + ( + id(6), + vec![ + Decoration { + name: "main".into(), + kind: DecorationKind::Local, + }, + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + ], + ), + ( + id(5), + vec![Decoration { + name: "topic".into(), + kind: DecorationKind::Local, + }], + ), + ]); + (graph, refs, decorations) + } + + #[test] + fn e_arms_immediate_deletion_of_all_ordinary_local_branches() { + let (graph, refs, mut decorations) = fixture(); + decorations.get_mut(&id(6)).expect("main is decorated").extend([ + Decoration { + name: "also-main".into(), + kind: DecorationKind::Local, + }, + Decoration { + name: "checked-out".into(), + kind: DecorationKind::WorktreeBranch, + }, + Decoration { + name: "remembered".into(), + kind: DecorationKind::HeadPinBranch, + }, + Decoration { + name: "origin/main".into(), + kind: DecorationKind::Remote, + }, + ]); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + assert!(tree.toggle(), "the ref-tree opens"); + + let Input::ResolveRemoteReferences(remote) = + tree.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)) + else { + panic!("e should request remote resolution") + }; + assert_eq!(remote, ["refs/remotes/origin/main"]); + assert!(tree.edit_expanded); + let Input::DeleteLocalBranches { names, .. } = + tree.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)) + else { + panic!("d should submit branch deletion") + }; + assert_eq!( + names, + ["refs/heads/also-main", "refs/heads/main"], + "d immediately submits every ordinary local branch and no protected decoration" + ); + assert!(!tree.edit_expanded); + } + + #[test] + fn e_r_deletes_every_resolved_remote_reference_without_confirmation() { + let (graph, refs, mut decorations) = fixture(); + decorations + .get_mut(&id(6)) + .expect("main is decorated") + .push(Decoration { + name: "origin/main".into(), + kind: DecorationKind::Remote, + }); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + assert!(tree.toggle()); + assert!(matches!( + tree.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), + Input::ResolveRemoteReferences(_) + )); + let groups = vec![RemoteDeletion { + remote: "origin".into(), + references: vec![ + "refs/heads/main".try_into().expect("valid"), + "refs/heads/other".try_into().expect("valid"), + ], + }]; + tree.set_remote_deletions(groups.clone()); + + assert!(matches!( + tree.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + Input::DeleteRemoteReferences { groups: submitted, .. } if submitted == groups + )); + assert!(!tree.edit_expanded); + } + + #[test] + fn resolves_only_remote_tracking_refs_with_an_upstream_mapping() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + for args in [ + &["config", "remote.origin.url", "."][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + ] { + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(args) + .status()? + .success() + ); + } + let repository = crate::test_repository::open(fixture.path())?; + let groups = resolve_remote_deletions( + &repository, + vec![ + "refs/remotes/origin/topic".try_into()?, + "refs/remotes/missing/topic".try_into()?, + ], + ); + assert_eq!( + groups, + [RemoteDeletion { + remote: "origin".into(), + references: vec!["refs/heads/topic".try_into()?], + }], + "only uniquely reverse-mapped remote references are editable" + ); + Ok(()) + } + + #[test] + fn remote_deletion_continues_after_a_failed_remote() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let git = |args: &[&str]| -> gix_testtools::Result<()> { + let status = std::process::Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(args) + .status()?; + assert!(status.success(), "git {} succeeds", args.join(" ")); + Ok(()) + }; + git(&["remote", "add", "z-working", "."])?; + git(&["fetch", "z-working", "topic"])?; + git(&["remote", "add", "a-broken", "./missing"])?; + + let outcome = crate::push_remote_deletions( + fixture.path(), + &[ + RemoteDeletion { + remote: "a-broken".into(), + references: vec!["refs/heads/topic".try_into()?], + }, + RemoteDeletion { + remote: "z-working".into(), + references: vec!["refs/heads/topic".try_into()?], + }, + ], + ); + assert_eq!(outcome.deleted, 1, "the later working remote is still attempted"); + assert_eq!(outcome.failures.len(), 1, "the failed remote is reported"); + let repository = crate::test_repository::open(fixture.path())?; + assert!(repository.try_find_reference("refs/heads/topic")?.is_none()); + assert!( + repository.try_find_reference("refs/remotes/z-working/topic")?.is_none(), + "git push removes the corresponding tracking reference" + ); + Ok(()) + } + + #[test] + fn escape_cancels_the_edit_prefix_without_leaving_the_ref_tree() { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + assert!(tree.toggle(), "the ref-tree opens"); + + tree.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + tree.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(tree.is_active(), "Escape cancels the armed prefix first"); + assert!(!tree.edit_expanded); + } + + #[test] + fn protected_branches_leave_no_delete_action() { + let (graph, refs, mut decorations) = fixture(); + decorations.get_mut(&id(5)).expect("topic is decorated")[0].kind = DecorationKind::HeadPinBranch; + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + tree.selected = tree.overview.as_ref().and_then(|overview| { + graph + .index(id(5)) + .and_then(|index| overview.by_commit.get(&index).copied()) + }); + + tree.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!( + tree.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), + Input::Handled + ); + assert_eq!( + tree.notice.as_ref().map(|notice| notice.text.as_str()), + Some("no deletable local branches at the selected node") + ); + } + + #[test] + fn first_parent_shape_uses_all_parent_reachability_for_counts_and_boundaries() { + let (graph, refs, decorations) = fixture(); + let overview = Overview::new(&graph, &refs, &decorations, true); + let main = overview.by_commit[&graph.index(id(6)).expect("main exists")]; + let topic = overview.by_commit[&graph.index(id(5)).expect("topic exists")]; + let fork = overview.by_commit[&graph.index(id(2)).expect("fork exists")]; + let mut overlay = Overlay::new(&graph, &overview, main); + + assert_eq!(overview.nodes.len(), 4, "only refs, the fork, and root remain"); + assert_eq!(overview.nodes[main].parent, Some(fork)); + assert_eq!(overview.nodes[topic].parent, Some(fork)); + assert_eq!(overlay.counts[main], Some(5), "selected count follows every parent"); + assert_eq!(overlay.counts[topic], None, "off-screen reference counts start lazy"); + let topic_edge = overview + .edges + .iter() + .position(|edge| edge.child == topic) + .expect("topic has a contracted edge"); + assert_eq!( + overlay.boundaries[topic_edge], + Some(id(4)), + "the hidden merged topic commit becomes the visual reachability boundary" + ); + let rail = place_rail(&overview, Some(&overlay)); + let boundary = rail.boundaries[topic_edge].expect("rail inserts the boundary row"); + assert!( + matches!(rail.rail_rows[boundary.y].kind, RailRowKind::Boundary(edge) if edge == topic_edge), + "the inserted rail row retains its source edge" + ); + let topic_row = rail.nodes[topic].y; + overlay.compute_visible_counts(&graph, &overview, &rail, topic_row..topic_row + 1); + assert_eq!( + overlay.counts[topic], + Some(1), + "a visible reference gets its exact exclusive count" + ); + let stamp = overlay.stamp; + overlay.compute_visible_counts(&graph, &overview, &rail, topic_row..topic_row + 1); + assert_eq!(overlay.stamp, stamp, "a cached visible count is not traversed again"); + } + + #[test] + fn space_keeps_counts_anchored_without_rebuilding_while_navigating() -> gix_testtools::Result { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + let mut terminal = Terminal::new(TestBackend::new(100, 18))?; + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + let main = tree + .selected + .and_then(|selected| tree.overview.as_ref()?.nodes.get(selected)) + .map(|node| node.id) + .expect("the initial selection exists"); + + tree.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert_eq!(tree.count_anchor, Some(main), "Space anchors counts to the cursor"); + tree.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)); + assert!(tree.overlay.is_some(), "anchored navigation retains reachability"); + assert!(tree.placed.is_some(), "anchored navigation retains tree placement"); + assert_eq!( + tree.overlay.as_ref().map(|overlay| overlay.selected), + graph.index(main), + "the retained overlay still uses the anchored commit" + ); + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + let footer: String = terminal + .backend() + .buffer() + .content + .chunks(100) + .last() + .expect("the terminal has a footer") + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!( + footer.contains("Space counts:main"), + "the fixed anchor remains identifiable" + ); + + tree.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!( + tree.overlay.is_none(), + "moving the anchor invalidates reachability once" + ); + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + let moved = tree + .selected + .and_then(|selected| tree.overview.as_ref()?.nodes.get(selected)) + .map(|node| node.id) + .expect("the moved selection exists"); + assert_eq!(tree.count_anchor, Some(moved)); + + tree.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!( + tree.count_anchor.is_none(), + "Space on the anchor restores automatic counts" + ); + assert!(tree.overlay.is_some(), "clearing at the cursor reuses the same overlay"); + tree.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)); + assert!( + tree.overlay.is_none(), + "automatic counts invalidate when the cursor moves" + ); + Ok(()) + } + + #[test] + fn only_the_current_detached_worktree_is_a_pin_marker() { + let (graph, mut refs, mut decorations) = fixture(); + *decorations.get_mut(&id(5)).expect("topic is decorated") = vec![ + Decoration { + name: "pin:first".into(), + kind: DecorationKind::Pin, + }, + Decoration { + name: "pin:second".into(), + kind: DecorationKind::Pin, + }, + ]; + refs.pins.push(crate::history::Pin { + name: "refs/worktree/tix/pins/first".try_into().expect("valid"), + target: gix::refs::Target::Object(id(5)), + id: id(5), + }); + refs.worktrees.push(crate::history::WorktreeCheckout { + id: id(3), + label_id: id(6), + checkout_name: "main-wt".into(), + reference: Some("refs/heads/main".try_into().expect("valid")), + is_current: true, + is_detached: true, + }); + refs.worktrees.push(crate::history::WorktreeCheckout { + id: id(4), + label_id: id(6), + checkout_name: "foreign-wt".into(), + reference: Some("refs/heads/main".try_into().expect("valid")), + is_current: false, + is_detached: true, + }); + decorations.get_mut(&id(6)).expect("main is decorated")[0].kind = DecorationKind::HeadPinBranch; + decorations.entry(id(4)).or_default().push(Decoration { + name: "foreign-wt".into(), + kind: DecorationKind::WorktreeDetached, + }); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + let overview = tree.overview.as_ref().expect("overview exists"); + assert!( + !overview.nodes.iter().any(|node| node.id == id(5)), + "ordinary pin-only tips are absent" + ); + let unicode = render_overview(overview, true, &HashMap::new()); + let ascii = render_overview(overview, false, &HashMap::new()); + assert_eq!(unicode.matches('📌').count(), 1, "detached HEAD has one marker"); + assert!( + unicode.contains("★main"), + "the remembered branch stays at its actual tip" + ); + assert!( + unicode.contains("foreign-wt@"), + "the foreign checkout identifies its actual HEAD" + ); + assert_eq!( + ascii.matches("[pin]").count(), + 1, + "only current detached state is marked" + ); + assert!(!unicode.contains("pin:"), "ordinary pin names remain hidden"); + } + + #[test] + fn count_anchor_survives_refresh_and_clears_with_a_hidden_tag() { + let (graph, refs, mut decorations) = fixture(); + decorations.entry(id(3)).or_default().push(Decoration { + name: "v1".into(), + kind: DecorationKind::Tag, + }); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + tree.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let main = tree.count_anchor.expect("main is anchored"); + tree.rebuild(&graph, &refs, &decorations); + assert_eq!(tree.count_anchor, Some(main), "refresh preserves a visible anchor"); + + tree.selected = tree.overview.as_ref().and_then(|overview| { + graph + .index(id(3)) + .and_then(|commit| overview.by_commit.get(&commit).copied()) + }); + tree.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert_eq!(tree.count_anchor, Some(id(3)), "Space moves the anchor to the tag"); + tree.toggle_tags(); + assert!( + tree.count_anchor.is_none(), + "hiding the anchor tag restores automatic counts" + ); + } + + #[test] + fn deleted_reference_selection_uses_the_next_surviving_row_then_the_previous() { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + tree.placed = tree.overview.as_ref().map(|overview| place_rail(overview, None)); + let overview = tree.overview.as_ref().expect("overview exists"); + let mut rows: Vec<_> = overview + .nodes + .iter() + .enumerate() + .map(|(index, node)| (tree.placed.as_ref().expect("placed").nodes[index].y, node.id)) + .collect(); + rows.sort_by_key(|(row, _)| *row); + let (_, selected) = rows[1]; + tree.selected = overview.nodes.iter().position(|node| node.id == selected); + let fallback = tree.reference_deletion_fallback().expect("selection has neighbours"); + tree.select_after_reference_deletion(fallback); + + let mut without_refs = refs.clone(); + without_refs.view_tips.retain(|id| *id != selected); + let mut without_selected = decorations.clone(); + without_selected.remove(&selected); + tree.rebuild(&graph, &without_refs, &without_selected); + assert_eq!( + tree.selected_id(), + Some(id(1)), + "the first surviving row below is preferred" + ); + + let graph = HistoryGraph::from_test_commits(&[(id(1), vec![]), (id(2), vec![]), (id(3), vec![])]); + let refs = RefSnapshot { + view: HashMap::new(), + hidden: HashMap::new(), + view_tips: vec![id(1), id(2), id(3)], + hidden_tips: Vec::new(), + pins: Vec::new(), + worktrees: Vec::new(), + }; + let decorations = HashMap::from([ + ( + id(1), + vec![ + Decoration { + name: "a".into(), + kind: DecorationKind::Local, + }, + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + ], + ), + ( + id(2), + vec![Decoration { + name: "b".into(), + kind: DecorationKind::Local, + }], + ), + ( + id(3), + vec![Decoration { + name: "c".into(), + kind: DecorationKind::Local, + }], + ), + ]); + tree.rebuild(&graph, &refs, &decorations); + tree.selected = tree + .overview + .as_ref() + .and_then(|overview| overview.nodes.iter().position(|node| node.id == id(3))); + tree.placed = tree.overview.as_ref().map(|overview| place_rail(overview, None)); + let fallback = tree.reference_deletion_fallback().expect("selection has neighbours"); + tree.select_after_reference_deletion(fallback); + let mut remaining_refs = refs.clone(); + remaining_refs.view_tips.retain(|candidate| *candidate != id(3)); + let mut remaining_decorations = decorations.clone(); + remaining_decorations.remove(&id(3)); + tree.rebuild(&graph, &remaining_refs, &remaining_decorations); + assert_eq!( + tree.selected_id(), + Some(id(2)), + "the previous row is used when nothing follows" + ); + + tree.rebuild(&graph, &refs, &decorations); + tree.selected = tree + .overview + .as_ref() + .and_then(|overview| overview.nodes.iter().position(|node| node.id == id(3))); + tree.placed = tree.overview.as_ref().map(|overview| place_rail(overview, None)); + let fallback = tree.reference_deletion_fallback().expect("selection has neighbours"); + tree.select_after_reference_deletion(fallback); + tree.selected = tree + .overview + .as_ref() + .and_then(|overview| overview.nodes.iter().position(|node| node.id == id(1))); + tree.rebuild(&graph, &remaining_refs, &remaining_decorations); + assert_eq!( + tree.selected_id(), + Some(id(1)), + "moving the cursor before refresh cancels deletion fallback" + ); + } + + #[test] + fn shift_navigation_is_topological_without_retaining_a_mode() { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + let overview = tree.overview.as_ref().expect("overview exists"); + let root = overview.by_commit[&graph.index(id(1)).expect("root exists")]; + let fork = overview.by_commit[&graph.index(id(2)).expect("fork exists")]; + let main = overview.by_commit[&graph.index(id(6)).expect("main exists")]; + tree.selected = Some(root); + + tree.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT)); + assert_eq!(tree.selected, Some(fork), "Shift-Up moves toward a leaf"); + tree.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)); + assert_eq!(tree.selected, Some(root), "Shift-Down moves toward the root"); + + tree.placed = tree.overview.as_ref().map(|overview| place_rail(overview, None)); + let nearest = nearest( + &tree.placed.as_ref().expect("the ref-tree is placed").nodes, + root, + Direction::Up, + ); + tree.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(tree.selected, nearest, "plain Up immediately returns to nearest motion"); + + tree.selected = Some(fork); + tree.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)); + assert_eq!( + tree.selected, + Some(fork), + "a usable branch choice does not move the cursor" + ); + tree.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)); + assert_eq!(tree.selected, Some(fork), "a saturated branch choice is a no-op"); + let chosen = tree.overview.as_ref().expect("overview exists").nodes[fork].children[1]; + tree.handle_key(KeyEvent::new(KeyCode::Char('K'), KeyModifiers::NONE)); + assert_eq!( + tree.selected, + Some(chosen), + "uppercase navigation is treated as shifted" + ); + + tree.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(tree.selected, Some(chosen), "Tab no longer toggles a motion mode"); + + tree.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + assert_eq!(tree.selected, Some(main), "plain g reaches the top selectable node"); + tree.selected = Some(main); + tree.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)); + assert_eq!( + tree.selected, + Some(root), + "uppercase G reaches the current component root" + ); + tree.selected = Some(main); + tree.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT)); + assert_eq!(tree.selected, Some(root), "shift-modified lowercase g reaches the root"); + } + + #[test] + fn plain_pages_move_the_cursor_while_shift_pages_and_plain_mouse_pan() { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + tree.placed = tree.overview.as_ref().map(|overview| place_rail(overview, None)); + tree.offset.page_height = 4; + let points = tree.placed.as_ref().expect("the ref-tree is placed").nodes.clone(); + let first = points + .iter() + .enumerate() + .min_by_key(|(_, point)| point.y) + .map(|(index, _)| index) + .expect("the fixture has nodes"); + tree.selected = Some(first); + let first_y = points[first].y; + + tree.offset.max_y = tree + .placed + .as_ref() + .expect("the ref-tree is placed") + .height + .saturating_sub(tree.offset.page_height); + tree.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); + let full_page = tree.selected.expect("PageDown retains a selection"); + let full_page_y = points[full_page].y; + assert!(full_page_y > first_y, "plain PageDown advances the ref-tree cursor"); + assert!( + tree.ensure_visible, + "plain page navigation keeps the new cursor visible" + ); + + tree.selected = Some(first); + tree.offset.y = 0; + tree.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)); + assert_eq!(tree.selected, Some(first), "Shift-PageDown leaves the cursor alone"); + assert!(tree.offset.y > 0, "Shift-PageDown pans the viewport"); + + tree.selected = Some(first); + tree.offset.y = 0; + tree.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + let half_page_y = points[tree.selected.expect("Ctrl-d retains a selection")].y; + assert!( + half_page_y > first_y && half_page_y <= full_page_y, + "plain half-page navigation advances no farther than a full page" + ); + + tree.selected = Some(first); + tree.offset.y = 0; + tree.handle_key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + assert_eq!(tree.selected, Some(first), "Shift-Ctrl-d leaves the cursor alone"); + assert!(tree.offset.y > 0, "Shift-Ctrl-d pans by half a viewport"); + + tree.selected = Some(first); + tree.placed = tree.overview.as_ref().map(|overview| place_rail(overview, None)); + tree.handle_mouse(MouseEventKind::ScrollDown, KeyModifiers::NONE, 2); + assert_eq!( + tree.selected, + Some(first), + "plain mouse scrolling remains viewport-only" + ); + tree.handle_mouse(MouseEventKind::ScrollDown, KeyModifiers::SHIFT, 1); + assert_ne!( + tree.selected, + Some(first), + "Shift-mouse scrolling moves the nearest cursor" + ); + } + + #[test] + fn enter_pins_every_visible_reference_kind_but_not_synthetic_nodes() { + let (graph, refs, mut decorations) = fixture(); + decorations.get_mut(&id(6)).expect("main is decorated").extend([ + Decoration { + name: "tag: release".into(), + kind: DecorationKind::Tag, + }, + Decoration { + name: "origin/main".into(), + kind: DecorationKind::Remote, + }, + ]); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + + assert_eq!( + tree.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Input::PinReferences { + id: id(6), + kinds: vec![DecorationKind::Local, DecorationKind::Remote, DecorationKind::Tag], + }, + "Enter retains every displayed reference namespace" + ); + assert_eq!( + tree.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), + Input::PinReferences { + id: id(6), + kinds: vec![DecorationKind::Local, DecorationKind::Remote, DecorationKind::Tag], + }, + "p retains every displayed reference namespace" + ); + + tree.selected = tree + .overview + .as_ref() + .and_then(|overview| overview.by_commit.get(&graph.index(id(2))?).copied()); + assert_eq!( + tree.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Input::Handled, + "fork-only nodes have no pin action" + ); + } + + #[test] + fn pin_references_uses_all_requested_refs_and_makes_symbolic_pins_visible() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let old = repo.rev_parse_single("main~2")?.detach(); + for name in ["refs/heads/old", "refs/tags/old", "refs/remotes/origin/old"] { + repo.reference( + name, + old, + gix::refs::transaction::PreviousValue::MustNotExist, + "prepare ref-tree pin test", + )?; + } + assert!( + std::process::Command::new("git") + .current_dir(fixture.path()) + .args([ + "symbolic-ref", + "refs/remotes/origin/old-head", + "refs/remotes/origin/old" + ]) + .status()? + .success(), + "git creates a symbolic remote alias" + ); + + let pins = pin_references(&repo, old, &[DecorationKind::Local, DecorationKind::Remote])?; + let targets: Vec<_> = pins + .iter() + .filter_map(|pin| pin.target.try_name().map(|name| name.as_bstr().to_owned())) + .collect(); + assert!(targets.contains(&b"refs/heads/old".as_bstr().to_owned())); + assert!(targets.contains(&b"refs/remotes/origin/old".as_bstr().to_owned())); + assert!( + targets.contains(&b"refs/remotes/origin/old-head".as_bstr().to_owned()), + "the pin retains the selected symbolic reference instead of its referent" + ); + assert!(!targets.contains(&b"refs/tags/old".as_bstr().to_owned())); + for pin in &pins { + assert_eq!( + repo.find_reference(pin.name.as_ref())?.target().into_owned(), + pin.target, + "the stored pin itself remains symbolic" + ); + } + assert!( + crate::history::snapshot(&repo, &[], &[], false)? + .view_tips + .contains(&old), + "a symbolic reference pin augments attached history even below HEAD" + ); + Ok(()) + } + + #[test] + fn rebuild_preserves_selection_by_object_id() { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + tree.selected = tree.overview.as_ref().and_then(|overview| { + graph + .index(id(5)) + .and_then(|index| overview.by_commit.get(&index).copied()) + }); + + let reordered = HistoryGraph::from_test_commits(&[ + (id(6), vec![id(3), id(4)]), + (id(5), vec![id(4)]), + (id(4), vec![id(2)]), + (id(3), vec![id(2)]), + (id(2), vec![id(1)]), + (id(1), vec![]), + ]); + tree.rebuild(&reordered, &refs, &decorations); + + assert_eq!( + tree.selected + .and_then(|selected| tree.overview.as_ref()?.nodes.get(selected)) + .map(|node| node.id), + Some(id(5)), + "refresh keeps the selected commit when graph indices change" + ); + } + + #[test] + fn toggling_tags_removes_their_labels_and_topology() { + let (graph, refs, mut decorations) = fixture(); + decorations.entry(id(3)).or_default().extend([ + Decoration { + name: "v1".into(), + kind: DecorationKind::Tag, + }, + Decoration { + name: "release".into(), + kind: DecorationKind::AnnotatedTag, + }, + ]); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + assert!(tree.toggle(), "the available ref-tree opens"); + let tagged = graph.index(id(3)).expect("tagged commit exists"); + assert!( + tree.overview + .as_ref() + .is_some_and(|overview| overview.by_commit.contains_key(&tagged)), + "tags retain otherwise linear commits" + ); + + tree.handle_key(KeyEvent::new(KeyCode::Char('T'), KeyModifiers::SHIFT)); + assert!(tree.hide_tags, "uppercase T hides tags"); + assert!( + tree.overview + .as_ref() + .is_some_and(|overview| !overview.by_commit.contains_key(&tagged)), + "a tag-only linear node disappears from the projection" + ); + + tree.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::SHIFT)); + assert!( + tree.overview + .as_ref() + .is_some_and(|overview| overview.by_commit.contains_key(&tagged)), + "shift-modified lowercase t restores tag nodes" + ); + tree.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)); + assert!(!tree.is_active(), "plain t returns to history"); + } + + #[test] + fn full_rendering_is_unstyled_ascii() { + let (graph, refs, decorations) = fixture(); + let overview = Overview::new(&graph, &refs, &decorations, true); + let rendered = render_overview(&overview, false, &HashMap::new()); + + assert!(rendered.contains("main"), "HEAD is rendered without selection state"); + assert!(rendered.contains("topic"), "ordinary nodes retain their label"); + assert!(rendered.contains("<root>"), "the full projection reaches its root"); + assert!(rendered.contains('o'), "ordinary nodes use an ASCII marker"); + assert!(!rendered.contains('●'), "ASCII output has no Unicode node glyphs"); + assert!(!rendered.contains('\u{1b}'), "plain output has no terminal styles"); + } + + #[test] + fn full_rendering_pairs_raw_commit_tips_with_change_ids() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::test_repository::open(fixture.path())?; + let mut commit = repo.head_commit()?.decode()?.into_owned()?; + commit.message = "unreferenced raw tip".into(); + let id = repo.write_object(&commit)?.detach(); + let rendered = render_full(&repo, &[id.to_string().into()], &[], true, false)?; + + assert!( + rendered.contains(&crate::change_id::display(&repo, id, 7)?), + "a displayed raw hash is immediately followed by its change ID: {rendered:?}" + ); + Ok(()) + } + + #[test] + fn rail_layout_uses_rounded_unicode_connections_and_ascii_fallbacks() { + let (graph, refs, decorations) = fixture(); + let overview = Overview::new(&graph, &refs, &decorations, true); + let unicode = render_overview(&overview, true, &HashMap::new()); + let ascii = render_overview(&overview, false, &HashMap::new()); + + assert!( + unicode.contains("│ ● topic"), + "branch nodes stay in their lane: {unicode:?}" + ); + assert!( + unicode.contains("├─╯"), + "forks terminate with a rounded corner: {unicode:?}" + ); + assert!(!unicode.contains('┌'), "rail corners are not square"); + assert!(ascii.contains("| o topic"), "ASCII output retains the branch lane"); + assert!(ascii.contains("+-+"), "ASCII output retains the join row"); + } + + #[test] + fn interactive_ref_tree_renders_exact_visible_counts() -> gix_testtools::Result { + let (graph, refs, mut decorations) = fixture(); + decorations.get_mut(&id(5)).expect("topic is decorated")[0].kind = DecorationKind::WorktreeBranch; + let mut tree = Tree::default(); + tree.set_history_commits([id(6)]); + tree.rebuild(&graph, &refs, &decorations); + let mut terminal = Terminal::new(TestBackend::new(100, 18))?; + + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + let rendered = terminal + .backend() + .buffer() + .content + .chunks(100) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::<String>()) + .collect::<Vec<_>>() + .join("\n"); + assert!(rendered.contains("main"), "the rounded ref-tree shows main"); + assert!(rendered.contains("topic"), "the rounded ref-tree shows topic"); + assert!(rendered.contains("ref-tree ·"), "the footer identifies the ref-tree"); + assert!( + rendered.contains("5• main"), + "the selected reference shows its exact count" + ); + assert!( + rendered.contains("1• topic"), + "visible references show relative exact counts" + ); + assert!( + !rendered.contains("5●"), + "the rail marker is not repeated after a count" + ); + let selected = tree.selected.expect("the ref-tree selects a node"); + let placed = tree.placed.as_ref().expect("drawing places the ref-tree"); + let point = placed.nodes[selected]; + let rail_width = placed.rail_width; + assert!( + terminal.backend().buffer()[(point.x as u16, point.y as u16)] + .modifier + .contains(Modifier::REVERSED), + "the selected disk is inverted with its label" + ); + assert!( + terminal.backend().buffer()[(rail_width as u16, point.y as u16)] + .modifier + .contains(Modifier::REVERSED), + "the selected node label remains inverted" + ); + let topic = + tree.overview.as_ref().expect("the ref-tree exists").by_commit[&graph.index(id(5)).expect("topic exists")]; + let point = tree.placed.as_ref().expect("drawing places the ref-tree").nodes[topic]; + assert_eq!( + terminal.backend().buffer()[(rail_width as u16, point.y as u16)].fg, + Color::Green, + "an out-of-history linked worktree uses dark green" + ); + tree.set_history_commits([id(6), id(5)]); + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + assert_eq!( + terminal.backend().buffer()[(rail_width as u16, point.y as u16)].fg, + Color::Cyan, + "history visibility takes precedence with the bright current-history color" + ); + + let fork = tree.overview.as_ref().expect("the ref-tree exists").by_commit + [&graph.index(id(2)).expect("the fork exists")]; + tree.selected = Some(fork); + tree.selection_changed(); + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + let point = tree.placed.as_ref().expect("drawing places the ref-tree").nodes[fork]; + assert!( + terminal.backend().buffer()[(point.x as u16, point.y as u16)] + .modifier + .contains(Modifier::REVERSED), + "a selected synthetic node keeps its disk inverted" + ); + assert!( + terminal + .backend() + .buffer() + .content + .chunks(100) + .nth(point.y) + .expect("the selected fork has a rendered row") + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::<String>() + .contains("1/2"), + "a selected fork always identifies its chosen child" + ); + tree.leave_error("remote deletion failed"); + terminal.draw(|frame| tree.draw(frame, Some(&graph)))?; + assert_eq!(terminal.backend().buffer()[(2, 16)].bg, Color::LightRed); + assert!( + terminal + .backend() + .buffer() + .content + .chunks(100) + .last() + .expect("the terminal has a footer") + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::<String>() + .contains("ref-tree"), + "ref-tree notices leave its footer visible" + ); + Ok(()) + } + + #[test] + fn ref_tree_toggle_opens_and_closes_the_single_layout() { + let (graph, refs, decorations) = fixture(); + let mut tree = Tree::default(); + tree.rebuild(&graph, &refs, &decorations); + + assert!(tree.toggle()); + assert!(tree.is_active()); + assert!(tree.toggle()); + assert!(!tree.is_active()); + } +} diff --git a/gix-tix/src/test_repository.rs b/gix-tix/src/test_repository.rs new file mode 100644 index 00000000000..6a80f30dee1 --- /dev/null +++ b/gix-tix/src/test_repository.rs @@ -0,0 +1,90 @@ +use std::path::Path; + +const DEFAULT_OVERRIDES: &[&str] = &[ + "user.name=author", + "user.email=author@example.com", + "gitoxide.commit.authorDate=2001-01-01T00:00:00 +0000", + "gitoxide.commit.committerDate=2001-01-01T00:00:00 +0000", + "commit.gpgSign=false", + "core.editor=:", +]; + +pub(crate) fn open(path: impl AsRef<Path>) -> Result<gix::Repository, gix::open::Error> { + open_with(path, std::iter::empty::<String>()) +} + +pub(crate) fn open_with<I, S>(path: impl AsRef<Path>, overrides: I) -> Result<gix::Repository, gix::open::Error> +where + I: IntoIterator<Item = S>, + S: Into<String>, +{ + let mut config: Vec<String> = DEFAULT_OVERRIDES.iter().map(ToString::to_string).collect(); + config.extend(overrides.into_iter().map(Into::into)); + gix::open_opts( + path.as_ref().to_owned(), + gix::open::Options::isolated().config_overrides(config), + ) +} + +pub(crate) fn replacing_editor(old: &str, new: &str) -> String { + #[cfg(windows)] + { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/replace-editor.ps1"); + format!("pwsh -NoProfile -File \"{}\" \"{old}\" \"{new}\"", script.display()) + } + #[cfg(not(windows))] + { + format!("sed -i.bak -e 's/^{old}$/{new}/'") + } +} + +/// Keep Git from converting fixture contents while a test compares exact worktree and index bytes. +pub(crate) fn disable_autocrlf(path: impl AsRef<Path>) -> std::io::Result<()> { + let status = std::process::Command::new("git") + .arg("-C") + .arg(path.as_ref()) + .args(["config", "--local", "core.autocrlf", "false"]) + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other( + "could not disable core.autocrlf for the test repository", + )) + } +} + +/// Remove the test-local line-ending override before assertions which snapshot repository configuration. +pub(crate) fn clear_autocrlf(path: impl AsRef<Path>) -> std::io::Result<()> { + let status = std::process::Command::new("git") + .arg("-C") + .arg(path.as_ref()) + .args(["config", "--local", "--unset", "core.autocrlf"]) + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other( + "could not remove the core.autocrlf test override", + )) + } +} + +#[test] +fn defaults_are_deterministic_and_case_specific_overrides_win() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repo = open_with(fixture, ["user.name=reviewer"])?; + let author = repo.author().expect("the default author is configured")?.to_owned()?; + let committer = repo + .committer() + .expect("the default committer is configured")? + .to_owned()?; + assert_eq!(author.name, "reviewer", "a case-specific override wins"); + assert_eq!(author.email, "author@example.com"); + assert_eq!(author.time.seconds, 978_307_200); + assert_eq!(committer.name, "reviewer"); + assert_eq!(committer.time.seconds, 978_307_200); + assert!(repo.commit_signing_options_if_enabled()?.is_none()); + assert_eq!(repo.editor(), Some(":".into())); + Ok(()) +} diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index cc182e9d504..6b2ecaf1f69 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -1,52 +1,640 @@ use gix::bstr::{BStr, BString, ByteSlice}; use ratatui::{ Frame, - layout::{Constraint, Layout, Margin, Rect}, + layout::{Alignment, Constraint, Layout, Margin, Position, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Clear, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Gauge, Paragraph, Wrap}, }; use crate::{ - app::{App, AttributionKind, CommitRow, CopyKind, NameMode, RefMode, State}, + BuiltInDiff, + app::{ + Alignment as HistoryAlignment, App, AttributionKind, ChangeGroup, ChangeKind, ChangePane, Changes, + ChangesLayout, ChangesMode, CommitRow, CopyKind, DateMode, HistoryEntry, IdMode, NameMode, Notice, NoticeKind, + RefMode, SelectionRelation, SignatureState, State, + }, + command_menu::{self, Command, CommandGroup, CommandId}, history::{DecorationKind, Decorations}, + menu::{MAX_VISIBLE_ROWS, Menu}, }; +const COMPARED_PARENT_COLOR: Color = Color::Cyan; +const COMMIT_PANE_WIDTH: u16 = 84; +const FILESYSTEM_NOTIFICATION_COLOR: Color = Color::Rgb(255, 165, 0); +const NOTE_COLOR: Color = Color::LightMagenta; +const PANE_STATUS_BACKGROUND: Color = Color::DarkGray; + +#[derive(Clone)] +struct MarkdownStyle; + +impl tui_markdown::StyleSheet for MarkdownStyle { + fn heading_marker(&self, _level: u8) -> &'static str { + "" + } + + fn code_block_fence(&self) -> &'static str { + "" + } +} + +fn note_style() -> Style { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .remove_modifier(Modifier::REVERSED) +} + +pub(crate) fn notice_area(notice: &Notice, horizontal: Rect, top: u16, bottom: u16) -> Option<Rect> { + if horizontal.width == 0 || top >= bottom { + return None; + } + let horizontal = horizontal.inner(Margin { + horizontal: 2.min(horizontal.width.saturating_sub(1) / 2), + vertical: 0, + }); + let height = u16::try_from( + Paragraph::new(notice.text.as_str()) + .wrap(Wrap { trim: false }) + .line_count(horizontal.width), + ) + .unwrap_or(u16::MAX) + .max(1) + .min(bottom.saturating_sub(top)); + Some(Rect::new( + horizontal.x, + bottom.saturating_sub(height), + horizontal.width, + height, + )) +} + +pub(crate) fn render_notice(frame: &mut Frame<'_>, area: Rect, notice: &Notice) { + let background = match notice.kind { + NoticeKind::Success => Color::Green, + NoticeKind::Attention => Color::Yellow, + NoticeKind::Error => Color::LightRed, + }; + frame.render_widget( + Paragraph::new(notice.text.as_str()).wrap(Wrap { trim: false }).style( + Style::default() + .fg(Color::Black) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +pub(crate) fn draw_command_menu( + frame: &mut Frame<'_>, + menu: &mut Menu<CommandId>, + commands: &[Command], +) -> Option<Position> { + if !menu.is_open() { + return None; + } + let frame_area = frame.area(); + let width = frame_area.width.saturating_sub(2).min(72); + if width < 4 { + menu.set_visible_rows(0); + return None; + } + menu.set_visible_rows(usize::from(frame_area.height.saturating_sub(5)).min(MAX_VISIBLE_ROWS)); + let height = frame_area + .height + .saturating_sub(2) + .min(u16::try_from(menu.visible_indices().len().max(1) + 3).unwrap_or(u16::MAX)); + if height < 3 { + return None; + } + let area = Rect::new( + frame_area.x + (frame_area.width - width) / 2, + frame_area.y + (frame_area.height - height) / 2, + width, + height, + ); + let block = Block::new().borders(Borders::ALL).title(" Command "); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let [query_area, results_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner); + let [prompt_area, input_area] = Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(query_area); + frame.render_widget( + Paragraph::new("> ").style(Style::default().add_modifier(Modifier::BOLD)), + prompt_area, + ); + + let before_cursor: String = menu.query().chars().take(menu.cursor()).collect(); + let cursor_width = Line::raw(before_cursor).width(); + let input_width = usize::from(input_area.width); + let scroll = cursor_width.saturating_sub(input_width.saturating_sub(1)); + frame.render_widget( + Paragraph::new(menu.query()).scroll((0, u16::try_from(scroll).unwrap_or(u16::MAX))), + input_area, + ); + + let selected = menu.selected_visible_row(); + let mut lines = Vec::new(); + for (row, index) in menu.visible_indices().iter().copied().enumerate() { + let command = &commands[index]; + let group = command.group.label(); + let mut shortcut = command.shortcut.chars(); + let shortcut = format!( + "{} {}", + shortcut.next().expect("a command shortcut has a prefix"), + shortcut.next().expect("a command shortcut has a key") + ); + let style = if selected == Some(row) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + lines.push(Line::styled( + format!("{} {group:<11} {} [{shortcut}]", row + 1, command.label), + style, + )); + } + if lines.is_empty() { + lines.push(Line::styled( + "no matching commands", + Style::default().add_modifier(Modifier::DIM), + )); + } + frame.render_widget(Paragraph::new(lines), results_area); + + (input_area.width > 0).then(|| { + Position::new( + input_area.x + u16::try_from(cursor_width.saturating_sub(scroll)).unwrap_or(u16::MAX), + input_area.y, + ) + }) +} + +pub(crate) fn draw_todo_progress(frame: &mut Frame<'_>, progress: crate::edit::rebase::Progress) { + let area = frame.area(); + frame.render_widget(Clear, area); + let width = area.width.min(72); + let height = area.height.min(4); + let progress_area = Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ); + let rows = Layout::vertical([Constraint::Length(1); 4]).split(progress_area); + frame.render_widget( + Paragraph::new("Rebasing commits") + .alignment(Alignment::Center) + .style(Style::default().add_modifier(Modifier::BOLD)), + rows[0], + ); + let ratio = if progress.total == 0 { + 1.0 + } else { + progress.processed.min(progress.total) as f64 / progress.total as f64 + }; + frame.render_widget( + Gauge::default() + .ratio(ratio) + .label(format!("{} / {} commits", progress.processed, progress.total)) + .gauge_style(Style::default().fg(Color::LightBlue)), + rows[1], + ); + frame.render_widget( + Paragraph::new(format!( + "cherry-picked {} · {:.1?}", + progress.cherry_picked, progress.cherry_pick_time + )) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::LightYellow)), + rows[2], + ); + frame.render_widget( + Paragraph::new(format!("signed {} · {:.1?}", progress.signed, progress.signing_time)) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Green)), + rows[3], + ); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ChangesPaneArea { + pane: ChangePane, + outer: Rect, +} + +fn index_separator(pane: ChangePane, changes: &Changes) -> Option<usize> { + if pane != ChangePane::Worktree { + return None; + } + let index = changes + .paths + .iter() + .position(|change| change.group == ChangeGroup::Unstaged)?; + changes.paths[..index] + .iter() + .any(|change| change.group == ChangeGroup::Staged) + .then_some(index) +} + +fn changes_pane_areas( + area: Rect, + max_height: u16, + tree: Option<(u16, usize)>, + worktree: Option<(u16, usize)>, +) -> (ChangesLayout, Vec<ChangesPaneArea>, u16) { + match (tree, worktree) { + (None, None) => (ChangesLayout::SideBySide, Vec::new(), 0), + (Some((height, _)), None) => { + let height = height.min(max_height); + ( + ChangesLayout::SideBySide, + vec![ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(area.x, area.bottom().saturating_sub(height), area.width, height), + }], + height, + ) + } + (None, Some((height, _))) => { + let height = height.min(max_height); + ( + ChangesLayout::SideBySide, + vec![ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new(area.x, area.bottom().saturating_sub(height), area.width, height), + }], + height, + ) + } + (Some((tree_height, tree_title)), Some((worktree_height, worktree_title))) => { + let tree_width = area.width / 2; + let worktree_width = area.width.saturating_sub(tree_width); + if tree_title <= usize::from(tree_width) && worktree_title <= usize::from(worktree_width) { + let tree_height = tree_height.min(max_height); + let worktree_height = worktree_height.min(max_height); + let height = tree_height.max(worktree_height); + ( + ChangesLayout::SideBySide, + vec![ + ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(area.x, area.bottom().saturating_sub(height), tree_width, height), + }, + ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new( + area.x.saturating_add(tree_width), + area.bottom().saturating_sub(height), + worktree_width, + height, + ), + }, + ], + height, + ) + } else { + let total = tree_height.saturating_add(worktree_height); + let (worktree_height, tree_height) = if total <= max_height { + (worktree_height, tree_height) + } else { + let half = max_height / 2; + if worktree_height <= half { + (worktree_height, max_height.saturating_sub(worktree_height)) + } else if tree_height <= half { + (max_height.saturating_sub(tree_height), tree_height) + } else { + (half.saturating_add(max_height % 2), half) + } + }; + let height = worktree_height.saturating_add(tree_height); + let tree_y = area.bottom().saturating_sub(tree_height); + ( + ChangesLayout::Stacked, + vec![ + ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new( + area.x, + tree_y.saturating_sub(worktree_height), + area.width, + worktree_height, + ), + }, + ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(area.x, tree_y, area.width, tree_height), + }, + ], + height, + ) + } + } + } +} + +pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: usize, horizontal_offset: usize) { + let [header, body, footer] = + Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); + frame.render_widget(Clear, frame.area()); + frame.render_widget( + Paragraph::new(diff.title.to_str_lossy()).style(Style::default().add_modifier(Modifier::BOLD)), + header, + ); + let mut lines = diff + .lines + .iter() + .map(|line| { + let style = if line.starts_with(b"@@") { + Style::default().fg(Color::Cyan) + } else if line.starts_with(b"+") { + Style::default().fg(Color::Green) + } else if line.starts_with(b"-") { + Style::default().fg(Color::LightRed) + } else if line.starts_with(b"Binary ") { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + Line::styled(line.to_str_lossy(), style) + }) + .collect::<Vec<_>>(); + if let Some(summary) = &diff.summary { + lines.splice(0..0, summary.iter().cloned().chain(std::iter::once(Line::default()))); + } + frame.render_widget( + Paragraph::new(Text::from(lines)).scroll(( + u16::try_from(offset).unwrap_or(u16::MAX), + u16::try_from(horizontal_offset).unwrap_or(u16::MAX), + )), + body, + ); + frame.render_widget( + Paragraph::new("↑↓/jk move · h/l pan · <enter>/q/Esc back").style(Style::default().add_modifier(Modifier::DIM)), + footer, + ); +} + +#[cfg(test)] pub(crate) fn draw( frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations, mailmap: &gix::mailmap::Snapshot, commit_message: Option<&BStr>, + tree_changes: Option<&Changes>, ) { - let [top_spacer, mut body, bottom_spacer, footer] = Layout::vertical([ - Constraint::Length(u16::from(app.inline)), - Constraint::Min(0), - Constraint::Length(u16::from(app.inline)), - Constraint::Length(1), - ]) - .areas(frame.area()); - frame.render_widget(Clear, top_spacer); - frame.render_widget(Clear, bottom_spacer); - let commit_pane = app.show_commit.then(|| { - let width = 80.min(body.width / 2); - let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(body); - body = commits; - message.inner(Margin { + draw_with_worktree(frame, app, decorations, mailmap, commit_message, tree_changes, None); +} + +pub(crate) fn draw_with_worktree( + frame: &mut Frame<'_>, + app: &mut App, + decorations: &Decorations, + mailmap: &gix::mailmap::Snapshot, + commit_message: Option<&BStr>, + tree_changes: Option<&Changes>, + worktree_changes: Option<&Changes>, +) { + let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); + let full_body = body; + let selected_segment = app.selected_is_segment(); + let time_travel_animation = app.time_travel_animation_origin().is_some(); + let compared_parent = if app.changes_visible() && !selected_segment { + tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id)) + } else { + None + }; + let tree_shown = app.changes_visible() && !selected_segment && tree_changes.is_some(); + let worktree_shown = + app.changes_visible() && app.changes_mode == Some(ChangesMode::Both) && worktree_changes.is_some(); + let tree_summary = tree_changes.map(|changes| changes_summary(ChangePane::Tree, app, changes)); + let worktree_summary = worktree_changes.map(|changes| changes_summary(ChangePane::Worktree, app, changes)); + let mut commit_pane = (app.show_commit && !selected_segment).then(|| { + let width = COMMIT_PANE_WIDTH.min(full_body.width / 2); + let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(full_body); + body.width = body.width.min(commits.width); + let content = message.inner(Margin { horizontal: 2, vertical: 1, - }) + }); + (message, content) + }); + let pane_height = |pane, changes: &Changes| { + u16::try_from(changes.paths.len()) + .unwrap_or(u16::MAX) + .saturating_add(u16::from(index_separator(pane, changes).is_some())) + .saturating_add(2) + }; + let (changes_layout, mut changes_panes, _) = changes_pane_areas( + body, + frame.area().height / 2, + tree_shown.then(|| { + ( + pane_height(ChangePane::Tree, tree_changes.expect("visible tree changes exist")), + tree_summary.as_ref().map_or(0, Line::width), + ) + }), + worktree_shown.then(|| { + ( + pane_height( + ChangePane::Worktree, + worktree_changes.expect("visible worktree changes exist"), + ), + worktree_summary.as_ref().map_or(0, Line::width), + ) + }), + ); + let notice = app.notice(); + let worktree_pane = changes_panes.iter().find(|pane| pane.pane == ChangePane::Worktree); + let notice_horizontal = worktree_pane.map_or(body, |pane| pane.outer); + let notice_bottom = worktree_pane.map_or_else( + || { + changes_panes + .iter() + .map(|pane| pane.outer.y) + .min() + .unwrap_or(body.bottom()) + }, + |pane| pane.outer.y, + ); + let mut notice_area = notice + .as_ref() + .and_then(|notice| notice_area(notice, notice_horizontal, body.y, notice_bottom)); + if let Some(notice_area) = notice_area { + body.height = notice_area.y.saturating_sub(body.y); + } + let history_changes_panes = changes_panes.clone(); + let history_notice_area = notice_area; + if let Some(changes) = worktree_changes { + app.set_worktree_conflicted(changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged)); + } + app.set_new_commit_availability( + (app.changes_mode == Some(ChangesMode::Both)) + .then_some(worktree_changes) + .flatten(), + ); + let selected_is_head = app.selected.and_then(|index| app.rows.get(index)).is_some_and(|row| { + decorations + .get(&row.id) + .is_some_and(|refs| refs.iter().any(|r| r.kind == DecorationKind::Head)) + }); + let selected_is_review = app + .selected + .and_then(|index| app.rows.get(index)) + .is_some_and(|row| row.is_review); + let selected_has_stash = app + .selected + .and_then(|index| app.rows.get(index)) + .and_then(|row| decorations.get(&row.id)) + .is_some_and(|refs| refs.iter().any(|reference| reference.kind == DecorationKind::Stash)); + let stashable = selected_is_head + && !selected_has_stash + && worktree_changes.is_some_and(|changes| { + !changes.paths.is_empty() && !changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged) + }); + let worktree_path_amend = worktree_changes.is_some_and(|changes| { + !changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged) + && changes + .paths + .get(app.worktree_changes.selected) + .is_some_and(|change| !selected_is_review || change.group == ChangeGroup::Staged) }); - app.viewport_rows = body.height as usize; - app.ensure_visible(); - let start = app.offset.min(app.rows.len()); - let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); - let visible_rows = &app.rows[start..end]; - let lanes = app.render_lanes(start..end); + app.set_head_edit_availability( + selected_is_head + && worktree_changes.is_some_and(|changes| { + if selected_is_review { + changes.paths.iter().any(|change| change.group == ChangeGroup::Staged) + } else { + !changes.paths.is_empty() + } + }), + stashable, + selected_is_head && selected_has_stash, + selected_is_head && worktree_path_amend, + worktree_changes.is_some_and(|changes| changes.paths.is_empty()), + selected_is_head && tree_changes.is_some_and(|changes| !changes.paths.is_empty()), + selected_is_head + && worktree_changes.is_some_and(|changes| { + changes.paths.iter().any(|change| change.group == ChangeGroup::Staged) + && changes.paths.iter().any(|change| change.group == ChangeGroup::Unstaged) + && !changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged) + }), + ); + if app.changes_visible() { + app.set_changes_layout( + changes_layout, + changes_panes + .iter() + .any(|pane| pane.pane == ChangePane::Tree && pane.outer.height > 0) + && tree_changes.is_some_and(Changes::is_visible), + changes_panes + .iter() + .any(|pane| pane.pane == ChangePane::Worktree && pane.outer.height > 0) + && worktree_changes.is_some_and(Changes::is_visible), + ); + } + app.viewport_rows = history_changes_panes + .iter() + .map(|pane| pane.outer.y.saturating_sub(body.y)) + .chain(history_notice_area.map(|area| area.y.saturating_sub(body.y))) + .min() + .unwrap_or(body.height) + .max(1) as usize; + app.center_initial_selection(); + app.prepare_history_viewport(); + let commands = + if app.history_display_expanded || app.actions_expanded || app.enrich_expanded || app.information_expanded { + command_menu::commands(app, decorations, app.has_verifiable_signatures()) + } else { + Vec::new() + }; + let focus_feedback = app.focus_feedback.take(); + let mut prefix_popup = active_prefix_popup( + app, + decorations, + &commands, + focus_feedback, + footer.width.saturating_sub(2) as usize, + ); + let popup_rows = prefix_popup.as_ref().map_or(0, |(_, rows)| rows.len()); + let mut prefix_popup_allowed = prefix_popup + .as_ref() + .is_some_and(|(anchor, _)| prefix_popup_can_render(frame.area(), footer, *anchor, popup_rows)); + if prefix_popup_allowed { + let popup_y = footer.y - popup_rows as u16; + let shifted_y = |area: Rect| area.y.saturating_sub(popup_rows as u16).max(full_body.y); + prefix_popup_allowed = changes_panes.iter().all(|pane| popup_y > shifted_y(pane.outer)) + && commit_pane.as_ref().is_none_or(|(outer, _)| popup_y > outer.y) + && notice_area.is_none_or(|area| popup_y.saturating_sub(shifted_y(area)) >= area.height); + if prefix_popup_allowed { + for pane in &mut changes_panes { + pane.outer.y = shifted_y(pane.outer); + pane.outer.height = pane.outer.height.min(popup_y.saturating_sub(pane.outer.y)); + } + if let Some(area) = notice_area.as_mut() { + area.y = shifted_y(*area); + area.height = area.height.min(popup_y.saturating_sub(area.y)); + } + if let Some((outer, content)) = commit_pane.as_mut() { + outer.height = outer.height.min(popup_y.saturating_sub(outer.y)); + *content = outer.inner(Margin { + horizontal: 2, + vertical: 1, + }); + } + } + } + // Keep pane content above the popup while its underlay still covers the history row beneath it. + let changes_pane_underlays = changes_panes + .iter() + .zip(&history_changes_panes) + .map(|(pane, unshifted)| { + let mut area = pane.outer; + if prefix_popup_allowed { + area.height = unshifted.outer.bottom().saturating_sub(area.y); + } + area + }) + .collect::<Vec<_>>(); + let worktree_dirty = worktree_changes.is_some_and(|changes| !changes.paths.is_empty()) + && changes_panes + .iter() + .any(|pane| pane.pane == ChangePane::Worktree && pane.outer.height > 0); + let start = app.offset.min(app.history_len()); + let render_end = start.saturating_add(body.height as usize).min(app.history_len()); + let visible_entries: Vec<_> = (start..render_end) + .filter_map(|index| app.history_entry(index)) + .collect(); + let lanes = app.render_lanes(start..render_end); + let enrichment_gutter = Line::raw(crate::enrich::marker(true, true, true)).width() as u16; + let has_duplicate_change_id = app.has_duplicate_change_ids(); + let change_id_gutter = if has_duplicate_change_id { + Line::raw("👯‍♂️").width() as u16 + } else { + 0 + }; + let has_conflict = app.has_conflict_marker(); + let conflict_gutter = if has_conflict { + Line::raw("💥").width() as u16 + } else { + 0 + }; + let status_x = body + .x + .saturating_add(enrichment_gutter) + .saturating_add(change_id_gutter) + .saturating_add(conflict_gutter); let content = Rect::new( - body.x.saturating_add(2), + status_x.saturating_add(2), body.y, - body.width.saturating_sub(2), + body.width.saturating_sub( + enrichment_gutter + .saturating_add(change_id_gutter) + .saturating_add(conflict_gutter) + .saturating_add(2), + ), body.height, ); let rendered_lane_width = lanes @@ -60,961 +648,6043 @@ pub(crate) fn draw( } else { rendered_lane_width }; - let align_limit = ((body.width as usize) / 3) - .saturating_sub(2) - .max(1) - .min(content.width as usize); - let align_width = max_lane_width.min(align_limit); - let align_metadata = app.align_metadata; - let show_committer_date = app.show_committer_date; + let alignment = app.alignment; + let date_mode = app.date_mode; + let id_mode = app.effective_id_mode(); let name_mode = app.name_mode; - let preview_author_copy = app.preview_author_copy; let copy_feedback = app.copy_feedback.take(); - let show_author_name = - preview_author_copy || copy_feedback == Some(CopyKind::Author) || name_mode != NameMode::None; + let show_author_name = copy_feedback == Some(CopyKind::Author) || name_mode != NameMode::None; let show_trailers = name_mode == NameMode::All && app.show_trailers; let ref_mode = app.ref_mode; - let selected = app.selected; - let metadata: Vec<_> = visible_rows + let selected = app.selected_history_index(); + let metadata_columns: Vec<_> = visible_entries .iter() .enumerate() - .map(|(index, row)| { - metadata_line( + .map(|(index, entry)| { + let HistoryEntry::Commit(row_index) = entry else { + return None; + }; + let row = &app.rows[*row_index]; + let row_selected = selected == Some(start + index); + let note_title = row_selected + .then(|| app.note(row.id)) + .flatten() + .map(|note| gix::objs::commit::MessageRef::from_bytes(note).title); + let mut metadata = metadata_columns( row, app.title(row), app.attributions(row), decorations, mailmap, MetadataOptions { - show_committer_date, + date_mode, + id_mode, + change_id: app.change_id(row.id), show_author_name, + show_emails: app.show_emails, show_trailers, - use_mailmap: app.use_mailmap && !preview_author_copy && copy_feedback != Some(CopyKind::Author), + has_notes: !app.notes(row.id).is_empty(), + note_title, + use_mailmap: app.use_mailmap && copy_feedback != Some(CopyKind::Author), ref_mode, - selected: selected == Some(start + index), - preview_author_copy, - copy_feedback: if selected == Some(start + index) { - copy_feedback - } else { - None - }, + selected: row_selected || compared_parent == Some(row.id), + copy_feedback: if row_selected { copy_feedback } else { None }, }, - ) + ); + if !row_selected && let Some(decorations) = decorations.get(&row.id) { + let current_head = decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Head); + let foreign_head = decorations.iter().any(|decoration| { + matches!( + decoration.kind, + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached + ) + }); + if current_head || foreign_head { + for span in &mut metadata.fields[5].spans { + span.style = if current_head { + span.style.add_modifier(Modifier::REVERSED) + } else { + span.style.bg(Color::DarkGray) + }; + } + } + } + Some(metadata) }) .collect(); - let graph_max_offset = max_lane_width.saturating_sub(align_width); - let metadata_max_offset = if align_metadata { - metadata - .iter() - .map(Line::width) - .max() - .unwrap_or_default() - .saturating_sub((content.width as usize).saturating_sub(align_width)) - } else { - 0 - }; - let max_offset = if align_metadata { - graph_max_offset.saturating_add(metadata_max_offset) - } else { - lanes - .iter() - .zip(&metadata) - .map(|(lane, metadata)| lane.chars().count().saturating_add(metadata.width())) - .max() - .unwrap_or_default() - .saturating_sub(content.width as usize) - } - .min(u16::MAX as usize); + let title_column = lanes + .iter() + .zip(&metadata_columns) + .filter_map(|(lane, metadata)| { + metadata + .as_ref() + .map(|metadata| lane.chars().count().saturating_add(metadata.prefix_width())) + }) + .max() + .unwrap_or_default(); + let column_widths = metadata_columns.iter().flatten().fold([0; 5], |mut widths, metadata| { + for (width, field) in widths.iter_mut().zip(&metadata.fields[..5]) { + *width = (*width).max(field.width()); + } + widths + }); + let metadata: Vec<_> = metadata_columns + .into_iter() + .enumerate() + .map(|(index, metadata)| { + metadata.map(|metadata| match alignment { + HistoryAlignment::None => { + let (metadata, prefix_width) = metadata.into_line_with_prefix(); + (metadata, 0, prefix_width) + } + HistoryAlignment::Title | HistoryAlignment::Compressed => { + let lane_width = lanes.lane(index).chars().count(); + let (metadata, prefix_width) = metadata.align_title(title_column.saturating_sub(lane_width)); + (metadata, lane_width, prefix_width) + } + HistoryAlignment::Columns => { + let (metadata, prefix_width) = metadata.align_columns(column_widths); + (metadata, max_lane_width, prefix_width) + } + }) + }) + .collect(); + let max_offset = visible_entries + .iter() + .enumerate() + .map(|(index, entry)| match (entry, &metadata[index]) { + (HistoryEntry::Segment { count, .. }, _) => lanes + .lane(index) + .chars() + .count() + .saturating_add(format!("[{count}]").chars().count()), + (HistoryEntry::Commit(_), Some((metadata, metadata_x, _))) => match alignment { + HistoryAlignment::None => lanes.lane(index).chars().count().saturating_add(metadata.width()), + HistoryAlignment::Title | HistoryAlignment::Columns | HistoryAlignment::Compressed => { + metadata_x.saturating_add(metadata.width()) + } + }, + (HistoryEntry::Commit(_), None) => 0, + }) + .max() + .unwrap_or_default() + .saturating_sub(content.width as usize) + .min(u16::MAX as usize); let horizontal_offset = app.horizontal_offset.min(max_offset); - let graph_offset = horizontal_offset.min(graph_max_offset); - let metadata_offset = horizontal_offset.saturating_sub(graph_max_offset); + let selection_info = selection_info_line( + (!time_travel_animation && !selected_segment && app.changes_visible()) + .then_some(tree_changes) + .flatten() + .filter(|changes| changes.is_visible()), + (!time_travel_animation && !selected_segment) + .then_some(app.selection_relation) + .flatten(), + app.topological_choice(), + ); + let selection_info_width = selection_info.width(); + let mut selection_info_area = None; for (index, metadata) in metadata.into_iter().enumerate() { let lane = lanes.lane(index); let y = body.y.saturating_add(index as u16); - let selected = app.selected == Some(start + index); + let row_area = Rect::new(content.x, y, content.width, 1); + let row_index = match visible_entries[index] { + HistoryEntry::Commit(index) => index, + HistoryEntry::Segment { count, .. } => { + let selected = selected_segment && selected == Some(start + index); + let selectable = app.history_entry_selectable(start + index); + let style = if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + if selected { + frame.render_widget( + Paragraph::new(Line::styled("> ", style)), + Rect::new(status_x, y, body.right().saturating_sub(status_x).min(2), 1), + ); + } + frame.render_widget( + Paragraph::new(Line::styled(format!("{lane}[{count}]"), style)) + .scroll((0, horizontal_offset as u16)), + row_area, + ); + color_graph( + frame, + row_area, + lane, + horizontal_offset, + selected.then_some(Color::Reset), + SignatureState::Unsigned, + None, + ); + if !selectable { + frame.buffer_mut().set_style( + Rect::new(body.x, y, body.width, 1), + Style::default().add_modifier(Modifier::DIM), + ); + } + continue; + } + }; + let row = &app.rows[row_index]; + let (metadata, metadata_x, metadata_prefix_width) = metadata.expect("commit history entries have metadata"); + let selected = app.selected == Some(row_index); + let head = decorations.get(&row.id).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Head) + }); + let attached_head = decorations.get(&row.id).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::CurrentWorktreeBranch) + }); + let head_has_descendants = app.worktree_head_has_descendants(row.id); + let head_state = head.then_some(HeadState { + has_descendants: head_has_descendants, + attached: attached_head, + }); let metadata_width = metadata.width(); - let style = if selected { - Style::default().add_modifier(Modifier::REVERSED) + let title_offset = match alignment { + HistoryAlignment::None => lane.chars().count().saturating_add(metadata_prefix_width), + HistoryAlignment::Title | HistoryAlignment::Columns | HistoryAlignment::Compressed => { + metadata_x.saturating_add(metadata_prefix_width) + } + }; + let hidden_branch_behind = app.hidden_branch_behind(row.id); + let line_width = match alignment { + HistoryAlignment::None => lane + .chars() + .count() + .saturating_add(metadata_width) + .saturating_sub(horizontal_offset), + HistoryAlignment::Title | HistoryAlignment::Columns | HistoryAlignment::Compressed => metadata_x + .saturating_add(metadata_width) + .saturating_sub(horizontal_offset), + }; + let hidden_branch_marker = hidden_branch_behind.and_then(|behind| { + let marker = format!("⇣{behind}"); + let width = marker.chars().count() as u16; + (body.width > width.saturating_add(2)).then(|| { + let natural_x = content + .x + .saturating_add(u16::try_from(line_width).unwrap_or(u16::MAX)) + .saturating_add(1) + .saturating_add(if selected && selection_info_width > 0 { + u16::try_from(selection_info_width) + .unwrap_or(u16::MAX) + .saturating_add(3) + } else { + 0 + }); + let x = natural_x.min(body.right().saturating_sub(width).saturating_sub(1)); + (marker, x, width) + }) + }); + let signature_color = signature_color(row.signature); + let highlight = if selected { + Some(signature_color) + } else if compared_parent == Some(row.id) { + Some(COMPARED_PARENT_COLOR) } else { - Style::default() + None }; + let style = highlight.map_or_else(Style::default, |highlight| { + color(highlight).add_modifier(Modifier::REVERSED) + }); + let conflict = app.conflict_marker(row.id, head); frame.render_widget( - Paragraph::new(if selected { "> " } else { " " }).style(style), - Rect::new(body.x, y, body.width.min(2), 1), + Paragraph::new(Line::from(vec![ + Span::raw(if app.todo(row.id) { "🚧" } else { " " }), + Span::raw(if app.note(row.id).is_some() { "📝" } else { " " }), + Span::raw(if app.checks_pass(row.id) { "✔️" } else { " " }), + ])), + Rect::new(body.x, y, enrichment_gutter, 1), ); - - let row_area = Rect::new(content.x, y, content.width, 1); - if align_metadata { + if app.has_duplicate_change_id(row.id) { frame.render_widget( - Paragraph::new(lane).style(style).scroll((0, graph_offset as u16)), - row_area, - ); - color_graph(frame, row_area, lane, graph_offset, selected); - let aligned = Rect::new( - content.x.saturating_add(align_width as u16), - y, - content.width.saturating_sub(align_width as u16), - 1, + Paragraph::new("👯‍♂️"), + Rect::new(body.x.saturating_add(enrichment_gutter), y, change_id_gutter, 1), ); - frame.render_widget(Clear, aligned); - frame.render_widget(Paragraph::new(metadata).scroll((0, metadata_offset as u16)), aligned); - } else { - let mut spans = Vec::with_capacity(metadata.spans.len() + 1); - spans.push(Span::styled(lane, style)); - spans.extend(metadata.spans); + } + if conflict { frame.render_widget( - Paragraph::new(Line::from(spans)).scroll((0, horizontal_offset as u16)), - row_area, + Paragraph::new("💥").style(color(Color::LightRed)), + Rect::new( + body.x + .saturating_add(enrichment_gutter) + .saturating_add(change_id_gutter), + y, + conflict_gutter, + 1, + ), ); - color_graph(frame, row_area, lane, horizontal_offset, selected); } - if selected && app.show_selection_tail && body.width > 0 { - let line_width = if align_metadata { - align_width.saturating_add(metadata_width.saturating_sub(metadata_offset)) + frame.render_widget( + Paragraph::new(if head && worktree_dirty { + "🫟" + } else if selected { + "> " } else { - lane.chars() - .count() - .saturating_add(metadata_width) - .saturating_sub(horizontal_offset) - }; + " " + }) + .style(if selected { style } else { Style::default() }), + Rect::new(status_x, y, body.right().saturating_sub(status_x).min(2), 1), + ); + + let mut spans = Vec::with_capacity(metadata.spans.len() + 2); + spans.push(Span::styled(lane, style)); + if alignment != HistoryAlignment::None { + spans.push(Span::raw(" ".repeat(metadata_x.saturating_sub(lane.chars().count())))); + } + spans.extend(metadata.spans); + frame.render_widget( + Paragraph::new(Line::from(spans)).scroll((0, horizontal_offset as u16)), + row_area, + ); + color_graph( + frame, + row_area, + lane, + horizontal_offset, + highlight, + row.signature, + head_state, + ); + if selected { + let end = if head { + content.x.saturating_add(u16::try_from(line_width).unwrap_or(u16::MAX)) + } else if title_offset > horizontal_offset { + content + .x + .saturating_add(u16::try_from(title_offset - horizontal_offset).unwrap_or(u16::MAX)) + .saturating_sub(1) + } else { + content.x + } + .min(body.right()); + frame.buffer_mut().set_style( + Rect::new(body.x, y, end.saturating_sub(body.x), 1), + Style::default().add_modifier(Modifier::REVERSED), + ); + } + if selected && body.width > 0 { + let marker_limit = hidden_branch_marker + .as_ref() + .map_or_else(|| body.right().saturating_sub(1), |(_, x, _)| x.saturating_sub(2)); let marker_x = content .x .saturating_add(u16::try_from(line_width).unwrap_or(u16::MAX)) .saturating_add(1) - .min(body.right().saturating_sub(1)); - frame.buffer_mut()[(marker_x, y)].set_style(style); + .saturating_add(u16::try_from(selection_info_width).unwrap_or(u16::MAX)) + .saturating_add(1) + .min(marker_limit); + if selection_info_width > 0 { + let width = u16::try_from(selection_info_width) + .unwrap_or(u16::MAX) + .min(marker_x.saturating_sub(content.x).saturating_sub(2)); + let area = Rect::new(marker_x.saturating_sub(width).saturating_sub(1), y, width, 1); + if width > 0 { + frame.buffer_mut()[(area.x - 1, y)] + .set_symbol(" ") + .set_style(Style::default().remove_modifier(Modifier::DIM | Modifier::REVERSED)); + frame.render_widget( + Paragraph::new(selection_info.clone()) + .style(Style::default().remove_modifier(Modifier::REVERSED)), + area, + ); + selection_info_area = Some(area); + } + } + let buffer = frame.buffer_mut(); + if marker_x > body.x { + buffer[(marker_x - 1, y)] + .set_symbol(" ") + .set_style(Style::default().remove_modifier(Modifier::REVERSED)); + } + buffer[(marker_x, y)].set_symbol(" ").set_style(style); + } + if !app.is_row_reachable(row_index) { + for x in body.x..body.right() { + frame.buffer_mut()[(x, y)].set_style(Style::default().add_modifier(Modifier::DIM)); + } + } + if app.is_row_hidden(row_index) { + for x in body.x..body.right() { + frame.buffer_mut()[(x, y)] + .set_fg(Color::Reset) + .set_bg(Color::Reset) + .set_style(Style::default().add_modifier(Modifier::DIM)); + } + if selected && let Some(area) = selection_info_area { + frame.render_widget( + Paragraph::new(selection_info.clone()).style(Style::default().remove_modifier(Modifier::REVERSED)), + area, + ); + } + } + if let Some((marker, x, width)) = hidden_branch_marker { + frame.buffer_mut()[(x - 1, y)].set_symbol(" "); + frame.render_widget( + Paragraph::new(marker).style(color(Color::LightRed)), + Rect::new(x, y, width, 1), + ); + frame.buffer_mut()[(x + width, y)].set_symbol(" "); + for marker_x in x..x + width { + let cell = &mut frame.buffer_mut()[(marker_x, y)]; + cell.set_fg(Color::LightRed); + cell.modifier.remove(Modifier::DIM | Modifier::REVERSED); + } } } app.set_horizontal_bounds(content.width as usize, max_offset); - if let (Some(area), Some(message)) = (commit_pane, commit_message) { - render_commit_message(frame, area, message); + if app.changes_focus.is_some() { + frame + .buffer_mut() + .set_style(body, Style::default().add_modifier(Modifier::DIM)); + if let Some(area) = selection_info_area { + frame.render_widget( + Paragraph::new(selection_info).style(Style::default().remove_modifier(Modifier::REVERSED)), + area, + ); + } + } + for (pane_area, underlay) in changes_panes.iter().zip(&changes_pane_underlays) { + let outer = pane_area.outer; + let pane = pane_area.pane; + let changes = match pane { + ChangePane::Tree => tree_changes.expect("visible tree changes exist"), + ChangePane::Worktree => worktree_changes.expect("visible worktree changes exist"), + }; + let summary = match pane { + ChangePane::Tree => tree_summary.clone().expect("visible tree summary exists"), + ChangePane::Worktree => worktree_summary.clone().expect("visible worktree summary exists"), + }; + let area = outer.inner(Margin { + horizontal: 2, + vertical: 1, + }); + frame.render_widget(Clear, *underlay); + frame.render_widget(Block::new().borders(Borders::TOP).title(summary), outer); + render_changes(frame, area, changes, pane, app); + if app.changes_focus == Some(pane) { + let status = Rect::new( + outer.x.saturating_add(2), + outer.bottom().saturating_sub(1), + outer.width.saturating_sub(4), + 1, + ); + let mut spans = Vec::new(); + if pane == ChangePane::Tree + && let Some(parent) = changes.parent + { + spans.extend([ + Span::styled( + format!( + "vs parent {}/{} {}", + parent.index + 1, + parent.total, + parent.id.to_hex_with_len(7) + ), + color(COMPARED_PARENT_COLOR), + ), + Span::raw(" · "), + ]); + spans.extend(shortcut("P next parent", 'P', true)); + spans.push(Span::raw(" · ")); + } + if let Some(error) = &app.changes(pane).error { + spans.push(Span::styled(format!("diff: {error}"), color(Color::LightRed))); + } else { + spans.push(Span::raw("↑↓/jk move · h/l pan · <enter> diff")); + } + spans.push(Span::raw(" · ")); + spans.extend(shortcut("copy", 'y', true)); + if let Some(label) = match app.changes_mode { + Some(ChangesMode::Both) => Some("cycle tree"), + Some(ChangesMode::Tree) => Some("close"), + None => None, + } { + spans.push(Span::raw(" · ")); + spans.extend(shortcut(label, 'e', true)); + } + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(PANE_STATUS_BACKGROUND)), + status, + ); + } else { + frame + .buffer_mut() + .set_style(outer, Style::default().add_modifier(Modifier::DIM)); + } + } + if changes_layout == ChangesLayout::SideBySide { + render_changes_divider(frame, &changes_panes, app); + } + if let Some((outer, area)) = commit_pane { + frame.render_widget(Clear, outer); + if let Some((red, green, blue)) = app.commit_pane_background { + frame + .buffer_mut() + .set_style(outer, Style::default().bg(Color::Rgb(red, green, blue))); + } + let max_offset = if let Some(message) = commit_message { + let selected = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); + let notes = selected.map(|id| app.notes(id)).unwrap_or_default(); + let note = selected.and_then(|id| app.note(id)); + render_commit_message(frame, area, message, note, notes, app.commit_offset) + } else { + 0 + }; + app.set_commit_bounds(area.height as usize, max_offset); + if max_offset > 0 { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("PgUp/C-b up page · PgDn/C-f down page · "), + Span::styled("m", Style::default().add_modifier(Modifier::UNDERLINED)), + Span::raw(" close"), + ])) + .style(Style::default().bg(PANE_STATUS_BACKGROUND)), + Rect::new( + outer.x.saturating_add(2), + outer.bottom().saturating_sub(1), + outer.width.saturating_sub(4), + 1, + ), + ); + } } - let status = match app.state { + let history_state = app.deferred_history_state.unwrap_or(app.state); + let status = match history_state { State::Loading => "", State::Cancelling => " · cancelling", State::Computing => " · computing", State::Complete => "", State::Cancelled => " · cancelled", }; - let mut footer_spans = vec![Span::raw(format!( - "{} commits{status} · ↑↓/jk move · h/l pan", - app.rows.len() - ))]; - footer_spans.extend([Span::raw(" · "), toggle("[ align", app.align_metadata)]); - footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); - if app.has_hidden_filter { - footer_spans.extend([ - Span::raw(" · "), - toggle( - if app.show_hidden { - "v hide hidden" - } else { - "v show hidden" - }, - app.show_hidden, - ), - ]); + let mut footer_spans = vec![Span::raw(status)]; + let mut time_travel = None; + let mut actions_prefix_spans = Vec::new(); + if !selected_segment && (app.changes_focus != Some(ChangePane::Worktree) || app.can_amend()) { + time_travel = time_travel_label(app, decorations); + actions_prefix_spans.push(Span::raw(" · ")); + actions_prefix_spans.push(Span::styled("a", Style::default().add_modifier(Modifier::UNDERLINED))); + actions_prefix_spans.push(Span::raw("ctions")); + if app.actions_expanded { + emphasize_prefix(&mut actions_prefix_spans[1..]); + } } - footer_spans.extend([Span::raw(" · "), toggle("d date", app.show_committer_date)]); - let (name_label, names_visible) = match app.name_mode { - NameMode::All => ("n names", true), - NameMode::Author => ("n name", true), - NameMode::None => ("n name", false), - }; - footer_spans.extend([Span::raw(" · "), toggle(name_label, names_visible)]); - for (label, enabled) in [("m mailmap", app.use_mailmap), ("t trailers", app.show_trailers)] { - footer_spans.extend([Span::raw(" · "), toggle(label, enabled)]); - } - let ref_label = match app.ref_mode { - RefMode::All => "r all refs", - RefMode::Default => "r refs", - RefMode::None => "r no refs", - }; - footer_spans.extend([Span::raw(" · "), toggle(ref_label, app.ref_mode != RefMode::None)]); - footer_spans.push(Span::raw(if app.preview_author_copy { - " · Y copy author" - } else { - " · y copy" - })); - if app.state == State::Loading { + if app.changes_focus.is_some() { + footer_spans.push(Span::raw(" · q/Esc history")); + } + let mut view_prefix_spans = Vec::new(); + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.push(Span::styled("v", Style::default().add_modifier(Modifier::UNDERLINED))); + view_prefix_spans.push(Span::raw("iew")); + if app.history_display_expanded { + emphasize_prefix(&mut view_prefix_spans[1..]); + } + let mut ordered = vec![Span::raw(history_position(app)), Span::raw(" · ")]; + ordered.extend(shortcut("p command", 'p', true)); + if selected_segment { + ordered.push(Span::raw(" · <enter> expand")); + } + ordered.append(&mut view_prefix_spans); + ordered.append(&mut actions_prefix_spans); + if !selected_segment { + ordered.push(Span::raw(" · ")); + let enrich_prefix_start = ordered.len(); + ordered.extend(shortcut("enrich", 'n', true)); + if app.enrich_expanded { + emphasize_prefix(&mut ordered[enrich_prefix_start..]); + } + } + if let Some(label) = time_travel { + ordered.push(Span::raw(" · ")); + ordered.extend(shortcut(label, '@', true)); + } + if app.can_cycle_duplicate() { + ordered.push(Span::raw(" · ")); + ordered.extend(shortcut("next duplicate", 'x', true)); + } + if !selected_segment { + ordered.push(Span::raw(" · ")); + ordered.extend(shortcut("copy", 'y', true)); + } + ordered.push(Span::raw(" · ")); + ordered.extend(shortcut("refs", 'r', app.ref_mode != RefMode::None)); + ordered.push(Span::raw(" · ")); + let information_prefix_start = ordered.len(); + ordered.push(Span::styled("?", Style::default().add_modifier(Modifier::UNDERLINED))); + if app.information_expanded { + emphasize_prefix(&mut ordered[information_prefix_start..]); + } + ordered.append(&mut footer_spans); + footer_spans = ordered; + if app.changes_focus.is_none() && history_state == State::Loading { footer_spans.push(Span::raw(" · Esc cancel")); } - footer_spans.push(Span::raw(" · q quit")); + if app.changes_focus.is_none() { + footer_spans.push(Span::raw(" · ")); + footer_spans.extend(shortcut("quit", 'q', true)); + } + if app.unseen_filesystem_redraw { + footer_spans = notification_discs(footer_spans); + if let Some((_, rows)) = prefix_popup.as_mut() { + for items in rows { + *items = notification_discs(std::mem::take(items)); + } + } + } frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); + if let (Some(area), Some(notice)) = (notice_area, notice.as_ref()) { + render_notice(frame, area, notice); + if let Some((applied, total, _)) = app.undo_position() { + render_undo_progress(frame, area, notice.kind, applied, total); + } + } + let _ = prefix_popup + .filter(|_| prefix_popup_allowed) + .and_then(|(anchor, items)| render_prefix_popup(frame, footer, anchor, items)); } -fn render_commit_message(frame: &mut Frame<'_>, area: Rect, message: &BStr) { - let parsed = gix::objs::commit::MessageRef::from_bytes(message); - let Some(body) = parsed.body() else { - frame.render_widget( - Paragraph::new(commit_text(parsed.title, None)).wrap(Wrap { trim: false }), - area, - ); - return; +fn render_undo_progress(frame: &mut Frame<'_>, area: Rect, kind: NoticeKind, applied: usize, total: usize) { + let bright_width = if total == 0 { + 0 + } else { + (u128::from(area.width) * applied.min(total) as u128 / total as u128) as u16 }; - let mut body_message = BString::default(); - let mut trailers = Vec::new(); - for block in body.message_blocks() { - body_message.extend_from_slice(block.message); - trailers.extend(block.trailers()); + let (bright, dim) = match kind { + NoticeKind::Success => (Color::LightGreen, Color::Green), + NoticeKind::Attention => (Color::LightYellow, Color::Yellow), + NoticeKind::Error => (Color::LightRed, Color::Red), + }; + frame.buffer_mut().set_style( + Rect::new(area.x, area.y, bright_width, area.height), + Style::default().bg(bright), + ); + frame.buffer_mut().set_style( + Rect::new( + area.x.saturating_add(bright_width), + area.y, + area.width.saturating_sub(bright_width), + area.height, + ), + Style::default().bg(dim), + ); +} + +fn history_position(app: &App) -> String { + if let (State::Complete, Some(selected)) = (app.deferred_history_state.unwrap_or(app.state), app.selected) + && let Some(count) = app.visual_count(selected) + { + format!("#{count}") + } else { + format!("{} commits", app.rows.len()) } - if trailers.is_empty() || area.width < 3 { - frame.render_widget( - Paragraph::new(commit_text(parsed.title, parsed.body)).wrap(Wrap { trim: false }), - area, - ); - return; +} + +fn time_travel_label(app: &App, decorations: &Decorations) -> Option<&'static str> { + if !app.time_travel_shortcut_visible() + || !decorations + .values() + .flatten() + .any(|decoration| decoration.kind == DecorationKind::Head) + { + return None; } - let key_width = trailers + let selected = app.selected.and_then(|index| app.rows.get(index))?; + let selected_refs = decorations.get(&selected.id).map(Vec::as_slice).unwrap_or_default(); + if selected_refs .iter() - .map(|trailer| Line::raw(trailer.token.to_str_lossy()).width()) - .max() - .unwrap_or_default(); - if key_width > area.width.saturating_sub(3) as usize { - frame.render_widget( - Paragraph::new(commit_text(parsed.title, parsed.body)).wrap(Wrap { trim: false }), - area, - ); - return; + .any(|decoration| decoration.kind == DecorationKind::Head) + { + None + } else if selected_refs + .iter() + .any(|decoration| decoration.kind == DecorationKind::Pin) + { + Some("@ return") + } else { + Some("@ travel") } - let key_width = key_width as u16; +} - let body_message = body_message.trim_end().as_bstr(); - let text = commit_text(parsed.title, (!body_message.is_empty()).then_some(body_message)); - let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); - let mut y = area - .y - .saturating_add(u16::try_from(paragraph.line_count(area.width)).unwrap_or(u16::MAX)) - .saturating_add(1); - frame.render_widget(paragraph, area); +fn active_prefix_popup_anchor(app: &App, decorations: &Decorations) -> Option<usize> { + let mut width = history_position(app).chars().count(); + width += 3 + "p command".len(); + let selected_segment = app.selected_is_segment(); + if selected_segment { + width += " · <enter> expand".chars().count(); + } + width += 3; + let view = width; + width += "view".len(); + let mut active = app.history_display_expanded.then_some(view); - let value_x = area.x.saturating_add(key_width).saturating_add(2); - let value_width = area.right().saturating_sub(value_x); - for trailer in trailers { - if y >= area.bottom() { - break; + let actions_visible = !selected_segment && (app.changes_focus != Some(ChangePane::Worktree) || app.can_amend()); + if actions_visible { + width += 3; + let actions = width; + width += "actions".len(); + if app.actions_expanded { + active = Some(actions); } - let value = Paragraph::new(trailer.value.to_str_lossy()).wrap(Wrap { trim: false }); - let height = u16::try_from(value.line_count(value_width)) - .unwrap_or(u16::MAX) - .max(1) - .min(area.bottom().saturating_sub(y)); - frame.render_widget( - Paragraph::new(format!("{}:", trailer.token.to_str_lossy())) - .style(color(Color::Green)) - .right_aligned(), - Rect::new(area.x, y, key_width.saturating_add(1), 1), - ); - frame.render_widget(value, Rect::new(value_x, y, value_width, height)); - y = y.saturating_add(height); } -} - -fn commit_text<'a>(title: &'a BStr, body: Option<&'a BStr>) -> Text<'a> { - let mut text = Text::raw(title.to_str_lossy()); - for line in &mut text.lines { - line.style = Style::default().add_modifier(Modifier::BOLD); + if !selected_segment { + width += 3; + let enrich = width; + width += "enrich".len(); + if app.enrich_expanded { + active = Some(enrich); + } } - if let Some(body) = body.filter(|body| !body.is_empty()) { - text.lines.push(Line::default()); - text.lines.extend(Text::raw(body.to_str_lossy()).lines); + if actions_visible && let Some(label) = time_travel_label(app, decorations) { + width += 3 + label.len(); } - text -} - -fn toggle(label: &'static str, enabled: bool) -> Span<'static> { - Span::styled( - label, - if enabled { - Style::default() - } else { - Style::default().add_modifier(Modifier::DIM) - }, - ) -} - -#[derive(Clone, Copy)] -struct MetadataOptions { - show_committer_date: bool, - show_author_name: bool, - show_trailers: bool, - use_mailmap: bool, - ref_mode: RefMode, - selected: bool, - preview_author_copy: bool, - copy_feedback: Option<CopyKind>, + if app.can_cycle_duplicate() { + width += 3 + "next duplicate".len(); + } + if !selected_segment { + width += 3 + "copy".len(); + } + width += 3 + "refs".len(); + width += 3; + if app.information_expanded { + active = Some(width); + } + active } -fn metadata_line<'a>( - row: &'a CommitRow, - title: &'a BStr, - attributions: &'a [crate::app::Attribution], - decorations: &'a Decorations, - mailmap: &'a gix::mailmap::Snapshot, - options: MetadataOptions, -) -> Line<'a> { - debug_assert!(row.metadata_loaded, "visible rows have metadata"); - let MetadataOptions { - show_committer_date, - show_author_name, - show_trailers, - use_mailmap, - ref_mode, - selected, - preview_author_copy, - copy_feedback, - } = options; - let id = row.id.to_hex().to_string(); - let id_style = if preview_author_copy || copy_feedback == Some(CopyKind::Id) { - Style::default() +fn render_changes_divider(frame: &mut Frame<'_>, panes: &[ChangesPaneArea], app: &App) { + let Some(tree) = panes.iter().find(|pane| pane.pane == ChangePane::Tree) else { + return; + }; + let Some(worktree) = panes.iter().find(|pane| pane.pane == ChangePane::Worktree) else { + return; + }; + let x = worktree.outer.x; + let top = tree.outer.y.min(worktree.outer.y); + let bottom = tree.outer.bottom().max(worktree.outer.bottom()); + let style = if app.changes_focus.is_none() { + Style::default().add_modifier(Modifier::DIM) } else { - color(Color::Magenta).add_modifier(Modifier::BOLD) + Style::default() }; - let mut spans = vec![Span::styled( - id[..7].to_owned(), - if selected { - id_style.add_modifier(Modifier::REVERSED) + for y in top..bottom { + let symbol = if tree.outer.y == worktree.outer.y && y == tree.outer.y { + "┬" + } else if y == tree.outer.y { + if tree.outer.y < worktree.outer.y { "┐" } else { "┤" } + } else if y == worktree.outer.y { + if worktree.outer.y < tree.outer.y { "┌" } else { "├" } } else { - id_style - }, - )]; - let mut labels = decorations - .get(&row.id) - .into_iter() - .flatten() - .filter(|decoration| match ref_mode { - RefMode::All => true, - RefMode::Default => decoration.kind != DecorationKind::Special, - RefMode::None => false, - }) - .peekable(); - if labels.peek().is_some() { - spans.push(Span::raw(" (")); - for (index, decoration) in labels.enumerate() { - if index != 0 { - spans.push(Span::raw(", ")); - } - spans.push(Span::styled( - decoration.name.to_str_lossy(), - decoration_style(decoration.kind), - )); - } - spans.push(Span::raw(") ")); - } else { - spans.push(Span::raw(" ")); + "│" + }; + frame.buffer_mut()[(x, y)].set_symbol(symbol).set_style(style); } - if show_committer_date { - spans.push(Span::styled( - format!("{} ", row.committer_time.format_or_unix(gix::date::time::format::SHORT)), - color(Color::Blue), - )); +} + +fn selection_info_line( + changes: Option<&Changes>, + relation: Option<SelectionRelation>, + topological_choice: Option<(usize, usize)>, +) -> Line<'static> { + let mut spans = Vec::new(); + if let Some((choice, total)) = topological_choice { + push_selection_span( + &mut spans, + Span::styled( + format!("{choice}/{total}"), + selection_color(Color::Yellow).add_modifier(Modifier::BOLD), + ), + ); } - if show_author_name { - let author = author_name(row.author, mailmap, use_mailmap).to_str_lossy(); - let author_style = if copy_feedback == Some(CopyKind::Author) { - Style::default() - } else if preview_author_copy { - color(Color::Magenta).add_modifier(Modifier::BOLD) - } else { - color(Color::Green) - }; - spans.push(Span::styled( - if row.author.is_bot() { - format!("[{author}] ") - } else { - format!("{author} ") - }, - author_style, - )); - if show_trailers { - for (kind, marker) in [ - (AttributionKind::CoAuthor, "Co: "), - (AttributionKind::Assisted, "As: "), - (AttributionKind::Reviewed, "Re: "), - (AttributionKind::Acked, "Ack: "), - (AttributionKind::Tested, "Te: "), - (AttributionKind::SignedOff, "So: "), - ] { - let mut actors = attributions.iter().filter(|actor| actor.kind == kind).peekable(); - if actors.peek().is_none() { - continue; - } - spans.push(Span::styled(marker, color(Color::Green).add_modifier(Modifier::DIM))); - for (index, actor) in actors.enumerate() { - if index != 0 { - spans.push(Span::raw(", ")); - } - let name = if actor.author == row.author { - "*".to_owned() - } else { - let name = author_name(actor.author, mailmap, use_mailmap).to_str_lossy(); - if actor.is_agent() { - format!("[{name}]") - } else { - name.into_owned() - } - }; - spans.push(Span::styled(name, color(Color::Green))); + if let Some(changes) = changes { + if changes.lines_added > 0 { + push_selection_span( + &mut spans, + Span::styled(format!("+{}", changes.lines_added), selection_color(Color::Green)), + ); + } + if changes.lines_removed > 0 { + push_selection_span( + &mut spans, + Span::styled(format!("-{}", changes.lines_removed), selection_color(Color::LightRed)), + ); + } + } + match relation { + Some(SelectionRelation::Tracking { ahead, behind }) => { + if ahead > 0 { + push_selection_span( + &mut spans, + Span::styled(format!("⇡{ahead}"), selection_color(Color::Green)), + ); + } + if behind > 0 { + if ahead == 0 { + push_selection_span( + &mut spans, + Span::styled(format!("⇣{behind}"), selection_color(Color::LightRed)), + ); + } else { + spans.push(Span::styled(format!("⇣{behind}"), selection_color(Color::LightRed))); } - spans.push(Span::raw(" ")); } } + Some(SelectionRelation::Visible(commits)) => { + push_selection_span( + &mut spans, + Span::styled(format!("⇡{commits}"), selection_color(Color::Green)), + ); + } + None => {} } - spans.push(Span::raw(title.to_str_lossy())); Line::from(spans) } -fn author_name<'a>(author: &'a crate::app::Author, mailmap: &'a gix::mailmap::Snapshot, use_mailmap: bool) -> &'a BStr { - if use_mailmap { - mailmap - .try_resolve_ref(gix::actor::SignatureRef { - name: author.name, - email: author.email, - time: "", - }) - .and_then(|resolved| resolved.name) - .unwrap_or(author.name) - } else { - author.name - } +fn selection_color(color: Color) -> Style { + Style::default() + .fg(color) + .remove_modifier(Modifier::DIM | Modifier::REVERSED) } -fn decoration_style(kind: DecorationKind) -> Style { - match kind { - DecorationKind::Head => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - DecorationKind::Local => Style::default().fg(Color::Cyan), - DecorationKind::Remote => Style::default().fg(Color::Yellow), - DecorationKind::Tag => Style::default().fg(Color::Magenta), - DecorationKind::AnnotatedTag => Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), - DecorationKind::Special => Style::default().fg(Color::Blue), +fn push_selection_span(spans: &mut Vec<Span<'static>>, span: Span<'static>) { + if !spans.is_empty() { + spans.push(Span::raw(" ")); } + spans.push(span); } -fn color(color: Color) -> Style { - Style::default().fg(color) +fn index_divider(width: u16) -> Line<'static> { + const LABEL: &str = "↑ index ↑ "; + let width = usize::from(width); + let label: String = LABEL.chars().take(width).collect(); + let rail_width = width - label.chars().count(); + Line::from(vec![ + Span::styled(label, Style::default().add_modifier(Modifier::DIM)), + Span::styled("─".repeat(rail_width), color(Color::Green)), + ]) } -fn color_graph(frame: &mut Frame<'_>, area: Rect, graph: &str, offset: usize, selected: bool) { - for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() { - if symbol.is_whitespace() { - continue; - } - let mut style = if symbol == '●' { - Style::default().fg(Color::Blue) +fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, pane: ChangePane, app: &mut App) { + if area.height == 0 { + app.set_changes_bounds(pane, 0, 0, None, area.width as usize, 0); + return; + } + let focused = app.changes_focus == Some(pane); + let selected_index = app.changes(pane).selected.min(changes.paths.len().saturating_sub(1)); + let separator = index_separator(pane, changes); + let display_len = changes.paths.len() + usize::from(separator.is_some()); + let path_capacity = usize::from(area.height); + let overflow = display_len > 1 && display_len > path_capacity; + let visible_rows = if overflow { + path_capacity.saturating_sub(1) + } else { + path_capacity.min(display_len) + }; + let mut lines: Vec<_> = changes + .paths + .iter() + .enumerate() + .map(|(index, change)| { + let selected = focused && index == selected_index; + let path_style = if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let mut spans = vec![ + Span::styled(change.kind.letter().to_string(), color(path_change_color(change))), + Span::raw(" "), + ]; + if let Some(source) = &change.source { + spans.extend([ + Span::styled(source.to_str_lossy(), path_style), + Span::styled(" -> ", path_style), + Span::styled(change.path.to_str_lossy(), path_style), + ]); + } else { + spans.push(Span::styled(change.path.to_str_lossy(), path_style)); + } + if selected && let Some((insertions, removals)) = change.lines { + if insertions > 0 { + spans.extend([ + Span::raw(" "), + Span::styled(format!("+{insertions}"), color(Color::Green)), + ]); + } + if removals > 0 { + spans.extend([ + Span::raw(" "), + Span::styled(format!("-{removals}"), color(Color::LightRed)), + ]); + } + } + Line::from(spans) + }) + .collect(); + if let Some(separator) = separator { + lines.insert(separator, index_divider(area.width)); + } + let horizontal_max = lines + .iter() + .map(Line::width) + .max() + .unwrap_or_default() + .saturating_sub(area.width as usize); + app.set_changes_bounds( + pane, + visible_rows, + changes.paths.len(), + separator, + area.width as usize, + horizontal_max, + ); + let offset = app.changes(pane).offset; + let horizontal_offset = app.changes(pane).horizontal_offset; + for (row, line) in lines.into_iter().skip(offset).take(visible_rows).enumerate() { + let display_index = offset + row; + let horizontal_offset = if separator == Some(display_index) { + 0 } else { - graph_style(offset.saturating_add(x) / 2) + horizontal_offset }; - if selected { - style = style.add_modifier(Modifier::REVERSED); - } - frame.buffer_mut()[(area.x + x as u16, area.y)].set_style(style); + frame.render_widget( + Paragraph::new(line).scroll((0, u16::try_from(horizontal_offset).unwrap_or(u16::MAX))), + Rect::new( + area.x, + area.y.saturating_add(u16::try_from(row).unwrap_or(u16::MAX)), + area.width, + 1, + ), + ); + } + let visible_end = offset.saturating_add(visible_rows); + let hidden = (0..changes.paths.len()) + .filter(|index| *index + usize::from(separator.is_some_and(|separator| *index >= separator)) >= visible_end) + .count(); + if overflow && hidden > 0 { + frame.render_widget( + Paragraph::new(Line::styled( + format!("… {hidden} {} not shown", if hidden == 1 { "line" } else { "lines" }), + Style::default().add_modifier(Modifier::DIM), + )), + Rect::new( + area.x, + area.bottom().saturating_sub(1), + area.width, + u16::from(area.height > 0), + ), + ); } } -fn graph_style(column: usize) -> Style { - const COLORS: [Color; 7] = [ - Color::Magenta, - Color::Yellow, - Color::Cyan, - Color::Green, - Color::Reset, - Color::White, - Color::Red, - ]; - let index = column % 14; - let style = Style::default().fg(COLORS[index % COLORS.len()]); - if index >= COLORS.len() { - style.add_modifier(Modifier::BOLD) - } else { - style +fn change_color(kind: ChangeKind) -> Color { + match kind { + ChangeKind::Added => Color::Green, + ChangeKind::Modified => Color::Yellow, + ChangeKind::Deleted => Color::LightRed, + ChangeKind::Renamed | ChangeKind::Copied => Color::Cyan, + ChangeKind::TypeChanged => Color::Magenta, + ChangeKind::Unmerged => Color::LightRed, } } -#[cfg(test)] -mod tests { - use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; +fn path_change_color(change: &crate::app::PathChange) -> Color { + match change.group { + ChangeGroup::Tree => change_color(change.kind), + ChangeGroup::Staged => Color::Green, + ChangeGroup::Unstaged => Color::LightRed, + } +} - use super::*; - use crate::{ - app::{Action, Attribution, AttributionKind, Author, Commit, LoadedCommits}, - history::{Decoration, DecorationKind}, +pub(crate) fn commit_diff_title( + row: &CommitRow, + title: &BStr, + mailmap: &gix::mailmap::Snapshot, + use_mailmap: bool, + show_emails: bool, +) -> BString { + let author = author_label(row.author, mailmap, use_mailmap, show_emails && !row.author.is_bot()); + let author = if row.author.is_bot() { + format!("[{author}]") + } else { + author }; + let mut out: BString = format!("{} {author} ", row.id.to_hex_with_len(7)).into(); + out.extend_from_slice(title); + out +} - fn author(name: &'static [u8], email: &'static [u8]) -> &'static Author { - Box::leak(Box::new(Author { - name: name.as_bstr(), - email: email.as_bstr(), - })) - } - - fn draw(frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations) { - super::draw(frame, app, decorations, &gix::mailmap::Snapshot::default(), None); - } +pub(crate) fn commit_diff_summary( + changes: &Changes, + line_counts: &[Option<(u32, u32)>], + lines_added: u64, + lines_removed: u64, +) -> Vec<Line<'static>> { + let paths = changes + .paths + .iter() + .map(|change| match &change.source { + Some(source) => format!("{} -> {}", source.to_str_lossy(), change.path.to_str_lossy()), + None => change.path.to_str_lossy().into_owned(), + }) + .collect::<Vec<_>>(); + let path_width = paths + .iter() + .map(|path| Line::from(path.as_str()).width()) + .max() + .unwrap_or_default(); + let count_width = line_counts + .iter() + .map(|counts| { + counts.map_or(3, |(added, removed)| { + (u64::from(added) + u64::from(removed)).to_string().len() + }) + }) + .max() + .unwrap_or_default(); + let max_changes = line_counts + .iter() + .flatten() + .map(|(added, removed)| u64::from(*added) + u64::from(*removed)) + .max() + .unwrap_or_default(); + let graph_width = max_changes.min(40) as usize; + let delta = |added: u32, removed: u32| i64::from(added) - i64::from(removed); + let format_delta = |delta: i64| { + if delta > 0 { + format!("+{delta}") + } else { + delta.to_string() + } + }; + let delta_width = line_counts + .iter() + .flatten() + .map(|(added, removed)| format_delta(delta(*added, *removed)).len()) + .max() + .unwrap_or_default(); + let mut lines = paths + .into_iter() + .zip(line_counts) + .map(|(path, counts)| { + let padding = " ".repeat(path_width.saturating_sub(Line::from(path.as_str()).width())); + let mut spans = vec![Span::raw(format!(" {path}{padding} | "))]; + match counts { + Some((added, removed)) => { + let total = u64::from(*added) + u64::from(*removed); + spans.push(Span::raw(format!("{total:>count_width$} "))); + let scaled = |count: u32| { + (u64::from(count) * graph_width as u64 / max_changes.max(1)).max(u64::from(count > 0)) as usize + }; + let added_width = scaled(*added); + let removed_width = scaled(*removed); + spans.push(Span::styled("+".repeat(added_width), color(Color::Green))); + spans.push(Span::styled("-".repeat(removed_width), color(Color::LightRed))); + let delta = delta(*added, *removed); + let delta = format_delta(delta); + spans.push(Span::raw(" ".repeat( + graph_width.saturating_sub(added_width + removed_width) + 1 + delta_width - delta.len(), + ))); + spans.push(Span::styled( + delta, + color(match added.cmp(removed) { + std::cmp::Ordering::Greater => Color::Green, + std::cmp::Ordering::Less => Color::LightRed, + std::cmp::Ordering::Equal => Color::Reset, + }), + )); + } + None => spans.push(Span::raw(format!("{:>count_width$}", "Bin"))), + } + Line::from(spans) + }) + .collect::<Vec<_>>(); + let mut spans = match (changes.range, changes.parent) { + (Some(range), _) => vec![Span::styled( + format!("{}..{} · ", range.base.to_hex_with_len(7), range.tip.to_hex_with_len(7)), + color(COMPARED_PARENT_COLOR), + )], + (None, Some(parent)) if parent.total > 1 => vec![Span::styled( + format!( + "vs parent {}/{} {} · ", + parent.index + 1, + parent.total, + parent.id.to_hex_with_len(7) + ), + color(COMPARED_PARENT_COLOR), + )], + (None, Some(parent)) => vec![Span::styled( + format!("vs parent {} · ", parent.id.to_hex_with_len(7)), + color(COMPARED_PARENT_COLOR), + )], + (None, None) => vec![Span::styled("root · ", color(COMPARED_PARENT_COLOR))], + }; + if changes.paths.is_empty() { + spans.push(Span::styled("No changes", Style::default().add_modifier(Modifier::DIM))); + } else { + append_change_aggregate( + &mut spans, + tree_change_counts(changes), + changes.paths.len(), + lines_added, + lines_removed, + ); + } + lines.push(Line::from(spans)); + lines +} + +fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'static> { + let mut spans = match pane { + ChangePane::Tree => { + let label = changes.range.map_or_else( + || { + app.time_travel_animation_origin() + .or_else(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id)) + .map_or_else(|| "-------".into(), |id| id.to_hex_with_len(7).to_string()) + }, + |range| format!("{}..{}", range.base.to_hex_with_len(7), range.tip.to_hex_with_len(7)), + ); + let mut spans = vec![Span::raw(format!("─ Tree {label} "))]; + if changes.paths.is_empty() { + spans.push(Span::styled("empty", color(Color::Green))); + spans.push(Span::raw(" ")); + } + spans.push(Span::raw("── ")); + spans + } + ChangePane::Worktree if changes.paths.is_empty() => vec![ + Span::raw("─ Worktree "), + Span::styled("clean", color(Color::Green)), + Span::raw(" ── "), + ], + ChangePane::Worktree => vec![Span::raw("─ Worktree ── ")], + }; + let counts: Vec<_> = match pane { + ChangePane::Tree => tree_change_counts(changes), + ChangePane::Worktree => { + let staged = changes + .paths + .iter() + .filter(|change| change.group == ChangeGroup::Staged) + .count(); + let unstaged = changes.paths.len().saturating_sub(staged); + [ + ("S".to_owned(), staged, Color::Green), + ("U".to_owned(), unstaged, Color::LightRed), + ] + .into_iter() + .filter(|(_, count, _)| *count > 0) + .collect() + } + }; + append_change_aggregate( + &mut spans, + counts, + changes.paths.len(), + changes.lines_added, + changes.lines_removed, + ); + Line::from(spans) +} + +fn tree_change_counts(changes: &Changes) -> Vec<(String, usize, Color)> { + [ + ChangeKind::Added, + ChangeKind::Modified, + ChangeKind::Deleted, + ChangeKind::Renamed, + ChangeKind::Copied, + ChangeKind::TypeChanged, + ] + .into_iter() + .filter_map(|kind| { + let count = changes.paths.iter().filter(|change| change.kind == kind).count(); + (count > 0).then(|| (kind.letter().to_string(), count, change_color(kind))) + }) + .collect() +} + +fn append_change_aggregate( + spans: &mut Vec<Span<'static>>, + counts: Vec<(String, usize, Color)>, + total: usize, + lines_added: u64, + lines_removed: u64, +) { + let has_counts = !counts.is_empty(); + let show_total = has_counts && (counts.len() != 1 || counts[0].1 != total); + for (index, (label, count, count_color)) in counts.into_iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" + ")); + } + spans.push(Span::styled(format!("{label} {count}"), color(count_color))); + } + if show_total { + spans.push(Span::raw(format!("{}= {}", if has_counts { " " } else { "" }, total))); + } + if lines_added > 0 || lines_removed > 0 { + spans.push(Span::raw(" · ")); + if lines_added > 0 { + spans.push(Span::styled(format!("+{lines_added}"), color(Color::Green))); + } + if lines_removed > 0 { + if lines_added > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(format!("-{lines_removed}"), color(Color::LightRed))); + } + spans.push(Span::raw(" ")); + } +} + +fn render_commit_message( + frame: &mut Frame<'_>, + area: Rect, + message: &BStr, + note: Option<&BStr>, + notes: &[BString], + offset: usize, +) -> usize { + let parsed = gix::objs::commit::MessageRef::from_bytes(message); + let mut body_message = BString::default(); + let mut trailers = Vec::new(); + if let Some(body) = parsed.body() { + for block in body.message_blocks() { + body_message.extend_from_slice(block.message); + trailers.extend(block.trailers()); + } + } + let body_message = body_message.trim_end().as_bstr(); + let body_message = (!body_message.is_empty()).then_some(body_message); + if trailers.is_empty() { + return render_scrolling_paragraph( + frame, + area, + Paragraph::new(commit_text(note, parsed.title, parsed.body, notes, area.width)).wrap(Wrap { trim: false }), + offset, + ); + } + let key_width = trailers + .iter() + .map(|trailer| Line::raw(trailer.token.to_str_lossy()).width()) + .max() + .unwrap_or_default(); + if area.width < 3 || key_width > area.width.saturating_sub(3) as usize { + if notes.is_empty() { + return render_scrolling_paragraph( + frame, + area, + Paragraph::new(commit_text(note, parsed.title, parsed.body, notes, area.width)) + .wrap(Wrap { trim: false }), + offset, + ); + } + let mut text = commit_text(note, parsed.title, body_message, notes, area.width); + text.lines.push(Line::default()); + for trailer in trailers { + text.lines.extend( + Text::raw(format!( + "{}: {}", + trailer.token.to_str_lossy(), + trailer.value.to_str_lossy() + )) + .lines, + ); + } + return render_scrolling_paragraph(frame, area, Paragraph::new(text).wrap(Wrap { trim: false }), offset); + } + let key_width = key_width as u16; + + let text = commit_text(note, parsed.title, body_message, notes, area.width); + let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + let body_height = paragraph.line_count(area.width); + let value_x = area.x.saturating_add(key_width).saturating_add(2); + let value_width = area.right().saturating_sub(value_x); + let trailers: Vec<_> = trailers + .into_iter() + .map(|trailer| { + let value = Paragraph::new(trailer.value.to_str_lossy()).wrap(Wrap { trim: false }); + let height = value.line_count(value_width).max(1); + (trailer, height) + }) + .collect(); + let total_height = body_height + .saturating_add(1) + .saturating_add(trailers.iter().map(|(_, height)| height).sum::<usize>()); + let max_offset = total_height.saturating_sub(area.height as usize).min(u16::MAX as usize); + let offset = offset.min(max_offset); + frame.render_widget(paragraph.scroll((u16::try_from(offset).unwrap_or(u16::MAX), 0)), area); + + let viewport_end = offset.saturating_add(area.height as usize); + let mut start = body_height.saturating_add(1); + for (trailer, height) in trailers { + let end = start.saturating_add(height); + if start >= viewport_end { + break; + } + if end > offset { + let skipped = offset.saturating_sub(start); + let y = area + .y + .saturating_add(u16::try_from(start.saturating_sub(offset)).unwrap_or_default()); + let visible_height = height + .saturating_sub(skipped) + .min(area.bottom().saturating_sub(y) as usize); + if skipped == 0 { + frame.render_widget( + Paragraph::new(format!("{}:", trailer.token.to_str_lossy())) + .style(color(Color::Green)) + .right_aligned(), + Rect::new(area.x, y, key_width.saturating_add(1), 1), + ); + } + let value = Paragraph::new(trailer.value.to_str_lossy()).wrap(Wrap { trim: false }); + frame.render_widget( + value.scroll((u16::try_from(skipped).unwrap_or(u16::MAX), 0)), + Rect::new( + value_x, + y, + value_width, + u16::try_from(visible_height).unwrap_or(u16::MAX), + ), + ); + } + start = end; + } + max_offset +} + +fn render_scrolling_paragraph(frame: &mut Frame<'_>, area: Rect, paragraph: Paragraph<'_>, offset: usize) -> usize { + let max_offset = paragraph + .line_count(area.width) + .saturating_sub(area.height as usize) + .min(u16::MAX as usize); + frame.render_widget( + paragraph.scroll((u16::try_from(offset.min(max_offset)).unwrap_or(u16::MAX), 0)), + area, + ); + max_offset +} + +fn commit_text<'a>( + note: Option<&'a BStr>, + title: &'a BStr, + body: Option<&'a BStr>, + notes: &'a [BString], + width: u16, +) -> Text<'a> { + let mut text = message_text(title, body); + if let Some(note) = note { + let parsed = gix::objs::commit::MessageRef::from_bytes(note); + let mut prefixed = message_text(parsed.title, parsed.body); + prefixed.lines.push(Line::styled( + "─".repeat(width as usize), + Style::default().add_modifier(Modifier::DIM), + )); + prefixed.lines.append(&mut text.lines); + text = prefixed; + } + for note in notes { + text.lines.push(Line::default()); + text.lines.push(Line::from(vec![ + Span::styled("Notes", color(NOTE_COLOR).add_modifier(Modifier::BOLD)), + Span::styled(":", color(NOTE_COLOR)), + ])); + text.lines.extend(markdown_text(note.as_bstr()).lines); + } + text +} + +fn message_text(title: &BStr, body: Option<&BStr>) -> Text<'static> { + let mut text = markdown_text(title); + for line in &mut text.lines { + line.style = line.style.add_modifier(Modifier::BOLD); + } + if let Some(body) = body.filter(|body| !body.is_empty()) { + text.lines.push(Line::default()); + text.lines.extend(markdown_text(body).lines); + } + text +} + +fn markdown_text(input: &BStr) -> Text<'static> { + let input = input.to_str_lossy(); + let rendered = tui_markdown::from_str_with_options(&input, &tui_markdown::Options::new(MarkdownStyle)); + Text { + alignment: rendered.alignment, + style: rendered.style, + lines: rendered + .lines + .into_iter() + .map(|line| Line { + style: line.style, + alignment: line.alignment, + spans: line + .spans + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect(), + }) + .collect(), + } +} + +fn markdown_title_spans(title: &BStr) -> Vec<Span<'static>> { + let text = markdown_text(title); + let text_style = text.style; + let mut out = Vec::new(); + for line in text.lines.into_iter().filter(|line| !line.spans.is_empty()) { + if !out.is_empty() { + out.push(Span::raw(" ")); + } + out.extend(line.spans.into_iter().map(|mut span| { + span.style = text_style.patch(line.style).patch(span.style); + span + })); + } + out +} + +fn shortcut(label: &'static str, key: char, enabled: bool) -> Vec<Span<'static>> { + let key_start = label.find(key).expect("shortcut key is present in its label"); + let key_end = key_start + key.len_utf8(); + let style = if enabled { + Style::default() + } else { + Style::default().add_modifier(Modifier::DIM) + }; + vec![ + Span::styled(&label[..key_start], style), + Span::styled(&label[key_start..key_end], style.add_modifier(Modifier::UNDERLINED)), + Span::styled(&label[key_end..], style), + ] +} + +fn command_items(commands: &[Command], group: CommandGroup, row: usize) -> Vec<Vec<Span<'static>>> { + let mut items = Vec::new(); + for command in commands + .iter() + .filter(|command| command.group == group && command.row == row) + { + let item = if command.id == CommandId::StackInsert { + vec![ + Span::raw("stack-inser"), + Span::styled("t", Style::default().add_modifier(Modifier::UNDERLINED)), + ] + } else { + shortcut(command.label, command.key(), command.active) + }; + items.push(item); + } + if items.is_empty() { + items.push(vec![Span::raw("no actions")]); + } + items +} + +fn active_prefix_popup( + app: &App, + decorations: &Decorations, + commands: &[Command], + focus_feedback: Option<&'static str>, + content_width: usize, +) -> Option<(usize, Vec<Vec<Span<'static>>>)> { + let selected_segment = app.selected_is_segment(); + let mut logical_rows = app + .history_display_expanded + .then(|| vec![command_items(commands, CommandGroup::View, 0)]); + if !selected_segment && (app.changes_focus != Some(ChangePane::Worktree) || app.can_amend()) && app.actions_expanded + { + logical_rows = Some(vec![ + command_items(commands, CommandGroup::Actions, 0), + command_items(commands, CommandGroup::Actions, 1), + ]); + } + if !selected_segment && app.enrich_expanded { + logical_rows = Some(vec![command_items(commands, CommandGroup::Enrich, 0)]); + } + if app.information_expanded { + let information = commands + .iter() + .filter(|command| command.group == CommandGroup::Information) + .map(|command| { + if command.id == CommandId::VerifySignatures { + if app.signature_failures > 0 { + vec![ + Span::raw(format!("s {} ", app.signature_failures)), + Span::styled("●", color(Color::LightRed)), + ] + } else { + vec![ + Span::raw("s "), + Span::styled("●", color(Color::Rgb(255, 165, 0))), + Span::raw(" -> "), + Span::styled("●", color(Color::Green)), + ] + } + } else { + shortcut(command.label, command.key(), command.active) + } + }) + .collect(); + let mut navigation = vec![shortcut("p command", 'p', true)]; + if !selected_segment && (app.tree_changes_visible || app.worktree_changes_visible) { + navigation.push(vec![match focus_feedback { + Some(destination) => Span::raw(format!("<tab> → {destination}")), + None => Span::raw("<tab> switch"), + }]); + } + navigation.extend([ + vec![Span::raw("↑↓/jk move")], + vec![Span::raw("h/l pan")], + vec![Span::raw("Shift+directions topo")], + vec![Span::raw("PgUp/PgDn move")], + vec![Span::raw("Shift+PgUp/PgDn pan")], + ]); + if app.changes_focus.is_none() { + navigation.push(vec![Span::raw(if selected_segment { + "<enter> expand" + } else { + "<enter> diff" + })]); + } + logical_rows = Some(vec![information, navigation]); + } + Some(( + active_prefix_popup_anchor(app, decorations)?, + wrap_prefix_popup_rows(logical_rows?, content_width), + )) +} + +fn wrap_prefix_popup_rows(logical_rows: Vec<Vec<Vec<Span<'static>>>>, content_width: usize) -> Vec<Vec<Span<'static>>> { + let mut rows = Vec::new(); + for items in logical_rows { + let mut row = Vec::new(); + let mut row_width = 0usize; + for mut item in items { + let item_width = spans_width(&item); + if !row.is_empty() && row_width.saturating_add(3).saturating_add(item_width) > content_width { + rows.push(row); + row = Vec::new(); + row_width = 0; + } + if !row.is_empty() { + row.push(Span::raw(" · ")); + row_width += 3; + } + row_width = row_width.saturating_add(item_width); + row.append(&mut item); + } + if row.is_empty() { + rows.push(Vec::new()); + } else { + rows.push(row); + } + } + rows +} + +fn emphasize_prefix(spans: &mut [Span<'_>]) { + for span in spans { + span.style = span.style.add_modifier(Modifier::REVERSED); + } +} + +fn spans_width(spans: &[Span<'_>]) -> usize { + spans.iter().map(Span::width).sum() +} + +fn render_prefix_popup( + frame: &mut Frame<'_>, + footer: Rect, + anchor: usize, + mut rows: Vec<Vec<Span<'static>>>, +) -> Option<Rect> { + let height = u16::try_from(rows.len()).unwrap_or(u16::MAX); + if !prefix_popup_can_render(frame.area(), footer, anchor, rows.len()) { + return None; + } + let mut width = 0; + for items in &mut rows { + items.insert(0, Span::raw(" ")); + items.push(Span::raw(" ")); + width = width.max(spans_width(items)); + } + let width = u16::try_from(width).unwrap_or(u16::MAX).min(footer.width); + let anchor = footer.x.saturating_add(u16::try_from(anchor).unwrap_or(u16::MAX)); + let area = Rect::new( + anchor.min(footer.right().saturating_sub(width)), + footer.y - height, + width, + height, + ); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(rows.into_iter().map(Line::from).collect::<Vec<_>>()) + .style(Style::default().add_modifier(Modifier::REVERSED)), + area, + ); + Some(area) +} + +fn prefix_popup_can_render(frame: Rect, footer: Rect, anchor: usize, rows: usize) -> bool { + footer.width > 0 && usize::from(footer.y.saturating_sub(frame.y)) >= rows && anchor < usize::from(footer.width) +} + +fn notification_discs(spans: Vec<Span<'_>>) -> Vec<Span<'_>> { + let mut out = Vec::with_capacity(spans.len()); + for span in spans { + let style = span.style; + for (index, text) in span.content.split('·').enumerate() { + if index > 0 { + out.push(Span::styled("●", style.fg(FILESYSTEM_NOTIFICATION_COLOR))); + } + if !text.is_empty() { + out.push(Span::styled(text.to_owned(), style)); + } + } + } + out +} + +#[derive(Clone, Copy)] +struct MetadataOptions<'a> { + date_mode: DateMode, + id_mode: IdMode, + change_id: gix::hash::ChangeId, + show_author_name: bool, + show_emails: bool, + show_trailers: bool, + has_notes: bool, + note_title: Option<&'a BStr>, + use_mailmap: bool, + ref_mode: RefMode, + selected: bool, + copy_feedback: Option<CopyKind>, +} + +struct MetadataColumns<'a> { + fields: [Line<'a>; 6], +} + +impl<'a> MetadataColumns<'a> { + fn prefix_width(&self) -> usize { + self.fields[..5].iter().map(Line::width).sum() + } + + fn into_line(self) -> Line<'a> { + Line::from( + self.fields + .into_iter() + .flat_map(|field| field.spans) + .collect::<Vec<_>>(), + ) + } + + fn into_line_with_prefix(self) -> (Line<'a>, usize) { + let prefix_width = self.prefix_width(); + (self.into_line(), prefix_width) + } + + fn align_title(mut self, width: usize) -> (Line<'a>, usize) { + let padding = width.saturating_sub(self.prefix_width()); + self.fields[4].spans.push(Span::raw(" ".repeat(padding))); + self.into_line_with_prefix() + } + + fn align_columns(mut self, widths: [usize; 5]) -> (Line<'a>, usize) { + for (field, width) in self.fields[..5].iter_mut().zip(widths) { + field + .spans + .push(Span::raw(" ".repeat(width.saturating_sub(field.width())))); + } + self.into_line_with_prefix() + } +} + +fn metadata_columns<'a>( + row: &'a CommitRow, + title: &'a BStr, + attributions: &'a [crate::app::Attribution], + decorations: &'a Decorations, + mailmap: &'a gix::mailmap::Snapshot, + options: MetadataOptions<'a>, +) -> MetadataColumns<'a> { + debug_assert!(row.metadata_loaded, "visible rows have metadata"); + let MetadataOptions { + date_mode, + id_mode, + change_id, + show_author_name, + show_emails, + show_trailers, + has_notes, + note_title, + use_mailmap, + ref_mode, + selected, + copy_feedback, + } = options; + let commit_style = if copy_feedback == Some(CopyKind::Id) { + Style::default() + } else { + color(Color::Magenta).add_modifier(Modifier::BOLD) + }; + let change_style = color(Color::LightCyan).add_modifier(Modifier::BOLD); + let selected_style = |style: Style| { + if selected { + style.add_modifier(Modifier::REVERSED) + } else { + style + } + }; + let mut id = Vec::new(); + match id_mode { + IdMode::Commit => id.push(Span::styled( + row.id.to_hex_with_len(7).to_string(), + selected_style(commit_style), + )), + IdMode::Change => id.push(Span::styled( + change_id.to_reverse_hex_with_len(7).to_string(), + selected_style(change_style), + )), + IdMode::Off => {} + } + let mut refs = Vec::new(); + let row_decorations = decorations.get(&row.id).map(Vec::as_slice).unwrap_or_default(); + if row.is_review { + refs.push(Span::styled(" ◆", decoration_style(DecorationKind::Review))); + } + if row_decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Pin) + { + refs.push(Span::styled(" 📌", decoration_style(DecorationKind::Pin))); + } + if row_decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Stash) + { + let marker = if row.is_review + || row_decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Pin) + { + "🎁" + } else { + " 🎁" + }; + refs.push(Span::styled(marker, decoration_style(DecorationKind::Stash))); + } + let mut labels = row_decorations + .iter() + .filter(|decoration| !matches!(decoration.kind, DecorationKind::Pin | DecorationKind::Stash)) + .filter(|decoration| decoration.kind != DecorationKind::CurrentWorktreeDetached) + .filter(|decoration| match ref_mode { + _ if decoration.kind == DecorationKind::Head => false, + _ if decoration.kind == DecorationKind::Review => true, + RefMode::All => true, + RefMode::Default => decoration.kind != DecorationKind::Special, + RefMode::None => { + selected + && matches!( + decoration.kind, + DecorationKind::WorktreeBranch + | DecorationKind::WorktreeDetached + | DecorationKind::CurrentWorktreeDetached + ) + } + }) + .peekable(); + if labels.peek().is_some() { + refs.push(Span::raw(" (")); + for (index, decoration) in labels.enumerate() { + if index != 0 { + refs.push(Span::raw(", ")); + } + let name = decoration.name.to_str_lossy(); + refs.push(Span::styled( + if matches!( + decoration.kind, + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached + ) { + format!("{name}@").into() + } else if decoration.kind == DecorationKind::HeadPinBranch { + format!("★{name}").into() + } else if matches!( + decoration.kind, + DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached + ) { + format!("@{name}").into() + } else { + name + }, + decoration_style(decoration.kind), + )); + } + refs.push(Span::raw(") ")); + } else { + refs.push(Span::raw(" ")); + } + let mut date_spans = Vec::new(); + let date = match date_mode { + DateMode::Author => Some(row.author_time), + DateMode::Committer => Some(row.committer_time), + DateMode::None => None, + }; + if let Some(date) = date { + date_spans.push(Span::styled( + format!("{} ", date.format_or_unix(gix::date::time::format::SHORT)), + color(Color::Blue), + )); + } + let mut author_spans = Vec::new(); + let mut attribution_spans = Vec::new(); + if show_author_name { + let author = author_label(row.author, mailmap, use_mailmap, show_emails && !row.author.is_bot()); + let mut author_style = if copy_feedback == Some(CopyKind::Author) { + Style::default() + } else { + color(Color::Green) + }; + if row.author.is_github_noreply() { + author_style = author_style.add_modifier(Modifier::ITALIC); + } + author_spans.push(Span::styled( + if row.author.is_bot() { + format!("[{author}] ") + } else { + format!("{author} ") + }, + author_style, + )); + if show_trailers { + type Group = (&'static str, Vec<&'static str>, Vec<(String, Style)>); + let mut groups: Vec<Group> = Vec::new(); + for (kind, marker, grouped_marker) in [ + (AttributionKind::CoAuthor, "Co: ", "Co"), + (AttributionKind::Assisted, "As: ", "A"), + (AttributionKind::Reviewed, "Re: ", "Re"), + (AttributionKind::Acked, "Ack: ", "Ack"), + (AttributionKind::Tested, "Te: ", "Te"), + (AttributionKind::SignedOff, "So: ", "So"), + ] { + let actors: Vec<_> = attributions + .iter() + .filter(|actor| actor.kind == kind) + .map(|actor| { + let name = if actor.author == row.author { + "*".to_owned() + } else { + let name = + author_label(actor.author, mailmap, use_mailmap, show_emails && !actor.is_agent()); + if actor.is_agent() { format!("[{name}]") } else { name } + }; + let style = if actor.author.is_github_noreply() { + color(Color::Green).add_modifier(Modifier::ITALIC) + } else { + color(Color::Green) + }; + (name, style) + }) + .collect(); + if actors.is_empty() { + continue; + } + if let Some((_, markers, _)) = groups + .iter_mut() + .find(|(_, _, displayed_actors)| *displayed_actors == actors) + { + markers.push(grouped_marker); + } else { + groups.push((marker, vec![grouped_marker], actors)); + } + } + for (marker, markers, actors) in groups { + attribution_spans.push(Span::styled( + if markers.len() == 1 { + marker.to_owned() + } else { + format!("{}: ", markers.join(", ")) + }, + color(Color::Green).add_modifier(Modifier::DIM), + )); + for (index, (name, style)) in actors.into_iter().enumerate() { + if index != 0 { + attribution_spans.push(Span::raw(", ")); + } + attribution_spans.push(Span::styled(name, style)); + } + attribution_spans.push(Span::raw(" ")); + } + } + } + if row.has_agent_marker { + attribution_spans.push(Span::styled("[A] ", color(NOTE_COLOR))); + } + if has_notes { + attribution_spans.push(Span::styled("[N] ", color(NOTE_COLOR))); + } + let mut title_spans = Vec::new(); + if !show_emails { + if let Some(note_title) = note_title { + let mut note_title = markdown_title_spans(note_title); + for span in &mut note_title { + span.style = span.style.patch(note_style()); + } + title_spans.extend(note_title); + title_spans.push(Span::raw(" ")); + } + title_spans.extend(markdown_title_spans(title)); + } + MetadataColumns { + fields: [ + Line::from(id), + Line::from(refs), + Line::from(date_spans), + Line::from(author_spans), + Line::from(attribution_spans), + Line::from(title_spans), + ], + } +} + +fn metadata_line<'a>( + row: &'a CommitRow, + title: &'a BStr, + attributions: &'a [crate::app::Attribution], + decorations: &'a Decorations, + mailmap: &'a gix::mailmap::Snapshot, + options: MetadataOptions<'a>, +) -> Line<'a> { + metadata_columns(row, title, attributions, decorations, mailmap, options).into_line() +} + +pub(crate) fn plain_history_metadata( + app: &App, + row: &CommitRow, + decorations: &Decorations, + mailmap: &gix::mailmap::Snapshot, + has_notes: bool, + change_id: Option<gix::hash::ChangeId>, +) -> String { + let mut line = metadata_line( + row, + app.title(row), + app.attributions(row), + decorations, + mailmap, + MetadataOptions { + date_mode: app.date_mode, + id_mode: app.effective_id_mode(), + change_id: app.change_id(row.id), + show_author_name: app.name_mode != NameMode::None, + show_emails: app.show_emails, + show_trailers: app.name_mode == NameMode::All && app.show_trailers, + has_notes, + note_title: None, + use_mailmap: app.use_mailmap, + ref_mode: app.ref_mode, + selected: false, + copy_feedback: None, + }, + ); + if let Some(change_id) = change_id { + line.spans + .insert(1, Span::raw(format!(" {}", change_id.to_reverse_hex_with_len(7)))); + } + line.spans.into_iter().map(|span| span.content.into_owned()).collect() +} + +pub(crate) fn todo_metadata(app: &App, row: &CommitRow, mailmap: &gix::mailmap::Snapshot) -> String { + let title = app.title(row); + let decorations = Decorations::new(); + let line = metadata_line( + row, + title, + app.attributions(row), + &decorations, + mailmap, + MetadataOptions { + date_mode: app.date_mode, + id_mode: IdMode::Off, + change_id: row.id.into(), + show_author_name: app.name_mode != crate::app::NameMode::None, + show_emails: app.show_emails, + show_trailers: app.name_mode == crate::app::NameMode::All && app.show_trailers, + has_notes: !app.notes(row.id).is_empty(), + note_title: None, + use_mailmap: app.use_mailmap, + ref_mode: app.ref_mode, + selected: false, + copy_feedback: None, + }, + ); + let mut out = line + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::<String>() + .trim() + .to_owned(); + if app.show_emails { + if !out.is_empty() { + out.push(' '); + } + let rendered = markdown_title_spans(title) + .into_iter() + .map(|span| span.content.into_owned()) + .collect::<String>(); + out.push_str(&rendered); + } + out +} + +fn author_label( + author: &crate::app::Author, + mailmap: &gix::mailmap::Snapshot, + use_mailmap: bool, + show_email: bool, +) -> String { + let resolved = use_mailmap + .then(|| { + mailmap.try_resolve_ref(gix::actor::SignatureRef { + name: author.name, + email: author.email, + time: "", + }) + }) + .flatten(); + let name = resolved.as_ref().and_then(|actor| actor.name).unwrap_or(author.name); + if show_email { + let email = resolved.as_ref().and_then(|actor| actor.email).unwrap_or(author.email); + format!("{} <{}>", name.to_str_lossy(), email.to_str_lossy()) + } else { + name.to_str_lossy().into_owned() + } +} + +pub(crate) fn decoration_style(kind: DecorationKind) -> Style { + match kind { + DecorationKind::Head => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + DecorationKind::Pin => Style::default().fg(Color::Blue), + DecorationKind::HeadPinBranch => Style::default().fg(Color::Cyan), + DecorationKind::Stash => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), + DecorationKind::Review => Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), + DecorationKind::CurrentWorktreeBranch | DecorationKind::CurrentWorktreeDetached => { + Style::default().fg(Color::Cyan) + } + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => Style::default().fg(Color::LightBlue), + DecorationKind::Local => Style::default().fg(Color::Cyan), + DecorationKind::Remote => Style::default().fg(Color::Yellow), + DecorationKind::Tag => Style::default().fg(Color::Magenta), + DecorationKind::AnnotatedTag => Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), + DecorationKind::Special => Style::default().fg(Color::Blue), + } +} + +fn color(color: Color) -> Style { + Style::default().fg(color) +} + +#[derive(Clone, Copy)] +struct HeadState { + has_descendants: bool, + attached: bool, +} + +fn color_graph( + frame: &mut Frame<'_>, + area: Rect, + graph: &str, + offset: usize, + highlight: Option<Color>, + signature: SignatureState, + head: Option<HeadState>, +) { + for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() { + if symbol.is_whitespace() { + continue; + } + let mut style = if let Some(highlight) = highlight { + color(highlight).add_modifier(Modifier::REVERSED) + } else if symbol == '●' { + color(signature_color(signature)) + } else { + graph_style(offset.saturating_add(x) / 2) + }; + if head.is_some_and(|head| head.has_descendants) && symbol == '●' { + style = style.add_modifier(Modifier::BOLD); + } + let cell = &mut frame.buffer_mut()[(area.x + x as u16, area.y)]; + if head.is_some() && symbol == '●' { + cell.set_symbol("@"); + if head.is_some_and(|head| head.attached) { + style = style.add_modifier(Modifier::ITALIC); + } + } + cell.set_style(style); + } +} + +fn signature_color(signature: SignatureState) -> Color { + match signature { + SignatureState::Unsigned => Color::Blue, + SignatureState::Unverified | SignatureState::Verifying => Color::Rgb(255, 165, 0), + SignatureState::Verified => Color::Green, + SignatureState::Failed => Color::LightRed, + SignatureState::PendingRebase => Color::Gray, + } +} + +fn graph_style(column: usize) -> Style { + const COLORS: [Color; 7] = [ + Color::Magenta, + Color::Yellow, + Color::Cyan, + Color::Green, + Color::Reset, + Color::White, + Color::LightRed, + ]; + let index = column % 14; + let style = Style::default().fg(COLORS[index % COLORS.len()]); + if index >= COLORS.len() { + style.add_modifier(Modifier::BOLD) + } else { + style + } +} + +#[cfg(test)] +mod tests { + use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; + + use super::*; + use crate::{ + app::{Action, Attribution, AttributionKind, Author, Commit, LoadedCommits}, + history::{Decoration, DecorationKind}, + }; + + fn author(name: &'static [u8], email: &'static [u8]) -> &'static Author { + Box::leak(Box::new(Author { + name: name.as_bstr(), + email: email.as_bstr(), + })) + } + + fn draw(frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations) { + super::draw(frame, app, decorations, &gix::mailmap::Snapshot::default(), None, None); + } + + fn complete(app: &mut App) { + let rows = app + .start_lane_computation() + .expect("a loading app starts lane computation"); + let (rows, lanes, lane_time) = crate::app::compute_lanes(rows); + app.finish_lane_computation(rows, lanes, lane_time); + } + + #[test] + fn embeds_status_shortcuts_in_their_labels() { + let spans = shortcut("copy", 'y', false); + assert_eq!(Line::from(spans.clone()).to_string(), "copy"); + assert!(spans[1].style.add_modifier.contains(Modifier::UNDERLINED)); + assert!(spans[1].style.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn time_travel_animation_keeps_the_tree_summary_on_its_origin() { + let ids = [1, 2].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let mut app = App::new(ids.len()); + app.extend_commits( + ids.into_iter() + .map(|id| Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.select_commit(ids[1]); + app.begin_time_travel_animation(); + app.select_commit_for_time_travel(ids[0]); + + assert!( + changes_summary(ChangePane::Tree, &app, &Changes::default()) + .to_string() + .contains("0202020"), + "the cached tree diff remains labelled with its origin while selection moves" + ); + app.finish_time_travel_animation(); + } + + #[test] + fn prefix_popout_clamps_and_clears_the_row_beneath_it() -> Result<(), Box<dyn std::error::Error>> { + let mut terminal = Terminal::new(TestBackend::new(20, 2))?; + let mut popup = None; + terminal.draw(|frame| { + frame.render_widget(Paragraph::new("underlying history"), Rect::new(0, 0, 20, 1)); + popup = render_prefix_popup( + frame, + Rect::new(0, 1, 20, 1), + 12, + vec![vec![Span::raw("abcdefghijklmnopqrstuvwxyz")]], + ); + })?; + + assert_eq!(popup, Some(Rect::new(0, 0, 20, 1))); + assert_eq!(rendered_line(&terminal, 0), " abcdefghijklmnopqrs"); + assert!( + (0..20).all(|x| terminal.backend().buffer()[(x, 0)] + .modifier + .contains(Modifier::REVERSED)), + "the complete clipped popout keeps its floating treatment" + ); + Ok(()) + } + + #[test] + fn prefix_popout_wraps_whole_items_and_preserves_logical_rows() -> Result<(), Box<dyn std::error::Error>> { + let rows = wrap_prefix_popup_rows( + vec![ + vec![ + shortcut("one", 'o', true), + shortcut("two", 't', false), + shortcut("three", 't', true), + ], + vec![shortcut("four", 'f', true)], + ], + 9, + ); + assert_eq!( + rows.iter() + .cloned() + .map(Line::from) + .map(|line| line.to_string()) + .collect::<Vec<_>>(), + ["one · two", "three", "four"], + "items wrap without orphaning separators and logical rows still start new display rows" + ); + + let mut terminal = Terminal::new(TestBackend::new(11, 4))?; + let mut popup = None; + terminal.draw(|frame| { + popup = render_prefix_popup(frame, Rect::new(0, 3, 11, 1), 0, rows); + })?; + + assert_eq!(popup, Some(Rect::new(0, 0, 11, 3))); + assert_eq!(rendered_line(&terminal, 0), " one · two "); + assert_eq!(rendered_line(&terminal, 1).trim_end(), " three"); + assert_eq!(rendered_line(&terminal, 2).trim_end(), " four"); + let styled_shortcut = terminal.backend().buffer()[(7, 0)].modifier; + assert!(styled_shortcut.contains(Modifier::UNDERLINED)); + assert!(styled_shortcut.contains(Modifier::DIM)); + assert!(styled_shortcut.contains(Modifier::REVERSED)); + Ok(()) + } + + #[test] + fn wrapped_prefix_popout_reserves_all_rows_or_stays_hidden() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.changes_mode = None; + app.configure_hidden_filter(true); + app.history_display_expanded = true; + app.leave_success("notice"); + let mut terminal = Terminal::new(TestBackend::new(35, 5))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + assert!(rendered_line(&terminal, 0).contains("notice")); + assert!(rendered_line(&terminal, 1).contains("author date")); + assert!(rendered_line(&terminal, 2).contains("names")); + assert!(rendered_line(&terminal, 3).contains("show hidden")); + + let mut short_app = App::new(1); + short_app.changes_mode = None; + short_app.configure_hidden_filter(true); + short_app.history_display_expanded = true; + let mut short = Terminal::new(TestBackend::new(35, 3))?; + short.draw(|frame| draw(frame, &mut short_app, &Decorations::new()))?; + + assert!( + !(0..2).any(|row| { + let line = rendered_line(&short, row); + line.contains("author date") || line.contains("names") || line.contains("show hidden") + }), + "a popup that does not fit is not partially rendered" + ); + Ok(()) + } + + #[test] + fn command_menu_renders_filtered_numbered_results_and_a_cursor() -> Result<(), Box<dyn std::error::Error>> { + let app = App::new(1); + let commands = command_menu::commands(&app, &Decorations::new(), false); + let items = commands + .iter() + .map(|command| { + crate::menu::Item::with_search_prefix( + command.label, + command.group.label(), + command.group.prefix(), + command.id, + ) + }) + .collect::<Vec<_>>(); + let mut menu = Menu::default(); + menu.open(&items); + for character in "rft".chars() { + menu.insert(character, &items); + } + let mut cursor = None; + let mut terminal = Terminal::new(TestBackend::new(60, 12))?; + terminal.draw(|frame| cursor = draw_command_menu(frame, &mut menu, &commands))?; + + let rendered = (0..12) + .map(|row| rendered_line(&terminal, row)) + .collect::<Vec<_>>() + .join("\n"); + assert!(rendered.contains("Command"), "the popup has a title"); + assert!(rendered.contains("> rft"), "the popup shows the query"); + assert!( + rendered.contains("1 Information ref-tree [? t]"), + "matching commands are numbered and retain their shortcut" + ); + let result_y = (0..12) + .find(|row| rendered_line(&terminal, *row).contains("ref-tree")) + .expect("the selected result is visible"); + assert!( + (0..60).any(|x| terminal.backend().buffer()[(x, result_y)] + .modifier + .contains(Modifier::REVERSED)), + "the selected command is highlighted" + ); + assert!(cursor.is_some(), "the query exposes a terminal cursor"); + + menu.open(&items); + let mut short = Terminal::new(TestBackend::new(60, 7))?; + short.draw(|frame| assert!(draw_command_menu(frame, &mut menu, &commands).is_some()))?; + assert_eq!(menu.visible_indices().len(), 2, "only rendered rows are selectable"); + assert_eq!(menu.submit_digit('3', &items), None, "a clipped row cannot execute"); + + menu.open(&items); + let mut tiny = Terminal::new(TestBackend::new(3, 12))?; + tiny.draw(|frame| assert_eq!(draw_command_menu(frame, &mut menu, &commands), None))?; + assert_eq!( + menu.submit_selected(&items), + None, + "an invisible selection cannot execute" + ); + Ok(()) + } + + #[test] + fn undo_progress_uses_the_message_line_without_shifting_the_body() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(4); + app.update(Action::ToggleInformation); + app.show_undo_position(1, 4, "reword commit"); + let mut terminal = Terminal::new(TestBackend::new(80, 5))?; + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + None, + ); + })?; + + let notice_y = (0..4) + .find(|y| rendered_line(&terminal, *y).contains("reword commit · 1 undo · 3 redo")) + .expect("the undo message is visible"); + assert!( + rendered_line(&terminal, notice_y).contains("reword commit · 1 undo · 3 redo"), + "the message line names the current operation" + ); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(2, notice_y)].bg, Color::LightGreen); + assert_eq!(buffer[(20, notice_y)].bg, Color::LightGreen); + assert_eq!(buffer[(21, notice_y)].bg, Color::Green); + assert_eq!(buffer[(77, notice_y)].bg, Color::Green); + assert_eq!(buffer[(78, notice_y)].bg, Color::Reset, "the notice margin stays clear"); + assert!( + rendered_line(&terminal, 4).starts_with("0 commits"), + "the footer remains full-width" + ); + + app.update(Action::MoveDown); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + None, + ); + })?; + assert_eq!(app.undo_position(), None, "ordinary movement dismisses undo progress"); + Ok(()) + } + + #[test] + fn undo_progress_colors_all_notice_rows_and_handles_edges() -> Result<(), Box<dyn std::error::Error>> { + let mut terminal = Terminal::new(TestBackend::new(8, 2))?; + for (kind, applied, total, bright_width, bright, dim) in [ + (NoticeKind::Success, 4, 4, 8, Color::LightGreen, Color::Green), + (NoticeKind::Success, 3, 4, 6, Color::LightGreen, Color::Green), + (NoticeKind::Success, 0, 4, 0, Color::LightGreen, Color::Green), + (NoticeKind::Success, 0, 0, 0, Color::LightGreen, Color::Green), + (NoticeKind::Attention, 3, 4, 6, Color::LightYellow, Color::Yellow), + (NoticeKind::Error, 3, 4, 6, Color::LightRed, Color::Red), + ] { + let notice = Notice { + kind, + text: "notice".into(), + }; + terminal.draw(|frame| { + render_notice(frame, frame.area(), ¬ice); + render_undo_progress(frame, frame.area(), kind, applied, total); + })?; + let buffer = terminal.backend().buffer(); + for y in 0..2 { + for x in 0..8 { + assert_eq!( + buffer[(x, y)].bg, + if x < bright_width { bright } else { dim }, + "every row uses the same progress boundary" + ); + } + } + assert_eq!(buffer[(0, 0)].fg, Color::Black, "progress preserves notice text color"); + assert!( + buffer[(0, 0)].modifier.contains(Modifier::BOLD), + "progress preserves notice emphasis" + ); + } + Ok(()) + } + + #[test] + fn renders_todo_progress_with_operation_counts_and_times() -> Result<(), Box<dyn std::error::Error>> { + let mut terminal = Terminal::new(TestBackend::new(80, 8))?; + terminal.draw(|frame| { + draw_todo_progress( + frame, + crate::edit::rebase::Progress { + total: 100, + processed: 42, + cherry_picked: 31, + signed: 28, + cherry_pick_time: std::time::Duration::from_millis(1234), + signing_time: std::time::Duration::from_millis(45), + }, + ); + })?; + assert_eq!(rendered_line(&terminal, 2).trim(), "Rebasing commits"); + assert!( + rendered_line(&terminal, 3).contains("42 / 100 commits"), + "the gauge labels current and total source commits" + ); + assert_eq!(rendered_line(&terminal, 4).trim(), "cherry-picked 31 · 1.2s"); + assert_eq!(rendered_line(&terminal, 5).trim(), "signed 28 · 45.0ms"); + Ok(()) + } + + #[test] + fn counts_commits_until_the_graph_is_complete_then_tracks_the_selected_row() { + let ids = [1, 2, 3].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let mut app = App::new(3); + app.extend_commits( + ids.into_iter() + .rev() + .enumerate() + .map(|(index, id)| Commit { + id, + parent_ids: (index < 2).then(|| ids[1 - index]).into_iter().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + assert_eq!(history_position(&app), "3 commits"); + + let rows = app + .start_lane_computation() + .expect("a loading app starts lane computation"); + assert_eq!(history_position(&app), "3 commits"); + let (rows, lanes, lane_time) = crate::app::compute_lanes(rows); + app.finish_lane_computation(rows, lanes, lane_time); + + assert_eq!(history_position(&app), "#2"); + app.update(Action::MoveDown); + assert_eq!(history_position(&app), "#1"); + app.update(Action::MoveDown); + assert_eq!(history_position(&app), "#0"); + } + + #[test] + fn visual_counts_restart_at_each_root_and_merges_choose_the_closest() { + let ids = [2, 3, 4, 5, 6, 7, 8].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let parents = [ + &[ids[5], ids[3]][..], + &[ids[2]][..], + &[ids[2]][..], + &[ids[1]][..], + &[][..], + &[ids[0]][..], + &[][..], + ]; + let mut app = App::new(ids.len()); + app.extend_commits( + ids.into_iter() + .rev() + .zip(parents) + .map(|(id, parents)| Commit { + id, + parent_ids: parents.iter().copied().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + + let expected = [ids[6], ids[5], ids[3], ids[1], ids[0], ids[4], ids[2]]; + assert_eq!(app.rows.iter().map(|row| row.id).collect::<Vec<_>>(), expected); + app.selected = Some(0); + assert_eq!( + history_position(&app), + "#4", + "the merge uses its visually closest root and counts an interleaved row" + ); + app.selected = Some(4); + assert_eq!(history_position(&app), "#0", "the first root starts at zero"); + app.selected = Some(6); + assert_eq!(history_position(&app), "#0", "the second root restarts at zero"); + } + + #[test] + fn enrichments_have_a_gutter_beside_selection_and_the_graph() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + app.set_enrichment( + id, + crate::enrich::Enrichment { + todo: true, + note: Some("follow-up *title*\n\nwhy it matters\n".into()), + }, + ); + app.set_tree_enrichment(id, crate::enrich::TreeEnrichment { checks_pass: true }); + let mut terminal = Terminal::new(TestBackend::new(80, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let row = terminal.backend().buffer(); + assert_eq!(row[(0, 0)].symbol(), "🚧", "todo leads the row"); + assert_eq!(row[(2, 0)].symbol(), "📝", "note directly follows todo"); + assert_eq!(row[(4, 0)].symbol(), "✔️", "tree status follows commit enrichments"); + assert_eq!(row[(6, 0)].symbol(), ">", "selection directly follows enrichments"); + assert_eq!(row[(8, 0)].symbol(), "●", "the graph remains separate"); + assert!( + rendered_line(&terminal, 0).contains("follow-up title subject"), + "the selected todo prefixes its title with the note title" + ); + let title_x = (0..80) + .find(|x| row[(*x, 0)].symbol() == "f") + .expect("the note title is visible"); + assert_eq!(row[(title_x, 0)].fg, Color::Black); + assert_eq!(row[(title_x, 0)].bg, Color::Yellow); + let separator = &row[(title_x + 15, 0)]; + assert_eq!(separator.symbol(), " ", "a space separates both titles"); + assert_eq!(separator.fg, Color::Reset, "the separator has no foreground color"); + assert_eq!(separator.bg, Color::Reset, "the separator has no background color"); + assert!(separator.modifier.is_empty(), "the separator has no modifiers"); + let commit_title = &row[(title_x + 16, 0)]; + assert_eq!(commit_title.symbol(), "s", "the commit title follows the note title"); + assert_eq!( + commit_title.fg, + Color::Reset, + "the commit title has its normal foreground" + ); + assert_eq!( + commit_title.bg, + Color::Reset, + "the commit title has its normal background" + ); + assert_eq!( + commit_title.modifier, + Modifier::empty(), + "the commit title has its normal modifiers" + ); + assert!( + (0..80).any(|x| row[(x, 0)].symbol() == "t" && row[(x, 0)].modifier.contains(Modifier::ITALIC)), + "the Markdown title is italicized" + ); + app.enrich_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!( + rendered_line(&terminal, 0).contains(" todo · note · checks-pass · git note "), + "the enrich group advertises all note actions" + ); + app.enrich_expanded = false; + + app.selected = None; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!( + rendered_line(&terminal, 0).contains("subject"), + "unselected rows retain their commit title" + ); + app.selected = Some(0); + app.set_enrichment( + id, + crate::enrich::Enrichment { + todo: false, + note: Some("follow-up *title*\n\nwhy it matters\n".into()), + }, + ); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!( + rendered_line(&terminal, 0).contains("follow-up title subject"), + "a note prefixes the selected title independently of todo" + ); + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); + assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "📝"); + assert_eq!(terminal.backend().buffer()[(4, 0)].symbol(), "✔️"); + assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), ">"); + assert_eq!(terminal.backend().buffer()[(8, 0)].symbol(), "●"); + Ok(()) + } + + #[test] + fn gutter_columns_and_history_offset_stay_fixed_while_scrolling() -> Result<(), Box<dyn std::error::Error>> { + let ids = [5, 4, 3, 2, 1].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let mut app = App::new(1); + app.extend_commits( + ids.into_iter() + .enumerate() + .map(|(index, id)| Commit { + id, + parent_ids: ids.get(index + 1).copied().into_iter().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + let ids: Vec<_> = app.rows.iter().map(|row| row.id).collect(); + app.set_enrichment(ids[0], crate::enrich::Enrichment { todo: true, note: None }); + app.set_enrichment( + ids[1], + crate::enrich::Enrichment { + todo: false, + note: Some("note".into()), + }, + ); + app.set_tree_enrichment(ids[2], crate::enrich::TreeEnrichment { checks_pass: true }); + app.set_change_ids( + std::collections::HashMap::new(), + std::collections::HashSet::from([ids[3]]), + ); + app.arm_rebase_conflict(ids[4]); + + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + for (index, marker_x, marker) in [(4, 8, "💥"), (3, 6, "👯‍♂️"), (2, 4, "✔️"), (1, 2, "📝"), (0, 0, "🚧")] + { + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let row = terminal.backend().buffer(); + assert_eq!(row[(marker_x, 0)].symbol(), marker, "row {index} keeps its marker slot"); + assert_eq!(row[(10, 0)].symbol(), ">", "row {index} keeps the status column"); + assert_eq!(row[(12, 0)].symbol(), "●", "row {index} keeps the graph column"); + for empty_x in [0, 2, 4, 6, 8].into_iter().filter(|x| *x != marker_x) { + assert_eq!( + row[(empty_x, 0)].symbol(), + " ", + "row {index} clears the unused gutter slot at {empty_x}" + ); + } + app.update(Action::MoveUp); + } + Ok(()) + } + + #[test] + fn keeps_the_completed_footer_while_background_progress_is_deferred() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::new(0, 0), + committer_time: gix::date::Time::new(86_400, 0), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + let mut terminal = Terminal::new(TestBackend::new(160, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let completed = rendered_line(&terminal, 1); + + app.deferred_history_state = Some(State::Complete); + app.state = State::Computing; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + rendered_line(&terminal, 1), + completed, + "short lane computation preserves the completed footer" + ); + + app.deferred_history_state = None; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let computing = rendered_line(&terminal, 1); + assert!( + computing.contains("1 commits · p command · view · actions · enrich · copy") + && computing.contains("computing"), + "expired deferral reveals computation progress" + ); + assert_ne!(computing, completed, "visible progress changes the footer"); + + app.deferred_history_state = Some(State::Complete); + app.state = State::Loading; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + rendered_line(&terminal, 1), + completed, + "short traversal setup also preserves the completed footer" + ); + Ok(()) + } + + #[test] + fn materialized_rebase_continuation_uses_a_persistent_notice_above_the_footer() + -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + complete(&mut app); + app.arm_rebase_continuation(); + app.set_worktree_conflicted(true); + app.leave_attention("materialized conflict"); + let mut terminal = Terminal::new(TestBackend::new(120, 4))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let notice = rendered_line(&terminal, 2); + assert!( + notice + .trim_start() + .starts_with("REBASE PAUSED · resolve conflicts, then <enter> continue · Esc stop"), + "an unresolved continuation owns the notice: {notice:?}" + ); + assert!( + notice.contains("materialized conflict"), + "operation context is retained beside the persistent prompt" + ); + assert_eq!(terminal.backend().buffer()[(2, 2)].bg, Color::Yellow); + assert!( + rendered_line(&terminal, 3).contains("view"), + "the ordinary footer remains visible" + ); + + app.information_expanded = true; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + None, + ); + })?; + assert!(rendered_line(&terminal, 0).trim_start().starts_with("REBASE PAUSED")); + assert!( + rendered_line(&terminal, 1).contains("[ title"), + "the popup keeps its action row" + ); + assert!(rendered_line(&terminal, 2).contains("p command")); + app.information_expanded = false; + + app.update(Action::MoveDown); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!( + rendered_line(&terminal, 2).trim_start().starts_with("REBASE PAUSED"), + "navigation cannot dismiss the continuation" + ); + app.leave_error("continue failed"); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!(terminal.backend().buffer()[(2, 2)].bg, Color::LightRed); + app.update(Action::MoveDown); + app.set_worktree_conflicted(false); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!(terminal.backend().buffer()[(2, 2)].bg, Color::Yellow); + assert!( + rendered_line(&terminal, 2) + .trim_start() + .starts_with("REBASE PAUSED · <enter> continue · Esc stop") + ); + + app.clear_rebase_continuation(); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(!(0..4).any(|y| rendered_line(&terminal, y).contains("REBASE PAUSED"))); + Ok(()) + } + + #[test] + fn an_offscreen_popup_does_not_move_a_notice() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.information_expanded = true; + app.leave_success("notice"); + let mut terminal = Terminal::new(TestBackend::new(10, 3))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + assert!(rendered_line(&terminal, 1).contains("notice")); + assert!(!rendered_line(&terminal, 0).contains("notice")); + Ok(()) + } + + #[test] + fn a_popup_is_suppressed_when_a_notice_cannot_move() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.information_expanded = true; + app.leave_success("notice"); + let mut terminal = Terminal::new(TestBackend::new(120, 2))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + assert!(rendered_line(&terminal, 0).contains("notice")); + assert!(!rendered_line(&terminal, 0).contains("[ title")); + Ok(()) + } + + #[test] + fn renders_selection_info_beside_the_right_marker_without_dimming_it() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(2); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "a subject which is deliberately too long".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + app.selection_relation = Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }); + let changes = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "file".into(), + lines: Some((3, 4)), + }], + lines_added: 3, + lines_removed: 4, + ..Changes::default() + }; + app.changes_focus = Some(ChangePane::Tree); + let mut terminal = Terminal::new(TestBackend::new(38, 7))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + let row = rendered_row(&terminal); + let info = "+3 -4 ⇡1⇣2"; + let info_byte = row.find(info).expect("selection info wins over the long subject"); + let info_x = row[..info_byte].chars().count() as u16; + let buffer = terminal.backend().buffer(); + assert_eq!( + buffer[(info_x - 1, 0)].symbol(), + " ", + "selection info has a left margin" + ); + assert_eq!(buffer[(info_x, 0)].fg, Color::Green); + assert_eq!(buffer[(info_x + 3, 0)].fg, Color::LightRed); + assert!(!buffer[(info_x, 0)].modifier.contains(Modifier::DIM)); + assert!( + !buffer[(info_x, 0)].modifier.contains(Modifier::REVERSED), + "contextual selection information stays outside the row inversion" + ); + let spacer_x = info_x + info.chars().count() as u16; + assert_eq!(buffer[(spacer_x, 0)].symbol(), " ", "the marker has a left spacer"); + assert!( + buffer[(spacer_x + 1, 0)].modifier.contains(Modifier::REVERSED), + "the right selection block follows the spacer" + ); + assert_eq!( + buffer[(spacer_x + 1, 0)].symbol(), + " ", + "the right selection block never inverts text" + ); + + app.selection_relation = None; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(36, 0)].symbol(), " ", "a plain marker has a left spacer"); + assert_eq!(buffer[(37, 0)].symbol(), " ", "a plain marker never inverts text"); + assert!(buffer[(37, 0)].modifier.contains(Modifier::REVERSED)); + + let text = |relation| { + selection_info_line(None, relation, None) + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::<String>() + }; + assert_eq!(text(Some(SelectionRelation::Tracking { ahead: 0, behind: 2 })), "⇣2"); + assert_eq!(text(Some(SelectionRelation::Tracking { ahead: 0, behind: 0 })), ""); + assert!( + selection_info_line(Some(&Changes::default()), None, None) + .spans + .is_empty(), + "selection information hides empty diff counts" + ); + Ok(()) + } + + #[test] + fn renders_the_topological_child_choice_outside_selection_inversion() -> Result<(), Box<dyn std::error::Error>> { + let ids = [1, 2, 3].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let commit = |id, parent: Option<gix::ObjectId>, title: &'static str| Commit { + id, + parent_ids: parent.into_iter().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: title.into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(3); + app.extend_commits(vec![ + commit(ids[2], Some(ids[0]), "first child"), + commit(ids[1], Some(ids[0]), "second child"), + commit(ids[0], None, "fork"), + ]); + complete(&mut app); + app.select_commit(ids[0]); + app.update(Action::NextChild); + let mut terminal = Terminal::new(TestBackend::new(80, 4))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let row = rendered_line(&terminal, 2); + let choice_byte = row.find("2/2").expect("the selected fork shows its child choice"); + let choice_x = row[..choice_byte].chars().count() as u16; + let cell = &terminal.backend().buffer()[(choice_x, 2)]; + assert_eq!(cell.fg, Color::Yellow); + assert!(cell.modifier.contains(Modifier::BOLD)); + assert!( + !cell.modifier.contains(Modifier::REVERSED), + "the choice annotation does not extend row inversion" + ); + Ok(()) + } + + #[test] + fn renders_a_colored_file_diff_pager() -> Result<(), Box<dyn std::error::Error>> { + let diff = BuiltInDiff::new( + "M file".into(), + ["--- a/file", "+++ b/file", "@@ -1 +1 @@", "-old", "+new"] + .into_iter() + .map(Into::into) + .collect(), + ); + let mut terminal = Terminal::new(TestBackend::new(48, 7))?; + + terminal.draw(|frame| draw_file_diff(frame, &diff, 0, 0))?; + + assert_eq!(rendered_line(&terminal, 0).trim(), "M file"); + for (y, color) in [ + (1, Color::LightRed), + (2, Color::Green), + (3, Color::Cyan), + (4, Color::LightRed), + (5, Color::Green), + ] { + assert_eq!(terminal.backend().buffer()[(0, y)].fg, color); + } + assert!(rendered_line(&terminal, 6).contains("<enter>/q/Esc back")); + Ok(()) + } + + #[test] + fn renders_and_streams_compact_commit_diff_summaries() -> Result<(), Box<dyn std::error::Error>> { + let row = Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: 0..0, + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + let mailmap = + gix::mailmap::Snapshot::from_bytes(b"mapped author <mapped@example.com> author <author@example.com>\n"); + let title = commit_diff_title(&row, b"subject".as_bstr(), &mailmap, true, false); + assert_eq!(title, "0101010 mapped author subject"); + assert_eq!( + commit_diff_title(&row, b"subject".as_bstr(), &mailmap, true, true), + "0101010 mapped author <mapped@example.com> subject" + ); + let changes = Changes { + paths: vec![ + crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "new".into(), + lines: None, + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "old".into(), + lines: None, + }, + crate::app::PathChange { + kind: ChangeKind::Deleted, + group: ChangeGroup::Tree, + source: None, + path: "gone".into(), + lines: None, + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "image.bin".into(), + lines: None, + }, + ], + ..Changes::default() + }; + let diff = BuiltInDiff::new( + title.clone(), + ["--- a/old", "+++ b/old"].into_iter().map(Into::into).collect(), + ) + .with_summary(commit_diff_summary( + &changes, + &[Some((2, 0)), Some((1, 1)), Some((0, 3)), None], + 3, + 4, + )); + let mut terminal = Terminal::new(TestBackend::new(64, 9))?; + + terminal.draw(|frame| draw_file_diff(frame, &diff, 0, 0))?; + + assert_eq!(rendered_line(&terminal, 0).trim(), title); + assert_eq!(rendered_line(&terminal, 1).trim(), "new | 2 ++ +2"); + assert_eq!(rendered_line(&terminal, 2).trim(), "old | 2 +- 0"); + assert_eq!(rendered_line(&terminal, 3).trim(), "gone | 3 --- -3"); + assert_eq!(rendered_line(&terminal, 4).trim(), "image.bin | Bin"); + let summary = "root · A 1 + M 2 + D 1 = 4 · +3 -4"; + assert_eq!(rendered_line(&terminal, 5).trim(), summary); + let buffer = terminal.backend().buffer(); + let summary_x = |needle| { + summary[..summary.find(needle).expect("summary term is present")] + .chars() + .count() as u16 + }; + assert_eq!(buffer[(0, 5)].fg, COMPARED_PARENT_COLOR); + assert_eq!(buffer[(summary_x("A 1"), 5)].fg, Color::Green); + assert_eq!(buffer[(summary_x("-4"), 5)].fg, Color::LightRed); + let delta_x = rendered_line(&terminal, 1).find("+2").expect("positive delta") as u16; + let zero_x = rendered_line(&terminal, 2).rfind('0').expect("zero delta") as u16; + assert_eq!( + delta_x + 2, + zero_x + 1, + "deltas are right-aligned despite different sign widths" + ); + assert_eq!( + delta_x, + rendered_line(&terminal, 3).find("-3").expect("negative delta") as u16 + ); + assert_eq!(buffer[(delta_x, 1)].fg, Color::Green); + assert_eq!(buffer[(zero_x, 2)].fg, Color::Reset); + assert_eq!(buffer[(delta_x, 3)].fg, Color::LightRed); + assert_eq!(rendered_line(&terminal, 6).trim(), ""); + assert_eq!(rendered_line(&terminal, 7).trim(), "--- a/old"); + + let mut streamed = Vec::new(); + diff.write_to(&mut streamed)?; + assert_eq!( + streamed, + b"0101010 mapped author subject\n new | 2 ++ +2\n old | 2 +- 0\n gone | 3 --- -3\n image.bin | Bin\nroot \xc2\xb7 A 1 + M 2 + D 1 = 4 \xc2\xb7 +3 -4 \n\n--- a/old\n+++ b/old\n" + ); + Ok(()) + } + + #[test] + fn renders_grouped_attributions_and_bot_names() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.id_mode = IdMode::Commit; + app.extend_commits(LoadedCommits { + rows: vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"Codex", b"codex@openai.com"), + attributions: 0..8, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }], + attributions: vec![ + Attribution { + kind: AttributionKind::CoAuthor, + author: author(b"Claude", b"noreply@anthropic.com"), + }, + Attribution { + kind: AttributionKind::CoAuthor, + author: author(b"Codex", b"codex@openai.com"), + }, + Attribution { + kind: AttributionKind::Assisted, + author: author(b"Claude", b"noreply@anthropic.com"), + }, + Attribution { + kind: AttributionKind::Assisted, + author: author(b"Codex", b"codex@openai.com"), + }, + Attribution { + kind: AttributionKind::Reviewed, + author: author(b"Human", b"human@example.com"), + }, + Attribution { + kind: AttributionKind::Acked, + author: author(b"Acknowledger", b"ack@example.com"), + }, + Attribution { + kind: AttributionKind::Tested, + author: author(b"Tester", b"tester@example.com"), + }, + Attribution { + kind: AttributionKind::SignedOff, + author: author(b"Signer", b"signer@example.com"), + }, + ], + }); + app.selected = None; + app.history_display_expanded = true; + let mut terminal = Terminal::new(TestBackend::new(160, 3))?; + + let mailmap = + gix::mailmap::Snapshot::from_bytes(b"Mapped Human <mapped@example.com> Human <human@example.com>\n"); + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; + + assert!( + rendered_line(&terminal, 2).contains(" · actions ·"), + "the commit and actions prefixes remain available beside the view group" + ); + + let row = rendered_row(&terminal); + assert!( + row.contains("[Codex] Co, A: [Claude], * Re: Mapped Human Ack: Acknowledger Te: Tester So: Signer subject"), + "attributions with identical displayed actors share their markers" + ); + let buffer = terminal.backend().buffer(); + let style_at = |needle: &str| { + let x = row.find(needle).expect("rendered metadata contains the named actor") as u16; + buffer[(x, 0)].fg + }; + assert_eq!(style_at("[Codex]"), Color::Green, "bot authors use the agent color"); + assert_eq!( + style_at("Co, A:"), + Color::Green, + "grouped attribution markers use the agent color" + ); + let marker_x = row.find("Co, A:").expect("rendered metadata contains a trailer marker") as u16; + assert!( + buffer[(marker_x, 0)].modifier.contains(Modifier::DIM), + "attribution markers are dimmed" + ); + assert_eq!(style_at("Human"), Color::Green, "human trailer actors are green"); + assert_eq!(style_at("[Claude]"), Color::Green, "bot co-authors use agent styling"); + assert!( + rendered_line(&terminal, 1).contains("trailers"), + "the popout advertises the trailer toggle" + ); + + app.update(Action::ToggleTrailers); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(!rendered_row(&terminal).contains("Co:"), "t hides trailer attribution"); + + app.update(Action::ToggleTrailers); + app.update(Action::ToggleName); + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; + let row = rendered_row(&terminal); + assert!(row.contains("Codex"), "the first n keeps the primary actor"); + assert!( + !row.contains("Mapped Human"), + "the first n hides trailer actors while trailers are enabled" + ); + app.update(Action::ToggleName); + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; + let row = rendered_row(&terminal); + assert!(!row.contains("Codex"), "the second n hides the primary actor"); + assert!( + !row.contains("Mapped Human"), + "the second n keeps trailer actors hidden" + ); + app.update(Action::ToggleName); + app.update(Action::ToggleMailmap); + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; + assert!( + rendered_row(&terminal).contains("Re: Human"), + "m restores original trailer actor names" + ); + + app.update(Action::ToggleEmail); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let row = rendered_row(&terminal); + assert!(row.contains("Human <human@example.com>")); + assert!(!row.contains("codex@openai.com")); + assert!(!row.contains("noreply@anthropic.com")); + Ok(()) + } + + #[test] + fn toggles_full_actor_and_comment() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "unique comment".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + app.selected = None; + let mut terminal = Terminal::new(TestBackend::new(100, 2))?; + + app.update(Action::ToggleEmail); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_row(&terminal).contains("author <author@example.com>")); + assert!(!rendered_row(&terminal).contains("unique comment")); + + app.update(Action::ToggleEmail); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(!rendered_row(&terminal).contains("<author@example.com>")); + assert!(rendered_row(&terminal).contains("unique comment")); + Ok(()) + } + + #[test] + fn italicizes_github_noreply_actors() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.extend_commits(LoadedCommits { + rows: vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"Author", b"1+author@users.noreply.github.com"), + attributions: 0..1, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }], + attributions: vec![Attribution { + kind: AttributionKind::Reviewed, + author: author(b"Reviewer", b"reviewer@USERS.NOREPLY.GITHUB.COM"), + }], + }); + app.selected = None; + app.update(Action::ToggleEmail); + let mut terminal = Terminal::new(TestBackend::new(160, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let row = rendered_row(&terminal); + for actor in [ + "Author <1+author@users.noreply.github.com>", + "Reviewer <reviewer@USERS.NOREPLY.GITHUB.COM>", + ] { + let start = row.find(actor).expect("the full actor is rendered") as u16; + for x in start..start + actor.len() as u16 { + assert!(terminal.backend().buffer()[(x, 0)].modifier.contains(Modifier::ITALIC)); + } + } + Ok(()) + } + + #[test] + fn renders_rows_decorations_selection_and_footer() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(2); + app.id_mode = IdMode::Commit; + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::new(0, 0), + committer_time: gix::date::Time::new(86_400, 0), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + let decorations = Decorations::from([( + id, + vec![ + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + Decoration { + name: "refs/patches/main/patch".into(), + kind: DecorationKind::Special, + }, + ], + )]); + let mailmap = + gix::mailmap::Snapshot::from_bytes(b"mapped author <mapped@example.com> author <author@example.com>\n"); + let mut terminal = Terminal::new(TestBackend::new(180, 2))?; + + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + + assert_eq!( + super::todo_metadata(&app, &app.rows[0], &mailmap), + "1970-01-01 mapped author subject", + "todo commit metadata uses the author date and excludes separately represented refs" + ); + + let footer_text = "#0 · p command · view · actions · enrich · copy · refs · ? · quit"; + let selected_line = " > @ 0101010 1970-01-01 mapped author subject"; + let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); + for x in 0..selected_line.chars().count() as u16 { + expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); + } + for x in 6..10 { + expected[(x, 0)].set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); + } + for x in 10..17 { + expected[(x, 0)].set_style( + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::REVERSED | Modifier::BOLD), + ); + } + for x in 18..29 { + expected[(x, 0)].set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); + } + for x in 29..43 { + expected[(x, 0)].set_style(Style::default().fg(Color::Green).add_modifier(Modifier::REVERSED)); + } + expected[(selected_line.chars().count() as u16 + 2, 0)] + .set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); + for (label, key) in [ + ("p command", 'p'), + ("view", 'v'), + ("actions", 'a'), + ("enrich", 'n'), + ("copy", 'y'), + ("refs", 'r'), + ("quit", 'q'), + ] { + let label_start = footer_text[..footer_text.find(label).expect("shortcut label is present")] + .chars() + .count(); + let key_offset = label[..label.find(key).expect("shortcut key is in its label")] + .chars() + .count(); + expected[((label_start + key_offset) as u16, 1)] + .modifier + .insert(Modifier::UNDERLINED); + } + let information = footer_text[..footer_text.find('?').expect("the information prefix is present")] + .chars() + .count(); + expected[(information as u16, 1)].modifier.insert(Modifier::UNDERLINED); + terminal.backend().assert_buffer(&expected); + + let row = terminal.backend().buffer(); + assert!( + (0..selected_line.chars().count() as u16).all(|x| row[(x, 0)].modifier.contains(Modifier::REVERSED)), + "the current worktree selection is reversed through its title" + ); + assert!( + !row[(selected_line.chars().count() as u16, 0)] + .modifier + .contains(Modifier::REVERSED), + "the current worktree selection ends after its title" + ); + assert!( + !rendered_row(&terminal).contains("HEAD"), + "the graph marker makes textual HEAD redundant" + ); + assert!( + !rendered_line(&terminal, 1).contains("Esc cancel"), + "completed work cannot be cancelled" + ); + + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; + let rendered = rendered_row(&terminal); + let title_byte = rendered.find("subject").expect("the selected commit title is visible"); + let title_x = rendered[..title_byte].chars().count() as u16; + let row = terminal.backend().buffer(); + assert!( + (0..title_x.saturating_sub(1)).all(|x| row[(x, 0)].modifier.contains(Modifier::REVERSED)), + "an off-worktree selection reverses through its non-title metadata" + ); + assert!( + (title_x.saturating_sub(1)..title_x + "subject".len() as u16) + .all(|x| !row[(x, 0)].modifier.contains(Modifier::REVERSED)), + "an off-worktree selection leaves a margin and its title uninverted" + ); + + app.unseen_filesystem_redraw = true; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + for (x, _) in footer_text + .chars() + .enumerate() + .filter(|(_, character)| *character == '·') + { + assert_eq!( + terminal.backend().buffer()[(x as u16, 1)].symbol(), + "●", + "status separators become prominent notification discs" + ); + assert_eq!( + terminal.backend().buffer()[(x as u16, 1)].fg, + FILESYSTEM_NOTIFICATION_COLOR, + "every status separator marks an unseen filesystem redraw" + ); + } + assert_eq!( + terminal.backend().buffer()[(0, 1)].fg, + Color::Reset, + "notification coloring does not affect status text" + ); + app.unseen_filesystem_redraw = false; + + app.leave_attention("worktree removed; using common repository"); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert_eq!( + rendered_line(&terminal, 0).trim(), + "worktree removed; using common repository", + "recovery information uses the body row above the status" + ); + assert_eq!(terminal.backend().buffer()[(2, 0)].bg, Color::Yellow); + assert_eq!( + rendered_line(&terminal, 1).trim(), + footer_text, + "the status remains visible" + ); + + app.history_display_expanded = true; + app.update(Action::ToggleMailmap); + assert!(app.notice().is_none(), "the next action clears the notice"); + let mut terminal = Terminal::new(TestBackend::new(180, 3))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + rendered_row(&terminal).contains(" author subject"), + "m restores the original author name" + ); + assert!(popup_is_dim(&terminal, "mailmap"), "disabled mailmap is dimmed"); + + app.history_display_expanded = false; + app.leave_attention("no commit created: no input was provided"); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert_eq!( + rendered_line(&terminal, 1).trim(), + "no commit created: no input was provided", + "an unchanged new-commit editor explains why nothing happened" + ); + app.leave_error("operation failed"); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::LightRed); + app.history_display_expanded = true; + app.update(Action::ToggleMailmap); + + app.update(Action::ToggleDate); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + rendered_row(&terminal).contains("1970-01-02"), + "the first d switches to the committer date" + ); + assert!(rendered_line(&terminal, 1).contains("committer date")); + + app.update(Action::ToggleDate); + app.update(Action::ToggleName); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + let row = rendered_row(&terminal); + assert!(!row.contains("1970-01-0"), "the second d hides dates"); + assert!( + !row.contains("author"), + "the first n hides the author when there are no attributions" + ); + assert!(!row.contains("refs/patches"), "special refs are hidden until requested"); + assert!(row.contains("subject"), "the commit subject remains visible"); + assert!(popup_is_dim(&terminal, "date"), "disabled date is dimmed"); + assert!(popup_is_dim(&terminal, "name"), "disabled name is dimmed"); + + app.update(Action::ToggleName); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + rendered_row(&terminal).contains("author"), + "the second n restores the author name" + ); + assert!(!popup_is_dim(&terminal, "name"), "the restored name mode is not dimmed"); + + app.update(Action::CycleRefs); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!(!rendered_row(&terminal).contains("HEAD"), "no refs hides regular refs"); + assert!( + rendered_row(&terminal).trim_start().starts_with("> @"), + "HEAD keeps its graph marker" + ); + assert!( + !rendered_row(&terminal).contains("refs/patches"), + "no refs hides special refs" + ); + assert!(popup_is_dim(&terminal, "no refs"), "no refs is dimmed"); + + app.update(Action::CycleRefs); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + !rendered_row(&terminal).contains("HEAD"), + "all refs still omits redundant HEAD" + ); + assert!( + rendered_row(&terminal).contains("refs/patches"), + "all refs shows special refs" + ); + assert!(!popup_is_dim(&terminal, "all refs"), "all refs is not dimmed"); + + app.update(Action::CycleRefs); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + !rendered_row(&terminal).contains("HEAD"), + "refs still omits redundant HEAD" + ); + assert!( + !rendered_row(&terminal).contains("refs/patches"), + "refs hides special refs" + ); + assert!(!popup_is_dim(&terminal, "refs"), "refs is not dimmed"); + + app.has_hidden_filter = true; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + rendered_line(&terminal, 1).contains("show hidden"), + "the popout advertises the configured hidden-history toggle" + ); + app.show_hidden = true; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert!( + rendered_line(&terminal, 1).contains("hide hidden"), + "the popout reflects the unfiltered view" + ); + + Ok(()) + } + + #[test] + fn advertises_travel_and_return_for_non_head_rows() -> Result<(), Box<dyn std::error::Error>> { + let selected = gix::ObjectId::Sha1([1; 20]); + let head = gix::ObjectId::Sha1([2; 20]); + let mut app = App::new(2); + app.id_mode = IdMode::Commit; + app.extend_commits(vec![Commit { + id: selected, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + let mut decorations = Decorations::from([ + ( + selected, + vec![ + Decoration { + name: "pin:01010101".into(), + kind: DecorationKind::Pin, + }, + Decoration { + name: "pin:01010102".into(), + kind: DecorationKind::Pin, + }, + Decoration { + name: "stash".into(), + kind: DecorationKind::Stash, + }, + ], + ), + ( + head, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + ), + ]); + let mut terminal = Terminal::new(TestBackend::new(140, 4))?; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let row = rendered_row(&terminal); + assert!(row.contains("📌"), "a pinned commit has an obvious pin marker: {row:?}"); + assert_eq!(row.matches("📌").count(), 1, "multiple pins share one visual marker"); + assert!(!row.contains("pin:01010101"), "pin identities stay out of history rows"); + let hash = row.find("0101010").expect("the row contains its hash"); + let pin = row.find("📌").expect("the row contains its pin"); + let gift = row.find("🎁").expect("the row contains its stash marker"); + let date = row.find("1970-01-01").expect("the row contains its date"); + assert!( + pin < gift && gift < date, + "resource markers form one compact group: {row:?}" + ); + assert!( + hash < pin && pin < date, + "the pin sits between the hash and metadata: {row:?}" + ); + std::sync::Arc::make_mut(&mut app.rows[0]).is_review = true; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let row = rendered_row(&terminal); + let review = row.find("◆").expect("a review has its resource marker"); + let pin = row.find("📌").expect("the row contains its pin"); + let gift = row.find("🎁").expect("the row contains its stash marker"); + assert!( + review < pin && pin < gift, + "review is the first resource marker: {row:?}" + ); + std::sync::Arc::make_mut(&mut app.rows[0]).is_review = false; + app.ref_mode = RefMode::None; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let row = rendered_row(&terminal); + assert!( + row.contains("📌") && row.contains("🎁"), + "hiding refs retains resource markers: {row:?}" + ); + app.actions_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + rendered_line(&terminal, 1).contains(" reword · new · new-empty · d forget · unpin "), + "the commit actions float above their prefix" + ); + assert!( + rendered_line(&terminal, 3).contains("actions · enrich · @ return · copy"), + "time travel stays outside the active actions prefix" + ); + + decorations.remove(&selected); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!(rendered_line(&terminal, 3).contains(" · @ travel · copy")); + assert!(!rendered_line(&terminal, 1).contains("unpin")); + + app.ref_mode = RefMode::Default; + decorations.insert( + selected, + vec![Decoration { + name: "main".into(), + kind: DecorationKind::HeadPinBranch, + }], + ); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let row = rendered_row(&terminal); + assert!( + row.contains("★main"), + "the remembered branch has its dedicated marker: {row:?}" + ); + assert!(!row.contains("📌"), "the HEAD pin is not an ordinary pin: {row:?}"); + assert!(rendered_line(&terminal, 3).contains(" · @ travel · copy")); + assert!(!rendered_line(&terminal, 1).contains("unpin")); + + decorations.remove(&head); + decorations.insert( + selected, + vec![ + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + Decoration { + name: "main".into(), + kind: DecorationKind::HeadPinBranch, + }, + ], + ); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let footer = rendered_line(&terminal, 3); + assert!( + !footer.contains("@ travel") && !footer.contains("@ return"), + "time travel is hidden at HEAD: {footer}" + ); + Ok(()) + } + + #[test] + fn visually_groups_active_prefix_options() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.changes_mode = None; + app.configure_hidden_filter(true); + app.history_display_expanded = true; + let mut terminal = Terminal::new(TestBackend::new(240, 3))?; + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + None, + ); + })?; + + let footer = rendered_line(&terminal, 2); + let compact = "0 commits · p command · view · actions · enrich · copy · refs · ? · Esc cancel · quit"; + assert_eq!(footer.trim_end(), compact, "the footer keeps every prefix compact"); + let view = "author date · ids · emails · names · mailmap · trailers · refs · show hidden"; + let popup = rendered_line(&terminal, 1); + let view_x = footer[..footer.find("view").expect("the view prefix is visible")] + .chars() + .count() as u16; + let popup_x = popup[..popup.find(view).expect("the view items are visible")] + .chars() + .count() as u16 + - 1; + assert_eq!(popup_x, view_x, "the popout is connected to its footer prefix"); + assert!( + terminal.backend().buffer()[(view_x, 2)] + .modifier + .contains(Modifier::UNDERLINED), + "the prefix key is underlined in its verb" + ); + assert_reversed_group(&terminal, 2, "view"); + assert_reversed_group(&terminal, 1, &format!(" {view} ")); + + app.history_display_expanded = false; + app.actions_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!(rendered_line(&terminal, 2).trim_end(), compact); + assert!(rendered_line(&terminal, 0).contains(" no actions ")); + assert!(rendered_line(&terminal, 1).contains(" no actions ")); + assert_reversed_group(&terminal, 2, "actions"); + assert_reversed_group(&terminal, 0, " no actions "); + assert_reversed_group(&terminal, 1, " no actions "); + + app.actions_expanded = false; + app.enrich_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!(rendered_line(&terminal, 2).trim_end(), compact); + assert!(rendered_line(&terminal, 1).contains(" no actions ")); + assert_reversed_group(&terminal, 2, "enrich"); + assert_reversed_group(&terminal, 1, " no actions "); + + app.enrich_expanded = false; + app.information_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!(rendered_line(&terminal, 2).trim_end(), compact); + let information = "[ title · ref-tree · message · changes"; + let navigation = "p command · ↑↓/jk move · h/l pan · Shift+directions topo · PgUp/PgDn move · Shift+PgUp/PgDn pan · <enter> diff"; + assert!(rendered_line(&terminal, 0).contains(information)); + assert!(rendered_line(&terminal, 1).contains(navigation)); + assert_reversed_group(&terminal, 2, "?"); + for (row, text) in [(0, information), (1, navigation)] { + let line = rendered_line(&terminal, row); + let x = line.find(text).expect("the popup row is visible") as u16; + assert!( + terminal.backend().buffer()[(x, row)] + .modifier + .contains(Modifier::REVERSED), + "the popup row is reversed" + ); + } + Ok(()) + } + + #[test] + fn actions_popup_shows_insert_shortcuts_without_the_cherry_prefix() -> Result<(), Box<dyn std::error::Error>> { + let head = gix::ObjectId::Sha1([1; 20]); + let base = gix::ObjectId::Sha1([2; 20]); + let parent = gix::ObjectId::Sha1([3; 20]); + let target = gix::ObjectId::Sha1([4; 20]); + let root = gix::ObjectId::Sha1([5; 20]); + let commit = |id, parent: Option<gix::ObjectId>| Commit { + id, + parent_ids: parent.into_iter().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(5); + app.set_worktree_head(Some(head), false); + app.set_worktree_branch(Some((head, true))); + app.extend_commits(vec![ + commit(head, Some(base)), + commit(base, Some(parent)), + commit(parent, Some(root)), + commit(root, None), + commit(target, None), + ]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == parent); + app.actions_expanded = true; + let mut terminal = Terminal::new(TestBackend::new(160, 5))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let popup = rendered_line(&terminal, 3); + assert!(popup.contains("copy-insert")); + assert!(popup.contains("move-insert")); + assert!(popup.contains("fork")); + assert!(popup.contains("attach")); + assert!(!popup.contains("cherry-")); + let label = "stack-insert"; + let start = popup[..popup.find(label).expect("the stack-insert action is visible")] + .chars() + .count() as u16; + let shortcut = start + label.len() as u16 - 1; + assert!( + terminal.backend().buffer()[(shortcut, 3)] + .modifier + .contains(Modifier::UNDERLINED) + ); + assert!( + (start..shortcut).all(|x| !terminal.backend().buffer()[(x, 3)] + .modifier + .contains(Modifier::UNDERLINED)), + "only the t in insert is underlined" + ); + Ok(()) + } + + #[test] + fn focused_paths_offer_only_their_scoped_edit_in_the_main_prefix() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(4); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + app.set_worktree_head(Some(id), false); + complete(&mut app); + app.changes_focus = Some(ChangePane::Tree); + app.actions_expanded = true; + let changes = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "file".into(), + lines: None, + }], + ..Changes::default() + }; + let mut decorations = Decorations::from([( + id, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + let mut terminal = Terminal::new(TestBackend::new(120, 8))?; + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + let popup = rendered_line(&terminal, 5); + assert!( + popup.contains(" spill "), + "tree focus keeps the scoped edit visible: {popup}" + ); + assert!(!popup.contains("amend"), "tree paths cannot be amended"); + + app.changes_focus = Some(ChangePane::Worktree); + let mut worktree = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: "file".into(), + lines: None, + }], + ..Changes::default() + }; + app.set_head_edit_availability(true, false, false, true, false, false, false); + assert!(app.can_amend(), "the focused worktree path is amendable"); + assert!(app.actions_expanded, "the actions prefix remains expanded"); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&worktree), + ); + })?; + assert!(app.can_amend(), "drawing retains scoped amend availability"); + assert!(app.actions_expanded, "drawing retains the expanded actions prefix"); + let popup = rendered_line(&terminal, 5); + assert!( + popup.contains(" amend "), + "worktree focus keeps the scoped edit visible: {popup}" + ); + assert!(!popup.contains("spill"), "worktree paths cannot be spilled"); + + app.changes_focus = None; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&worktree), + ); + })?; + assert!( + rendered_line(&terminal, 6).contains("stash"), + "loaded unconflicted worktree changes offer stashing" + ); + decorations + .get_mut(&id) + .expect("HEAD has decorations") + .push(Decoration { + name: "stash".into(), + kind: DecorationKind::Stash, + }); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&worktree), + ); + })?; + assert!( + rendered_line(&terminal, 6).contains("unstash"), + "an existing commit stash offers in-place restoration even with worktree changes" + ); + decorations + .get_mut(&id) + .expect("HEAD has decorations") + .retain(|decoration| decoration.kind != DecorationKind::Stash); + app.changes_focus = Some(ChangePane::Worktree); + + std::sync::Arc::make_mut(&mut app.rows[0]).is_review = true; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&worktree), + ); + })?; + assert!( + !rendered_line(&terminal, 5).contains("amend"), + "a review cannot amend an unstaged selected path" + ); + worktree.paths[0].group = ChangeGroup::Staged; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&worktree), + ); + })?; + assert!( + rendered_line(&terminal, 5).contains(" amend "), + "a review may amend its selected staged path" + ); + Ok(()) + } + + #[test] + fn renders_worktree_labels_and_keeps_them_selected_when_refs_are_hidden() -> Result<(), Box<dyn std::error::Error>> + { + let checked_out = gix::ObjectId::Sha1([1; 20]); + let other = gix::ObjectId::Sha1([2; 20]); + let commit = |id| Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(2); + app.extend_commits(vec![commit(checked_out), commit(other)]); + complete(&mut app); + let decorations = Decorations::from([( + checked_out, + vec![ + Decoration { + name: "current".into(), + kind: DecorationKind::CurrentWorktreeBranch, + }, + Decoration { + name: "main".into(), + kind: DecorationKind::WorktreeBranch, + }, + Decoration { + name: "detached".into(), + kind: DecorationKind::WorktreeDetached, + }, + Decoration { + name: "current-detached".into(), + kind: DecorationKind::CurrentWorktreeDetached, + }, + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + ], + )]); + let mut terminal = Terminal::new(TestBackend::new(140, 3))?; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let row = rendered_row(&terminal); + assert!(row.contains("@current")); + assert!(row.contains("main@")); + assert!(row.contains("detached@")); + assert!( + !row.contains("current-detached"), + "the graph @ already identifies the current detached worktree" + ); + assert!(!row.contains("HEAD"), "a worktree label replaces textual HEAD"); + let x = row.find("main@").expect("the worktree label is visible") as u16; + assert_eq!(terminal.backend().buffer()[(x, 0)].fg, Color::LightBlue); + let x = row.find("@current").expect("the current branch label is visible") as u16; + assert_eq!(terminal.backend().buffer()[(x, 0)].fg, Color::Cyan); + + app.update(Action::ToggleRefs); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + rendered_row(&terminal).contains("main@"), + "the selected row retains worktrees" + ); + assert!( + !rendered_row(&terminal).contains("@current"), + "the current branch follows ordinary reference visibility" + ); + app.update(Action::MoveDown); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + !rendered_row(&terminal).contains("main@"), + "hidden refs omit worktrees from unselected rows" + ); + Ok(()) + } + + #[test] + fn removes_the_copied_fields_color_from_only_the_selected_row_for_one_frame() + -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(2); + app.id_mode = IdMode::Commit; + app.extend_commits( + (1..=2) + .map(|n| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + + drop(app.update(Action::Copy)); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let selected_hash = rendered_line(&terminal, 0) + .find("0101010") + .expect("the selected hash is visible") as u16; + let other_hash = rendered_line(&terminal, 1) + .find("0202020") + .expect("the other hash is visible") as u16; + assert_eq!( + terminal.backend().buffer()[(selected_hash, 0)].fg, + Color::Reset, + "the copied hash loses its color" + ); + assert_eq!( + terminal.backend().buffer()[(other_hash, 1)].fg, + Color::Magenta, + "copy feedback does not affect other rows" + ); + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + terminal.backend().buffer()[(selected_hash, 0)].fg, + Color::Magenta, + "the hash color returns on the next frame" + ); + + drop(app.update(Action::CopyAuthor)); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let selected_author = rendered_line(&terminal, 0) + .find("author") + .expect("the selected author is visible") as u16; + let other_author = rendered_line(&terminal, 1) + .find("author") + .expect("the other author is visible") as u16; + assert_eq!( + terminal.backend().buffer()[(selected_author, 0)].fg, + Color::Reset, + "the copied author loses its color" + ); + assert_eq!( + terminal.backend().buffer()[(other_author, 1)].fg, + Color::Green, + "author feedback does not affect other rows" + ); + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + terminal.backend().buffer()[(selected_author, 0)].fg, + Color::Green, + "the author color returns on the next frame" + ); + Ok(()) + } + + #[test] + fn colors_commit_markers_by_signature_state() -> Result<(), Box<dyn std::error::Error>> { + let states = [ + (SignatureState::Unsigned, Color::Blue), + (SignatureState::Unverified, Color::Rgb(255, 165, 0)), + (SignatureState::Verified, Color::Green), + (SignatureState::Failed, Color::LightRed), + (SignatureState::PendingRebase, Color::Gray), + ]; + let mut terminal = Terminal::new(TestBackend::new(4, states.len() as u16))?; + terminal.draw(|frame| { + for (y, (state, _)) in states.iter().enumerate() { + for (x, head) in [(0, false), (2, true)] { + frame.render_widget(Paragraph::new("●─"), Rect::new(x, y as u16, 2, 1)); + color_graph( + frame, + Rect::new(x, y as u16, 2, 1), + "●─", + 0, + Some(signature_color(*state)), + *state, + head.then_some(HeadState { + has_descendants: false, + attached: false, + }), + ); + } + } + })?; + + for (y, (_, expected)) in states.iter().enumerate() { + assert_eq!(terminal.backend().buffer()[(0, y as u16)].symbol(), "●"); + assert_eq!(terminal.backend().buffer()[(2, y as u16)].symbol(), "@"); + for x in 0..4 { + let cell = &terminal.backend().buffer()[(x, y as u16)]; + assert_eq!(cell.fg, *expected); + assert!(cell.modifier.contains(Modifier::REVERSED)); + } + } + Ok(()) + } + + #[test] + fn review_commit_at_head_uses_the_head_marker() -> Result<(), Box<dyn std::error::Error>> { + let mut terminal = Terminal::new(TestBackend::new(2, 1))?; + terminal.draw(|frame| { + frame.render_widget(Paragraph::new("●─"), Rect::new(0, 0, 2, 1)); + color_graph( + frame, + Rect::new(0, 0, 2, 1), + "●─", + 0, + None, + SignatureState::Unsigned, + Some(HeadState { + has_descendants: true, + attached: false, + }), + ); + })?; + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "@"); + assert_eq!(terminal.backend().buffer()[(1, 0)].symbol(), "─"); + Ok(()) + } + + #[test] + fn italicizes_only_an_attached_head_marker() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + app.selected = None; + app.set_lane(0, "● "); + let mut terminal = Terminal::new(TestBackend::new(80, 2))?; + let head = Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }; + + let attached = Decorations::from([( + id, + vec![ + head.clone(), + Decoration { + name: "main".into(), + kind: DecorationKind::CurrentWorktreeBranch, + }, + ], + )]); + terminal.draw(|frame| draw(frame, &mut app, &attached))?; + let marker = &terminal.backend().buffer()[(8, 0)]; + assert_eq!(marker.symbol(), "@"); + assert!(marker.modifier.contains(Modifier::ITALIC), "attached HEAD is italic"); + + let detached = Decorations::from([( + id, + vec![ + head, + Decoration { + name: "worktree".into(), + kind: DecorationKind::CurrentWorktreeDetached, + }, + ], + )]); + terminal.draw(|frame| draw(frame, &mut app, &detached))?; + let marker = &terminal.backend().buffer()[(8, 0)]; + assert_eq!(marker.symbol(), "@"); + assert!(!marker.modifier.contains(Modifier::ITALIC), "detached HEAD is upright"); + Ok(()) + } + + #[test] + fn reverses_an_unselected_head_title_and_the_full_selected_row() -> Result<(), Box<dyn std::error::Error>> { + let head = gix::ObjectId::Sha1([1; 20]); + let child = gix::ObjectId::Sha1([2; 20]); + let commit = |id: gix::ObjectId, parent: Option<gix::ObjectId>| Commit { + id, + parent_ids: parent.into_iter().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(2); + app.id_mode = IdMode::Commit; + app.set_worktree_head(Some(head), false); + app.extend_commits(vec![commit(child, Some(head)), commit(head, None)]); + complete(&mut app); + app.selected = Some(0); + let decorations = Decorations::from([( + head, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let head_row = 1; + let line = rendered_line(&terminal, head_row); + let title = line.find("subject").expect("the HEAD title is visible") as u16; + for x in title..title + "subject".len() as u16 { + assert!( + terminal.backend().buffer()[(x, head_row)] + .modifier + .contains(Modifier::REVERSED), + "the unselected HEAD title is reversed" + ); + } + for x in [0, 6, 8, 11, 18, 79] { + assert!( + !terminal.backend().buffer()[(x, head_row)] + .modifier + .contains(Modifier::REVERSED), + "HEAD emphasis does not invert the gutter or metadata" + ); + } + assert!( + (0..80).all(|x| !terminal.backend().buffer()[(x, head_row)] + .modifier + .contains(Modifier::UNDERLINED)), + "the old HEAD underline is gone" + ); + assert!( + terminal.backend().buffer()[(8, head_row)] + .modifier + .contains(Modifier::BOLD), + "the non-tip @ is bold" + ); + + app.selected = Some(1); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + terminal.backend().buffer()[(title, head_row)] + .modifier + .contains(Modifier::REVERSED), + "selection reverses the HEAD title as part of the full row" + ); + assert!( + terminal.backend().buffer()[(8, head_row)] + .modifier + .contains(Modifier::BOLD), + "the selected non-tip @ remains bold" + ); + + app.set_worktree_head(Some(child), false); + let decorations = Decorations::from([( + child, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + app.selected = Some(1); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let tip_title = rendered_line(&terminal, 0) + .find("subject") + .expect("the tip title is visible") as u16; + assert!( + terminal.backend().buffer()[(tip_title, 0)] + .modifier + .contains(Modifier::REVERSED), + "an unselected tip HEAD title is reversed too" + ); + assert!( + !terminal.backend().buffer()[(8, 0)].modifier.contains(Modifier::BOLD), + "a tip @ keeps its normal weight" + ); + Ok(()) + } + + #[test] + fn highlights_foreign_worktree_head_titles_without_competing_with_current_head() + -> Result<(), Box<dyn std::error::Error>> { + let current = gix::ObjectId::Sha1([1; 20]); + let attached = gix::ObjectId::Sha1([2; 20]); + let detached = gix::ObjectId::Sha1([3; 20]); + let ordinary = gix::ObjectId::Sha1([4; 20]); + let commit = |id: gix::ObjectId, title: &'static str| Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: title.into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(4); + app.id_mode = IdMode::Commit; + app.ref_mode = RefMode::None; + app.set_worktree_head(Some(current), false); + app.extend_commits(vec![ + commit(current, "current title"), + commit(attached, "attached title"), + commit(detached, "detached title"), + commit(ordinary, "ordinary title"), + ]); + complete(&mut app); + app.selected = None; + let decorations = Decorations::from([ + ( + current, + vec![ + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + Decoration { + name: "shared".into(), + kind: DecorationKind::WorktreeBranch, + }, + ], + ), + ( + attached, + vec![Decoration { + name: "topic".into(), + kind: DecorationKind::WorktreeBranch, + }], + ), + ( + detached, + vec![Decoration { + name: "agent-wt".into(), + kind: DecorationKind::WorktreeDetached, + }], + ), + ]); + let mut terminal = Terminal::new(TestBackend::new(100, 5))?; + + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let current_x = rendered_line(&terminal, 0) + .find("current title") + .expect("the current HEAD title is visible") as u16; + assert!( + terminal.backend().buffer()[(current_x, 0)] + .modifier + .contains(Modifier::REVERSED), + "the current HEAD keeps its stronger reverse-video title" + ); + assert_eq!( + terminal.backend().buffer()[(current_x, 0)].bg, + Color::Reset, + "foreign worktrees do not replace current HEAD reverse video" + ); + for (row, title) in [(1, "attached title"), (2, "detached title")] { + let line = rendered_line(&terminal, row); + let title_x = line.find(title).expect("the foreign worktree title is visible") as u16; + let title_cell = &terminal.backend().buffer()[(title_x, row)]; + assert_eq!(title_cell.bg, Color::DarkGray, "the foreign HEAD title is shaded"); + assert!( + !title_cell.modifier.contains(Modifier::REVERSED), + "foreign HEAD titles remain less prominent than current HEAD" + ); + assert_eq!( + terminal.backend().buffer()[(0, row)].bg, + Color::Reset, + "the foreign HEAD background is limited to its title" + ); + assert!( + !line.contains('@'), + "hidden reference labels do not provide the highlight" + ); + } + let ordinary_line = rendered_line(&terminal, 3); + let ordinary_x = ordinary_line + .find("ordinary title") + .expect("the ordinary title is visible") as u16; + assert_eq!( + terminal.backend().buffer()[(ordinary_x, 3)].bg, + Color::Reset, + "ordinary commit titles keep the terminal background" + ); + + app.selected = Some(1); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let selected_line = rendered_line(&terminal, 1); + let selected_title = selected_line + .find("attached title") + .expect("the selected foreign worktree title is visible") as u16; + assert_eq!( + terminal.backend().buffer()[(selected_title, 1)].bg, + Color::Reset, + "selection takes precedence over foreign HEAD emphasis" + ); + assert!( + !terminal.backend().buffer()[(selected_title, 1)] + .modifier + .contains(Modifier::REVERSED), + "a selected foreign worktree title stays outside the row inversion" + ); + Ok(()) + } + + #[test] + fn marks_dirty_head_independently_of_history_selection() -> Result<(), Box<dyn std::error::Error>> { + let head = gix::ObjectId::Sha1([1; 20]); + let other = gix::ObjectId::Sha1([2; 20]); + let mut app = App::new(5); + app.extend_commits( + [head, other] + .into_iter() + .map(|id| Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + let decorations = Decorations::from([( + head, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + let dirty = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: "dirty".into(), + lines: None, + }], + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 8))?; + + app.selected = Some(1); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!(rendered_line(&terminal, 0).trim_start().starts_with("🫟 @")); + assert!(rendered_line(&terminal, 1).trim_start().starts_with("> ●")); + assert_eq!(terminal.backend().buffer()[(6, 0)].modifier, Modifier::empty()); + assert!( + terminal.backend().buffer()[(6, 1)] + .modifier + .contains(Modifier::REVERSED) + ); + + app.selected = Some(0); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!(rendered_line(&terminal, 0).trim_start().starts_with("🫟 @")); + assert!( + terminal.backend().buffer()[(6, 0)] + .modifier + .contains(Modifier::REVERSED) + ); + + let conflicted = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Unmerged, + group: ChangeGroup::Unstaged, + source: None, + path: "dirty".into(), + lines: None, + }], + ..Changes::default() + }; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&conflicted), + ); + })?; + assert!( + rendered_line(&terminal, 0).trim_start().starts_with("💥 🫟 @"), + "the conflict gutter precedes the ordinary status: {:?}", + rendered_line(&terminal, 0) + ); + assert_eq!(terminal.backend().buffer()[(6, 0)].fg, Color::LightRed); + assert!( + !terminal.backend().buffer()[(6, 0)] + .modifier + .contains(Modifier::SLOW_BLINK), + "the conflict marker remains steady" + ); + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!( + rendered_line(&terminal, 0).trim_start().starts_with("🫟 @"), + "resolving the index restores the ordinary dirty marker" + ); + + app.arm_rebase_conflict(other); + app.selected = Some(1); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!( + rendered_line(&terminal, 1).trim_start().starts_with("💥 > ●"), + "the conflict gutter does not replace selection: {:?}", + rendered_line(&terminal, 1) + ); + app.clear_rebase_conflict(); + app.selected = Some(0); + + app.changes_mode = Some(ChangesMode::Tree); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!(rendered_line(&terminal, 0).trim_start().starts_with("> @")); + Ok(()) + } + + #[test] + fn shows_signature_action_only_while_actionable() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unverified, + }]); + complete(&mut app); + let mut terminal = Terminal::new(TestBackend::new(160, 3))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_line(&terminal, 2).contains("copy · refs · ? ·")); + assert!(!rendered_line(&terminal, 2).contains("s ● -> ●")); + + app.information_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_line(&terminal, 0).contains(" s ● -> ● · [ title")); + assert!(rendered_line(&terminal, 1).contains("p command")); + assert!(rendered_line(&terminal, 2).contains("copy · refs · ?")); + + app.finish_signature_verification(vec![(id, false)]); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_line(&terminal, 0).contains(" s 1 ● · [ title")); + Ok(()) + } + + #[test] + fn advertises_cancel_only_while_loading() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + let mut terminal = Terminal::new(TestBackend::new(180, 2))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!( + !rendered_line(&terminal, 1).contains("loading"), + "loading is already apparent from the streaming history" + ); + assert!(rendered_line(&terminal, 1).contains("Esc cancel")); + + app.update(Action::Cancel); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(!rendered_line(&terminal, 1).contains("Esc cancel")); + Ok(()) + } + + #[test] + fn toggles_the_full_commit_message_in_a_padded_half_width_pane() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(3); + app.commit_pane_background = Some((15, 16, 17)); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + let mut terminal = Terminal::new(TestBackend::new(120, 8))?; + + app.information_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(popup_is_dim(&terminal, "message"), "the closed commit pane is dimmed"); + + app.update(Action::ToggleCommit); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject\n\nbody".as_bstr()), + None, + ); + })?; + assert_eq!( + terminal.backend().buffer()[(62, 1)].symbol(), + "s", + "the title starts after horizontal and vertical margins" + ); + assert_eq!( + terminal.backend().buffer()[(60, 0)].symbol(), + " ", + "the pane starts with padding instead of a border" + ); + assert_eq!( + terminal.backend().buffer()[(60, 0)].bg, + Color::Rgb(15, 16, 17), + "the commit pane has the derived terminal-background shade" + ); + assert_eq!( + terminal.backend().buffer()[(62, 1)].bg, + Color::Rgb(15, 16, 17), + "the shade extends behind the commit message" + ); + assert_eq!( + terminal.backend().buffer()[(59, 0)].bg, + Color::Reset, + "the history background is unchanged" + ); + assert_eq!( + terminal.backend().buffer()[(62, 3)].symbol(), + "b", + "the commit body remains separated from its title" + ); + assert!( + !popup_is_dim(&terminal, "message"), + "the open commit pane is not dimmed" + ); + + app.update(Action::ToggleCommit); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + terminal.backend().buffer()[(62, 3)].symbol(), + " ", + "closing the pane removes the commit body" + ); + assert_eq!( + terminal.backend().buffer()[(62, 3)].bg, + Color::Reset, + "closing the pane removes its background shade" + ); + + app.update(Action::ToggleCommit); + let mut wide_terminal = Terminal::new(TestBackend::new(200, 6))?; + let conventional_line = format!("{} word", "x".repeat(75)); + wide_terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(conventional_line.as_bytes().as_bstr()), + None, + ); + })?; + assert_eq!( + wide_terminal.backend().buffer()[(118, 1)].symbol(), + "x", + "the pane reserves eighty content columns on a wide screen" + ); + assert!( + rendered_line(&wide_terminal, 1) + .chars() + .skip(118) + .take(80) + .collect::<String>() + .ends_with(" word") + && rendered_line(&wide_terminal, 2) + .chars() + .skip(118) + .take(80) + .collect::<String>() + .trim() + .is_empty(), + "an eighty-column message line does not wrap its final word" + ); + Ok(()) + } + + #[test] + fn pages_overflowing_commit_messages_and_hides_the_status_when_they_fit() -> Result<(), Box<dyn std::error::Error>> + { + let mut app = App::new(4); + app.commit_pane_background = Some((15, 16, 17)); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }]); + app.update(Action::ToggleCommit); + let message = b"subject\n\none\ntwo\nthree\nfour\nfive\nsix\n\nSigned-off-by: Alice".as_bstr(); + let mut terminal = Terminal::new(TestBackend::new(120, 7))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(message), + None, + ); + })?; + assert!( + rendered_line(&terminal, 5).contains("PgUp/C-b up page · PgDn/C-f down page"), + "overflowing commit messages advertise both full-page key pairs" + ); + assert_eq!( + terminal.backend().buffer()[(62, 5)].bg, + PANE_STATUS_BACKGROUND, + "the commit status has the shared pane-status background" + ); + assert_eq!( + terminal.backend().buffer()[(0, 6)].bg, + Color::Reset, + "the main status keeps its original background" + ); + + app.information_expanded = true; + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(message), + None, + ); + })?; + assert!( + rendered_line(&terminal, 3).contains("PgUp/C-b up page · PgDn/C-f down page"), + "the popup moves the commit-message status up" + ); + assert!(rendered_line(&terminal, 4).contains("[ title")); + assert!(rendered_line(&terminal, 5).contains("p command")); + app.information_expanded = false; + + app.update(Action::PageDown); + app.update(Action::PageDown); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(message), + None, + ); + })?; + assert!( + rendered_line(&terminal, 4).contains("Alice"), + "the last page reaches aligned trailers" + ); + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject".as_bstr()), + None, + ); + })?; + assert!( + !rendered_line(&terminal, 5).contains("PgUp"), + "the commit status disappears when all content fits" + ); + assert_eq!(app.commit_offset, 0, "shorter content clamps the old offset"); + Ok(()) + } + + #[test] + fn popup_keeps_a_focused_changes_error_visible() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.id_mode = IdMode::Commit; + app.extend_commits( + (1..=5) + .map(|n| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("history {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.changes_focus = Some(ChangePane::Tree); + app.tree_changes.error = Some("failed deliberately".into()); + app.information_expanded = true; + let changes = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "file".into(), + lines: None, + }], + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(120, 6))?; + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + None, + ); + })?; + + assert!(rendered_line(&terminal, 2).contains("diff: failed deliberately")); + assert_eq!(terminal.backend().buffer()[(2, 2)].bg, PANE_STATUS_BACKGROUND); + assert!(rendered_line(&terminal, 3).contains("[ title")); + assert!(rendered_line(&terminal, 4).contains("p command")); + Ok(()) + } + + #[test] + fn changing_the_changes_height_keeps_history_alignment_stable() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(11); + app.id_mode = IdMode::Commit; + app.extend_commits( + (1..=10) + .map(|n| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.selected = Some(7); + app.ensure_visible(); + let selection = app.selected; + app.set_lane(6, "●──────── "); + let path = crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "path".into(), + lines: None, + }; + let changes = |len| Changes { + paths: vec![path.clone(); len], + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 12))?; - fn complete(app: &mut App) { - let rows = app - .start_lane_computation() - .expect("a loading app starts lane computation"); - let (rows, lanes, lane_time) = crate::app::compute_lanes(rows); - app.finish_lane_computation(rows, lanes, lane_time); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(1)), + ); + })?; + let short = rendered_line(&terminal, 0) + .find("0101010") + .expect("metadata is visible with a short changes pane"); + assert_eq!((app.selected, app.offset), (selection, 0)); + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(8)), + ); + })?; + assert_eq!( + rendered_line(&terminal, 0).find("0404040"), + Some(short), + "changes pane height does not move aligned history metadata" + ); + assert_eq!( + (app.selected, app.offset), + (selection, 3), + "the selected commit stays immediately above the taller changes pane" + ); + + app.update(Action::MoveDown); + assert_eq!( + (app.selected, app.offset), + (Some(8), 4), + "moving down advances the commit and scrolls history at the pane boundary" + ); + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(1)), + ); + })?; + assert_eq!( + (app.selected, app.offset), + (Some(8), 4), + "a shorter changes pane does not pull history back into the freed space" + ); + Ok(()) } #[test] - fn renders_grouped_attributions_and_bot_names() -> Result<(), Box<dyn std::error::Error>> { - let mut app = App::new(1); - app.extend_commits(LoadedCommits { - rows: vec![Commit { + fn shows_changed_paths_in_a_bottom_pane_below_the_summary() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(6); + app.id_mode = IdMode::Commit; + app.commit_pane_background = Some((15, 16, 17)); + app.extend_commits(vec![ + Commit { id: gix::ObjectId::Sha1([1; 20]), + parent_ids: [gix::ObjectId::Sha1([2; 20]), gix::ObjectId::Sha1([3; 20])] + .into_iter() + .collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "merge".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }, + Commit { + id: gix::ObjectId::Sha1([2; 20]), parent_ids: Default::default(), + author_time: gix::date::Time::default(), committer_time: gix::date::Time::default(), - author: author(b"Codex", b"codex@openai.com"), - attributions: 0..7, - title: "subject".into(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "parent".into(), metadata_loaded: true, - }], - attributions: vec![ - Attribution { - kind: AttributionKind::CoAuthor, - author: author(b"Human", b"human@example.com"), - }, - Attribution { - kind: AttributionKind::CoAuthor, - author: author(b"Claude", b"noreply@anthropic.com"), + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }, + ]); + complete(&mut app); + let changes = Changes { + parent: None, + range: None, + paths: vec![ + crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "added".into(), + lines: Some((10, 0)), }, - Attribution { - kind: AttributionKind::Assisted, - author: author(b"Codex", b"codex@openai.com"), + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "modified".into(), + lines: Some((5, 2)), }, - Attribution { - kind: AttributionKind::Reviewed, - author: author(b"Reviewer", b"reviewer@example.com"), + crate::app::PathChange { + kind: ChangeKind::Deleted, + group: ChangeGroup::Tree, + source: None, + path: "deleted".into(), + lines: Some((0, 7)), }, - Attribution { - kind: AttributionKind::Acked, - author: author(b"Acknowledger", b"ack@example.com"), + crate::app::PathChange { + kind: ChangeKind::Renamed, + group: ChangeGroup::Tree, + source: Some("old".into()), + path: "new".into(), + lines: Some((3, 3)), }, - Attribution { - kind: AttributionKind::Tested, - author: author(b"Tester", b"tester@example.com"), + crate::app::PathChange { + kind: ChangeKind::Copied, + group: ChangeGroup::Tree, + source: Some("source".into()), + path: "copy".into(), + lines: Some((0, 0)), }, - Attribution { - kind: AttributionKind::SignedOff, - author: author(b"Signer", b"signer@example.com"), + crate::app::PathChange { + kind: ChangeKind::TypeChanged, + group: ChangeGroup::Tree, + source: None, + path: format!("{}tail", "x".repeat(130)).into(), + lines: Some((24, 5)), }, ], - }); - app.selected = None; - let mut terminal = Terminal::new(TestBackend::new(160, 2))?; - - let mailmap = - gix::mailmap::Snapshot::from_bytes(b"Mapped Human <mapped@example.com> Human <human@example.com>\n"); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; + diffs: Vec::new(), + lines_added: 42, + lines_removed: 17, + has_tracked_changes: false, + }; + let mut terminal = Terminal::new(TestBackend::new(120, 16))?; - let row = rendered_row(&terminal); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + let mut footer_terminal = Terminal::new(TestBackend::new(240, 16))?; + footer_terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!( + rendered_line(&footer_terminal, 15).contains("? · quit"), + "the collapsed information prefix is followed only by quit" + ); + assert!( + !rendered_line(&footer_terminal, 15).contains("<tab> switch") + && !rendered_line(&footer_terminal, 15).contains("↑↓/jk move") + && !rendered_line(&footer_terminal, 15).contains("h/l pan") + && !rendered_line(&footer_terminal, 15).contains("<enter> diff"), + "information actions are hidden with their prefix" + ); + app.information_expanded = true; + footer_terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!( + rendered_line(&footer_terminal, 13).contains(" [ title · ref-tree · message · changes "), + "the expanded information prefix floats its actions above the navigation" + ); assert!( - row.contains( - "[Codex] Co: Mapped Human, [Claude] As: * Re: Reviewer Ack: Acknowledger Te: Tester So: Signer subject" + rendered_line(&footer_terminal, 14).contains( + " p command · <tab> switch · ↑↓/jk move · h/l pan · Shift+directions topo · PgUp/PgDn move · Shift+PgUp/PgDn pan · <enter> diff " ), - "same-kind trailers share one marker, use mailmap, and collapse the primary author to an asterisk" + "the expanded information prefix keeps keyboard help next to the footer" ); - let buffer = terminal.backend().buffer(); - let style_at = |needle: &str| { - let x = row.find(needle).expect("rendered metadata contains the named actor") as u16; - buffer[(x, 0)].fg + assert!(rendered_line(&footer_terminal, 15).contains("? · quit")); + app.information_expanded = false; + + assert_eq!( + terminal.backend().buffer()[(119, 7)].symbol(), + "─", + "the changes pane starts at the screen's halfway point" + ); + assert!( + terminal.backend().buffer()[(119, 7)].modifier.contains(Modifier::DIM), + "the inactive changes border is dimmed" + ); + assert!( + !terminal.backend().buffer()[(5, 0)].modifier.contains(Modifier::DIM) + && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), + "the focused history and its status use their normal intensity" + ); + let summary = rendered_line(&terminal, 7); + assert_eq!( + terminal.backend().buffer()[(0, 7)].symbol(), + "─", + "the tree title border reaches the left edge" + ); + assert!( + summary.contains("Tree 0101010 ── A 1 + M 1 + D 1 + R 1 + C 1 + T 1 = 6 · +42 -17"), + "the top border contains the tree identity and aggregates" + ); + let position = |needle| { + summary[..summary.find(needle).expect("aggregate is visible")] + .chars() + .count() as u16 }; - assert_eq!(style_at("[Codex]"), Color::Green, "bot authors use the agent color"); - assert_eq!(style_at("Co:"), Color::Green, "attribution markers use the agent color"); - let marker_x = row.find("Co:").expect("rendered metadata contains a trailer marker") as u16; + let added_x = position("A 1"); + let deleted_x = position("D 1"); + assert_eq!(terminal.backend().buffer()[(added_x, 7)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(deleted_x, 7)].fg, Color::LightRed); assert!( - buffer[(marker_x, 0)].modifier.contains(Modifier::DIM), - "attribution markers are dimmed" + terminal.backend().buffer()[(added_x, 7)] + .modifier + .contains(Modifier::DIM), + "the inactive summary is dimmed without losing its colors" ); - assert_eq!(style_at("Human"), Color::Green, "human trailer actors are green"); - assert_eq!(style_at("[Claude]"), Color::Green, "bot co-authors use agent styling"); assert!( - rendered_line(&terminal, 1).contains("t trailers"), - "the footer advertises the trailer toggle" + rendered_line(&terminal, 8).contains("A added"), + "changed paths follow the summary border in diff order" ); - - app.update(Action::ToggleTrailers); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert!(!rendered_row(&terminal).contains("Co:"), "t hides trailer attribution"); - - app.update(Action::ToggleTrailers); - app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; - let row = rendered_row(&terminal); - assert!(row.contains("Codex"), "the first n keeps the primary actor"); + let inactive_path = rendered_line(&terminal, 8); + let inactive_x = inactive_path.find("A added").expect("changed path is visible") as u16; assert!( - !row.contains("Reviewer"), - "the first n hides trailer actors while trailers are enabled" + terminal.backend().buffer()[(inactive_x, 8)] + .modifier + .contains(Modifier::DIM) + && terminal.backend().buffer()[(inactive_x + 2, 8)] + .modifier + .contains(Modifier::DIM), + "the inactive change kind and path are dimmed" ); - app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; - let row = rendered_row(&terminal); - assert!(!row.contains("Codex"), "the second n hides the primary actor"); - assert!(!row.contains("Reviewer"), "the second n keeps trailer actors hidden"); - app.update(Action::ToggleName); - app.update(Action::ToggleMailmap); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; assert!( - rendered_row(&terminal).contains("Co: Human, [Claude]"), - "m restores original trailer actor names" + !rendered_line(&terminal, 8).contains("+10"), + "inactive panes do not display a path selection" ); - Ok(()) - } - - #[test] - fn renders_rows_decorations_selection_and_footer() -> Result<(), Box<dyn std::error::Error>> { - let id = gix::ObjectId::Sha1([1; 20]); - let mut app = App::new(2); - app.extend_commits(vec![Commit { - id, - parent_ids: Default::default(), - committer_time: gix::date::Time::default(), - author: author(b"author", b"author@example.com"), - attributions: 0..0, - title: "subject".into(), - metadata_loaded: true, - }]); - complete(&mut app); - let decorations = Decorations::from([( - id, - vec![ - Decoration { - name: "HEAD".into(), - kind: DecorationKind::Head, - }, - Decoration { - name: "refs/patches/main/patch".into(), - kind: DecorationKind::Special, - }, - ], - )]); - let mailmap = - gix::mailmap::Snapshot::from_bytes(b"mapped author <mapped@example.com> author <author@example.com>\n"); - let mut terminal = Terminal::new(TestBackend::new(140, 2))?; - - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + assert!( + rendered_line(&terminal, 13).contains("T "), + "reclaiming the summary row lets all paths fit" + ); + assert!( + !rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), + "the unfocused changes status is hidden" + ); + assert_eq!( + terminal.backend().buffer()[(2, 15)].bg, + Color::Reset, + "the main status keeps its original background" + ); + assert!(!rendered_line(&terminal, 15).contains("<tab> switch")); - let footer_text = "1 commits · ↑↓/jk move · h/l pan · [ align · o commit · d date · n names · m mailmap · t trailers · r refs · y copy · q quit"; - let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; - let mut expected = Buffer::with_lines([format!("{selected_line:<140}"), format!("{footer_text:<140}")]); - for x in 0..11 { - expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); - } - expected[(2, 0)].set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); - for x in 4..11 { - expected[(x, 0)].set_style( - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::REVERSED | Modifier::BOLD), + app.changes_suppressed = true; + app.information_expanded = true; + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), ); - } - for x in 13..17 { - expected[(x, 0)].set_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); - } - for x in 19..30 { - expected[(x, 0)].set_style(Style::default().fg(Color::Blue)); - } - for x in 30..44 { - expected[(x, 0)].set_style(Style::default().fg(Color::Green)); - } - expected[(selected_line.chars().count() as u16 + 1, 0)] - .set_style(Style::default().add_modifier(Modifier::REVERSED)); - let commit = footer_text[..footer_text.find("o commit").expect("the commit toggle is present")] - .chars() - .count(); - for x in commit..commit + "o commit".len() { - expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM)); - } - terminal.backend().assert_buffer(&expected); - - app.inline = true; - let mut inline_terminal = Terminal::new(TestBackend::new(140, 4))?; - inline_terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + })?; assert!( - rendered_line(&inline_terminal, 0).trim().is_empty(), - "inline mode separates the commits from preceding content" + !rendered_line(&terminal, 7).contains("files changed"), + "repeated history navigation temporarily hides the changes pane" ); assert!( - rendered_line(&inline_terminal, 2).trim().is_empty(), - "inline mode separates the commits from the status line" + app.changes_mode.is_some() && !popup_is_dim(&terminal, "changes"), + "temporary suppression leaves the persistent changes setting enabled" ); - assert!(rendered_line(&inline_terminal, 3).starts_with("1 commits")); - app.inline = false; + app.changes_suppressed = false; - let row = terminal.backend().buffer(); + app.update(Action::ToggleChangesFocus); + app.update(Action::MoveDown); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; assert!( - row[(10, 0)].modifier.contains(Modifier::REVERSED), - "selection includes the final hash character" + !terminal.backend().buffer()[(119, 7)].modifier.contains(Modifier::DIM), + "the focused changes border uses its normal style" ); assert!( - !row[(11, 0)].modifier.contains(Modifier::REVERSED), - "selection ends immediately after the hash" + rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), + "the focused changes status advertises its navigation keys" + ); + assert_eq!( + terminal.backend().buffer()[(2, 14)].bg, + PANE_STATUS_BACKGROUND, + "the focused changes status uses the shared pane-status background" ); - assert_eq!(row[(13, 0)].fg, Color::Cyan, "reference colors remain visible"); assert!( - !rendered_line(&terminal, 1).contains("Esc cancel"), - "completed work cannot be cancelled" + terminal.backend().buffer()[(5, 0)].modifier.contains(Modifier::DIM) + && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), + "the inactive history is dimmed without dimming the main status" ); - - app.update(Action::ToggleMailmap); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + assert!(!rendered_line(&terminal, 15).contains("<tab> → tree changes")); + assert!(rendered_line(&terminal, 15).contains("q/Esc history")); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!(!rendered_line(&terminal, 15).contains("<tab> switch")); assert!( - rendered_row(&terminal).contains(" author subject"), - "m restores the original author name" + !terminal.backend().buffer()[(added_x, 7)] + .modifier + .contains(Modifier::DIM) + && !terminal.backend().buffer()[(2, 14)].modifier.contains(Modifier::DIM), + "the focused summary and status use their normal intensity" ); - assert!(footer_is_dim(&terminal, "m mailmap"), "disabled mailmap is dimmed"); - app.update(Action::ToggleMailmap); - - app.update(Action::ToggleDate); - app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; - let row = rendered_row(&terminal); - assert!(!row.contains("1970-01-01"), "d hides the committer date"); + let selected = rendered_line(&terminal, 9); + assert!(selected.contains("M modified +5 -2")); + let path_x = selected.find("modified").expect("selected path is visible") as u16; + let kind_x = selected.find("M modified").expect("selected kind is visible") as u16; + let added_x = selected.find("+5").expect("selected additions are visible") as u16; + let removed_x = selected.find("-2").expect("selected removals are visible") as u16; assert!( - !row.contains("author"), - "the first n hides the author when there are no attributions" + !terminal.backend().buffer()[(kind_x, 9)] + .modifier + .contains(Modifier::DIM) + && !terminal.backend().buffer()[(path_x, 9)] + .modifier + .contains(Modifier::DIM), + "focused paths use their normal intensity" + ); + assert!( + terminal.backend().buffer()[(path_x, 9)] + .modifier + .contains(Modifier::REVERSED), + "the selected filepath is inverted" + ); + assert_eq!(terminal.backend().buffer()[(added_x, 9)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(removed_x, 9)].fg, Color::LightRed); + assert!( + !terminal.backend().buffer()[(added_x, 9)] + .modifier + .contains(Modifier::REVERSED), + "the diff-line suffix keeps its normal background" + ); + assert!( + !rendered_line(&terminal, 8).contains("+10"), + "only the selected path displays its line counts" ); - assert!(!row.contains("refs/patches"), "special refs are hidden until requested"); - assert!(row.contains("subject"), "the commit subject remains visible"); - assert!(footer_is_dim(&terminal, "d date"), "disabled date is dimmed"); - assert!(footer_is_dim(&terminal, "n name"), "disabled name is dimmed"); + assert!(rendered_line(&terminal, 13).contains("T ")); + assert!(rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan")); - app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; assert!( - rendered_row(&terminal).contains("author"), - "the second n restores the author name" + rendered_line(&terminal, 14).contains("<enter> diff · copy · cycle tree"), + "the changes pane advertises the next cycle mode" ); assert!( - !footer_is_dim(&terminal, "n name"), - "the restored name mode is not dimmed" + rendered_line(&terminal, 14).contains("copy"), + "the changes pane advertises path copying" ); - app.update(Action::ToggleRefs); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; - assert!(!rendered_row(&terminal).contains("HEAD"), "no refs hides regular refs"); + app.update(Action::MoveUp); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!(rendered_line(&terminal, 8).contains("A added +10")); assert!( - !rendered_row(&terminal).contains("refs/patches"), - "no refs hides special refs" + !rendered_line(&terminal, 8).contains("-0"), + "selected paths hide empty counts" ); - assert!(footer_is_dim(&terminal, "r no refs"), "no refs is dimmed"); - app.update(Action::ToggleRefs); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; - assert!(rendered_row(&terminal).contains("HEAD"), "all refs shows regular refs"); + app.update(Action::Last); + app.update(Action::ScrollRight); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert_eq!(app.tree_changes.horizontal_offset, 20); assert!( - rendered_row(&terminal).contains("refs/patches"), - "all refs shows special refs" + rendered_line(&terminal, 13).contains("tail"), + "h/l pans long path rows while the summary remains fixed" ); - assert!(!footer_is_dim(&terminal, "r all refs"), "all refs is not dimmed"); - - app.update(Action::ToggleRefs); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; - assert!(rendered_row(&terminal).contains("HEAD"), "refs shows regular refs"); assert!( - !rendered_row(&terminal).contains("refs/patches"), - "refs hides special refs" + !rendered_line(&terminal, 13).contains("not shown"), + "the overflow indicator disappears at the end" ); - assert!(!footer_is_dim(&terminal, "r refs"), "refs is not dimmed"); - app.has_hidden_filter = true; - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + let mut short_terminal = Terminal::new(TestBackend::new(120, 8))?; + short_terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; assert!( - rendered_line(&terminal, 1).contains("v show hidden"), - "the footer advertises the configured hidden-history toggle" + !rendered_line(&short_terminal, 5).contains("not shown"), + "the overflow count disappears once the selected final path is visible" ); - app.show_hidden = true; - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + + let mut merge_changes = changes.clone(); + merge_changes.parent = Some(crate::app::ComparedParent { + index: 0, + total: 2, + id: gix::ObjectId::Sha1([2; 20]), + }); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&merge_changes), + ); + })?; assert!( - rendered_line(&terminal, 1).contains("v hide hidden"), - "the footer reflects the unfiltered view" + rendered_line(&terminal, 7).contains("Tree 0101010 ── A 1"), + "parent context no longer crowds the aggregate border" ); - - app.update(Action::PreviewAuthorCopy(true)); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; - let row = rendered_row(&terminal); assert!( - row.contains("author subject"), - "holding Shift reveals the raw author even when names are hidden" + rendered_line(&terminal, 14).contains( + "vs parent 1/2 0202020 · P next parent · ↑↓/jk move · h/l pan · <enter> diff · copy · cycle tree" + ), + "merge diffs keep parent controls alongside navigation" ); + let parent = rendered_line(&terminal, 1); + let disk_x = parent.find('●').expect("the parent disk is visible") as u16; + let hash_x = parent.find("0202020").expect("the parent hash is visible") as u16; let buffer = terminal.backend().buffer(); - let hash_x = row.find("0101010").expect("the commit hash is rendered") as u16; - let author_x = row.find("author").expect("the author is rendered") as u16; - assert_ne!(buffer[(hash_x, 0)].fg, Color::Magenta, "the hash yields its copy color"); - assert_eq!( - buffer[(author_x, 0)].fg, - Color::Magenta, - "the author takes the copy color" - ); + assert_eq!(buffer[(disk_x, 1)].fg, COMPARED_PARENT_COLOR); + assert!(buffer[(disk_x, 1)].modifier.contains(Modifier::REVERSED)); assert!( - rendered_line(&terminal, 1).contains("Y copy author"), - "the footer previews the shifted shortcut" + buffer[(hash_x, 1)].modifier.contains(Modifier::REVERSED), + "the compared parent's hash is inverted" ); - Ok(()) - } - - #[test] - fn removes_the_copied_fields_color_from_only_the_selected_row_for_one_frame() - -> Result<(), Box<dyn std::error::Error>> { - let mut app = App::new(2); - app.extend_commits( - (1..=2) - .map(|n| Commit { - id: gix::ObjectId::Sha1([n; 20]), - parent_ids: Default::default(), - committer_time: gix::date::Time::default(), - author: author(b"author", b"author@example.com"), - attributions: 0..0, - title: format!("subject {n}").into(), - metadata_loaded: true, - }) - .collect::<Vec<_>>(), + assert!( + !rendered_line(&terminal, 15).contains("next parent"), + "parent cycling is absent from the main status line" ); - let mut terminal = Terminal::new(TestBackend::new(80, 3))?; - drop(app.update(Action::Copy)); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - let selected_hash = rendered_line(&terminal, 0) - .find("0101010") - .expect("the selected hash is visible") as u16; - let other_hash = rendered_line(&terminal, 1) - .find("0202020") - .expect("the other hash is visible") as u16; + app.update(Action::ToggleCommit); + let worktree_changes = Changes::default(); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject".as_bstr()), + Some(&changes), + Some(&worktree_changes), + ); + })?; assert_eq!( - terminal.backend().buffer()[(selected_hash, 0)].fg, - Color::Reset, - "the copied hash loses its color" + app.changes_layout, + ChangesLayout::Stacked, + "both change blocks adapt to the width left by the commit pane" ); - assert_eq!( - terminal.backend().buffer()[(other_hash, 1)].fg, - Color::Magenta, - "copy feedback does not affect other rows" + assert!( + rendered_line(&terminal, 7) + .chars() + .take(60) + .collect::<String>() + .contains("Worktree") ); - - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert_eq!( - terminal.backend().buffer()[(selected_hash, 0)].fg, - Color::Magenta, - "the hash color returns on the next frame" + assert!( + rendered_line(&terminal, 9) + .chars() + .take(60) + .collect::<String>() + .contains("Tree") ); - - drop(app.update(Action::PreviewAuthorCopy(true))); - drop(app.update(Action::CopyAuthor)); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - let selected_author = rendered_line(&terminal, 0) - .find("author") - .expect("the selected author is visible") as u16; - let other_author = rendered_line(&terminal, 1) - .find("author") - .expect("the other author is visible") as u16; + assert_eq!(terminal.backend().buffer()[(60, 7)].symbol(), " "); assert_eq!( - terminal.backend().buffer()[(selected_author, 0)].fg, - Color::Reset, - "the copied author loses its color" + terminal.backend().buffer()[(60, 7)].bg, + Color::Rgb(15, 16, 17), + "the shaded commit pane separates the overlays without a border" ); assert_eq!( - terminal.backend().buffer()[(other_author, 1)].fg, - Color::Magenta, - "author feedback does not affect other rows" + app.viewport_rows, 7, + "history remains bounded above the highest overlay" ); + assert!(rendered_line(&terminal, 0).trim_start().starts_with('>')); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let mut wide_terminal = Terminal::new(TestBackend::new(240, 16))?; + wide_terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject".as_bstr()), + Some(&changes), + Some(&worktree_changes), + ); + })?; assert_eq!( - terminal.backend().buffer()[(selected_author, 0)].fg, - Color::Magenta, - "the author color returns on the next frame" + app.changes_layout, + ChangesLayout::SideBySide, + "sufficient remaining width still permits side-by-side changes" ); + assert_eq!(wide_terminal.backend().buffer()[(156, 7)].symbol(), " "); + assert_eq!(wide_terminal.backend().buffer()[(156, 7)].bg, Color::Rgb(15, 16, 17)); Ok(()) } #[test] - fn advertises_cancel_only_while_loading() -> Result<(), Box<dyn std::error::Error>> { + fn summarizes_staged_and_unstaged_changes_in_the_top_border() -> Result<(), Box<dyn std::error::Error>> { let mut app = App::new(1); - let mut terminal = Terminal::new(TestBackend::new(180, 2))?; + app.changes_mode = Some(ChangesMode::Both); + app.changes_focus = Some(ChangePane::Worktree); + let changes = Changes { + paths: vec![ + crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Staged, + source: None, + path: "same".into(), + lines: Some((1, 0)), + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: "same".into(), + lines: Some((2, 1)), + }, + ], + lines_added: 3, + lines_removed: 1, + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 12))?; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&changes), + ); + })?; - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let (header_y, header) = (0..12) + .map(|y| (y, rendered_line(&terminal, y))) + .find(|(_, line)| line.contains("Worktree")) + .expect("the worktree border is visible"); assert!( - !rendered_line(&terminal, 1).contains("loading"), - "loading is already apparent from the streaming history" + header.contains("Worktree ── S 1 + U 1 = 2 · +3 -1"), + "the border distinguishes staged and unstaged rows: {header:?}" + ); + let staged_y = header_y + 1; + let divider_y = header_y + 2; + let unstaged_y = header_y + 3; + let divider = rendered_line(&terminal, divider_y); + let label_x = divider[..divider.find("↑ index ↑").expect("the index label is visible")] + .chars() + .count() as u16; + let staged_x = rendered_line(&terminal, staged_y).find('A').expect("staged letter") as u16; + assert_eq!(label_x, staged_x, "the index label aligns with path kinds"); + assert_eq!(terminal.backend().buffer()[(label_x, divider_y)].fg, Color::Reset); + assert!( + terminal.backend().buffer()[(label_x, divider_y)] + .modifier + .contains(Modifier::DIM), + "the label uses dimmed normal text" + ); + let rail_x = label_x + "↑ index ↑ ".chars().count() as u16; + assert_eq!(terminal.backend().buffer()[(rail_x, divider_y)].fg, Color::Green); + assert!( + !terminal.backend().buffer()[(rail_x, divider_y)] + .modifier + .contains(Modifier::DIM), + "the colored divider rail is not dimmed" + ); + let unstaged_x = rendered_line(&terminal, unstaged_y).find('M').expect("unstaged letter") as u16; + assert_eq!(terminal.backend().buffer()[(staged_x, staged_y)].fg, Color::Green); + assert_eq!( + terminal.backend().buffer()[(unstaged_x, unstaged_y)].fg, + Color::LightRed ); - assert!(rendered_line(&terminal, 1).contains("Esc cancel")); - app.update(Action::Cancel); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert!(!rendered_line(&terminal, 1).contains("Esc cancel")); + let staged_only = Changes { + paths: vec![changes.paths[0].clone()], + ..Changes::default() + }; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&staged_only), + ); + })?; + assert!( + !(0..12).any(|y| rendered_line(&terminal, y).contains("index")), + "a single change group has no divider" + ); + assert_eq!(index_divider(5).to_string(), "↑ ind", "narrow panes clip the label"); + + let modified = Changes { + paths: (0..12) + .map(|index| crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: format!("file-{index}").into(), + lines: Some((0, 0)), + }) + .collect(), + ..Changes::default() + }; + let summary = changes_summary(ChangePane::Tree, &app, &modified) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(); + assert!(summary.contains("M 12")); + assert!( + !summary.contains("+0") && !summary.contains("-0"), + "empty diff counts are hidden" + ); + assert!(!summary.contains("= 12"), "a single term already expresses the total"); + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&Changes::default()), + ); + })?; + let (clean_y, clean_header) = (0..12) + .map(|y| (y, rendered_line(&terminal, y))) + .find(|(_, line)| line.contains("Worktree clean")) + .expect("an enabled clean worktree remains visible as an empty block"); + let clean_x = clean_header.find("clean").expect("clean label") as u16; + assert_eq!(terminal.backend().buffer()[(clean_x, clean_y)].fg, Color::Green); + assert!( + !(0..12).any(|y| rendered_line(&terminal, y).contains("+0") || rendered_line(&terminal, y).contains("-0")), + "a clean worktree omits empty diff counts" + ); + assert!( + !(0..12).any(|y| rendered_line(&terminal, y).contains("= 0")), + "a clean worktree has no empty aggregate" + ); + assert!(!app.worktree_changes_visible, "an empty block is not focusable"); + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&Changes::default()), + Some(&Changes::default()), + ); + })?; + let (empty_y, empty_tree) = (0..12) + .map(|y| (y, rendered_line(&terminal, y))) + .find(|(_, line)| line.contains("Tree ------- empty")) + .expect("an empty tree remains visible and says it is empty"); + let empty_x = empty_tree.find("empty").expect("empty tree label") as u16; + assert_eq!(terminal.backend().buffer()[(empty_x, empty_y)].fg, Color::Green); + assert!(!app.tree_changes_visible, "an empty tree block is not focusable"); Ok(()) } #[test] - fn toggles_the_full_commit_message_in_a_padded_half_width_pane() -> Result<(), Box<dyn std::error::Error>> { - let mut app = App::new(3); - app.extend_commits(vec![Commit { - id: gix::ObjectId::Sha1([1; 20]), - parent_ids: Default::default(), - committer_time: gix::date::Time::default(), - author: author(b"author", b"author@example.com"), - attributions: 0..0, - title: "subject".into(), - metadata_loaded: true, - }]); - let mut terminal = Terminal::new(TestBackend::new(120, 6))?; - - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert!(footer_is_dim(&terminal, "o commit"), "the closed commit pane is dimmed"); + fn worktree_index_divider_scrolls_with_paths() -> Result<(), Box<dyn std::error::Error>> { + let mut app = App::new(1); + app.changes_mode = Some(ChangesMode::Both); + app.changes_focus = Some(ChangePane::Worktree); + let changes = Changes { + paths: [ + ChangeGroup::Staged, + ChangeGroup::Staged, + ChangeGroup::Unstaged, + ChangeGroup::Unstaged, + ] + .into_iter() + .enumerate() + .map(|(index, group)| crate::app::PathChange { + kind: ChangeKind::Modified, + group, + source: None, + path: format!("file-{index}").into(), + lines: None, + }) + .collect(), + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 10))?; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&changes), + ); + })?; - app.update(Action::ToggleCommit); + app.update(Action::MoveDown); + app.update(Action::MoveDown); terminal.draw(|frame| { - super::draw( + super::draw_with_worktree( frame, &mut app, &Decorations::new(), &gix::mailmap::Snapshot::default(), - Some(b"subject\n\nbody".as_bstr()), + None, + None, + Some(&changes), ); })?; + + let divider_y = (0..10) + .find(|y| rendered_line(&terminal, *y).contains("↑ index ↑")) + .expect("the boundary scrolls into view"); + assert!(rendered_line(&terminal, divider_y + 1).contains("M file-2")); + assert!( + rendered_line(&terminal, divider_y + 2).contains("… 1 line not shown"), + "the overflow count excludes the divider" + ); assert_eq!( - terminal.backend().buffer()[(62, 1)].symbol(), - "s", - "the pane is capped at half width with two columns of horizontal margin" + app.update(Action::OpenDiff), + vec![crate::app::Effect::OpenDiff(ChangePane::Worktree, 2)], + "the selected row after the divider retains its path index" ); + Ok(()) + } + + #[test] + fn lays_out_tree_and_worktree_changes_by_available_width() -> Result<(), Box<dyn std::error::Error>> { + let (layout, panes, height) = changes_pane_areas(Rect::new(0, 0, 120, 20), 10, Some((5, 30)), Some((3, 25))); + assert_eq!(layout, ChangesLayout::SideBySide); + assert_eq!(height, 5); + assert_eq!(panes[0].outer, Rect::new(0, 15, 60, 5)); + assert_eq!(panes[1].outer, Rect::new(60, 15, 60, 5)); + + let (layout, panes, height) = changes_pane_areas(Rect::new(0, 0, 60, 20), 10, Some((8, 31)), Some((3, 31))); + assert_eq!(layout, ChangesLayout::Stacked); + assert_eq!(height, 10); assert_eq!( - terminal.backend().buffer()[(62, 3)].symbol(), - "b", - "vertical margin leaves the full commit body intact" + panes[0], + ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new(0, 10, 60, 3), + } ); - assert!( - !footer_is_dim(&terminal, "o commit"), - "the open commit pane is not dimmed" + assert_eq!( + panes[1], + ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(0, 13, 60, 7), + } ); - app.update(Action::ToggleCommit); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert_eq!( - terminal.backend().buffer()[(62, 3)].symbol(), - " ", - "closing the pane removes the commit body" + let (_, panes, height) = changes_pane_areas(Rect::new(0, 0, 120, 20), 10, None, Some((3, 25))); + assert_eq!(height, 3); + assert_eq!(panes[0].outer, Rect::new(0, 17, 120, 3)); + + let mut app = App::new(1); + app.changes_mode = Some(ChangesMode::Both); + let path = |group, path: &'static str| Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group, + source: None, + path: path.into(), + lines: Some((1, 1)), + }], + lines_added: 1, + lines_removed: 1, + ..Changes::default() + }; + let mut tree = path(ChangeGroup::Tree, "tree-file"); + tree.paths.push(crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "tree-file-2".into(), + lines: Some((0, 0)), + }); + let worktree = path(ChangeGroup::Staged, "worktree-file"); + let mut terminal = Terminal::new(TestBackend::new(120, 10))?; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&tree), + Some(&worktree), + ); + })?; + let halves = |row: String| { + let left = row.chars().take(60).collect::<String>(); + let right = row.chars().skip(60).collect::<String>(); + (left, right) + }; + let (left, right) = halves(rendered_line(&terminal, 5)); + assert!(left.contains("Tree")); + assert!(right.contains("Worktree")); + let (left, right) = halves(rendered_line(&terminal, 6)); + assert!(left.contains("tree-file")); + assert!(right.contains("worktree-file")); + let (left, right) = halves(rendered_line(&terminal, 7)); + assert!(left.contains("tree-file-2")); + assert_eq!(right.trim(), "│"); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(60, 5)].symbol(), "┬"); + assert_eq!(buffer[(60, 6)].symbol(), "│"); + assert_eq!(buffer[(60, 7)].symbol(), "│"); + assert_eq!(buffer[(60, 8)].symbol(), "│"); + + app.leave_success( + "success success success success success success success success success success success success", ); - - app.update(Action::ToggleCommit); - let mut wide_terminal = Terminal::new(TestBackend::new(200, 6))?; - wide_terminal.draw(|frame| { - super::draw( + terminal.draw(|frame| { + super::draw_with_worktree( frame, &mut app, &Decorations::new(), &gix::mailmap::Snapshot::default(), - Some(b"subject".as_bstr()), + None, + Some(&tree), + Some(&worktree), ); })?; + assert_eq!(terminal.backend().buffer()[(62, 4)].bg, Color::Green); assert_eq!( - wide_terminal.backend().buffer()[(122, 1)].symbol(), - "s", - "the pane remains eighty columns wide on a wide screen" + terminal.backend().buffer()[(60, 4)].bg, + Color::Reset, + "the notice has horizontal margin within the worktree pane" + ); + assert_eq!( + terminal.backend().buffer()[(60, 9)].bg, + Color::Reset, + "the footer is never covered" ); Ok(()) } @@ -1024,7 +6694,9 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(40, 8))?; let message = b"subject\n\nbody\n\nShort: one two three four five six seven\nCo-authored-by: Alice".as_bstr(); - terminal.draw(|frame| render_commit_message(frame, frame.area(), message))?; + terminal.draw(|frame| { + render_commit_message(frame, frame.area(), message, None, &[], 0); + })?; assert_eq!( rendered_line(&terminal, 4).find("one"), @@ -1065,7 +6737,14 @@ mod tests { let mut plain_terminal = Terminal::new(TestBackend::new(40, 4))?; plain_terminal.draw(|frame| { - render_commit_message(frame, frame.area(), b"plain subject\n\nplain body".as_bstr()); + render_commit_message( + frame, + frame.area(), + b"plain subject\n\nplain body".as_bstr(), + None, + &[], + 0, + ); })?; assert!( plain_terminal.backend().buffer()[(0, 0)] @@ -1082,42 +6761,156 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(60, 8))?; let message = b"subject\n\nnot a trailer\nSigned-off-by: Alice\nanother note\nSigned-off-by: Bob".as_bstr(); - terminal.draw(|frame| render_commit_message(frame, frame.area(), message))?; + terminal.draw(|frame| { + render_commit_message(frame, frame.area(), message, None, &[], 0); + })?; assert!( - rendered_line(&terminal, 2).contains("not a trailer") - && rendered_line(&terminal, 3).contains("another note"), - "mixed message parts are combined ahead of the trailers" + rendered_line(&terminal, 2).contains("not a trailer another note"), + "Markdown soft breaks combine message lines ahead of the trailers" ); assert!( - rendered_line(&terminal, 4).trim().is_empty(), + rendered_line(&terminal, 3).trim().is_empty(), "the combined message remains separated from its trailers" ); assert_eq!( - rendered_line(&terminal, 5).find("Alice"), + rendered_line(&terminal, 4).find("Alice"), Some(15), "the first trailer moves below all message parts" ); assert_eq!( - rendered_line(&terminal, 6).find("Bob"), + rendered_line(&terminal, 5).find("Bob"), Some(15), "later trailer runs share the aligned value column" ); Ok(()) } + #[test] + fn note_precedes_the_commit_message_and_its_trailers() -> Result<(), Box<dyn std::error::Error>> { + let mut terminal = Terminal::new(TestBackend::new(40, 10))?; + terminal.draw(|frame| { + render_commit_message( + frame, + frame.area(), + b"commit *subject*\n\ncommit ~~body~~\n\nSigned-off-by: Alice".as_bstr(), + Some(b"todo *subject*\n\ntodo **body**".as_bstr()), + &[], + 0, + ); + })?; + + assert_eq!(rendered_line(&terminal, 0).trim(), "todo subject"); + assert_eq!(rendered_line(&terminal, 2).trim(), "todo body"); + assert_eq!(terminal.backend().buffer()[(0, 0)].bg, Color::Reset); + assert_eq!(terminal.backend().buffer()[(0, 2)].bg, Color::Reset); + assert!(terminal.backend().buffer()[(5, 0)].modifier.contains(Modifier::ITALIC)); + assert!(terminal.backend().buffer()[(5, 2)].modifier.contains(Modifier::BOLD)); + assert!( + rendered_line(&terminal, 3).chars().all(|char| char == '─'), + "a horizontal rule separates note from the commit" + ); + assert_eq!(rendered_line(&terminal, 4).trim(), "commit subject"); + assert_eq!(rendered_line(&terminal, 6).trim(), "commit body"); + assert!(terminal.backend().buffer()[(7, 4)].modifier.contains(Modifier::ITALIC)); + assert!( + terminal.backend().buffer()[(7, 6)] + .modifier + .contains(Modifier::CROSSED_OUT) + ); + assert!( + rendered_line(&terminal, 8).contains("Alice"), + "commit trailers retain their layout" + ); + Ok(()) + } + + #[test] + fn markdown_hides_structural_delimiters() { + let text = markdown_text(b"# Heading\n\n```rust\nlet value = 1;\n```".as_bstr()); + let rendered = text.to_string(); + assert!(rendered.contains("Heading") && rendered.contains("let value = 1;")); + assert!(!rendered.contains('#') && !rendered.contains("```")); + assert!(text.lines[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn renders_note_markers_and_notes_before_trailers() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: true, + is_review: false, + signature: SignatureState::Unsigned, + }]); + app.set_notes(id, vec!["review *note*".into()]); + app.selected = None; + let mut history = Terminal::new(TestBackend::new(100, 2))?; + history.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let row = rendered_row(&history); + let agent_x = row.find("[A]").expect("the agent marker is visible") as u16; + let note_x = row.find("[N]").expect("the note marker is visible") as u16; + assert!( + row.contains("[A] [N] subject"), + "agent and note markers precede the title" + ); + assert_eq!(history.backend().buffer()[(agent_x, 0)].fg, Color::LightMagenta); + assert_eq!(history.backend().buffer()[(note_x, 0)].fg, Color::LightMagenta); + + let mut message = Terminal::new(TestBackend::new(40, 9))?; + message.draw(|frame| { + render_commit_message( + frame, + frame.area(), + b"subject\n\nbody\n\nSigned-off-by: Alice".as_bstr(), + None, + &["review *note*".into()], + 0, + ); + })?; + assert_eq!(rendered_line(&message, 4).trim(), "Notes:"); + assert_eq!(rendered_line(&message, 5).trim(), "review note"); + assert!(message.backend().buffer()[(7, 5)].modifier.contains(Modifier::ITALIC)); + assert!(rendered_line(&message, 7).contains("Alice"), "trailers follow notes"); + let notes_label = &message.backend().buffer()[(0, 4)]; + assert_eq!(notes_label.fg, NOTE_COLOR); + assert!( + notes_label.modifier.contains(Modifier::BOLD), + "only the Notes label is bold" + ); + assert!( + !message.backend().buffer()[(5, 4)].modifier.contains(Modifier::BOLD) + && !message.backend().buffer()[(0, 5)].modifier.contains(Modifier::BOLD), + "the colon and note body are not bold" + ); + Ok(()) + } + #[test] fn renders_only_the_visible_rows() -> Result<(), Box<dyn std::error::Error>> { let mut app = App::new(2); + app.id_mode = IdMode::Commit; app.extend_commits( (1..=3) .map(|n| Commit { id: gix::ObjectId::Sha1([n; 20]), parent_ids: Default::default(), + author_time: gix::date::Time::default(), committer_time: gix::date::Time::default(), author: author(b"author", b"author@example.com"), attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, }) .collect::<Vec<_>>(), ); @@ -1128,10 +6921,10 @@ mod tests { terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(5, 0)].symbol(), "2", "the viewport starts at the second row"); - assert_eq!(buffer[(5, 1)].symbol(), "3", "the selected third row remains visible"); + assert_eq!(buffer[(11, 0)].symbol(), "2", "the viewport starts at the second row"); + assert_eq!(buffer[(11, 1)].symbol(), "3", "the selected third row remains visible"); assert!( - buffer[(0, 1)].modifier.contains(Modifier::REVERSED), + buffer[(6, 1)].modifier.contains(Modifier::REVERSED), "the slice-local selection highlights the global selection" ); assert!( @@ -1140,17 +6933,136 @@ mod tests { ); assert_eq!(app.selected, Some(2), "drawing preserves the global selection"); assert_eq!(app.offset, 1, "drawing preserves the global offset"); + Ok(()) + } - app.show_selection_tail = false; - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - let buffer = terminal.backend().buffer(); + #[test] + fn renders_hidden_boundary_rows_without_colors() -> Result<(), Box<dyn std::error::Error>> { + let commit = |n: u8| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unverified, + }; + let mut app = App::new(2); + app.id_mode = IdMode::Commit; + app.extend_commits(vec![commit(1)]); + std::sync::Arc::make_mut(&mut app.rows[0]).parent_ids = [gix::ObjectId::Sha1([2; 20])].into_iter().collect(); + app.extend_hidden_commits(vec![commit(2)]); + complete(&mut app); + app.select_commit(gix::ObjectId::Sha1([2; 20])); + app.set_hidden_branch_updates(std::collections::HashMap::from([( + gix::ObjectId::Sha1([2; 20]), + (2, gix::ObjectId::Sha1([3; 20])), + )])); + app.set_lane(0, "● "); + app.set_lane(1, "● "); + let changes = Changes { + range: Some(crate::app::ComparedRange { + base: gix::ObjectId::Sha1([2; 20]), + tip: gix::ObjectId::Sha1([1; 20]), + }), + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "changed".into(), + lines: Some((3, 1)), + }], + lines_added: 3, + lines_removed: 1, + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(120, 8))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &Default::default(), + None, + Some(&changes), + ); + })?; + + let line = rendered_line(&terminal, 1); + assert!( + line.contains("subject 2"), + "the hidden commit keeps its normal content: {line:?}" + ); + let visible = rendered_line(&terminal, 0); + let visible_hash = visible.find("0101010").expect("the visible hash is present") as u16; + assert_ne!(terminal.backend().buffer()[(visible_hash, 0)].fg, Color::Reset); + let hash = line.find("0202020").expect("the hidden hash is visible") as u16; + assert!( + terminal.backend().buffer()[(hash, 1)].modifier.contains(Modifier::BOLD), + "non-color styling is retained" + ); + let marker_byte = line.find("⇣2").expect("the behind count is visible"); + let marker = line[..marker_byte].chars().count() as u16; + let added_byte = line.find("+3").expect("the branch additions are visible"); + let added = line[..added_byte].chars().count() as u16; + let removed_byte = line.find("-1").expect("the branch removals are visible"); + let removed = line[..removed_byte].chars().count() as u16; + assert_eq!(&line[marker_byte - 1..marker_byte], " ", "the marker has a left margin"); + assert_eq!( + terminal.backend().buffer()[(marker + 2, 1)].symbol(), + " ", + "the marker has a right margin" + ); + assert!( + line.find("subject 2") < line.find("+3") + && line.find("+3") < line.find("-1") + && line.find("-1") < line.find("⇣2"), + "the branch diff-stat and behind marker follow the title: {line:?}" + ); + for x in 0..terminal.backend().buffer().area.width { + let cell = &terminal.backend().buffer()[(x, 1)]; + if (marker..marker + 2).contains(&x) { + assert_eq!(cell.fg, Color::LightRed, "behind information uses its usual color"); + assert!(!cell.modifier.contains(Modifier::DIM)); + continue; + } + if (added..added + 2).contains(&x) || (removed..removed + 2).contains(&x) { + assert!( + !cell.modifier.contains(Modifier::DIM), + "branch diff-stat remains prominent" + ); + continue; + } + assert_eq!(cell.fg, Color::Reset, "the hidden row has no foreground colors"); + assert_eq!(cell.bg, Color::Reset, "the hidden row has no background colors"); + assert!(cell.modifier.contains(Modifier::DIM), "the hidden row is dimmed"); + } + assert_eq!( + terminal.backend().buffer()[(6, 1)].symbol(), + ">", + "the hidden base is selectable" + ); + + let mut narrow = Terminal::new(TestBackend::new(28, 3))?; + narrow.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; assert!( - buffer[(0, 1)].modifier.contains(Modifier::REVERSED), - "the final frame keeps the left selection marker" + rendered_line(&narrow, 1).ends_with(" ⇣2 "), + "the terminal edge pushes the marker over clipped title text" ); + app.actions_expanded = true; + let mut commit = Terminal::new(TestBackend::new(120, 3))?; + commit.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_line(&commit, 0).contains(" pin ")); assert!( - !buffer[(23, 1)].modifier.contains(Modifier::REVERSED), - "the final frame hides the trailing selection marker" + rendered_line(&commit, 1).contains(" rebase · rebase-update ") + && rendered_line(&commit, 2).contains("actions"), + "the selected hidden base offers rebasing from actions: {:?}", + rendered_line(&commit, 1) ); Ok(()) } @@ -1161,11 +7073,15 @@ mod tests { let commit = Commit { id, parent_ids: Default::default(), + author_time: gix::date::Time::default(), committer_time: gix::date::Time::default(), author: author(b"author", b"author@example.com"), attributions: 0..0, title: "subject".into(), metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, }; let decorations = Decorations::from([( id, @@ -1204,13 +7120,17 @@ mod tests { &decorations, &mailmap, MetadataOptions { - show_committer_date: true, + date_mode: DateMode::Committer, + id_mode: IdMode::Commit, + change_id: row.id.into(), show_author_name: true, + show_emails: false, show_trailers: true, + has_notes: false, + note_title: None, use_mailmap: false, ref_mode: RefMode::All, selected: false, - preview_author_copy: false, copy_feedback: None, }, ); @@ -1227,9 +7147,9 @@ mod tests { ); assert_eq!(style("1970-01-01 "), Style::default().fg(Color::Blue)); assert_eq!(style("author "), Style::default().fg(Color::Green)); - assert_eq!( - style("HEAD"), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + assert!( + line.spans.iter().all(|span| span.content != "HEAD"), + "the graph marker makes textual HEAD redundant" ); assert_eq!(style("main"), Style::default().fg(Color::Cyan)); assert_eq!(style("origin/main"), Style::default().fg(Color::Yellow)); @@ -1243,87 +7163,323 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(80, 2))?; terminal.draw(|frame| draw(frame, &mut app, &decorations))?; let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(2, 0)].fg, Color::Blue, "commit dots use graph-commit"); - assert_eq!(buffer[(4, 0)].fg, Color::Yellow, "lanes cycle through tig's palette"); + assert_eq!(buffer[(8, 0)].fg, Color::Blue, "commit dots use graph-commit"); + assert_eq!(buffer[(10, 0)].fg, Color::Yellow, "lanes cycle through tig's palette"); assert_eq!( - buffer[(16, 0)].fg, + buffer[(22, 0)].fg, Color::Magenta, "the palette repeats after seven lanes" ); assert!( - buffer[(16, 0)].modifier.contains(Modifier::BOLD), + buffer[(22, 0)].modifier.contains(Modifier::BOLD), "the second palette cycle is bold" ); Ok(()) } #[test] - fn overlays_metadata_on_wide_graphs_and_allows_natural_flow() -> Result<(), Box<dyn std::error::Error>> { + fn hides_ids_by_default_and_marks_duplicate_change_ids() -> Result<(), Box<dyn std::error::Error>> { + let id = gix::ObjectId::Sha1([1; 20]); + let duplicate_id = gix::ObjectId::Sha1([2; 20]); let mut app = App::new(1); - app.extend_commits(vec![Commit { - id: gix::ObjectId::Sha1([1; 20]), + let commit = Commit { + id, parent_ids: Default::default(), + author_time: gix::date::Time::default(), committer_time: gix::date::Time::default(), author: author(b"author", b"author@example.com"), attributions: 0..0, - title: format!("{} subject-tail", "a".repeat(50)).into(), + title: "subject".into(), metadata_loaded: true, - }]); - complete(&mut app); - app.set_lane(0, &format!("{}{}", "A".repeat(40), "B".repeat(40))); - let mut terminal = Terminal::new(TestBackend::new(60, 2))?; - - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - let aligned_column = rendered_row(&terminal) - .find("0101010") - .expect("alignment keeps metadata visible beside wide graphs"); - assert!(aligned_column < 60, "alignment keeps metadata within the viewport"); + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }; + app.extend_commits(vec![ + commit.clone(), + Commit { + id: duplicate_id, + title: "duplicate".into(), + ..commit + }, + ]); + let row = &app.rows[0]; + let plain = plain_history_metadata( + &app, + row, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + false, + None, + ); + assert!(!plain.contains("0101010"), "history rows hide IDs by default"); - app.update(Action::ScrollRight); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "B"); + let change_id = gix::hash::ChangeId::from(duplicate_id); + app.set_change_ids( + std::collections::HashMap::from([(id, change_id)]), + std::collections::HashSet::from([id, duplicate_id]), + ); + let row = &app.rows[0]; + let decorations = Decorations::new(); + let mailmap = gix::mailmap::Snapshot::default(); + let line = metadata_line( + row, + app.title(row), + app.attributions(row), + &decorations, + &mailmap, + MetadataOptions { + date_mode: DateMode::None, + id_mode: app.effective_id_mode(), + change_id: app.change_id(id), + show_author_name: false, + show_emails: false, + show_trailers: false, + has_notes: false, + note_title: None, + use_mailmap: false, + ref_mode: RefMode::None, + selected: false, + copy_feedback: None, + }, + ); + assert_eq!(line.spans[0].content, change_id.to_reverse_hex_with_len(7).to_string()); assert_eq!( - rendered_row(&terminal).find("0101010"), - Some(aligned_column), - "horizontal graph scrolling leaves aligned metadata fixed" + line.spans[0].style, + color(Color::LightCyan).add_modifier(Modifier::BOLD) + ); + assert!( + line.spans + .iter() + .all(|span| span.style != color(Color::Magenta).add_modifier(Modifier::BOLD)), + "the commit ID is not mixed into an ambiguous change ID" ); - app.update(Action::ScrollLeft); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - app.update(Action::ToggleAlign); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + complete(&mut app); + app.selected = Some(0); + let mut terminal = Terminal::new(TestBackend::new(80, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; assert!( - !rendered_row(&terminal).contains("0101010"), - "[ restores natural post-graph placement" + rendered_line(&terminal, 0).trim_start().starts_with("👯‍♂️ > "), + "the ambiguity gutter does not replace selection: {:?}", + rendered_line(&terminal, 0) + ); + assert!( + rendered_line(&terminal, 1).contains("next duplicate"), + "the footer advertises duplicate cycling for the selected commit" + ); + Ok(()) + } + + #[test] + fn aligns_titles_or_all_columns_from_only_visible_rows() -> Result<(), Box<dyn std::error::Error>> { + fn column(line: &str, needle: &str) -> usize { + line[..line.find(needle).expect("field is visible")].chars().count() + } + + let ids = [1, 2, 3].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let mut app = App::new(2); + app.extend_commits(LoadedCommits { + rows: vec![ + Commit { + id: ids[0], + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"Byron", b"byron@example.com"), + attributions: 0..0, + title: "first-title".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }, + Commit { + id: ids[1], + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"Byron", b"byron@example.com"), + attributions: 0..1, + title: "second-title".into(), + metadata_loaded: true, + has_agent_marker: true, + is_review: false, + signature: SignatureState::Unsigned, + }, + Commit { + id: ids[2], + parent_ids: Default::default(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"An extraordinarily long author", b"long@example.com"), + attributions: 1..1, + title: "third-title".into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }, + ], + attributions: vec![Attribution { + kind: AttributionKind::CoAuthor, + author: author(b"GPT", b"gpt@example.com"), + }], + }); + complete(&mut app); + app.selected = None; + app.set_lane(0, "● "); + app.set_lane(1, "●─┐ "); + app.set_lane(2, "● "); + let decorations = Decorations::from([( + ids[0], + vec![Decoration { + name: "various-improvements".into(), + kind: DecorationKind::Local, + }], + )]); + let mut terminal = Terminal::new(TestBackend::new(120, 3))?; + + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let first_title = column(&rendered_line(&terminal, 0), "first-title"); + let second_title = column(&rendered_line(&terminal, 1), "second-title"); + assert_eq!( + first_title, + second_title, + "[ aligns only the visible titles: {:?} / {:?}", + rendered_line(&terminal, 0), + rendered_line(&terminal, 1) + ); + assert_ne!( + column(&rendered_line(&terminal, 0), "1970-01-01"), + column(&rendered_line(&terminal, 1), "1970-01-01"), + "title mode leaves earlier fields at natural positions" ); - assert!(footer_is_dim(&terminal, "[ align"), "disabled alignment is dimmed"); + let mut narrow_title = Terminal::new(TestBackend::new(40, 3))?; + narrow_title.draw(|frame| draw(frame, &mut app, &decorations))?; + let before = rendered_line(&narrow_title, 0); app.update(Action::ScrollRight); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; assert!( - rendered_row(&terminal).contains("0101010"), - "l pages far enough right to reveal natural metadata" + app.horizontal_offset > 0, + "title-aligned rows expose their clipped width" ); - + narrow_title.draw(|frame| draw(frame, &mut app, &decorations))?; + assert_ne!(rendered_line(&narrow_title, 0), before, "l pans the title-aligned row"); app.update(Action::ScrollLeft); - app.set_lane(0, "● "); + assert_eq!(app.horizontal_offset, 0, "h returns to the title-aligned row start"); + app.update(Action::ToggleAlign); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let first = rendered_line(&terminal, 0); + let second = rendered_line(&terminal, 1); + assert_eq!( + column(&first, "1970-01-01"), + column(&second, "1970-01-01"), + "dates align" + ); + assert_eq!(column(&first, "Byron"), column(&second, "Byron"), "authors align"); assert_eq!( - terminal.backend().buffer()[(4, 0)].symbol(), - "0", - "aligned metadata starts immediately after the widest visible lane" + column(&first, "first-title"), + column(&second, "second-title"), + "titles align" ); assert!( - !rendered_row(&terminal).contains("subject-tail"), - "long aligned metadata starts clipped" + second.contains("Co: GPT [A] second-title"), + "attribution and markers share one column" ); + let mut narrow = Terminal::new(TestBackend::new(46, 3))?; + narrow.draw(|frame| draw(frame, &mut app, &decorations))?; app.update(Action::ScrollRight); - terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(app.horizontal_offset > 0, "wide aligned columns create a scroll range"); + narrow.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + rendered_line(&narrow, 0).contains("first-title"), + "l reveals the clipped aligned title" + ); + app.update(Action::ScrollLeft); + assert_eq!(app.horizontal_offset, 0, "h returns to the aligned row start"); + + let visible_title = column(&first, "first-title"); + app.offset = 1; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + column(&rendered_line(&terminal, 0), "second-title") > visible_title, + "an off-screen wide author affects alignment only after entering the viewport" + ); + Ok(()) + } + + #[test] + fn renders_selected_compressed_segments_as_expandable_without_stale_commit_ui() + -> Result<(), Box<dyn std::error::Error>> { + let ids = [1, 2, 3, 4].map(|byte| gix::ObjectId::Sha1([byte; 20])); + let mut app = App::new(2); + app.extend_commits( + ids.iter() + .enumerate() + .map(|(index, id)| Commit { + id: *id, + parent_ids: ids.get(index + 1).copied().into_iter().collect(), + author_time: gix::date::Time::default(), + committer_time: gix::date::Time::default(), + author: author(b"Byron", b"byron@example.com"), + attributions: 0..0, + title: if index == 0 { "tip" } else { "hidden" }.into(), + metadata_loaded: true, + has_agent_marker: false, + is_review: false, + signature: SignatureState::Unsigned, + }) + .collect::<Vec<_>>(), + ); + complete(&mut app); + app.set_view_tips(&ids[..1]); + for _ in 0..3 { + app.update(Action::ToggleAlign); + } + app.update(Action::MoveDown); + app.show_commit = true; + + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + let decorations = Decorations::new(); + let mailmap = gix::mailmap::Snapshot::default(); + let stale_tree_changes = Changes::default(); + terminal.draw(|frame| { + draw_with_worktree( + frame, + &mut app, + &decorations, + &mailmap, + Some(BStr::new(b"stale commit message")), + Some(&stale_tree_changes), + None, + ); + })?; + + assert!( + rendered_line(&terminal, 0).contains("tip"), + "the retained tip keeps its metadata" + ); + let line = rendered_line(&terminal, 1); + let summary = "> ○ [2]"; + let summary_x = line.find(summary).expect("the compressed summary is visible") as u16; + assert_eq!(line.trim(), summary); + assert!( + (summary_x..summary_x + summary.chars().count() as u16).all(|x| terminal.backend().buffer()[(x, 1)] + .modifier + .contains(Modifier::REVERSED)), + "the selected summary, including its status and count, is reversed" + ); + let footer = rendered_line(&terminal, 2); + assert!(footer.contains("<enter> expand")); + for hidden in [" · commit", " · actions", " · enrich", " · copy"] { + assert!(!footer.contains(hidden), "synthetic selection hides {hidden:?}"); + } assert!( - rendered_row(&terminal).contains("subject-tail"), - "l reveals clipped aligned metadata after graph panning is exhausted" + (0..3).all(|y| !rendered_line(&terminal, y).contains("stale commit message")), + "stale commit-message and tree-change panes stay hidden" ); Ok(()) } @@ -1339,10 +7495,34 @@ mod tests { }) } - fn footer_is_dim(terminal: &Terminal<TestBackend>, label: &str) -> bool { - let y = terminal.backend().buffer().area.height - 1; - let footer = rendered_line(terminal, y); - let x = footer[..footer.find(label).expect("toggle is visible")].chars().count() as u16; + fn assert_reversed_group(terminal: &Terminal<TestBackend>, y: u16, group: &str) { + let line = rendered_line(terminal, y); + let start = line[..line.rfind(group).expect("the active prefix group is visible")] + .chars() + .count() as u16; + let end = start + group.chars().count() as u16; + let buffer = terminal.backend().buffer(); + assert!( + (start..end).all(|x| buffer[(x, y)].modifier.contains(Modifier::REVERSED)), + "every cell in {group:?} is reversed" + ); + assert!( + !buffer[(start - 1, y)].modifier.contains(Modifier::REVERSED) + && !buffer[(end, y)].modifier.contains(Modifier::REVERSED), + "the active treatment is bounded to {group:?}" + ); + } + + fn popup_is_dim(terminal: &Terminal<TestBackend>, label: &str) -> bool { + let height = terminal.backend().buffer().area.height; + let (y, popup) = (0..height.saturating_sub(1)) + .rev() + .find_map(|y| { + let line = rendered_line(terminal, y); + line.contains(label).then_some((y, line)) + }) + .expect("toggle is visible in a popup"); + let x = popup[..popup.rfind(label).expect("toggle is visible")].chars().count() as u16; terminal.backend().buffer()[(x, y)].modifier.contains(Modifier::DIM) } } diff --git a/gix-tix/tests/fixtures/create_commit.sh b/gix-tix/tests/fixtures/create_commit.sh new file mode 100755 index 00000000000..320bcd3fc33 --- /dev/null +++ b/gix-tix/tests/fixtures/create_commit.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +# A staged version plus a different unstaged version makes the staged-wins rule observable. +git init -q -b main . +git config user.name author +git config user.email author@example.com +printf 'base\n' >tracked +git add tracked +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base +printf 'staged\n' >tracked +git add tracked +printf 'unstaged\n' >tracked +printf 'untracked\n' >untracked diff --git a/gix-tix/tests/fixtures/forget_commit.sh b/gix-tix/tests/fixtures/forget_commit.sh new file mode 100755 index 00000000000..9964e4396bd --- /dev/null +++ b/gix-tix/tests/fixtures/forget_commit.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# The top commit changes and adds tracked files; an unrelated untracked file must survive forgetting it. +git init -q -b main . +git config user.name author +git config user.email author@example.com +printf 'base\n' >tracked +git add tracked +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base +printf 'top\n' >tracked +printf 'added\n' >added +git add tracked added +GIT_AUTHOR_DATE='2000-01-02T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-02T00:00:00 +0000' git commit -q -m top +git update-ref refs/patches/forget HEAD +git update-ref refs/tags/keep HEAD +git update-ref refs/remotes/origin/keep HEAD +printf 'untracked\n' >untracked diff --git a/gix-tix/tests/fixtures/generated-archives/create_commit.tar b/gix-tix/tests/fixtures/generated-archives/create_commit.tar new file mode 100644 index 00000000000..0059d8d99c2 Binary files /dev/null and b/gix-tix/tests/fixtures/generated-archives/create_commit.tar differ diff --git a/gix-tix/tests/fixtures/generated-archives/forget_commit.tar b/gix-tix/tests/fixtures/generated-archives/forget_commit.tar new file mode 100644 index 00000000000..79d27c24ed2 Binary files /dev/null and b/gix-tix/tests/fixtures/generated-archives/forget_commit.tar differ diff --git a/gix-tix/tests/fixtures/generated-archives/rebase_conflict.tar b/gix-tix/tests/fixtures/generated-archives/rebase_conflict.tar new file mode 100644 index 00000000000..9a97d11f4a0 Binary files /dev/null and b/gix-tix/tests/fixtures/generated-archives/rebase_conflict.tar differ diff --git a/gix-tix/tests/fixtures/generated-archives/rebase_edit.tar b/gix-tix/tests/fixtures/generated-archives/rebase_edit.tar new file mode 100644 index 00000000000..1dc9d0d9bcc Binary files /dev/null and b/gix-tix/tests/fixtures/generated-archives/rebase_edit.tar differ diff --git a/gix-tix/tests/fixtures/generated-archives/split_commit.tar b/gix-tix/tests/fixtures/generated-archives/split_commit.tar new file mode 100644 index 00000000000..783420d2a90 Binary files /dev/null and b/gix-tix/tests/fixtures/generated-archives/split_commit.tar differ diff --git a/gix-tix/tests/fixtures/history.sh b/gix-tix/tests/fixtures/history.sh index e4122e01aaf..a85ee743cbd 100755 --- a/gix-tix/tests/fixtures/history.sh +++ b/gix-tix/tests/fixtures/history.sh @@ -21,11 +21,15 @@ git switch -q main commit main "2000-01-03T00:00:00" git merge -q --no-edit merged git switch -q -c topic HEAD~1 +# Two files make parallel changed-line aggregation and diff ordering observable. echo topic >topic -git add topic +echo topic-extra >topic-extra +git add topic topic-extra GIT_AUTHOR_DATE="2000-01-04T00:00:00 +0000" GIT_COMMITTER_DATE="2000-01-04T00:00:00 +0000" \ git commit -q --author="Codex <Codex@OpenAI.com>" -m topic \ - -m "Co-authored-by: Human Coauthor <human@example.com> + -m "--- agent + +Co-authored-by: Human Coauthor <human@example.com> Co-authored-by: Claude <noreply@anthropic.com> Assisted-by: Opus 4.7 rEvIeWeD-bY: Reviewer <reviewer@example.com> diff --git a/gix-tix/tests/fixtures/rebase_conflict.sh b/gix-tix/tests/fixtures/rebase_conflict.sh new file mode 100755 index 00000000000..61477a9b04f --- /dev/null +++ b/gix-tix/tests/fixtures/rebase_conflict.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +# Removing the middle commit asks the tip's same-line edit to apply to an incompatible base. +git init -q -b main . +git config user.name author +git config user.email author@example.com +git config commit.gpgSign false +printf 'base\n' >file +git add file +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base +printf 'middle\n' >file +git commit -qam middle +printf 'tip\n' >file +git commit -qam tip diff --git a/gix-tix/tests/fixtures/rebase_edit.sh b/gix-tix/tests/fixtures/rebase_edit.sh new file mode 100755 index 00000000000..a815dbdf838 --- /dev/null +++ b/gix-tix/tests/fixtures/rebase_edit.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +# A three-commit linear stack makes parent-only rewrites and tree-transplanting observable. +git init -q -b main . +git config user.name author +git config user.email author@example.com +git config commit.gpgSign false + +printf 'base\n' >base +git add base +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base + +printf 'middle\n' >middle +git add middle +GIT_AUTHOR_DATE='2000-01-02T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-02T00:00:00 +0000' git commit -q -m middle +git update-ref refs/patches/middle HEAD + +printf 'tip\n' >tip +git add tip +GIT_AUTHOR_DATE='2000-01-03T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-03T00:00:00 +0000' git commit -q -m tip +git update-ref refs/patches/tip HEAD diff --git a/gix-tix/tests/fixtures/replace-editor.ps1 b/gix-tix/tests/fixtures/replace-editor.ps1 new file mode 100644 index 00000000000..a910c6ccedb --- /dev/null +++ b/gix-tix/tests/fixtures/replace-editor.ps1 @@ -0,0 +1,9 @@ +param( + [Parameter(Mandatory = $true)][string]$Old, + [Parameter(Mandatory = $true)][string]$New, + [Parameter(Mandatory = $true)][string]$Path +) + +$content = [System.IO.File]::ReadAllText($Path) +$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText($Path, $content.Replace($Old, $New), $utf8WithoutBom) diff --git a/gix-tix/tests/fixtures/split_commit.sh b/gix-tix/tests/fixtures/split_commit.sh new file mode 100755 index 00000000000..385bd2de950 --- /dev/null +++ b/gix-tix/tests/fixtures/split_commit.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +# A split must preserve disjoint staged and unstaged hunks, including changes to the same file. +git init -q -b main . +git config user.name author +git config user.email author@example.com +printf 'one\nbase staged\nmiddle\nbase worktree\nend\n' >both +printf 'base\n' >staged +printf 'base\n' >unstaged +git add both staged unstaged +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base + +printf 'one\nstaged\nmiddle\nbase worktree\nend\n' >both +printf 'staged\n' >staged +git add both staged + +printf 'one\nstaged\nmiddle\nworktree\nend\n' >both +printf 'worktree\n' >unstaged +printf 'untracked\n' >untracked diff --git a/gix/src/note.rs b/gix/src/note.rs index 247fbb61d4c..77bd455383a 100644 --- a/gix/src/note.rs +++ b/gix/src/note.rs @@ -170,11 +170,24 @@ impl<'repo> Platform<'repo> { .try_into() .or_raise(|| message("The notes reference name is invalid"))?; let notes_ref = expand_notes_ref(notes_ref.as_ref())?; + self.replace_at_ref(notes_ref.as_ref(), object, data) + } + + /// Replace a note for `object` with `data` in the fully qualified `notes_ref`, + /// or add it if absent, returning the previous note id. + /// + /// Unlike [`Self::replace()`], this does not apply `refs/notes/` shorthand expansion. + pub fn replace_at_ref( + &mut self, + notes_ref: &gix_ref::FullNameRef, + object: impl Into<gix_hash::ObjectId>, + data: impl AsRef<[u8]>, + ) -> Result<Option<Id<'repo>>, crate::Error> { let EditRoot { root_tree_id, parent_commit_id, update_ref, - } = self.lookup_edit_root(notes_ref.as_ref())?; + } = self.lookup_edit_root(notes_ref)?; let annotated_object_id = object.into(); let note_blob_id = self .repo diff --git a/gix/tests/gix/repository/note.rs b/gix/tests/gix/repository/note.rs index 879d4eaccd6..0cc6cf68c16 100644 --- a/gix/tests/gix/repository/note.rs +++ b/gix/tests/gix/repository/note.rs @@ -35,8 +35,7 @@ fn query_and_mutate_a_configured_notes_ref() -> crate::Result { let found = notes.get(target)?; assert_eq!(found.len(), 1, "the target has exactly one note after insertion"); assert_eq!( - found[0].reference.to_string(), - "refs/notes/review", + found[0].reference, "refs/notes/review", "the note is found through the configured notes reference" ); assert_eq!(found[0].blob.data, b"first", "the inserted note data is returned"); @@ -317,14 +316,12 @@ fn query_and_mutate_multiple_notes_refs() -> crate::Result { let found = notes.get(target)?; assert_eq!(found.len(), 2, "one note is returned from each selected reference"); assert_eq!( - found[0].reference.to_string(), - "refs/notes/review", + found[0].reference, "refs/notes/review", "the first note follows the selected reference order" ); assert_eq!(found[0].blob.data, b"review note", "the review note data is returned"); assert_eq!( - found[1].reference.to_string(), - "refs/notes/security", + found[1].reference, "refs/notes/security", "the second note follows the selected reference order" ); assert_eq!( @@ -333,3 +330,27 @@ fn query_and_mutate_multiple_notes_refs() -> crate::Result { ); Ok(()) } + +#[test] +fn add_to_an_exact_fully_qualified_reference() -> crate::Result { + let (repo, _tmp) = crate::util::basic_rw_repo()?; + let target = repo.write_blob(b"annotated")?.detach(); + let reference: gix::refs::FullName = "refs/worktree/tix/notes".try_into()?; + let mut notes = repo.notes()?; + + assert_eq!( + notes.replace_at_ref(reference.as_ref(), target, b"[commit]\n\ttodo = true\n")?, + None + ); + let mut exact = repo.notes()?.with_refs([reference.as_bstr()])?; + assert_eq!( + exact.get(target)?.first().map(|note| note.blob.data.as_slice()), + Some(b"[commit]\n\ttodo = true\n".as_slice()), + "the exact worktree-local ref stores the note" + ); + assert!( + repo.try_find_reference("refs/notes/refs/worktree/tix/notes")?.is_none(), + "exact writes do not apply notes shorthand" + ); + Ok(()) +} diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index e672cef82ce..91052bd2706 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -158,29 +158,7 @@ pub fn main() -> Result<()> { match cmd { #[cfg(feature = "tix")] - Subcommands::Tix { - help: _, - quit_on_finish, - screen, - hide, - revisions, - } => { - let screen = match screen.as_str() { - "auto" => gix_tix::Screen::Auto, - "always" => gix_tix::Screen::Always, - "half" => gix_tix::Screen::Half, - value => anyhow::bail!("invalid screen mode: {value}"), - }; - gix_tix::run( - repository(Mode::Lenient)?.into_sync(), - revisions, - gix_tix::Options { - quit_on_finish, - hide, - screen, - }, - ) - } + Subcommands::Tix(command) => command.run(repository(Mode::Lenient)?.into_sync()), Subcommands::Env => prepare_and_run( "env", trace, @@ -1868,36 +1846,39 @@ mod tests { for name in ["tix", "tui", "interactive", "i"] { let args = Args::try_parse_from(["gix", name]).expect("the command or alias parses"); assert!( - matches!(args.cmd, Subcommands::Tix { .. }), + matches!(args.cmd, Subcommands::Tix(_)), "{name} routes to the tix command" ); } - let args = Args::try_parse_from(["gix", "tix", "-h", "main", "--hide", "tag", "topic"]) - .expect("hide options and a visible revision parse"); - let Subcommands::Tix { hide, revisions, .. } = args.cmd else { - panic!("tix arguments route to tix") - }; - assert_eq!(hide, ["main", "tag"], "short and long hide options append"); - assert_eq!(revisions, ["topic"], "positional revisions remain visible tips"); - let args = Args::try_parse_from(["gix", "tix", "--screen", "half"]).expect("the half-screen mode parses"); - let Subcommands::Tix { screen, .. } = args.cmd else { - panic!("tix arguments route to tix") - }; - assert_eq!(screen, "half", "the requested screen mode is retained"); - assert_eq!( - Args::try_parse_from(["gix", "tix", "--screen", "other"]) - .expect_err("unknown screen modes are rejected") - .kind(), - clap::error::ErrorKind::InvalidValue, - "screen mode validation happens at the command line" - ); + for arguments in [ + vec!["gix", "tix", "-x", "main", "--hide", "tag", "topic"], + vec!["gix", "tix", "amend"], + vec!["gix", "tix", "spill"], + ] { + assert!( + matches!( + Args::try_parse_from(arguments).expect("shared tix arguments parse").cmd, + Subcommands::Tix(_) + ), + "the complete tix command is delegated" + ); + } assert_eq!( - Args::try_parse_from(["gix", "tix", "--help"]) - .expect_err("help exits through clap") + Args::try_parse_from(["gix", "tix", "--screen", "half"]) + .expect_err("screen selection is no longer supported") .kind(), - clap::error::ErrorKind::DisplayHelp, - "long help remains available while -h belongs to hide" + clap::error::ErrorKind::UnknownArgument, + "alternate-screen operation has no command-line mode" ); + for help in ["-h", "--help"] { + assert_eq!( + Args::try_parse_from(["gix", "tix", help]) + .expect_err("help exits through clap") + .kind(), + clap::error::ErrorKind::DisplayHelp, + "embedded tix supports {help}" + ); + } } } diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index c253ff11b27..6c82f91fde0 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -165,28 +165,8 @@ pub enum Subcommands { Log(log::Platform), /// Interactively browse commits and their graph. #[cfg(feature = "tix")] - #[clap( - visible_alias = "tui", - visible_alias = "interactive", - visible_alias = "i", - disable_help_flag = true - )] - Tix { - /// Print help. - #[clap(long, action = clap::ArgAction::HelpLong)] - help: Option<bool>, - /// Exit once all commits and graph lanes have been computed. - #[clap(long)] - quit_on_finish: bool, - /// Choose automatic, full alternate-screen, or compact half-screen display. - #[clap(long, value_name = "MODE", value_parser = ["auto", "always", "half"], default_value = "auto")] - screen: String, - /// Hide this revision and every commit reachable from it. - #[clap(short = 'h', long, value_name = "REVSPEC")] - hide: Vec<std::ffi::OsString>, - /// Revisions whose reachable commits should be shown, or HEAD if omitted. - revisions: Vec<std::ffi::OsString>, - }, + #[clap(visible_alias = "tui", visible_alias = "interactive", visible_alias = "i")] + Tix(gix_tix::command::Platform), Worktree(worktree::Platform), /// Subcommands that need no Git repository to run. #[clap(subcommand)]