Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-git/src/gitops.rs

//! Pure-Rust git WRITE + inspection operations used by the release doctors
//! (dwarves `release_doctor::*`, nornir `release::*`) — the operations those
//! tools used to shell `git` for. Everything here is `gix`-only: NO
//! `std::process::Command`, no libgit2/C.
//!
//! These are the leaf equivalents of the release cascade's git verbs:
//!
//! * ref inspection — [`resolve_ref`], [`branch_exists`], [`is_ancestor`],
//!   [`tags_at_head`], [`local_branches`], [`count_commits_ahead`],
//!   [`list_worktrees`], [`is_dirty`], [`short_head_sha`].
//! * ref/branch mutation — [`delete_branch`], [`update_ref`],
//!   [`checkout_branch`], [`checkout_detach`], [`create_or_reset_branch_at_head`].
//! * worktree file restore — [`restore_path_from_head`].
//! * commit — [`commit_paths`] (stage a pathspec set + commit with the repo's
//!   real identity), [`stage_paths`] (add to the on-disk index, no commit).
//! * local merge — [`merge_ff_only`], [`merge_into_head`].
//! * local config — [`read_local_config`], [`set_local_config`].
//!
//! **What gix 0.84 CANNOT do:** the send-pack side of the git wire protocol, so
//! an over-the-network PUSH is impossible in-process (see
//! [`crate::gitio::push_to_local_remote`], which rejects non-file remotes for
//! exactly this reason). Callers that need to push to a network `origin` must
//! stay out-of-band until a send-pack transport lands.

use std::path::{Path, PathBuf};

use anyhow::{anyhow, bail, Context, Result};

use crate::gitio;

// ─── ref inspection ──────────────────────────────────────────────────────────

/// Resolve a committish (`HEAD`, a branch/tag name, a full ref, a sha prefix) to
/// its full 40-char commit SHA. `Ok(None)` when it does not resolve (the
/// equivalent of `git rev-parse --verify --quiet <refish>` exiting non-zero).
pub fn resolve_ref(root: &Path, refish: &str) -> Result<Option<String>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    match repo.rev_parse_single(refish) {
        Ok(id) => Ok(Some(id.detach().to_string())),
        Err(_) => Ok(None),
    }
}

/// Whether the local branch `refs/heads/<branch>` exists (a resolvable ref).
/// Mirrors `git rev-parse --verify --quiet refs/heads/<branch>`.
pub fn branch_exists(root: &Path, branch: &str) -> Result<bool> {
    let full = format!("refs/heads/{branch}");
    Ok(resolve_ref(root, &full)?.is_some())
}

/// `true` when `ancestor` is an ancestor of (or equal to) `tip` — the
/// fast-forward test (`git merge-base --is-ancestor <ancestor> <tip>`).
/// Accepts either committishes or raw SHAs. A ref that will not resolve ⇒
/// `false` (mirrors the CLI's non-zero exit).
pub fn is_ancestor(root: &Path, ancestor: &str, tip: &str) -> Result<bool> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let (Some(anc), Some(tip)) = (
        repo.rev_parse_single(ancestor).ok(),
        repo.rev_parse_single(tip).ok(),
    ) else {
        return Ok(false);
    };
    if anc == tip {
        return Ok(true);
    }
    // Walk tip's first-parent-inclusive ancestry; `anc` reachable ⇒ ancestor.
    let anc_oid = anc.detach();
    let platform = tip
        .ancestors()
        .all()
        .context("start ancestry walk")?;
    for info in platform {
        let info = info.context("read ancestry commit")?;
        if info.id == anc_oid {
            return Ok(true);
        }
    }
    Ok(false)
}

/// The lightweight/annotated tags whose peeled target is `HEAD` — the
/// `git tag --points-at HEAD` set, sorted.
pub fn tags_at_head(root: &Path) -> Result<Vec<String>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let head = gitio::head_sha(root)?;
    let head_oid = gix::ObjectId::from_hex(head.as_bytes()).ok();
    let mut out = Vec::new();
    let refs = repo.references().map_err(|e| anyhow!("open ref store: {e}"))?;
    for r in refs.tags().map_err(|e| anyhow!("iterate tags: {e}"))? {
        let Ok(mut r) = r else { continue };
        let name = r.name().shorten().to_string();
        if let Ok(id) = r.peel_to_id() {
            if Some(id.detach()) == head_oid {
                out.push(name);
            }
        }
    }
    out.sort();
    Ok(out)
}

/// Every local branch (short name), sorted — `git for-each-ref
/// --format='%(refname:short)' refs/heads/`.
pub fn local_branches(root: &Path) -> Result<Vec<String>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let mut out = Vec::new();
    let refs = repo.references().map_err(|e| anyhow!("open ref store: {e}"))?;
    for r in refs
        .prefixed(gix::bstr::BStr::new("refs/heads/"))
        .map_err(|e| anyhow!("iterate branches: {e}"))?
    {
        let Ok(r) = r else { continue };
        out.push(r.name().shorten().to_string());
    }
    out.sort();
    out.dedup();
    Ok(out)
}

