@biffo/cli 0.297.0 → 0.297.2

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 CHANGED
@@ -573,7 +573,25 @@ import { execSync as execSync2 } from "child_process";
573
573
  import { existsSync as existsSync15, rmSync as rmSync5 } from "fs";
574
574
  import { join as join16, resolve as resolve3 } from "path";
575
575
  import chalk4 from "chalk";
576
- import { execa as execa3 } from "execa";
576
+
577
+ // src/lib/exec.ts
578
+ import { execa as rawExeca } from "execa";
579
+ var execTimeoutMs = () => {
580
+ const raw = process.env.BIFFO_EXEC_TIMEOUT_MS;
581
+ const parsed = raw === void 0 ? Number.NaN : Number(raw);
582
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 9e5;
583
+ };
584
+ var execa = (file, args, options) => {
585
+ const opts = options ?? {};
586
+ const supplied = "stdin" in opts || "input" in opts;
587
+ return rawExeca(file, args ?? [], {
588
+ ...supplied ? {} : { stdin: "ignore" },
589
+ timeout: execTimeoutMs(),
590
+ ...opts
591
+ });
592
+ };
593
+
594
+ // src/commands/core-upgrade.ts
577
595
  import { Command as Command3 } from "commander";
578
596
 
579
597
  // src/adapters/git/index.ts
@@ -581,7 +599,6 @@ import { randomUUID } from "crypto";
581
599
  import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, rmSync as rmSync2 } from "fs";
582
600
  import { tmpdir as tmpdir2 } from "os";
583
601
  import { join as join6 } from "path";
584
- import { execa as execa2 } from "execa";
585
602
 
586
603
  // src/lib/core-upgrade.ts
587
604
  import { isUtf8 } from "buffer";
@@ -597,7 +614,6 @@ import {
597
614
  } from "fs";
598
615
  import { tmpdir } from "os";
599
616
  import { dirname as dirname3, join as join5 } from "path";
600
- import { execa } from "execa";
601
617
  import { z as z4 } from "zod";
602
618
 
603
619
  // src/lib/core-ownership-guard.ts
