const std = @import("std");
const builtin = @import("builtin");
const model = @import("model.zig");

pub const GitError = error{
    NotGitRepository,
    CommandFailed,
    UnexpectedOutput,
};

pub const ExecResult = struct {
    stdout: []u8,
    stderr: []u8,
    term: std.process.Child.Term,

    pub fn deinit(self: *ExecResult, allocator: std.mem.Allocator) void {
        allocator.free(self.stdout);
        allocator.free(self.stderr);
        self.* = undefined;
    }

    pub fn ok(self: ExecResult) bool {
        return switch (self.term) {
            .exited => |code| code == 0,
            else => false,
        };
    }
};

/// On a transient lock error, retry up to `lock_retry_count` times with an
/// exponential backoff: the wait starts at `lock_retry_base_ms` and doubles each
/// attempt, capped at `lock_retry_cap_ms`. A big repo's index write can hold the
/// lock for a few hundred ms, so the old flat 5×50ms (250ms) budget ran out and
/// surfaced the error; this gives ~1.1s total, which absorbs realistic
/// contention while still failing on a genuinely stuck lock.
const lock_retry_count = 8;
const lock_retry_base_ms: i64 = 25;
const lock_retry_cap_ms: i64 = 250;

/// True when git's output indicates a transient lock error worth retrying —
/// another git process (a background refresh, or one the user ran elsewhere)
/// briefly held `.git/index.lock` or a ref lock.
pub fn isRetryableLockError(text: []const u8) bool {
    // `index.lock` (rather than `.git/index.lock`) so linked-worktree paths
    // (`.git/worktrees/<name>/index.lock`) and the bare message both match.
    return std.mem.indexOf(u8, text, "index.lock") != null or
        std.mem.indexOf(u8, text, "cannot lock ref") != null;
}

/// Drop git comment lines (those whose first character is `#`) from `raw` and
/// trim surrounding blank lines — matching git's default `strip` cleanup of a
/// message that came through the editor. Returns an owned slice.
fn stripCommentLines(allocator: std.mem.Allocator, raw: []const u8) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(allocator);
    var it = std.mem.splitScalar(u8, raw, '\n');
    while (it.next()) |line| {
        if (line.len > 0 and line[0] == '#') continue; // full-line comment
        try out.appendSlice(allocator, line);
        try out.append(allocator, '\n');
    }
    return allocator.dupe(u8, std.mem.trim(u8, out.items, " \t\r\n"));
}

fn literalPathspec(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    return std.fmt.allocPrint(allocator, ":(literal){s}", .{path});
}

/// Run a process like `std.process.run`, but retry briefly on a transient git
/// lock error instead of surfacing it. Returns the last result either way; the
/// returned stdout/stderr are owned by `allocator`. Used for every git command
/// so a momentary lock never reaches the user as a hard failure.
pub fn runWithLockRetry(allocator: std.mem.Allocator, io: std.Io, options: std.process.RunOptions) std.process.RunError!std.process.RunResult {
    var attempt: usize = 1;
    var wait_ms: i64 = lock_retry_base_ms;
    while (true) : (attempt += 1) {
        const result = try std.process.run(allocator, io, options);
        const failed = switch (result.term) {
            .exited => |code| code != 0,
            else => true,
        };
        if (!failed or attempt >= lock_retry_count or
            !(isRetryableLockError(result.stderr) or isRetryableLockError(result.stdout)))
        {
            return result;
        }
        // Transient lock: discard this attempt's output, wait, and retry with an
        // exponential backoff (capped) so a slower competing write is waited out.
        allocator.free(result.stdout);
        allocator.free(result.stderr);
        std.Io.sleep(io, std.Io.Duration.fromMilliseconds(wait_ms), .awake) catch {};
        wait_ms = @min(wait_ms * 2, lock_retry_cap_ms);
    }
}