/// Number of commits reachable from `branch` but not from `base` —
/// `git rev-list --count <base>..<branch>`. `0` when `branch` is behind or equal.
pub fn count_commits_ahead(root: &Path, base: &str, branch: &str) -> Result<usize> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let branch_id = match repo.rev_parse_single(branch) {
        Ok(id) => id,
        Err(_) => return Ok(0),
    };
    let base_oid = repo.rev_parse_single(base).ok().map(|i| i.detach());
    let mut count = 0usize;
    let platform = branch_id.ancestors().all().context("start rev-list")?;
    for info in platform {
        let info = info.context("read rev-list commit")?;
        if Some(info.id) == base_oid {
            break; // reached base along first-parent; stop (matches base..branch)
        }
        // A full `base..branch` excludes everything reachable from base. For the
        // release use (base is an ancestor of branch) the first-parent stop above
        // is exact; guard the rare non-linear case by testing reachability.
        if let Some(b) = base_oid {
            if is_ancestor_oid(&repo, info.id, b)? {
                continue; // reachable from base — not counted
            }
        }
        count += 1;
    }
    Ok(count)
}

fn is_ancestor_oid(
    repo: &gix::Repository,
    candidate: gix::ObjectId,
    of: gix::ObjectId,
) -> Result<bool> {
    if candidate == of {
        return Ok(true);
    }
    let platform = repo
        .find_object(of)
        .context("find base object")?
        .try_into_commit()
        .context("base is not a commit")?
        .id()
        .ancestors()
        .all()
        .context("ancestry")?;
    for info in platform {
        if info.context("read ancestor")?.id == candidate {
            return Ok(true);
        }
    }
    Ok(false)
}

/// The set of git worktrees for the repo at `root`: `(worktree_path,
/// checked_out_branch_short_name)`. Mirrors `git worktree list --porcelain`
/// (the main worktree included).
pub fn list_worktrees(root: &Path) -> Result<Vec<(PathBuf, Option<String>)>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let mut out = Vec::new();
    // Main worktree.
    if let Some(wd) = repo.workdir() {
        let branch = repo.head_name().ok().flatten().map(|n| n.shorten().to_string());
        out.push((wd.to_path_buf(), branch));
    }
    for proxy in repo.worktrees().context("enumerate linked worktrees")? {
        let base = proxy.base().context("worktree base path")?;
        let branch = proxy
            .into_repo_with_possibly_inaccessible_worktree()
            .ok()
            .and_then(|r| r.head_name().ok().flatten())
            .map(|n| n.shorten().to_string());
        out.push((base, branch));
    }
    Ok(out)
}

/// Whether the working tree has ANY uncommitted change (staged, tracked
/// modification, or untracked file) — `!(git status --porcelain).is_empty()`.
pub fn is_dirty(root: &Path) -> Result<bool> {
    Ok(!gitio::worktree_changed_paths(root)?.is_empty())
}

/// Short (7-char) SHA of `HEAD` — `git rev-parse --short HEAD`.
pub fn short_head_sha(root: &Path) -> Result<String> {
    let full = gitio::head_sha(root)?;
    Ok(full.chars().take(7).collect())
}

// ─── ref / branch mutation ─────────────────────────────────────────────────────

/// Delete the local branch `refs/heads/<branch>` — `git branch -D <branch>`.
/// No-op-friendly: a missing branch is `Ok(())`.
pub fn delete_branch(root: &Path, branch: &str) -> Result<()> {
    use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
    use gix::refs::FullName;

    // A deletion writes a reflog line too; `open_for_ref_edits` is the opener
    // every ref edit in this crate takes.
    let repo = gitio::open_for_ref_edits(root)?;
    let full = format!("refs/heads/{branch}");
    if repo.try_find_reference(full.as_str()).ok().flatten().is_none() {
        return Ok(()); // already gone
    }
    let name: FullName = full
        .as_str()
        .try_into()
        .map_err(|e| anyhow!("invalid branch ref `{full}`: {e}"))?;
    repo.edit_reference(RefEdit {
        change: Change::Delete { expected: PreviousValue::Any, log: RefLog::AndReference },
        name,
        deref: false,
    })
    .with_context(|| format!("delete branch {branch}"))?;
    Ok(())
}

/// Force `<fullref>` (e.g. `refs/heads/main`) to point at `sha` — the
/// `git update-ref <fullref> <sha>` primitive (used by undo to reset a branch).
pub fn update_ref(root: &Path, fullref: &str, sha: &str) -> Result<()> {
    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
    use gix::refs::{FullName, Target};

    // `open_for_ref_edits`, not `gix::open`: this writes a reflog, and gix
    // refuses that with no committer identity. See its doc comment.
    let repo = gitio::open_for_ref_edits(root)?;
    let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
    let name: FullName = fullref
        .try_into()
        .map_err(|e| anyhow!("invalid ref `{fullref}`: {e}"))?;
    repo.edit_reference(RefEdit {
        change: Change::Update {
            log: LogChange {
                mode: RefLog::AndReference,
                force_create_reflog: false,
                message: "nornir-git: update-ref".into(),
            },
            expected: PreviousValue::Any,
            new: Target::Object(oid),
        },
        name,
        deref: false,
    })
    .with_context(|| format!("update-ref {fullref} -> {sha}"))?;
    Ok(())
}

/// Switch `HEAD` to the existing local branch `<branch>` — `git checkout
/// <branch>`. Errors if the branch does not exist.
///
/// When the branch points at the SAME commit as the current `HEAD` (the release
/// cascade's common case: an ephemeral branch cut with no commits, restored
/// afterwards), only `HEAD` is repointed and the working tree is LEFT UNTOUCHED
/// — so uncommitted edits (the publish's `Cargo.lock` churn, the operator's
/// work) carry across exactly as real `git checkout` does. When the commits
/// differ, the target tree is materialised (tracked files reset to the branch's
/// content).
pub fn checkout_branch(root: &Path, branch: &str) -> Result<()> {
    let sha = branch_head_sha(root, branch)?;
    if gitio::head_sha(root).ok().as_deref() == Some(sha.as_str()) {
        return gitio::point_head_at_branch(root, branch, &sha);
    }
    gitio::set_head_and_checkout(root, branch, &sha)
}

