automata-cli 0.8.0-develop.331 → 0.8.0-develop.353

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/README.md CHANGED
@@ -74,12 +74,12 @@ Checks that the PR exists and is merged, the working tree is clean, and the remo
74
74
  Execute the full GitFlow release sequence and push to `origin`. Only requires `git`.
75
75
 
76
76
  ```bash
77
- automata git publish-release # auto-detect version from master tag
77
+ automata git publish-release # auto-detect version from the trunk tag
78
78
  automata git publish-release 2.0.0 # explicit version
79
79
  automata git publish-release --dry-run # preview without executing
80
80
  ```
81
81
 
82
- When no version is given, the latest semver tag on `master` is detected and the minor segment is incremented (e.g. `1.2.0 → 1.3.0`).
82
+ When no version is given, the trunk branch is resolved from `origin` (`main`, `master` or whatever the remote calls it), tags are fetched, and the latest semver tag on `origin/<trunk>` is detected and the minor segment incremented (e.g. `1.2.0 → 1.3.0`). See [docs/git.md](docs/git.md#automata-git-publish-release).
83
83
 
84
84
  ---
85
85
 
@@ -36,7 +36,7 @@ var EXECUTOR_OPTIONS = [
36
36
  { label: "Claude Code", value: "claude" },
37
37
  { label: "Codex", value: "codex" }
38
38
  ];
39
- var MAIN_MENU_OPTIONS = ["Remote / Mode", "Implement-Next", "Prompts", "Issue Watch", "Do Work"];
39
+ var MAIN_MENU_OPTIONS = ["Remote / Mode", "Implement-Next", "Prompts", "Issue Watch", "Do Work", "Git"];
40
40
  var PROMPTS_MENU_OPTIONS = [
41
41
  "Sonar",
42
42
  "Fix-Comments",
@@ -173,6 +173,7 @@ function ConfigWizard() {
173
173
  const [doWorkPrOrphanPrompt, setDoWorkPrOrphanPrompt] = useState(
174
174
  existing.doWork?.prompts?.prOrphan ?? DEFAULT_DO_WORK_PR_ORPHAN_PROMPT
175
175
  );
176
+ const [gitTrunkBranch, setGitTrunkBranch] = useState(existing.git?.trunkBranch ?? "");
176
177
  const [pendingRemote, setPendingRemote] = useState(existing.remoteType ?? "gh");
177
178
  const [pendingTechnique, setPendingTechnique] = useState(
178
179
  existing.issueDiscoveryTechnique ?? "label"
@@ -379,6 +380,16 @@ function ConfigWizard() {
379
380
  setScreen("prompts-menu");
380
381
  },
381
382
  onBack: () => setScreen("prompts-menu")
383
+ },
384
+ "git-trunk-branch": {
385
+ setValue: setGitTrunkBranch,
386
+ onSubmit: () => {
387
+ const branch = gitTrunkBranch.trim();
388
+ const current = readRawConfig();
389
+ writeConfig({ ...current, git: { ...current.git, trunkBranch: branch || void 0 } });
390
+ exit();
391
+ },
392
+ onBack: () => setScreen("main")
382
393
  }
383
394
  };
384
395
  const menus = {
@@ -392,6 +403,7 @@ function ConfigWizard() {
392
403
  else if (chosen === "Implement-Next") setScreen("technique");
393
404
  else if (chosen === "Issue Watch") setScreen("allowed-users");
394
405
  else if (chosen === "Do Work") setScreen("do-work-base-branch");
406
+ else if (chosen === "Git") setScreen("git-trunk-branch");
395
407
  else setScreen("prompts-menu");
396
408
  }
397
409
  },
@@ -550,6 +562,12 @@ function ConfigWizard() {
550
562
  label: "Instructions for a pull request with no linked issue:",
551
563
  value: doWorkPrOrphanPrompt,
552
564
  hint: `Type prompt \xB7 Enter to save \xB7 ${BACK}`
565
+ },
566
+ "git-trunk-branch": {
567
+ title: "Git \u2014 Trunk Branch",
568
+ label: "Branch publish-release releases to (blank = detect it from the remote):",
569
+ value: gitTrunkBranch,
570
+ hint: `Type branch \xB7 Enter to save \xB7 ${BACK}`
553
571
  }
554
572
  };
555
573
  const menuViews = {
package/dist/index.js CHANGED
@@ -214,12 +214,23 @@ var configSetDoWorkPrompt = new Command("do-work-prompt").description("Set the t
214
214
  process.stdout.write(`do-work ${turnKind} prompt set.
215
215
  `);
216
216
  });
217
- var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkEffort).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkPrompt);
217
+ var configSetGitTrunkBranch = new Command("git-trunk-branch").description("Pin the branch `publish-release` releases to, instead of detecting it").argument("<value>", "Branch name (unset = detect it from the remote)").action((value) => {
218
+ const branch = value.trim();
219
+ if (branch.length === 0) {
220
+ process.stderr.write("Error: git-trunk-branch requires a non-empty branch name.\n");
221
+ process.exit(1);
222
+ }
223
+ const current = readRawConfig();
224
+ writeConfig({ ...current, git: { ...current.git, trunkBranch: branch } });
225
+ process.stdout.write(`git trunk branch set to: ${branch}
226
+ `);
227
+ });
228
+ var configSet = new Command("set").description("Set a configuration value").addCommand(configSetType).addCommand(configSetIssueDiscoveryTechnique).addCommand(configSetIssueDiscoveryValue).addCommand(configSetClaudeSystemPrompt).addCommand(configSetAllowedUsers).addCommand(configSetAgentUser).addCommand(configSetDoWorkBaseBranch).addCommand(configSetDoWorkProtectedBranches).addCommand(configSetDoWorkExecutor).addCommand(configSetDoWorkModel).addCommand(configSetDoWorkEffort).addCommand(configSetDoWorkMaxRuns).addCommand(configSetDoWorkLockStaleMinutes).addCommand(configSetDoWorkPrompt).addCommand(configSetGitTrunkBranch);
218
229
  var configCommand = new Command("config").description("Configure automata settings").addCommand(configSet).action(async () => {
219
230
  const [{ render }, React, { ConfigWizard }] = await Promise.all([
220
231
  import("ink"),
221
232
  import("react"),
222
- import("./ConfigWizard-D6NYL2E6.js")
233
+ import("./ConfigWizard-GULICHML.js")
223
234
  ]);
224
235
  const { waitUntilExit } = render(React.createElement(ConfigWizard));
225
236
  await waitUntilExit();
@@ -230,6 +241,7 @@ import { Command as Command2 } from "commander";
230
241
 
231
242
  // src/git/gitService.ts
232
243
  import { spawnSync as spawnSync2 } from "child_process";
244
+ import { existsSync } from "fs";
233
245
 
234
246
  // src/config/azdoService.ts
235
247
  import { spawnSync } from "child_process";
@@ -279,6 +291,44 @@ function getPrInfo() {
279
291
  };
280
292
  }
281
293
 
294
+ // src/git/trunkDetection.ts
295
+ var TRUNK_CANDIDATES = ["main", "master"];
296
+ var TRUNK_BRANCH_CONFIG_KEY = "git.trunkBranch";
297
+ var ORIGIN_HEAD_PREFIX = "refs/remotes/origin/";
298
+ var SYMREF_PREFIX = "ref: refs/heads/";
299
+ function parseOriginHeadRef(stdout) {
300
+ const ref = stdout.trim();
301
+ if (!ref.startsWith(ORIGIN_HEAD_PREFIX)) return null;
302
+ const branch = ref.slice(ORIGIN_HEAD_PREFIX.length);
303
+ return branch.length > 0 ? branch : null;
304
+ }
305
+ function parseLsRemoteSymref(stdout) {
306
+ for (const line of stdout.split("\n")) {
307
+ const trimmed2 = line.trim();
308
+ if (!trimmed2.startsWith(SYMREF_PREFIX)) continue;
309
+ const branch = trimmed2.slice(SYMREF_PREFIX.length).split(/\s+/)[0];
310
+ if (branch.length > 0) return branch;
311
+ }
312
+ return null;
313
+ }
314
+ function describeTrunkSource(source, branch) {
315
+ switch (source) {
316
+ case "config":
317
+ return `configured as ${TRUNK_BRANCH_CONFIG_KEY}`;
318
+ case "origin-head":
319
+ return "from origin/HEAD";
320
+ case "ls-remote":
321
+ return "from the remote's advertised HEAD";
322
+ case "probe":
323
+ return `probed as origin/${branch}`;
324
+ }
325
+ }
326
+ function unresolvedTrunkMessage(attempted) {
327
+ return `Could not determine the trunk branch of 'origin'.
328
+ Tried: ${attempted.join(", ")}.
329
+ Set it explicitly with: automata config set git-trunk-branch <branch>`;
330
+ }
331
+
282
332
  // src/git/gitService.ts
283
333
  function run2(cmd, args) {
284
334
  const result = spawnSync2(cmd, args, { encoding: "utf8" });
@@ -752,7 +802,7 @@ function checkoutAndPull(targetBranch) {
752
802
  if (checkout.status !== 0) {
753
803
  throw new Error(`Failed to checkout ${targetBranch}: ${checkout.stderr.trim()}`);
754
804
  }
755
- const pull = run2("git", ["pull"]);
805
+ const pull = run2("git", ["pull", "--ff-only"]);
756
806
  if (pull.status !== 0) {
757
807
  throw new Error(`Failed to pull ${targetBranch}: ${pull.stderr.trim()}`);
758
808
  }
@@ -785,6 +835,41 @@ function isAncestorCommit(maybeAncestor, descendant) {
785
835
  function resetHardTo(ref) {
786
836
  return gitCommand(["reset", "--hard", ref]);
787
837
  }
838
+ var CHERRY_LINE = /^([+-]) ([0-9a-f]+)$/;
839
+ function describeDivergence(upstream, head) {
840
+ const cherry = run2("git", ["cherry", upstream, head]);
841
+ if (cherry.status !== 0) return null;
842
+ const commits = [];
843
+ for (const line of cherry.stdout.split("\n")) {
844
+ const trimmed2 = line.trim();
845
+ if (trimmed2.length === 0) continue;
846
+ const match = CHERRY_LINE.exec(trimmed2);
847
+ if (match === null) return null;
848
+ commits.push({ sha: match[2], alreadyUpstream: match[1] === "-" });
849
+ }
850
+ const merges = run2("git", ["rev-list", "--count", "--merges", `${upstream}..${head}`]);
851
+ if (merges.status !== 0) return null;
852
+ const count = Number.parseInt(merges.stdout.trim(), 10);
853
+ if (!Number.isInteger(count)) return null;
854
+ return { commits, merges: count };
855
+ }
856
+ function rebaseOnto(ref) {
857
+ const { stdout, stderr, status } = run2("git", ["rebase", "--no-reapply-cherry-picks", ref]);
858
+ const detail = [stdout.trim(), stderr.trim()].filter((part) => part.length > 0).join("\n");
859
+ return { ok: status === 0, stderr: detail };
860
+ }
861
+ function abortRebase() {
862
+ return gitCommand(["rebase", "--abort"]);
863
+ }
864
+ var REBASE_STATE_PATHS = ["rebase-merge", "rebase-apply/rebasing"];
865
+ function isRebaseInProgress() {
866
+ return REBASE_STATE_PATHS.some((name) => {
867
+ const { stdout, status } = run2("git", ["rev-parse", "--git-path", name]);
868
+ if (status !== 0) return false;
869
+ const path = stdout.trim();
870
+ return path.length > 0 && existsSync(path);
871
+ });
872
+ }
788
873
  function fetchPrune() {
789
874
  const result = run2("git", ["fetch", "--prune"]);
790
875
  if (result.status !== 0) {
@@ -927,7 +1012,63 @@ function resolveCurrentBranchComments() {
927
1012
  return { ok: true, branch, comments: raw };
928
1013
  }
929
1014
  var SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
930
- function getLatestTagOnMaster() {
1015
+ function remoteBranchExists(branch) {
1016
+ return !isUpstreamGone(branch);
1017
+ }
1018
+ function resolveTrunkBranch() {
1019
+ const configured = readRawConfig().git?.trunkBranch?.trim();
1020
+ if (configured) {
1021
+ return { ok: true, branch: configured, source: "config" };
1022
+ }
1023
+ const attempted = [];
1024
+ attempted.push("origin/HEAD");
1025
+ const head = run2("git", ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
1026
+ if (head.status === 0) {
1027
+ const branch = parseOriginHeadRef(head.stdout);
1028
+ if (branch !== null) return { ok: true, branch, source: "origin-head" };
1029
+ }
1030
+ attempted.push("git ls-remote --symref origin HEAD");
1031
+ const symref = run2("git", ["ls-remote", "--symref", "origin", "HEAD"]);
1032
+ if (symref.status === 0) {
1033
+ const branch = parseLsRemoteSymref(symref.stdout);
1034
+ if (branch !== null) return { ok: true, branch, source: "ls-remote" };
1035
+ }
1036
+ for (const candidate of TRUNK_CANDIDATES) {
1037
+ attempted.push(`origin/${candidate}`);
1038
+ if (remoteBranchExists(candidate)) {
1039
+ return { ok: true, branch: candidate, source: "probe" };
1040
+ }
1041
+ }
1042
+ return { ok: false, attempted };
1043
+ }
1044
+ function fetchTrunkAndTags(trunk) {
1045
+ const { status, stderr } = run2("git", [
1046
+ "fetch",
1047
+ "--tags",
1048
+ "origin",
1049
+ `+refs/heads/${trunk}:refs/remotes/origin/${trunk}`
1050
+ ]);
1051
+ if (status !== 0) {
1052
+ return { ok: false, message: stderr.trim() || `git fetch --tags origin ${trunk} failed.` };
1053
+ }
1054
+ return { ok: true };
1055
+ }
1056
+ function localBranchExists(branch) {
1057
+ const { status } = run2("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
1058
+ return status === 0;
1059
+ }
1060
+ function trunkBehindCount(trunk) {
1061
+ if (!localBranchExists(trunk)) return 0;
1062
+ const { stdout, status } = run2("git", [
1063
+ "rev-list",
1064
+ "--count",
1065
+ `refs/heads/${trunk}..refs/remotes/origin/${trunk}`
1066
+ ]);
1067
+ if (status !== 0) return 0;
1068
+ const count = Number.parseInt(stdout.trim(), 10);
1069
+ return Number.isNaN(count) ? 0 : count;
1070
+ }
1071
+ function getLatestTagOnTrunk(ref) {
931
1072
  const { stdout, status } = run2("git", [
932
1073
  "describe",
933
1074
  "--tags",
@@ -936,7 +1077,7 @@ function getLatestTagOnMaster() {
936
1077
  "[0-9]*.[0-9]*.[0-9]*",
937
1078
  "--match",
938
1079
  "v[0-9]*.[0-9]*.[0-9]*",
939
- "master"
1080
+ ref
940
1081
  ]);
941
1082
  if (status !== 0) return null;
942
1083
  const tag = stdout.trim();
@@ -957,17 +1098,45 @@ ${stderr.trim()}`);
957
1098
  }
958
1099
  return stdout.trim().length > 0;
959
1100
  }
960
- function publishRelease(version2, dryRun) {
1101
+ function checkReleasePreconditions() {
1102
+ let branch;
1103
+ try {
1104
+ branch = getCurrentBranch();
1105
+ } catch (err) {
1106
+ return { ok: false, message: err.message };
1107
+ }
1108
+ if (branch !== "develop") {
1109
+ return {
1110
+ ok: false,
1111
+ message: `publish-release must be run from the 'develop' branch (currently on '${branch}').`
1112
+ };
1113
+ }
1114
+ if (hasUncommittedChanges()) {
1115
+ return {
1116
+ ok: false,
1117
+ message: "You have uncommitted changes. Commit or stash them before publishing a release."
1118
+ };
1119
+ }
1120
+ return { ok: true };
1121
+ }
1122
+ function publishRelease(version2, dryRun, trunk) {
961
1123
  const releaseBranch = `release/${version2}`;
1124
+ const checkoutTrunk = localBranchExists(trunk) ? { args: ["checkout", trunk], desc: `git checkout ${trunk}` } : {
1125
+ args: ["checkout", "-b", trunk, `origin/${trunk}`],
1126
+ desc: `git checkout -b ${trunk} origin/${trunk}`
1127
+ };
962
1128
  const steps = [
963
1129
  { args: ["checkout", "-b", releaseBranch], desc: `git checkout -b ${releaseBranch}` },
964
- { args: ["checkout", "master"], desc: `git checkout master` },
1130
+ checkoutTrunk,
965
1131
  { args: ["merge", "--no-ff", releaseBranch], desc: `git merge --no-ff ${releaseBranch}` },
966
1132
  { args: ["tag", version2], desc: `git tag ${version2}` },
967
1133
  { args: ["checkout", "develop"], desc: `git checkout develop` },
968
1134
  { args: ["merge", "--no-ff", releaseBranch], desc: `git merge --no-ff ${releaseBranch}` },
969
1135
  { args: ["branch", "-d", releaseBranch], desc: `git branch -d ${releaseBranch}` },
970
- { args: ["push", "origin", "develop", "master", version2], desc: `git push origin develop master ${version2}` }
1136
+ {
1137
+ args: ["push", "origin", "develop", trunk, version2],
1138
+ desc: `git push origin develop ${trunk} ${version2}`
1139
+ }
971
1140
  ];
972
1141
  for (const step of steps) {
973
1142
  if (dryRun) {
@@ -983,6 +1152,29 @@ ${stderr.trim()}`);
983
1152
  }
984
1153
  }
985
1154
 
1155
+ // src/git/releaseVersion.ts
1156
+ var SEMVER_ARG_RE = /^\d+\.\d+\.\d+$/;
1157
+ function resolveReleaseVersion(requested, trunkRef, latestTag) {
1158
+ if (requested !== void 0) {
1159
+ if (!SEMVER_ARG_RE.test(requested)) {
1160
+ return {
1161
+ ok: false,
1162
+ message: `Version '${requested}' is not valid semver. Use X.Y.Z format (e.g. 1.2.0).`
1163
+ };
1164
+ }
1165
+ return { ok: true, version: requested, notice: null };
1166
+ }
1167
+ const latest = latestTag();
1168
+ if (latest === null) {
1169
+ return {
1170
+ ok: false,
1171
+ message: `No semver tag found on ${trunkRef}. Pass a version explicitly: automata git publish-release <X.Y.Z>`
1172
+ };
1173
+ }
1174
+ const version2 = bumpMinorVersion(latest);
1175
+ return { ok: true, version: version2, notice: `Auto-detected version: ${latest} \u2192 ${version2}` };
1176
+ }
1177
+
986
1178
  // src/commands/git.ts
987
1179
  var FAIL_CONCLUSIONS = /* @__PURE__ */ new Set(["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"]);
988
1180
  var SKIP_CONCLUSIONS = /* @__PURE__ */ new Set(["SKIPPED", "NEUTRAL"]);
@@ -1347,59 +1539,58 @@ var finishFeatureCmd = new Command2("finish-feature").description("Clean up a me
1347
1539
  process.exit(1);
1348
1540
  }
1349
1541
  });
1350
- var SEMVER_ARG_RE = /^\d+\.\d+\.\d+$/;
1351
- var publishReleaseCmd = new Command2("publish-release").description("Execute the full GitFlow release sequence and push to origin").argument("[version]", "Release version in X.Y.Z format (auto-detected from master tag if omitted)").option("--dry-run", "Print git commands without executing them").addHelpText(
1542
+ var publishReleaseCmd = new Command2("publish-release").description("Execute the full GitFlow release sequence and push to origin").argument("[version]", "Release version in X.Y.Z format (auto-detected from the trunk tag if omitted)").option("--dry-run", "Print git commands without executing them").addHelpText(
1352
1543
  "after",
1353
1544
  `
1354
1545
  Release sequence:
1355
1546
  1. git checkout -b release/<version>
1356
- 2. git checkout master && git merge --no-ff release/<version>
1547
+ 2. git checkout <trunk> && git merge --no-ff release/<version>
1357
1548
  3. git tag <version>
1358
1549
  4. git checkout develop && git merge --no-ff release/<version>
1359
1550
  5. git branch -d release/<version>
1360
- 6. git push origin develop master <version>
1551
+ 6. git push origin develop <trunk> <version>
1361
1552
 
1362
- When [version] is omitted the latest semver tag on master is detected and the
1363
- minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).`
1553
+ <trunk> is resolved from origin \u2014 git.trunkBranch in .automata/config.json if
1554
+ set, else origin/HEAD, the remote's advertised HEAD, or a probe of main/master.
1555
+ Tags are fetched from origin first, in --dry-run too, so the version a dry run
1556
+ prints is the one a real run would use.
1557
+
1558
+ When [version] is omitted the latest semver tag on origin/<trunk> is detected
1559
+ and the minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).`
1364
1560
  ).action((version2, options) => {
1365
1561
  const dryRun = options.dryRun ?? false;
1366
- let branch;
1367
- try {
1368
- branch = getCurrentBranch();
1369
- } catch (err) {
1370
- process.stderr.write(`Error: ${err.message}
1562
+ const preconditions = checkReleasePreconditions();
1563
+ if (!preconditions.ok) {
1564
+ process.stderr.write(`Error: ${preconditions.message}
1371
1565
  `);
1372
1566
  process.exit(1);
1373
1567
  }
1374
- if (branch !== "develop") {
1375
- process.stderr.write(
1376
- `Error: publish-release must be run from the 'develop' branch (currently on '${branch}').
1377
- `
1378
- );
1568
+ const trunk = resolveTrunkBranch();
1569
+ if (!trunk.ok) {
1570
+ process.stderr.write(`Error: ${unresolvedTrunkMessage(trunk.attempted)}
1571
+ `);
1379
1572
  process.exit(1);
1380
1573
  }
1381
- if (hasUncommittedChanges()) {
1382
- process.stderr.write("Error: You have uncommitted changes. Commit or stash them before publishing a release.\n");
1574
+ const trunkBranch = trunk.branch;
1575
+ const trunkRef = `origin/${trunkBranch}`;
1576
+ process.stdout.write(`Trunk branch: ${trunkBranch} (${describeTrunkSource(trunk.source, trunkBranch)})
1577
+ `);
1578
+ const fetched = fetchTrunkAndTags(trunkBranch);
1579
+ if (!fetched.ok) {
1580
+ process.stderr.write(`Error: Failed to fetch ${trunkBranch} and tags from origin.
1581
+ ${fetched.message}
1582
+ `);
1383
1583
  process.exit(1);
1384
1584
  }
1385
- let resolvedVersion;
1386
- if (version2 !== void 0) {
1387
- if (!SEMVER_ARG_RE.test(version2)) {
1388
- process.stderr.write(`Error: Version '${version2}' is not valid semver. Use X.Y.Z format (e.g. 1.2.0).
1585
+ const versionResult = resolveReleaseVersion(version2, trunkRef, () => getLatestTagOnTrunk(trunkRef));
1586
+ if (!versionResult.ok) {
1587
+ process.stderr.write(`Error: ${versionResult.message}
1389
1588
  `);
1390
- process.exit(1);
1391
- }
1392
- resolvedVersion = version2;
1393
- } else {
1394
- const latest = getLatestTagOnMaster();
1395
- if (latest === null) {
1396
- process.stderr.write(
1397
- "Error: No semver tag found on master. Pass a version explicitly: automata git publish-release <X.Y.Z>\n"
1398
- );
1399
- process.exit(1);
1400
- }
1401
- resolvedVersion = bumpMinorVersion(latest);
1402
- process.stdout.write(`Auto-detected version: ${latest} \u2192 ${resolvedVersion}
1589
+ process.exit(1);
1590
+ }
1591
+ const resolvedVersion = versionResult.version;
1592
+ if (versionResult.notice !== null) {
1593
+ process.stdout.write(`${versionResult.notice}
1403
1594
  `);
1404
1595
  }
1405
1596
  if (tagExists(resolvedVersion)) {
@@ -1407,6 +1598,14 @@ minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).`
1407
1598
  `);
1408
1599
  process.exit(1);
1409
1600
  }
1601
+ const behind = trunkBehindCount(trunkBranch);
1602
+ if (behind > 0) {
1603
+ process.stderr.write(
1604
+ `Error: Local branch '${trunkBranch}' is ${String(behind)} commit(s) behind ${trunkRef}. Update it (git checkout ${trunkBranch} && git merge --ff-only ${trunkRef}) or delete it, then re-run.
1605
+ `
1606
+ );
1607
+ process.exit(1);
1608
+ }
1410
1609
  if (dryRun) {
1411
1610
  process.stdout.write(`Dry-run: release ${resolvedVersion}
1412
1611
  `);
@@ -1415,7 +1614,7 @@ minor segment is incremented (e.g. 1.2.0 \u2192 1.3.0).`
1415
1614
  `);
1416
1615
  }
1417
1616
  try {
1418
- publishRelease(resolvedVersion, dryRun);
1617
+ publishRelease(resolvedVersion, dryRun, trunkBranch);
1419
1618
  } catch (err) {
1420
1619
  process.stderr.write(`Error: ${err.message}
1421
1620
  `);
@@ -2035,7 +2234,7 @@ function parseCreatedPrUrl(stdout, head) {
2035
2234
  // src/claude/claudeService.ts
2036
2235
  import { spawn, spawnSync as spawnSync5 } from "child_process";
2037
2236
  import { createInterface } from "readline";
2038
- import { existsSync } from "fs";
2237
+ import { existsSync as existsSync2 } from "fs";
2039
2238
  import { delimiter, join } from "path";
2040
2239
 
2041
2240
  // src/cli/spawnUtils.ts
@@ -2129,7 +2328,7 @@ function resolveCommand(name) {
2129
2328
  const pathDirs = (process.env["PATH"] ?? "").split(delimiter);
2130
2329
  for (const dir of pathDirs) {
2131
2330
  const candidate = join(dir, name);
2132
- if (existsSync(candidate)) return candidate;
2331
+ if (existsSync2(candidate)) return candidate;
2133
2332
  }
2134
2333
  return name;
2135
2334
  }
@@ -3668,7 +3867,7 @@ function prepareBaseBranch(baseBranch) {
3668
3867
  if (!pull.ok) {
3669
3868
  return { ok: false, reason: "pull-failed", detail: pull.stderr };
3670
3869
  }
3671
- return { ok: true, branch: baseBranch };
3870
+ return { ok: true, branch: baseBranch, strategy: "fast-forward" };
3672
3871
  }
3673
3872
  function resetToForcePushedRemote(headRefName, previousRemoteSha) {
3674
3873
  const localSha = revParse(`refs/heads/${headRefName}`);
@@ -3678,7 +3877,81 @@ function resetToForcePushedRemote(headRefName, previousRemoteSha) {
3678
3877
  if (!reset.ok) {
3679
3878
  return { ok: false, reason: "pull-failed", detail: reset.stderr };
3680
3879
  }
3681
- return { ok: true, branch: headRefName };
3880
+ return { ok: true, branch: headRefName, strategy: "reset-to-remote" };
3881
+ }
3882
+ function rebaseOntoAlreadyAppliedRemote(headRefName) {
3883
+ const upstream = `refs/remotes/origin/${headRefName}`;
3884
+ const divergence = describeDivergence(upstream, `refs/heads/${headRefName}`);
3885
+ if (divergence === null) return null;
3886
+ if (divergence.merges > 0) return null;
3887
+ if (divergence.commits.length === 0) return null;
3888
+ if (divergence.commits.some((commit) => !commit.alreadyUpstream)) return null;
3889
+ if (isRebaseInProgress()) {
3890
+ return {
3891
+ ok: false,
3892
+ reason: "pull-failed",
3893
+ detail: `a rebase is already in progress in this checkout and automata did not start it, so ${headRefName} was left untouched; finish it with \`git rebase --continue\` or drop it with \`git rebase --abort\``
3894
+ };
3895
+ }
3896
+ const rebase = rebaseOnto(upstream);
3897
+ if (!rebase.ok) {
3898
+ if (!isRebaseInProgress()) {
3899
+ return {
3900
+ ok: false,
3901
+ reason: "pull-failed",
3902
+ detail: `${rebase.stderr} \u2014 the rebase never started, so ${headRefName} is untouched`
3903
+ };
3904
+ }
3905
+ const aborted = abortRebase();
3906
+ const restored = aborted.ok ? `the rebase was aborted, so ${headRefName} is back where it was` : `the rebase could NOT be aborted (${aborted.stderr}) \u2014 run \`git rebase --abort\` in the checkout`;
3907
+ return {
3908
+ ok: false,
3909
+ reason: "rebase-conflict",
3910
+ detail: `${rebase.stderr} \u2014 ${restored}`
3911
+ };
3912
+ }
3913
+ const local = revParse(`refs/heads/${headRefName}`);
3914
+ const remote = revParse(upstream);
3915
+ if (local === null || remote === null || local !== remote) {
3916
+ return {
3917
+ ok: false,
3918
+ reason: "pull-failed",
3919
+ detail: `the rebase of ${headRefName} onto origin/${headRefName} reported success but did not land on the remote tip, so the branch is still out of sync; compare them with \`git log origin/${headRefName}..${headRefName}\``
3920
+ };
3921
+ }
3922
+ return { ok: true, branch: headRefName, strategy: "rebase" };
3923
+ }
3924
+ function divergenceRefusal(headRefName, pullError) {
3925
+ const divergence = describeDivergence(
3926
+ `refs/remotes/origin/${headRefName}`,
3927
+ `refs/heads/${headRefName}`
3928
+ );
3929
+ if (divergence === null) {
3930
+ return {
3931
+ ok: false,
3932
+ reason: "pull-failed",
3933
+ detail: `${pullError} \u2014 how the local ${headRefName} relates to origin/${headRefName} could not be established, so nothing was changed and ${headRefName} is untouched; inspect it with \`git log --oneline origin/${headRefName}...${headRefName}\` before deciding what to do`
3934
+ };
3935
+ }
3936
+ const unpushedCount = divergence.commits.filter((c) => !c.alreadyUpstream).length + divergence.merges;
3937
+ if (unpushedCount === 0) {
3938
+ return {
3939
+ ok: false,
3940
+ reason: "pull-failed",
3941
+ detail: `${pullError} \u2014 no commit of the local ${headRefName} is missing from origin/${headRefName}, so this is not a divergence; ${headRefName} is untouched and git's own error above is the cause to fix`
3942
+ };
3943
+ }
3944
+ let unpushed;
3945
+ if (unpushedCount === 1) {
3946
+ unpushed = `1 of its commits is not on origin/${headRefName}`;
3947
+ } else {
3948
+ unpushed = `${String(unpushedCount)} of its commits are not on origin/${headRefName}`;
3949
+ }
3950
+ return {
3951
+ ok: false,
3952
+ reason: "pull-failed",
3953
+ detail: `${pullError} \u2014 the local ${headRefName} has diverged from origin/${headRefName} and ${unpushed}, so it was not synchronized automatically; inspect them with \`git log origin/${headRefName}..${headRefName}\` and \`git reset --hard origin/${headRefName}\` yourself once they are safe to lose`
3954
+ };
3682
3955
  }
3683
3956
  function preparePrBranch(headRefName) {
3684
3957
  if (hasUncommittedChanges([RUN_LOCK_RELATIVE_PATH])) return dirtyTree();
@@ -3687,25 +3960,28 @@ function preparePrBranch(headRefName) {
3687
3960
  if (!fetched.ok) {
3688
3961
  return { ok: false, reason: "checkout-failed", detail: fetched.stderr };
3689
3962
  }
3963
+ const localExisted = revParse(`refs/heads/${headRefName}`) !== null;
3690
3964
  const checkout = checkoutBranch(headRefName);
3691
3965
  if (!checkout.ok) {
3692
3966
  const created = createTrackingBranch(headRefName);
3693
3967
  if (!created.ok) {
3694
3968
  return { ok: false, reason: "checkout-failed", detail: created.stderr };
3695
3969
  }
3696
- return { ok: true, branch: headRefName };
3970
+ return { ok: true, branch: headRefName, strategy: "tracking-branch" };
3697
3971
  }
3698
3972
  const pull = pullFastForwardOnly(headRefName);
3699
3973
  if (!pull.ok) {
3700
3974
  const reset = resetToForcePushedRemote(headRefName, previousRemoteSha);
3701
3975
  if (reset !== null) return reset;
3702
- return {
3703
- ok: false,
3704
- reason: "pull-failed",
3705
- detail: `${pull.stderr} \u2014 the local ${headRefName} has commits origin/${headRefName} does not, so it was not reset automatically; inspect them and \`git reset --hard origin/${headRefName}\` yourself`
3706
- };
3976
+ const rebased = rebaseOntoAlreadyAppliedRemote(headRefName);
3977
+ if (rebased !== null) return rebased;
3978
+ return divergenceRefusal(headRefName, pull.stderr);
3707
3979
  }
3708
- return { ok: true, branch: headRefName };
3980
+ return {
3981
+ ok: true,
3982
+ branch: headRefName,
3983
+ strategy: localExisted ? "fast-forward" : "tracking-branch"
3984
+ };
3709
3985
  }
3710
3986
 
3711
3987
  // src/git/repoHygiene.ts
@@ -4073,6 +4349,18 @@ function repoField(repo) {
4073
4349
  const flat = oneLine(repo);
4074
4350
  return flat.length === 0 ? "-" : flat;
4075
4351
  }
4352
+ var QUIET_SYNC = /* @__PURE__ */ new Set(["fast-forward", "tracking-branch"]);
4353
+ function summarizeSync(items) {
4354
+ const counts = /* @__PURE__ */ new Map();
4355
+ for (const item of items) {
4356
+ if (item.sync === void 0) continue;
4357
+ const key = oneLine(item.sync);
4358
+ if (key.length === 0 || QUIET_SYNC.has(key)) continue;
4359
+ counts.set(key, (counts.get(key) ?? 0) + 1);
4360
+ }
4361
+ if (counts.size === 0) return null;
4362
+ return [...counts].map(([strategy, count]) => `${strategy}:${String(count)}`).join(",");
4363
+ }
4076
4364
  function formatExecutionLine(tick) {
4077
4365
  const counts = {
4078
4366
  answered: 0,
@@ -4096,6 +4384,8 @@ function formatExecutionLine(tick) {
4096
4384
  `exit=${String(tick.exitCode)}`,
4097
4385
  `dur=${(tick.durationMs / 1e3).toFixed(1)}s`
4098
4386
  ];
4387
+ const sync = summarizeSync(tick.items);
4388
+ if (sync !== null) fields.push(`sync=${sync}`);
4099
4389
  if (tick.note !== void 0 && tick.note.length > 0) fields.push(`note=${tick.note}`);
4100
4390
  return fields.join(" ") + "\n";
4101
4391
  }
@@ -4112,13 +4402,18 @@ function describeItemExecution(item) {
4112
4402
  const effort = item.effort === void 0 ? "" : ` effort=${oneLine(item.effort)}`;
4113
4403
  return ` [${oneLine(item.executor)}${model}${effort}]`;
4114
4404
  }
4405
+ function describeItemSync(item) {
4406
+ if (item.sync === void 0) return "";
4407
+ const flat = oneLine(item.sync);
4408
+ return flat.length === 0 ? "" : ` sync=${flat}`;
4409
+ }
4115
4410
  function formatWorkRecord(tick) {
4116
4411
  const ran = tick.items.filter((item) => item.ranExecutor);
4117
4412
  if (ran.length === 0) return null;
4118
4413
  const header = `=== ${tick.timestamp.toISOString()} ${repoField(tick.repo)} ===
4119
4414
  `;
4120
4415
  const lines = ran.map(
4121
- (item) => `${oneLine(item.subject)} ${item.turn === null ? "-" : oneLine(item.turn)} ${item.outcome}${describeItemExecution(item)} \u2014 ${briefDetail(item.detail)}
4416
+ (item) => `${oneLine(item.subject)} ${item.turn === null ? "-" : oneLine(item.turn)} ${item.outcome}${describeItemExecution(item)}${describeItemSync(item)} \u2014 ${briefDetail(item.detail)}
4122
4417
  `
4123
4418
  );
4124
4419
  return header + lines.join("") + "\n";
@@ -4202,6 +4497,7 @@ function recordTick(tick, dir = operationLogDirectory()) {
4202
4497
 
4203
4498
  // src/commands/doWork.ts
4204
4499
  var inFlightMarker = null;
4500
+ var inFlightSync = null;
4205
4501
  function subjectLabel(issue, pr) {
4206
4502
  if (issue !== null) return `#${String(issue)}`;
4207
4503
  return pr === null ? "#?" : `PR #${String(pr)}`;
@@ -4840,6 +5136,7 @@ async function processItem(planned, settings, silent, preflightCauses) {
4840
5136
  progress(`
4841
5137
  ${itemLabel(planned)} ${planned.turn}: ${planned.reason}
4842
5138
  `);
5139
+ inFlightSync = null;
4843
5140
  const refreshed = refreshItem(planned, settings);
4844
5141
  if (refreshed.kind === "skip") {
4845
5142
  progress(` skipped: ${refreshed.detail}
@@ -4872,9 +5169,18 @@ ${itemLabel(planned)} ${planned.turn}: ${planned.reason}
4872
5169
  return {
4873
5170
  ...base,
4874
5171
  outcome: "skipped",
4875
- detail: `${prepared.reason}: ${prepared.detail}${why}`
5172
+ detail: `${prepared.reason}: ${prepared.detail}${why}`,
5173
+ sync: prepared.reason
4876
5174
  };
4877
5175
  }
5176
+ base.sync = prepared.strategy;
5177
+ inFlightSync = prepared.strategy;
5178
+ if (prepared.strategy !== "fast-forward" && prepared.strategy !== "tracking-branch") {
5179
+ progress(
5180
+ ` ${prepared.branch} had diverged from origin; synchronized by ${prepared.strategy}.
5181
+ `
5182
+ );
5183
+ }
4878
5184
  claimIssue(item, settings);
4879
5185
  if (item.turn === "pr-work" && item.pr !== null && item.prNeedsAssignment) {
4880
5186
  claimPr(item.pr.number, settings.participants.agentUser);
@@ -5077,7 +5383,8 @@ function toTickLogItem(report) {
5077
5383
  ranExecutor: report.ranExecutor ?? false,
5078
5384
  executor: report.execution?.executor,
5079
5385
  model: report.execution?.model,
5080
- effort: report.execution?.effort
5386
+ effort: report.execution?.effort,
5387
+ sync: report.sync
5081
5388
  };
5082
5389
  }
5083
5390
  function logTick(reports, exitCode, startedAt, note) {
@@ -5183,7 +5490,8 @@ ${describePlan(decisions)}`;
5183
5490
  title: itemTitle(item),
5184
5491
  turn: item.turn,
5185
5492
  outcome: "failed",
5186
- detail: err.message
5493
+ detail: err.message,
5494
+ sync: inFlightSync ?? void 0
5187
5495
  };
5188
5496
  }
5189
5497
  if (report.ranExecutor === true) runsUsed++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.8.0-develop.331",
3
+ "version": "0.8.0-develop.353",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "engines": {
@@ -37,14 +37,14 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^10.0.1",
40
- "@types/node": "^26.5.0",
40
+ "@types/node": "^26.6.1",
41
41
  "@types/react": "^19.3.0",
42
42
  "eslint": "^10.10.0",
43
43
  "ink-testing-library": "^4.0.0",
44
- "prettier": "^3.9.6",
44
+ "prettier": "^3.9.7",
45
45
  "tsup": "^8.5.1",
46
46
  "typescript": "^5.9.3",
47
47
  "typescript-eslint": "^8.70.0",
48
- "vitest": "^5.0.0"
48
+ "vitest": "^5.0.1"
49
49
  }
50
50
  }