@workit-dev/core 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
package/src/git/ops.ts ADDED
@@ -0,0 +1,1823 @@
1
+ // `workit git branch|commit|push` (design §2.1 S11): policy-checked local git
2
+ // effects plus an observed, verified push. The CLI verb only parses flags and
3
+ // resolves the forge identity; every rule lives here.
4
+ //
5
+ // - branch: the name must pass the workspace branch policy (protected names
6
+ // and allowed patterns, core/branch.ts). The base defaults to the policy's
7
+ // default target and is taken from the freshly fetched remote branch; a
8
+ // local, unprotected, non-default base is a stack parent and is used as is.
9
+ // Dirt outside docs/ and untracked files needs --carry.
10
+ // - commit: never on a protected branch; the subject must pass the workspace
11
+ // commit convention (auto = the flavor the history uses). Only the index,
12
+ // the named paths (`--only` semantics) or, with --all, everything is
13
+ // committed: unrelated dirt is never swept in implicitly. A named path the
14
+ // user staged in part (`git add -p`, `workit git stage --patch`) commits its
15
+ // staged content only, unless --whole; --staged commits exactly the index.
16
+ // The acting session
17
+ // goes into a `Workit-Session:` trailer and a `commit.recorded` ledger row,
18
+ // so a verdict from the same session reads as the author's (D18). A
19
+ // workspace with `trailers: false` gets the ledger row only. `--trailer`
20
+ // adds the caller's own trailers (Co-Authored-By) to the same commit.
21
+ // - push: never to a protected branch, an exact SHA (a commit that lands while
22
+ // pushing is not pushed by accident), force only as --force-with-lease with
23
+ // the tip workit last recorded, and success only when the remote tip
24
+ // observed afterwards equals the local SHA (`push.verified` row).
25
+ import { spawnSync } from "node:child_process";
26
+ import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
27
+ import path from "node:path";
28
+ import {
29
+ branchKindOf,
30
+ classifyBranchDirt,
31
+ dirtyPaths,
32
+ flowMismatch,
33
+ flowRoute,
34
+ gitFlowOf,
35
+ isProtectedTarget,
36
+ releaseBranch,
37
+ releaseBranchesOf,
38
+ resolveCommitPolicyFor,
39
+ validateBranchNameFor,
40
+ validateCommitMessageFor,
41
+ type BranchKind,
42
+ type FlowRoute,
43
+ } from "../core/branch";
44
+ import { releaseTagPrefixOf } from "../core/branch-policy";
45
+ import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
46
+ import { pushTargetIsStable } from "../core/pr-create";
47
+ import { readReleaseTracks, recordBranchBase, trackBranchName } from "../core/release-tracks";
48
+ import { configFilePath } from "../core/config";
49
+ import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
50
+ import {
51
+ resolveRuntimeReleaseTracks,
52
+ resolveRuntimeWorkspacePolicy,
53
+ workspaceTrailers,
54
+ } from "../core/workspaces";
55
+ import { redactText } from "../forge/redact";
56
+ import { failure, success, type ForgeResult } from "../forge/types";
57
+ import { issuedBinding, sessionClaimRefusal } from "../host-session";
58
+ import { appendObserved, readLedger, type LedgerActor } from "../ledger";
59
+ import {
60
+ GIT_TIMEOUTS,
61
+ currentBranch,
62
+ fetchRefs,
63
+ gitNetwork,
64
+ headSha,
65
+ parseRemoteUrl,
66
+ pushRemoteName,
67
+ pushUrl,
68
+ redactRemote,
69
+ remoteNames,
70
+ remoteRefTip,
71
+ resolveRef,
72
+ } from "./rev";
73
+
74
+ /** Commits run the repository's hooks (lint, tests), so they get a long bound. */
75
+ const COMMIT_TIMEOUT_MS = 10 * 60_000;
76
+ const PUSH_TIMEOUT_MS = 120_000;
77
+
78
+ type Run = {
79
+ ok: boolean;
80
+ status: number | null;
81
+ stdout: string;
82
+ stderr: string;
83
+ signal?: NodeJS.Signals | null;
84
+ };
85
+
86
+ export const git = (
87
+ cwd: string,
88
+ args: string[],
89
+ options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {},
90
+ ): Run => {
91
+ const result = spawnSync("git", args, {
92
+ cwd,
93
+ encoding: "utf8",
94
+ env: { ...(options.env ?? process.env), GIT_TERMINAL_PROMPT: "0" },
95
+ maxBuffer: 64 * 1024 * 1024,
96
+ timeout: options.timeoutMs ?? GIT_TIMEOUTS.local,
97
+ killSignal: "SIGKILL",
98
+ windowsHide: true,
99
+ ...(options.input === undefined
100
+ ? { stdio: ["ignore", "pipe", "pipe"] as const }
101
+ : { input: options.input, stdio: ["pipe", "pipe", "pipe"] as const }),
102
+ });
103
+ return {
104
+ ok: result.status === 0,
105
+ status: result.status,
106
+ stdout: result.stdout ?? "",
107
+ stderr: result.stderr ?? "",
108
+ signal: result.signal,
109
+ };
110
+ };
111
+
112
+ /** The last meaningful lines of git's stderr, redacted, for an error message. */
113
+ export const gitError = (
114
+ run: { stderr: string; stdout?: string; signal?: NodeJS.Signals | null },
115
+ max = 3,
116
+ ): string => {
117
+ const lines = redactText(`${run.stderr}\n${run.stdout ?? ""}`)
118
+ .split(/\r?\n/u)
119
+ .map((value) => value.trim())
120
+ .filter((value) => value && !value.startsWith("hint:"));
121
+ return (
122
+ lines.slice(-max).join(" / ").slice(0, 400) ||
123
+ (run.signal ? `git was killed by ${run.signal} (timed out) and printed nothing` : "git failed")
124
+ );
125
+ };
126
+
127
+ /**
128
+ * What a failed `git switch -c <name>` left behind. The branch ref is deleted
129
+ * only when it was absent before the switch and now points at the base this
130
+ * run passed. An index.lock is never removed (it may belong to a live git);
131
+ * after a kill the message names the check and the command instead.
132
+ */
133
+ export const recoverFailedSwitch = (
134
+ cwd: string,
135
+ name: string,
136
+ run: Run,
137
+ before: { existed: boolean; baseSha: string },
138
+ ): string[] => {
139
+ const notes: string[] = [];
140
+ if (run.signal) {
141
+ const lock = git(cwd, [
142
+ "rev-parse",
143
+ "--path-format=absolute",
144
+ "--git-path",
145
+ "index.lock",
146
+ ]).stdout.trim();
147
+ if (lock && existsSync(lock))
148
+ notes.push(`index.lock remains: if no git process is running, remove it with: rm ${lock}`);
149
+ }
150
+ if (!before.existed && resolveRef(cwd, `refs/heads/${name}`) === before.baseSha) {
151
+ if (currentBranch(cwd) !== name && git(cwd, ["branch", "-D", "--", name]).ok)
152
+ notes.push(`deleted the half-made ${name}`);
153
+ }
154
+ return notes;
155
+ };
156
+
157
+ /**
158
+ * One line naming the rule a commit-msg hook (commitlint) refused, from its
159
+ * `✖ … [rule]` output: `commit refused by commit-msg hook: header-max-length 100 (got 104)`.
160
+ * Null when the output names no rule.
161
+ */
162
+ export const hookRefusal = (output: string): string | null => {
163
+ const match = /✖\s+(.*?)\s*\[([\w-]+)\]/u.exec(output);
164
+ if (!match) return null;
165
+ const [, text, rule] = match;
166
+ const limit = /(?:longer|shorter|exceed\w*|more|less|than)\D*?(\d+)/iu.exec(text)?.[1];
167
+ const got = /(?:current length is|got|is)\s+(\d+)/iu.exec(text)?.[1];
168
+ return `commit refused by commit-msg hook: ${rule}${limit ? ` ${limit}` : ""}${got ? ` (got ${got})` : ""}${limit || got ? "" : ` (${text})`}`;
169
+ };
170
+
171
+ /** Past this many lines, hook output keeps its head and tail. */
172
+ const OUTPUT_LINES = 200;
173
+ /** A longer output line (a minified bundle, a JSON blob) is cut here. */
174
+ const OUTPUT_LINE_CHARS = 1000;
175
+
176
+ /**
177
+ * Everything git and its hooks printed, redacted and without `hint:` lines, so
178
+ * a failing lint hook's errors reach the agent. Long output keeps the first
179
+ * and last OUTPUT_LINES / 2 lines.
180
+ */
181
+ const gitOutput = (run: { stderr: string; stdout?: string }): string => {
182
+ const lines = redactText(`${run.stderr}\n${run.stdout ?? ""}`)
183
+ .split(/\r?\n/u)
184
+ .map((value) => value.trimEnd())
185
+ .filter((value) => value.trim() && !value.trimStart().startsWith("hint:"))
186
+ .map((value) =>
187
+ value.length > OUTPUT_LINE_CHARS ? `${value.slice(0, OUTPUT_LINE_CHARS)}…` : value,
188
+ );
189
+ if (!lines.length) return "git failed";
190
+ if (lines.length <= OUTPUT_LINES) return lines.join("\n");
191
+ const half = OUTPUT_LINES / 2;
192
+ return [
193
+ ...lines.slice(0, half),
194
+ `… ${lines.length - OUTPUT_LINES} lines omitted …`,
195
+ ...lines.slice(-half),
196
+ ].join("\n");
197
+ };
198
+
199
+ type Operation = "rebase" | "am" | "merge" | "cherry-pick" | "revert";
200
+
201
+ type InProgress = {
202
+ operation: Operation;
203
+ /**
204
+ * A commit is the legitimate next step: an interactive rebase stopped at an
205
+ * `edit`, or a revert or merge without conflicts (`--no-commit`).
206
+ */
207
+ commitAllowed: boolean;
208
+ /** The branch being rebased (HEAD is detached meanwhile). */
209
+ branch: string | null;
210
+ };
211
+
212
+ /** The multi-step git operation the checkout is in the middle of, if any. */
213
+ export function operationInProgress(cwd: string): InProgress | null {
214
+ const markers: [string, Operation][] = [
215
+ ["rebase-merge", "rebase"],
216
+ ["rebase-apply", "rebase"],
217
+ ["MERGE_HEAD", "merge"],
218
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
219
+ ["REVERT_HEAD", "revert"],
220
+ ];
221
+ const run = git(cwd, ["rev-parse", ...markers.flatMap(([name]) => ["--git-path", name])]);
222
+ if (!run.ok) return null;
223
+ const found = run.stdout.split("\n").map((line) => path.resolve(cwd, line.trim()));
224
+ const conflicted = () => git(cwd, ["ls-files", "-u"]).stdout.trim() !== "";
225
+ for (const [index, [, operation]] of markers.entries()) {
226
+ const marker = found[index];
227
+ if (!marker || !existsSync(marker)) continue;
228
+ // `git am` also uses rebase-apply; its `applying` file tells them apart.
229
+ if (index === 1 && existsSync(path.join(marker, "applying")))
230
+ return { operation: "am", commitAllowed: false, branch: null };
231
+ // An `edit` stop leaves rebase-merge/amend; the rebased branch is head-name.
232
+ if (index === 0 && existsSync(path.join(marker, "amend")) && !conflicted()) {
233
+ let headName = "";
234
+ try {
235
+ headName = readFileSync(path.join(marker, "head-name"), "utf8").trim();
236
+ } catch {
237
+ // No head-name: a rebase of a detached HEAD; there is no branch to commit for.
238
+ }
239
+ const branch = headName.startsWith("refs/heads/")
240
+ ? headName.slice("refs/heads/".length)
241
+ : null;
242
+ return { operation, commitAllowed: branch !== null, branch };
243
+ }
244
+ if ((operation === "revert" || operation === "merge") && !conflicted())
245
+ return { operation, commitAllowed: true, branch: null };
246
+ return { operation, commitAllowed: false, branch: null };
247
+ }
248
+ return null;
249
+ }
250
+
251
+ /** A refusal that names the operation in progress and how to finish or leave it. */
252
+ export function inProgressFailure<T>(operation: Operation, verb: string): ForgeResult<T> {
253
+ const command = operation === "am" ? "git am" : `git ${operation}`;
254
+ return failure(
255
+ "blocked",
256
+ `${operation.replace("-", "_")}_in_progress: a ${operation === "am" ? "git am" : operation} is in progress; finish or abort it before workit can ${verb}`,
257
+ `resolve the conflicts, git add <files>, then ${command} --continue # or ${command} --abort to return to where you started`,
258
+ );
259
+ }
260
+
261
+ const validRefName = (cwd: string, name: string): boolean =>
262
+ !name.startsWith("-") && git(cwd, ["check-ref-format", "--branch", name]).ok;
263
+
264
+ /** Porcelain v1 entries (`XY path`), NUL-separated so any file name survives. */
265
+ function dirtEntries(cwd: string): string[] {
266
+ const run = git(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
267
+ if (!run.ok) return [];
268
+ const parts = run.stdout.split("\0");
269
+ const out: string[] = [];
270
+ for (let index = 0; index < parts.length; index += 1) {
271
+ const entry = parts[index];
272
+ if (!entry) continue;
273
+ out.push(entry);
274
+ // A rename/copy carries its source path in the next field.
275
+ if (entry[0] === "R" || entry[0] === "C") index += 1;
276
+ }
277
+ return out;
278
+ }
279
+
280
+ const nulList = (text: string): string[] => text.split("\0").filter(Boolean);
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // branch
284
+
285
+ export type BranchOutcome = {
286
+ branch: string;
287
+ previous: string | null;
288
+ base: string;
289
+ /** The ref the branch was created from (refs/remotes/origin/main, refs/heads/feature/a, a sha). */
290
+ baseRef: string;
291
+ baseSha: string;
292
+ /** Uncommitted changes came along to the new branch. */
293
+ carried: boolean;
294
+ /** Fallbacks taken (fetch failed, base used from the local copy). */
295
+ notes: string[];
296
+ /** The release track the base and name came from; null without tracks. */
297
+ releaseTrack: ResolvedReleaseTrack | null;
298
+ /** The git-flow route the base came from; null outside git-flow or with a release track. */
299
+ flow: FlowRoute | null;
300
+ };
301
+
302
+ export type BranchInput = {
303
+ /** An explicit branch name; else `kind` + `slug` through the track's naming. */
304
+ name?: string;
305
+ kind?: BranchKind;
306
+ slug?: string;
307
+ base?: string | null;
308
+ carry?: boolean;
309
+ /** An explicit release track (`--track`). */
310
+ track?: string | null;
311
+ };
312
+
313
+ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOutcome> {
314
+ if (!headSha(cwd) && currentBranch(cwd) === null)
315
+ return failure("invalid_input", "not inside a git repository with a commit");
316
+ const progress = operationInProgress(cwd);
317
+ if (progress) return inProgressFailure(progress.operation, "create a branch");
318
+ // One resolution: the default target, the base for new branches and the
319
+ // release track (core/release-tracks.ts) all come from vcsConfig. With
320
+ // --base the track is the base's line (it decides the --kind naming);
321
+ // otherwise the checkout's.
322
+ const explicitBase = input.base?.trim() || null;
323
+ const resolved = vcsConfig("resolve", cwd, {
324
+ track: input.track ?? null,
325
+ ...(explicitBase ? { branch: explicitBase } : {}),
326
+ });
327
+ const releaseTrack: ResolvedReleaseTrack | null =
328
+ resolved.ok === false ? null : (resolved.releaseTrack ?? null);
329
+ // An undetermined line blocks whenever the track would decide something:
330
+ // the base (no --base) or the --kind/--slug name.
331
+ if (releaseTrack?.blocking && (!explicitBase || input.name === undefined))
332
+ return failure(
333
+ "blocked",
334
+ String(releaseTrack.blocking),
335
+ "workit git branch … --track <name> # or --base <branch>",
336
+ );
337
+ const name = (
338
+ input.name ??
339
+ (input.kind && input.slug
340
+ ? (trackBranchName(releaseTrack?.track ?? null, input.kind, input.slug) ??
341
+ `${input.kind}/${input.slug}`)
342
+ : "")
343
+ ).trim();
344
+ if (!name) return failure("invalid_input", "missing branch name");
345
+ if (!validRefName(cwd, name))
346
+ return failure("invalid_input", `invalid branch name ${JSON.stringify(name)}`);
347
+ const policy = validateBranchNameFor(cwd, name);
348
+ if (!policy.ok) return failure("blocked", policy.error, policy.correction);
349
+ if (resolveRef(cwd, `refs/heads/${name}`))
350
+ return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
351
+ // git-flow (no release track): the kind decides the base, and an explicit
352
+ // base that is the other long-lived branch is refused.
353
+ const kind = input.kind ?? branchKindOf(name);
354
+ const flow =
355
+ resolved.ok !== false && !releaseTrack?.track
356
+ ? gitFlowOf(cwd, String(resolved.defaultTargetBranch ?? "") || null)
357
+ : null;
358
+ const route = flow && kind ? flowRoute(flow, kind) : null;
359
+ const mismatch = explicitBase ? flowMismatch(flow, kind, explicitBase, "base") : null;
360
+ if (mismatch && route)
361
+ return failure(
362
+ "blocked",
363
+ mismatch,
364
+ `workit git branch ${input.kind ? `--kind ${kind} --slug ${input.slug}` : name} # without --base it starts from ${route.base}`,
365
+ );
366
+
367
+ let base = explicitBase;
368
+ let defaultTarget: string | null = null;
369
+ let defaultBase: string | null = null;
370
+ // An explicit --track that does not resolve is never ignored, even with --base.
371
+ if (resolved.ok === false && (!base || input.track))
372
+ return failure(
373
+ "blocked",
374
+ String(resolved.error),
375
+ `fix ${configFilePath("vcs.json")} or pass --base <branch>`,
376
+ );
377
+ if (resolved.ok !== false) {
378
+ defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
379
+ defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
380
+ }
381
+ base ??= route?.base ?? defaultBase;
382
+ if (!base)
383
+ return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
384
+ if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
385
+
386
+ const notes: string[] = [...(releaseTrack?.warnings ?? [])];
387
+ if (route?.mergeBack)
388
+ notes.push(
389
+ `git-flow: ${route.kind}/* lands on ${route.target} (workit pr create targets it), then ${route.target} merges back into ${route.mergeBack}`,
390
+ );
391
+ const remotes = remoteNames(cwd);
392
+ const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
393
+ const localTip = resolveRef(cwd, `refs/heads/${base}`);
394
+ // A local, unprotected branch that is not the default target is a stack
395
+ // parent: its local tip is the truth (it may not be pushed yet).
396
+ const stackParent =
397
+ localTip !== null &&
398
+ base !== defaultTarget &&
399
+ base !== defaultBase &&
400
+ !isProtectedTarget(cwd, base);
401
+ let baseRef: string | null = null;
402
+ if (stackParent) baseRef = `refs/heads/${base}`;
403
+ else if (remote && validRefName(cwd, base)) {
404
+ const tracking = `refs/remotes/${remote}/${base}`;
405
+ const fetched = fetchRefs(cwd, remote, [`+refs/heads/${base}:${tracking}`]);
406
+ if (fetched.ok && resolveRef(cwd, tracking)) baseRef = tracking;
407
+ else if (resolveRef(cwd, tracking)) {
408
+ baseRef = tracking;
409
+ notes.push(
410
+ `fetch of ${remote}/${base} failed; branched from the last fetched ${remote}/${base}`,
411
+ );
412
+ } else if (localTip) {
413
+ baseRef = `refs/heads/${base}`;
414
+ notes.push(`${remote}/${base} is not available; branched from local ${base}`);
415
+ }
416
+ } else if (localTip) baseRef = `refs/heads/${base}`;
417
+ if (!baseRef && git(cwd, ["rev-parse", "--verify", "-q", `${base}^{commit}`]).ok) baseRef = base;
418
+ const baseSha = baseRef
419
+ ? git(cwd, ["rev-parse", "--verify", "-q", `${baseRef}^{commit}`]).stdout.trim()
420
+ : "";
421
+ if (!baseRef || !baseSha)
422
+ return failure(
423
+ "not_found",
424
+ `base ${base} does not resolve (no ${remote ?? "remote"}/${base}, no local ${base})`,
425
+ `git fetch ${remote ?? "origin"} ${base} # or pass --base <ref>`,
426
+ );
427
+
428
+ const dirt = classifyBranchDirt(cwd);
429
+ if (dirt === "stash-required" && !input.carry)
430
+ return failure(
431
+ "blocked",
432
+ `dirty_worktree: tracked changes outside docs/ would ride along to the new branch: ${dirtyPaths(cwd)}`,
433
+ `commit them first (workit git commit -m … -- <paths>), or carry them: workit git branch ${name} --carry`,
434
+ );
435
+ const previous = currentBranch(cwd);
436
+ const existedBefore = resolveRef(cwd, `refs/heads/${name}`) !== null;
437
+ const switched = git(cwd, ["switch", "--no-track", "-c", name, baseSha]);
438
+ if (!switched.ok) {
439
+ const undone = recoverFailedSwitch(cwd, name, switched, {
440
+ existed: existedBefore,
441
+ baseSha,
442
+ });
443
+ return failure(
444
+ "failed",
445
+ `git switch -c ${name} failed: ${gitError(switched)}${undone.length ? ` (${undone.join("; ")})` : ""}`,
446
+ `workit git branch ${name} # retry; if it keeps failing, run git status and fix what it names`,
447
+ );
448
+ }
449
+ // The cheapest track signal later (release-tracks.ts): where this branch began.
450
+ recordBranchBase(cwd, name, base);
451
+ return success({
452
+ branch: name,
453
+ previous,
454
+ base,
455
+ baseRef,
456
+ baseSha,
457
+ carried: dirt !== "clean",
458
+ notes,
459
+ releaseTrack,
460
+ flow: route,
461
+ });
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // commit
466
+
467
+ export type CommitOutcome = {
468
+ sha: string;
469
+ branch: string;
470
+ message: string;
471
+ files: string[];
472
+ /** Changes left uncommitted (not selected). */
473
+ leftDirty: number;
474
+ session: string | null;
475
+ /** Whether the commit carries the `Workit-Session:` trailer (workspace `trailers`). */
476
+ sessionTrailer: boolean;
477
+ /** With --amend: the commit this one replaced. */
478
+ amended: string | null;
479
+ /** Follow-ups the agent should know (an amended commit that was already pushed). */
480
+ notes: string[];
481
+ /** Named paths staged in part: only their staged content was committed. */
482
+ keptPartial: string[];
483
+ /** The ledger row id, or why it could not be written (the commit stands). */
484
+ recorded: { id: string } | { error: string };
485
+ };
486
+
487
+ /** The commit convention check: the configured preset, or the history's flavor for `auto`. */
488
+ export function lintCommitMessage(
489
+ cwd: string,
490
+ message: string,
491
+ ): { ok: true } | { ok: false; error: string; correction: string } {
492
+ const check = validateCommitMessageFor(cwd, message);
493
+ if (!check.ok) return { ok: false, error: check.error, correction: check.correction };
494
+ let preset: string;
495
+ try {
496
+ preset = resolveCommitPolicyFor(cwd).preset;
497
+ } catch {
498
+ return { ok: true };
499
+ }
500
+ if (preset !== "auto") return { ok: true };
501
+ const log = git(cwd, ["log", "-n", "50", "--no-merges", "--format=%s"]);
502
+ const subjects = log.ok ? log.stdout.split("\n").filter(Boolean) : [];
503
+ const detected = detectCommitFlavor(subjects).flavor;
504
+ if (!detected || matchCommitFlavor(message, detected)) return { ok: true };
505
+ const subject = message.split("\n", 1)[0] ?? "";
506
+ return {
507
+ ok: false,
508
+ error: `commit_style: commit subject ${JSON.stringify(subject)} does not follow the ${detected} style this repository uses (commitPolicy auto)`,
509
+ correction: `use a ${detected} commit subject`,
510
+ };
511
+ }
512
+
513
+ const SESSION_SAFE = /^[\w.:@/+-]{1,128}$/u;
514
+ /** A `--trailer` the caller adds: one `Key: value` line. */
515
+ const EXTRA_TRAILER = /^[A-Za-z0-9][A-Za-z0-9-]*: *\S[^\n\r]*$/u;
516
+
517
+ const SESSION_KEY = /^workit-session\s*:/iu;
518
+ /** `Key:` as git's find_separator reads it: key characters, optional blanks, then `:`. */
519
+ const TRAILER_LINE = /^[A-Za-z0-9-]+[ \t]*:/u;
520
+ /** Lines git counts as trailers that it recognizes (trailer.c git_generated_prefixes). */
521
+ const GIT_GENERATED = ["Signed-off-by: ", "(cherry picked from commit "];
522
+ const isTrailer = (line: string): boolean =>
523
+ TRAILER_LINE.test(line) || GIT_GENERATED.some((prefix) => line.startsWith(prefix));
524
+ const isContinuation = (line: string): boolean => /^\s/u.test(line);
525
+
526
+ /**
527
+ * The index of the first line of the trailer block in `lines` (no trailing
528
+ * blank lines), or null, by git interpret-trailers' rule
529
+ * (trailer.c find_trailer_block_start): the last paragraph after `from` is a
530
+ * block when every line is a trailer or a continuation, or when it holds a
531
+ * git-generated trailer (Signed-off-by) and at least a quarter of its lines
532
+ * are trailers. `#` comment lines are skipped. With `opensAtStart`, the start
533
+ * of the text counts as a paragraph break (no subject paragraph).
534
+ */
535
+ function trailerBlockStart(
536
+ lines: readonly string[],
537
+ from: number,
538
+ opensAtStart: boolean,
539
+ ): number | null {
540
+ let trailers = 0;
541
+ let others = 0;
542
+ let continuations = 0;
543
+ let recognized = false;
544
+ const decide = (start: number): number | null => {
545
+ others += continuations;
546
+ if ((recognized && trailers * 3 >= others) || (trailers > 0 && others === 0)) return start;
547
+ return null;
548
+ };
549
+ for (let index = lines.length - 1; index >= from; index -= 1) {
550
+ const line = lines[index] ?? "";
551
+ if (line.startsWith("#")) {
552
+ others += continuations;
553
+ continuations = 0;
554
+ } else if (line.trim() === "") return decide(index + 1);
555
+ else if (isTrailer(line) && !isContinuation(line)) {
556
+ trailers += 1;
557
+ continuations = 0;
558
+ if (GIT_GENERATED.some((prefix) => line.startsWith(prefix))) recognized = true;
559
+ } else if (isContinuation(line)) continuations += 1;
560
+ else {
561
+ others += 1 + continuations;
562
+ continuations = 0;
563
+ }
564
+ }
565
+ return opensAtStart && from === 0 ? decide(0) : null;
566
+ }
567
+
568
+ /**
569
+ * `text` without the `Workit-Session:` trailers (and their folded
570
+ * continuation lines) of its trailer block, found as git interpret-trailers
571
+ * finds it, so a final paragraph mixing prose, Signed-off-by and
572
+ * Workit-Session loses only the Workit-Session trailer. Prose, other
573
+ * trailers and the rest of the text are kept byte for byte. With `subject`,
574
+ * the first paragraph is a subject and never a trailer block (a commit
575
+ * message); without it (a PR body) it can be.
576
+ */
577
+ export function stripSessionTrailers(text: string, options: { subject: boolean }): string {
578
+ const end = text.length - (/\s*$/u.exec(text)?.[0].length ?? 0);
579
+ const lines = text.slice(0, end).split("\n");
580
+ let from = 0;
581
+ if (options.subject) {
582
+ while (from < lines.length && lines[from]?.trim() !== "") from += 1;
583
+ }
584
+ const start = trailerBlockStart(lines, from, !options.subject);
585
+ if (start === null) return text;
586
+ const block = lines.slice(start);
587
+ const kept: string[] = [];
588
+ let dropping = false;
589
+ for (const line of block) {
590
+ if (!isContinuation(line)) dropping = isTrailer(line) && SESSION_KEY.test(line);
591
+ if (!dropping) kept.push(line);
592
+ }
593
+ if (kept.length === block.length) return text;
594
+ let head = lines.slice(0, start);
595
+ if (!kept.length) {
596
+ let cut = head.length;
597
+ while (cut > 0 && head[cut - 1]?.trim() === "") cut -= 1;
598
+ head = head.slice(0, cut);
599
+ }
600
+ return [...head, ...kept].join("\n") + text.slice(end);
601
+ }
602
+
603
+ /** The values of the `Workit-Session:` trailers among `trailers`. */
604
+ const sessionValues = (trailers: readonly string[]): string[] =>
605
+ trailers
606
+ .filter((trailer) => SESSION_KEY.test(trailer))
607
+ .map((trailer) => trailer.slice(trailer.indexOf(":") + 1).trim());
608
+
609
+ // Fixed trailer syntax, whatever the user's trailer.* config says.
610
+ const TRAILER_CONFIG = ["-c", "trailer.separators=:", "-c", "trailer.where=end"];
611
+
612
+ /** The trailers (`Key: value`) of a commit message. */
613
+ function messageTrailers(cwd: string, message: string): string[] {
614
+ const run = git(cwd, [...TRAILER_CONFIG, "interpret-trailers", "--parse", "--no-divider"], {
615
+ input: `${message}\n`,
616
+ });
617
+ return run.ok ? run.stdout.split("\n").filter((line) => line.trim()) : [];
618
+ }
619
+
620
+ /**
621
+ * The message with `trailers` appended, keeping every existing trailer and
622
+ * never repeating an identical one (an amend of a commit this session already
623
+ * made). `--no-divider`: a `---` line in the body is text, not a patch start.
624
+ */
625
+ function withTrailers(cwd: string, message: string, trailers: readonly string[]): string {
626
+ if (!trailers.length) return message;
627
+ const run = git(
628
+ cwd,
629
+ [
630
+ ...TRAILER_CONFIG,
631
+ "interpret-trailers",
632
+ "--no-divider",
633
+ "--if-exists",
634
+ "addIfDifferent",
635
+ ...trailers.flatMap((trailer) => ["--trailer", trailer]),
636
+ ],
637
+ { input: `${message}\n` },
638
+ );
639
+ return run.ok ? run.stdout.trim() : `${message}\n\n${trailers.join("\n")}`;
640
+ }
641
+
642
+ /** Short names (`main`, `develop`) of the refs that contain `sha`, local or remote. */
643
+ function branchesContaining(cwd: string, sha: string): Set<string> {
644
+ const run = git(cwd, [
645
+ "for-each-ref",
646
+ "--format=%(refname)",
647
+ "--contains",
648
+ sha,
649
+ "refs/heads",
650
+ "refs/remotes",
651
+ ]);
652
+ const names = new Set<string>();
653
+ for (const ref of run.ok ? run.stdout.split("\n") : []) {
654
+ if (ref.startsWith("refs/heads/")) names.add(ref.slice("refs/heads/".length));
655
+ else if (ref.startsWith("refs/remotes/")) {
656
+ const name = ref.split("/").slice(3).join("/");
657
+ if (name && name !== "HEAD") names.add(name);
658
+ }
659
+ }
660
+ return names;
661
+ }
662
+
663
+ /**
664
+ * The shared branch (protected, or the default target or base) that already
665
+ * contains `sha`: amending it would rewrite a commit that is not this
666
+ * branch's own. Null when `sha` is the branch's own.
667
+ */
668
+ function sharedBranchContaining(cwd: string, sha: string, branch: string): string | null {
669
+ const resolved = vcsConfig("resolve", cwd);
670
+ const defaults = new Set(
671
+ resolved.ok === false
672
+ ? []
673
+ : [resolved.defaultTargetBranch, resolved.baseBranch]
674
+ .map((value) => String(value ?? ""))
675
+ .filter(Boolean),
676
+ );
677
+ for (const name of branchesContaining(cwd, sha))
678
+ if (name !== branch && (defaults.has(name) || isProtectedTarget(cwd, name))) return name;
679
+ return null;
680
+ }
681
+
682
+ const nothingToCommit = (what: string) =>
683
+ failure<CommitOutcome>(
684
+ "failed",
685
+ `nothing_to_commit: ${what}`,
686
+ "workit git commit --allow-empty -m '<msg>' # only to record an empty commit on purpose",
687
+ );
688
+
689
+ /**
690
+ * After a path-form (`--only`) commit, point the real index at HEAD for each
691
+ * named path whose working tree already matches HEAD. Git runs pre-commit on
692
+ * a temporary index there, so an auto-fix hook (lint-staged, `stage_fixed`)
693
+ * fixes the commit and the working tree while the real index keeps the
694
+ * pre-hook copy (`MM`). Paths whose working tree still differs from HEAD, and
695
+ * anything outside `paths`, keep their index entries.
696
+ */
697
+ function resyncCommittedPaths(cwd: string, paths: readonly string[]): void {
698
+ const names = (...args: string[]) =>
699
+ new Set(
700
+ nulList(git(cwd, ["diff", "--name-only", "-z", ...args, "HEAD", "--", ...paths]).stdout),
701
+ );
702
+ const worktreeDiffers = names();
703
+ const stale = [...names("--cached")].filter((file) => !worktreeDiffers.has(file));
704
+ // diff prints paths from the top, without pathspec magic: match them literally.
705
+ if (stale.length)
706
+ git(cwd, ["reset", "-q", "HEAD", "--", ...stale.map((file) => `:(top,literal)${file}`)]);
707
+ }
708
+
709
+ /**
710
+ * Files under `paths` staged in part: the index differs from HEAD and the
711
+ * working tree differs from the index. Top-relative names.
712
+ */
713
+ function partiallyStaged(cwd: string, paths: readonly string[]): string[] {
714
+ const names = (...args: string[]) =>
715
+ nulList(
716
+ git(cwd, ["diff", "--name-only", "-z", "--no-renames", ...args, "--", ...paths]).stdout,
717
+ );
718
+ const unstaged = new Set(names());
719
+ return names("--cached").filter((file) => unstaged.has(file));
720
+ }
721
+
722
+ const literalTop = (file: string) => `:(top,literal)${file}`;
723
+
724
+ /**
725
+ * A private index for a path-form commit that keeps partial staging: a copy
726
+ * of the real index (the named paths already added, partial files as staged)
727
+ * with every staged change outside `paths` put back to HEAD, as `--only`
728
+ * would leave it out. The caller removes the file.
729
+ */
730
+ const PRIVATE_INDEX = /^workit-commit-(\d+)-(\d+)\.index(?:\.lock)?$/u;
731
+ const PRIVATE_INDEX_TTL_MS = 60 * 60_000;
732
+
733
+ const processAlive = (pid: number): boolean => {
734
+ try {
735
+ process.kill(pid, 0);
736
+ return true;
737
+ } catch (error) {
738
+ // EPERM: the process exists under another user.
739
+ return (error as NodeJS.ErrnoException).code === "EPERM";
740
+ }
741
+ };
742
+
743
+ /**
744
+ * Best effort: remove private indexes (and their locks) a killed commit left
745
+ * in `dir`, those whose process is gone or that are over an hour old.
746
+ */
747
+ function sweepPrivateIndexes(dir: string): void {
748
+ let names: string[];
749
+ try {
750
+ names = readdirSync(dir);
751
+ } catch {
752
+ return;
753
+ }
754
+ for (const name of names) {
755
+ const match = PRIVATE_INDEX.exec(name);
756
+ if (!match) continue;
757
+ const pid = Number(match[1]);
758
+ const stale =
759
+ Date.now() - Number(match[2]) > PRIVATE_INDEX_TTL_MS ||
760
+ (pid !== process.pid && !processAlive(pid));
761
+ if (stale) rmSync(path.join(dir, name), { force: true });
762
+ }
763
+ }
764
+
765
+ function pathCommitIndex(
766
+ cwd: string,
767
+ paths: readonly string[],
768
+ env: NodeJS.ProcessEnv,
769
+ ): { file: string } | { error: string } {
770
+ const where = (name: string) =>
771
+ path.resolve(cwd, git(cwd, ["rev-parse", "--git-path", name]).stdout.trim());
772
+ const file = where(`workit-commit-${process.pid}-${Date.now()}.index`);
773
+ sweepPrivateIndexes(path.dirname(file));
774
+ try {
775
+ copyFileSync(where("index"), file);
776
+ } catch (error) {
777
+ return {
778
+ error: `could not copy the index: ${error instanceof Error ? error.message : String(error)}`,
779
+ };
780
+ }
781
+ const indexEnv = { ...env, GIT_INDEX_FILE: file };
782
+ const staged = (...pathspec: string[]) =>
783
+ nulList(
784
+ git(cwd, ["diff", "--cached", "--name-only", "-z", "--no-renames", "--", ...pathspec], {
785
+ env: indexEnv,
786
+ }).stdout,
787
+ );
788
+ const inside = new Set(staged(...paths));
789
+ const outside = staged().filter((name) => !inside.has(name));
790
+ if (outside.length) {
791
+ const reset = git(cwd, ["reset", "-q", "--", ...outside.map(literalTop)], { env: indexEnv });
792
+ if (!reset.ok) {
793
+ rmSync(file, { force: true });
794
+ return { error: `could not leave out the other staged changes: ${gitError(reset)}` };
795
+ }
796
+ }
797
+ return { file };
798
+ }
799
+
800
+ export function gitCommit(
801
+ cwd: string,
802
+ input: {
803
+ /** The new message; with `amend` and no message the amended commit's is kept. */
804
+ message?: string | null;
805
+ all?: boolean;
806
+ paths?: readonly string[];
807
+ /** With paths: commit each one's whole working tree even when staged in part. */
808
+ whole?: boolean;
809
+ /** Commit exactly the index; nothing is added. */
810
+ staged?: boolean;
811
+ /** Replace the branch's last commit (`git commit --amend`). */
812
+ amend?: boolean;
813
+ /** Record a commit even when it changes no file. */
814
+ allowEmpty?: boolean;
815
+ /** Extra `Key: value` trailers for this commit (`--trailer`, e.g. Co-Authored-By). */
816
+ trailers?: readonly string[];
817
+ actor: LedgerActor;
818
+ env?: NodeJS.ProcessEnv;
819
+ },
820
+ ): ForgeResult<CommitOutcome> {
821
+ const given = input.message?.trim() ?? "";
822
+ if (!given && !input.amend)
823
+ return failure("invalid_input", "a commit message is required (-m <msg> or -F <file>)");
824
+ const extra = (input.trailers ?? []).map((trailer) => trailer.trim());
825
+ const malformed = extra.find((trailer) => !EXTRA_TRAILER.test(trailer));
826
+ if (malformed !== undefined)
827
+ return failure(
828
+ "invalid_input",
829
+ `--trailer ${JSON.stringify(malformed)} is not one "Key: value" line`,
830
+ `workit git commit -m '<msg>' --trailer "Co-Authored-By: Name <email>"`,
831
+ );
832
+ if (extra.some((trailer) => /^workit-session:/iu.test(trailer)))
833
+ return failure(
834
+ "invalid_input",
835
+ "--trailer cannot set Workit-Session: workit writes it from the acting session",
836
+ );
837
+ const paths = input.paths ?? [];
838
+ if (input.all && paths.length) return failure("invalid_input", "pass --all or paths, not both");
839
+ if (input.staged && (input.all || paths.length || input.whole))
840
+ return failure(
841
+ "invalid_input",
842
+ "--staged commits the index as is: drop --all, --whole and paths",
843
+ );
844
+ if (input.whole && !paths.length)
845
+ return failure(
846
+ "invalid_input",
847
+ "--whole applies to named paths: workit git commit --whole -- <paths>",
848
+ );
849
+ const progress = operationInProgress(cwd);
850
+ if (progress && !progress.commitAllowed) return inProgressFailure(progress.operation, "commit");
851
+ const branch = progress?.branch ?? currentBranch(cwd);
852
+ if (!branch)
853
+ return failure(
854
+ "invalid_input",
855
+ "HEAD is detached; commit on a branch (workit git branch <name>)",
856
+ );
857
+ if (isProtectedTarget(cwd, branch))
858
+ return failure(
859
+ "blocked",
860
+ `protected_branch: ${branch} is protected by the workspace branch policy; workit never commits to it`,
861
+ "workit git branch <feature/name> # then commit there",
862
+ );
863
+ const previous = input.amend ? headSha(cwd) : null;
864
+ if (input.amend && !previous)
865
+ return failure("invalid_input", `nothing to amend: ${branch} has no commit yet`);
866
+ if (previous) {
867
+ const shared = sharedBranchContaining(cwd, previous, branch);
868
+ if (shared)
869
+ return failure(
870
+ "blocked",
871
+ `amend_shared_commit: ${previous.slice(0, 12)} is already on ${shared}, so it is not ${branch}'s own commit; workit never rewrites it`,
872
+ "workit git commit -m '<msg>' -- <paths> # add a new commit instead",
873
+ );
874
+ }
875
+ let message = given;
876
+ let carried: string[] = [];
877
+ let previousTrailers: string[] = [];
878
+ if (given) {
879
+ const lint = lintCommitMessage(cwd, given);
880
+ if (!lint.ok) return failure("blocked", lint.error, lint.correction);
881
+ }
882
+ if (previous) {
883
+ const kept = git(cwd, ["log", "-1", "--format=%B", previous]);
884
+ if (!kept.ok)
885
+ return failure("failed", `could not read the message of ${branch}: ${gitError(kept)}`);
886
+ // --no-edit keeps the message whole; a new message carries the old trailers over.
887
+ previousTrailers = messageTrailers(cwd, kept.stdout.trim());
888
+ if (given) carried = previousTrailers;
889
+ else message = kept.stdout.trim();
890
+ }
891
+ const session = input.actor.session;
892
+ if (session && !SESSION_SAFE.test(session))
893
+ return failure(
894
+ "invalid_input",
895
+ "WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
896
+ );
897
+ // A commit names its author session (commit.recorded, the trailer): the
898
+ // same claim check as a verdict, and an id issued to a verifier or
899
+ // reviewer never authors code (audit mv83zyg6 P2b).
900
+ if (session) {
901
+ const env = input.env ?? process.env;
902
+ if (issuedBinding(cwd, session) !== null)
903
+ return failure(
904
+ "blocked",
905
+ `judge_session: ${session} was issued to a verifier or reviewer subagent; it records verdicts and never authors commits`,
906
+ "commit from the implementer's or the lead's own session (unset WORKIT_SESSION_ID back to the shell's)",
907
+ );
908
+ const refusal = sessionClaimRefusal(env, session, cwd);
909
+ if (refusal)
910
+ return failure(
911
+ "blocked",
912
+ refusal.error,
913
+ "commit from the session this shell really is: never set WORKIT_SESSION_ID by hand for a commit (unset it, or use the id the host put in this shell)",
914
+ );
915
+ }
916
+ // A commit that only rewords (amend) or is empty on purpose needs no change.
917
+ const changeRequired = !input.amend && !input.allowEmpty;
918
+
919
+ // Amending an empty commit (a CI retrigger) keeps it empty without --allow-empty.
920
+ const emptyAmend =
921
+ previous !== null &&
922
+ git(cwd, ["rev-parse", "--verify", "-q", `${previous}^{tree}`]).stdout.trim() ===
923
+ git(cwd, ["rev-parse", "--verify", "-q", `${previous}~1^{tree}`]).stdout.trim();
924
+ const args = ["commit", "--no-edit"];
925
+ if (input.amend) args.push("--amend");
926
+ if (input.allowEmpty || emptyAmend) args.push("--allow-empty");
927
+ // Off (`trailers: false`): authorship is the commit.recorded row alone,
928
+ // and an amend drops the Workit-Session trailers the old commit had.
929
+ const trailersOn = workspaceTrailers(cwd).enabled;
930
+ const sessionTrailer = Boolean(session) && trailersOn;
931
+ if (!trailersOn) {
932
+ carried = carried.filter((trailer) => !SESSION_KEY.test(trailer));
933
+ if (!given) message = stripSessionTrailers(message, { subject: true });
934
+ }
935
+ const finalMessage = withTrailers(cwd, message, [
936
+ ...carried,
937
+ ...extra,
938
+ ...(sessionTrailer ? [`Workit-Session: ${session}`] : []),
939
+ ]);
940
+ // Only workit writes Workit-Session: the acting session's, or (trailers
941
+ // on) the amended commit's own. A message line or a `trailer.<alias>.key`
942
+ // config that expands a --trailer into the key never forges one.
943
+ const allowed = new Set([
944
+ ...(trailersOn ? sessionValues(previousTrailers) : []),
945
+ ...(sessionTrailer && session ? [session] : []),
946
+ ]);
947
+ const forged = sessionValues(messageTrailers(cwd, finalMessage)).find(
948
+ (value) => !allowed.has(value),
949
+ );
950
+ if (forged !== undefined)
951
+ return failure(
952
+ "invalid_input",
953
+ `forged_session_trailer: the message would carry "Workit-Session: ${forged}", which workit did not add; workit writes that trailer itself`,
954
+ "drop the Workit-Session line (or the trailer.<alias>.key config that expands into it)",
955
+ );
956
+ args.push("-m", finalMessage);
957
+ let keptPartial: string[] = [];
958
+ let partialIndex: string | null = null;
959
+ if (paths.length) {
960
+ if (paths.some((value) => value.startsWith("-") || !value.trim()))
961
+ return failure("invalid_input", "paths must not be empty or start with -");
962
+ // A file staged in part keeps its index entry: its staged hunks are the
963
+ // commit, its unstaged rest stays in the working tree (--whole adds it).
964
+ keptPartial = input.whole ? [] : partiallyStaged(cwd, paths);
965
+ const partial = new Set(keptPartial);
966
+ const top = git(cwd, ["rev-parse", "--show-prefix"]).stdout.trim();
967
+ // A path already removed from disk and index (`git rm`) has nothing to
968
+ // add, and `git add` would fail on it; the commit below still records it.
969
+ const tracked = new Set(nulList(git(cwd, ["ls-files", "-z", "--", ...paths]).stdout));
970
+ const addable = paths.filter(
971
+ (value) =>
972
+ !partial.has(path.posix.normalize(`${top}${value.replaceAll("\\", "/")}`)) &&
973
+ (existsSync(path.resolve(cwd, value)) ||
974
+ [...tracked].some((file) => file === value || file.startsWith(`${value}/`))),
975
+ );
976
+ if (addable.length) {
977
+ const added = git(cwd, [
978
+ "add",
979
+ "-A",
980
+ "--",
981
+ ...addable,
982
+ ...keptPartial.map((file) => `:(exclude,top,literal)${file}`),
983
+ ]);
984
+ if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
985
+ }
986
+ if (changeRequired && git(cwd, ["diff", "--cached", "--quiet", "HEAD", "--", ...paths]).ok)
987
+ return nothingToCommit(`${paths.join(", ")} has no change to commit`);
988
+ if (keptPartial.length) {
989
+ // `--only` would take each path's working tree: commit a private index instead.
990
+ const built = pathCommitIndex(cwd, paths, input.env ?? process.env);
991
+ if ("error" in built) return failure("failed", built.error);
992
+ partialIndex = built.file;
993
+ } else args.push("--", ...paths);
994
+ } else if (input.all) {
995
+ const added = git(cwd, ["add", "-A"]);
996
+ if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
997
+ if (changeRequired && git(cwd, ["diff", "--cached", "--quiet"]).ok)
998
+ return nothingToCommit("the working tree is clean");
999
+ } else if (changeRequired) {
1000
+ const staged = git(cwd, ["diff", "--cached", "--name-only", "-z"]);
1001
+ if (staged.ok && !staged.stdout.replaceAll("\0", "")) {
1002
+ const dirt = dirtEntries(cwd);
1003
+ if (input.staged)
1004
+ return failure(
1005
+ "blocked",
1006
+ `nothing_staged: --staged commits the index, and nothing is staged${dirt.length ? ` (${dirt.length} unstaged change(s))` : ""}`,
1007
+ "workit git stage --patch <file|-> (selected hunks) or workit git stage -- <paths>, then commit --staged",
1008
+ );
1009
+ if (!dirt.length) return nothingToCommit("the working tree is clean");
1010
+ return failure(
1011
+ "blocked",
1012
+ `nothing_staged: nothing is staged and workit never commits unrelated changes implicitly (${dirt.length} changed: ${dirt
1013
+ .slice(0, 5)
1014
+ .map((entry) => entry.slice(3))
1015
+ .join(", ")}${dirt.length > 5 ? ", …" : ""})`,
1016
+ `workit git commit -m '<msg>' -- <paths> # or --all to commit every change`,
1017
+ );
1018
+ }
1019
+ }
1020
+ let committed: Run;
1021
+ try {
1022
+ committed = git(cwd, args, {
1023
+ timeoutMs: COMMIT_TIMEOUT_MS,
1024
+ // Decided here: the session hook's helper skips its own `workit git trailers` call.
1025
+ env: {
1026
+ ...(input.env ?? process.env),
1027
+ WORKIT_TRAILERS: trailersOn ? "on" : "off",
1028
+ ...(partialIndex ? { GIT_INDEX_FILE: partialIndex } : {}),
1029
+ },
1030
+ });
1031
+ } finally {
1032
+ if (partialIndex) rmSync(partialIndex, { force: true });
1033
+ }
1034
+ if (!committed.ok) {
1035
+ const output = gitOutput(committed);
1036
+ const refusal = hookRefusal(output);
1037
+ return failure(
1038
+ "failed",
1039
+ `git commit failed (a hook or git refused it):\n${output}${refusal ? `\n${refusal}` : ""}`,
1040
+ );
1041
+ }
1042
+ const sha = headSha(cwd);
1043
+ if (!sha) return failure("failed", "the commit did not resolve");
1044
+ if (paths.length) resyncCommittedPaths(cwd, paths);
1045
+ // A merge commit lists what it brought in relative to its first parent.
1046
+ const merge = git(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok;
1047
+ const files = nulList(
1048
+ git(
1049
+ cwd,
1050
+ merge
1051
+ ? ["diff", "--name-only", "-z", `${sha}^1`, sha]
1052
+ : ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", sha],
1053
+ ).stdout,
1054
+ );
1055
+ const leftDirty = dirtEntries(cwd).length;
1056
+ const notes: string[] = [];
1057
+ // Only a commit on the branch's own upstream needs a forced push.
1058
+ const upstream = `refs/remotes/${pushRemoteName(cwd, branch) ?? "origin"}/${branch}`;
1059
+ if (previous && git(cwd, ["merge-base", "--is-ancestor", previous, upstream]).ok)
1060
+ notes.push(
1061
+ `the amended commit ${previous.slice(0, 12)} was already pushed (${upstream.slice("refs/remotes/".length)}); publish the rewrite with: workit git push --force-with-lease`,
1062
+ );
1063
+
1064
+ // Bounded row (MAX_LINE_BYTES): the subject and the first files only.
1065
+ const listed: string[] = [];
1066
+ let size = 0;
1067
+ for (const file of files) {
1068
+ size += file.length + 4;
1069
+ if (size > 1500) break;
1070
+ listed.push(file);
1071
+ }
1072
+ const row = appendObserved(cwd, {
1073
+ type: "commit.recorded",
1074
+ actor: input.actor,
1075
+ branch,
1076
+ head: sha,
1077
+ sha,
1078
+ session,
1079
+ agentId: input.actor.agentId,
1080
+ subject: (message.split("\n", 1)[0] ?? "").slice(0, 200),
1081
+ files: listed,
1082
+ fileCount: files.length,
1083
+ ...(previous ? { amended: previous } : {}),
1084
+ });
1085
+ return success({
1086
+ sha,
1087
+ branch,
1088
+ message,
1089
+ files,
1090
+ leftDirty,
1091
+ session,
1092
+ sessionTrailer,
1093
+ amended: previous,
1094
+ notes,
1095
+ keptPartial,
1096
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
1097
+ });
1098
+ }
1099
+
1100
+ // ---------------------------------------------------------------------------
1101
+ // push
1102
+
1103
+ export type PushPlan = {
1104
+ branch: string;
1105
+ sha: string;
1106
+ remote: string;
1107
+ /** Redacted push URL (safe to print). */
1108
+ url: string;
1109
+ /** The push URL as configured (ls-remote target); never printed. */
1110
+ rawUrl: string;
1111
+ /** The remote is a local path/file URL: there is no forge account to check. */
1112
+ local: boolean;
1113
+ };
1114
+
1115
+ /** Everything `git push` needs that does not touch the network. */
1116
+ export function pushPreflight(
1117
+ cwd: string,
1118
+ options: { branch?: string | null } = {},
1119
+ ): ForgeResult<PushPlan> {
1120
+ const progress = operationInProgress(cwd);
1121
+ if (progress) return inProgressFailure(progress.operation, "push");
1122
+ const branch = options.branch ?? currentBranch(cwd);
1123
+ if (!branch) return failure("invalid_input", "HEAD is detached; push a branch");
1124
+ if (isProtectedTarget(cwd, branch))
1125
+ return failure(
1126
+ "blocked",
1127
+ `protected_branch: ${branch} is protected by the workspace branch policy; workit git push never pushes to it`,
1128
+ releaseBranch(cwd, branch)
1129
+ ? `workit git push --release ${branch} # finishing a release or hotfix; needs the release grant`
1130
+ : "workit git branch <feature/name> # push a feature branch and open a PR",
1131
+ );
1132
+ const sha = resolveRef(cwd, `refs/heads/${branch}`);
1133
+ if (!sha) return failure("not_found", `branch ${branch} has no commit to push`);
1134
+ const remote = pushRemoteName(cwd, branch);
1135
+ if (!remote)
1136
+ return failure(
1137
+ "not_found",
1138
+ "no push remote is configured for this branch",
1139
+ "git remote add origin <url>",
1140
+ );
1141
+ const raw = pushUrl(cwd, remote);
1142
+ if (!raw)
1143
+ return failure(
1144
+ "blocked",
1145
+ `remote "${remote}" has no single push URL`,
1146
+ `git remote get-url --push --all ${remote} # keep exactly one push URL`,
1147
+ );
1148
+ const parsed = parseRemoteUrl(raw);
1149
+ const local = parsed?.protocol === "file";
1150
+ // `git remote get-url --push` already applied the rewrites; a URL that a
1151
+ // rule would rewrite again is ambiguous. A local path is taken as is.
1152
+ if (!local && !pushTargetIsStable(cwd, raw))
1153
+ return failure(
1154
+ "blocked",
1155
+ `push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
1156
+ "git config --get-regexp '^url\\.' # remove the rewrite for this remote",
1157
+ );
1158
+ return success({
1159
+ branch,
1160
+ sha,
1161
+ remote,
1162
+ url: redactRemote(raw),
1163
+ rawUrl: raw,
1164
+ local,
1165
+ });
1166
+ }
1167
+
1168
+ /**
1169
+ * The remote tip workit last pushed for `branch`: the newest `push.verified`
1170
+ * row where workit actually moved the ref (`pushed: true`). A no-op push only
1171
+ * observed someone's tip (`push.noop`) and is never a lease anchor. The remote-tracking ref is never a lease (a plain `git fetch`
1172
+ * moves it to someone else's tip, which would make the force overwrite
1173
+ * them). `undefined` means workit has no record.
1174
+ */
1175
+ export function recordedRemoteTip(cwd: string, plan: PushPlan): string | undefined {
1176
+ const ledger = readLedger(cwd);
1177
+ if (ledger.ok)
1178
+ for (let index = ledger.value.rows.length - 1; index >= 0; index -= 1) {
1179
+ const row = ledger.value.rows[index];
1180
+ if (
1181
+ row.type === "push.verified" &&
1182
+ row.pushed === true &&
1183
+ row.branch === plan.branch &&
1184
+ row.observer === "workit_cli" &&
1185
+ row.head
1186
+ )
1187
+ return row.head;
1188
+ }
1189
+ return undefined;
1190
+ }
1191
+
1192
+ /**
1193
+ * `--force-if-includes` semantics: the remote tip was integrated locally, i.e.
1194
+ * it is reachable from the local branch or from a former tip in the branch's
1195
+ * reflog (the branch held it before an amend or rebase). Reachability only:
1196
+ * this guards what a forced push may drop.
1197
+ */
1198
+ function includesRemoteTip(cwd: string, branch: string, tip: string): boolean {
1199
+ const ref = `refs/heads/${branch}`;
1200
+ if (git(cwd, ["merge-base", "--is-ancestor", tip, ref]).ok) return true;
1201
+ const reflog = git(cwd, ["reflog", "show", "--format=%H", ref, "--"]);
1202
+ const former = [
1203
+ ...new Set(
1204
+ (reflog.ok ? reflog.stdout : "")
1205
+ .split("\n")
1206
+ .map((line) => line.trim())
1207
+ .filter(Boolean),
1208
+ ),
1209
+ ];
1210
+ if (former.includes(tip)) return true;
1211
+ if (!former.length) return false;
1212
+ // Commits reachable from the tip but from no former tip: none means included.
1213
+ const outside = git(cwd, ["rev-list", "-n", "1", "--stdin"], {
1214
+ input: `${tip}\n${former.map((sha) => `^${sha}`).join("\n")}\n`,
1215
+ });
1216
+ return outside.ok && !outside.stdout.trim();
1217
+ }
1218
+
1219
+ /**
1220
+ * Every commit the remote tip has and the branch lacks is a non-merge commit
1221
+ * with a patch-equivalent local commit (the same work, rebased elsewhere).
1222
+ * Only words the push advice; it never relaxes the lease guard, because a
1223
+ * merge commit can carry changes no patch-id shows.
1224
+ */
1225
+ function patchEquivalent(cwd: string, branch: string, tip: string): boolean {
1226
+ const ref = `refs/heads/${branch}`;
1227
+ const merges = git(cwd, ["rev-list", "--count", "--merges", `${ref}..${tip}`]);
1228
+ if (!merges.ok || merges.stdout.trim() !== "0") return false;
1229
+ const cherry = git(cwd, ["cherry", ref, tip]);
1230
+ const lines = cherry.ok ? cherry.stdout.split("\n").filter(Boolean) : [];
1231
+ return lines.length > 0 && lines.every((line) => line.startsWith("-"));
1232
+ }
1233
+
1234
+ export type PushOutcome = {
1235
+ remote: string;
1236
+ url: string;
1237
+ branch: string;
1238
+ sha: string;
1239
+ /** The remote tip before the push (null: the branch was new). */
1240
+ previous: string | null;
1241
+ pushed: boolean;
1242
+ forced: boolean;
1243
+ delivered: true;
1244
+ upstream: boolean;
1245
+ recorded: { id: string } | { error: string };
1246
+ };
1247
+
1248
+ const SHA = /^[0-9a-f]{40,64}$/u;
1249
+ const SHA_PREFIX = /^[0-9a-f]{7,39}$/u;
1250
+
1251
+ /** Resolve an abbreviated `--expect` against the known remote tips; refuse unknown or ambiguous. */
1252
+ export function resolveExpectPrefix(
1253
+ prefix: string,
1254
+ tips: ReadonlyArray<string | null | undefined>,
1255
+ label: string,
1256
+ ): { ok: true; sha: string } | { ok: false; code: "invalid_input"; error: string } {
1257
+ const known = [...new Set(tips.filter((tip): tip is string => typeof tip === "string"))];
1258
+ if (!SHA_PREFIX.test(prefix))
1259
+ return {
1260
+ ok: false,
1261
+ code: "invalid_input",
1262
+ error: "--expect must be a commit sha (full, or an abbreviation of at least 7 hex digits)",
1263
+ };
1264
+ const matches = known.filter((tip) => tip.startsWith(prefix));
1265
+ if (matches.length === 1) return { ok: true, sha: matches[0] };
1266
+ return {
1267
+ ok: false,
1268
+ code: "invalid_input",
1269
+ error:
1270
+ matches.length > 1
1271
+ ? `--expect ${prefix} is ambiguous for ${label}; choose one of: ${matches.join(", ")}`
1272
+ : `--expect ${prefix} matches no known tip of ${label}${known.length ? `; known: ${known.join(", ")}` : ""}; pass the full sha`,
1273
+ };
1274
+ }
1275
+
1276
+ export function executePush(
1277
+ cwd: string,
1278
+ plan: PushPlan,
1279
+ input: {
1280
+ forceWithLease?: boolean;
1281
+ /** Allow a lease on a remote tip local history never contained (drops those commits). */
1282
+ overwriteUnintegrated?: boolean;
1283
+ expect?: string | null;
1284
+ setUpstream?: boolean;
1285
+ actor: LedgerActor;
1286
+ timeoutMs?: number;
1287
+ /** The pre-push identity check's outcome, kept on the ledger row. */
1288
+ identity?: Record<string, unknown>;
1289
+ },
1290
+ ): ForgeResult<PushOutcome> {
1291
+ const before = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
1292
+ if (!before.ok) return failure(before.code, before.error);
1293
+ let forced = false;
1294
+ let pushed = false;
1295
+ if (before.sha !== plan.sha) {
1296
+ const args = ["push", "--porcelain", "--no-follow-tags", "--recurse-submodules=no"];
1297
+ if (input.forceWithLease) {
1298
+ const explicit = input.expect !== undefined && input.expect !== null;
1299
+ let expectSha = input.expect ?? null;
1300
+ if (explicit && !SHA.test(expectSha as string)) {
1301
+ const resolved = resolveExpectPrefix(
1302
+ expectSha as string,
1303
+ [before.sha, recordedRemoteTip(cwd, plan)],
1304
+ `${plan.remote}/${plan.branch}`,
1305
+ );
1306
+ if (!resolved.ok) return failure(resolved.code, resolved.error);
1307
+ expectSha = resolved.sha;
1308
+ }
1309
+ const integrate = `git fetch ${plan.remote} ${plan.branch} && git rebase ${plan.remote}/${plan.branch} # or merge it; then: workit git push`;
1310
+ const recorded = explicit ? (expectSha as string) : recordedRemoteTip(cwd, plan);
1311
+ // A new branch needs no lease; otherwise workit must have recorded the tip.
1312
+ if (recorded === undefined && before.sha !== null)
1313
+ return failure(
1314
+ "blocked",
1315
+ `lease_unknown: workit has no recorded push of ${plan.branch}, so it cannot tell whose commits ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) holds`,
1316
+ `${integrate} # or, after reviewing that tip: workit git push --force-with-lease --expect <sha you reviewed> (--overwrite-unintegrated only to drop commits you never had)`,
1317
+ );
1318
+ const expected = recorded ?? null;
1319
+ if (before.sha !== expected)
1320
+ return failure(
1321
+ "blocked",
1322
+ `lease_mismatch: ${plan.remote}/${plan.branch} is at ${before.sha?.slice(0, 12) ?? "(absent)"} but ${explicit ? "--expect names" : "workit last recorded"} ${expected?.slice(0, 12) ?? "(absent)"}; someone else pushed`,
1323
+ integrate,
1324
+ );
1325
+ if (
1326
+ !input.overwriteUnintegrated &&
1327
+ before.sha !== null &&
1328
+ !includesRemoteTip(cwd, plan.branch, before.sha)
1329
+ )
1330
+ return failure(
1331
+ "blocked",
1332
+ `lease_not_integrated: ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) was never part of local ${plan.branch}; forcing would drop it`,
1333
+ `${integrate} # or, to drop those commits on purpose: workit git push --force-with-lease --expect ${before.sha} --overwrite-unintegrated`,
1334
+ );
1335
+ args.push(`--force-with-lease=refs/heads/${plan.branch}:${expected ?? ""}`);
1336
+ forced = true;
1337
+ }
1338
+ args.push(plan.remote, `${plan.sha}:refs/heads/${plan.branch}`);
1339
+ const run = gitNetwork(cwd, args, input.timeoutMs ?? PUSH_TIMEOUT_MS);
1340
+ if (!run.ok) {
1341
+ const text = `${run.stderr}\n${run.stdout}`;
1342
+ if (run.timedOut) return failure("unavailable", `git push to ${plan.url} timed out`);
1343
+ if (/stale info/iu.test(text))
1344
+ return failure(
1345
+ "blocked",
1346
+ `lease_mismatch: ${plan.remote}/${plan.branch} moved while pushing`,
1347
+ `git fetch ${plan.remote} ${plan.branch} && git rebase ${plan.remote}/${plan.branch} # or merge it; then: workit git push`,
1348
+ );
1349
+ const rejected = /non-fast-forward|fetch first|\[rejected\]/iu.test(text);
1350
+ const integrated =
1351
+ rejected && before.sha !== null && includesRemoteTip(cwd, plan.branch, before.sha);
1352
+ const equivalent =
1353
+ rejected &&
1354
+ !integrated &&
1355
+ before.sha !== null &&
1356
+ patchEquivalent(cwd, plan.branch, before.sha);
1357
+ if (before.sha !== null && (integrated || equivalent)) {
1358
+ // An amend or rebase rewrote commits that are already pushed: a fetch
1359
+ // and rebase would re-apply the old ones. The lease is the tip seen now.
1360
+ const lease =
1361
+ recordedRemoteTip(cwd, plan) === before.sha
1362
+ ? "workit git push --force-with-lease"
1363
+ : `workit git push --force-with-lease --expect ${before.sha}`;
1364
+ return failure(
1365
+ "failed",
1366
+ integrated
1367
+ ? `non_fast_forward: local ${plan.branch} was rewritten (amend or rebase) after ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) was pushed; that tip is already part of the local history, so do not fetch and rebase`
1368
+ : `non_fast_forward: every commit on ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) has a patch-equivalent commit in local ${plan.branch} (rebased elsewhere); fetching and rebasing would apply them twice`,
1369
+ integrated
1370
+ ? lease
1371
+ : `${lease} --overwrite-unintegrated # after checking ${plan.remote}/${plan.branch} holds nothing else`,
1372
+ );
1373
+ }
1374
+ if (/non-fast-forward|fetch first|\[rejected\]/iu.test(text))
1375
+ return failure(
1376
+ "failed",
1377
+ `non_fast_forward: ${plan.remote}/${plan.branch} has commits this branch does not`,
1378
+ `git fetch ${plan.remote} && git rebase ${plan.remote}/${plan.branch} # or, if the rewrite is intended: workit git push --force-with-lease`,
1379
+ );
1380
+ return failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
1381
+ }
1382
+ pushed = true;
1383
+ }
1384
+ const after = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
1385
+ if (!after.ok)
1386
+ return failure(
1387
+ after.code,
1388
+ `push sent but not verified: ${after.error}`,
1389
+ "workit verify-delivery push",
1390
+ );
1391
+ if (after.sha !== plan.sha)
1392
+ return failure(
1393
+ "failed",
1394
+ `push_unverified: ${plan.remote}/${plan.branch} is at ${after.sha?.slice(0, 12) ?? "(absent)"}, not the pushed ${plan.sha.slice(0, 12)}`,
1395
+ "workit verify-delivery push",
1396
+ );
1397
+ let upstream = false;
1398
+ if (input.setUpstream) {
1399
+ upstream =
1400
+ git(cwd, ["config", `branch.${plan.branch}.remote`, plan.remote]).ok &&
1401
+ git(cwd, ["config", `branch.${plan.branch}.merge`, `refs/heads/${plan.branch}`]).ok;
1402
+ }
1403
+ // Only a push that moved the ref is a lease anchor; a no-op merely observed it.
1404
+ const row = appendObserved(cwd, {
1405
+ type: pushed ? "push.verified" : "push.noop",
1406
+ actor: input.actor,
1407
+ branch: plan.branch,
1408
+ head: plan.sha,
1409
+ remote: plan.remote,
1410
+ url: plan.url,
1411
+ previous: before.sha,
1412
+ pushed,
1413
+ forced,
1414
+ ...(input.identity ? { identity: input.identity } : {}),
1415
+ });
1416
+ return success({
1417
+ remote: plan.remote,
1418
+ url: plan.url,
1419
+ branch: plan.branch,
1420
+ sha: plan.sha,
1421
+ previous: before.sha,
1422
+ pushed,
1423
+ forced,
1424
+ delivered: true,
1425
+ upstream,
1426
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
1427
+ });
1428
+ }
1429
+
1430
+ // ---------------------------------------------------------------------------
1431
+ // release push: finishing a git-flow release or hotfix
1432
+
1433
+ export type ReleaseRef = {
1434
+ kind: "branch" | "tag";
1435
+ /** The short name (`main`, `v4.1.0`). */
1436
+ name: string;
1437
+ /** The full ref (`refs/heads/main`, `refs/tags/v4.1.0`). */
1438
+ ref: string;
1439
+ /** The commit it points at (a tag peeled). */
1440
+ sha: string;
1441
+ /** A tag's line production branch: the tag's commit must be on it. */
1442
+ production?: string | null;
1443
+ /** A tag's version, line and prefix (`v` + `1.4.0`): its release notes are read from them. */
1444
+ release?: { version: string; track: string | null; tagPrefix: string };
1445
+ };
1446
+
1447
+ export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
1448
+ refs: ReleaseRef[];
1449
+ /** The workspace's production branch (git-flow production, else the default target). */
1450
+ production: string | null;
1451
+ };
1452
+
1453
+ /**
1454
+ * One release line: the branches a release or hotfix finishes onto. The
1455
+ * workspace's own (git-flow production and develop, else the trunk) or a
1456
+ * release track's.
1457
+ */
1458
+ export type ReleaseLine = {
1459
+ /** The release track's name; null for the workspace's own branches. */
1460
+ track: string | null;
1461
+ production: string;
1462
+ integration: string;
1463
+ /** Where a finished release merges back (git-flow: develop). */
1464
+ mergeBack: string[];
1465
+ /** The release tag prefix: the track's tagNamespace, then the workspace prefix. */
1466
+ tagPrefix: string;
1467
+ /** git-flow merges a release locally; elsewhere the trunk moves only through a PR. */
1468
+ gitflow: boolean;
1469
+ /** A track's branch-name templates (`release/nun-{version}`). */
1470
+ naming: { release?: string; hotfix?: string };
1471
+ };
1472
+
1473
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1474
+ typeof value === "object" && value !== null && !Array.isArray(value);
1475
+
1476
+ /**
1477
+ * The workspace's release lines: its own first, then one per release track
1478
+ * (core/release-tracks.ts reads them leniently). The tag prefix is the
1479
+ * branch policy's (releaseTagPrefixOf); a track adds its `tagNamespace`. A
1480
+ * track's `strategy`, else the workspace preset, decides whether it is git-flow.
1481
+ */
1482
+ export function releaseLinesOf(cwd: string): ReleaseLine[] {
1483
+ const { preset, branches, production } = releaseBranchesOf(cwd);
1484
+ let policyPrefix: unknown;
1485
+ try {
1486
+ const selected = resolveRuntimeWorkspacePolicy(
1487
+ cwd,
1488
+ "branch",
1489
+ process.env.WORKIT_PROFILE?.trim() || undefined,
1490
+ ).policy as Record<string, unknown> | undefined;
1491
+ policyPrefix = selected?.releaseTagPrefix;
1492
+ } catch {
1493
+ policyPrefix = undefined;
1494
+ }
1495
+ const prefix = releaseTagPrefixOf(cwd, policyPrefix);
1496
+ const trunk = production ?? "main";
1497
+ const lines: ReleaseLine[] = [
1498
+ {
1499
+ track: null,
1500
+ production: branches[0] ?? trunk,
1501
+ integration: branches[1] ?? trunk,
1502
+ mergeBack: branches[1] ? [branches[1]] : [],
1503
+ tagPrefix: prefix,
1504
+ gitflow: branches.length > 0,
1505
+ naming: {},
1506
+ },
1507
+ ];
1508
+ let raw: unknown;
1509
+ try {
1510
+ raw = resolveRuntimeReleaseTracks(cwd)?.releaseTracks;
1511
+ } catch {
1512
+ return lines;
1513
+ }
1514
+ for (const track of readReleaseTracks(raw).tracks) {
1515
+ const value = isRecord(raw) ? raw[track.name] : undefined;
1516
+ const entry: Record<string, unknown> = isRecord(value) ? value : {};
1517
+ const strategy = typeof entry.strategy === "string" ? entry.strategy : preset;
1518
+ const namespace = typeof entry.tagNamespace === "string" ? entry.tagNamespace.trim() : "";
1519
+ lines.push({
1520
+ track: track.name,
1521
+ production: track.productionBranch,
1522
+ integration: track.integrationBranch,
1523
+ // git-flow always merges a release back into the integration branch.
1524
+ mergeBack: track.mergeBackBranches.length
1525
+ ? track.mergeBackBranches
1526
+ : [track.integrationBranch],
1527
+ tagPrefix: `${namespace}${prefix}`,
1528
+ gitflow: strategy === "gitflow",
1529
+ naming: { release: track.naming.release, hotfix: track.naming.hotfix },
1530
+ });
1531
+ }
1532
+ return lines;
1533
+ }
1534
+
1535
+ /**
1536
+ * The line a release tag belongs to: its prefix and a digit (`v4.1.0`,
1537
+ * `1.4.0` with an empty prefix, `nun/v2`); the longest matching prefix wins.
1538
+ * Null for any other tag.
1539
+ */
1540
+ const lineOfTag = (tag: string, lines: readonly ReleaseLine[]): ReleaseLine | null =>
1541
+ lines
1542
+ .filter(
1543
+ (line) => tag.startsWith(line.tagPrefix) && /^\d/u.test(tag.slice(line.tagPrefix.length)),
1544
+ )
1545
+ .toSorted((a, b) => b.tagPrefix.length - a.tagPrefix.length)[0] ?? null;
1546
+
1547
+ /**
1548
+ * What `workit git push --release` may push: the git-flow production and
1549
+ * develop branches (and a git-flow release track's production, integration
1550
+ * and merge-back branches) and release tags (the policy's tag prefix and a
1551
+ * digit), nothing else, each by its local ref. Everything else stays on the
1552
+ * feature-branch path (`workit git push`, `workit pr create`).
1553
+ */
1554
+ export function releasePushPreflight(
1555
+ cwd: string,
1556
+ names: readonly string[],
1557
+ ): ForgeResult<ReleasePushPlan> {
1558
+ const progress = operationInProgress(cwd);
1559
+ if (progress) return inProgressFailure(progress.operation, "push");
1560
+ const wanted = [...new Set(names)];
1561
+ if (!wanted.length) return failure("invalid_input", "name the release refs to push");
1562
+ const { preset, production } = releaseBranchesOf(cwd);
1563
+ const lines = releaseLinesOf(cwd);
1564
+ const tracks = lines.filter((line) => line.track !== null && line.gitflow);
1565
+ const branches = [
1566
+ ...new Set([
1567
+ ...releaseBranchesOf(cwd).branches,
1568
+ ...tracks.flatMap((line) => [line.production, line.integration, ...line.mergeBack]),
1569
+ ]),
1570
+ ];
1571
+ const tagShape = `${lines[0].tagPrefix}<version>`;
1572
+ const refs: ReleaseRef[] = [];
1573
+ for (const raw of wanted) {
1574
+ if (raw.startsWith("-") || raw.includes(":"))
1575
+ return failure("invalid_input", `${raw} is not a ref name (refspecs are not accepted)`);
1576
+ const tagName = raw.replace(/^refs\/tags\//u, "");
1577
+ const branchName = raw.replace(/^refs\/heads\//u, "");
1578
+ const tag = raw.startsWith("refs/heads/") ? null : resolveRef(cwd, `refs/tags/${tagName}`);
1579
+ const branch = raw.startsWith("refs/tags/")
1580
+ ? null
1581
+ : resolveRef(cwd, `refs/heads/${branchName}`);
1582
+ if (tag && branch)
1583
+ return failure(
1584
+ "invalid_input",
1585
+ `${raw} names both a branch and a tag`,
1586
+ `workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
1587
+ );
1588
+ if (tag) {
1589
+ const line = lineOfTag(tagName, lines);
1590
+ if (!line)
1591
+ return failure(
1592
+ "blocked",
1593
+ `release_push_refused: tag ${tagName} is not a release tag (${tagShape})`,
1594
+ `git tag -a ${tagShape} -m <message> # then: workit git push --release ${tagShape}`,
1595
+ );
1596
+ refs.push({
1597
+ kind: "tag",
1598
+ name: tagName,
1599
+ ref: `refs/tags/${tagName}`,
1600
+ sha: tag,
1601
+ production: line.track === null ? production : line.production,
1602
+ release: {
1603
+ version: tagName.slice(line.tagPrefix.length),
1604
+ track: line.track,
1605
+ tagPrefix: line.tagPrefix,
1606
+ },
1607
+ });
1608
+ continue;
1609
+ }
1610
+ if (!branch) return failure("not_found", `${raw} is not a local branch or tag`);
1611
+ if (!branches.some((name) => name.toLowerCase() === branchName.toLowerCase()))
1612
+ return failure(
1613
+ "blocked",
1614
+ branches.length
1615
+ ? `release_push_refused: ${branchName} is not a git-flow release branch (${branches.join(", ")})`
1616
+ : `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes ${tagShape} tags there, not branches`,
1617
+ branches.length
1618
+ ? "workit git push # a feature, bugfix, release or hotfix branch, then: workit pr create"
1619
+ : `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release ${tagShape}`,
1620
+ );
1621
+ refs.push({ kind: "branch", name: branchName, ref: `refs/heads/${branchName}`, sha: branch });
1622
+ }
1623
+ const remote = pushRemoteName(cwd, refs.find((ref) => ref.kind === "branch")?.name ?? null);
1624
+ if (!remote)
1625
+ return failure(
1626
+ "not_found",
1627
+ "no push remote is configured for this repository",
1628
+ "git remote add origin <url>",
1629
+ );
1630
+ const raw = pushUrl(cwd, remote);
1631
+ if (!raw)
1632
+ return failure(
1633
+ "blocked",
1634
+ `remote "${remote}" has no single push URL`,
1635
+ `git remote get-url --push --all ${remote} # keep exactly one push URL`,
1636
+ );
1637
+ const local = parseRemoteUrl(raw)?.protocol === "file";
1638
+ if (!local && !pushTargetIsStable(cwd, raw))
1639
+ return failure(
1640
+ "blocked",
1641
+ `push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
1642
+ "git config --get-regexp '^url\\.' # remove the rewrite for this remote",
1643
+ );
1644
+ return success({
1645
+ refs,
1646
+ remote,
1647
+ url: redactRemote(raw),
1648
+ rawUrl: raw,
1649
+ local,
1650
+ production,
1651
+ });
1652
+ }
1653
+
1654
+ export type ReleasePushOutcome = {
1655
+ remote: string;
1656
+ url: string;
1657
+ refs: Array<ReleaseRef & { previous: string | null; pushed: boolean }>;
1658
+ recorded: { id: string } | { error: string };
1659
+ };
1660
+
1661
+ /**
1662
+ * Push the planned release refs in one atomic push: a branch only fast
1663
+ * forward, a tag only when the remote lacks it (a release tag never moves),
1664
+ * never forced. Success only when every remote tip observed afterwards is the
1665
+ * local one; recorded as one `release.pushed` row.
1666
+ */
1667
+ export function executeReleasePush(
1668
+ cwd: string,
1669
+ plan: ReleasePushPlan,
1670
+ input: {
1671
+ actor: LedgerActor;
1672
+ grant: string;
1673
+ timeoutMs?: number;
1674
+ identity?: Record<string, unknown>;
1675
+ },
1676
+ ): ForgeResult<ReleasePushOutcome> {
1677
+ const previous = new Map<string, string | null>();
1678
+ for (const ref of plan.refs) {
1679
+ const before = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1680
+ if (!before.ok) return failure(before.code, before.error);
1681
+ previous.set(ref.ref, before.sha);
1682
+ if (before.sha === null || before.sha === ref.sha) continue;
1683
+ if (ref.kind === "tag")
1684
+ return failure(
1685
+ "blocked",
1686
+ `tag_exists: ${plan.remote} already has ${ref.name} at ${before.sha.slice(0, 12)}, not ${ref.sha.slice(0, 12)}; a release tag never moves`,
1687
+ `git tag -d ${ref.name} # tag the next version instead`,
1688
+ );
1689
+ if (!git(cwd, ["merge-base", "--is-ancestor", before.sha, ref.sha]).ok)
1690
+ return failure(
1691
+ "failed",
1692
+ `non_fast_forward: ${plan.remote}/${ref.name} (${before.sha.slice(0, 12)}) is not part of local ${ref.name}; a release push never rewrites it`,
1693
+ `git fetch ${plan.remote} ${ref.name} && git switch ${ref.name} && git merge --ff-only ${plan.remote}/${ref.name} # then: workit git push --release ${ref.name}`,
1694
+ );
1695
+ }
1696
+ // A release tag marks a commit its line's production branch already has
1697
+ // (on the remote, or as this push moves it): never an unmerged feature
1698
+ // commit, and never another line's production (a nun/ tag on master).
1699
+ const tags = plan.refs.filter((ref) => ref.kind === "tag");
1700
+ const contains = (tip: string, sha: string) =>
1701
+ git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
1702
+ for (const tag of tags) {
1703
+ const production = tag.production ?? plan.production;
1704
+ let remoteTip: string | null = null;
1705
+ if (production) {
1706
+ const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${production}`);
1707
+ if (!tip.ok) return failure(tip.code, tip.error);
1708
+ remoteTip = tip.sha;
1709
+ }
1710
+ const pushed = plan.refs.find(
1711
+ (ref) => ref.kind === "branch" && ref.name.toLowerCase() === production?.toLowerCase(),
1712
+ );
1713
+ const released =
1714
+ (remoteTip !== null && contains(remoteTip, tag.sha)) ||
1715
+ (pushed !== undefined && contains(pushed.sha, tag.sha));
1716
+ if (!released)
1717
+ return failure(
1718
+ "blocked",
1719
+ `tag_unreleased: ${tag.name} (${tag.sha.slice(0, 12)}) is not on ${plan.remote}/${production ?? "the production branch"}${pushed ? ` or the ${production} in this push` : ""}; a release tag marks a commit that already landed on its line's production branch`,
1720
+ `land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
1721
+ );
1722
+ }
1723
+ const pending = plan.refs.filter((ref) => previous.get(ref.ref) !== ref.sha);
1724
+ if (pending.length) {
1725
+ const run = gitNetwork(
1726
+ cwd,
1727
+ [
1728
+ "push",
1729
+ "--porcelain",
1730
+ "--atomic",
1731
+ "--no-follow-tags",
1732
+ "--recurse-submodules=no",
1733
+ plan.remote,
1734
+ ...pending.map((ref) =>
1735
+ ref.kind === "tag" ? `${ref.ref}:${ref.ref}` : `${ref.sha}:${ref.ref}`,
1736
+ ),
1737
+ ],
1738
+ input.timeoutMs ?? PUSH_TIMEOUT_MS,
1739
+ );
1740
+ if (!run.ok)
1741
+ return run.timedOut
1742
+ ? failure("unavailable", `git push to ${plan.url} timed out`)
1743
+ : failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
1744
+ }
1745
+ for (const ref of plan.refs) {
1746
+ const after = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1747
+ if (!after.ok) return failure(after.code, `push sent but not verified: ${after.error}`);
1748
+ if (after.sha !== ref.sha)
1749
+ return failure(
1750
+ "failed",
1751
+ `push_unverified: ${plan.remote} ${ref.ref} is at ${after.sha?.slice(0, 12) ?? "(absent)"}, not ${ref.sha.slice(0, 12)}`,
1752
+ );
1753
+ }
1754
+ const refs = plan.refs.map((ref) => ({
1755
+ ...ref,
1756
+ previous: previous.get(ref.ref) ?? null,
1757
+ pushed: pending.includes(ref),
1758
+ }));
1759
+ const row = appendObserved(cwd, {
1760
+ type: "release.pushed",
1761
+ actor: input.actor,
1762
+ remote: plan.remote,
1763
+ url: plan.url,
1764
+ refs: refs.map(({ ref, sha, previous: before, pushed }) => ({
1765
+ ref,
1766
+ sha,
1767
+ previous: before,
1768
+ pushed,
1769
+ })),
1770
+ grant: input.grant,
1771
+ ...(input.identity ? { identity: input.identity } : {}),
1772
+ });
1773
+ return success({
1774
+ remote: plan.remote,
1775
+ url: plan.url,
1776
+ refs,
1777
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
1778
+ });
1779
+ }
1780
+
1781
+ /**
1782
+ * Delete a remote branch only while it still points at `expectedTip`
1783
+ * (`--force-with-lease=refs/heads/<b>:<tip> --delete`). `lease` is false when
1784
+ * the tip moved; nothing is deleted then.
1785
+ */
1786
+ export function deleteRemoteBranch(
1787
+ cwd: string,
1788
+ target: string,
1789
+ branch: string,
1790
+ expectedTip: string,
1791
+ options: { timeoutMs?: number } = {},
1792
+ ): { ok: true } | { ok: false; lease: boolean; error: string } {
1793
+ if (target.startsWith("-") || branch.startsWith("-") || !SHA.test(expectedTip))
1794
+ return { ok: false, lease: false, error: "invalid branch deletion target" };
1795
+ if (isProtectedTarget(cwd, branch))
1796
+ return {
1797
+ ok: false,
1798
+ lease: false,
1799
+ error: `${branch} is a protected branch; it is never deleted`,
1800
+ };
1801
+ const run = gitNetwork(
1802
+ cwd,
1803
+ [
1804
+ "push",
1805
+ "--no-follow-tags",
1806
+ "--recurse-submodules=no",
1807
+ `--force-with-lease=refs/heads/${branch}:${expectedTip}`,
1808
+ "--delete",
1809
+ target,
1810
+ branch,
1811
+ ],
1812
+ options.timeoutMs ?? PUSH_TIMEOUT_MS,
1813
+ );
1814
+ if (run.ok) return { ok: true };
1815
+ const text = `${run.stderr}\n${run.stdout}`;
1816
+ return /stale info|cannot lock ref|fetch first|non-fast-forward/iu.test(text)
1817
+ ? {
1818
+ ok: false,
1819
+ lease: true,
1820
+ error: "the remote branch tip changed before deletion",
1821
+ }
1822
+ : { ok: false, lease: false, error: gitError(run) };
1823
+ }