/// Detach `HEAD` at `committish` (or at the current `HEAD` when `None`) and
/// materialise that tree — `git checkout --detach [<committish>]`.
pub fn checkout_detach(root: &Path, committish: Option<&str>) -> Result<()> {
    let target = committish.unwrap_or("HEAD");
    let sha = resolve_ref(root, target)?
        .ok_or_else(|| anyhow!("{target} does not resolve in {}", root.display()))?;
    gitio::detach_head_and_checkout(root, &sha)
}

/// Create or reset `<branch>` to the CURRENT `HEAD` commit and point `HEAD` at
/// it — `git checkout -B <branch>`. The working tree is unchanged (HEAD sha is
/// unchanged); only refs move.
pub fn create_or_reset_branch_at_head(root: &Path, branch: &str) -> Result<()> {
    let sha = gitio::head_sha(root)?;
    gitio::point_head_at_branch(root, branch, &sha)
}

fn branch_head_sha(root: &Path, branch: &str) -> Result<String> {
    branch_exists(root, branch)?
        .then_some(())
        .ok_or_else(|| anyhow!("branch `{branch}` not found in {}", root.display()))?;
    resolve_ref(root, &format!("refs/heads/{branch}"))?
        .ok_or_else(|| anyhow!("branch `{branch}` unresolvable in {}", root.display()))
}

// ─── worktree file restore ─────────────────────────────────────────────────────

/// Restore `relpath` in the working tree to its `HEAD` blob — `git checkout
/// HEAD -- <relpath>`. A path absent from `HEAD` is left untouched (mirrors the
/// harmless "pathspec did not match" the callers already swallow).
pub fn restore_path_from_head(root: &Path, relpath: &str) -> Result<()> {
    use gix::bstr::ByteSlice;

    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let workdir = repo
        .workdir()
        .ok_or_else(|| anyhow!("bare repo has no working tree: {}", root.display()))?
        .to_path_buf();
    let Ok(head_commit) = repo.head_commit() else {
        return Ok(()); // unborn HEAD — nothing to restore from
    };
    let Ok(tree) = head_commit.tree() else {
        return Ok(());
    };
    let Some(entry) = tree
        .lookup_entry_by_path(Path::new(relpath))
        .with_context(|| format!("lookup {relpath} in HEAD tree"))?
    else {
        return Ok(()); // path not tracked at HEAD — no-op (pathspec did not match)
    };
    let obj = entry.object().with_context(|| format!("read blob for {relpath}"))?;
    let full = workdir.join(relpath);
    if let Some(parent) = full.parent() {
        std::fs::create_dir_all(parent).ok();
    }
    let mode = entry.mode();
    if mode.is_link() {
        #[cfg(unix)]
        {
            use std::os::unix::ffi::OsStrExt;
            let target = std::ffi::OsStr::from_bytes(obj.data.as_bstr());
            std::fs::remove_file(&full).ok();
            std::os::unix::fs::symlink(target, &full)
                .with_context(|| format!("symlink {}", full.display()))?;
            return Ok(());
        }
    }
    std::fs::write(&full, &obj.data).with_context(|| format!("write {}", full.display()))?;
    #[cfg(unix)]
    if mode.is_executable() {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).ok();
    }
    Ok(())
}

// ─── commit ─────────────────────────────────────────────────────────────────────

/// The repo's commit identity from local/global git config
/// (`user.name`/`user.email`), falling back to the deterministic fixture
/// identity when unconfigured — so a release commit carries the operator's real
/// authorship when git is configured, and still succeeds in a bare sandbox.
fn commit_identity(repo: &gix::Repository) -> (String, String) {
    let cfg = repo.config_snapshot();
    let name = cfg
        .string("user.name")
        .map(|b| b.to_string())
        .unwrap_or_else(|| "Nornir Fixture".to_string());
    let email = cfg
        .string("user.email")
        .map(|b| b.to_string())
        .unwrap_or_else(|| "fixtures@nornir.invalid".to_string());
    (name, email)
}

/// Current wall-clock time in gitoxide's raw signature format (`<unix> +0000`),
/// the shape [`gix::actor::SignatureRef::time`] wants.
fn git_time_now() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    format!("{secs} +0000")
}

/// Match `pathspec` against a repo-relative path. Supports the two forms the
/// release cascade uses: a leading-`*` basename glob (`*Cargo.toml` — every
/// manifest at any depth) and an exact repo-relative path (`Cargo.lock`,
/// `docs/README.md`). This is NOT full gitignore/pathspec matching — just the
/// subset the doctors rely on.
fn pathspec_matches(pathspec: &str, rela: &str) -> bool {
    if let Some(suffix) = pathspec.strip_prefix('*') {
        let base = rela.rsplit('/').next().unwrap_or(rela);
        rela.ends_with(suffix) || base.ends_with(suffix)
    } else {
        rela == pathspec
    }
}

