@biffo/cli 0.299.1 → 0.300.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +256 -5
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1255,6 +1255,51 @@ var GitAdapter = class {
1255
1255
  const { exitCode } = await execa("git", ["branch", "-D", branch], { cwd, reject: false });
1256
1256
  return exitCode === 0;
1257
1257
  }
1258
+ /**
1259
+ * Removes a linked worktree, returning whether it went.
1260
+ *
1261
+ * Deliberately NOT `--force`: the caller (`doctor --fix`, #1682) only ever
1262
+ * reaches this after confirming the worktree is clean, so a plain `remove`
1263
+ * should always succeed. If it does not — a lock file, a submodule holding
1264
+ * a reference, something this check did not anticipate — failing rather
1265
+ * than forcing past it is the point: an unexpected refusal is new
1266
+ * information, not an obstacle to blast through on a path that deletes
1267
+ * things.
1268
+ */
1269
+ async removeWorktree(cwd, path) {
1270
+ const { exitCode } = await execa("git", ["worktree", "remove", path], { cwd, reject: false });
1271
+ return exitCode === 0;
1272
+ }
1273
+ /**
1274
+ * `HEAD`'s commit SHA at `cwd`, or `null` if it cannot be resolved (an
1275
+ * unborn branch, a path that is not a git repo). Used by `doctor --fix`
1276
+ * (#1810) to prove a worktree's *current* tip — not just its branch name —
1277
+ * is what a merged PR actually shipped, before trusting it is safe to
1278
+ * delete.
1279
+ */
1280
+ async headSha(cwd) {
1281
+ const { stdout, exitCode } = await execa("git", ["rev-parse", "HEAD"], { cwd, reject: false });
1282
+ return exitCode === 0 ? stdout.trim() : null;
1283
+ }
1284
+ /**
1285
+ * Is `ancestor` reachable from `descendant` (including `ancestor ===
1286
+ * descendant`)? Three-valued, not boolean: `git merge-base --is-ancestor`
1287
+ * exits `0` for yes, `1` for no, and anything else — most commonly a SHA
1288
+ * this repo has never fetched the object for — is a genuine "cannot tell",
1289
+ * which callers must treat as unsafe rather than as a "no" (#1810). `cwd`
1290
+ * only needs to be *any* worktree of the same clone: the object database is
1291
+ * shared, so a commit reachable from one worktree's history is visible from
1292
+ * every other.
1293
+ */
1294
+ async isAncestor(cwd, ancestor, descendant) {
1295
+ const { exitCode } = await execa("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
1296
+ cwd,
1297
+ reject: false
1298
+ });
1299
+ if (exitCode === 0) return true;
1300
+ if (exitCode === 1) return false;
1301
+ return null;
1302
+ }
1258
1303
  /** Create and switch to a new branch. Fails if it already exists. */