pub const Git = struct {
    allocator: std.mem.Allocator,
    io: std.Io,
    environ: *std.process.Environ.Map,
    root: []u8,
    /// Absolute path to the git directory (`.git`), for storing side files like
    /// the pending-commit draft. Empty on throwaway worker `Git`s that don't need it.
    git_dir: []u8 = &.{},
    /// Ring of recently-run mutating commands, for the command-log view.
    command_log: std.ArrayList([]u8) = .empty,
    // Active filters for the Commits list, applied on every load. Owned.
    log_grep: ?[]u8 = null,
    log_author: ?[]u8 = null,
    log_path: ?[]u8 = null,
    /// How `loadCommits` orders the HEAD log (date / topo / author-date).
    log_order: model.LogOrder = .topo,
    /// How `loadBranches` orders the local branch list.
    branch_sort: model.BranchSortOrder = .date,
    /// Which untracked files `git status` lists. Mirrors git's own
    /// `status.showUntrackedFiles` config (resolved in `init`); `all` is git's —
    /// and our — default, but a large repo can set `normal`/`no` to make status
    /// far cheaper.
    untracked_files: UntrackedFiles = .all,

    pub const LogFilter = enum { grep, author, path };

    pub const UntrackedFiles = enum {
        all,
        normal,
        no,

        pub fn flag(self: UntrackedFiles) []const u8 {
            return switch (self) {
                .all => "--untracked-files=all",
                .normal => "--untracked-files=normal",
                .no => "--untracked-files=no",
            };
        }
    };

    const command_log_cap = 200;

    pub fn init(allocator: std.mem.Allocator, io: std.Io, environ: *std.process.Environ.Map) !Git {
        return initAt(allocator, io, environ, ".");
    }

    /// Like `init`, but discovers the repository containing `cwd` (used to
    /// re-root the app onto a worktree or submodule directory).
    pub fn initAt(allocator: std.mem.Allocator, io: std.Io, environ: *std.process.Environ.Map, cwd: []const u8) !Git {
        // One call yields both the worktree root and the absolute `.git` dir.
        var argv = [_][]const u8{ "git", "rev-parse", "--show-toplevel", "--absolute-git-dir" };
        const result = try std.process.run(allocator, io, .{
            .argv = &argv,
            .cwd = .{ .path = cwd },
            .stdout_limit = .limited(1024 * 1024),
            .stderr_limit = .limited(1024 * 1024),
        });
        defer allocator.free(result.stderr);
        defer allocator.free(result.stdout);

        const ok = switch (result.term) {
            .exited => |code| code == 0,
            else => false,
        };
        if (!ok) return GitError.NotGitRepository;

        var lines = std.mem.splitScalar(u8, std.mem.trim(u8, result.stdout, " \t\r\n"), '\n');
        const root = try allocator.dupe(u8, std.mem.trim(u8, lines.next() orelse "", " \t\r\n"));
        errdefer allocator.free(root);
        const git_dir = try allocator.dupe(u8, std.mem.trim(u8, lines.next() orelse "", " \t\r\n"));
        return .{
            .allocator = allocator,
            .io = io,
            .environ = environ,
            .root = root,
            .git_dir = git_dir,
            .untracked_files = resolveUntrackedFiles(allocator, io, environ, root),
        };
    }

    /// Read git's `status.showUntrackedFiles` config so `git status` matches what
    /// the user configured (and so a big repo can opt into the cheaper `normal`).
    /// Unset, unrecognized, or any error falls back to `all` — git's default.
    fn resolveUntrackedFiles(allocator: std.mem.Allocator, io: std.Io, environ: *std.process.Environ.Map, root: []const u8) UntrackedFiles {
        var argv = [_][]const u8{ "git", "config", "--get", "status.showUntrackedFiles" };
        const result = std.process.run(allocator, io, .{
            .argv = &argv,
            .cwd = .{ .path = root },
            .environ_map = environ,
            .stdout_limit = .limited(64 * 1024),
            .stderr_limit = .limited(64 * 1024),
        }) catch return .all;
        defer allocator.free(result.stdout);
        defer allocator.free(result.stderr);
        const value = std.mem.trim(u8, result.stdout, " \t\r\n");
        if (std.mem.eql(u8, value, "no")) return .no;
        if (std.mem.eql(u8, value, "normal")) return .normal;
        // "all", "yes" (git's alias for the default), unset, or anything else.
        return .all;
    }

    pub fn deinit(self: *Git) void {
        self.allocator.free(self.root);
        self.allocator.free(self.git_dir);
        for (self.command_log.items) |entry| self.allocator.free(entry);
        self.command_log.deinit(self.allocator);
        self.clearLogFilters();
        self.* = undefined;
    }

    fn logFilterSlot(self: *Git, which: LogFilter) *?[]u8 {
        return switch (which) {
            .grep => &self.log_grep,
            .author => &self.log_author,
            .path => &self.log_path,
        };
    }

    /// Set one Commits-list filter (replacing any prior value for it). The next
    /// load applies it. An empty value clears that filter.
    pub fn setLogFilter(self: *Git, which: LogFilter, value: []const u8) !void {
        const slot = self.logFilterSlot(which);
        if (slot.*) |old| self.allocator.free(old);
        slot.* = if (value.len == 0) null else try self.allocator.dupe(u8, value);
    }

    pub fn clearLogFilters(self: *Git) void {
        inline for (.{ &self.log_grep, &self.log_author, &self.log_path }) |slot| {
            if (slot.*) |old| self.allocator.free(old);
            slot.* = null;
        }
    }

    pub fn hasLogFilter(self: *const Git) bool {
        return self.log_grep != null or self.log_author != null or self.log_path != null;
    }

    /// Read-only invocations ziggity issues during loads/refreshes; these are
    /// kept out of the command log so the auto-refresh does not flood it.
    fn isReadOnly(args: []const []const u8) bool {
        if (args.len == 0) return true;
        const reads = [_][]const u8{ "status", "diff", "diff-tree", "log", "show", "rev-parse", "rev-list", "for-each-ref", "ls-files", "reflog", "cat-file" };
        for (reads) |r| if (std.mem.eql(u8, args[0], r)) return true;
        if (args.len >= 2) {
            const second = args[1];
            const long_flag = std.mem.startsWith(u8, second, "--") and second.len > 2;
            if (std.mem.eql(u8, args[0], "branch") and long_flag) return true;
            if (std.mem.eql(u8, args[0], "tag") and long_flag) return true;
            if (std.mem.eql(u8, args[0], "stash") and std.mem.eql(u8, second, "list")) return true;
        }
        return false;
    }

    /// Record a command run outside `exec` (e.g. an async network op) in the log.
    pub fn recordExternal(self: *Git, args: []const []const u8) void {
        self.recordCommand(args);
    }

    /// Append a verbatim command line to the log (e.g. a slow mutation run on a
    /// worker, whose own Git's log is discarded). Cap-aware, best-effort.
    pub fn recordRaw(self: *Git, line: []const u8) void {
        const entry = self.allocator.dupe(u8, line) catch return;
        if (self.command_log.items.len >= command_log_cap) {
            self.allocator.free(self.command_log.orderedRemove(0));
        }
        self.command_log.append(self.allocator, entry) catch self.allocator.free(entry);
    }

    fn recordCommand(self: *Git, args: []const []const u8) void {
        if (isReadOnly(args)) return;
        var buf: std.Io.Writer.Allocating = .init(self.allocator);
        defer buf.deinit();
        buf.writer.writeAll("git") catch return;
        for (args) |arg| {
            buf.writer.writeByte(' ') catch return;
            buf.writer.writeAll(arg) catch return;
        }
        const entry = buf.toOwnedSlice() catch return;
        if (self.command_log.items.len >= command_log_cap) {
            self.allocator.free(self.command_log.orderedRemove(0));
        }
        self.command_log.append(self.allocator, entry) catch self.allocator.free(entry);
    }

    /// Run an arbitrary shell command in the repository root (for user-defined
    /// custom commands). Recorded in the command log.
    pub fn runShell(self: *Git, command: []const u8) !ExecResult {
        const entry = std.fmt.allocPrint(self.allocator, "$ {s}", .{command}) catch null;
        if (entry) |e| {
            if (self.command_log.items.len >= command_log_cap) {
                self.allocator.free(self.command_log.orderedRemove(0));
            }
            self.command_log.append(self.allocator, e) catch self.allocator.free(e);
        }
        const result = try runWithLockRetry(self.allocator, self.io, .{
            .argv = &.{ "sh", "-c", command },
            .cwd = .{ .path = self.root },
            .stdout_limit = .limited(16 * 1024 * 1024),
            .stderr_limit = .limited(4 * 1024 * 1024),
        });
        return .{ .stdout = result.stdout, .stderr = result.stderr, .term = result.term };
    }

    pub fn exec(self: *Git, args: []const []const u8) !ExecResult {
        self.recordCommand(args);
        var argv: std.ArrayList([]const u8) = .empty;
        defer argv.deinit(self.allocator);
        try argv.append(self.allocator, "git");
        // `--no-optional-locks` as an explicit global flag (belt-and-suspenders
        // with GIT_OPTIONAL_LOCKS=0 in the env): the frequent read-only commands,
        // above all the background `git status`, must never take `.git/index.lock`
        // to refresh the on-disk index/untracked-cache/fsmonitor extension —
        // otherwise they collide with a concurrent `git add` and surface a
        // spurious "unable to create '.git/index.lock': File exists". The flag is
        // honored even when the env var isn't (e.g. an fsmonitor daemon). It's a
        // no-op for write commands, which take the required index lock regardless.
        // Recorded command log keeps the logical args only (see `recordCommand`).
        try argv.append(self.allocator, "--no-optional-locks");
        try argv.appendSlice(self.allocator, args);

        const result = try runWithLockRetry(self.allocator, self.io, .{
            .argv = argv.items,
            .cwd = .{ .path = self.root },
            // Also keep the env (GIT_TERMINAL_PROMPT=0, GIT_OPTIONAL_LOCKS=0).
            .environ_map = self.environ,
            .stdout_limit = .limited(16 * 1024 * 1024),
            .stderr_limit = .limited(4 * 1024 * 1024),
        });
        return .{
            .stdout = result.stdout,
            .stderr = result.stderr,
            .term = result.term,
        };
    }

    fn output(self: *Git, args: []const []const u8) ![]u8 {
        var result = try self.exec(args);
        errdefer result.deinit(self.allocator);
        if (!result.ok()) return GitError.CommandFailed;
        self.allocator.free(result.stderr);
        return result.stdout;
    }

    /// The plain unified diff for one file's unstaged (working tree vs index)
    /// or staged (index vs HEAD) changes, with no decoration — suitable for
    /// hunk parsing and re-application.
    pub fn rawFileDiff(self: *Git, path: []const u8, staged: bool, diff_context: u8) ![]u8 {
        const context_arg = try std.fmt.allocPrint(self.allocator, "--unified={d}", .{diff_context});
        defer self.allocator.free(context_arg);
        if (staged) {
            return self.output(&.{ "diff", "--staged", "--no-ext-diff", "--no-color", context_arg, "--", path });
        }
        return self.output(&.{ "diff", "--no-ext-diff", "--no-color", context_arg, "--", path });
    }

    /// The whole staged (or unstaged) diff with 3 lines of context — used to
    /// find which commit a change should be fixed up into.
    pub fn diffForFixup(self: *Git, staged: bool) ![]u8 {
        if (staged) {
            return self.output(&.{ "diff", "--staged", "--no-ext-diff", "--no-color", "--unified=3", "--" });
        }
        return self.output(&.{ "diff", "--no-ext-diff", "--no-color", "--unified=3", "--" });
    }

    /// `git blame` the old (HEAD) lines `start..end` of `file`, in porcelain form
    /// so each line's originating commit hash can be parsed.
    pub fn blameRange(self: *Git, file: []const u8, start: usize, end: usize) !ExecResult {
        const range = try std.fmt.allocPrint(self.allocator, "{d},{d}", .{ start, end });
        defer self.allocator.free(range);
        return self.exec(&.{ "blame", "-L", range, "--porcelain", "HEAD", "--", file });
    }

    /// Build `<git-dir>/<rel>` (owned). Uses the resolved git directory when
    /// known — correct inside a linked worktree, where `<root>/.git` is a
    /// gitlink *file* and the real git dir lives elsewhere — and otherwise
    /// falls back to `<root>/.git/<rel>`.
    fn gitDirPath(self: *Git, rel: []const u8) ![]u8 {
        if (self.git_dir.len > 0)
            return std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.git_dir, rel });
        return std.fmt.allocPrint(self.allocator, "{s}/.git/{s}", .{ self.root, rel });
    }

    /// Apply a patch to the index. `reverse` unstages (used on the staged
    /// side). The patch is written to a temp file under the git dir since
    /// `std.process.run` cannot feed stdin.
    pub fn applyPatch(self: *Git, patch: []const u8, reverse: bool) !ExecResult {
        const patch_path = try self.gitDirPath("ziggity-stage.patch");
        defer self.allocator.free(patch_path);
        try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = patch_path, .data = patch });
        defer std.Io.Dir.deleteFile(.cwd(), self.io, patch_path) catch {};
        if (reverse) {
            return self.exec(&.{ "apply", "--cached", "--reverse", "--whitespace=nowarn", "--", patch_path });
        }
        return self.exec(&.{ "apply", "--cached", "--whitespace=nowarn", "--", patch_path });
    }

    /// Write `patch` to a temp file under the git dir and return its path; the
    /// caller must delete the file and free the path.
    fn writeTempPatch(self: *Git, patch: []const u8) ![]u8 {
        const p = try self.gitDirPath("ziggity-custom.patch");
        errdefer self.allocator.free(p);
        try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = p, .data = patch });
        return p;
    }

    /// Apply a custom patch to the working tree (not the index). `reverse`
    /// un-applies it. Used by the custom-patch "apply to working tree" actions.
    pub fn applyCustomPatch(self: *Git, patch: []const u8, reverse: bool) !ExecResult {
        const p = try self.writeTempPatch(patch);
        defer {
            std.Io.Dir.deleteFile(.cwd(), self.io, p) catch {};
            self.allocator.free(p);
        }
        if (reverse) {
            return self.exec(&.{ "apply", "--reverse", "--whitespace=nowarn", "--", p });
        }
        return self.exec(&.{ "apply", "--whitespace=nowarn", "--", p });
    }

    /// Discard a staging-view selection by reverse-applying its patch (built
    /// against the current file state — the working tree on the unstaged side,
    /// the index on the staged side). On the unstaged side the change is dropped
    /// from the working tree only; on the staged side `--index` drops it from
    /// both the index and the working tree, so the change is gone entirely —
    /// mirroring how `space` acts on whichever side is active. Destructive.
    pub fn discardStagingPatch(self: *Git, patch: []const u8, staged: bool) !ExecResult {
        const p = try self.writeTempPatch(patch);
        defer {
            std.Io.Dir.deleteFile(.cwd(), self.io, p) catch {};
            self.allocator.free(p);
        }
        if (staged) {
            return self.exec(&.{ "apply", "--reverse", "--index", "--whitespace=nowarn", "--", p });
        }
        return self.exec(&.{ "apply", "--reverse", "--whitespace=nowarn", "--", p });
    }

    /// Remove a built patch's changes from its source commit: rebase with the
    /// caller's `edit` todo to stop at the source, reverse-apply the patch to
    /// the index and tree, amend, then continue. If the patch fails to apply
    /// the rebase is aborted; a conflict on continue is left for the user.
    pub fn removePatchFromCommit(self: *Git, base_ref: []const u8, todo: []const u8, patch: []const u8) !ExecResult {
        var r1 = try self.rebaseTodo(base_ref, todo, null);
        if (!r1.ok()) return r1;
        r1.deinit(self.allocator);

        const p = try self.writeTempPatch(patch);
        defer {
            std.Io.Dir.deleteFile(.cwd(), self.io, p) catch {};
            self.allocator.free(p);
        }

        var ap = try self.exec(&.{ "apply", "--reverse", "--index", "--whitespace=nowarn", "--", p });
        if (!ap.ok()) {
            var abort = self.rebaseAbort() catch return ap;
            abort.deinit(self.allocator);
            return ap;
        }
        ap.deinit(self.allocator);

        var amend = try self.exec(&.{ "commit", "--amend", "--no-edit" });
        if (!amend.ok()) {
            var abort = self.rebaseAbort() catch return amend;
            abort.deinit(self.allocator);
            return amend;
        }
        amend.deinit(self.allocator);

        return self.rebaseContinue();
    }

    /// Run a non-interactive `git rebase -i <base_ref>` with a caller-supplied
    /// todo. The todo is written to a temp file and installed via a
    /// `sequence.editor` that copies it over git's generated todo; messages are
    /// accepted as-is (`core.editor=true`) unless `message` is given, in which
    /// case it replaces the message git would have opened (used for reword).
    pub fn rebaseTodo(self: *Git, base_ref: []const u8, todo: []const u8, message: ?[]const u8) !ExecResult {
        const todo_path = try self.gitDirPath("ziggity-rebase-todo");
        defer self.allocator.free(todo_path);
        try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = todo_path, .data = todo });
        defer std.Io.Dir.deleteFile(.cwd(), self.io, todo_path) catch {};

        // The sequence editor copies our todo over git's; the commit-message
        // editor accepts the default (true) or, for reword, supplies our
        // message. These must be set via env vars: `-c core.editor` does not
        // reach the reword/squash message subprocess.
        const seq_editor = try std.fmt.allocPrint(self.allocator, "cp '{s}'", .{todo_path});
        defer self.allocator.free(seq_editor);

        var msg_path: ?[]u8 = null;
        defer if (msg_path) |p| {
            std.Io.Dir.deleteFile(.cwd(), self.io, p) catch {};
            self.allocator.free(p);
        };
        const msg_editor = if (message) |msg| blk: {
            const mp = try self.gitDirPath("ziggity-rebase-msg");
            errdefer self.allocator.free(mp);
            try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = mp, .data = msg });
            // Build the editor command first; only then hand `mp` to the outer
            // `defer` by setting `msg_path`. Doing it the other way round would
            // let both this `errdefer` and the outer `defer` free `mp` if the
            // allocPrint below failed (double-free).
            const editor = try std.fmt.allocPrint(self.allocator, "cp '{s}'", .{mp});
            msg_path = mp;
            break :blk editor;
        } else try self.allocator.dupe(u8, "true");
        defer self.allocator.free(msg_editor);

        var env = try self.environ.clone(self.allocator);
        defer env.deinit();
        try env.put("GIT_SEQUENCE_EDITOR", seq_editor);
        try env.put("GIT_EDITOR", msg_editor);

        const argv = [_][]const u8{ "git", "rebase", "-i", "--autostash", base_ref };
        self.recordCommand(argv[1..]);
        const result = try runWithLockRetry(self.allocator, self.io, .{
            .argv = &argv,
            .cwd = .{ .path = self.root },
            .environ_map = &env,
            .stdout_limit = .limited(16 * 1024 * 1024),
            .stderr_limit = .limited(4 * 1024 * 1024),
        });
        return .{ .stdout = result.stdout, .stderr = result.stderr, .term = result.term };
    }

    fn gitPathExists(self: *Git, rel: []const u8) bool {
        const path = self.gitDirPath(rel) catch return false;
        defer self.allocator.free(path);
        std.Io.Dir.access(.cwd(), self.io, path, .{}) catch return false;
        return true;
    }

    pub fn detectState(self: *Git) model.RepoState {
        if (self.gitPathExists("rebase-merge") or self.gitPathExists("rebase-apply")) return .rebasing;
        if (self.gitPathExists("CHERRY_PICK_HEAD")) return .cherry_picking;
        if (self.gitPathExists("MERGE_HEAD")) return .merging;
        return .clean;
    }

    pub fn isBisecting(self: *Git) bool {
        return self.gitPathExists("BISECT_LOG");
    }

    /// Resolve a conflicted file by keeping our side (or theirs), then stage it.
    pub fn resolveConflict(self: *Git, path: []const u8, take_theirs: bool) !ExecResult {
        const side = if (take_theirs) "--theirs" else "--ours";
        if (try self.runStep(&.{ "checkout", side, "--", path })) |failure| return failure;
        if (try self.runStep(&.{ "add", "--", path })) |failure| return failure;
        return self.successResult();
    }

    pub fn mergeContinue(self: *Git) !ExecResult {
        return self.exec(&.{ "commit", "--no-edit" });
    }

    pub fn mergeAbort(self: *Git) !ExecResult {
        return self.exec(&.{ "merge", "--abort" });
    }

    pub fn rebaseContinue(self: *Git) !ExecResult {
        // core.editor=true accepts the existing message without opening an editor.
        return self.exec(&.{ "-c", "core.editor=true", "rebase", "--continue" });
    }

    pub fn rebaseAbort(self: *Git) !ExecResult {
        return self.exec(&.{ "rebase", "--abort" });
    }

    /// Begin a bisect and mark `rev` as the first good/bad endpoint.
    pub fn bisectStartMark(self: *Git, good: bool, rev: []const u8) !ExecResult {
        var start = try self.exec(&.{ "bisect", "start" });
        if (!start.ok()) return start;
        start.deinit(self.allocator);
        return self.bisectMark(good, rev);
    }

    /// Mark a commit good/bad during a bisect. With no `rev` the current
    /// checkout (HEAD) is marked, which is how the search narrows.
    pub fn bisectMark(self: *Git, good: bool, rev: ?[]const u8) !ExecResult {
        const word = if (good) "good" else "bad";
        if (rev) |r| return self.exec(&.{ "bisect", word, r });
        return self.exec(&.{ "bisect", word });
    }

    pub fn bisectSkip(self: *Git) !ExecResult {
        return self.exec(&.{ "bisect", "skip" });
    }

    pub fn bisectReset(self: *Git) !ExecResult {
        return self.exec(&.{ "bisect", "reset" });
    }

    /// Amend the commit a rebase has stopped at (the `edit` step) with whatever
    /// is currently staged, keeping its message, then resume the rebase.
    pub fn amendAndContinueRebase(self: *Git) !ExecResult {
        var amend = try self.exec(&.{ "commit", "--amend", "--no-edit" });
        if (!amend.ok()) return amend;
        amend.deinit(self.allocator);
        return self.rebaseContinue();
    }

    pub fn loadStatusSummary(self: *Git) !model.StatusSummary {
        var status = model.StatusSummary{
            .current_branch = try self.currentBranch(),
        };
        errdefer status.deinit(self.allocator);

        // Resolve the upstream and whether it's gone from the branch's config
        // (`%(upstream:*)`), which is reliable even when the remote ref was
        // deleted — unlike `@{u}`, which stops resolving once the ref is gone.
        if (status.current_branch.len > 0) {
            if (self.upstreamTrack(status.current_branch)) |track| {
                status.upstream = track.upstream;
                status.upstream_gone = track.gone;
            } else |_| {}
        }
        if (status.upstream != null and !status.upstream_gone) {
            const counts = self.aheadBehind() catch null;
            if (counts) |value| {
                status.behind = value.behind;
                status.ahead = value.ahead;
            }
        }

        return status;
    }

    const UpstreamTrack = struct { upstream: ?[]u8, gone: bool };

    /// The configured upstream of `branch` and whether it's "[gone]" (deleted on
    /// the remote), read via `for-each-ref` so it works from config regardless of
    /// whether the remote-tracking ref still exists. `upstream` is owned.
    fn upstreamTrack(self: *Git, branch: []const u8) !UpstreamTrack {
        const ref = try std.fmt.allocPrint(self.allocator, "refs/heads/{s}", .{branch});
        defer self.allocator.free(ref);
        var result = try self.exec(&.{ "for-each-ref", "--format=%(upstream:short)%00%(upstream:track)", ref });
        defer result.deinit(self.allocator);
        if (!result.ok()) return .{ .upstream = null, .gone = false };
        var it = std.mem.splitScalar(u8, std.mem.trim(u8, result.stdout, " \t\r\n"), 0);
        const up = it.next() orelse "";
        const track = it.next() orelse "";
        return .{
            .upstream = if (up.len > 0) try self.allocator.dupe(u8, up) else null,
            .gone = std.mem.indexOf(u8, track, "[gone]") != null,
        };
    }

    pub fn currentBranch(self: *Git) ![]u8 {
        var result = try self.exec(&.{ "branch", "--show-current" });
        defer result.deinit(self.allocator);
        if (result.ok()) {
            const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
            if (trimmed.len > 0) return self.allocator.dupe(u8, trimmed);
        }

        var detached = try self.exec(&.{ "rev-parse", "--short", "HEAD" });
        defer detached.deinit(self.allocator);
        if (!detached.ok()) return GitError.CommandFailed;
        const trimmed = std.mem.trim(u8, detached.stdout, " \t\r\n");
        return std.fmt.allocPrint(self.allocator, "detached@{s}", .{trimmed});
    }

    /// HEAD's full commit hash, or an empty string on an unborn branch (a fresh
    /// repo with no commits). Cheap enough to run on every background refresh so
    /// history moves (an external commit/rebase) can be noticed. Owned.
    pub fn headHash(self: *Git) ![]u8 {
        var result = try self.exec(&.{ "rev-parse", "HEAD" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.dupe(u8, "");
        return self.allocator.dupe(u8, std.mem.trim(u8, result.stdout, " \t\r\n"));
    }

    const AheadBehind = struct {
        ahead: usize,
        behind: usize,
    };

    fn aheadBehind(self: *Git) !?AheadBehind {
        var result = try self.exec(&.{ "rev-list", "--left-right", "--count", "@{u}...HEAD" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return null;

        var fields = std.mem.tokenizeAny(u8, result.stdout, " \t\r\n");
        const behind_raw = fields.next() orelse return null;
        const ahead_raw = fields.next() orelse return null;
        return .{
            .behind = std.fmt.parseInt(usize, behind_raw, 10) catch 0,
            .ahead = std.fmt.parseInt(usize, ahead_raw, 10) catch 0,
        };
    }

    /// Raw `git status --porcelain -z` bytes. Split out from `loadFiles` so a
    /// background worker can capture the (slow) git output off the UI thread and
    /// let the UI thread do the cheap parsing with its own allocator.
    pub fn statusPorcelain(self: *Git) ![]u8 {
        return self.output(&.{ "status", "--porcelain", "-z", self.untracked_files.flag(), "--find-renames=50%" });
    }

    pub fn loadFiles(self: *Git) ![]model.FileStatus {
        const output_bytes = try self.statusPorcelain();
        defer self.allocator.free(output_bytes);
        return parseStatus(self.allocator, output_bytes);
    }

    pub fn loadBranches(self: *Git) ![]model.Branch {
        // `alphabetical` sorts by ref name; `date`/`recency` start from
        // most-recent commit first. `recency` is then reordered by the reflog.
        const sort_flag: []const u8 = switch (self.branch_sort) {
            .alphabetical => "--sort=refname",
            .date, .recency => "--sort=-committerdate",
        };
        const bytes = try self.output(&.{ "branch", sort_flag, "--format=%(HEAD)%00%(refname:short)%00%(upstream:short)%00%(upstream:track)%00%(committerdate:unix)" });
        defer self.allocator.free(bytes);
        const list = try parseBranches(self.allocator, bytes);
        errdefer {
            for (list) |*branch| branch.deinit(self.allocator);
            self.allocator.free(list);
        }
        if (self.branch_sort == .recency) {
            if (self.loadBranchRecency()) |recency| {
                defer {
                    for (recency) |name| self.allocator.free(name);
                    self.allocator.free(recency);
                }
                applyRecencyOrder(list, recency);
            } else |_| {}
        }
        // The current branch is always listed first.
        moveCurrentBranchToFront(list);
        return list;
    }

    /// Branch names in most-recently-checked-out order, parsed from HEAD's
    /// reflog `checkout: moving from X to Y` subjects (for the `recency` order).
    pub fn loadBranchRecency(self: *Git) ![][]u8 {
        var result = try self.exec(&.{ "reflog", "-200", "--format=%gs" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc([]u8, 0);
        return parseBranchRecency(self.allocator, result.stdout);
    }

    pub fn loadRemoteBranches(self: *Git) ![]model.Branch {
        // Skip the `<remote>/HEAD -> ...` symbolic entry git lists for remotes.
        var result = try self.exec(&.{ "branch", "--remotes", "--format=%00%(refname:short)%00", "--list" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Branch, 0);
        const all = try parseBranches(self.allocator, result.stdout);
        defer self.allocator.free(all);

        var kept: std.ArrayList(model.Branch) = .empty;
        errdefer {
            for (kept.items) |*branch| branch.deinit(self.allocator);
            kept.deinit(self.allocator);
        }
        for (all) |*branch| {
            // The symbolic `<remote>/HEAD` entry shortens to just the remote
            // name (no slash); real remote branches are always `remote/branch`.
            if (std.mem.indexOfScalar(u8, branch.name, '/') == null) {
                branch.deinit(self.allocator);
                continue;
            }
            try kept.append(self.allocator, branch.*);
        }
        return kept.toOwnedSlice(self.allocator);
    }

    /// The configured remote names (`git remote`), one per line, independent of
    /// whether any of their branches have been fetched.
    pub fn loadRemotes(self: *Git) ![]model.Remote {
        var result = try self.exec(&.{ "remote", "-v" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Remote, 0);
        return parseRemotes(self.allocator, result.stdout);
    }

    pub fn loadTags(self: *Git) ![]model.Tag {
        var result = try self.exec(&.{ "tag", "--sort=-creatordate", "--format=%(refname:short)%00%(contents:subject)" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Tag, 0);
        return parseTags(self.allocator, result.stdout);
    }

    pub fn loadWorktrees(self: *Git) ![]model.Worktree {
        var result = try self.exec(&.{ "worktree", "list", "--porcelain" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Worktree, 0);
        return parseWorktrees(self.allocator, result.stdout, self.root);
    }

    pub fn removeWorktree(self: *Git, path: []const u8) !ExecResult {
        return self.exec(&.{ "worktree", "remove", "--", path });
    }

    /// Create a worktree at `path`. With an empty `base`, git makes a new branch
    /// (named after the path) from HEAD; otherwise it checks out `base` there.
    pub fn addWorktree(self: *Git, path: []const u8, base: []const u8) !ExecResult {
        if (base.len == 0) return self.exec(&.{ "worktree", "add", path });
        return self.exec(&.{ "worktree", "add", path, base });
    }

    pub fn loadSubmodules(self: *Git) ![]model.Submodule {
        var result = try self.exec(&.{ "submodule", "status" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Submodule, 0);
        return parseSubmodules(self.allocator, result.stdout);
    }

    pub fn updateSubmodule(self: *Git, path: []const u8) !ExecResult {
        return self.exec(&.{ "submodule", "update", "--init", "--", path });
    }

    pub fn addSubmodule(self: *Git, url: []const u8, path: []const u8) !ExecResult {
        return self.exec(&.{ "submodule", "add", "--", url, path });
    }

    /// Point the submodule at a new URL (in `.gitmodules`) and sync it into the
    /// active config.
    pub fn setSubmoduleUrl(self: *Git, path: []const u8, url: []const u8) !ExecResult {
        var r = try self.exec(&.{ "submodule", "set-url", "--", path, url });
        if (!r.ok()) return r;
        r.deinit(self.allocator);
        return self.exec(&.{ "submodule", "sync", "--", path });
    }

    /// Remove a submodule: deinit it, then `git rm` (which also drops its
    /// `.gitmodules` entry from the index).
    pub fn removeSubmodule(self: *Git, path: []const u8) !ExecResult {
        var r = try self.exec(&.{ "submodule", "deinit", "-f", "--", path });
        if (!r.ok()) return r;
        r.deinit(self.allocator);
        return self.exec(&.{ "rm", "-f", "--", path });
    }

    pub const SubmoduleBulk = enum { init_all, update_all, update_recursive, deinit_all };

    pub fn bulkSubmodule(self: *Git, op: SubmoduleBulk) !ExecResult {
        return switch (op) {
            .init_all => self.exec(&.{ "submodule", "init" }),
            .update_all => self.exec(&.{ "submodule", "update" }),
            .update_recursive => self.exec(&.{ "submodule", "update", "--init", "--recursive" }),
            .deinit_all => self.exec(&.{ "submodule", "deinit", "--all", "-f" }),
        };
    }

    /// Best-effort: write git's commit-graph cache (commit generation numbers)
    /// so `--topo-order` logs stay fast on large repos — the same cache a
    /// gc/fetch-maintained repo already has, which is why lazygit's topo default
    /// is snappy there. It's a pure cache under `.git/objects/info`, incremental
    /// (cheap when already current), and any failure (read-only repo, old git,
    /// shallow clone) is silently ignored. Runs off the UI thread.
    pub fn ensureCommitGraph(self: *Git) void {
        var res = self.exec(&.{ "commit-graph", "write", "--reachable" }) catch return;
        res.deinit(self.allocator);
    }

    /// A commit's author and committer identity fields, read in strict ISO 8601
    /// dates (`%aI`/`%cI`) so they round-trip back through `GIT_*_DATE`. Used to
    /// prefill the date prompts and to pin the fields an edit should leave alone.
    pub const CommitIdentity = struct {
        author_name: []u8,
        author_email: []u8,
        author_date: []u8,
        committer_name: []u8,
        committer_email: []u8,
        committer_date: []u8,

        pub fn deinit(self: *CommitIdentity, allocator: std.mem.Allocator) void {
            allocator.free(self.author_name);
            allocator.free(self.author_email);
            allocator.free(self.author_date);
            allocator.free(self.committer_name);
            allocator.free(self.committer_email);
            allocator.free(self.committer_date);
            self.* = undefined;
        }
    };

    /// The current time as git's raw "<epoch> <tz>" date string — accepted by
    /// `--date`, `GIT_COMMITTER_DATE`, and `isValidDate` alike (unlike the word
    /// "now", which the committer path rejects). Read off `git var
    /// GIT_AUTHOR_IDENT`, whose output ends "… <epoch> <tz>". Caller owns it.
    pub fn nowDate(self: *Git, allocator: std.mem.Allocator) ![]u8 {
        var res = try self.exec(&.{ "var", "GIT_AUTHOR_IDENT" });
        defer res.deinit(self.allocator);
        if (!res.ok()) return error.GitCommandFailed;
        const line = std.mem.trim(u8, res.stdout, " \t\r\n");
        // Name/email may contain spaces, so take the final two tokens.
        const last = std.mem.lastIndexOfScalar(u8, line, ' ') orelse return error.ParseFailed;
        const tz = line[last + 1 ..];
        const prev = std.mem.lastIndexOfScalar(u8, line[0..last], ' ') orelse return error.ParseFailed;
        const epoch = line[prev + 1 .. last];
        return std.fmt.allocPrint(allocator, "{s} {s}", .{ epoch, tz });
    }

    /// Whether `date` is a form git will accept for author/committer dates.
    /// Validated with `git var GIT_AUTHOR_IDENT` (which parses `GIT_AUTHOR_DATE`
    /// through the same strict date parser the committer-date path uses), so a
    /// pass here guarantees the later amend won't fail mid-rebase. Accepts ISO
    /// 8601 and `@<epoch> <tz>`; rejects garbage and relative forms like
    /// "2 days ago" (which the committer-date path cannot take anyway).
    pub fn isValidDate(self: *Git, date: []const u8) bool {
        var env = self.environ.clone(self.allocator) catch return false;
        defer env.deinit();
        env.put("GIT_AUTHOR_DATE", date) catch return false;
        var child = std.process.spawn(self.io, .{
            .argv = &.{ "git", "var", "GIT_AUTHOR_IDENT" },
            .cwd = .{ .path = self.root },
            .environ_map = &env,
            .stdin = .ignore,
            .stdout = .ignore,
            .stderr = .ignore,
        }) catch return false;
        const term = child.wait(self.io) catch return false;
        return switch (term) {
            .exited => |code| code == 0,
            else => false,
        };
    }

    pub fn commitIdentity(self: *Git, hash: []const u8) !CommitIdentity {
        var res = try self.exec(&.{ "show", "-s", "--no-show-signature", "--format=%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI", hash });
        defer res.deinit(self.allocator);
        if (!res.ok()) return error.GitCommandFailed;
        const trimmed = std.mem.trim(u8, res.stdout, "\n");
        var it = std.mem.splitScalar(u8, trimmed, 0);
        const an = it.next() orelse return error.ParseFailed;
        const ae = it.next() orelse return error.ParseFailed;
        const ai = it.next() orelse return error.ParseFailed;
        const cn = it.next() orelse return error.ParseFailed;
        const ce = it.next() orelse return error.ParseFailed;
        const ci = it.next() orelse return error.ParseFailed;

        const author_name = try self.allocator.dupe(u8, an);
        errdefer self.allocator.free(author_name);
        const author_email = try self.allocator.dupe(u8, ae);
        errdefer self.allocator.free(author_email);
        const author_date = try self.allocator.dupe(u8, ai);
        errdefer self.allocator.free(author_date);
        const committer_name = try self.allocator.dupe(u8, cn);
        errdefer self.allocator.free(committer_name);
        const committer_email = try self.allocator.dupe(u8, ce);
        errdefer self.allocator.free(committer_email);
        const committer_date = try self.allocator.dupe(u8, ci);
        return .{
            .author_name = author_name,
            .author_email = author_email,
            .author_date = author_date,
            .committer_name = committer_name,
            .committer_email = committer_email,
            .committer_date = committer_date,
        };
    }

    /// The staged patch (`git diff --cached`), the AI context for commit
    /// authoring. Owned by the caller; empty on failure.
    pub fn stagedDiff(self: *Git) ![]u8 {
        var res = try self.exec(&.{ "diff", "--cached", "--no-color", "--no-ext-diff" });
        defer res.deinit(self.allocator);
        if (!res.ok()) return self.allocator.dupe(u8, "");
        return self.allocator.dupe(u8, res.stdout);
    }

    /// Newline-separated names of staged files (`git diff --cached --name-only`).
    pub fn stagedFileNames(self: *Git) ![]u8 {
        var res = try self.exec(&.{ "diff", "--cached", "--name-only" });
        defer res.deinit(self.allocator);
        if (!res.ok()) return self.allocator.dupe(u8, "");
        return self.allocator.dupe(u8, res.stdout);
    }

    /// The `n` most recent commit subjects (newline-separated), so the model can
    /// pick up the repo's commit-message conventions. Owned; empty on failure.
    pub fn recentSubjects(self: *Git, n: usize) ![]u8 {
        const narg = std.fmt.allocPrint(self.allocator, "-{d}", .{n}) catch return self.allocator.dupe(u8, "");
        defer self.allocator.free(narg);
        var res = self.exec(&.{ "log", narg, "--no-merges", "--format=%s" }) catch return self.allocator.dupe(u8, "");
        defer res.deinit(self.allocator);
        if (!res.ok()) return self.allocator.dupe(u8, "");
        return self.allocator.dupe(u8, res.stdout);
    }

    /// Append `s` single-quoted (POSIX-safe) to `out`, for embedding a path in a
    /// `sh -c` line.
    fn appendSingleQuoted(out: *std.ArrayList(u8), allocator: std.mem.Allocator, s: []const u8) !void {
        try out.append(allocator, '\'');
        for (s) |c| {
            if (c == '\'') try out.appendSlice(allocator, "'\\''") else try out.append(allocator, c);
        }
        try out.append(allocator, '\'');
    }

    /// Run the configured AI command, feeding `prompt` on stdin and returning its
    /// stdout. The prompt is piped in from a temp file (`cat file | <command>`),
    /// which handles pipelines and avoids any stdin/stdout deadlock. `slot`
    /// (e.g. "title"/"desc") separates concurrent requests' temp files. ziggity
    /// treats the command as a black box: prompt in, completion out.
    pub fn runAiCommand(self: *Git, command: []const u8, slot: []const u8, prompt: []const u8) !ExecResult {
        const name = try std.fmt.allocPrint(self.allocator, "ziggity-ai-{s}", .{slot});
        defer self.allocator.free(name);
        const path = try self.gitDirPath(name);
        defer self.allocator.free(path);
        try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = path, .data = prompt });
        defer std.Io.Dir.deleteFile(.cwd(), self.io, path) catch {};

        var full: std.ArrayList(u8) = .empty;
        defer full.deinit(self.allocator);
        try full.appendSlice(self.allocator, "cat ");
        try appendSingleQuoted(&full, self.allocator, path);
        try full.appendSlice(self.allocator, " | ");
        try full.appendSlice(self.allocator, command);

        const result = try runWithLockRetry(self.allocator, self.io, .{
            .argv = &.{ "sh", "-c", full.items },
            .cwd = .{ .path = self.root },
            .environ_map = self.environ,
            .stdout_limit = .limited(1 * 1024 * 1024),
            .stderr_limit = .limited(256 * 1024),
        });
        return .{ .stdout = result.stdout, .stderr = result.stderr, .term = result.term };
    }

    pub fn loadCommits(self: *Git, ref_name: []const u8, limit: usize) ![]model.Commit {
        const limit_arg = try std.fmt.allocPrint(self.allocator, "-{d}", .{limit});
        defer self.allocator.free(limit_arg);

        var argv: std.ArrayList([]const u8) = .empty;
        defer argv.deinit(self.allocator);
        try argv.appendSlice(self.allocator, &.{
            "log",
            ref_name,
            "--date=relative",
            // %P (parent hashes) feeds the inline commit graph.
            "--pretty=format:%H%x00%h%x00%an%x00%cr%x00%D%x00%P%x00%s",
            "--no-show-signature",
            limit_arg,
        });
        // Commit ordering (date / topo / author-date) — topo keeps a branch's
        // commits contiguous so the inline graph's lanes stay readable.
        if (model.logOrderFlag(self.log_order)) |flag| try argv.append(self.allocator, flag);

        // Optional Commits-list filters. The --grep/--author args own their
        // formatted strings until the command runs.
        var grep_arg: ?[]u8 = null;
        defer if (grep_arg) |a| self.allocator.free(a);
        if (self.log_grep) |g| {
            grep_arg = try std.fmt.allocPrint(self.allocator, "--grep={s}", .{g});
            try argv.appendSlice(self.allocator, &.{ "--regexp-ignore-case", grep_arg.? });
        }
        var author_arg: ?[]u8 = null;
        defer if (author_arg) |a| self.allocator.free(a);
        if (self.log_author) |a| {
            author_arg = try std.fmt.allocPrint(self.allocator, "--author={s}", .{a});
            try argv.append(self.allocator, author_arg.?);
        }
        // A pathspec must come last, after a `--` separator.
        if (self.log_path) |p| try argv.appendSlice(self.allocator, &.{ "--", p });

        // `git log` fails on an unborn branch (no commits yet) and on an
        // unmatched/invalid filter; treat that as an empty list rather than
        // failing the whole repo load (matches the other list loaders).
        var result = try self.exec(argv.items);
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Commit, 0);
        const commits = try parseCommits(self.allocator, result.stdout);
        // Colour each commit by push state in the main (HEAD) view.
        if (std.mem.eql(u8, ref_name, "HEAD")) self.markPushStatus(commits);
        return commits;
    }

    /// Tag each commit `unpushed` (ahead of the current branch's upstream) or
    /// `pushed`, so the Commits panel can colour the hash (red vs the usual
    /// yellow). Best-effort: with no upstream (`rev-list` fails) statuses are
    /// left `.none` and the default hash colour is used.
    fn markPushStatus(self: *Git, commits: []model.Commit) void {
        var res = self.exec(&.{ "rev-list", "@{upstream}..HEAD" }) catch return;
        defer res.deinit(self.allocator);
        if (!res.ok()) return;

        var unpushed = std.BufSet.init(self.allocator);
        defer unpushed.deinit();
        var lines = std.mem.tokenizeScalar(u8, res.stdout, '\n');
        while (lines.next()) |line| {
            const h = std.mem.trim(u8, line, " \r");
            if (h.len > 0) unpushed.insert(h) catch return;
        }
        for (commits) |*c| c.status = if (unpushed.contains(c.hash)) .unpushed else .pushed;
    }

    /// Commits that diverge between the current branch and its upstream, for the
    /// Divergence tab — a left-right log of `HEAD...@{upstream}`. `%m` marks each
    /// side: `<` = ahead (local-only, ↑ — to push), `>` = behind (on the
    /// upstream, ↓ — to pull). Ahead commits are grouped first. Empty when there
    /// is no upstream or the branch is in sync.
    pub fn loadDivergence(self: *Git, limit: usize) ![]model.Commit {
        const limit_arg = try std.fmt.allocPrint(self.allocator, "-{d}", .{limit});
        defer self.allocator.free(limit_arg);
        var result = self.exec(&.{
            "log",
            "--left-right",
            "--date=relative",
            "--pretty=format:%m%x00%H%x00%h%x00%an%x00%cr%x00%s",
            "--no-show-signature",
            limit_arg,
            "HEAD...@{upstream}",
        }) catch return self.allocator.alloc(model.Commit, 0);
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Commit, 0);

        const commits = try parseDivergence(self.allocator, result.stdout);
        // Group the ahead (↑) commits before the behind (↓) ones. Moving the
        // structs re-uses their owned buffers; only the old slice is freed.
        const ordered = try self.allocator.alloc(model.Commit, commits.len);
        var n: usize = 0;
        for (commits) |c| if (c.divergence == .ahead) {
            ordered[n] = c;
            n += 1;
        };
        for (commits) |c| if (c.divergence != .ahead) {
            ordered[n] = c;
            n += 1;
        };
        self.allocator.free(commits);
        return ordered;
    }

    fn parseDivergence(allocator: std.mem.Allocator, bytes: []const u8) ![]model.Commit {
        var commits: std.ArrayList(model.Commit) = .empty;
        errdefer {
            for (commits.items) |*item| item.deinit(allocator);
            commits.deinit(allocator);
        }
        var lines = std.mem.splitScalar(u8, bytes, '\n');
        while (lines.next()) |line| {
            if (line.len == 0) continue;
            var fields = std.mem.splitScalar(u8, line, 0);
            const marker = fields.next() orelse continue; // "<" (ahead) / ">" (behind)
            const hash = fields.next() orelse continue;
            const short_hash = fields.next() orelse continue;
            const author = fields.next() orelse "";
            const time = fields.next() orelse "";
            const subject = fields.next() orelse "";
            const divergence: model.Divergence = if (marker.len > 0 and marker[0] == '<')
                .ahead
            else if (marker.len > 0 and marker[0] == '>')
                .behind
            else
                .none;
            var item = model.Commit{
                .hash = try allocator.dupe(u8, hash),
                .short_hash = try allocator.dupe(u8, short_hash),
                .author = try allocator.dupe(u8, author),
                .time = try allocator.dupe(u8, time),
                .refs = try allocator.dupe(u8, ""),
                .subject = try allocator.dupe(u8, subject),
                .divergence = divergence,
            };
            errdefer item.deinit(allocator);
            try commits.append(allocator, item);
        }
        return commits.toOwnedSlice(allocator);
    }

    pub fn loadReflog(self: *Git, limit: usize) ![]model.Commit {
        const limit_arg = try std.fmt.allocPrint(self.allocator, "-{d}", .{limit});
        defer self.allocator.free(limit_arg);
        // %gd is the reflog selector (HEAD@{n}); %gs the reflog subject. They
        // map onto the Commit model's refs/subject fields for reuse.
        var result = try self.exec(&.{
            "log",
            "-g",
            "--date=relative",
            "--pretty=format:%H%x00%h%x00%an%x00%cr%x00%gd%x00%gs",
            "--no-show-signature",
            limit_arg,
        });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.Commit, 0);
        return parseCommits(self.allocator, result.stdout);
    }

    pub fn loadStash(self: *Git) ![]model.StashEntry {
        var result = try self.exec(&.{ "stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.StashEntry, 0);
        return parseStash(self.allocator, result.stdout);
    }

    /// Files changed by a single commit (vs its parent, or all files for the
    /// root commit thanks to --root).
    pub fn loadCommitFiles(self: *Git, hash: []const u8) ![]model.CommitFile {
        var result = try self.exec(&.{ "diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-z", hash });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(model.CommitFile, 0);
        return parseCommitFiles(self.allocator, result.stdout);
    }

    /// The diff a single commit introduced for one path.
    pub fn diffForCommitFile(self: *Git, hash: []const u8, path: []const u8, diff_context: u8) ![]u8 {
        const context_arg = try std.fmt.allocPrint(self.allocator, "--unified={d}", .{diff_context});
        defer self.allocator.free(context_arg);
        var result = try self.exec(&.{ "show", hash, "--no-ext-diff", "--no-color", context_arg, "--format=", "--", path });
        errdefer result.deinit(self.allocator);
        if (!result.ok()) return GitError.CommandFailed;
        self.allocator.free(result.stderr);
        return result.stdout;
    }

    /// The configured `core.editor` (or null if unset), allocated by the Git's
    /// allocator — caller frees. Used to auto-detect the file editor.
    pub fn coreEditor(self: *Git) ?[]u8 {
        var res = self.exec(&.{ "config", "--get", "core.editor" }) catch return null;
        defer res.deinit(self.allocator);
        if (!res.ok()) return null;
        const trimmed = std.mem.trim(u8, res.stdout, " \t\r\n");
        if (trimmed.len == 0) return null;
        return self.allocator.dupe(u8, trimmed) catch null;
    }

    pub fn stagePaths(self: *Git, paths: []const []const u8) !ExecResult {
        if (paths.len == 0) return self.successResult();
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "add", "--" });
        try args.appendSlice(self.allocator, paths);
        return self.exec(args.items);
    }

    pub fn unstagePaths(self: *Git, paths: []const []const u8) !ExecResult {
        if (paths.len == 0) return self.successResult();
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "reset", "HEAD", "--" });
        try args.appendSlice(self.allocator, paths);
        return self.exec(args.items);
    }

    pub fn stageAll(self: *Git) !ExecResult {
        return self.exec(&.{ "add", "-A" });
    }

    pub fn unstageAll(self: *Git) !ExecResult {
        return self.exec(&.{"reset"});
    }

    pub fn discardFile(self: *Git, file: model.FileStatus) !ExecResult {
        if (file.isRename()) {
            if (try self.resetFilePaths(file)) |failure| return failure;
            if (file.previous_path) |previous| {
                if (try self.runStep(&.{ "checkout", "HEAD", "--", previous })) |failure| return failure;
            }
            if (try self.runStep(&.{ "clean", "-fd", "--", file.path })) |failure| return failure;
            return self.successResult();
        }

        if (!file.tracked or file.added) {
            if (file.has_staged) {
                if (try self.resetFilePaths(file)) |failure| return failure;
            }
            if (try self.runStep(&.{ "clean", "-fd", "--", file.path })) |failure| return failure;
            return self.successResult();
        }

        if (try self.runStep(&.{ "checkout", "HEAD", "--", file.path })) |failure| return failure;
        return self.successResult();
    }

    /// Discard only the unstaged (working-tree) changes for a file, keeping any
    /// staged changes intact by restoring the working tree from the index.
    pub fn discardUnstaged(self: *Git, file: model.FileStatus) !ExecResult {
        return self.exec(&.{ "checkout", "--", file.path });
    }

    pub fn discardAll(self: *Git) !ExecResult {
        if (try self.runStep(&.{ "reset", "--hard", "HEAD" })) |failure| return failure;
        if (try self.runStep(&.{ "clean", "-fd" })) |failure| return failure;
        return self.successResult();
    }

    pub fn commit(self: *Git, message: []const u8, no_verify: bool) !ExecResult {
        if (no_verify) return self.exec(&.{ "commit", "--no-verify", "-m", message });
        return self.exec(&.{ "commit", "-m", message });
    }

    /// Run the repo's `prepare-commit-msg` hook (if present) against an empty
    /// message file the way an interactive commit would — no `source` argument,
    /// so hooks that guard on an empty `$2` (e.g. a branch-derived ticket prefix)
    /// fire — and return the resulting message with git comment (`#`) lines
    /// stripped. Returns null when there is no runnable hook or it declines
    /// (non-zero exit); the caller owns any returned slice.
    ///
    /// Uses git's default hook directory (`<git-dir>/hooks`); a `core.hooksPath`
    /// relocation is not consulted, keeping the common no-hook case to a single
    /// `access` check with no subprocess spawned.
    pub fn prepareCommitMsg(self: *Git) !?[]u8 {
        const hook_path = try std.fmt.allocPrint(self.allocator, "{s}/hooks/prepare-commit-msg", .{self.git_dir});
        defer self.allocator.free(hook_path);
        // Cheap gate: no runnable hook -> nothing to do (one syscall, no process).
        std.Io.Dir.access(.cwd(), self.io, hook_path, .{ .execute = true }) catch return null;

        const msg_path = try std.fmt.allocPrint(self.allocator, "{s}/COMMIT_EDITMSG", .{self.git_dir});
        defer self.allocator.free(msg_path);
        // Seed an empty template for the hook to populate, mirroring git's flow.
        std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = msg_path, .data = "" }) catch return null;

        var argv = [_][]const u8{ hook_path, msg_path };
        const result = std.process.run(self.allocator, self.io, .{
            .argv = &argv,
            .cwd = .{ .path = self.root },
            .environ_map = self.environ,
            .stdout_limit = .limited(1 << 20),
            .stderr_limit = .limited(1 << 20),
        }) catch return null;
        self.allocator.free(result.stdout);
        self.allocator.free(result.stderr);
        switch (result.term) {
            .exited => |code| if (code != 0) return null,
            else => return null,
        }

        const raw = std.Io.Dir.readFileAlloc(.cwd(), self.io, msg_path, self.allocator, .limited(1 << 20)) catch return null;
        defer self.allocator.free(raw);
        return try stripCommentLines(self.allocator, raw);
    }

    /// Append `path` to the repo's `.gitignore`, creating it if absent.
    /// Append `path` as its own line to the ignore file at `abs_file` (an
    /// absolute path), creating it if absent and skipping an exact-line
    /// duplicate. Ensures a trailing newline first so entries never run together.
    fn appendIgnoreEntry(self: *Git, abs_file: []const u8, path: []const u8) !ExecResult {
        const existing = std.Io.Dir.readFileAlloc(.cwd(), self.io, abs_file, self.allocator, .limited(1 << 20)) catch |err| switch (err) {
            error.FileNotFound => try self.allocator.dupe(u8, ""),
            else => return err,
        };
        defer self.allocator.free(existing);
        // Avoid duplicating an entry that's already there (line-exact match).
        var it = std.mem.splitScalar(u8, existing, '\n');
        while (it.next()) |line| {
            if (std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), path)) return self.successResult();
        }
        const needs_nl = existing.len > 0 and existing[existing.len - 1] != '\n';
        const updated = try std.fmt.allocPrint(self.allocator, "{s}{s}{s}\n", .{ existing, if (needs_nl) "\n" else "", path });
        defer self.allocator.free(updated);
        try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = abs_file, .data = updated });
        return self.successResult();
    }

    /// Add `path` to the repo's `.gitignore` (shared and committed with the team).
    pub fn ignoreFile(self: *Git, path: []const u8) !ExecResult {
        const gi = try std.fmt.allocPrint(self.allocator, "{s}/.gitignore", .{self.root});
        defer self.allocator.free(gi);
        return self.appendIgnoreEntry(gi, path);
    }

    /// Add `path` to `.git/info/exclude` — a local, per-repo ignore list that is
    /// never committed. `--git-path` resolves the file to the right place,
    /// including a linked worktree's shared copy.
    pub fn excludeFile(self: *Git, path: []const u8) !ExecResult {
        var pr = try self.exec(&.{ "rev-parse", "--git-path", "info/exclude" });
        defer pr.deinit(self.allocator);
        if (!pr.ok()) return pr; // caller owns and reports the error
        const rel = std.mem.trim(u8, pr.stdout, " \t\r\n");
        // `--git-path` is relative to the repo root (exec's cwd); make it absolute
        // so the write does not depend on the process's working directory.
        const excl = if (std.fs.path.isAbsolute(rel))
            try self.allocator.dupe(u8, rel)
        else
            try std.fs.path.join(self.allocator, &.{ self.root, rel });
        defer self.allocator.free(excl);
        return self.appendIgnoreEntry(excl, path);
    }

    pub fn checkout(self: *Git, branch_name: []const u8) !ExecResult {
        return self.exec(&.{ "checkout", branch_name });
    }

    /// Check out a remote-tracking ref (e.g. `origin/feature`) as a new local
    /// tracking branch (`feature`) instead of a detached HEAD: `checkout --track`.
    pub fn checkoutTrack(self: *Git, ref: []const u8) !ExecResult {
        return self.exec(&.{ "checkout", "--track", ref });
    }

    /// Force-checkout a branch, discarding uncommitted changes (`checkout -f`).
    pub fn forceCheckout(self: *Git, branch_name: []const u8) !ExecResult {
        return self.exec(&.{ "checkout", "-f", branch_name });
    }

    /// How many commits HEAD is ahead of the nearest main branch (main/master)
    /// — the current branch's own, not-yet-on-main commits. 0 when none / no
    /// main branch exists (e.g. you're on main itself).
    pub fn branchCommitsCount(self: *Git) !usize {
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "rev-list", "--count", "HEAD", "--not" });
        var any = false;
        for ([_][]const u8{ "main", "master" }) |m| {
            var r = self.exec(&.{ "rev-parse", "--verify", "--quiet", m }) catch continue;
            const exists = r.ok();
            r.deinit(self.allocator);
            if (exists) {
                try args.append(self.allocator, m);
                any = true;
            }
        }
        if (!any) return 0;
        var res = try self.exec(args.items);
        defer res.deinit(self.allocator);
        if (!res.ok()) return 0;
        return std.fmt.parseInt(usize, std.mem.trim(u8, res.stdout, " \t\r\n"), 10) catch 0;
    }

    /// Create a new branch from HEAD and switch to it.
    pub fn createBranch(self: *Git, name: []const u8) !ExecResult {
        return self.exec(&.{ "checkout", "-b", name });
    }

    pub fn deleteBranch(self: *Git, name: []const u8, force: bool) !ExecResult {
        const flag = if (force) "-D" else "-d";
        return self.exec(&.{ "branch", flag, "--", name });
    }

    /// Delete several local branches in one `git branch -d/-D -- <names...>`.
    pub fn deleteBranches(self: *Git, names: []const []const u8, force: bool) !ExecResult {
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "branch", if (force) "-D" else "-d", "--" });
        try args.appendSlice(self.allocator, names);
        return self.exec(args.items);
    }

    pub fn renameBranch(self: *Git, old_name: []const u8, new_name: []const u8) !ExecResult {
        return self.exec(&.{ "branch", "-m", old_name, new_name });
    }

    /// Create a tag. A non-empty `message` makes it annotated (`-a -m`); empty
    /// makes it lightweight. `force` (`-f`) overwrites an existing tag. A
    /// non-empty `target` (a commit-ish) tags that commit; empty tags HEAD.
    pub fn createTag(self: *Git, name: []const u8, message: []const u8, force: bool, target: []const u8) !ExecResult {
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.append(self.allocator, "tag");
        if (force) try args.append(self.allocator, "-f");
        if (message.len > 0) try args.appendSlice(self.allocator, &.{ "-a", "-m", message });
        try args.appendSlice(self.allocator, &.{ "--", name });
        if (target.len > 0) try args.append(self.allocator, target);
        return self.exec(args.items);
    }

    pub fn deleteTag(self: *Git, name: []const u8) !ExecResult {
        return self.exec(&.{ "tag", "-d", "--", name });
    }

    /// Delete a tag on a remote: `git push <remote> --delete refs/tags/<tag>`.
    /// The `refs/tags/` prefix disambiguates from a same-named branch.
    pub fn deleteRemoteTag(self: *Git, remote: []const u8, name: []const u8) !ExecResult {
        const ref = try std.fmt.allocPrint(self.allocator, "refs/tags/{s}", .{name});
        defer self.allocator.free(ref);
        return self.exec(&.{ "push", remote, "--delete", ref });
    }

    pub fn deleteRemoteBranch(self: *Git, remote: []const u8, branch: []const u8) !ExecResult {
        return self.exec(&.{ "push", remote, "--delete", branch });
    }

    /// Delete several branches on one `remote` in a single push.
    pub fn deleteRemoteBranches(self: *Git, remote: []const u8, refs: []const []const u8) !ExecResult {
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "push", remote, "--delete" });
        try args.appendSlice(self.allocator, refs);
        return self.exec(args.items);
    }

    /// The configured fetch URL of `remote` (e.g. `git@github.com:owner/repo`).
    pub fn remoteUrl(self: *Git, remote: []const u8) ![]u8 {
        return self.output(&.{ "remote", "get-url", remote });
    }

    pub fn addRemote(self: *Git, name: []const u8, url: []const u8) !ExecResult {
        return self.exec(&.{ "remote", "add", name, url });
    }

    pub fn renameRemote(self: *Git, old: []const u8, new: []const u8) !ExecResult {
        return self.exec(&.{ "remote", "rename", old, new });
    }

    /// Create (and check out) a new local branch `name` starting at `start`
    /// (a remote ref like "origin/main" becomes a tracking branch).
    pub fn newBranchFrom(self: *Git, name: []const u8, start: []const u8) !ExecResult {
        return self.exec(&.{ "checkout", "-b", name, start });
    }

    /// Move `original`'s commits onto a new branch `name`: create+switch to the
    /// new branch at HEAD (carrying the commits), then reset `original` back to
    /// its upstream. The caller guarantees `original` has an upstream.
    pub fn moveCommitsToNewBranch(self: *Git, name: []const u8, original: []const u8) !ExecResult {
        var r = try self.exec(&.{ "checkout", "-b", name });
        if (!r.ok()) return r;
        r.deinit(self.allocator);
        const upstream = try std.fmt.allocPrint(self.allocator, "{s}@{{u}}", .{original});
        defer self.allocator.free(upstream);
        return self.exec(&.{ "branch", "-f", original, upstream });
    }

    pub fn setRemoteUrl(self: *Git, name: []const u8, url: []const u8) !ExecResult {
        return self.exec(&.{ "remote", "set-url", name, url });
    }

    pub fn removeRemote(self: *Git, name: []const u8) !ExecResult {
        return self.exec(&.{ "remote", "remove", name });
    }

    /// Set the current branch's upstream tracking ref (e.g. `origin/main`).
    pub fn setUpstream(self: *Git, ref: []const u8) !ExecResult {
        const arg = try std.fmt.allocPrint(self.allocator, "--set-upstream-to={s}", .{ref});
        defer self.allocator.free(arg);
        return self.exec(&.{ "branch", arg });
    }

    /// Open `url` in the user's default browser via the platform opener. Returns
    /// quickly; the browser launch is fire-and-forget.
    pub fn openUrl(self: *Git, url: []const u8) !void {
        // Termux (Android) has no `xdg-open`; it ships `termux-open-url`, which
        // hands the URL to the system browser. Detected at runtime from the
        // `TERMUX_VERSION` env var Termux sets, so the same Linux binary opens
        // links on a desktop and on a phone.
        const opener = if (builtin.os.tag == .macos)
            "open"
        else if (self.environ.get("TERMUX_VERSION") != null)
            "termux-open-url"
        else
            "xdg-open";
        const result = try std.process.run(self.allocator, self.io, .{
            .argv = &.{ opener, url },
            .cwd = .{ .path = self.root },
            .environ_map = self.environ,
            .stdout_limit = .limited(4096),
            .stderr_limit = .limited(4096),
        });
        self.allocator.free(result.stdout);
        self.allocator.free(result.stderr);
    }

    /// Fast-forward the checked-out branch to its upstream.
    pub fn fastForwardCurrent(self: *Git) !ExecResult {
        return self.exec(&.{ "pull", "--ff-only" });
    }

    /// Fast-forward a non-checked-out local branch to its remote tracking ref
    /// without checking it out, via a fetch refspec `<remote_ref>:<local>`.
    pub fn fastForwardBranch(self: *Git, remote: []const u8, remote_ref: []const u8, local: []const u8) !ExecResult {
        const refspec = try std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ remote_ref, local });
        defer self.allocator.free(refspec);
        return self.exec(&.{ "fetch", remote, refspec });
    }

    pub fn mergeBranch(self: *Git, name: []const u8) !ExecResult {
        return self.exec(&.{ "merge", "--no-edit", name });
    }

    pub fn rebaseOnto(self: *Git, name: []const u8) !ExecResult {
        return self.exec(&.{ "rebase", name });
    }

    /// Replay commits from `marked_base` (inclusive) through HEAD onto `newbase`,
    /// dropping any commits before the marked base. Runs `git rebase --onto <newbase> <marked_base>^`.
    pub fn rebaseOntoMarked(self: *Git, newbase: []const u8, marked_base: []const u8) !ExecResult {
        var buf: [80]u8 = undefined;
        const upstream = try std.fmt.bufPrint(&buf, "{s}^", .{marked_base});
        return self.exec(&.{ "rebase", "--onto", newbase, upstream });
    }

    pub const ResetMode = enum {
        soft,
        mixed,
        hard,

        fn flag(self: ResetMode) []const u8 {
            return switch (self) {
                .soft => "--soft",
                .mixed => "--mixed",
                .hard => "--hard",
            };
        }
    };

    pub fn resetTo(self: *Git, hash: []const u8, mode: ResetMode) !ExecResult {
        return self.exec(&.{ "reset", mode.flag(), hash });
    }

    pub fn revertCommit(self: *Git, hash: []const u8) !ExecResult {
        return self.exec(&.{ "revert", "--no-edit", hash });
    }

    /// Read a working-tree file (relative to the repo root) into an owned slice.
    pub fn readWorkingFile(self: *Git, path: []const u8) ![]u8 {
        const full = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.root, path });
        defer self.allocator.free(full);
        return std.Io.Dir.readFileAlloc(.cwd(), self.io, full, self.allocator, .limited(64 * 1024 * 1024));
    }

    /// Overwrite a working-tree file (relative to the repo root) with `data`.
    pub fn writeWorkingFile(self: *Git, path: []const u8, data: []const u8) !void {
        const full = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.root, path });
        defer self.allocator.free(full);
        try std.Io.Dir.writeFile(.cwd(), self.io, .{ .sub_path = full, .data = data });
    }

    /// Verify a commit's GPG signature. gpg writes its human-readable result
    /// (Good/Bad signature, key, signer) to stderr; exit is non-zero for a bad
    /// or missing signature. The caller shows the output in a dialog.
    pub fn verifyCommit(self: *Git, hash: []const u8) !ExecResult {
        return self.exec(&.{ "verify-commit", "-v", hash });
    }

    /// Revert several commits in one `git revert --no-edit <hashes...>`. The
    /// hashes are applied in the order given (newest-first).
    pub fn revertCommits(self: *Git, hashes: []const []const u8) !ExecResult {
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "revert", "--no-edit" });
        try args.appendSlice(self.allocator, hashes);
        return self.exec(args.items);
    }

    /// Amend the last commit with the currently-staged changes, keeping its
    /// message.
    pub fn amendCommit(self: *Git) !ExecResult {
        return self.exec(&.{ "commit", "--amend", "--no-edit" });
    }

    /// Commit the staged changes as a `fixup! <subject of hash>` commit.
    pub fn createFixup(self: *Git, hash: []const u8) !ExecResult {
        const arg = try std.fmt.allocPrint(self.allocator, "--fixup={s}", .{hash});
        defer self.allocator.free(arg);
        return self.exec(&.{ "commit", arg });
    }

    /// Autosquash fixup!/squash! commits in the range above `base`, accepting
    /// git's reordered todo (both editors are no-ops via env vars).
    pub fn autosquashRebase(self: *Git, base: []const u8) !ExecResult {
        var env = try self.environ.clone(self.allocator);
        defer env.deinit();
        try env.put("GIT_SEQUENCE_EDITOR", "true");
        try env.put("GIT_EDITOR", "true");
        const argv = [_][]const u8{ "git", "rebase", "-i", "--autosquash", "--autostash", base };
        self.recordCommand(argv[1..]);
        const result = try runWithLockRetry(self.allocator, self.io, .{
            .argv = &argv,
            .cwd = .{ .path = self.root },
            .environ_map = &env,
            .stdout_limit = .limited(16 * 1024 * 1024),
            .stderr_limit = .limited(4 * 1024 * 1024),
        });
        return .{ .stdout = result.stdout, .stderr = result.stderr, .term = result.term };
    }

    /// Cherry-pick several commits in one go (apply in the given order).
    pub fn cherryPickMany(self: *Git, hashes: []const []const u8) !ExecResult {
        if (hashes.len == 0) return self.successResult();
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.append(self.allocator, "cherry-pick");
        try args.appendSlice(self.allocator, hashes);
        return self.exec(args.items);
    }

    pub fn cherryPickContinue(self: *Git) !ExecResult {
        return self.exec(&.{ "-c", "core.editor=true", "cherry-pick", "--continue" });
    }

    pub fn cherryPickAbort(self: *Git) !ExecResult {
        return self.exec(&.{ "cherry-pick", "--abort" });
    }

    /// The subject of the most recent reflog entry (describes the last op).
    pub fn reflogSubject(self: *Git) ![]u8 {
        var result = try self.exec(&.{ "reflog", "-1", "--format=%gs" });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(u8, 0);
        return self.allocator.dupe(u8, std.mem.trim(u8, result.stdout, " \t\r\n"));
    }

    /// Undo the last history operation by resetting HEAD to its prior position.
    ///
    /// A plain commit or an amend folds your staged changes into the commit, so a
    /// `--hard` reset would throw those changes away. Detect that from the reflog
    /// subject and undo with `--soft` instead, which moves HEAD back but keeps the
    /// index and working tree — the changes reappear staged rather than vanishing.
    /// Every other operation (merge, rebase, reset, cherry-pick, revert, …) has no
    /// such uncommitted work to preserve and restores its prior state with `--hard`.
    pub fn undoLastOperation(self: *Git) !ExecResult {
        const subject = self.reflogSubject() catch null;
        defer if (subject) |s| self.allocator.free(s);
        const mode: []const u8 = if (subject != null and undoKeepsChanges(subject.?)) "--soft" else "--hard";
        return self.exec(&.{ "reset", mode, "HEAD@{1}" });
    }

    /// True when undoing the operation described by reflog subject `s` must keep
    /// the working-tree changes it folded in (commit / amend / initial commit),
    /// i.e. reset `--soft`. False for operations that only moved history
    /// (`merge …`, `commit (merge)`, `commit (cherry-pick)`, `revert:`, `reset:`,
    /// rebase, …), which reset `--hard`.
    pub fn undoKeepsChanges(s: []const u8) bool {
        return std.mem.startsWith(u8, s, "commit: ") or
            std.mem.startsWith(u8, s, "commit (amend)") or
            std.mem.startsWith(u8, s, "commit (initial)");
    }

    /// The body (message minus subject line) of a commit, trimmed. Empty if the
    /// commit has only a subject. Caller owns the returned slice.
    pub fn commitBody(self: *Git, hash: []const u8) ![]u8 {
        var result = try self.exec(&.{ "log", "-1", "--format=%b", hash });
        defer result.deinit(self.allocator);
        if (!result.ok()) return self.allocator.alloc(u8, 0);
        return self.allocator.dupe(u8, std.mem.trim(u8, result.stdout, " \t\r\n"));
    }

    pub fn fetch(self: *Git) !ExecResult {
        return self.exec(&.{ "fetch", "--all", "--no-write-fetch-head" });
    }

    pub fn pull(self: *Git) !ExecResult {
        return self.exec(&.{ "pull", "--no-edit" });
    }

    pub fn push(self: *Git) !ExecResult {
        return self.exec(&.{"push"});
    }

    /// The stash's changes as a plain (uncoloured) patch, including untracked
    /// files, suitable for writing to a `.patch` file and `git apply`ing.
    pub fn stashShowPatch(self: *Git, selector: []const u8) !ExecResult {
        return self.exec(&.{ "stash", "show", "-p", "-u", "--no-ext-diff", selector });
    }

    pub fn stashApply(self: *Git, index: usize) !ExecResult {
        const selector = try std.fmt.allocPrint(self.allocator, "stash@{{{d}}}", .{index});
        defer self.allocator.free(selector);
        return self.exec(&.{ "stash", "apply", selector });
    }

    pub fn stashPop(self: *Git, index: usize) !ExecResult {
        const selector = try std.fmt.allocPrint(self.allocator, "stash@{{{d}}}", .{index});
        defer self.allocator.free(selector);
        return self.exec(&.{ "stash", "pop", selector });
    }

    pub fn stashDrop(self: *Git, index: usize) !ExecResult {
        const selector = try std.fmt.allocPrint(self.allocator, "stash@{{{d}}}", .{index});
        defer self.allocator.free(selector);
        return self.exec(&.{ "stash", "drop", selector });
    }

    /// Drop several stash entries. Entries are dropped highest-index-first so the
    /// lower indices stay valid as the list shifts.
    pub fn stashDropMany(self: *Git, indices: []const usize) !ExecResult {
        const sorted = try self.allocator.dupe(usize, indices);
        defer self.allocator.free(sorted);
        std.mem.sort(usize, sorted, {}, std.sort.desc(usize));
        for (sorted) |idx| {
            var r = try self.stashDrop(idx);
            if (!r.ok()) return r;
            r.deinit(self.allocator);
        }
        return self.successResult();
    }

    /// Rename a stash: drop the entry and re-store its commit `hash` under the new
    /// `message` (git has no in-place rename). The renamed stash returns to the
    /// top of the list.
    pub fn renameStash(self: *Git, index: usize, hash: []const u8, message: []const u8) !ExecResult {
        const selector = try std.fmt.allocPrint(self.allocator, "stash@{{{d}}}", .{index});
        defer self.allocator.free(selector);
        var r = try self.exec(&.{ "stash", "drop", selector });
        if (!r.ok()) return r;
        r.deinit(self.allocator);
        return self.exec(&.{ "stash", "store", "-m", message, hash });
    }

    // The stash-create commands take an optional message. A null (or empty)
    // message is passed as no `-m` at all, so git assigns its default
    // `WIP on <branch>: <hash> <subject>` name — matching `git stash push -m ""`.
    pub fn stashAll(self: *Git, message: ?[]const u8) !ExecResult {
        if (message) |m| return self.exec(&.{ "stash", "push", "-m", m });
        return self.exec(&.{ "stash", "push" });
    }

    /// Stash tracked changes plus untracked files (`-u`).
    pub fn stashIncludingUntracked(self: *Git, message: ?[]const u8) !ExecResult {
        if (message) |m| return self.exec(&.{ "stash", "push", "--include-untracked", "-m", m });
        return self.exec(&.{ "stash", "push", "--include-untracked" });
    }

    /// Stash only what is staged (`--staged`), leaving the working tree.
    pub fn stashStaged(self: *Git, message: ?[]const u8) !ExecResult {
        if (message) |m| return self.exec(&.{ "stash", "push", "--staged", "-m", m });
        return self.exec(&.{ "stash", "push", "--staged" });
    }

    pub fn stashFile(self: *Git, path: []const u8, message: ?[]const u8) !ExecResult {
        // `--include-untracked` so a selected *untracked* file can be stashed too:
        // a bare `stash push -- <path>` errors ("pathspec did not match any
        // file(s) known to git") on a path git doesn't yet track. Prefix the
        // pathspec with `:(literal)` so Git doesn't treat characters like `*`,
        // `?`, `[`, or a leading `:(...)` as pathspec syntax for a selected file.
        // `-- <pathspec>` must come last.
        const literal = try literalPathspec(self.allocator, path);
        defer self.allocator.free(literal);

        // A file staged for deletion is gone from the index, so a pathspec-limited
        // `git stash push` can't match it: it fails with "did not match any files"
        // and, worse, `--include-untracked` records a spurious full stash first.
        // Unstage the deletion so it becomes an ordinary unstaged deletion, which
        // stashes cleanly — popping the entry restores the file the same way
        // regardless of which side the deletion sat on.
        if (try self.pathStagedForDeletion(literal)) {
            var unstaged = try self.exec(&.{ "restore", "--staged", "--", literal });
            if (!unstaged.ok()) return unstaged; // caller owns and reports it
            unstaged.deinit(self.allocator);
        }

        if (message) |m| return self.exec(&.{ "stash", "push", "--include-untracked", "-m", m, "--", literal });
        return self.exec(&.{ "stash", "push", "--include-untracked", "--", literal });
    }

    /// True when `pathspec` (a single-file `:(literal)…` spec) is staged for
    /// deletion — a `D` in the index (first) column of porcelain status. Such a
    /// file no longer exists in the index, so a pathspec-limited stash can't
    /// match it. Any error answers false and lets the stash surface the real
    /// problem. An unmerged `DD` is excluded (that is a conflict, not a plain
    /// staged deletion).
    fn pathStagedForDeletion(self: *Git, pathspec: []const u8) !bool {
        var res = try self.exec(&.{ "status", "--porcelain", "--", pathspec });
        defer res.deinit(self.allocator);
        if (!res.ok()) return false;
        const s = res.stdout;
        return s.len >= 2 and s[0] == 'D' and s[1] != 'D';
    }

    /// Stash the working state — tracked, staged, AND untracked — into a new
    /// entry while leaving the working tree and index exactly as they were.
    /// `git stash create` can't capture untracked files, so this pushes
    /// everything and immediately re-applies it: `push --include-untracked`
    /// records the full state, `apply --index` restores it (preserving the
    /// staged/unstaged split; a plain apply is the fallback). Returns null for a
    /// clean tree. The removal is transient — the tree ends unchanged — but the
    /// two steps aren't atomic.
    pub fn stashKeeping(self: *Git, message: ?[]const u8) !?ExecResult {
        const before = try self.stashTop();
        defer self.allocator.free(before);

        var pushed = if (message) |m|
            try self.exec(&.{ "stash", "push", "--include-untracked", "-m", m })
        else
            try self.exec(&.{ "stash", "push", "--include-untracked" });
        if (!pushed.ok()) return pushed; // caller owns and reports the error

        // A clean tree pushes nothing, so the stash ref is unchanged.
        const after = try self.stashTop();
        defer self.allocator.free(after);
        if (std.mem.eql(u8, before, after)) {
            pushed.deinit(self.allocator);
            return null;
        }
        pushed.deinit(self.allocator);

        // Restore the working tree + index from the stash we just made (keeping
        // it). Apply the exact hash we just created rather than implicit
        // `refs/stash`, so another stash created between the push/apply steps
        // cannot make us restore the wrong entry. `--index` preserves the
        // staged/unstaged split; if it can't, a plain apply still restores the
        // files.
        var applied = try self.exec(&.{ "stash", "apply", "--index", after });
        if (applied.ok()) return applied;
        applied.deinit(self.allocator);
        return try self.exec(&.{ "stash", "apply", after });
    }

    /// The top stash commit hash (`refs/stash`), or "" when there is no stash.
    fn stashTop(self: *Git) ![]u8 {
        var r = try self.exec(&.{ "rev-parse", "--quiet", "--verify", "refs/stash" });
        defer r.deinit(self.allocator);
        return self.allocator.dupe(u8, std.mem.trim(u8, r.stdout, " \t\r\n"));
    }

    fn resetFilePaths(self: *Git, file: model.FileStatus) !?ExecResult {
        var args: std.ArrayList([]const u8) = .empty;
        defer args.deinit(self.allocator);
        try args.appendSlice(self.allocator, &.{ "reset", "HEAD", "--", file.path });
        if (file.previous_path) |previous| try args.append(self.allocator, previous);
        return self.runStep(args.items);
    }

    fn runStep(self: *Git, args: []const []const u8) !?ExecResult {
        var result = try self.exec(args);
        if (!result.ok()) return result;
        result.deinit(self.allocator);
        return null;
    }

    pub fn discardFiles(self: *Git, files: []const model.FileStatus) !ExecResult {
        for (files) |file| {
            var r = try self.discardFile(file);
            if (!r.ok()) return r;
            r.deinit(self.allocator);
        }
        return self.successResult();
    }

    pub fn discardUnstagedFiles(self: *Git, files: []const model.FileStatus) !ExecResult {
        for (files) |file| {
            var r = try self.discardUnstaged(file);
            if (!r.ok()) return r;
            r.deinit(self.allocator);
        }
        return self.successResult();
    }

    pub fn successResult(self: *Git) !ExecResult {
        return .{
            .stdout = try self.allocator.alloc(u8, 0),
            .stderr = try self.allocator.alloc(u8, 0),
            .term = .{ .exited = 0 },
        };
    }
};