/// Stage the working-tree content of every path matching `pathspecs` and COMMIT
/// it on top of `HEAD` with the repo's real identity — the pure-gix equivalent
/// of `git add -- <pathspecs> && git commit -m <message>`.
///
/// Only ADDITIONS/MODIFICATIONS of matched, currently-existing worktree files
/// are captured (the release cascade only ever bump-edits manifests + the lock —
/// it never stages deletions here). When the resulting tree is identical to
/// `HEAD`'s tree the commit is skipped and `Ok(None)` returned (unless
/// `allow_empty`), so a caller that guards with "commit only if staged" keeps
/// that behaviour. Returns the new commit SHA on success.
pub fn commit_paths(
    root: &Path,
    pathspecs: &[&str],
    message: &str,
    allow_empty: bool,
) -> Result<Option<String>> {
    use gix::object::tree::EntryKind;

    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let workdir = repo
        .workdir()
        .ok_or_else(|| anyhow!("bare repo has no working tree: {}", root.display()))?
        .to_path_buf();

    // Seed the tree editor from HEAD's tree (or the empty tree if unborn).
    let (head_tree_id, parents): (gix::ObjectId, Vec<gix::ObjectId>) = match repo.head_commit() {
        Ok(c) => (c.tree_id().context("HEAD tree")?.detach(), vec![c.id]),
        Err(_) => (gix::ObjectId::empty_tree(repo.object_hash()), vec![]),
    };
    let mut editor = repo.edit_tree(head_tree_id).context("seed tree editor from HEAD")?;

    // Walk the worktree; upsert every matched file's current content.
    let mut staged_any = false;
    let mut stack = vec![workdir.clone()];
    while let Some(dir) = stack.pop() {
        let rd = match std::fs::read_dir(&dir) {
            Ok(rd) => rd,
            Err(_) => continue,
        };
        for entry in rd.flatten() {
            let path = entry.path();
            let name = entry.file_name();
            if name == ".git" {
                continue;
            }
            let meta = match std::fs::symlink_metadata(&path) {
                Ok(m) => m,
                Err(_) => continue,
            };
            if meta.is_dir() {
                stack.push(path);
                continue;
            }
            let rela = match path.strip_prefix(&workdir) {
                Ok(r) => r.to_string_lossy().replace('\\', "/"),
                Err(_) => continue,
            };
            if !pathspecs.iter().any(|ps| pathspec_matches(ps, &rela)) {
                continue;
            }
            let ft = meta.file_type();
            let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
                let target = std::fs::read_link(&path)
                    .with_context(|| format!("readlink {}", path.display()))?;
                (gix::path::into_bstr(target)?.into_owned().into(), EntryKind::Link)
            } else {
                let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
                #[cfg(unix)]
                let kind = {
                    use std::os::unix::fs::PermissionsExt;
                    if meta.permissions().mode() & 0o111 != 0 {
                        EntryKind::BlobExecutable
                    } else {
                        EntryKind::Blob
                    }
                };
                #[cfg(not(unix))]
                let kind = EntryKind::Blob;
                (bytes, kind)
            };
            let blob = repo.write_blob(&bytes).context("write blob")?;
            let rela_bstr = gix::path::into_bstr(Path::new(&rela))?.into_owned();
            editor
                .upsert(rela_bstr.as_ref() as &gix::bstr::BStr, kind, blob.detach())
                .with_context(|| format!("tree upsert {rela}"))?;
            staged_any = true;
        }
    }

    let tree = editor.write().context("write commit tree")?.detach();
    if !allow_empty && tree == head_tree_id {
        return Ok(None); // nothing changed among the pathspecs
    }
    let _ = staged_any;

    let (name, email) = commit_identity(&repo);
    let time = git_time_now();
    let sig = gix::actor::SignatureRef {
        name: gix::bstr::BStr::new(name.as_bytes()),
        email: gix::bstr::BStr::new(email.as_bytes()),
        time: &time,
    };
    let id = repo
        .commit_as(sig, sig, "HEAD", message, tree, parents)
        .context("create commit")?;

    // Rebuild the index so the tree just committed reads clean afterwards.
    if let Ok(mut index) = repo.index_from_tree(&tree) {
        let _ = index.write(gix::index::write::Options::default());
    }
    Ok(Some(id.to_string()))
}

/// Build a single **orphan** commit (NO parents — no history) whose tree is the
/// full recursive contents of `content_dir` (minus any `.git`), and point
/// `refs/heads/<branch>` at it. Returns the new commit SHA.
///
/// Pure-gix: the blobs, tree and commit are written into `repo_root`'s object
/// database and the branch ref is created WITHOUT a worktree, a checkout, or a
/// `git` subprocess — the `HEAD` and working tree of `repo_root` are untouched.
/// Any pre-existing `<branch>` is deleted first, so each call rebuilds the tip
/// wholesale (a squashed, single-commit orphan branch).
///
/// This is the in-process core of a gh-pages-style publish: a throwaway branch
/// whose tip is rebuilt from a built `dist/` directory each deploy, keeping the
/// source history free of build blobs (see `nornir-pages`). The one step it does
/// NOT perform is the network force-push — gix 0.84 has no send-pack, so the
/// caller shells that single `git push`, the ONE sanctioned `git` exception.
pub fn commit_dir_as_orphan_branch(
    repo_root: &Path,
    content_dir: &Path,
    branch: &str,
    message: &str,
) -> Result<String> {
    let repo = gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;

    // Rebuild wholesale: drop any existing local branch so the orphan commit
    // (no parents) can create it fresh regardless of the prior tip.
    delete_branch(repo_root, branch)?;

    // Snapshot content_dir into a tree (reusing the leaf's ONE directory-tree walk).
    let empty = gix::ObjectId::empty_tree(repo.object_hash());
    let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;
    gitio::add_dir_recursive(&repo, &mut editor, content_dir, content_dir)
        .with_context(|| format!("snapshot {} into a tree", content_dir.display()))?;
    let tree = editor.write().context("write pages tree")?.detach();

    let (name, email) = commit_identity(&repo);
    let time = git_time_now();
    let sig = gix::actor::SignatureRef {
        name: gix::bstr::BStr::new(name.as_bytes()),
        email: gix::bstr::BStr::new(email.as_bytes()),
        time: &time,
    };
    let full = format!("refs/heads/{branch}");
    // No parents ⇒ orphan. `commit_as` writes the commit and points `full` at it.
    let id = repo
        .commit_as(sig, sig, full.as_str(), message, tree, Vec::<gix::ObjectId>::new())
        .with_context(|| format!("create orphan commit on {full}"))?;
    Ok(id.to_string())
}

