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)));
}
}