1259
1304
  async createBranch(cwd, branch) {
1260
1305
  await execa("git", ["switch", "-c", branch], { cwd });
@@ -14724,6 +14769,97 @@ import { join as join61, resolve as resolve20 } from "path";
14724
14769
  import chalk21 from "chalk";
14725
14770
  import { Command as Command25 } from "commander";
14726
14771
 
14772
+ // src/adapters/github-cli/index.ts
14773
+ var GithubCliAdapter = class {
14774
+ /**
14775
+ * What GitHub says happened to `branch`'s pull request(s), in this repo.
14776
+ *
14777
+ * `--state all` so a merged or closed PR is not filtered out before we see
14778
+ * it. `open` wins over any other state present (an open PR is never safe to
14779
+ * touch regardless of history), `merged` wins over `closed` next (a branch
14780
+ * can carry more than one PR across its life — a reopened one, say — and
14781
+ * "was ever merged" is the fact that matters). `none` means no PR was ever
14782
+ * opened from this branch; `unknown` means the lookup itself could not be
14783
+ * trusted (no network, `gh` unauthenticated, unparseable output) and is
14784
+ * handled identically to a hard "keep" by the caller — never as license to
14785
+ * guess.
14786
+ */
14787
+ async prVerdictForBranch(cwd, branch) {
14788
+ const { stdout, exitCode } = await execa(
14789
+ "gh",
14790
+ ["pr", "list", "--head", branch, "--state", "all", "--json", "state", "--limit", "20"],
14791
+ { cwd, reject: false }
14792
+ );
14793
+ if (exitCode !== 0) return "unknown";
14794
+ let parsed;
14795
+ try {
14796
+ const value = JSON.parse(stdout);
14797
+ if (!Array.isArray(value)) return "unknown";
14798
+ parsed = value;
14799
+ } catch {
14800
+ return "unknown";
14801
+ }
14802
+ const states = new Set(
14803
+ parsed.map((p) => typeof p.state === "string" ? p.state : "").filter((s) => s !== "")
14804
+ );
14805
+ if (states.size === 0) return "none";
14806
+ if (states.has("OPEN")) return "open";
14807
+ if (states.has("MERGED")) return "merged";
14808
+ if (states.has("CLOSED")) return "closed";
14809
+ return "unknown";
14810
+ }
14811
+ /**
14812
+ * The head commit SHA (`headRefOid`) GitHub recorded for `branch`'s merged
14813
+ * pull request, or `null` when there is none or the lookup could not be
14814
+ * trusted.
14815
+ *
14816
+ * This is the fact `prVerdictForBranch` alone cannot supply, and #1810 is
14817
+ * the reason it must be asked for separately: "a PR merged for this branch
14818
+ * name" proves the branch's history at *some* point merged, never that the
14819
+ * worktree's *current* tip is that same point — a worktree can carry real,
14820
+ * committed, unpushed commits on top of an already-merged branch, and nothing
14821
+ * about the branch name changes. GitHub keeps `headRefOid` pointing at the
14822
+ * PR's last pushed commit even after the remote branch itself is deleted
14823
+ * post-merge (nothing can push to a deleted ref again), so it stays a stable
14824
+ * "this is what actually shipped" marker for the caller to check the
14825
+ * worktree's HEAD against via `GitAdapter.isAncestor`.
14826
+ *
14827
+ * Deliberately a second call rather than folding `headRefOid` into
14828
+ * `prVerdictForBranch`'s existing `--json state` query: that function's
14829
+ * return type and every existing caller are already covered by tests
14830
+ * written against `Promise<PrVerdict>`, and this fact is only ever needed
14831
+ * once a verdict of `'merged'` is already in hand.
14832
+ */
14833
+ async mergedHeadSha(cwd, branch) {
14834
+ const { stdout, exitCode } = await execa(
14835
+ "gh",
14836
+ [
14837
+ "pr",
14838
+ "list",
14839
+ "--head",
14840
+ branch,
14841
+ "--state",
14842
+ "merged",
14843
+ "--json",
14844
+ "headRefOid",
14845
+ "--limit",
14846
+ "20"
14847
+ ],
14848
+ { cwd, reject: false }
14849
+ );
14850
+ if (exitCode !== 0) return null;
14851
+ let parsed;
14852
+ try {
14853
+ parsed = JSON.parse(stdout);
14854
+ } catch {
14855
+ return null;
14856
+ }
14857
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
14858
+ const first = parsed[0];
14859
+ return typeof first.headRefOid === "string" && /^[0-9a-f]{7,40}$/i.test(first.headRefOid) ? first.headRefOid : null;
14860
+ }
14861
+ };
14862
+
14727
14863
  // src/lib/doctor.ts
14728
14864
  function checkCheckoutCurrency(facts) {
14729
14865
  const findings = [];
@@ -14843,22 +14979,90 @@ function runDoctorChecks(facts) {
14843
14979
  ];
14844
14980
  }
14845
14981
 
14982
+ // src/lib/doctor-reaper.ts
14983
+ function classifyReapCandidate(facts) {
14984
+ if (facts.isDetached) return { action: "keep", reason: "detached-head" };
14985
+ if (facts.isDirty) return { action: "keep", reason: "uncommitted-changes" };
14986
+ switch (facts.prVerdict) {
14987
+ case "merged":
14988
+ if (facts.mergeContainsHead === true) return { action: "reap" };
14989
+ if (facts.mergeContainsHead === false) {
14990
+ return { action: "keep", reason: "commits-not-in-merge" };
14991
+ }
14992
+ return { action: "keep", reason: "unknown-merge-head" };
14993
+ case "open":
14994
+ return { action: "keep", reason: "pr-open" };
14995
+ case "closed":
14996
+ return { action: "keep", reason: "pr-closed" };
14997
+ case "none":
14998
+ return { action: "keep", reason: "no-pr" };
14999
+ case "unknown":
15000
+ return { action: "keep", reason: "unknown-pr-verdict" };
15001
+ }
15002
+ }
15003
+ function findReapCandidates(branches, worktrees) {
15004
+ const goneBranches = new Set(branches.filter((b) => b.track.includes("gone")).map((b) => b.name));
15005
+ return worktrees.filter((w) => goneBranches.has(w.branch)).map((w) => ({ branch: w.branch, worktreePath: w.path }));
15006
+ }
15007
+ async function reapCandidate(cwd, candidate, deps) {
15008
+ const { git, github } = deps;
15009
+ const [current, isDirty] = await Promise.all([
15010
+ git.currentBranch(candidate.worktreePath),
15011
+ git.hasUncommittedChanges(candidate.worktreePath)
15012
+ ]);
15013
+ const isDetached = current === "HEAD" || current === "";
15014
+ const prVerdict = isDetached || isDirty ? "unknown" : await github.prVerdictForBranch(cwd, candidate.branch);
15015
+ let mergeContainsHead = true;
15016
+ if (prVerdict === "merged") {
15017
+ const [headSha, mergedHeadSha] = await Promise.all([
15018
+ git.headSha(candidate.worktreePath),
15019
+ github.mergedHeadSha(cwd, candidate.branch)
15020
+ ]);
15021
+ mergeContainsHead = headSha === null || mergedHeadSha === null ? null : await git.isAncestor(cwd, headSha, mergedHeadSha);
15022
+ }
15023
+ const verdict = classifyReapCandidate({ isDetached, isDirty, prVerdict, mergeContainsHead });
15024
+ if (verdict.action === "keep") {
15025
+ return { candidate, verdict, worktreeRemoved: null };
15026
+ }
15027
+ const worktreeRemoved = await git.removeWorktree(cwd, candidate.worktreePath);
15028
+ return { candidate, verdict, worktreeRemoved };
15029
+ }
15030
+ async function reapAll(cwd, branches, worktrees, currentBranch, deps) {
15031
+ const candidates = findReapCandidates(branches, worktrees).filter(
15032
+ (c) => c.branch !== currentBranch
15033
+ );
15034
+ const outcomes = [];
15035
+ for (const candidate of candidates) {
15036
+ outcomes.push(await reapCandidate(cwd, candidate, deps));
15037
+ }
15038
+ return outcomes;
15039
+ }
15040
+
14846
15041
  // src/commands/doctor.ts
14847
15042
  var INTEGRATION_BRANCH = "dev";
14848
15043
  var doctorCommand = new Command25("doctor").description(
14849
15044
  "Report repo-state conditions that make everything read from this checkout unreliable"
14850
- ).option("--cwd <path>", "Repo root to inspect (defaults to the current directory)").option("--no-fetch", "Skip the fetch; report against refs as they already are locally").action(async (options) => {
15045
+ ).option("--cwd <path>", "Repo root to inspect (defaults to the current directory)").option("--no-fetch", "Skip the fetch; report against refs as they already are locally").option(
15046
+ "--fix",
15047
+ "Also remove any worktree PROVEN safe: one whose branch's PR merged, verified via GitHub (never local commit reachability \u2014 a squash merge rewrites every SHA), AND whose current HEAD is confirmed contained in what that PR actually shipped \u2014 not merely on a branch of the same name (#1810). Everything else (no PR, PR open, PR closed unmerged, detached HEAD, uncommitted changes, commits ahead of what merged, or that containment could not be confirmed) is reported, never touched. Branch deletion is not yet automated here \u2014 that is #1682 milestone 2."
15048
+ ).action(async (options) => {
14851
15049
  const cwd = options.cwd ? resolve20(options.cwd) : process.cwd();
15050
+ const git = new GitAdapter();
14852
15051
  try {
14853
- const findings = await runDoctor({ cwd, fetch: options.fetch !== false });
15052
+ const facts = await gatherRepoFacts({ cwd, fetch: options.fetch !== false }, { git });
15053
+ const findings = runDoctorChecks(facts);
14854
15054
  printFindings(findings);
15055
+ if (options.fix === true) {
15056
+ const outcomes = await runDoctorFix(cwd, facts, { git, github: new GithubCliAdapter() });
15057
+ printReapOutcomes(outcomes);
15058
+ }
14855
15059
  if (findings.some((f) => f.severity === "error")) process.exit(1);
14856
15060
  } catch (err) {
14857
15061
  log.error(err.message);
14858
15062
  process.exit(1);
14859
15063
  }
14860
15064
  });
14861
- async function runDoctor(options, deps = { git: new GitAdapter() }) {
15065
+ async function gatherRepoFacts(options, deps) {
14862
15066
  const { git } = deps;
14863
15067
  if (!await git.isGitRepo(options.cwd)) {
14864
15068
  throw new Error(`${options.cwd} is not a git repository.`);
@@ -14876,7 +15080,7 @@ async function runDoctor(options, deps = { git: new GitAdapter() }) {
14876
15080
  behind: await git.countBehind(options.cwd, w.branch, `origin/${INTEGRATION_BRANCH}`)
14877
15081
  }))
14878
15082
  );
14879
- const facts = {
15083
+ return {
14880
15084
  currentBranch,
14881
15085
  isPrimary,
14882
15086
  integrationBranch: INTEGRATION_BRANCH,
@@ -14896,7 +15100,9 @@ async function runDoctor(options, deps = { git: new GitAdapter() }) {
14896
15100
  branches,
14897
15101
  worktrees
14898
15102
  };
14899
- return runDoctorChecks(facts);
15103
+ }
15104
+ async function runDoctorFix(cwd, facts, deps = { git: new GitAdapter(), github: new GithubCliAdapter() }) {
15105
+ return reapAll(cwd, facts.branches, facts.worktrees, facts.currentBranch, deps);
14900
15106
  }
14901
15107
  function readLocalCoreVersion(cwd) {
14902
15108
  const path = join61(cwd, INSTANCE_CORE_FILE);
@@ -14953,6 +15159,51 @@ function printFindings(findings) {
14953
15159
  )
14954
15160
  );
14955
15161
  }
15162
+ var KEEP_REASON_TEXT = {
15163
+ "detached-head": "worktree HEAD is detached",
15164
+ "uncommitted-changes": "worktree has uncommitted changes",
15165
+ "pr-open": "PR is still open",
15166
+ "pr-closed": "PR closed unmerged",
15167
+ "no-pr": "no PR was ever opened from this branch",
15168
+ "unknown-pr-verdict": "could not read this branch's PR state from GitHub",
15169
+ "commits-not-in-merge": "worktree HEAD includes commits the merged PR never shipped",
15170
+ "unknown-merge-head": "could not confirm worktree HEAD is contained in what merged"
15171
+ };
15172
+ function printReapOutcomes(outcomes) {
15173
+ if (outcomes.length === 0) {
15174
+ console.log(chalk21.dim(" --fix: no worktree with a gone upstream to consider.\n"));
15175
+ return;
15176
+ }
15177
+ const attempted = outcomes.filter((o) => o.verdict.action === "reap");
15178
+ const kept = outcomes.filter((o) => o.verdict.action === "keep");
15179
+ const removed = attempted.filter((o) => o.worktreeRemoved === true);
15180
+ const failed = attempted.filter((o) => o.worktreeRemoved !== true);
15181
+ console.log("");
15182
+ for (const o of attempted) {
15183
+ if (o.worktreeRemoved === true) {
15184
+ console.log(chalk21.green(` removed ${o.candidate.worktreePath} (${o.candidate.branch})`));
15185
+ } else {
15186
+ console.log(
15187
+ chalk21.red(
15188
+ ` FAILED ${o.candidate.worktreePath} (${o.candidate.branch}) \u2014 judged safe to remove but 'git worktree remove' did not succeed; left as-is`
15189
+ )
15190
+ );
15191
+ }
15192
+ }
15193
+ for (const o of kept) {
15194
+ const reason = o.verdict.reason === void 0 ? "unknown" : KEEP_REASON_TEXT[o.verdict.reason];
15195
+ console.log(
15196
+ chalk21.dim(` kept ${o.candidate.worktreePath} (${o.candidate.branch}) \u2014 ${reason}`)
15197
+ );
15198
+ }
15199
+ console.log(
15200
+ chalk21.dim(
15201
+ `
15202
+ --fix: ${String(removed.length)} removed, ${String(failed.length)} failed, ${String(kept.length)} kept, of ${String(outcomes.length)} worktree(s) considered.
15203
+ `
15204
+ )
15205
+ );
15206
+ }
14956
15207
 
14957
15208
  // src/commands/teardown.ts
14958
15209
  import { execSync as execSync7 } from "child_process";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.299.1",
3
+ "version": "0.300.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",