/// Stage the working-tree content of `pathspecs` into the on-disk index WITHOUT
/// committing — `git add -- <pathspecs>`. Returns the repo-relative paths that
/// were staged. ADDITIVE (like real `git add`): the current index is preserved
/// and the named paths are added/updated on top of it, so calling it repeatedly
/// (one path per call) accumulates rather than resetting.
///
/// Only the exact-path form is supported here (the index-staging callers name
/// exact repo-relative paths); the `*glob` form is a [`commit_paths`] feature.
pub fn stage_paths(root: &Path, pathspecs: &[&str]) -> Result<Vec<String>> {
    use gix::index::entry::Mode;
    use gix::object::tree::EntryKind;

    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let workdir = repo
        .workdir()
        .ok_or_else(|| anyhow!("bare repo has no working tree: {}", root.display()))?
        .to_path_buf();

    // Seed the tree editor from HEAD, then overlay the CURRENT index so any
    // already-staged content is preserved (additive `git add` semantics). Fall
    // back to the empty tree on an unborn HEAD.
    let head_tree = match repo.head_commit() {
        Ok(c) => c.tree_id().context("HEAD tree")?.detach(),
        Err(_) => gix::ObjectId::empty_tree(repo.object_hash()),
    };
    let mut editor = repo.edit_tree(head_tree).context("seed index tree editor")?;
    if let Ok(index) = repo.open_index() {
        for entry in index.entries() {
            let kind = match entry.mode {
                Mode::FILE => EntryKind::Blob,
                Mode::FILE_EXECUTABLE => EntryKind::BlobExecutable,
                Mode::SYMLINK => EntryKind::Link,
                Mode::DIR => EntryKind::Tree,
                Mode::COMMIT => EntryKind::Commit,
                _ => continue,
            };
            let path = entry.path(&index).to_owned();
            editor
                .upsert(path.as_ref() as &gix::bstr::BStr, kind, entry.id)
                .ok();
        }
    }

    let mut staged = Vec::new();
    for ps in pathspecs {
        let rela = ps.trim_start_matches("./");
        let full = workdir.join(rela);
        if !full.is_file() {
            continue;
        }
        let bytes = std::fs::read(&full).with_context(|| format!("read {}", full.display()))?;
        let blob = repo.write_blob(&bytes).context("write blob")?;
        let rela_bstr = gix::path::into_bstr(Path::new(rela))?.into_owned();
        editor
            .upsert(rela_bstr.as_ref() as &gix::bstr::BStr, EntryKind::Blob, blob.detach())
            .with_context(|| format!("tree upsert {rela}"))?;
        staged.push(rela.to_string());
    }
    if staged.is_empty() {
        return Ok(staged);
    }
    let tree = editor.write().context("write index tree")?.detach();
    let mut index = repo.index_from_tree(&tree).context("index from staged tree")?;
    index.write(gix::index::write::Options::default()).context("persist index")?;
    Ok(staged)
}

// ─── local merge ─────────────────────────────────────────────────────────────────

/// Fast-forward the CURRENT branch to `branch`'s tip — `git merge --ff-only
/// <branch>`. Requires the current `HEAD` to be an ancestor of `<branch>`;
/// otherwise errors (no merge commit is ever created here). On success `HEAD`
/// (and the branch it points at) advances to the tip and the worktree is
/// materialised.
pub fn merge_ff_only(root: &Path, branch: &str) -> Result<String> {
    let tip = branch_head_sha(root, branch)?;
    let head = gitio::head_sha(root)?;
    if head == tip {
        return Ok(head);
    }
    if !is_ancestor(root, &head, &tip)? {
        bail!("not a fast-forward: HEAD {head} is not an ancestor of {branch} ({tip})");
    }
    let cur = gitio::head_branch(root)?;
    if cur == "(detached)" {
        bail!("cannot fast-forward a detached HEAD");
    }
    gitio::set_head_and_checkout(root, &cur, &tip)?;
    Ok(tip)
}