pub fn parseStatus(allocator: std.mem.Allocator, bytes: []const u8) ![]model.FileStatus {
    var files: std.ArrayList(model.FileStatus) = .empty;
    errdefer {
        for (files.items) |*file| file.deinit(allocator);
        files.deinit(allocator);
    }

    var parts = std.mem.splitScalar(u8, bytes, 0);
    while (parts.next()) |entry| {
        if (entry.len < 3) continue;
        const short_status: [2]u8 = .{ entry[0], entry[1] };
        const path = entry[3..];
        const fields = model.deriveStatusFields(short_status);
        var file = model.FileStatus{
            .path = try allocator.dupe(u8, path),
            .short_status = short_status,
            .has_staged = fields.has_staged,
            .has_unstaged = fields.has_unstaged,
            .tracked = fields.tracked,
            .added = fields.added,
            .deleted = fields.deleted,
            .conflict = fields.conflict,
        };
        errdefer file.deinit(allocator);

        if (short_status[0] == 'R' or short_status[0] == 'C') {
            if (parts.next()) |previous| {
                file.previous_path = try allocator.dupe(u8, previous);
            }
        }

        try files.append(allocator, file);
    }

    const result = try files.toOwnedSlice(allocator);
    // Order by path, not by git's porcelain grouping (which lists untracked
    // files in a separate block, so staging one would promote it into the
    // tracked block and jump the cursor). Path order is the stable default and
    // the safe base regardless of the configured `file_sort_order`: the UI
    // re-groups to `status` on top of this when asked.
    model.sortFiles(result, .name);
    return result;
}

