// #1634 / #1458 — the ONE question every tip-bookkeeping site actually asks: does this commit // introduce anything the target does not already have? // // Every site used to answer it with its own ladder of `merge-base --is-ancestor` + `git cherry`, // and both rungs under-report containment: // // - SHA ancestry misses a cherry-pick, a rebase, and a squash outright — the content landed, // the SHA did not. // - `git patch-id` (what `git cherry` compares) hashes the hunk CONTEXT, not just the change. // A commit cherry-picked onto a different base hashes differently the moment an intervening // commit moved the lines around it, so `git cherry` reports it `+` (unlanded) forever. That // is #1634: a content-landed commit pinned a workspace tip permanently, and every head the // workspace produced afterwards was discarded against it. // // So there is a third rung: merge `tip` into `target` in memory (`merge-tree --write-tree`) and // compare the resulting tree to target's. If they are identical, `tip` provably contributes // nothing target lacks — whatever the SHAs or the patch ids say. Cheap checks run first; the // tree merge is the fallback, not the default. // // The verdict is deliberately three-valued. "I cannot prove either way" (unrelated histories, // a missing object, a base ref that no longer resolves) is NOT "carries new work" and is NOT // "contained" — the two call sites fail-safe in OPPOSITE directions and must each answer for // `unknown` themselves: // // - cleanup.ts asks so it can DELETE a branch/worktree/rescue tip → unknown must preserve it. // - protected-tip.ts asks so it can REWRITE a live branch → unknown must preserve the head. // // Both are the same principle ("never destroy committed work on an unproven belief") pointing // at different objects, which is exactly why the primitive reports uncertainty instead of // collapsing it into a boolean each site would then collapse the wrong way. import { git } from "../git"; export type ContentContainmentVerdict = "contained" | "carries-new" | "unknown"; export interface ContentContainment { verdict: ContentContainmentVerdict; /** Commits in `tip` with no patch-equivalent in `target` (`git cherry` '+'), when git could count them. */ unmergedAhead?: number; /** Which rung decided it — carried into operator-facing messages so a verdict is explainable. */ via: "identical" | "ancestor" | "tree-identical" | "patch-id" | "merge-tree" | "merge-conflict" | "unprovable"; } export interface ContentContainmentOptions { signal?: AbortSignal; timeoutMs?: number; timeoutLabel?: string; } const OID = /^[0-9a-f]{40,64}$/; function firstLine(stdout: string): string { return stdout.split("\n", 1)[0]?.trim() ?? ""; } /** * Is every change `tip` carries already present in `target`? * * `contained` means dropping `tip` loses nothing. `carries-new` means it holds work `target` * does not (a conflicting merge counts: conflicting edits are edits target lacks). `unknown` * means git could not answer — see the header for why that is not a boolean. */ export async function contentContainedIn(cwd: string, tip: string, target: string, options: ContentContainmentOptions = {}): Promise { const run = (args: string[]): ReturnType => git(args, cwd, options); if (tip === target) return { verdict: "contained", unmergedAhead: 0, via: "identical" }; if ((await run(["merge-base", "--is-ancestor", tip, target])).ok) return { verdict: "contained", unmergedAhead: 0, via: "ancestor" }; if ((await run(["diff", "--quiet", target, tip])).ok) return { verdict: "contained", unmergedAhead: 0, via: "tree-identical" }; // Rung 2 — patch-id. Keep the count even when it says "unlanded": it is the number operators // have read in preserve reasons since #614, and the tree rung below may still overrule it. let unmergedAhead: number | undefined; const cherry = await run(["cherry", target, tip]); if (cherry.ok) { unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0; if (unmergedAhead === 0) return { verdict: "contained", unmergedAhead, via: "patch-id" }; } const counted = unmergedAhead === undefined ? {} : { unmergedAhead }; // Rung 3 — merge the tip into the target in memory. Exit 0 with a tree OID is a clean merge; // exit 1 with a tree OID is a conflicted one (the tip edits lines target edited differently, // so it demonstrably carries content target lacks); anything else is git declining to answer. const merged = await run(["merge-tree", "--write-tree", target, tip]); const mergedTree = firstLine(merged.stdout); if (!OID.test(mergedTree)) return { verdict: "unknown", ...counted, via: "unprovable" }; if (!merged.ok) return { verdict: "carries-new", ...counted, via: "merge-conflict" }; const targetTree = await run(["rev-parse", `${target}^{tree}`]); const targetTreeOid = targetTree.ok ? targetTree.stdout.trim() : ""; if (!OID.test(targetTreeOid)) return { verdict: "unknown", ...counted, via: "unprovable" }; return targetTreeOid === mergedTree ? { verdict: "contained", unmergedAhead: 0, via: "merge-tree" } : { verdict: "carries-new", ...counted, via: "merge-tree" }; } /** Operator-facing phrase for a non-`contained` verdict, preserving the "N unlanded commit(s)" wording. */ export function unlandedContentReason(containment: ContentContainment): string { if (containment.verdict === "unknown") return "content that could not be proven landed"; const count = containment.unmergedAhead; return typeof count === "number" && count > 0 ? `${count} unlanded commit(s)` : "unlanded content"; } /** * The ref a containment question should actually be asked against: a workspace's local `main` * can trail origin by hours, and "did this land?" means "is it on the published base?". */ export async function landedBaseRef(cwd: string, base: string, options: ContentContainmentOptions = {}): Promise { const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], cwd, options); return res.ok && res.stdout ? res.stdout : base; }