/// Create a merge commit on the CURRENT branch with parents `[HEAD, <branch>]`
/// and the tree of `<branch>`'s tip — the `git merge --no-ff -m <message>
/// <branch>` used by finalize when the base branch IS an ancestor of the ramp
/// tip (the only shape the release cascade produces: the ramp is cut FROM base,
/// so no 3-way content merge is needed — the merged tree IS the ramp tree).
/// A genuinely diverged `<branch>` (HEAD not an ancestor) is refused rather than
/// attempting a conflict-prone 3-way merge in-process.
pub fn merge_into_head(root: &Path, branch: &str, message: &str) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let tip = branch_head_sha(root, branch)?;
    let head = gitio::head_sha(root)?;
    if !is_ancestor(root, &head, &tip)? {
        bail!(
            "refusing in-process --no-ff merge of `{branch}`: HEAD {head} is not an ancestor of \
             {tip} (a true 3-way merge is not supported by the gix leaf)"
        );
    }
    let cur = gitio::head_branch(root)?;
    if cur == "(detached)" {
        bail!("cannot merge onto a detached HEAD");
    }
    let head_oid = gix::ObjectId::from_hex(head.as_bytes()).context("parse HEAD sha")?;
    let tip_oid = gix::ObjectId::from_hex(tip.as_bytes()).context("parse tip sha")?;
    let tree = repo
        .find_commit(tip_oid)
        .context("find ramp tip commit")?
        .tree_id()
        .context("ramp tip tree")?
        .detach();
    let (name, email) = commit_identity(&repo);
    let time = git_time_now();
    let sig = gix::actor::SignatureRef {
        name: gix::bstr::BStr::new(name.as_bytes()),
        email: gix::bstr::BStr::new(email.as_bytes()),
        time: &time,
    };
    let id = repo
        .commit_as(sig, sig, "HEAD", message, tree, vec![head_oid, tip_oid])
        .context("create merge commit")?;
    gitio::set_head_and_checkout(root, &cur, &id.to_string())?;
    Ok(id.to_string())
}

// ─── local config ─────────────────────────────────────────────────────────────────

/// Read a local git config value — `git config --local --get <key>` — trimmed.
/// `Ok(None)` when unset.
pub fn read_local_config(root: &Path, key: &str) -> Result<Option<String>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let cfg = repo.config_snapshot();
    Ok(cfg.string(key).map(|b| b.to_string()).filter(|s| !s.is_empty()))
}