pub fn parseBranches(allocator: std.mem.Allocator, bytes: []const u8) ![]model.Branch {
    var branches: std.ArrayList(model.Branch) = .empty;
    errdefer {
        for (branches.items) |*branch| branch.deinit(allocator);
        branches.deinit(allocator);
    }

    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var fields = std.mem.splitScalar(u8, line, 0);
        const head = fields.next() orelse continue;
        const name = fields.next() orelse continue;
        const upstream = fields.next() orelse "";
        // `%(upstream:track)` is "[gone]" when the tracked remote branch was
        // deleted, or "[ahead N, behind M]" / "[ahead N]" / "[behind M]" / ""
        // otherwise.
        const track = fields.next() orelse "";
        const date = std.mem.trim(u8, fields.next() orelse "", " \t\r\n");
        var branch = model.Branch{
            .name = try allocator.dupe(u8, name),
            .current = head.len > 0 and head[0] == '*',
            .upstream_gone = std.mem.eql(u8, track, "[gone]"),
            .ahead = trackCount(track, "ahead "),
            .behind = trackCount(track, "behind "),
            .commit_time = std.fmt.parseInt(i64, date, 10) catch 0,
        };
        errdefer branch.deinit(allocator);
        if (upstream.len > 0) branch.upstream = try allocator.dupe(u8, upstream);
        try branches.append(allocator, branch);
    }

    return branches.toOwnedSlice(allocator);
}