@@ -953,7 +969,7 @@ var GitAdapter = class {
953
969
  async configuredIdentity(cwd) {
954
970
  const read2 = async (key) => {
955
971
  try {
956
- const { stdout } = await execa2("git", ["config", "--get", key], cwd ? { cwd } : {});
972
+ const { stdout } = await execa("git", ["config", "--get", key], cwd ? { cwd } : {});
957
973
  return stdout.trim() || null;
958
974
  } catch {
959
975
  return null;
@@ -963,7 +979,7 @@ var GitAdapter = class {
963
979
  }
964
980
  async isGitRepo(cwd) {
965
981
  try {
966
- await execa2("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
982
+ await execa("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
967
983
  return true;
968
984
  } catch {
969
985
  return false;
@@ -988,7 +1004,7 @@ var GitAdapter = class {
988
1004
  const dir = mkdtempSync2(join6(tmpdir2(), `${namePrefix}-${randomUUID().slice(0, 8)}-`));
989
1005
  const cloneUrl = token ? injectToken(repoUrl, token) : repoUrl;
990
1006
  try {
991
- await execa2("git", ["clone", "--depth", "1", cloneUrl, dir]);
1007
+ await execa("git", ["clone", "--depth", "1", cloneUrl, dir]);
992
1008
  } catch (err) {
993
1009
  rmSync2(dir, { recursive: true, force: true });
994
1010
  throw gitFailure(`Failed to clone ${repoUrl}`, err, [token]);
@@ -1011,7 +1027,7 @@ var GitAdapter = class {
1011
1027
  const dir = mkdtempSync2(join6(tmpdir2(), `${namePrefix}-${randomUUID().slice(0, 8)}-`));
1012
1028
  const cloneUrl = token ? injectToken(repoUrl, token) : repoUrl;
1013
1029
  try {
1014
- await execa2("git", ["clone", cloneUrl, dir]);
1030
+ await execa("git", ["clone", cloneUrl, dir]);
1015
1031
  } catch (err) {
1016
1032
  rmSync2(dir, { recursive: true, force: true });
1017
1033
  throw gitFailure(`Failed to clone ${repoUrl}`, err, [token]);
@@ -1035,26 +1051,26 @@ var GitAdapter = class {
1035
1051
  * repo uses (#559).
1036
1052
  */
1037
1053
  async init(cwd, initialBranch = "dev") {
1038
- await execa2("git", ["init", "-b", initialBranch], { cwd });
1054
+ await execa("git", ["init", "-b", initialBranch], { cwd });
1039
1055
  }
1040
1056
  /** Adds a remote. Fails if a remote with this name already exists. */
1041
1057
  async addRemote(cwd, name, url) {
1042
- await execa2("git", ["remote", "add", name, url], { cwd });
1058
+ await execa("git", ["remote", "add", name, url], { cwd });
1043
1059
  }
1044
1060
  async add(cwd, paths) {
1045
- await execa2("git", ["add", ...paths], { cwd });
1061
+ await execa("git", ["add", ...paths], { cwd });
1046
1062
  }
1047
1063
  async commit(cwd, message) {
1048
- await execa2("git", ["commit", "-m", message], { cwd });
1064
+ await execa("git", ["commit", "-m", message], { cwd });
1049
1065
  }
1050
1066
  /** The current branch name (e.g. "dev"). */
1051
1067
  async currentBranch(cwd) {
1052
- const { stdout } = await execa2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
1068
+ const { stdout } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
1053
1069
  return stdout.trim();
1054
1070
  }
1055
1071
  /** True if the working tree or index has uncommitted changes. */
1056
1072
  async hasUncommittedChanges(cwd) {
1057
- const { stdout } = await execa2("git", ["status", "--porcelain"], { cwd });
1073
+ const { stdout } = await execa("git", ["status", "--porcelain"], { cwd });
1058
1074
  return stdout.trim().length > 0;
1059
1075
  }
1060
1076
  /** Best-effort fetch of the tracking remote, so the ahead/behind check below
@@ -1062,13 +1078,13 @@ var GitAdapter = class {
1062
1078
  * offline or a missing remote must not block an upgrade on its own — the
1063
1079
  * ahead/behind check that follows simply works from whatever is local. */
1064
1080
  async fetch(cwd, remote = "origin") {
1065
- await execa2("git", ["fetch", "--quiet", remote], { cwd, reject: false });
1081
+ await execa("git", ["fetch", "--quiet", remote], { cwd, reject: false });
1066
1082
  }
1067
1083
  /** HEAD's position relative to its upstream. `hasUpstream` is false when the
1068
1084
  * branch tracks nothing (or HEAD is detached), in which case currency cannot
1069
1085
  * be established and ahead/behind are 0 (#394). */
1070
1086
  async aheadBehind(cwd) {
1071
- const { stdout, exitCode } = await execa2(
1087
+ const { stdout, exitCode } = await execa(
1072
1088
  "git",
1073
1089
  ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
1074
1090
  { cwd, reject: false }
@@ -1079,7 +1095,7 @@ var GitAdapter = class {
1079
1095
  }
1080
1096
  /** The fetch URL of a remote (default "origin"). */
1081
1097
  async getRemoteUrl(cwd, remote = "origin") {
1082
- const { stdout } = await execa2("git", ["remote", "get-url", remote], { cwd });
1098
+ const { stdout } = await execa("git", ["remote", "get-url", remote], { cwd });
1083
1099
  return stdout.trim();
1084
1100
  }
1085
1101
  /**
@@ -1092,7 +1108,7 @@ var GitAdapter = class {
1092
1108
  * SHA as an honest "could not determine", never as license to invent one.
1093
1109
  */
1094
1110
  async resolveDefaultBranchSha(repoUrl) {
1095
- const { stdout, exitCode } = await execa2("git", ["ls-remote", "--exit-code", repoUrl, "HEAD"], {
1111
+ const { stdout, exitCode } = await execa("git", ["ls-remote", "--exit-code", repoUrl, "HEAD"], {
1096
1112
  reject: false
1097
1113
  });
1098
1114
  if (exitCode !== 0) return null;
@@ -1110,7 +1126,7 @@ var GitAdapter = class {
1110
1126
  * equally best-effort call.
1111
1127
  */
1112
1128
  async fetchPrune(cwd, remote = "origin") {
1113
- await execa2("git", ["fetch", "--quiet", "--prune", remote], { cwd, reject: false });
1129
+ await execa("git", ["fetch", "--quiet", "--prune", remote], { cwd, reject: false });
1114
1130
  }
1115
1131
  /**
1116
1132
  * Is `cwd` the primary checkout, rather than a linked worktree?
@@ -1127,8 +1143,8 @@ var GitAdapter = class {
1127
1143
  async isPrimaryWorktree(cwd) {
1128
1144
  const opts = { cwd, reject: false };
1129
1145
  const [dir, common] = await Promise.all([
1130
- execa2("git", ["rev-parse", "--absolute-git-dir"], opts),
1131
- execa2("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], opts)
1146
+ execa("git", ["rev-parse", "--absolute-git-dir"], opts),
1147
+ execa("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], opts)
1132
1148
  ]);
1133
1149
  if (dir.exitCode !== 0 || common.exitCode !== 0) return true;
1134
1150
  return dir.stdout.trim() === common.stdout.trim();
@@ -1140,7 +1156,7 @@ var GitAdapter = class {
1140
1156
  * annotations vary, and this has to survive paths with spaces.
1141
1157
  */
1142
1158
  async listWorktrees(cwd) {
1143
- const { stdout, exitCode } = await execa2("git", ["worktree", "list", "--porcelain"], {
1159
+ const { stdout, exitCode } = await execa("git", ["worktree", "list", "--porcelain"], {
1144
1160
  cwd,
1145
1161
  reject: false
1146
1162
  });
@@ -1158,7 +1174,7 @@ var GitAdapter = class {
1158
1174
  }
1159
1175
  /** How many commits `branch` is behind `base`; null when it cannot be measured. */
1160
1176
  async countBehind(cwd, branch, base) {
1161
- const { stdout, exitCode } = await execa2("git", ["rev-list", "--count", `${branch}..${base}`], {
1177
+ const { stdout, exitCode } = await execa("git", ["rev-list", "--count", `${branch}..${base}`], {
1162
1178
  cwd,
1163
1179
  reject: false
1164
1180
  });
@@ -1168,7 +1184,7 @@ var GitAdapter = class {
1168
1184
  }
1169
1185
  /** A file's contents at a ref, or null when it is absent there. */
1170
1186
  async showFileAtRef(cwd, ref, path) {
1171
- const { stdout, exitCode } = await execa2("git", ["show", `${ref}:${path}`], {
1187
+ const { stdout, exitCode } = await execa("git", ["show", `${ref}:${path}`], {
1172
1188
  cwd,
1173
1189
  reject: false
1174
1190
  });
@@ -1176,7 +1192,7 @@ var GitAdapter = class {
1176
1192
  }
1177
1193
  /** Every local branch with its upstream and tracking state (#758). */
1178
1194
  async listBranchRefs(cwd) {
1179
- const { stdout, exitCode } = await execa2(
1195
+ const { stdout, exitCode } = await execa(
1180
1196
  "git",
1181
1197
  ["for-each-ref", `--format=${BRANCH_REF_FORMAT}`, "refs/heads"],
1182
1198
  { cwd, reject: false }
@@ -1194,12 +1210,12 @@ var GitAdapter = class {
1194
1210
  * passes branches whose upstream git reports as gone.
1195
1211
  */
1196
1212
  async deleteBranch(cwd, branch) {
1197
- const { exitCode } = await execa2("git", ["branch", "-D", branch], { cwd, reject: false });
1213
+ const { exitCode } = await execa("git", ["branch", "-D", branch], { cwd, reject: false });
1198
1214
  return exitCode === 0;
1199
1215
  }
1200
1216
  /** Create and switch to a new branch. Fails if it already exists. */
1201
1217
  async createBranch(cwd, branch) {
1202
- await execa2("git", ["switch", "-c", branch], { cwd });
1218
+ await execa("git", ["switch", "-c", branch], { cwd });
1203
1219
  }
1204
1220
  /**
1205
1221
  * Switch to an existing branch. Fails if it does not exist, and — deliberately
@@ -1208,7 +1224,7 @@ var GitAdapter = class {
1208
1224
  * putting back.
1209
1225
  */
1210
1226
  async switchBranch(cwd, branch) {
1211
- await execa2("git", ["switch", branch], { cwd });
1227
+ await execa("git", ["switch", branch], { cwd });
1212
1228
  }
1213
1229
  /**
1214
1230
  * Push the current HEAD to `branch` on the remote. When `token` is given and
@@ -1232,7 +1248,7 @@ var GitAdapter = class {
1232
1248
  if (authed !== url) target = authed;
1233
1249
  }
1234
1250
  try {
1235
- await execa2("git", ["push", target, `HEAD:refs/heads/${branch}`], { cwd });
1251
+ await execa("git", ["push", target, `HEAD:refs/heads/${branch}`], { cwd });
1236
1252
  } catch (err) {
1237
1253
  throw gitFailure(`Failed to push branch '${branch}' to remote '${remote}'`, err, [opts.token]);
1238
1254
  }
@@ -1283,9 +1299,9 @@ var GitAdapter = class {
1283
1299
  */
1284
1300
  async setUpstreamAfterPush(cwd, branch, remote) {
1285
1301
  const opts = { cwd, reject: false };
1286
- await execa2("git", ["update-ref", `refs/remotes/${remote}/${branch}`, "HEAD"], opts);
1287
- await execa2("git", ["config", `branch.${branch}.remote`, remote], opts);
1288
- await execa2("git", ["config", `branch.${branch}.merge`, `refs/heads/${branch}`], opts);
1302
+ await execa("git", ["update-ref", `refs/remotes/${remote}/${branch}`, "HEAD"], opts);
1303
+ await execa("git", ["config", `branch.${branch}.remote`, remote], opts);
1304
+ await execa("git", ["config", `branch.${branch}.merge`, `refs/heads/${branch}`], opts);
1289
1305
  }
1290
1306
  };
1291
1307
  function injectToken(repoUrl, token) {
@@ -4296,7 +4312,7 @@ var defaultRunCommand = async (command, cwd) => {
4296
4312
  const [bin, ...args] = command;
4297
4313
  if (!bin) return { ok: false, error: "empty command" };
4298
4314
  try {
4299
- await execa3(bin, args, {
4315
+ await execa(bin, args, {
4300
4316
  cwd,
4301
4317
  // TWO REASONS THIS HUNG FOR 29 HOURS, AND THE FIX NEEDS BOTH.
4302
4318
  //
@@ -8707,7 +8723,6 @@ import chalk15 from "chalk";
8707
8723
  import { Command as Command15 } from "commander";
8708
8724
 
8709
8725
  // src/adapters/plugin-migrations/index.ts
8710
- import { execa as execa4 } from "execa";
8711
8726
  import { join as join28 } from "path";
8712
8727
  var PluginMigrationsAdapter = class {
8713
8728
  /**
@@ -8732,7 +8747,7 @@ var PluginMigrationsAdapter = class {
8732
8747
  }
8733
8748
  let result;
8734
8749
  try {
8735
- result = await execa4("uv", args, { cwd: join28(cwd, "services", "api") });
8750
+ result = await execa("uv", args, { cwd: join28(cwd, "services", "api") });
8736
8751
  } catch (err) {
8737
8752
  const cause = err;
8738
8753
  if (cause.code === "ENOENT") {
@@ -8751,7 +8766,6 @@ var PluginMigrationsAdapter = class {
8751
8766
  // src/lib/plugin-provenance.ts
8752
8767
  import { existsSync as existsSync28, readFileSync as readFileSync21, writeFileSync as writeFileSync10 } from "fs";
8753
8768
  import { join as join29 } from "path";
8754
- import { execa as execa5 } from "execa";
8755
8769
  var PLUGIN_PROVENANCE_FILENAME = ".biffo-plugin-provenance.json";
8756
8770
  function isPluginProvenance(value) {
8757
8771
  if (typeof value !== "object" || value === null) return false;
@@ -8813,7 +8827,7 @@ function resolveRegistryProvenance(repoUrl, sha) {
8813
8827
  }
8814
8828
  async function isGitWorkingTree(dir) {
8815
8829
  try {
8816
- await execa5("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir });
8830
+ await execa("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir });
8817
8831
  return true;
8818
8832
  } catch {
8819
8833
  return false;
@@ -8821,7 +8835,7 @@ async function isGitWorkingTree(dir) {
8821
8835
  }
8822
8836
  async function tryGit(cwd, args) {
8823
8837
  try {
8824
- const { stdout } = await execa5("git", args, { cwd });
8838
+ const { stdout } = await execa("git", args, { cwd });
8825
8839
  return stdout.trim() || null;
8826
8840
  } catch {
8827
8841
  return null;
@@ -8870,7 +8884,6 @@ function vendorPluginSeed(pluginSourceDir, manifest, cwd) {
8870
8884
  // src/lib/plugin-source-copy.ts
8871
8885
  import { copyFileSync as copyFileSync2, cpSync as cpSync4, mkdirSync as mkdirSync10 } from "fs";
8872
8886
  import { basename, dirname as dirname9, join as join31 } from "path";
8873
- import { execa as execa6 } from "execa";
8874
8887
  var LOCAL_COPY_EXCLUDES = /* @__PURE__ */ new Set([
8875
8888
  ".git",
8876
8889
  ".venv",
@@ -8904,14 +8917,14 @@ async function copyPluginSource(sourceDir, targetDir) {
8904
8917
  }
8905
8918
  async function isGitWorkingTree2(dir) {
8906
8919
  try {
8907
- await execa6("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir });
8920
+ await execa("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir });
8908
8921
  return true;
8909
8922
  } catch {
8910
8923
  return false;
8911
8924
  }
8912
8925
  }
8913
8926
  async function listGitFiles(dir) {
8914
- const { stdout } = await execa6(
8927
+ const { stdout } = await execa(
8915
8928
  "git",
8916
8929
  ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
8917
8930
  { cwd: dir }
@@ -9851,7 +9864,6 @@ import { cpSync as cpSync6, existsSync as existsSync36, mkdirSync as mkdirSync12
9851
9864
  import { join as join38, relative as relative7, resolve as resolve17 } from "path";
9852
9865
  import chalk19 from "chalk";
9853
9866
  import { Command as Command20 } from "commander";
9854
- import { execa as execa7 } from "execa";
9855
9867
  import inquirer7 from "inquirer";
9856
9868
  var pluginUpgradeCommand = new Command20("upgrade").description(
9857
9869
  "Upgrade an installed plugin to a new minor version (biffo plugin upgrade <name>@<new-minor>) or refresh it in place from a local, unpublished checkout (biffo plugin upgrade --local <path>)"
@@ -10180,7 +10192,7 @@ var defaultRunCommand2 = async (command, cwd) => {
10180
10192
  const [bin, ...args] = command;
10181
10193
  if (!bin) return { ok: false, error: "empty command" };
10182
10194
  try {
10183
- await execa7(bin, args, {
10195
+ await execa(bin, args, {
10184
10196
  cwd,
10185
10197
  // TWO REASONS THIS HUNG FOR 29 HOURS, AND THE FIX NEEDS BOTH.
10186
10198
  //
@@ -10590,7 +10602,6 @@ import { Command as Command24 } from "commander";
10590
10602
  // src/scripts/check-adr-numbering.ts
10591
10603
  import { existsSync as existsSync39 } from "fs";
10592
10604
  import { join as join40 } from "path";
10593
- import { execa as execa8 } from "execa";
10594
10605
 
10595
10606
  // src/lib/adr-numbering-guard.ts
10596
10607
  import { existsSync as existsSync38, readdirSync as readdirSync15, readFileSync as readFileSync29 } from "fs";
@@ -10664,7 +10675,7 @@ function formatAdrReservedRangeViolations(violations, reservedUpTo = TEMPLATE_AD
10664
10675
 
10665
10676
  // src/scripts/check-adr-numbering.ts
10666
10677
  async function runAdrNumberingCheck() {
10667
- const root = (await execa8("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10678
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10668
10679
  const adrDir = join40(root, "docs", "ADR");
10669
10680
  if (!existsSync39(adrDir)) {
10670
10681
  console.log("\u2713 ADR numbering guard: no docs/ADR/ directory \u2014 nothing to compare");
@@ -10705,7 +10716,6 @@ Already accepted? List it in docs/ADR/${ALLOWLIST_FILENAME} instead of leaving t
10705
10716
 
10706
10717
  // src/scripts/check-branch-protection.ts
10707
10718
  import { Octokit as Octokit2 } from "@octokit/rest";
10708
- import { execa as execa9 } from "execa";
10709
10719
 
10710
10720
  // src/lib/branch-protection-apply.ts
10711
10721
  var CONTEXT_CONSISTENCY_THRESHOLD = 2 / 3;
@@ -10832,7 +10842,7 @@ async function resolveRepo(explicit) {
10832
10842
  }
10833
10843
  return { owner, repo };
10834
10844
  }
10835
- const { stdout } = await execa9("git", ["remote", "get-url", "origin"]);
10845
+ const { stdout } = await execa("git", ["remote", "get-url", "origin"]);
10836
10846
  const m = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/.exec(stdout.trim());
10837
10847
  if (!m?.[1] || !m[2]) {
10838
10848
  console.error(
@@ -10963,9 +10973,6 @@ async function runBranchProtectionCheck(explicitRepo, options = {}) {
10963
10973
  console.log(`\u2713 branch-protection guard: ${owner}/${repo} (${audited.join(", ")}) OK`);
10964
10974
  }
10965
10975
 
10966
- // src/scripts/check-claim-invocation.ts
10967
- import { execa as execa10 } from "execa";
10968
-
10969
10976
  // src/lib/claim-invocation-parity.ts
10970
10977
  import { existsSync as existsSync40, readFileSync as readFileSync30, readdirSync as readdirSync16 } from "fs";
10971
10978
  import { join as join41 } from "path";
@@ -11090,7 +11097,7 @@ function formatParityViolations(violations) {
11090
11097
 
11091
11098
  // src/scripts/check-claim-invocation.ts
11092
11099
  async function runClaimInvocationCheck() {
11093
- const root = (await execa10("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11100
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11094
11101
  const docs = distributedAgentsDocs(root);
11095
11102
  console.log(
11096
11103
  `audited ${docs.length} distributed AGENTS.md (${docs.map((d) => d.path).join(", ") || "none"}) under ${root}`
@@ -11112,7 +11119,6 @@ async function runClaimInvocationCheck() {
11112
11119
  // src/scripts/check-codeql-suppression.ts
11113
11120
  import { existsSync as existsSync41 } from "fs";
11114
11121
  import { join as join43, relative as relative8 } from "path";
11115
- import { execa as execa11 } from "execa";
11116
11122
 
11117
11123
  // src/lib/codeql-suppression-guard.ts
11118
11124
  import { readdirSync as readdirSync17, readFileSync as readFileSync31, statSync as statSync9 } from "fs";
@@ -11183,7 +11189,7 @@ function sweepCodeqlSuppressionComments(root) {
11183
11189
 
11184
11190
  // src/scripts/check-codeql-suppression.ts
11185
11191
  async function runCodeqlSuppressionCheck() {
11186
- const root = (await execa11("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11192
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11187
11193
  const scanRoot = join43(root, "cli", "src");
11188
11194
  if (!existsSync41(scanRoot)) {
11189
11195
  console.log(
@@ -11208,9 +11214,6 @@ async function runCodeqlSuppressionCheck() {
11208
11214
  console.log("\u2713 codeql-suppression guard: no dead `codeql[...]` suppression comment found");
11209
11215
  }
11210
11216
 
11211
- // src/scripts/check-cognito-invite-template.ts
11212
- import { execa as execa12 } from "execa";
11213
-
11214
11217
  // src/lib/cognito-invite-template-guard.ts
11215
11218
  import { readdirSync as readdirSync18, readFileSync as readFileSync32, statSync as statSync10 } from "fs";
11216
11219
  import { join as join44 } from "path";
@@ -11316,7 +11319,7 @@ function checkCognitoInviteTemplates(repoRoot) {
11316
11319
 
11317
11320
  // src/scripts/check-cognito-invite-template.ts
11318
11321
  async function runCognitoInviteTemplateCheck() {
11319
- const root = (await execa12("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11322
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11320
11323
  const files = findModuleTerraformFiles(root);
11321
11324
  console.log(`audited ${files.length} .tf file(s) under modules/ under ${root}`);
11322
11325
  if (files.length === 0) {
@@ -11339,7 +11342,6 @@ async function runCognitoInviteTemplateCheck() {
11339
11342
 
11340
11343
  // src/scripts/check-core-direct-paths.ts
11341
11344
  import { join as join46 } from "path";
11342
- import { execa as execa13 } from "execa";
11343
11345
 
11344
11346
  // src/lib/core-direct-paths-audit.ts
11345
11347
  import { existsSync as existsSync42, readFileSync as readFileSync33, readdirSync as readdirSync19, statSync as statSync11 } from "fs";
@@ -11667,7 +11669,7 @@ function auditSiblingCoreDirectPaths(params) {
11667
11669
 
11668
11670
  // src/scripts/check-core-direct-paths.ts
11669
11671
  async function runCoreDirectPathsCheck(opts = {}) {
11670
- const root = (await execa13("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11672
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11671
11673
  const sibling = opts.sibling ?? "sibling-template (self-check)";
11672
11674
  const frontendSrcDir = opts.frontendSrc ?? join46(root, "_skeletons", "sibling-template", "apps", "frontend", "src");
11673
11675
  let coreApiSrcDir;
@@ -11721,7 +11723,6 @@ async function runCoreDirectPathsCheck(opts = {}) {
11721
11723
  }
11722
11724
 
11723
11725
  // src/scripts/check-core-ownership.ts
11724
- import { execa as execa14 } from "execa";
11725
11726
  var BOLD = "\x1B[1m";
11726
11727
  var DIM = "\x1B[2m";
11727
11728
  var RED = "\x1B[31m";
@@ -11732,7 +11733,7 @@ async function runOwnershipCheck(argv) {
11732
11733
  const stagedFlag = args.indexOf("--staged");
11733
11734
  const staged = stagedFlag !== -1;
11734
11735
  const messageFile = staged ? args[stagedFlag + 1] : void 0;
11735
- const root = (await execa14("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11736
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11736
11737
  const ownership = classifyRepoOwnership(root);
11737
11738
  if (ownership === "template") {
11738
11739
  console.log("\u2713 core ownership guard: skipped \u2014 this is the template, which owns these paths.");
@@ -11748,7 +11749,7 @@ async function runOwnershipCheck(argv) {
11748
11749
  let deletedFiles = [];
11749
11750
  let commitMessage = "";
11750
11751
  if (staged) {
11751
- const { stdout } = await execa14("git", ["diff", "--cached", "--name-status"], { cwd: root });
11752
+ const { stdout } = await execa("git", ["diff", "--cached", "--name-status"], { cwd: root });
11752
11753
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
11753
11754
  if (messageFile) {
11754
11755
  const { readFileSync: readFileSync45, existsSync: existsSync54 } = await import("fs");
@@ -11760,18 +11761,18 @@ async function runOwnershipCheck(argv) {
11760
11761
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
11761
11762
  process.exit(2);
11762
11763
  }
11763
- await execa14("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
11764
- const { stdout } = await execa14("git", ["diff", "--name-status", `origin/${base}...HEAD`], {
11764
+ await execa("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
11765
+ const { stdout } = await execa("git", ["diff", "--name-status", `origin/${base}...HEAD`], {
11765
11766
  cwd: root
11766
11767
  });
11767
11768
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
11768
- const { stdout: log2 } = await execa14("git", ["log", "--format=%B", `origin/${base}..HEAD`], {
11769
+ const { stdout: log2 } = await execa("git", ["log", "--format=%B", `origin/${base}..HEAD`], {
11769
11770
  cwd: root,
11770
11771
  reject: false
11771
11772
  });
11772
11773
  commitMessage = log2;
11773
11774
  }
11774
- const { stdout: gitBranch } = await execa14("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
11775
+ const { stdout: gitBranch } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
11775
11776
  cwd: root,
11776
11777
  reject: false
11777
11778
  });
@@ -11852,9 +11853,6 @@ ${BOLD}If the divergence is deliberate${OFF}
11852
11853
  process.exit(1);
11853
11854
  }
11854
11855
 
11855
- // src/scripts/check-eventbridge-log-permissions.ts
11856
- import { execa as execa15 } from "execa";
11857
-
11858
11856
  // src/lib/eventbridge-log-permission-guard.ts
11859
11857
  import { readFileSync as readFileSync34, readdirSync as readdirSync20, statSync as statSync12 } from "fs";
11860
11858
  import { join as join47 } from "path";
@@ -12030,7 +12028,7 @@ function auditEventBridgeLogPermissions(root) {
12030
12028
 
12031
12029
  // src/scripts/check-eventbridge-log-permissions.ts
12032
12030
  async function runEventBridgeLogPermissionCheck() {
12033
- const root = (await execa15("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12031
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12034
12032
  let report;
12035
12033
  try {
12036
12034
  report = auditEventBridgeLogPermissions(root);
@@ -12069,7 +12067,6 @@ async function runEventBridgeLogPermissionCheck() {
12069
12067
  // src/scripts/check-instance-adoption.ts
12070
12068
  import { existsSync as existsSync43 } from "fs";
12071
12069
  import { join as join48 } from "path";
12072
- import { execa as execa16 } from "execa";
12073
12070
  async function runInstanceAdoptionCheck(opts = {}) {
12074
12071
  if (!opts.instanceDir) {
12075
12072
  console.error(
@@ -12083,7 +12080,7 @@ async function runInstanceAdoptionCheck(opts = {}) {
12083
12080
  );
12084
12081
  process.exit(2);
12085
12082
  }
12086
- const root = (await execa16("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12083
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12087
12084
  const theirsDir = opts.theirsDir ?? root;
12088
12085
  const instanceLabel = opts.instance ?? join48(opts.instanceDir).split("/").filter(Boolean).pop();
12089
12086
  const report = checkInstanceAdoption(theirsDir, opts.instanceDir);
@@ -12106,9 +12103,6 @@ async function runInstanceAdoptionCheck(opts = {}) {
12106
12103
  process.exit(1);
12107
12104
  }
12108
12105
 
12109
- // src/scripts/check-lambda-output.ts
12110
- import { execa as execa17 } from "execa";
12111
-
12112
12106
  // src/lib/lambda-output-guard.ts
12113
12107
  import { readFileSync as readFileSync36 } from "fs";
12114
12108
  import { join as join50 } from "path";
@@ -12269,7 +12263,7 @@ function checkLambdaOutput(repoRoot) {
12269
12263
 
12270
12264
  // src/scripts/check-lambda-output.ts
12271
12265
  async function runLambdaOutputCheck() {
12272
- const root = (await execa17("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12266
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12273
12267
  const files = findWorkflowFiles(root);
12274
12268
  console.log(`audited ${files.length} workflow file(s) under ${root}`);
12275
12269
  if (files.length === 0) {
@@ -12291,7 +12285,6 @@ async function runLambdaOutputCheck() {
12291
12285
  }
12292
12286
 
12293
12287
  // src/scripts/check-migration-body-change.ts
12294
- import { execa as execa18 } from "execa";
12295
12288
  import { existsSync as existsSync45 } from "fs";
12296
12289
  import { join as join51 } from "path";
12297
12290
 
@@ -12348,11 +12341,11 @@ var GREEN = "\x1B[32m";
12348
12341
  var YELLOW2 = "\x1B[33m";
12349
12342
  var OFF2 = "\x1B[0m";
12350
12343
  async function showAt(ref, path, cwd) {
12351
- const result = await execa18("git", ["show", `${ref}:${path}`], { cwd, reject: false });
12344
+ const result = await execa("git", ["show", `${ref}:${path}`], { cwd, reject: false });
12352
12345
  return result.exitCode === 0 ? result.stdout : null;
12353
12346
  }
12354
12347
  async function runMigrationBodyChangeCheck(argv) {
12355
- const root = (await execa18("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12348
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12356
12349
  if (isInstanceRepo(root)) {
12357
12350
  console.log(
12358
12351
  `\u2713 migration body-change guard: skipped \u2014 this is an instance (${INSTANCE_CORE_FILE} present). Its migrations/versions/ is a carried, user-owned copy, not the template source this guard protects.`
@@ -12368,8 +12361,8 @@ async function runMigrationBodyChangeCheck(argv) {
12368
12361
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
12369
12362
  process.exit(2);
12370
12363
  }
12371
- await execa18("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
12372
- const { stdout } = await execa18(
12364
+ await execa("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
12365
+ const { stdout } = await execa(
12373
12366
  "git",
12374
12367
  [
12375
12368
  "diff",
@@ -12463,7 +12456,6 @@ ${BOLD2}What to do${OFF2}
12463
12456
  // src/scripts/check-pipe-trap.ts
12464
12457
  import { readFileSync as readFileSync37, readdirSync as readdirSync22 } from "fs";
12465
12458
  import { join as join52, relative as relative9 } from "path";
12466
- import { execa as execa19 } from "execa";
12467
12459
 
12468
12460
  // src/lib/pipe-trap-guard.ts
12469
12461
  var STATUS_BEARING = [
@@ -12575,7 +12567,7 @@ function shellFiles(root) {
12575
12567
  return out;
12576
12568
  }
12577
12569
  async function runPipeTrapCheck() {
12578
- const root = (await execa19("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12570
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12579
12571
  const files = shellFiles(root);
12580
12572
  console.log(`audited ${files.length} shell file(s) under scripts/ and .githooks/ under ${root}`);
12581
12573
  if (files.length === 0) {
@@ -12601,9 +12593,6 @@ async function runPipeTrapCheck() {
12601
12593
  console.log(`\u2713 Pipe-trap guard: no status-bearing command is piped away`);
12602
12594
  }
12603
12595
 
12604
- // src/scripts/check-plugin-allowlist-convention.ts
12605
- import { execa as execa20 } from "execa";
12606
-
12607
12596
  // src/lib/plugin-allowlist-convention.ts
12608
12597
  import { readFileSync as readFileSync38 } from "fs";
12609
12598
  import { join as join53 } from "path";
@@ -12713,7 +12702,7 @@ Plugins would be rejected by require_service_principal (ADR-0009). Fix the glob,
12713
12702
 
12714
12703
  // src/scripts/check-plugin-allowlist-convention.ts
12715
12704
  async function runPluginAllowlistConventionCheck() {
12716
- const root = (await execa20("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12705
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12717
12706
  let violations;
12718
12707
  try {
12719
12708
  violations = checkAllowlistConvention(root);
@@ -12740,7 +12729,6 @@ async function runPluginAllowlistConventionCheck() {
12740
12729
  // src/scripts/check-plugin-collisions.ts
12741
12730
  import { existsSync as existsSync47 } from "fs";
12742
12731
  import { join as join55 } from "path";
12743
- import { execa as execa21 } from "execa";
12744
12732
 
12745
12733
  // src/lib/plugin-collision-guard.ts
12746
12734
  import { existsSync as existsSync46, readdirSync as readdirSync23, statSync as statSync14 } from "fs";
@@ -12810,7 +12798,7 @@ function formatCollisions(collisions) {
12810
12798
 
12811
12799
  // src/scripts/check-plugin-collisions.ts
12812
12800
  async function runPluginCollisionCheck() {
12813
- const root = (await execa21("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12801
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12814
12802
  const servicesDir = join55(root, "services");
12815
12803
  if (!existsSync47(servicesDir)) {
12816
12804
  console.log("\u2713 plugin collision guard: no services/ directory \u2014 nothing to compare");
@@ -12828,9 +12816,6 @@ async function runPluginCollisionCheck() {
12828
12816
  console.log("\u2713 plugin collision guard: OK");
12829
12817
  }
12830
12818
 
12831
- // src/scripts/check-plugin-terraform.ts
12832
- import { execa as execa22 } from "execa";
12833
-
12834
12819
  // src/lib/plugin-terraform-guard.ts
12835
12820
  import { existsSync as existsSync48, readFileSync as readFileSync39, readdirSync as readdirSync24 } from "fs";
12836
12821
  import { dirname as dirname10, join as join56, relative as relative10, sep as sep4 } from "path";
@@ -12902,7 +12887,7 @@ function formatViolations(violations) {
12902
12887
 
12903
12888
  // src/scripts/check-plugin-terraform.ts
12904
12889
  async function runPluginTerraformCheck() {
12905
- const root = (await execa22("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12890
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12906
12891
  const violations = checkPluginTerraform(root);
12907
12892
  if (violations.length > 0) {
12908
12893
  console.error("\u2717 plugin Terraform guard: event subscriptions with no infrastructure\n");
@@ -12915,7 +12900,6 @@ async function runPluginTerraformCheck() {
12915
12900
  // src/scripts/check-plugin-tool-supply.ts
12916
12901
  import { existsSync as existsSync50 } from "fs";
12917
12902
  import { join as join58 } from "path";
12918
- import { execa as execa23 } from "execa";
12919
12903
 
12920
12904
  // src/lib/plugin-tool-supply-audit.ts
12921
12905
  import { existsSync as existsSync49, readFileSync as readFileSync40, readdirSync as readdirSync25, statSync as statSync15 } from "fs";
@@ -13867,7 +13851,7 @@ function auditPluginToolSupply(pluginsRoot) {
13867
13851
 
13868
13852
  // src/scripts/check-plugin-tool-supply.ts
13869
13853
  async function runPluginToolSupplyCheck() {
13870
- const root = (await execa23("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
13854
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
13871
13855
  let allOk = true;
13872
13856
  const pluginsRoot = join58(root, "services", "_plugins");
13873
13857
  if (!existsSync50(pluginsRoot)) {
@@ -13946,9 +13930,6 @@ async function runPluginToolSupplyCheck() {
13946
13930
  }
13947
13931
  }
13948
13932
 
13949
- // src/scripts/check-release-subject.ts
13950
- import { execa as execa24 } from "execa";
13951
-
13952
13933
  // src/lib/release-version.ts
13953
13934
  var MINOR_TYPES = /* @__PURE__ */ new Set(["feat"]);
13954
13935
  var SUBJECT = /^([a-z]+)(?:\([^)]*\))?(!)?:\s+\S/;
@@ -13984,7 +13965,7 @@ async function fetchPrTitleViaGh({
13984
13965
  PR_NUMBER,
13985
13966
  GH_REPO
13986
13967
  }) {
13987
- const { stdout } = await execa24(
13968
+ const { stdout } = await execa(
13988
13969
  "gh",
13989
13970
  ["pr", "view", PR_NUMBER, "--repo", GH_REPO, "--json", "title", "--jq", ".title"],
13990
13971
  { env: { ...process.env, GH_TOKEN } }
@@ -14020,7 +14001,7 @@ async function resolveReleaseSubject({
14020
14001
  );
14021
14002
  }
14022
14003
  }
14023
- return (await execa24("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
14004
+ return (await execa("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
14024
14005
  }
14025
14006
  async function runReleaseSubjectCheck(argv) {
14026
14007
  const base = process.env["GITHUB_BASE_REF"] ?? argv[0];
@@ -14028,9 +14009,9 @@ async function runReleaseSubjectCheck(argv) {
14028
14009
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
14029
14010
  process.exit(2);
14030
14011
  }
14031
- const root = (await execa24("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14032
- await execa24("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
14033
- const { stdout } = await execa24("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
14012
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14013
+ await execa("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
14014
+ const { stdout } = await execa("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
14034
14015
  cwd: root
14035
14016
  });
14036
14017
  const changedFiles = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
@@ -14283,7 +14264,6 @@ async function runSharedFileReductionCheck(args) {
14283
14264
  // src/scripts/check-skeleton-drift.ts
14284
14265
  import { existsSync as existsSync51, readdirSync as readdirSync27 } from "fs";
14285
14266
  import { join as join60 } from "path";
14286
- import { execa as execa25 } from "execa";
14287
14267
 
14288
14268
  // src/lib/skeleton-drift-guard.ts
14289
14269
  import { readFileSync as readFileSync43, readdirSync as readdirSync26, statSync as statSync16 } from "fs";
@@ -14412,7 +14392,7 @@ function discoverSkeletons(root) {
14412
14392
  return entries.filter((name) => existsSync51(join60(skeletonsDir, name, ".github", "workflows", "ci.yml"))).sort();
14413
14393
  }
14414
14394
  async function runSkeletonDriftCheck() {
14415
- const root = (await execa25("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14395
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14416
14396
  const skeletons = discoverSkeletons(root);
14417
14397
  let filesConsidered = 0;
14418
14398
  for (const name of skeletons) {
@@ -14444,9 +14424,8 @@ async function runSkeletonDriftCheck() {
14444
14424
  }
14445
14425
 
14446
14426
  // src/scripts/check-terraform-input.ts
14447
- import { execa as execa26 } from "execa";
14448
14427
  async function runTerraformInputCheck() {
14449
- const root = (await execa26("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14428
+ const root = (await execa("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
14450
14429
  const files = findWorkflowFiles(root);
14451
14430
  console.log(`audited ${files.length} workflow file(s) under ${root}`);
14452
14431
  if (files.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.297.0",
3
+ "version": "0.297.2",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/scripts/claim.sh CHANGED
@@ -107,6 +107,21 @@
107
107
  # - **Excludes the branch being pushed, and any PR whose head IS that
108
108
  # branch**, from counting as a conflict. Without this, pushing your own
109
109
  # branch a second time blocks you on your own work.
110
+ # - **Excludes a SUPERSEDED PREDECESSOR** — a remote branch naming the same
111
+ # issue whose every commit is already carried by the branch being pushed,
112
+ # which is what a rebase leaves behind. An issue number has no lineage, so
113
+ # the number-only comparison could not tell that branch from a rival
114
+ # session's, and a dead predecessor blocked every later push naming the
115
+ # issue (tabsii-com/tabsii-platform#1112). Decided from the object graph by
116
+ # `branch_is_absorbed` below, which fails CLOSED — anything it cannot
117
+ # positively demonstrate stays a conflict, INCLUDING a candidate carrying
118
+ # no commits of its own. A branch sitting at `dev`'s tip is a reservation
119
+ # (AGENTS.md asks for exactly that: "push your branch as soon as it
120
+ # exists"), and "every commit on it is already carried" is vacuously true
121
+ # of a branch with no commits — so without that requirement the discount
122
+ # waved through the most ordinary rival there is. Note the discount applies
123
+ # to the BRANCH signal only: an OPEN PR is a live claim regardless of
124
+ # lineage and is never discounted.
110
125
  # - **A real conflict — another branch or another open PR naming the same
111
126
  # issue — exits 1**, naming what was found. AGENTS.md permits stealing a
112
127
  # claim that is over an hour stale, with a comment; the message points
@@ -308,6 +323,146 @@ derive_branch_issue() {
308
323
  printf '%s' "$_dbi_n" | sed 's/^0*\([0-9]\)/\1/'
309
324
  }
310
325
 
326
+ # --- lineage: is remote branch tip $1 already carried by local branch $2? -----
327
+ #
328
+ # The branch check below compares ISSUE NUMBERS, and a number has no lineage.
329
+ # So it could not tell a genuine RIVAL CLAIMANT -- another session working the
330
+ # same issue -- from the pusher's OWN SUPERSEDED PREDECESSOR, the branch they
331
+ # abandoned and rebased away from. Measured live on tabsii-platform:
332
+ # `fix/1050-1033-1061-upstream-carry` is still on the remote at 5b1b8977 while
333
+ # its successor `fix/1050-1033-1061-carry-rebased` was auto-deleted on merge,
334
+ # so every future push naming 1050 is refused by a dead branch
335
+ # (tabsii-com/tabsii-platform#1112).
336
+ #
337
+ # **`git merge-base --is-ancestor` is not the primitive.** A rebase rewrites
338
+ # every commit, so the predecessor's tip stops being an ancestor of its own
339
+ # successor -- verified by experiment (`is-ancestor` rc=1 on exactly the pair
340
+ # `git cherry` reports as fully equivalent). Ancestry answers a strictly
341
+ # narrower question and would have caught none of the reported case.
342
+ #
343
+ # So the question asked here is not "is this branch mine?" -- identity is not a
344
+ # trustworthy signal in this estate and this script never compares it -- but the
345
+ # stronger, decidable one:
346
+ #
347
+ # **does the candidate carry any work the branch being pushed does not
348
+ # already have?**
349
+ #
350
+ # If it does not, there is nothing to collide over whoever made it: pushing
351
+ # cannot duplicate or lose work that is already in hand. That is a property of
352
+ # the object graph, not of a name, and it is what makes the discount safe to
353
+ # grant automatically rather than via a hand-maintained skip list.
354
+ #
355
+ # **A claim reserves FUTURE work; a content test can only see PAST work.**
356
+ # This is the predicate's structural limit, and the next reader should have it
357
+ # rather than rediscover it. `git cherry` compares commits that exist; a claim
358
+ # is about commits that do not exist yet. So no content-subset test can ever be
359
+ # complete, and the only safe posture is the one taken here: discount ONLY on
360
+ # positive evidence that the candidate is a replay of work already in hand, and
361
+ # read everything else -- including its own silence -- as a rival.
362
+ #
363
+ # It FAILS CLOSED at every step -- each `return 1` means "conflict", and the
364
+ # only route to `return 0` is positive evidence:
365
+ #
366
+ # 1. Both refs must be known. No local tip, no candidate sha, no discount.
367
+ # 2. The candidate's objects must be present LOCALLY. A branch this machine
368
+ # has never fetched cannot be shown to be superseded, and absence of
369
+ # evidence is not evidence of supersession.
370
+ # 3. The candidate must carry AT LEAST ONE COMMIT OF ITS OWN. Step 5 asks
371
+ # "does the candidate carry work this push does not already have?", and
372
+ # that question is VACUOUSLY TRUE of a candidate with no commits at all --
373
+ # a branch pointing at `dev`'s tip, or at anything else already reachable
374
+ # from the pusher. `git cherry` then emits nothing, no `+` line is found,
375
+ # and a LIVE RESERVATION is discounted while the notice calls it a
376
+ # superseded predecessor. Reproduced: with `fix/1050-other-agent` pushed
377
+ # at `dev`'s tip, `--guard fix/1050-mine` printed `discounted
378
+ # fix/1050-other-agent` and exited 0.
379
+ #
380
+ # That is the LIKELY rival shape, not an exotic one. AGENTS.md asks for
381
+ # exactly it -- "push your branch as soon as it exists. The claim is a
382
+ # reservation; the branch is the evidence" -- so an agent that stakes an
383
+ # issue before writing code produces a commitless branch by following the
384
+ # documented protocol. It is also symmetric: two sessions staking one
385
+ # issue seconds apart both sit at `dev`'s tip and would each discount the
386
+ # other, which is the 2026-08-03 collision shape. Nothing else catches it
387
+ # either -- a commitless branch has no open PR to find, and `--guard`
388
+ # deliberately never reads the `in-progress` label.
389
+ #
390
+ # **Own commits are counted against the branch being pushed, not against
391
+ # `dev`** -- `git rev-list <candidate> --not <local tip>`, i.e. the
392
+ # commits on the candidate's side of the merge base with this push. Three
393
+ # reasons that is the right base:
394
+ # - It is the SAME frame of reference steps 4 and 5 already use, so the
395
+ # three cannot disagree about what "beyond" means. A second base would
396
+ # be a second authority, which is this estate's most-repeated defect.
397
+ # - `dev` is not knowable here. `--guard` is handed one branch name by a
398
+ # pre-push hook, is never told the integration branch, and a candidate
399
+ # need not derive from it anyway.
400
+ # - It answers the question actually being asked. Every candidate that
401
+ # must not be discounted counts zero against it: at `dev`'s tip, at
402
+ # the pusher's own tip (the symmetric race), and at any older ancestor
403
+ # -- all are already reachable, so none carries anything of its own.
404
+ # A candidate whose only commits are MERGES counts non-zero here and is
405
+ # refused by step 4 instead; this step is deliberately not the one that
406
+ # decides that case.
407
+ # 4. No unmerged MERGE COMMIT. `git cherry` compares non-merge commits by
408
+ # patch id and omits merges entirely -- verified: a candidate carrying one
409
+ # merge commit had that commit listed by neither `+` nor `-`, so an evil
410
+ # merge's own resolution would be invisible. Refuse rather than guess.
411
+ # 5. `git cherry <local tip> <candidate>` must emit no `+` line. `-` means an
412
+ # equivalent patch is already present (what a rebase produces); `+` means
413
+ # the candidate carries something this push does not.
414
+ #
415
+ # Known FALSE POSITIVES (still blocks, conservatively):
416
+ # - a predecessor that was SQUASHED or amended rather than replayed -- the
417
+ # patch ids differ, so it reads as a rival. Verified: `git merge --squash`
418
+ # of the predecessor produces `+` on both of its commits.
419
+ # - a predecessor whose objects are not in this clone (fresh machine).
420
+ # - a candidate carrying a merge commit.
421
+ # - a predecessor left pointing at a commit already reachable from this push
422
+ # (someone reset it back onto `dev`). It carries nothing of its own, so
423
+ # step 3 refuses it -- correctly, because that branch is indistinguishable
424
+ # from a rival's fresh reservation.
425
+ #
426
+ # Known FALSE NEGATIVES (discounts something that was not ours):
427
+ # - a genuine rival that has committed real work, EVERY commit of which has
428
+ # a patch-id equivalent already in the pushed branch (a cherry-pick of
429
+ # exactly this work and nothing more), AND whose objects happen to be in
430
+ # this clone. Their work is then already fully in hand, so there is no
431
+ # duplicated effort left to warn about -- the discount is right for the
432
+ # wrong reason. This is the residual gap. Step 3 narrows it to rivals who
433
+ # have actually duplicated this push's content, rather than leaving it open
434
+ # to anyone who merely staked a branch, but it cannot close it: see the
435
+ # future/past limit at the top of this comment.
436
+ # - the reverse of (4) cannot happen: a merge is refused, never absorbed.
437
+ branch_is_absorbed() {
438
+ _abs_cand="$1"
439
+ _abs_tip="$2"
440
+
441
+ [ -n "$_abs_cand" ] || return 1
442
+ [ -n "$_abs_tip" ] || return 1
443
+
444
+ git cat-file -e "${_abs_cand}^{commit}" 2>/dev/null || return 1
445
+
446
+ # Step 3 (see above): zero own commits is a RESERVATION, never a
447
+ # supersession. Counted against the branch being pushed, which is the same
448
+ # base the two checks below use. A count that cannot be computed, or that
449
+ # comes back non-numeric, is a cannot-tell and fails closed like everything
450
+ # else here -- `[` itself returns non-zero on a non-integer operand, so the
451
+ # `|| return 1` covers that without a second parse.
452
+ _abs_own=$(git rev-list --count "$_abs_cand" --not "$_abs_tip" 2>/dev/null) || return 1
453
+ [ -n "$_abs_own" ] || return 1
454
+ [ "$_abs_own" -gt 0 ] 2>/dev/null || return 1
455
+
456
+ _abs_merges=$(git rev-list --merges --count "$_abs_cand" --not "$_abs_tip" 2>/dev/null) || return 1
457
+ [ "$_abs_merges" = "0" ] || return 1
458
+
459
+ _abs_cherry=$(git cherry "$_abs_tip" "$_abs_cand" 2>/dev/null) || return 1
460
+ if printf '%s\n' "$_abs_cherry" | grep -q '^+'; then
461
+ return 1
462
+ fi
463
+ return 0
464
+ }
465
+
311
466
  # --- the structural claim predicate (#1411, class #1362 instance 8) ---------
312
467
  #
313
468
  # "Does this open PR claim issue $1?" used to be answered independently at
@@ -467,6 +622,12 @@ if [ -n "$GUARD_BRANCH" ]; then
467
622
  conflict=0
468
623
  findings=""
469
624
  cannot_tell_reasons=""
625
+ absorbed_branches=""
626
+
627
+ # The local tip of the branch being pushed, resolved once. Empty when it
628
+ # cannot be resolved (the caller named a branch this repo does not have), and
629
+ # `branch_is_absorbed` then discounts nothing -- exactly today's behaviour.
630
+ guard_tip=$(git rev-parse --verify --quiet "refs/heads/$GUARD_BRANCH^{commit}" 2>/dev/null) || guard_tip=""
470
631
 
471
632
  # --- an open PR referencing the issue, excluding our own branch's PR --------
472
633
  slug=$(repo_slug)
@@ -502,22 +663,49 @@ if [ -n "$GUARD_BRANCH" ]; then
502
663
  # derived (#1672), and compare the two normalised numbers -- not a
503
664
  # substring search for $guard_issue inside the candidate's raw text. See
504
665
  # `derive_branch_issue` above for why that asymmetry was the defect.
505
- other_branch=$(printf '%s\n' "$raw_branches" |
506
- sed 's|.*refs/heads/||' |
507
- grep -v -x "$GUARD_BRANCH" |
508
- while IFS= read -r _cand; do
666
+ # Each candidate that names the same issue is then classified by LINEAGE
667
+ # (see `branch_is_absorbed`): `rival` is a conflict, `absorbed` is this
668
+ # pusher's own superseded predecessor and is reported but not blocked.
669
+ # The candidate's SHA -- `git ls-remote`'s first, tab-separated field -- is
670
+ # what makes that question answerable, so it is no longer thrown away by a
671
+ # `sed` that kept only the name.
672
+ _classified=$(printf '%s\n' "$raw_branches" |
673
+ while IFS= read -r _line; do
674
+ case "$_line" in
675
+ *"refs/heads/"*) ;;
676
+ *) continue ;;
677
+ esac
678
+ _cand=${_line##*refs/heads/}
509
679
  [ -n "$_cand" ] || continue
680
+ [ "$_cand" = "$GUARD_BRANCH" ] && continue
510
681
  _cand_issue=$(derive_branch_issue "$_cand")
511
- if [ -n "$_cand_issue" ] && [ "$_cand_issue" = "$guard_issue" ]; then
512
- printf '%s\n' "$_cand"
682
+ [ -n "$_cand_issue" ] || continue
683
+ [ "$_cand_issue" = "$guard_issue" ] || continue
684
+ _cand_sha=$(printf '%s\n' "$_line" | cut -f1)
685
+ if branch_is_absorbed "$_cand_sha" "$guard_tip"; then
686
+ printf 'absorbed %s\n' "$_cand"
687
+ else
688
+ printf 'rival %s\n' "$_cand"
513
689
  fi
514
- done | head -1)
690
+ done)
691
+
692
+ other_branch=$(printf '%s\n' "$_classified" | sed -n 's/^rival //p' | head -1)
693
+ absorbed_branches=$(printf '%s\n' "$_classified" | sed -n 's/^absorbed //p')
515
694
  if [ -n "$other_branch" ]; then
516
695
  conflict=1
517
696
  findings="${findings} ${RED}branch${OFF} $other_branch\n"
518
697
  fi
519
698
  fi
520
699
 
700
+ # A discount a guard grants silently is a guard nobody can audit, so say so
701
+ # -- on stderr, only when it actually fired, and whether or not a real rival
702
+ # was also found.
703
+ if [ -n "$absorbed_branches" ]; then
704
+ printf '%b' "${DIM}claim --guard: discounted $(printf '%s' "$absorbed_branches" | tr '\n' ' ') ${OFF}" >&2
705
+ echo "${DIM}-- every commit on it is already carried by $GUARD_BRANCH, so it is a${OFF}" >&2
706
+ echo "${DIM}superseded predecessor rather than a rival claim (#1112).${OFF}" >&2
707
+ fi
708
+
521
709
  if [ "$conflict" -eq 1 ]; then
522
710
  printf '%b' "${RED}claim --guard: issue #$guard_issue looks claimed by someone else.${OFF}\n$findings"
523
711
  echo