/// Set a local git config value — `git config --local <key> <value>` — persisted
/// to the repo's `.git/config`.
pub fn set_local_config(root: &Path, key: &str, value: &str) -> Result<()> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let (section, name) = key
        .rsplit_once('.')
        .ok_or_else(|| anyhow!("config key `{key}` must be `section.name`"))?;

    // Read → overwrite the key → write back the repo's local `.git/config`.
    let local_path = repo.git_dir().join("config");
    let existing = std::fs::read(&local_path).unwrap_or_default();
    let mut disk = gix::config::File::from_bytes_no_includes(
        &existing,
        gix::config::file::Metadata::from(gix::config::Source::Local),
        Default::default(),
    )
    .unwrap_or_default();
    // Overwrite the key: drop any existing values under section.name, then set.
    // gix-config 0.59 (via gix 0.86) changed `push`: the value NAME is now taken as
    // `impl AsRef<str>` and validated internally, returning a `Result`. The old API
    // took a pre-parsed `ValueName` built with `try_into` and returned `&mut Self`.
    if let Ok(mut s) = disk.section_mut(section, None) {
        while s.remove(name).is_some() {}
        s.push(name, Some(gix::bstr::BStr::new(value.as_bytes())))
            .map_err(|e| anyhow!("invalid config key `{name}`: {e}"))?;
    } else {
        let mut s = disk
            .section_mut_or_create_new(section, None)
            .with_context(|| format!("config section `{section}`"))?;
        s.push(name, Some(gix::bstr::BStr::new(value.as_bytes())))
            .map_err(|e| anyhow!("invalid config key `{name}`: {e}"))?;
    }
    let mut out = Vec::new();
    disk.write_to(&mut out).context("serialize config")?;
    std::fs::write(&local_path, out).with_context(|| format!("write {}", local_path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::gitio;

    /// A repo with one commit containing `Cargo.toml` + `src/lib.rs`, on `main`.
    fn repo() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();
        gitio::init(&root).unwrap();
        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"0.1.0\"\n").unwrap();
        std::fs::create_dir_all(root.join("src")).unwrap();
        std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap();
        gitio::commit_all(&root, "init").unwrap();
        (dir, root)
    }

    #[test]
    fn resolve_and_branch_exists() {
        let (_d, root) = repo();
        let head = gitio::head_sha(&root).unwrap();
        assert_eq!(resolve_ref(&root, "HEAD").unwrap().as_deref(), Some(head.as_str()));
        assert!(resolve_ref(&root, "does-not-exist").unwrap().is_none());
        let branch = gitio::head_branch(&root).unwrap();
        assert!(branch_exists(&root, &branch).unwrap());
        assert!(!branch_exists(&root, "nope").unwrap());
    }

    #[test]
    fn commit_paths_stages_only_matched_and_skips_empty() {
        let (_d, root) = repo();
        // Edit the manifest AND an unrelated file.
        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"0.2.0\"\n").unwrap();
        std::fs::write(root.join("src/lib.rs"), "// changed\n").unwrap();
        let head0 = gitio::head_sha(&root).unwrap();
        let sha = commit_paths(&root, &["*Cargo.toml"], "bump", false).unwrap();
        assert!(sha.is_some(), "manifest change committed");
        let head1 = gitio::head_sha(&root).unwrap();
        assert_ne!(head0, head1);
        // The committed tree carries the new manifest but NOT the lib.rs edit.
        let repo = gix::open(&root).unwrap();
        let tree = repo.head_commit().unwrap().tree().unwrap();
        let manifest = tree.lookup_entry_by_path(Path::new("Cargo.toml")).unwrap().unwrap();
        let blob = manifest.object().unwrap();
        assert!(String::from_utf8_lossy(&blob.data).contains("0.2.0"));
        let lib = tree.lookup_entry_by_path(Path::new("src/lib.rs")).unwrap().unwrap();
        assert_eq!(&lib.object().unwrap().data[..], b"// lib\n", "lib.rs NOT staged");
        // A second commit with nothing matching changed ⇒ None.
        assert!(commit_paths(&root, &["*Cargo.toml"], "noop", false).unwrap().is_none());
    }

    #[test]
    fn branch_create_reset_delete_and_checkout() {
        let (_d, root) = repo();
        let head = gitio::head_sha(&root).unwrap();
        create_or_reset_branch_at_head(&root, "release/x").unwrap();
        assert_eq!(gitio::head_branch(&root).unwrap(), "release/x");
        assert_eq!(resolve_ref(&root, "refs/heads/release/x").unwrap().unwrap(), head);
        // Switch back to main, delete the ephemeral branch.
        checkout_branch(&root, "main").unwrap();
        assert_eq!(gitio::head_branch(&root).unwrap(), "main");
        delete_branch(&root, "release/x").unwrap();
        assert!(!branch_exists(&root, "release/x").unwrap());
        delete_branch(&root, "release/x").unwrap(); // idempotent
    }

    /// The gh-pages publish core: an orphan `pages` branch whose tree is a built
    /// output dir, built in-process (no worktree/checkout). Asserts (a) the branch
    /// commit has NO parents, (b) its tree is exactly the dir's contents, (c) HEAD
    /// and the working tree are untouched, and (d) a re-run rebuilds the tip
    /// wholesale (the squash) rather than failing on the existing branch.
    #[test]
    fn commit_dir_as_orphan_branch_squashes_a_dist_dir_without_touching_head() {
        let (_d, root) = repo();
        let head_before = gitio::head_sha(&root).unwrap();
        let branch_before = gitio::head_branch(&root).unwrap();

        // A built "dist/" laid down outside the repo tree.
        let dist = _d.path().join("dist");
        std::fs::create_dir_all(dist.join("assets")).unwrap();
        std::fs::write(dist.join("index.html"), "<h1>v1</h1>").unwrap();
        std::fs::write(dist.join("assets/app.js"), "console.log(1)").unwrap();

        let sha = commit_dir_as_orphan_branch(&root, &dist, "pages", "deploy v1").unwrap();

        // The branch exists at the returned commit, which is an ORPHAN (no parents).
        assert_eq!(resolve_ref(&root, "refs/heads/pages").unwrap().unwrap(), sha);
        let repo = gix::open(&root).unwrap();
        let commit = repo.find_commit(gix::ObjectId::from_hex(sha.as_bytes()).unwrap()).unwrap();
        assert_eq!(commit.parent_ids().count(), 0, "orphan: no parents");
        // Its tree is exactly dist/ (nested path included).
        let tree = commit.tree().unwrap();
        let idx = tree.lookup_entry_by_path(Path::new("index.html")).unwrap().unwrap();
        assert_eq!(&idx.object().unwrap().data[..], b"<h1>v1</h1>");
        assert!(tree.lookup_entry_by_path(Path::new("assets/app.js")).unwrap().is_some());

        // HEAD + working tree untouched (we never checked pages out).
        assert_eq!(gitio::head_sha(&root).unwrap(), head_before, "HEAD did not move");
        assert_eq!(gitio::head_branch(&root).unwrap(), branch_before, "still on the original branch");

        // Re-run with new content squashes the tip (no non-ff failure on the existing branch).
        std::fs::write(dist.join("index.html"), "<h1>v2</h1>").unwrap();
        let sha2 = commit_dir_as_orphan_branch(&root, &dist, "pages", "deploy v2").unwrap();
        assert_ne!(sha, sha2, "the tip was rebuilt");
        let repo2 = gix::open(&root).unwrap();
        let commit2 = repo2.find_commit(gix::ObjectId::from_hex(sha2.as_bytes()).unwrap()).unwrap();
        assert_eq!(commit2.parent_ids().count(), 0, "still an orphan after re-run");
        let idx2 = commit2.tree().unwrap().lookup_entry_by_path(Path::new("index.html")).unwrap().unwrap();
        assert_eq!(&idx2.object().unwrap().data[..], b"<h1>v2</h1>");
    }

    #[test]
    fn ancestor_ff_merge_and_tags() {
        let (_d, root) = repo();
        let base = gitio::head_sha(&root).unwrap();
        // Cut a ramp branch and advance it by one commit.
        create_or_reset_branch_at_head(&root, "ramp").unwrap();
        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"0.3.0\"\n").unwrap();
        let tip = commit_paths(&root, &["*Cargo.toml"], "bump on ramp", false).unwrap().unwrap();
        assert!(is_ancestor(&root, &base, &tip).unwrap());
        assert!(!is_ancestor(&root, &tip, &base).unwrap());
        assert_eq!(count_commits_ahead(&root, "main", "ramp").unwrap(), 1);
        // Tag the tip, confirm tags_at_head sees it.
        gitio::tag_lightweight(&root, "v0.3.0", &tip).unwrap();
        assert_eq!(tags_at_head(&root).unwrap(), vec!["v0.3.0".to_string()]);
        // Fast-forward main to the ramp tip.
        checkout_branch(&root, "main").unwrap();
        let merged = merge_ff_only(&root, "ramp").unwrap();
        assert_eq!(merged, tip);
        assert_eq!(gitio::head_sha(&root).unwrap(), tip);
        assert_eq!(gitio::head_branch(&root).unwrap(), "main");
    }

    #[test]
    fn no_ff_merge_creates_merge_commit() {
        let (_d, root) = repo();
        create_or_reset_branch_at_head(&root, "ramp").unwrap();
        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"0.4.0\"\n").unwrap();
        let tip = commit_paths(&root, &["*Cargo.toml"], "bump", false).unwrap().unwrap();
        checkout_branch(&root, "main").unwrap();
        let merge = merge_into_head(&root, "ramp", "Release: merge ramp").unwrap();
        assert_ne!(merge, tip, "a distinct merge commit");
        let repo = gix::open(&root).unwrap();
        let parents: Vec<_> =
            repo.head_commit().unwrap().parent_ids().map(|p| p.detach().to_string()).collect();
        assert_eq!(parents.len(), 2, "merge commit has two parents");
        assert!(parents.contains(&tip));
    }

    #[test]
    fn checkout_between_divergent_trees_leaves_clean_index() {
        // REGRESSION: switching to a branch whose tree differs from the current one must
        // leave the working tree CLEAN (index rebuilt from the checked-out tree) — else a
        // subsequent status reports the whole tree as spurious dirt.
        let (_d, root) = repo();
        create_or_reset_branch_at_head(&root, "ramp").unwrap();
        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"0.5.0\"\n").unwrap();
        std::fs::write(root.join("NEWFILE.rs"), "// added on ramp\n").unwrap();
        commit_paths(&root, &["*Cargo.toml", "NEWFILE.rs"], "ramp work", false).unwrap();
        assert!(!is_dirty(&root).unwrap(), "clean after committing on ramp");
        // Switch back to main (a DIFFERENT tree — no NEWFILE.rs, older manifest).
        checkout_branch(&root, "main").unwrap();
        assert!(!is_dirty(&root).unwrap(), "clean immediately after checkout to main");
        assert!(!root.join("NEWFILE.rs").exists(), "ramp-only file gone on main");
        // And back to ramp.
        checkout_branch(&root, "ramp").unwrap();
        assert!(!is_dirty(&root).unwrap(), "clean immediately after checkout to ramp");
        assert!(root.join("NEWFILE.rs").exists(), "ramp file restored");
    }

    #[test]
    fn restore_path_from_head_reverts_worktree_file() {
        let (_d, root) = repo();
        std::fs::write(root.join("Cargo.toml"), "GARBAGE").unwrap();
        restore_path_from_head(&root, "Cargo.toml").unwrap();
        assert_eq!(
            std::fs::read_to_string(root.join("Cargo.toml")).unwrap(),
            "[package]\nversion = \"0.1.0\"\n"
        );
        restore_path_from_head(&root, "not/tracked.txt").unwrap(); // no-op, no error
    }

    #[test]
    fn update_ref_and_detach() {
        let (_d, root) = repo();
        let head = gitio::head_sha(&root).unwrap();
        create_or_reset_branch_at_head(&root, "tmp").unwrap();
        std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"0.9.0\"\n").unwrap();
        commit_paths(&root, &["*Cargo.toml"], "bump", false).unwrap();
        update_ref(&root, "refs/heads/tmp", &head).unwrap();
        assert_eq!(resolve_ref(&root, "refs/heads/tmp").unwrap().unwrap(), head);
        checkout_detach(&root, Some("main")).unwrap();
        assert_eq!(gitio::head_branch(&root).unwrap(), "(detached)");
    }

    #[test]
    fn local_config_round_trips() {
        let (_d, root) = repo();
        assert!(read_local_config(&root, "core.hooksPath").unwrap().is_none());
        set_local_config(&root, "core.hooksPath", ".githooks").unwrap();
        assert_eq!(read_local_config(&root, "core.hooksPath").unwrap().as_deref(), Some(".githooks"));
        // Overwrite, not append.
        set_local_config(&root, "core.hooksPath", ".other").unwrap();
        assert_eq!(read_local_config(&root, "core.hooksPath").unwrap().as_deref(), Some(".other"));
    }

    #[test]
    fn stage_paths_marks_index_and_accumulates() {
        let (_d, root) = repo();
        std::fs::write(root.join("A.md"), "a\n").unwrap();
        std::fs::write(root.join("B.md"), "b\n").unwrap();
        // Two SEPARATE calls (the gate.rs per-file loop) must ACCUMULATE, not reset.
        assert_eq!(stage_paths(&root, &["A.md"]).unwrap(), vec!["A.md".to_string()]);
        assert_eq!(stage_paths(&root, &["B.md"]).unwrap(), vec!["B.md".to_string()]);
        let changed = gitio::worktree_changed_paths(&root).unwrap();
        assert!(changed.contains(&"A.md".to_string()), "first stage preserved: {changed:?}");
        assert!(changed.contains(&"B.md".to_string()), "second stage present: {changed:?}");
    }

    #[test]
    fn is_dirty_and_short_sha() {
        let (_d, root) = repo();
        assert!(!is_dirty(&root).unwrap());
        std::fs::write(root.join("Cargo.toml"), "dirty").unwrap();
        assert!(is_dirty(&root).unwrap());
        assert_eq!(short_head_sha(&root).unwrap().len(), 7);
    }

    #[test]
    fn list_worktrees_includes_main() {
        let (_d, root) = repo();
        let wts = list_worktrees(&root).unwrap();
        assert!(wts.iter().any(|(p, _)| p == &root || root.starts_with(p) || p.starts_with(&root)));
    }
}