test "parseBranches reads ahead/behind from upstream:track" {
    const bytes = " \x00feature\x00origin/feature\x00[ahead 2]\x001700000000\n" ++
        " \x00topic\x00origin/topic\x00[ahead 3, behind 1]\x001699999999\n" ++
        "*\x00main\x00origin/main\x00\x001700000123\n";
    const branches = try parseBranches(std.testing.allocator, bytes);
    defer {
        for (branches) |*b| b.deinit(std.testing.allocator);
        std.testing.allocator.free(branches);
    }
    try std.testing.expectEqual(@as(usize, 3), branches.len);
    try std.testing.expectEqual(@as(usize, 2), branches[0].ahead);
    try std.testing.expectEqual(@as(usize, 0), branches[0].behind);
    try std.testing.expectEqual(@as(i64, 1700000000), branches[0].commit_time);
    try std.testing.expectEqual(@as(usize, 3), branches[1].ahead);
    try std.testing.expectEqual(@as(usize, 1), branches[1].behind);
    try std.testing.expectEqual(@as(usize, 0), branches[2].ahead);
    try std.testing.expect(branches[2].current);
    try std.testing.expectEqual(@as(i64, 1700000123), branches[2].commit_time);
}

/// The number following `label` ("ahead "/"behind ") in a `%(upstream:track)`
/// string like "[ahead 2, behind 1]", or 0 if absent.
fn trackCount(track: []const u8, label: []const u8) usize {
    const at = std.mem.indexOf(u8, track, label) orelse return 0;
    var i = at + label.len;
    var n: usize = 0;
    while (i < track.len and track[i] >= '0' and track[i] <= '9') : (i += 1) {
        n = n * 10 + (track[i] - '0');
    }
    return n;
}

