@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.
- package/dist/index.js +256 -5
- 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").
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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";
|