//! The git commands that exist to build a pull request's payload. //! //! A Tangled pull carries its patches in the record, so `pr create`, //! `pr resubmit` and `stack create` all have to turn a commit range into //! `git format-patch` output before anything is written. What is here is that //! turning: the ranges, the patch text, the change-ids that let a rewritten //! commit still match the pull it belongs to, and the rewrite that adds them. //! //! Separate from [`super::run`] because these are the only git calls whose //! output is a *document* rather than an answer — everything here is destined //! for a blob in somebody's PDS. use super::run::{Run, current_branch, git}; use anyhow::{Result, bail}; /// The subject of the *oldest* commit in `base..HEAD` — the bottom of the /// branch, not its tip. /// /// What a pull request is about is what its first commit says. The ones above /// it are usually the finishing: a test, a fixup, the register entry. Naming a /// branch after its tip named it after the least descriptive thing on it, and /// did so most reliably for the branches with the most care in them — three /// pull requests opened against this repo in one day were titled after their /// `docs(plan): …` commit and renamed by hand afterwards. /// /// `stack create` has always done it this way: "a member is titled and /// described by its bottom commit". This is `pr create` agreeing with the verb /// beside it rather than a new rule. /// /// An empty range yields an empty string, which the one caller has already /// refused by the time it asks — it counts the commits first. pub fn bottom_subject(base: &str) -> Result { let subjects = git(&["log", "--reverse", "--format=%s", &format!("{base}..HEAD")])?; Ok(subjects .lines() .next() .unwrap_or_default() .trim() .to_string()) } pub fn commit_count(base: &str) -> Result { let n = git(&["rev-list", "--count", &format!("{base}..HEAD")])?; Ok(n.parse()?) } /// Text-based git-format-patch of everything on HEAD that isn't on `base`, /// in the shape a pull round's patchBlob expects (before gzip). pub fn format_patch(base: &str) -> Result { git(&["format-patch", "--stdout", &format!("{base}..HEAD")]) } /// The commits `base..HEAD` would carry, oldest first. /// /// Oldest first because that is the order `stack create` writes in: a pull /// for each commit, each `dependentOn` the pull of the commit before it, so /// the first sha here becomes the bottom of the stack. pub fn commits_since(base: &str) -> Result> { let out = git(&["rev-list", "--reverse", &format!("{base}..HEAD")])?; Ok(out.lines().map(str::to_string).collect()) } /// The local branches whose tip is one of `commits`, as (commit, branch). /// /// Asked by the change-id rewrite, and only by it: rewriting `base..HEAD` /// gives every commit from the first missing id upward a new sha, and a ref /// left pointing at the old one is a ref pointing into history the branch no /// longer has. So they all travel, which is what `git rebase --update-refs` /// does for a rebase — every ref in the range, not only the ones a stack is /// cut at, because a `backup` branch orphaned by a rewrite is just as lost. /// /// `refs/heads` only: a remote-tracking ref is where a branch *was* on the /// server, and this rewrite has not been pushed anywhere. `except` is the /// branch itself, which is moved separately and last. pub fn branch_tips_in(commits: &[String], except: &str) -> Result> { let out = git(&[ "for-each-ref", "--format=%(objectname) %(refname:short)", "refs/heads", ])?; let in_range: std::collections::HashSet<&str> = commits.iter().map(String::as_str).collect(); let mut tips: Vec<(String, String)> = out .lines() .filter_map(|line| line.split_once(' ')) .filter(|(sha, name)| in_range.contains(sha) && *name != except) .map(|(sha, name)| (sha.to_string(), name.to_string())) .collect(); tips.sort(); Ok(tips) } /// Whether `rebase.updateRefs` is on. /// /// Asked only to say something when it is not: with branch tips marking a /// stack's members, a plain `git rebase` leaves every one of them behind on /// commits the branch no longer has, and the next reconcile sees a stack /// with no cuts in it. git has moved them since 2.38, but only when told. pub fn rebase_updates_refs() -> bool { matches!( git(&["config", "--get", "--type=bool", "rebase.updateRefs"]).as_deref(), Ok("true") ) } /// The merge commits in `base..HEAD`, oldest first. /// /// Asked before anything stack-shaped runs, because `stack create` opens one /// pull per commit on a straight line and a merge is not on one: `rev-list` /// would hand back both parents' histories interleaved, `format-patch` produces /// no usable patch for the merge itself, and the change-id rewrite would /// silently flatten it. Every stack write refuses the range instead. pub fn merges_since(base: &str) -> Result> { let out = git(&[ "rev-list", "--reverse", "--merges", &format!("{base}..HEAD"), ])?; Ok(out.lines().map(str::to_string).collect()) } /// Text-based git-format-patch of exactly one commit, in the shape a /// stacked pull round's patchBlob expects (before gzip). pub fn format_patch_one(commit: &str) -> Result { git(&["format-patch", "--stdout", "-1", commit]) } /// The mailbox for a run of commits, `first` through `last` inclusive, /// oldest message first — one stack member that holds more than one commit. /// /// `first^..last` rather than a count, so the range is named by its ends and /// a miscount cannot quietly carry a neighbour's commit. A `first` with no /// parent is the repository's root commit, which has no `^` to name: git is /// asked for the same range with `--root` instead of being handed an error /// about a bad revision. pub fn format_patch_range(first: &str, last: &str) -> Result { match git(&[ "rev-parse", "--verify", "--quiet", &format!("{first}^{{commit}}^"), ]) { Ok(_) => git(&["format-patch", "--stdout", &format!("{first}^..{last}")]), Err(_) => git(&["format-patch", "--stdout", "--root", last]), } } /// Where each message of a format-patch mailbox begins. /// /// `git format-patch` separates messages with the mbox `From Mon Sep /// 17 00:00:00 2001` line and nothing else, so that line — at the very start /// of the text, or straight after a newline — is the only boundary there is. /// Splitting on it is what lets a member of several commits carry a header /// per commit rather than one for the whole mailbox. /// /// Here rather than beside the stack code that first needed it because the /// same boundary carries the sha [`head_sha`] reads, and that is a `pr` /// question as much as a stack one. pub fn message_offsets(patch: &str) -> Vec { let mut offsets = Vec::new(); let mut at = 0; while let Some(found) = patch[at..].find("From ") { let start = at + found; let line_start = start == 0 || patch.as_bytes()[start - 1] == b'\n'; if line_start && patch[start..].contains("Mon Sep 17 00:00:00 2001") { let line_end = patch[start..] .find('\n') .map(|n| start + n) .unwrap_or(patch.len()); if patch[start..line_end].ends_with("Mon Sep 17 00:00:00 2001") { offsets.push(start); } } at = start + 5; } offsets } /// The sha of the last commit in a mailbox: the branch head the round that /// carries it was cut from. /// /// The `From ` boundary line is the commit's real hash — `git /// format-patch` writes it there, and the knot's own patches come from the /// same command with no `--zero-commit` anywhere /// (`knotserver/git/diff.go`), which is what lets a round written by the web /// answer this as well as one written here. Messages are oldest first in /// every mailbox either produces, so the last one is the tip. /// /// `None` rather than a guess for anything that is not a 40-character hex /// sha: this answer becomes a `--force-with-lease` expectation, and leasing /// against a value read out of a malformed patch would be a force in all but /// name. A branch whose tip is a *merge* also lands here holding the wrong /// answer — format-patch omits merges — which is why the stack commands /// refuse a range containing one before they ever ask. /// Every commit a mailbox carries, oldest first, by the sha on its `From` /// boundary. /// /// The set a patch would *apply*, which is the question two patches have to /// be compared on before they can be members of one stack: a merge takes a /// member plus everything unmerged below it as one series, so two patches /// holding the same commit apply it twice and the second application fails /// on a tree that already has it. /// /// Only well-formed 40-character hex, for [`head_sha`]'s reason — an /// anonymized or malformed boundary is not evidence of which commit this is, /// and a comparison drawn from one would be a guess. A message whose sha /// cannot be read is skipped rather than matched, which errs toward /// *allowing* a link: refusing on something unreadable would block a stack /// over bytes nobody can interpret. pub fn commit_shas(patch: &str) -> Vec { message_offsets(patch) .into_iter() .filter_map(|at| { let sha = patch[at..] .lines() .next()? .strip_prefix("From ")? .split(' ') .next()?; let hex = sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()); let null = sha.chars().all(|c| c == '0'); (hex && !null).then(|| sha.to_string()) }) .collect() } pub fn head_sha(patch: &str) -> Option { let last = *message_offsets(patch).last()?; let line = patch[last..].lines().next()?; let sha = line.strip_prefix("From ")?.split(' ').next()?; let hex = sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()); // The null OID is git's word for "no such object", and it is what tools // that anonymize a mailbox (`format-patch --zero-commit`) leave behind. // As a lease it would mean "only if this branch does not exist", which // is the opposite of what a round is asking for. let null = sha.chars().all(|c| c == '0'); (hex && !null).then(|| sha.to_string()) } /// The commit's change-id, if it carries one. `None` is "not set up for /// stacking", which the stack commands answer with advice, not an error. /// /// Two spellings, most authoritative first. jj (0.29+, with /// `write-change-id-header = true`) writes a `change-id` header into the /// commit object itself, where it survives every rewrite jj performs. Plain /// git has no porcelain for that header, so Gerrit-style tooling carries /// the same fact as a `Change-Id:` trailer in the message, which an amend /// preserves. The header wins when both are present — it is the one jj /// maintains — and the last trailer wins among several, which is Gerrit's /// own rule. /// /// Tangled reads the id off the *patch*, not the commit, so whatever is /// found here still has to be put on the wire by the code that builds a /// round's patch. pub fn change_id(commit: &str) -> Result> { let raw = git(&["cat-file", "commit", commit])?; // Headers end at the first blank line; continuation lines start with a // space, so a message mentioning "change-id" cannot match here. let headers = raw.split("\n\n").next().unwrap_or(""); for line in headers.lines() { if let Some(value) = line.strip_prefix("change-id ") { let value = value.trim(); if !value.is_empty() { return Ok(Some(value.to_string())); } } } let trailers = git(&[ "log", "-1", "--format=%(trailers:key=Change-Id,valueonly)", commit, ])?; Ok(trailers .lines() .map(str::trim) .rfind(|line| !line.is_empty()) .map(str::to_string)) } /// Whether the working tree and index have no changes — asked before any /// operation that rewrites the branch, because rewriting under somebody's /// uncommitted work is how that work gets attributed to the wrong commit. pub fn working_tree_clean() -> Result { Ok(git(&["status", "--porcelain"])?.is_empty()) } /// Run git with stdin and extra environment, capturing stdout. The private /// general case behind [`rewrite_with_change_ids`], which needs both: a /// commit message on stdin and the original author on the environment. /// /// Built on [`super::run::Run`] rather than on a `Command` of its own, which /// is what keeps the two commits this rewrite writes inside the reach of /// [`crate::logging::git`]. The stdin is a commit message and is recorded /// only as a length; the author on the environment is recorded only as the /// names of the variables. fn git_stdin_env(args: &[&str], stdin: &str, envs: &[(&str, &str)]) -> Result { let out = Run::new(args).env(envs).stdin(stdin.as_bytes()).run()?; if !out.status.success() { bail!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8(out.stdout)?.trim().to_string()) } /// A change-id rewrite that has already happened, and the way back from it. /// /// Returned rather than merely counted because the count is only useful in /// the success line, and the sha is only useful in every other line. A /// rewrite moves `refs/heads/` and there is no second copy of the /// commits it replaced: anything that fails after it — a refused session, a /// blob upload, the batch itself — leaves somebody holding shas they did not /// ask for and no statement of where the old ones went. `git reflog` has /// them, but naming the reflog is not the same as naming the entry, and the /// caller that performed the rewrite is the only code that still knows it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Rewrite { pub branch: String, /// Where `refs/heads/` pointed before the rewrite moved it. pub was: String, /// Where it points now. Recorded so [`Rewrite::undo`] can tell a branch /// this rewrite is still responsible for from one something else has /// moved since — a second worktree, a `git commit`, a rebase — and /// decline to clobber the second. pub now: String, /// How many commits gained a `Change-Id:` trailer. pub added: usize, } impl Rewrite { /// The sentence every failure below the rewrite ends up carrying. /// /// Written to read as a prefix, because that is where `anyhow`'s /// single-line form puts a context: "the branch was rewritten … : the /// thing that failed". Same shape, and the same reason, as /// [`crate::cmd::repo`]'s note about what a half-finished delete leaves /// behind. /// Put the branch back where it was, for a command that rewrote it and /// then failed. /// /// **This is what makes the rewrite atomic with the command around it,** /// and it is why "settle everything that can refuse before mutating /// anything" no longer has to be remembered by every verb. That rule was /// stated in prose, fixed in `stack create`, and then broken again in /// `stack resubmit` ten days later; the list of steps that can fail is /// not a list anybody keeps current. /// /// Safe to do bluntly because of what the rewrite is: `git commit-tree` /// reusing every tree byte-for-byte, so `was` and `now` have identical /// trees and only messages and parentage differ. Moving the ref back /// therefore cannot conflict, cannot touch the working tree or the /// index, and cannot lose work — unlike the `git reset --hard` this used /// to tell people to run themselves. /// /// Declines when the branch is no longer where the rewrite left it: /// something else moved it, and the commits on it now are not this /// command's to discard. The caller falls back to naming the reflog. pub fn undo(&self) -> Result { let at = git(&["rev-parse", &format!("refs/heads/{}", self.branch)])? .trim() .to_string(); if at != self.now { crate::logging::debug::log(format!( "not undoing the rewrite of {}: it is at {at}, not the {} this left it at", self.branch, self.now )); return Ok(false); } git(&[ "update-ref", "-m", "atgc: put the branch back after a failed stack write", &format!("refs/heads/{}", self.branch), &self.was, ])?; Ok(true) } /// What a failure says when the branch was put back: the rewrite is not /// something the reader has to act on any more, only something that /// happened and was undone. pub fn restored(&self) -> String { format!( "{} was rewritten to add {} Change-Id trailer(s) and has been put back to {} \ because this failed", self.branch, self.added, &self.was[..12.min(self.was.len())], ) } pub fn recovery(&self) -> String { format!( "{} was rewritten before this failed: `git reset --hard {}` puts it back, \ and `git reflog {}` has the entry", self.branch, self.was, self.branch, ) } } /// Rewrite `base..HEAD` so every commit carries a change-id. `None` is /// "every commit already had one", so nothing was written and no ref moved. /// /// The rewrite is `git commit-tree`, not a rebase: every tree is reused /// byte-for-byte, so the working tree and index do not move and nothing can /// conflict — only messages and parentage change. Commits before the first /// one lacking an id keep their shas (and, for jj users, their `change-id` /// commit headers, which `commit-tree` cannot write and would otherwise /// drop); from there on, a commit that already has an id keeps it, restated /// as a `Change-Id:` trailer through git's own trailer machinery, and a /// commit that has none gains `Change-Id: I` — unique, /// and stable from then on, which is all a change-id has to be. /// /// The branch ref is moved with a reflog message naming atgc, so /// `git reflog` shows exactly what happened and `git reset` undoes it. /// /// # Why `base` must already be an ancestor /// /// Reusing the trees is what makes this safe *and* what makes it wrong on a /// branch that has fallen behind. `commit-tree` writes a commit with the /// tree it is given and the parent it is named, and asks no questions about /// whether the two are related. When `base` is the commit the branch was /// actually built on, the new parent is the old parent and every rewritten /// commit has exactly the diff it had before. When `base` has moved ahead — /// somebody else landed something — the trees still describe the *old* /// base, so each rewritten commit's diff against its new parent silently /// grows a deletion of everything the branch never saw. /// /// That is not theoretical. Reported by an agent: four commits rewritten /// against a moved `origin/main` came out carrying /// `src/exit.rs | 298 ----------------------------------`, a file none of /// them touched and the newer main had added. Opening that stack would have /// proposed reverting it, and the only visible sign was the bottom patch /// coming out five times the size the dry run had printed a minute earlier. /// /// So this refuses instead, and names the rebase that fixes it. A real /// `git rebase` replays the *diffs* and keeps the file; only tree-preserving /// reparenting can drop it, which is why the check belongs here rather than /// in either caller. pub fn rewrite_with_change_ids(base: &str) -> Result> { let branch = current_branch()?; if !crate::clients::git::run::is_ancestor(base, "HEAD") { bail!( "{branch} is not built on {base} any more, so adding change-ids here would \ rewrite every commit onto a base its tree does not know\n\ the patches would carry a deletion of everything {base} has gained since\n\ rebase first (`git fetch && git rebase {base}`), then rerun" ); } let commits = commits_since(base)?; // The marks a stack is cut at sit on commits in this range, and every // one from the first missing id upward is about to get a new sha. Left // where they are, they point at commits the branch no longer has, and // the cut they described silently becomes no cut at all — a stack of // one pull per commit, written without a word about it. So they are // read before the rewrite and moved with it, which is what // `git rebase --update-refs` does for a rebase. let marks = branch_tips_in(&commits, &branch)?; // Everything up to the first commit without an id is left untouched. let mut first_missing = None; for (i, sha) in commits.iter().enumerate() { if change_id(sha)?.is_none() { first_missing = Some(i); break; } } let Some(first_missing) = first_missing else { return Ok(None); }; // Read before anything is written, because after the ref moves nothing // in this process can name the old tip again. let was = git(&["rev-parse", &format!("refs/heads/{branch}")])?; let mut parent = match first_missing { 0 => git(&["rev-parse", &format!("{base}^{{commit}}")])?, n => commits[n - 1].clone(), }; let mut added = 0; let mut moved: Vec<(String, String)> = Vec::new(); for sha in &commits[first_missing..] { let tree = git(&["rev-parse", &format!("{sha}^{{tree}}")])?; let author = git(&["log", "-1", "--format=%an%x00%ae%x00%aD", sha])?; let mut parts = author.split('\0'); let (name, email, date) = ( parts.next().unwrap_or_default(), parts.next().unwrap_or_default(), parts.next().unwrap_or_default(), ); let message = git(&["log", "-1", "--format=%B", sha])?; let trailer = match change_id(sha)? { Some(id) => format!("Change-Id: {id}"), None => { added += 1; format!("Change-Id: I{sha}") } }; let message = git_stdin_env( &[ "interpret-trailers", "--if-exists", "doNothing", "--trailer", &trailer, ], &message, &[], )?; parent = git_stdin_env( &["commit-tree", &tree, "-p", &parent], &message, &[ ("GIT_AUTHOR_NAME", name), ("GIT_AUTHOR_EMAIL", email), ("GIT_AUTHOR_DATE", date), ], )?; for (tip, name) in &marks { if tip == sha { moved.push((name.clone(), parent.clone())); } } } // Before the branch ref, so a failure here cannot leave the branch // rewritten with its marks still on the old commits — the one order in // which a half-done rewrite is still legible. for (name, to) in &moved { git(&[ "update-ref", "-m", "atgc: move stack mark onto the rewritten commit", &format!("refs/heads/{name}"), to, ])?; } git(&[ "update-ref", "-m", "atgc stack create: add Change-Id trailers", &format!("refs/heads/{branch}"), &parent, ])?; Ok(Some(Rewrite { branch, was, now: parent, added, })) } #[cfg(test)] mod tests { use super::*; use crate::testutil::TempRepo; /// Putting a branch back is a ref move and nothing else: `commit-tree` /// reused every tree, so the working tree and index never saw the /// rewrite and do not see the undo either. #[test] fn an_undo_returns_the_branch_to_where_the_rewrite_found_it() { let repo = TempRepo::new("rewrite-undo"); repo.commit("one.txt", "one\n", "feat: one"); let was = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); // The same tree under a new message and a new sha, which is what the // rewrite does: `commit-tree` reuses the tree byte-for-byte and only // the message and parentage change. A fixture that committed a *file* // here would leave the working tree disagreeing with `was` and prove // nothing about the real case. repo.git(&[ "commit", "-q", "--amend", "-m", "feat: one\n\nChange-Id: Iabc", ]); let now = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); assert_ne!(was, now, "the amend did not move the branch"); assert_eq!( repo.git(&["rev-parse", &format!("{was}^{{tree}}")]).trim(), repo.git(&["rev-parse", &format!("{now}^{{tree}}")]).trim(), "the fixture does not model a rewrite: the trees differ" ); let branch = repo .git(&["rev-parse", "--abbrev-ref", "HEAD"]) .trim() .to_string(); let rewrite = Rewrite { branch: branch.clone(), was: was.clone(), now: now.clone(), added: 1, }; assert!(rewrite.undo().expect("the undo runs"), "it declined"); assert_eq!(repo.git(&["rev-parse", "HEAD"]).trim(), was); // Nothing staged and nothing modified: the ref moved and the tree // did not. assert_eq!(repo.git(&["status", "--porcelain"]).trim(), ""); } /// **The one case where the old shas are not this command's to discard.** /// Something else moved the branch between the rewrite and the failure — /// another worktree, a commit, a rebase — so the undo declines and the /// caller falls back to naming the reflog. #[test] fn an_undo_declines_a_branch_that_moved_under_it() { let repo = TempRepo::new("rewrite-undo-declines"); repo.commit("one.txt", "one\n", "feat: one"); let was = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); repo.commit("two.txt", "two\n", "feat: two"); let branch = repo .git(&["rev-parse", "--abbrev-ref", "HEAD"]) .trim() .to_string(); // The rewrite believes it left the branch somewhere it no longer is. let rewrite = Rewrite { branch, was, now: "0000000000000000000000000000000000000000".to_string(), added: 1, }; let at = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); assert!( !rewrite.undo().expect("the undo runs"), "it clobbered a moved branch" ); assert_eq!( repo.git(&["rev-parse", "HEAD"]).trim(), at, "the ref moved anyway" ); } /// A pull is named after the commit it starts with, not the one it ends /// with. /// /// The tip of a branch is usually its least descriptive commit — a /// fixup, a test, the register entry — so titling by it named the branch /// after the finishing rather than the work. `stack create` already /// titles a member by its bottom commit; this is the flat verb agreeing. #[test] fn a_pull_is_named_after_the_commit_it_starts_with() { let repo = TempRepo::new("subject"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&["checkout", "-q", "-b", "claude/testing"]); repo.commit( "b.txt", "two\n", "Distinguish an empty list from a stale index", ); repo.commit("c.txt", "three\n", "docs(plan): record what landed"); assert_eq!( bottom_subject("origin/main").unwrap(), "Distinguish an empty list from a stale index", "the tip is the register entry; the branch is about the commit below it" ); } /// One commit is both the bottom and the tip, which is the ordinary case /// and must not be a special one. #[test] fn a_single_commit_branch_is_named_after_that_commit() { let repo = TempRepo::new("subject-one"); repo.commit("a.txt", "one\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&["checkout", "-q", "-b", "claude/testing"]); repo.commit("b.txt", "two\n", "fix(pr): the only commit"); assert_eq!( bottom_subject("origin/main").unwrap(), "fix(pr): the only commit" ); } /// The three things `pr create` does before it will send anything: /// count what is new, refuse an empty range, and build the patch. #[test] fn counts_and_formats_the_commits_a_pr_would_carry() { let repo = TempRepo::new("patch"); repo.commit("a.txt", "base\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&["checkout", "-q", "-b", "claude/testing"]); // Nothing on the branch yet: this is the "no commits" bail. assert_eq!(commit_count("origin/main").unwrap(), 0); assert!(format_patch("origin/main").unwrap().is_empty()); repo.commit("b.txt", "two\n", "Add b"); repo.commit("c.txt", "three\n", "Add c"); assert_eq!(commit_count("origin/main").unwrap(), 2); let patch = format_patch("origin/main").unwrap(); assert!( patch.starts_with("From "), "not a mailbox patch: {patch:.80}" ); assert_eq!(patch.matches("\nSubject: ").count(), 2, "one per commit"); assert!(patch.contains("Subject: [PATCH 1/2] Add b"), "{patch}"); assert!(patch.contains("Subject: [PATCH 2/2] Add c"), "{patch}"); // The diff itself, which is the part the knot applies. assert!(patch.contains("+++ b/b.txt"), "{patch}"); assert!(patch.contains("+three"), "{patch}"); } /// The lease a resubmit pushes with is only as good as this: the sha it /// reads has to be the branch's real tip, and anything that is not /// plainly a sha has to come back `None` rather than a guess — a bad /// expectation in a `--force-with-lease` is a force. #[test] fn reads_the_tip_sha_out_of_a_mailbox_or_nothing_at_all() { let repo = TempRepo::new("head-sha"); repo.commit("a.txt", "base\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.commit("b.txt", "two\n", "Add b"); repo.commit("c.txt", "three\n", "Add c"); let patch = format_patch("origin/main").unwrap(); let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); assert_eq!( head_sha(&patch).as_deref(), Some(tip.as_str()), "the last message of the mailbox is the branch head" ); // One commit is the one-message case, and the case every stack // member written before members could hold several commits is in. let one = format_patch_one("HEAD").unwrap(); assert_eq!(head_sha(&one).as_deref(), Some(tip.as_str())); assert_eq!(head_sha(""), None, "no mailbox, no answer"); assert_eq!( head_sha("diff --git a/a b/a\n"), None, "a bare diff is not one" ); assert_eq!( head_sha("From 0000 Mon Sep 17 00:00:00 2001\nSubject: [PATCH] x\n"), None, "a short sha is not a sha: leasing against it would be a force" ); assert_eq!( head_sha(&format!( "From {} Mon Sep 17 00:00:00 2001\nSubject: x\n", "0".repeat(40) )), None, "the null OID leases against the branch not existing" ); } /// A stack is cut out of this list bottom-up, so it has to come back /// oldest first — the reverse of `git log` — and each commit's /// patch has to carry that commit alone. #[test] fn lists_the_commits_a_stack_would_carry_oldest_first() { let repo = TempRepo::new("stack-commits"); repo.commit("a.txt", "base\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&["checkout", "-q", "-b", "claude/stack"]); assert!(commits_since("origin/main").unwrap().is_empty()); repo.commit("b.txt", "two\n", "Add b"); repo.commit("c.txt", "three\n", "Add c"); let commits = commits_since("origin/main").unwrap(); assert_eq!(commits.len(), 2); let subject = |sha: &str| repo.git(&["log", "-1", "--format=%s", sha]); assert_eq!(subject(&commits[0]), "Add b", "oldest first: the bottom"); assert_eq!(subject(&commits[1]), "Add c", "newest last: the top"); let patch = format_patch_one(&commits[0]).unwrap(); assert_eq!( patch.matches("\nSubject: ").count(), 1, "format_patch_one is the single-commit member's patch" ); assert!(patch.contains("Subject: [PATCH] Add b"), "{patch}"); assert!(patch.contains("+two"), "{patch}"); assert!( !patch.contains("Add c"), "the commit above must not leak in" ); } /// A merge in a range disqualifies it from stacking — rev-list would /// interleave both parents' histories — and this is the helper every /// stack write asks first. #[test] fn finds_the_merges_that_disqualify_a_range() { let repo = TempRepo::new("merges"); repo.commit("a.txt", "base\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&["checkout", "-q", "-b", "feature"]); repo.commit("b.txt", "two\n", "Add b"); assert!(merges_since("origin/main").unwrap().is_empty()); repo.git(&["checkout", "-q", "-b", "side", "origin/main"]); repo.commit("c.txt", "three\n", "Side c"); repo.git(&["checkout", "-q", "feature"]); repo.git(&["merge", "-q", "--no-ff", "-m", "Merge side", "side"]); let merges = merges_since("origin/main").unwrap(); assert_eq!(merges.len(), 1); assert_eq!( repo.git(&["log", "-1", "--format=%s", &merges[0]]), "Merge side" ); // And the linear helper shows why: the side branch's commit is in // the "stack" a merge range would produce. assert_eq!(commits_since("origin/main").unwrap().len(), 3); } /// The two spellings of a change-id, and their precedence. The jj header /// is built as a raw commit object because plain git has no porcelain /// that writes one — which is also why the trailer form exists at all. #[test] fn reads_a_change_id_from_trailer_or_jj_header() { let repo = TempRepo::new("change-id"); repo.commit("a.txt", "one\n", "Add a"); assert_eq!(change_id("HEAD").unwrap(), None, "no id is not an error"); // A Gerrit-style trailer, in its own paragraph as git defines one. repo.git(&[ "commit", "-q", "--amend", "-m", "Add a", "-m", "Change-Id: I0123abcd", ]); assert_eq!(change_id("HEAD").unwrap().as_deref(), Some("I0123abcd")); // A jj-style header on a commit that also carries a trailer: the // header is the one jj maintains across rewrites, so it wins. let tree = repo.git(&["rev-parse", "HEAD^{tree}"]); let parent = repo.git(&["rev-parse", "HEAD"]); let raw = format!( "tree {tree}\nparent {parent}\n\ author A U Thor 1700000000 +0000\n\ committer A U Thor 1700000000 +0000\n\ change-id kxxvtzsspnzmzqrnsvlqxpzslmnokwmo\n\n\ Add with header\n\nChange-Id: Itrailer\n" ); std::fs::write("commit.txt", raw).unwrap(); let sha = repo.git(&["hash-object", "-t", "commit", "-w", "commit.txt"]); assert_eq!( change_id(&sha).unwrap().as_deref(), Some("kxxvtzsspnzmzqrnsvlqxpzslmnokwmo"), "the header outranks the trailer" ); } /// The rewrite that backs `stack create --add-change-ids`. Trees, the /// working tree, authorship and existing ids must all survive it; the /// only things allowed to change are messages (gaining a trailer) and /// therefore shas. #[test] fn rewrites_the_branch_to_add_change_ids_and_nothing_else() { let repo = TempRepo::new("rewrite-ids"); // The rewrite below is production code running git for itself, so it // inherits nothing the fixture passes on git's command line. A real // checkout has this from `atgc repo configure`; without it here the // test passes or fails on whether whoever ran it has a global git // identity, which is a fact about the laptop and not about atgc. repo.git(&["config", "user.name", "atgc tests"]); repo.git(&["config", "user.email", "tests@example.invalid"]); repo.commit("a.txt", "base\n", "Add a"); repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); repo.git(&["checkout", "-q", "-b", "claude/stack"]); // Bottom commit already carries a trailer; the top two do not. repo.commit("b.txt", "two\n", "Add b"); repo.git(&[ "commit", "-q", "--amend", "-m", "Add b", "-m", "Change-Id: Ikeepme", ]); repo.commit("c.txt", "three\n", "Add c"); repo.commit("d.txt", "four\n", "Add d"); let before = commits_since("origin/main").unwrap(); let tip_before = repo.git(&["rev-parse", "HEAD"]); let trees_before: Vec = before .iter() .map(|sha| repo.git(&["rev-parse", &format!("{sha}^{{tree}}")])) .collect(); // A stack's cut marks sit on commits in the range, and the rewrite // gives those commits new shas. Left behind, the mark points at a // commit the branch no longer has and the cut it described is gone. repo.git(&["branch", "-f", "part1", "HEAD~1"]); let mark_before = repo.git(&["rev-parse", "part1"]); let rewrite = rewrite_with_change_ids("origin/main").unwrap().unwrap(); assert_eq!(rewrite.added, 2); // The tip it moved away from, which is the only thing that can put // the branch back and the only thing a failure below here can name. assert_eq!(rewrite.was, tip_before); assert_eq!(rewrite.branch, "claude/stack"); assert!(rewrite.recovery().contains(&tip_before), "{rewrite:?}"); let after = commits_since("origin/main").unwrap(); assert_eq!(after.len(), 3); // The mark moved with its commit: still the second-from-top, and no // longer where it was. let mark_after = repo.git(&["rev-parse", "part1"]); assert_ne!(mark_after, mark_before, "the mark was left on an old sha"); assert_eq!( mark_after, after[1], "the mark must land on the rewritten commit it marked" ); // The prefix with an id is untouched — same sha, so a jj header // would have survived too. assert_eq!(after[0], before[0], "commit with an id keeps its sha"); assert_ne!(after[1], before[1]); assert_eq!( change_id(&after[0]).unwrap().as_deref(), Some("Ikeepme"), "an existing id is kept, not replaced" ); // New ids come from the pre-rewrite shas: unique then, stable now. assert_eq!( change_id(&after[1]).unwrap().as_deref(), Some(format!("I{}", before[1]).as_str()) ); assert_eq!( change_id(&after[2]).unwrap().as_deref(), Some(format!("I{}", before[2]).as_str()) ); // Trees byte-for-byte, so the working tree had no reason to move. let trees_after: Vec = after .iter() .map(|sha| repo.git(&["rev-parse", &format!("{sha}^{{tree}}")])) .collect(); assert_eq!(trees_after, trees_before); assert!( working_tree_clean().unwrap(), "nothing to touch, nothing touched" ); // Authorship survives; only the message grew. assert_eq!( repo.git(&["log", "-1", "--format=%an", &after[2]]), repo.git(&["log", "-1", "--format=%an", &before[2]]), ); assert!( repo.git(&["log", "-1", "--format=%s", &after[2]]) == "Add d", "the subject is not the trailer's to change" ); // Running it again finds nothing to do, and says so by writing // nothing rather than by reporting a rewrite of zero commits. assert_eq!(rewrite_with_change_ids("origin/main").unwrap(), None); assert_eq!(commits_since("origin/main").unwrap(), after); } /// `commit_count` parses git's output into a number, so a base that does /// not resolve has to fail as an error rather than as a parse of "". #[test] fn counting_against_a_missing_base_is_an_error() { let repo = TempRepo::new("missing-base"); repo.commit("a.txt", "one\n", "Add a"); let err = commit_count("origin/nope").unwrap_err().to_string(); assert!(err.contains("git rev-list"), "got: {err}"); } }