/// Parse `git remote -v` into one `Remote` per name, keeping the fetch URL.
pub fn parseRemotes(allocator: std.mem.Allocator, bytes: []const u8) ![]model.Remote {
    var remotes: std.ArrayList(model.Remote) = .empty;
    errdefer {
        for (remotes.items) |*r| r.deinit(allocator);
        remotes.deinit(allocator);
    }
    var lines = std.mem.tokenizeScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        var toks = std.mem.tokenizeAny(u8, line, " \t");
        const name = toks.next() orelse continue;
        const url = toks.next() orelse "";
        const kind = toks.next() orelse "";
        if (!std.mem.eql(u8, kind, "(fetch)")) continue; // one row per remote
        var seen = false;
        for (remotes.items) |r| {
            if (std.mem.eql(u8, r.name, name)) {
                seen = true;
                break;
            }
        }
        if (seen) continue;
        const n = try allocator.dupe(u8, name);
        errdefer allocator.free(n);
        const u = try allocator.dupe(u8, url);
        try remotes.append(allocator, .{ .name = n, .url = u });
    }
    return remotes.toOwnedSlice(allocator);
}

test "parseRemotes keeps one fetch URL per remote" {
    const bytes =
        "origin\thttps://example.com/x.git (fetch)\n" ++
        "origin\thttps://example.com/x.git (push)\n" ++
        "upstream\tgit@github.com:y/z.git (fetch)\n" ++
        "upstream\tgit@github.com:y/z.git (push)\n";
    const remotes = try parseRemotes(std.testing.allocator, bytes);
    defer {
        for (remotes) |*r| r.deinit(std.testing.allocator);
        std.testing.allocator.free(remotes);
    }
    try std.testing.expectEqual(@as(usize, 2), remotes.len);
    try std.testing.expectEqualStrings("origin", remotes[0].name);
    try std.testing.expectEqualStrings("https://example.com/x.git", remotes[0].url);
    try std.testing.expectEqualStrings("upstream", remotes[1].name);
    try std.testing.expectEqualStrings("git@github.com:y/z.git", remotes[1].url);
}

/// Move the current (checked-out) branch to index 0, preserving the relative
/// order of the rest — the current branch is always listed first. A no-op
/// for remote-branch lists (none is marked current).
pub fn moveCurrentBranchToFront(branches: []model.Branch) void {
    for (branches, 0..) |branch, i| {
        if (!branch.current) continue;
        if (i == 0) return;
        const cur = branches[i];
        var j = i;
        while (j > 0) : (j -= 1) branches[j] = branches[j - 1];
        branches[0] = cur;
        return;
    }
}

/// Parse the ordered, de-duplicated branch names from reflog `%gs` subjects.
/// Each `checkout: moving from X to Y` contributes X then Y; the first time a
/// name is seen fixes its position, so the result is in most-recent-first order.
pub fn parseBranchRecency(allocator: std.mem.Allocator, bytes: []const u8) ![][]u8 {
    const prefix = "checkout: moving from ";
    var names: std.ArrayList([]u8) = .empty;
    errdefer {
        for (names.items) |n| allocator.free(n);
        names.deinit(allocator);
    }

    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (!std.mem.startsWith(u8, line, prefix)) continue;
        const rest = line[prefix.len..];
        const to_at = std.mem.indexOf(u8, rest, " to ") orelse continue;
        const from = rest[0..to_at];
        const to = rest[to_at + " to ".len ..];
        for ([_][]const u8{ from, to }) |name| {
            if (name.len == 0) continue;
            var seen = false;
            for (names.items) |existing| {
                if (std.mem.eql(u8, existing, name)) {
                    seen = true;
                    break;
                }
            }
            if (seen) continue;
            try names.append(allocator, try allocator.dupe(u8, name));
        }
    }
    return names.toOwnedSlice(allocator);
}

/// Reorder `branches` in place for the `recency` sort: branches present in
/// `recency` (in that order, skipping the current branch) come first, then the
/// rest sorted alphabetically. The current branch is positioned separately.
fn applyRecencyOrder(branches: []model.Branch, recency: []const []const u8) void {
    var front: usize = 0; // next slot for a recency-ordered branch
    for (recency) |name| {
        var k = front;
        while (k < branches.len) : (k += 1) {
            if (branches[k].current) continue;
            if (!std.ascii.eqlIgnoreCase(branches[k].name, name)) continue;
            const hit = branches[k];
            var j = k;
            while (j > front) : (j -= 1) branches[j] = branches[j - 1];
            branches[front] = hit;
            front += 1;
            break;
        }
    }
    // Remaining branches (not in the reflog) keep deterministic alphabetical order.
    std.mem.sort(model.Branch, branches[front..], {}, struct {
        fn lt(_: void, a: model.Branch, b: model.Branch) bool {
            return std.mem.lessThan(u8, a.name, b.name);
        }
    }.lt);
}

pub fn parseTags(allocator: std.mem.Allocator, bytes: []const u8) ![]model.Tag {
    var tags: std.ArrayList(model.Tag) = .empty;
    errdefer {
        for (tags.items) |*tag| tag.deinit(allocator);
        tags.deinit(allocator);
    }

    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var fields = std.mem.splitScalar(u8, line, 0);
        const name = fields.next() orelse continue;
        if (name.len == 0) continue;
        const subject = fields.next() orelse "";
        var tag = model.Tag{
            .name = try allocator.dupe(u8, name),
            .subject = try allocator.dupe(u8, subject),
        };
        errdefer tag.deinit(allocator);
        try tags.append(allocator, tag);
    }

    return tags.toOwnedSlice(allocator);
}

pub fn parseWorktrees(allocator: std.mem.Allocator, bytes: []const u8, current_root: []const u8) ![]model.Worktree {
    var worktrees: std.ArrayList(model.Worktree) = .empty;
    errdefer {
        for (worktrees.items) |*wt| wt.deinit(allocator);
        worktrees.deinit(allocator);
    }

    // `--porcelain` emits one blank-line-separated block per worktree.
    var blocks = std.mem.splitSequence(u8, bytes, "\n\n");
    while (blocks.next()) |block| {
        var path: ?[]const u8 = null;
        var branch: []const u8 = "(detached)";
        var lines = std.mem.splitScalar(u8, block, '\n');
        while (lines.next()) |line| {
            if (std.mem.startsWith(u8, line, "worktree ")) {
                path = line["worktree ".len..];
            } else if (std.mem.eql(u8, line, "bare")) {
                branch = "(bare)";
            } else if (std.mem.eql(u8, line, "detached")) {
                branch = "(detached)";
            } else if (std.mem.startsWith(u8, line, "branch ")) {
                const ref = line["branch ".len..];
                branch = if (std.mem.startsWith(u8, ref, "refs/heads/")) ref["refs/heads/".len..] else ref;
            }
        }
        const wt_path = path orelse continue;
        if (wt_path.len == 0) continue;
        var wt = model.Worktree{
            .path = try allocator.dupe(u8, wt_path),
            .branch = try allocator.dupe(u8, branch),
            .is_current = std.mem.eql(u8, wt_path, current_root),
        };
        errdefer wt.deinit(allocator);
        try worktrees.append(allocator, wt);
    }

    return worktrees.toOwnedSlice(allocator);
}

pub fn parseSubmodules(allocator: std.mem.Allocator, bytes: []const u8) ![]model.Submodule {
    var submodules: std.ArrayList(model.Submodule) = .empty;
    errdefer {
        for (submodules.items) |*sm| sm.deinit(allocator);
        submodules.deinit(allocator);
    }

    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (line.len < 2) continue;
        // Format: "<status><sha> <path> [(describe)]"; the status char (which
        // may be a space) is attached to the SHA, so read it before tokenizing.
        const status = line[0];
        var fields = std.mem.tokenizeScalar(u8, line[1..], ' ');
        const sha = fields.next() orelse continue;
        const path = fields.next() orelse continue;
        var sm = model.Submodule{
            .path = try allocator.dupe(u8, path),
            .sha = try allocator.dupe(u8, sha),
            .status = status,
        };
        errdefer sm.deinit(allocator);
        try submodules.append(allocator, sm);
    }

    return submodules.toOwnedSlice(allocator);
}

pub fn parseCommits(allocator: std.mem.Allocator, bytes: []const u8) ![]model.Commit {
    var commits: std.ArrayList(model.Commit) = .empty;
    errdefer {
        for (commits.items) |*item| item.deinit(allocator);
        commits.deinit(allocator);
    }

    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var fields = std.mem.splitScalar(u8, line, 0);
        const hash = fields.next() orelse continue;
        const short_hash = fields.next() orelse continue;
        const author = fields.next() orelse "";
        const time = fields.next() orelse "";
        const refs = fields.next() orelse "";
        const parents_raw = fields.next() orelse "";
        const subject = fields.next() orelse "";

        // %P is a space-separated list of full parent hashes (empty for a root).
        var parents: std.ArrayList([]u8) = .empty;
        errdefer {
            for (parents.items) |p| allocator.free(p);
            parents.deinit(allocator);
        }
        var pit = std.mem.tokenizeScalar(u8, parents_raw, ' ');
        while (pit.next()) |p| try parents.append(allocator, try allocator.dupe(u8, p));

        var item = model.Commit{
            .hash = try allocator.dupe(u8, hash),
            .short_hash = try allocator.dupe(u8, short_hash),
            .author = try allocator.dupe(u8, author),
            .time = try allocator.dupe(u8, time),
            .refs = try allocator.dupe(u8, refs),
            .subject = try allocator.dupe(u8, subject),
            .parents = try parents.toOwnedSlice(allocator),
        };
        errdefer item.deinit(allocator);
        try commits.append(allocator, item);
    }

    return commits.toOwnedSlice(allocator);
}

pub fn parseStash(allocator: std.mem.Allocator, bytes: []const u8) ![]model.StashEntry {
    var entries: std.ArrayList(model.StashEntry) = .empty;
    errdefer {
        for (entries.items) |*entry| entry.deinit(allocator);
        entries.deinit(allocator);
    }

    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var fields = std.mem.splitScalar(u8, line, 0);
        const selector = fields.next() orelse continue;
        const hash = fields.next() orelse "";
        const time = fields.next() orelse "";
        const message = fields.next() orelse "";
        var entry = model.StashEntry{
            .index = parseStashIndex(selector) orelse entries.items.len,
            .selector = try allocator.dupe(u8, selector),
            .hash = try allocator.dupe(u8, hash),
            .time = try allocator.dupe(u8, time),
            .message = try allocator.dupe(u8, message),
        };
        errdefer entry.deinit(allocator);
        try entries.append(allocator, entry);
    }

    return entries.toOwnedSlice(allocator);
}

pub fn parseCommitFiles(allocator: std.mem.Allocator, bytes: []const u8) ![]model.CommitFile {
    var files: std.ArrayList(model.CommitFile) = .empty;
    errdefer {
        for (files.items) |*file| file.deinit(allocator);
        files.deinit(allocator);
    }

    // -z output is a flat NUL-separated stream: STATUS, PATH, STATUS, PATH...
    // Renames/copies (R/C) carry two paths (source then destination).
    var parts = std.mem.splitScalar(u8, bytes, 0);
    while (parts.next()) |status_token| {
        if (status_token.len == 0) continue;
        const status = status_token[0];
        const path = if (status == 'R' or status == 'C') blk: {
            _ = parts.next() orelse break; // source path, unused
            break :blk parts.next() orelse break; // destination path
        } else parts.next() orelse break;
        if (path.len == 0) continue;
        var file = model.CommitFile{
            .status = status,
            .path = try allocator.dupe(u8, path),
        };
        errdefer file.deinit(allocator);
        try files.append(allocator, file);
    }

    return files.toOwnedSlice(allocator);
}

fn parseStashIndex(selector: []const u8) ?usize {
    const start = std.mem.indexOfScalar(u8, selector, '{') orelse return null;
    const end = std.mem.indexOfScalarPos(u8, selector, start, '}') orelse return null;
    return std.fmt.parseInt(usize, selector[start + 1 .. end], 10) catch null;
}

test "log filters set, report and clear" {
    const allocator = std.testing.allocator;
    var git = Git{ .allocator = allocator, .io = undefined, .environ = undefined, .root = undefined };

    try std.testing.expect(!git.hasLogFilter());

    try git.setLogFilter(.author, "alice");
    try git.setLogFilter(.path, "src/app.zig");
    try std.testing.expect(git.hasLogFilter());
    try std.testing.expectEqualStrings("alice", git.log_author.?);
    try std.testing.expectEqualStrings("src/app.zig", git.log_path.?);

    // An empty value clears just that filter.
    try git.setLogFilter(.author, "");
    try std.testing.expect(git.log_author == null);
    try std.testing.expect(git.hasLogFilter());

    git.clearLogFilters();
    try std.testing.expect(!git.hasLogFilter());
}

test "UntrackedFiles maps to git's status flag" {
    try std.testing.expectEqualStrings("--untracked-files=all", Git.UntrackedFiles.all.flag());
    try std.testing.expectEqualStrings("--untracked-files=normal", Git.UntrackedFiles.normal.flag());
    try std.testing.expectEqualStrings("--untracked-files=no", Git.UntrackedFiles.no.flag());
}

test "parse porcelain status including rename" {
    const input = " M src/main.zig\x00R  new.txt\x00old.txt\x00?? scratch.txt\x00";
    const files = try parseStatus(std.testing.allocator, input);
    defer {
        for (files) |*file| file.deinit(std.testing.allocator);
        std.testing.allocator.free(files);
    }

    // The list is sorted by path (new.txt < scratch.txt < src/main.zig),
    // regardless of git's porcelain grouping.
    try std.testing.expectEqual(@as(usize, 3), files.len);
    try std.testing.expectEqualSlices(u8, "new.txt", files[0].path);
    try std.testing.expect(files[0].isRename());
    try std.testing.expectEqualSlices(u8, "old.txt", files[0].previous_path.?);
    try std.testing.expectEqualSlices(u8, "scratch.txt", files[1].path);
    try std.testing.expect(!files[1].tracked);
    try std.testing.expectEqualSlices(u8, "src/main.zig", files[2].path);
    try std.testing.expect(files[2].has_unstaged);
}

test "parse porcelain status handles merge/rebase conflict codes" {
    // The codes `git status` emits during a merge/rebase/cherry-pick conflict.
    const input = "UU both.txt\x00AA addadd.txt\x00DD deldel.txt\x00UA us-added.txt\x00 M plain.txt\x00";
    const files = try parseStatus(std.testing.allocator, input);
    defer {
        for (files) |*file| file.deinit(std.testing.allocator);
        std.testing.allocator.free(files);
    }
    // Sorted by path: addadd, both, deldel, plain, us-added.
    try std.testing.expectEqual(@as(usize, 5), files.len);
    try std.testing.expectEqualSlices(u8, "addadd.txt", files[0].path);
    try std.testing.expect(files[0].conflict); // AA
    try std.testing.expectEqualSlices(u8, "both.txt", files[1].path);
    try std.testing.expect(files[1].conflict and files[1].has_unstaged); // UU
    try std.testing.expect(files[2].conflict); // DD deldel.txt
    try std.testing.expect(!files[3].conflict); // plain.txt, a plain modification
    try std.testing.expect(files[4].conflict); // UA us-added.txt
}

test "parse branches handles rebase/detached pseudo-entry as current" {
    // `branch --format` lists a pseudo-entry for the detached HEAD during a
    // rebase or detached checkout; it must parse without panicking and be the
    // current branch.
    const input = "*\x00(no branch, rebasing topic)\x00\x00\n" ++
        " \x00main\x00\x00\n" ++
        " \x00topic\x00\x00\n";
    const branches = try parseBranches(std.testing.allocator, input);
    defer {
        for (branches) |*b| b.deinit(std.testing.allocator);
        std.testing.allocator.free(branches);
    }
    try std.testing.expectEqual(@as(usize, 3), branches.len);
    try std.testing.expect(branches[0].current);
    try std.testing.expectEqualSlices(u8, "(no branch, rebasing topic)", branches[0].name);
    try std.testing.expect(!branches[1].current);
    try std.testing.expectEqualSlices(u8, "main", branches[1].name);
}

test "parse commits handles empty (unborn branch) output" {
    const commits = try parseCommits(std.testing.allocator, "");
    defer std.testing.allocator.free(commits);
    try std.testing.expectEqual(@as(usize, 0), commits.len);
}

test "parseDivergence maps left-right marks to ahead/behind" {
    const allocator = std.testing.allocator;
    // Fields per line: mark, hash, short, author, reltime, subject.
    // `<` = ahead (local-only), `>` = behind (upstream-only).
    const input =
        "<\x00aaa111\x00aaa\x00me\x001 day ago\x00mine, ahead\n" ++
        ">\x00bbb222\x00bbb\x00them\x002 days ago\x00theirs, behind";
    const commits = try Git.parseDivergence(allocator, input);
    defer {
        for (commits) |*c| c.deinit(allocator);
        allocator.free(commits);
    }
    try std.testing.expectEqual(@as(usize, 2), commits.len);
    try std.testing.expectEqual(model.Divergence.ahead, commits[0].divergence);
    try std.testing.expectEqualStrings("aaa111", commits[0].hash);
    try std.testing.expectEqualStrings("mine, ahead", commits[0].subject);
    try std.testing.expectEqual(model.Divergence.behind, commits[1].divergence);
    try std.testing.expectEqualStrings("theirs, behind", commits[1].subject);
}

test "parse branches captures current marker, upstream, and gone upstream" {
    // Fields per line: HEAD, refname:short, upstream:short, upstream:track.
    const input =
        "*\x00main\x00origin/main\x00[ahead 1]\n" ++
        " \x00feature/demo\x00origin/feature/demo\x00\n" ++
        " \x00merged\x00origin/merged\x00[gone]\n" ++
        " \x00local-only\x00\x00\n";
    const branches = try parseBranches(std.testing.allocator, input);
    defer {
        for (branches) |*branch| branch.deinit(std.testing.allocator);
        std.testing.allocator.free(branches);
    }

    try std.testing.expectEqual(@as(usize, 4), branches.len);
    try std.testing.expect(branches[0].current);
    try std.testing.expectEqualSlices(u8, "main", branches[0].name);
    try std.testing.expectEqualSlices(u8, "origin/main", branches[0].upstream.?);
    try std.testing.expect(!branches[0].upstream_gone);
    try std.testing.expect(!branches[1].current);
    try std.testing.expectEqualSlices(u8, "feature/demo", branches[1].name);
    try std.testing.expect(!branches[1].upstream_gone);
    // The "[gone]" track marks the upstream as deleted.
    try std.testing.expectEqualSlices(u8, "merged", branches[2].name);
    try std.testing.expectEqualSlices(u8, "origin/merged", branches[2].upstream.?);
    try std.testing.expect(branches[2].upstream_gone);
    try std.testing.expect(branches[3].upstream == null);
    try std.testing.expect(!branches[3].upstream_gone);
}

test "moveCurrentBranchToFront lists the current branch first" {
    // git sorts by -committerdate; the current branch may be anywhere.
    var b = [_]model.Branch{
        .{ .name = @constCast(@as([]const u8, "feat-a")) },
        .{ .name = @constCast(@as([]const u8, "main")), .current = true },
        .{ .name = @constCast(@as([]const u8, "feat-b")) },
    };
    moveCurrentBranchToFront(&b);
    try std.testing.expectEqualStrings("main", b[0].name); // moved to top
    try std.testing.expectEqualStrings("feat-a", b[1].name); // others keep order
    try std.testing.expectEqualStrings("feat-b", b[2].name);

    // No current branch (remote list / detached HEAD): order is unchanged.
    var r = [_]model.Branch{
        .{ .name = @constCast(@as([]const u8, "origin/x")) },
        .{ .name = @constCast(@as([]const u8, "origin/y")) },
    };
    moveCurrentBranchToFront(&r);
    try std.testing.expectEqualStrings("origin/x", r[0].name);
    try std.testing.expectEqualStrings("origin/y", r[1].name);
}

test "parseBranchRecency extracts checkout order, de-duplicated" {
    const input =
        "checkout: moving from main to feature\n" ++
        "commit: some work\n" ++
        "checkout: moving from feature to main\n" ++
        "checkout: moving from main to old-thing\n";
    const names = try parseBranchRecency(std.testing.allocator, input);
    defer {
        for (names) |n| std.testing.allocator.free(n);
        std.testing.allocator.free(names);
    }
    try std.testing.expectEqual(@as(usize, 3), names.len);
    try std.testing.expectEqualStrings("main", names[0]);
    try std.testing.expectEqualStrings("feature", names[1]);
    try std.testing.expectEqualStrings("old-thing", names[2]);
}

test "applyRecencyOrder orders by reflog then alphabetical, skipping current" {
    // committerdate order from git; `main` is the current branch.
    var b = [_]model.Branch{
        .{ .name = @constCast(@as([]const u8, "feature")) },
        .{ .name = @constCast(@as([]const u8, "main")), .current = true },
        .{ .name = @constCast(@as([]const u8, "old")) },
        .{ .name = @constCast(@as([]const u8, "zeta")) },
    };
    const recency = [_][]const u8{ "main", "feature", "old" };
    applyRecencyOrder(&b, &recency);
    // `main` is current so it's skipped here; feature/old lead in reflog order,
    // then the rest (main, zeta) sorted alphabetically.
    try std.testing.expectEqualStrings("feature", b[0].name);
    try std.testing.expectEqualStrings("old", b[1].name);
    try std.testing.expectEqualStrings("main", b[2].name);
    try std.testing.expectEqualStrings("zeta", b[3].name);
}

test "command log filters read-only invocations" {
    // Read-only loads/refreshes are not logged.
    try std.testing.expect(Git.isReadOnly(&.{ "status", "--porcelain" }));
    try std.testing.expect(Git.isReadOnly(&.{ "diff", "--staged" }));
    try std.testing.expect(Git.isReadOnly(&.{ "branch", "--format=%x00" }));
    try std.testing.expect(Git.isReadOnly(&.{ "tag", "--sort=-creatordate" }));
    try std.testing.expect(Git.isReadOnly(&.{ "stash", "list" }));
    // Mutations are logged.
    try std.testing.expect(!Git.isReadOnly(&.{ "commit", "-m", "x" }));
    try std.testing.expect(!Git.isReadOnly(&.{ "branch", "-d", "--", "feat" }));
    try std.testing.expect(!Git.isReadOnly(&.{ "tag", "--", "v1" }));
    try std.testing.expect(!Git.isReadOnly(&.{ "stash", "pop" }));
    try std.testing.expect(!Git.isReadOnly(&.{"push"}));
}

test "parse tags captures name and optional subject" {
    const input =
        "v1.2.0\x00Release 1.2.0\n" ++
        "v1.1.0\x00\n" ++
        "v1.0.0\x00First stable\n";
    const tags = try parseTags(std.testing.allocator, input);
    defer {
        for (tags) |*tag| tag.deinit(std.testing.allocator);
        std.testing.allocator.free(tags);
    }

    try std.testing.expectEqual(@as(usize, 3), tags.len);
    try std.testing.expectEqualSlices(u8, "v1.2.0", tags[0].name);
    try std.testing.expectEqualSlices(u8, "Release 1.2.0", tags[0].subject);
    try std.testing.expectEqualSlices(u8, "v1.1.0", tags[1].name);
    try std.testing.expectEqual(@as(usize, 0), tags[1].subject.len);
}

test "parse commits handles optional refs and subjects" {
    // Fields: %H %h %an %cr %D %P %s. The first commit has one parent (the
    // second); the second is a root (empty %P).
    const input =
        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00aaaaaaa\x00Sam\x002 hours ago\x00HEAD -> main, origin/main\x00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x00Initial commit\n" ++
        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x00bbbbbbb\x00Lee\x00yesterday\x00\x00\x00Follow up\n";
    const commits = try parseCommits(std.testing.allocator, input);
    defer {
        for (commits) |*commit| commit.deinit(std.testing.allocator);
        std.testing.allocator.free(commits);
    }

    try std.testing.expectEqual(@as(usize, 2), commits.len);
    try std.testing.expectEqualSlices(u8, "aaaaaaa", commits[0].short_hash);
    try std.testing.expectEqualSlices(u8, "Sam", commits[0].author);
    try std.testing.expectEqualSlices(u8, "HEAD -> main, origin/main", commits[0].refs);
    try std.testing.expectEqualSlices(u8, "Initial commit", commits[0].subject);
    try std.testing.expectEqual(@as(usize, 1), commits[0].parents.len);
    try std.testing.expectEqualSlices(u8, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", commits[0].parents[0]);
    try std.testing.expectEqualSlices(u8, "", commits[1].refs);
    try std.testing.expectEqualSlices(u8, "Follow up", commits[1].subject);
    try std.testing.expectEqual(@as(usize, 0), commits[1].parents.len);
}

test "parse stash entries extracts selector index" {
    const input =
        "stash@{0}\x001111111111111111111111111111111111111111\x005 minutes ago\x00WIP on main\n" ++
        "stash@{12}\x002222222222222222222222222222222222222222\x003 days ago\x00Saved work\n";
    const entries = try parseStash(std.testing.allocator, input);
    defer {
        for (entries) |*entry| entry.deinit(std.testing.allocator);
        std.testing.allocator.free(entries);
    }

    try std.testing.expectEqual(@as(usize, 2), entries.len);
    try std.testing.expectEqual(@as(usize, 0), entries[0].index);
    try std.testing.expectEqualSlices(u8, "stash@{0}", entries[0].selector);
    try std.testing.expectEqualSlices(u8, "WIP on main", entries[0].message);
    try std.testing.expectEqual(@as(usize, 12), entries[1].index);
    try std.testing.expectEqualSlices(u8, "Saved work", entries[1].message);
}

test "parse worktrees marks the current one and shortens branches" {
    const bytes =
        "worktree /repo\nHEAD aaaa\nbranch refs/heads/main\n\n" ++
        "worktree /repo-wt\nHEAD bbbb\nbranch refs/heads/feature\n\n" ++
        "worktree /repo-detached\nHEAD cccc\ndetached\n";
    const worktrees = try parseWorktrees(std.testing.allocator, bytes, "/repo");
    defer {
        for (worktrees) |*wt| wt.deinit(std.testing.allocator);
        std.testing.allocator.free(worktrees);
    }

    try std.testing.expectEqual(@as(usize, 3), worktrees.len);
    try std.testing.expectEqualStrings("/repo", worktrees[0].path);
    try std.testing.expectEqualStrings("main", worktrees[0].branch);
    try std.testing.expect(worktrees[0].is_current);
    try std.testing.expectEqualStrings("feature", worktrees[1].branch);
    try std.testing.expect(!worktrees[1].is_current);
    try std.testing.expectEqualStrings("(detached)", worktrees[2].branch);
}

test "parse submodules reads the status char, sha and path" {
    const bytes =
        " cd8600b5b4b075bae86b0e3b66a133c687d65716 libs/sub (heads/main)\n" ++
        "-aaaa000000000000000000000000000000000000 vendor/x\n" ++
        "+bbbb000000000000000000000000000000000000 vendor/y (v1.2-3-gabc)\n";
    const subs = try parseSubmodules(std.testing.allocator, bytes);
    defer {
        for (subs) |*sm| sm.deinit(std.testing.allocator);
        std.testing.allocator.free(subs);
    }

    try std.testing.expectEqual(@as(usize, 3), subs.len);
    try std.testing.expectEqual(@as(u8, ' '), subs[0].status);
    try std.testing.expectEqualStrings("libs/sub", subs[0].path);
    try std.testing.expectEqualStrings("ok", subs[0].stateLabel());
    try std.testing.expectEqual(@as(u8, '-'), subs[1].status);
    try std.testing.expectEqualStrings("vendor/x", subs[1].path);
    try std.testing.expectEqualStrings("uninitialized", subs[1].stateLabel());
    try std.testing.expectEqual(@as(u8, '+'), subs[2].status);
    try std.testing.expectEqualStrings("vendor/y", subs[2].path);
}

test "parse commit files handles modifications and renames" {
    const input = "M\x00src/main.zig\x00A\x00new.zig\x00R100\x00old.zig\x00renamed.zig\x00D\x00gone.zig\x00";
    const files = try parseCommitFiles(std.testing.allocator, input);
    defer model.deinitCommitFiles(std.testing.allocator, files);

    try std.testing.expectEqual(@as(usize, 4), files.len);
    try std.testing.expectEqual(@as(u8, 'M'), files[0].status);
    try std.testing.expectEqualSlices(u8, "src/main.zig", files[0].path);
    try std.testing.expectEqual(@as(u8, 'A'), files[1].status);
    try std.testing.expectEqual(@as(u8, 'R'), files[2].status);
    try std.testing.expectEqualSlices(u8, "renamed.zig", files[2].path);
    try std.testing.expectEqual(@as(u8, 'D'), files[3].status);
    try std.testing.expectEqualSlices(u8, "gone.zig", files[3].path);
}

test "retryable lock errors are detected, other errors are not" {
    try std.testing.expect(isRetryableLockError("fatal: Unable to create '/repo/.git/index.lock': File exists."));
    try std.testing.expect(isRetryableLockError("error: cannot lock ref 'refs/heads/main': is at ... but expected ..."));
    try std.testing.expect(!isRetryableLockError("error: pathspec 'x' did not match any file(s)"));
    try std.testing.expect(!isRetryableLockError("Everything up-to-date"));
    try std.testing.expect(!isRetryableLockError(""));
}

test "stripCommentLines drops comments and trims surrounding blanks" {
    const a = std.testing.allocator;

    const kept = try stripCommentLines(a, "PROJ-1: \n# please enter a message\n# with lines\n");
    defer a.free(kept);
    try std.testing.expectEqualStrings("PROJ-1:", kept);

    const body = try stripCommentLines(a, "subject line\n\nbody line\n# a comment\n");
    defer a.free(body);
    try std.testing.expectEqualStrings("subject line\n\nbody line", body);

    // A `#` only counts as a comment at the very start of the line.
    const mid = try stripCommentLines(a, "fix issue #42\n");
    defer a.free(mid);
    try std.testing.expectEqualStrings("fix issue #42", mid);

    const empty = try stripCommentLines(a, "# all comments\n#\n");
    defer a.free(empty);
    try std.testing.expectEqualStrings("", empty);
}

test "stashFile treats the selected path as a literal pathspec" {
    const a = std.testing.allocator;
    const tio = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var pbuf: [160]u8 = undefined;
    const dir_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}", .{tmp.sub_path});

    var env = std.process.Environ.Map.init(a);
    defer env.deinit();
    try env.put("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
    try env.put("HOME", dir_path);
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_CONFIG_SYSTEM", "/dev/null");

    const setup =
        \\set -e
        \\git init -q
        \\git config user.email t@t
        \\git config user.name t
        \\git config commit.gpgsign false
        \\printf base > tracked.txt
        \\git add tracked.txt
        \\git commit -qm init
        \\printf magic > ':(glob)bad.txt'
    ;
    var sh = [_][]const u8{ "sh", "-c", setup };
    const setup_result = try std.process.run(a, tio, .{
        .argv = &sh,
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout_limit = .limited(1 << 20),
        .stderr_limit = .limited(1 << 20),
    });
    defer a.free(setup_result.stdout);
    defer a.free(setup_result.stderr);
    switch (setup_result.term) {
        .exited => |code| if (code != 0) return error.SetupFailed,
        else => return error.SetupFailed,
    }

    var git = try Git.initAt(a, tio, &env, dir_path);
    defer git.deinit();

    var stashed = try git.stashFile(":(glob)bad.txt", null);
    defer stashed.deinit(a);
    try std.testing.expect(stashed.ok());

    var status = try git.exec(&.{ "status", "--porcelain=v1", "-z", "--untracked-files=all" });
    defer status.deinit(a);
    try std.testing.expect(status.ok());
    try std.testing.expectEqualStrings("", status.stdout);
}

test "stashFile stashes a staged deletion without leaving a bogus stash" {
    const a = std.testing.allocator;
    const tio = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var pbuf: [160]u8 = undefined;
    const dir_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}", .{tmp.sub_path});

    var env = std.process.Environ.Map.init(a);
    defer env.deinit();
    try env.put("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
    try env.put("HOME", dir_path);
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_CONFIG_SYSTEM", "/dev/null");

    // gone.txt is staged for deletion (git rm); keep.txt stays dirty and must
    // not be swept into the single-file stash.
    const setup =
        \\set -e
        \\git init -q
        \\git config user.email t@t
        \\git config user.name t
        \\git config commit.gpgsign false
        \\printf gone > gone.txt
        \\printf keep > keep.txt
        \\git add gone.txt keep.txt
        \\git commit -qm init
        \\printf keepmod > keep.txt
        \\git rm -q gone.txt
    ;
    var sh = [_][]const u8{ "sh", "-c", setup };
    const setup_result = try std.process.run(a, tio, .{
        .argv = &sh,
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout_limit = .limited(1 << 20),
        .stderr_limit = .limited(1 << 20),
    });
    defer a.free(setup_result.stdout);
    defer a.free(setup_result.stderr);
    switch (setup_result.term) {
        .exited => |code| if (code != 0) return error.SetupFailed,
        else => return error.SetupFailed,
    }

    var git = try Git.initAt(a, tio, &env, dir_path);
    defer git.deinit();

    var stashed = try git.stashFile("gone.txt", null);
    defer stashed.deinit(a);
    try std.testing.expect(stashed.ok());

    // Exactly one stash entry (no spurious extra), and the deletion left the
    // working tree — gone.txt is restored, keep.txt is still dirty.
    var list = try git.exec(&.{ "stash", "list" });
    defer list.deinit(a);
    try std.testing.expect(list.ok());
    var lines: usize = 0;
    var it = std.mem.tokenizeScalar(u8, list.stdout, '\n');
    while (it.next()) |_| lines += 1;
    try std.testing.expectEqual(@as(usize, 1), lines);

    var status = try git.exec(&.{ "status", "--porcelain=v1" });
    defer status.deinit(a);
    try std.testing.expect(status.ok());
    try std.testing.expectEqualStrings(" M keep.txt\n", status.stdout);
}

test "discardStagingPatch reverse-applies to the working tree and to the index" {
    const a = std.testing.allocator;
    const tio = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var pbuf: [160]u8 = undefined;
    const dir_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}", .{tmp.sub_path});

    var env = std.process.Environ.Map.init(a);
    defer env.deinit();
    try env.put("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
    try env.put("HOME", dir_path);
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_CONFIG_SYSTEM", "/dev/null");

    const setup =
        \\set -e
        \\git init -q
        \\git config user.email t@t
        \\git config user.name t
        \\git config commit.gpgsign false
        \\printf 'a\nb\nc\n' > f.txt
        \\git add f.txt
        \\git commit -qm init
    ;
    var sh = [_][]const u8{ "sh", "-c", setup };
    const setup_result = try std.process.run(a, tio, .{
        .argv = &sh,
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout_limit = .limited(1 << 20),
        .stderr_limit = .limited(1 << 20),
    });
    defer a.free(setup_result.stdout);
    defer a.free(setup_result.stderr);
    switch (setup_result.term) {
        .exited => |code| if (code != 0) return error.SetupFailed,
        else => return error.SetupFailed,
    }

    var git = try Git.initAt(a, tio, &env, dir_path);
    defer git.deinit();

    var fbuf: [200]u8 = undefined;
    const file_path = try std.fmt.bufPrint(&fbuf, "{s}/f.txt", .{dir_path});

    // Unstaged side: an unstaged change, discarded via the `git diff` patch,
    // reverse-applied to the working tree — the file returns to HEAD, index clean.
    try std.Io.Dir.writeFile(.cwd(), tio, .{ .sub_path = file_path, .data = "a\nB\nc\n" });
    const unstaged_diff = try git.output(&.{ "diff", "--", "f.txt" });
    defer a.free(unstaged_diff);
    var d1 = try git.discardStagingPatch(unstaged_diff, false);
    defer d1.deinit(a);
    try std.testing.expect(d1.ok());
    var st1 = try git.exec(&.{ "status", "--porcelain=v1" });
    defer st1.deinit(a);
    try std.testing.expectEqualStrings("", st1.stdout);

    // Staged side: a staged change, discarded via the `git diff --cached` patch
    // with `--index`, reverse-applied to both index and working tree — gone from
    // both, so the tree is clean again.
    try std.Io.Dir.writeFile(.cwd(), tio, .{ .sub_path = file_path, .data = "a\nB\nc\n" });
    var add = try git.exec(&.{ "add", "f.txt" });
    defer add.deinit(a);
    try std.testing.expect(add.ok());
    const staged_diff = try git.output(&.{ "diff", "--cached", "--", "f.txt" });
    defer a.free(staged_diff);
    var d2 = try git.discardStagingPatch(staged_diff, true);
    defer d2.deinit(a);
    try std.testing.expect(d2.ok());
    var st2 = try git.exec(&.{ "status", "--porcelain=v1" });
    defer st2.deinit(a);
    try std.testing.expectEqualStrings("", st2.stdout);
}

test "excludeFile appends to .git/info/exclude, dedupes, and hides the file" {
    const a = std.testing.allocator;
    const tio = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var pbuf: [160]u8 = undefined;
    const dir_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}", .{tmp.sub_path});

    var env = std.process.Environ.Map.init(a);
    defer env.deinit();
    try env.put("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
    try env.put("HOME", dir_path);
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_CONFIG_SYSTEM", "/dev/null");

    const setup =
        \\set -e
        \\git init -q
        \\git config user.email t@t
        \\git config user.name t
        \\printf secret > secret.txt
    ;
    var sh = [_][]const u8{ "sh", "-c", setup };
    const setup_result = try std.process.run(a, tio, .{
        .argv = &sh,
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout_limit = .limited(1 << 20),
        .stderr_limit = .limited(1 << 20),
    });
    defer a.free(setup_result.stdout);
    defer a.free(setup_result.stderr);
    switch (setup_result.term) {
        .exited => |code| if (code != 0) return error.SetupFailed,
        else => return error.SetupFailed,
    }

    var git = try Git.initAt(a, tio, &env, dir_path);
    defer git.deinit();

    // secret.txt is untracked and visible before excluding.
    var before = try git.exec(&.{ "status", "--porcelain=v1" });
    defer before.deinit(a);
    try std.testing.expectEqualStrings("?? secret.txt\n", before.stdout);

    var r1 = try git.excludeFile("secret.txt");
    defer r1.deinit(a);
    try std.testing.expect(r1.ok());
    // Excluding again must not duplicate the entry.
    var r2 = try git.excludeFile("secret.txt");
    defer r2.deinit(a);
    try std.testing.expect(r2.ok());

    // The local exclude file holds exactly one entry, and git now hides the file.
    const excl_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}/.git/info/exclude", .{tmp.sub_path});
    const excl = try std.Io.Dir.readFileAlloc(.cwd(), tio, excl_path, a, .limited(1 << 20));
    defer a.free(excl);
    var count: usize = 0;
    var it = std.mem.tokenizeScalar(u8, excl, '\n');
    while (it.next()) |line| {
        if (std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), "secret.txt")) count += 1;
    }
    try std.testing.expectEqual(@as(usize, 1), count);

    var after = try git.exec(&.{ "status", "--porcelain=v1" });
    defer after.deinit(a);
    try std.testing.expectEqualStrings("", after.stdout);
}

test "stashKeeping restores the same working state it stashed" {
    const a = std.testing.allocator;
    const tio = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var pbuf: [160]u8 = undefined;
    const dir_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}", .{tmp.sub_path});

    var env = std.process.Environ.Map.init(a);
    defer env.deinit();
    try env.put("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
    try env.put("HOME", dir_path);
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_CONFIG_SYSTEM", "/dev/null");

    const setup =
        \\set -e
        \\git init -q
        \\git config user.email t@t
        \\git config user.name t
        \\git config commit.gpgsign false
        \\printf base > tracked.txt
        \\git add tracked.txt
        \\git commit -qm init
        \\printf staged > tracked.txt
        \\git add tracked.txt
        \\printf unstaged > tracked.txt
        \\printf scratch > untracked.txt
    ;
    var sh = [_][]const u8{ "sh", "-c", setup };
    const setup_result = try std.process.run(a, tio, .{
        .argv = &sh,
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout_limit = .limited(1 << 20),
        .stderr_limit = .limited(1 << 20),
    });
    defer a.free(setup_result.stdout);
    defer a.free(setup_result.stderr);
    switch (setup_result.term) {
        .exited => |code| if (code != 0) return error.SetupFailed,
        else => return error.SetupFailed,
    }

    var git = try Git.initAt(a, tio, &env, dir_path);
    defer git.deinit();

    var kept = (try git.stashKeeping("keep")) orelse return error.ExpectedStash;
    defer kept.deinit(a);
    try std.testing.expect(kept.ok());

    var status = try git.exec(&.{ "status", "--porcelain=v1" });
    defer status.deinit(a);
    try std.testing.expect(status.ok());
    try std.testing.expect(std.mem.indexOf(u8, status.stdout, "MM tracked.txt\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, status.stdout, "?? untracked.txt\n") != null);

    var stash_list = try git.exec(&.{ "stash", "list" });
    defer stash_list.deinit(a);
    try std.testing.expect(stash_list.ok());
    try std.testing.expect(std.mem.indexOf(u8, stash_list.stdout, "keep") != null);
}

test "prepareCommitMsg runs a guarded hook and seeds the branch ticket" {
    const a = std.testing.allocator;
    const tio = std.testing.io;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var pbuf: [160]u8 = undefined;
    const dir_path = try std.fmt.bufPrint(&pbuf, ".zig-cache/tmp/{s}", .{tmp.sub_path});

    // A minimal env: git and the hook's own `git`/`grep` need PATH; HOME points at
    // the scratch dir so the test never reads or writes the real git config.
    var env = std.process.Environ.Map.init(a);
    defer env.deinit();
    // A PATH broad enough to find git/sh/grep on macOS and Linux without reading
    // the real environment (which needs libc `getenv`).
    try env.put("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
    try env.put("HOME", dir_path);
    try env.put("GIT_CONFIG_GLOBAL", "/dev/null");
    try env.put("GIT_CONFIG_SYSTEM", "/dev/null");

    const setup =
        \\set -e
        \\git init -q
        \\git config user.email t@t
        \\git config user.name t
        \\git checkout -q -b feature/PROJ-42-thing
        \\mkdir -p .git/hooks
        \\cat > .git/hooks/prepare-commit-msg <<'EOF'
        \\#!/bin/sh
        \\[ -z "$2" ] || exit 0
        \\ticket=$(git symbolic-ref --short HEAD | grep -oE '[A-Z]+-[0-9]+')
        \\printf '%s: %s' "$ticket" "$(cat "$1")" > "$1"
        \\EOF
        \\chmod +x .git/hooks/prepare-commit-msg
    ;
    var sh = [_][]const u8{ "sh", "-c", setup };
    const r = try std.process.run(a, tio, .{
        .argv = &sh,
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout_limit = .limited(1 << 20),
        .stderr_limit = .limited(1 << 20),
    });
    a.free(r.stdout);
    a.free(r.stderr);
    switch (r.term) {
        .exited => |code| if (code != 0) return error.SetupFailed,
        else => return error.SetupFailed,
    }

    var git = try Git.initAt(a, tio, &env, dir_path);
    defer git.deinit();

    const seed = (try git.prepareCommitMsg()) orelse return error.HookProducedNoSeed;
    defer a.free(seed);
    try std.testing.expectEqualStrings("PROJ-42:", seed);
}

test "undoKeepsChanges: only commit/amend/initial reset --soft (keep changes)" {
    // These fold your staged work into the commit, so undo must keep it (--soft).
    try std.testing.expect(Git.undoKeepsChanges("commit: second"));
    try std.testing.expect(Git.undoKeepsChanges("commit (amend): second"));
    try std.testing.expect(Git.undoKeepsChanges("commit (initial): first"));
    // These only moved history, so undo restores cleanly (--hard).
    try std.testing.expect(!Git.undoKeepsChanges("merge feature: Merge made by the 'ort' strategy."));
    try std.testing.expect(!Git.undoKeepsChanges("commit (merge): resolve conflicts"));
    try std.testing.expect(!Git.undoKeepsChanges("commit (cherry-pick): work on main"));
    try std.testing.expect(!Git.undoKeepsChanges("revert: Revert \"second\""));
    try std.testing.expect(!Git.undoKeepsChanges("reset: moving to HEAD~1"));
    try std.testing.expect(!Git.undoKeepsChanges("rebase (finish): returning to refs/heads/main"));
    try std.testing.expect(!Git.undoKeepsChanges(""));
}
