@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/rev.ts ADDED
@@ -0,0 +1,1175 @@
1
+ // Pure git revision and push-target helpers for the CLI verbs (design §2.0,
2
+ // §2.2; D16). Plain TS on top of the git binary: no task store, no config
3
+ // reads, no zod, so any verb can import this without paying for the engine.
4
+ //
5
+ // - headSha / worktreeTree / mergeBase / patchId / remoteTip key evidence and
6
+ // verdicts to code state (fresh = same tree key, carried = same patch-id).
7
+ // - pushRemoteName / pushUrl / deriveForge / pushForge derive the forge from
8
+ // the PUSH remote host, honoring ~/.ssh/config Host aliases, instead of the
9
+ // configured provider (design §0 #6). forgeConflict turns a disagreement
10
+ // with the workspace provider into a `blocked` result with an unblock hint.
11
+ //
12
+ // Every git call is bounded: local reads time out after GIT_TIMEOUTS.local,
13
+ // worktree hashing after GIT_TIMEOUTS.worktree, and network calls after
14
+ // GIT_TIMEOUTS.network. Network calls run under a watchdog that kills git's
15
+ // whole process tree on timeout, with prompts disabled
16
+ // (networkGitInvocation). Remote URLs are never echoed raw: messages go through
17
+ // redactRemote, which drops credentials, query and fragment.
18
+ import { spawnSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { configFilePath } from "../core/config";
24
+ import { gitDirs } from "./facts";
25
+ import { readHeadBranch, readHeadCommit } from "./refs-read";
26
+
27
+ const MAX_BUFFER = 256 * 1024 * 1024;
28
+
29
+ /** Default bounds (ms) for git subprocesses; every network helper takes an override. */
30
+ export const GIT_TIMEOUTS = { local: 20_000, worktree: 120_000, network: 15_000 } as const;
31
+
32
+ type GitRun = { ok: boolean; stdout: string; stderr: string; timedOut: boolean };
33
+
34
+ const git = (
35
+ cwd: string,
36
+ args: string[],
37
+ options: { env?: NodeJS.ProcessEnv; input?: string; timeoutMs?: number } = {},
38
+ ): GitRun => {
39
+ const result = spawnSync("git", args, {
40
+ cwd,
41
+ encoding: "utf8",
42
+ env: options.env ?? process.env,
43
+ input: options.input,
44
+ maxBuffer: MAX_BUFFER,
45
+ timeout: options.timeoutMs ?? GIT_TIMEOUTS.local,
46
+ killSignal: "SIGKILL",
47
+ windowsHide: true,
48
+ });
49
+ const error: NodeJS.ErrnoException | undefined = result.error;
50
+ return {
51
+ ok: result.status === 0,
52
+ stdout: result.stdout ?? "",
53
+ stderr: result.stderr ?? "",
54
+ timedOut: error?.code === "ETIMEDOUT",
55
+ };
56
+ };
57
+
58
+ const line = (run: GitRun): string | null => {
59
+ if (!run.ok) return null;
60
+ const value = run.stdout.trim();
61
+ return value ? value : null;
62
+ };
63
+
64
+ // A ref or remote argument must never be read as an option by git.
65
+ const safeArg = (value: string): boolean => value.length > 0 && !value.startsWith("-");
66
+
67
+ /**
68
+ * The exact invocation for a git call that may touch the network: git args
69
+ * with HTTP stall limits, and an environment that can never prompt.
70
+ *
71
+ * - GIT_TERMINAL_PROMPT=0 and empty GIT_ASKPASS/SSH_ASKPASS (an empty value
72
+ * stops git from falling back to core.askPass) mean no credential prompt.
73
+ * SSH_ASKPASS_REQUIRE=never and GCM_INTERACTIVE=never cover ssh and Git
74
+ * Credential Manager.
75
+ * - ssh gets `-o BatchMode=yes -o ConnectTimeout=N`, appended to an existing
76
+ * GIT_SSH_COMMAND or core.sshCommand. ssh takes the first value of each
77
+ * option, so the user's own settings still win. A bare GIT_SSH program
78
+ * (e.g. plink) is left alone because it may not accept `-o`.
79
+ * - HTTPS aborts once the transfer stays below 1 B/s for the whole budget
80
+ * (http.lowSpeedLimit/lowSpeedTime).
81
+ */
82
+ export function networkGitInvocation(
83
+ cwd: string,
84
+ args: string[],
85
+ timeoutMs: number,
86
+ ): { args: string[]; env: NodeJS.ProcessEnv } {
87
+ const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
88
+ const env: NodeJS.ProcessEnv = {
89
+ ...process.env,
90
+ GIT_TERMINAL_PROMPT: "0",
91
+ GIT_ASKPASS: "",
92
+ SSH_ASKPASS: "",
93
+ SSH_ASKPASS_REQUIRE: "never",
94
+ GCM_INTERACTIVE: "never",
95
+ };
96
+ const base =
97
+ process.env.GIT_SSH_COMMAND ||
98
+ line(git(cwd, ["config", "--get", "core.sshCommand"])) ||
99
+ (process.env.GIT_SSH ? null : "ssh");
100
+ if (base)
101
+ env.GIT_SSH_COMMAND = `${base} -o BatchMode=yes -o ConnectTimeout=${Math.min(10, seconds)}`;
102
+ return {
103
+ args: ["-c", "http.lowSpeedLimit=1", "-c", `http.lowSpeedTime=${seconds}`, ...args],
104
+ env,
105
+ };
106
+ }
107
+
108
+ // Runs in a child runtime (node or bun, whichever runs workit). It starts git
109
+ // in its own process group and, on timeout, kills the whole group: git and
110
+ // every helper it spawned (git-remote-https with the URL in its argv, ssh).
111
+ // Leftover helpers are also killed after a normal exit. Windows has no
112
+ // process groups, so the tree is killed with `taskkill /T`.
113
+ const WATCHDOG = `
114
+ const cp = require("node:child_process");
115
+ const { ms, args } = JSON.parse(process.env.WORKIT_GIT_WATCHDOG);
116
+ delete process.env.WORKIT_GIT_WATCHDOG;
117
+ const win = process.platform === "win32";
118
+ const child = cp.spawn("git", args, { stdio: "inherit", detached: !win, windowsHide: true });
119
+ const killTree = () => {
120
+ if (!child.pid) return;
121
+ if (win) cp.spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
122
+ else try { process.kill(-child.pid, "SIGKILL"); } catch {}
123
+ };
124
+ let timedOut = false;
125
+ const timer = setTimeout(() => { timedOut = true; killTree(); }, ms);
126
+ child.on("error", () => { clearTimeout(timer); process.exit(127); });
127
+ child.on("exit", (code) => {
128
+ clearTimeout(timer);
129
+ if (!win) killTree();
130
+ process.exit(timedOut ? 124 : code ?? 1);
131
+ });
132
+ `;
133
+
134
+ const WATCHDOG_TIMEOUT_EXIT = 124;
135
+
136
+ /** A network git call (push, fetch, ls-remote) under the process-group watchdog (see WATCHDOG). */
137
+ export const gitNetwork = (cwd: string, args: string[], timeoutMs: number): GitRun => {
138
+ const invocation = networkGitInvocation(cwd, args, timeoutMs);
139
+ const result = spawnSync(process.execPath, ["-e", WATCHDOG], {
140
+ cwd,
141
+ encoding: "utf8",
142
+ env: {
143
+ ...invocation.env,
144
+ WORKIT_GIT_WATCHDOG: JSON.stringify({ ms: timeoutMs, args: invocation.args }),
145
+ },
146
+ maxBuffer: MAX_BUFFER,
147
+ // Backstop only: the watchdog itself enforces timeoutMs.
148
+ timeout: timeoutMs + 10_000,
149
+ killSignal: "SIGKILL",
150
+ windowsHide: true,
151
+ });
152
+ const error: NodeJS.ErrnoException | undefined = result.error;
153
+ return {
154
+ ok: result.status === 0,
155
+ stdout: result.stdout ?? "",
156
+ stderr: result.stderr ?? "",
157
+ timedOut: result.status === WATCHDOG_TIMEOUT_EXIT || error?.code === "ETIMEDOUT",
158
+ };
159
+ };
160
+
161
+ const UNPARSEABLE = "<unparseable remote>";
162
+
163
+ /**
164
+ * A remote (URL or name) that is safe to print: userinfo (except an SSH login
165
+ * name), query and fragment removed. Anything that does not parse as a URL,
166
+ * an scp-like `user@host:path`, a remote name or a local path prints as
167
+ * `<unparseable remote>`.
168
+ */
169
+ export function redactRemote(raw: string): string {
170
+ const value = raw.trim();
171
+ if (!value) return UNPARSEABLE;
172
+ // git's `<transport>::<address>` syntax (ext::<command>) can embed anything.
173
+ if (/^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return UNPARSEABLE;
174
+ if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("."))
175
+ return value;
176
+ // A remote name (origin) or a relative path carries no userinfo.
177
+ if (!/[:@]/u.test(value)) return value;
178
+ if (!value.includes("://")) {
179
+ // `user:secret@host:path` is not scp syntax; refuse rather than guess.
180
+ if (/^[^/@]*:[^/]*@/u.test(value)) return UNPARSEABLE;
181
+ const scp = /^(?:([^@\s/:]+)@)?([^:/\s]+):([^?#]+)/u.exec(value);
182
+ return scp ? `${scp[1] ? `${scp[1]}@` : ""}${scp[2]}:${scp[3]}` : UNPARSEABLE;
183
+ }
184
+ try {
185
+ const url = new URL(value);
186
+ const ssh = /^(?:ssh|git\+ssh|ssh\+git):$/u.test(url.protocol);
187
+ url.password = "";
188
+ if (!ssh) url.username = "";
189
+ url.search = "";
190
+ url.hash = "";
191
+ return url.toString();
192
+ } catch {
193
+ return UNPARSEABLE;
194
+ }
195
+ }
196
+
197
+ /** The commit HEAD points at, or null (unborn HEAD, not a repository). */
198
+ export function headSha(cwd: string): string | null {
199
+ return readHeadCommit(cwd) ?? line(git(cwd, ["rev-parse", "--verify", "-q", "HEAD^{commit}"]));
200
+ }
201
+
202
+ const emptyTree = (cwd: string): string | null => line(git(cwd, ["mktree"], { input: "" }));
203
+
204
+ /** Default cap for hashing an untracked file into the worktree key. */
205
+ export const MAX_UNTRACKED_BYTES = 5 * 1024 * 1024;
206
+
207
+ export type WorktreeTree = {
208
+ /** The git tree of the worktree (tracked + small untracked files). */
209
+ tree: string;
210
+ /**
211
+ * The freshness key: `tree` itself, or `sha256:<hex>` over `tree` plus the
212
+ * skipped files' path/size/mtime when any untracked file was over the cap.
213
+ */
214
+ key: string;
215
+ dirty: boolean;
216
+ /** Untracked files over the cap, recorded by stat instead of content. */
217
+ skipped: Array<{ path: string; size: number; mtimeMs: number }>;
218
+ };
219
+
220
+ /**
221
+ * Copy the index and keep its mtime. Git trusts an entry's cached stat only
222
+ * when the entry is older than the index file ("racy git"). An entry written
223
+ * in the same timestamp tick as the index is re-hashed instead. A plain copy
224
+ * stamps the index with the current time, so a same-size rewrite in that
225
+ * tick would read as clean. That is common on git builds that compare whole
226
+ * seconds (macOS). The copy's mtime is floored one microsecond below the
227
+ * original: an older index only makes more entries racy, which is the safe
228
+ * direction.
229
+ */
230
+ const seedIndex = (from: string, to: string): void => {
231
+ fs.copyFileSync(from, to);
232
+ const { atimeNs, mtimeNs } = fs.statSync(from, { bigint: true });
233
+ const seconds = (ns: bigint): number => (Number(ns / 1000n) - 1) / 1e6;
234
+ fs.utimesSync(to, seconds(atimeNs), seconds(mtimeNs));
235
+ };
236
+
237
+ /**
238
+ * The tree the worktree would commit right now, including unstaged and
239
+ * untracked (non-ignored) files. Built in a throwaway index
240
+ * (GIT_INDEX_FILE + `git add -A` + `git write-tree`), so the real index, HEAD
241
+ * and every ref stay untouched. `dirty` is true when that tree differs from
242
+ * HEAD's tree (or from the empty tree on an unborn branch), or when an
243
+ * oversized untracked file exists.
244
+ *
245
+ * Untracked files larger than `maxUntrackedBytes` (default 5 MB: build
246
+ * output, dumps, media) are not hashed into the object store; they enter the
247
+ * key by path + size + mtime instead. Touching one changes the key, but an
248
+ * edit that keeps size and mtime is not seen. Tracked files are always hashed.
249
+ */
250
+ export function worktreeTree(
251
+ cwd: string,
252
+ options: { maxUntrackedBytes?: number; timeoutMs?: number } = {},
253
+ ): WorktreeTree | null {
254
+ const dirs = gitDirs(cwd);
255
+ const top = dirs?.top ?? null;
256
+ // `--git-path index` is the git dir's index unless GIT_INDEX_FILE moves it.
257
+ const indexPath =
258
+ dirs && process.env.GIT_INDEX_FILE === undefined
259
+ ? `${dirs.gitDir}/index` // git's own form (forward slashes on Windows too)
260
+ : line(git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"]));
261
+ if (!top || !indexPath) return null;
262
+ const cap = options.maxUntrackedBytes ?? MAX_UNTRACKED_BYTES;
263
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.worktree;
264
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "workit-tree-"));
265
+ try {
266
+ const tempIndex = path.join(scratch, "index");
267
+ // Seeding from the real index keeps git's stat cache, so `add -A` only
268
+ // rehashes files that actually changed.
269
+ if (fs.existsSync(indexPath)) seedIndex(indexPath, tempIndex);
270
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
271
+ const untracked = git(top, ["ls-files", "--others", "--exclude-standard", "-z"], {
272
+ env,
273
+ timeoutMs,
274
+ });
275
+ if (!untracked.ok) return null;
276
+ const skipped: WorktreeTree["skipped"] = [];
277
+ for (const file of untracked.stdout.split("\0").filter(Boolean)) {
278
+ try {
279
+ const stat = fs.statSync(path.join(top, file));
280
+ if (stat.isFile() && stat.size > cap)
281
+ skipped.push({ path: file, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) });
282
+ } catch {
283
+ // Vanished between listing and stat: `add -A` decides.
284
+ }
285
+ }
286
+ skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
287
+ const added = skipped.length
288
+ ? git(top, ["add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], {
289
+ env,
290
+ timeoutMs,
291
+ input: [".", ...skipped.map((file) => `:(exclude,literal)${file.path}`)].join("\0"),
292
+ })
293
+ : git(top, ["add", "-A"], { env, timeoutMs });
294
+ if (!added.ok) return null;
295
+ const tree = line(git(top, ["write-tree"], { env, timeoutMs }));
296
+ if (!tree) return null;
297
+ const base = line(git(top, ["rev-parse", "--verify", "-q", "HEAD^{tree}"])) ?? emptyTree(top);
298
+ const key = skipped.length
299
+ ? `sha256:${createHash("sha256").update(JSON.stringify({ tree, skipped })).digest("hex")}`
300
+ : tree;
301
+ return { tree, key, dirty: tree !== base || skipped.length > 0, skipped };
302
+ } finally {
303
+ fs.rmSync(scratch, { recursive: true, force: true });
304
+ }
305
+ }
306
+
307
+ /**
308
+ * A cheap worktree freshness signal for per-turn paths: sha256 over HEAD and,
309
+ * for every entry `git status` reports (untracked files listed one by one),
310
+ * its path, lstat mode, size and mtime. A directory entry (a submodule or an
311
+ * untracked nested repository) also contributes its own HEAD, so moving a
312
+ * submodule or committing in a nested repo changes the signal. Git answers
313
+ * from its stat cache and, with GIT_OPTIONAL_LOCKS=0, never refreshes the
314
+ * index, and nothing is written to the object store (unlike worktreeTree).
315
+ * Equal signals mean "probably unchanged": an edit to an already-dirty file
316
+ * that keeps its mode, size and mtime is not seen, so gates that decide
317
+ * (close, inspect) use worktreeTree. Null outside a repository.
318
+ */
319
+ export function worktreeSignal(cwd: string, options: { timeoutMs?: number } = {}): string | null {
320
+ const top = gitDirs(cwd)?.top ?? null;
321
+ if (!top) return null;
322
+ const status = git(
323
+ top,
324
+ [
325
+ "status",
326
+ "--porcelain=v1",
327
+ "-z",
328
+ "--untracked-files=all",
329
+ "--no-renames",
330
+ "--ignore-submodules=dirty",
331
+ ],
332
+ {
333
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
334
+ timeoutMs: options.timeoutMs ?? GIT_TIMEOUTS.worktree,
335
+ },
336
+ );
337
+ if (!status.ok) return null;
338
+ const hash = createHash("sha256");
339
+ hash.update(`${headSha(top) ?? "unborn"}\0`);
340
+ for (const entry of status.stdout.split("\0")) {
341
+ if (entry.length < 4) continue;
342
+ const file = entry.slice(3);
343
+ const absolute = path.join(top, file);
344
+ let stat = "-";
345
+ try {
346
+ const info = fs.lstatSync(absolute, { bigint: true });
347
+ stat = `${info.mode.toString(8)}:${info.size}:${info.mtimeNs}`;
348
+ if (info.isDirectory()) stat += `:${headSha(absolute) ?? "-"}`;
349
+ } catch {
350
+ // Deleted: the status code alone records it.
351
+ }
352
+ hash.update(`${entry.slice(0, 2)}\0${file}\0${stat}\0`);
353
+ }
354
+ return `sig:${hash.digest("hex")}`;
355
+ }
356
+
357
+ /** The best common ancestor of two revisions, or null. */
358
+ export function mergeBase(cwd: string, a: string, b: string): string | null {
359
+ if (!safeArg(a) || !safeArg(b)) return null;
360
+ return line(git(cwd, ["merge-base", a, b]));
361
+ }
362
+
363
+ /**
364
+ * The stable patch-id of `merge-base(base, head)..head`: equal before and
365
+ * after a rebase that only moved the base (and while the base moves without a
366
+ * rebase), different once the change itself differs. Null when the range is
367
+ * empty or a revision does not resolve.
368
+ *
369
+ * Diff options are pinned (3 context lines, myers + indent heuristic, no
370
+ * renames, no color, external diff or textconv, fixed prefixes) so user
371
+ * config cannot change the id between machines. Caveat: patch-id hashes the
372
+ * context lines, so a base change landing within 3 lines of a hunk changes
373
+ * the id. That reads as stale, the safe direction.
374
+ */
375
+ export function patchId(cwd: string, base: string, head: string): string | null {
376
+ if (!safeArg(base) || !safeArg(head)) return null;
377
+ const diff = git(cwd, ["diff", ...PINNED_DIFF, `${base}...${head}`, "--"]);
378
+ return diff.ok ? patchIdOfDiff(cwd, diff.stdout) : null;
379
+ }
380
+
381
+ /** The pinned `git diff` options patch-ids (and ledger diff hashes) are computed with. */
382
+ export const PINNED_DIFF: readonly string[] = [
383
+ "-U3",
384
+ "--diff-algorithm=myers",
385
+ "--indent-heuristic",
386
+ "--no-color",
387
+ "--no-ext-diff",
388
+ "--no-textconv",
389
+ "--no-renames",
390
+ "--full-index",
391
+ "--src-prefix=a/",
392
+ "--dst-prefix=b/",
393
+ ];
394
+
395
+ /** The stable patch-id of a diff made with PINNED_DIFF; null for an empty one. */
396
+ export function patchIdOfDiff(cwd: string, diff: string): string | null {
397
+ if (!diff.trim()) return null;
398
+ const id = git(cwd, ["patch-id", "--stable"], { input: diff });
399
+ return line(id)?.split(/\s+/u)[0] ?? null;
400
+ }
401
+
402
+ export type RemoteTipResult =
403
+ | { ok: true; sha: string | null }
404
+ | { ok: false; code: "invalid_input" | "unavailable"; error: string };
405
+
406
+ /**
407
+ * The commit a remote branch points at (`git ls-remote`). `sha: null` means
408
+ * the remote answered and the branch does not exist; an unreachable remote,
409
+ * an auth prompt or a timeout is `unavailable`, never a hang.
410
+ */
411
+ export function remoteTip(
412
+ cwd: string,
413
+ remote: string,
414
+ branch: string,
415
+ options: { timeoutMs?: number } = {},
416
+ ): RemoteTipResult {
417
+ if (!safeArg(branch))
418
+ return { ok: false, code: "invalid_input", error: "remote and branch must not start with -" };
419
+ return remoteRefTip(cwd, remote, `refs/heads/${branch}`, options);
420
+ }
421
+
422
+ /**
423
+ * The commit a full remote ref points at (`refs/heads/x`, `refs/tags/v1`); an
424
+ * annotated tag resolves to the commit it peels to. Same contract as remoteTip.
425
+ */
426
+ export function remoteRefTip(
427
+ cwd: string,
428
+ remote: string,
429
+ ref: string,
430
+ options: { timeoutMs?: number } = {},
431
+ ): RemoteTipResult {
432
+ if (!safeArg(remote) || !safeArg(ref))
433
+ return { ok: false, code: "invalid_input", error: "remote and ref must not start with -" };
434
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.network;
435
+ const listed = gitNetwork(cwd, ["ls-remote", remote, ref, `${ref}^{}`], timeoutMs);
436
+ if (!listed.ok)
437
+ return {
438
+ ok: false,
439
+ code: "unavailable",
440
+ error: listed.timedOut
441
+ ? `git ls-remote ${redactRemote(remote)} timed out after ${timeoutMs} ms`
442
+ : `git ls-remote ${redactRemote(remote)} failed (unreachable or not authorized)`,
443
+ };
444
+ let direct: string | null = null;
445
+ for (const row of listed.stdout.split(/\r?\n/u)) {
446
+ const [sha, name] = row.split(/\s+/u);
447
+ if (!sha) continue;
448
+ if (name === `${ref}^{}`) return { ok: true, sha };
449
+ if (name === ref) direct = sha;
450
+ }
451
+ return { ok: true, sha: direct };
452
+ }
453
+
454
+ export type FetchResult =
455
+ | { ok: true }
456
+ | { ok: false; code: "invalid_input" | "unavailable" | "busy"; error: string };
457
+
458
+ /**
459
+ * `git fetch --no-tags --no-recurse-submodules --no-write-fetch-head
460
+ * <remote> <refspec…>` under the watchdog (no prompts, bounded). Pass bare
461
+ * object ids (no `:dst`) for a pure read: the objects arrive, no ref moves.
462
+ * Contention on a repository lock is `busy`.
463
+ */
464
+ export function fetchRefs(
465
+ cwd: string,
466
+ remote: string,
467
+ refspecs: readonly string[],
468
+ options: { timeoutMs?: number } = {},
469
+ ): FetchResult {
470
+ if (
471
+ !safeArg(remote) ||
472
+ refspecs.length === 0 ||
473
+ !refspecs.every((spec) => safeArg(spec.replace(/^\+/u, "")))
474
+ )
475
+ return { ok: false, code: "invalid_input", error: "remote and refspecs must not start with -" };
476
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.network;
477
+ const fetched = gitNetwork(
478
+ cwd,
479
+ [
480
+ "fetch",
481
+ "--no-tags",
482
+ "--no-recurse-submodules",
483
+ "--no-write-fetch-head",
484
+ "--quiet",
485
+ remote,
486
+ ...refspecs,
487
+ ],
488
+ timeoutMs,
489
+ );
490
+ if (fetched.ok) return { ok: true };
491
+ if (/\.lock'?:? File exists|unable to lock|another git process/iu.test(fetched.stderr))
492
+ return {
493
+ ok: false,
494
+ code: "busy",
495
+ error: `git fetch ${redactRemote(remote)}: the repository is locked by another git process`,
496
+ };
497
+ return {
498
+ ok: false,
499
+ code: "unavailable",
500
+ error: fetched.timedOut
501
+ ? `git fetch ${redactRemote(remote)} timed out after ${timeoutMs} ms`
502
+ : `git fetch ${redactRemote(remote)} failed (unreachable, not authorized, or ref missing)`,
503
+ };
504
+ }
505
+
506
+ /** The configured remote names. */
507
+ export function remoteNames(cwd: string): string[] {
508
+ return (git(cwd, ["remote"]).stdout ?? "")
509
+ .split(/\r?\n/u)
510
+ .map((value) => value.trim())
511
+ .filter(Boolean);
512
+ }
513
+
514
+ /** The absolute git common dir (shared by every worktree), or null outside a repository. */
515
+ export function gitCommonDir(cwd: string): string | null {
516
+ return gitDirs(cwd)?.commonDir ?? null;
517
+ }
518
+
519
+ /** True when `rev` names a commit present in the local object store. */
520
+ export function hasCommit(cwd: string, rev: string): boolean {
521
+ if (!safeArg(rev)) return false;
522
+ return git(cwd, ["cat-file", "-e", `${rev}^{commit}`]).ok;
523
+ }
524
+
525
+ /** The commit a local ref (e.g. refs/remotes/origin/main) points at, or null. */
526
+ export function resolveRef(cwd: string, ref: string): string | null {
527
+ if (!safeArg(ref)) return null;
528
+ return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
529
+ }
530
+
531
+ /** The branch origin's HEAD names (`origin/main`), or null. */
532
+ export function originHeadRef(cwd: string): string | null {
533
+ return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
534
+ }
535
+
536
+ /** The top of the working tree, or null outside one. */
537
+ export function worktreeTop(cwd: string): string | null {
538
+ return gitDirs(cwd)?.top ?? null;
539
+ }
540
+
541
+ /** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
542
+ export function isTracked(top: string, file: string): boolean {
543
+ return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
544
+ }
545
+
546
+ /**
547
+ * The files whose own change differs between two versions of a branch: the
548
+ * judged `from` (forked from `fromFork`) and the current `to` (forked from
549
+ * `toFork`), compared by exact blob and mode, never by diff text (#334). A
550
+ * file the branch touches on one side only differs. A file it touches on
551
+ * both sides is unchanged only when the base left it alone between the forks
552
+ * and the branch's resulting blob is identical, so a hunk moved within a
553
+ * file, a re-indent or a base edit in a file the branch touches always
554
+ * counts. Null when git cannot read either side: the caller fails closed.
555
+ */
556
+ export function ownChangeDrift(
557
+ cwd: string,
558
+ range: { from: string; to: string; fromFork: string; toFork: string },
559
+ ): string[] | null {
560
+ const before = diffRaw(cwd, range.fromFork, range.from);
561
+ const after = diffRaw(cwd, range.toFork, range.to);
562
+ if (!before || !after) return null;
563
+ const judged = new Map(before.map((entry) => [entry.path, entry]));
564
+ const current = new Map(after.map((entry) => [entry.path, entry]));
565
+ const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
566
+ a !== undefined &&
567
+ b !== undefined &&
568
+ a.oldMode === b.oldMode &&
569
+ a.oldBlob === b.oldBlob &&
570
+ a.newMode === b.newMode &&
571
+ a.newBlob === b.newBlob;
572
+ return [...new Set([...judged.keys(), ...current.keys()])]
573
+ .filter((file) => !same(judged.get(file), current.get(file)))
574
+ .toSorted();
575
+ }
576
+
577
+ /**
578
+ * Whether the branch's own files are identical at `from` (on `fromBase`) and
579
+ * `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
580
+ * exactness behind every patch-equal carry; false when git cannot tell.
581
+ */
582
+ export function sameOwnChange(
583
+ cwd: string,
584
+ range: { from: string; to: string; fromBase: string; toBase: string },
585
+ ): boolean {
586
+ const fromFork = mergeBase(cwd, range.fromBase, range.from);
587
+ const toFork = mergeBase(cwd, range.toBase, range.to);
588
+ if (!fromFork || !toFork) return false;
589
+ return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
590
+ }
591
+
592
+ /**
593
+ * The verbatim patch-ids of the non-merge commits of `fork..head` (empty
594
+ * commits have none) and how many merge commits the range holds: unlike
595
+ * `--stable`, whitespace counts, so a re-indent is another patch. Diff
596
+ * options are pinned as in patchId. Null when git fails, or has no
597
+ * `patch-id --verbatim`: the caller fails closed.
598
+ */
599
+ export function commitPatchIds(
600
+ cwd: string,
601
+ fork: string,
602
+ head: string,
603
+ ): { patchIds: string[]; merges: number } | null {
604
+ if (!safeArg(fork) || !safeArg(head)) return null;
605
+ const range = `${fork}..${head}`;
606
+ const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
607
+ const log = git(
608
+ cwd,
609
+ [
610
+ "log",
611
+ "--no-merges",
612
+ "-p",
613
+ "-U3",
614
+ "--diff-algorithm=myers",
615
+ "--indent-heuristic",
616
+ "--no-color",
617
+ "--no-ext-diff",
618
+ "--no-textconv",
619
+ "--no-renames",
620
+ "--full-index",
621
+ "--src-prefix=a/",
622
+ "--dst-prefix=b/",
623
+ "--format=commit %H",
624
+ range,
625
+ "--",
626
+ ],
627
+ { timeoutMs: GIT_TIMEOUTS.worktree },
628
+ );
629
+ if (merges === null || !log.ok) return null;
630
+ if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
631
+ const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
632
+ if (!ids.ok) return null;
633
+ return {
634
+ patchIds: ids.stdout
635
+ .split("\n")
636
+ .map((entry) => entry.trim().split(/\s+/u)[0])
637
+ .filter(Boolean),
638
+ merges: Number(merges),
639
+ };
640
+ }
641
+
642
+ /** Pinned diff options for the review-lane reads: user config never changes them. */
643
+ const LANE_DIFF = [
644
+ "-c",
645
+ "core.quotePath=false",
646
+ "diff",
647
+ "--no-color",
648
+ "--no-ext-diff",
649
+ "--no-textconv",
650
+ "--no-renames",
651
+ ];
652
+
653
+ /** `fork..head`, or with `head` null the working tree's tracked changes since `fork`. */
654
+ const laneRange = (fork: string, head: string | null): string[] | null => {
655
+ if (!safeArg(fork) || (head !== null && !safeArg(head))) return null;
656
+ return head === null ? [fork] : [fork, head];
657
+ };
658
+
659
+ export type NumstatEntry = { path: string; added: number; deleted: number; binary: boolean };
660
+
661
+ /** Added and deleted lines per file of `fork..head` (no renames); null when git fails. */
662
+ export function diffNumstat(cwd: string, fork: string, head: string | null): NumstatEntry[] | null {
663
+ const range = laneRange(fork, head);
664
+ if (!range) return null;
665
+ const run = git(cwd, [...LANE_DIFF, "--numstat", "-z", ...range, "--"]);
666
+ if (!run.ok) return null;
667
+ const out: NumstatEntry[] = [];
668
+ for (const record of run.stdout.split("\0")) {
669
+ const match = /^(-|\d+)\t(-|\d+)\t(.+)$/su.exec(record.replace(/^\n/u, ""));
670
+ if (!match) continue;
671
+ const binary = match[1] === "-";
672
+ out.push({
673
+ path: match[3],
674
+ added: binary ? 0 : Number(match[1]),
675
+ deleted: binary ? 0 : Number(match[2]),
676
+ binary,
677
+ });
678
+ }
679
+ return out;
680
+ }
681
+
682
+ /** One file of `git diff --raw`: modes and blob ids on each side (zeros: absent or the working tree). */
683
+ export type RawEntry = {
684
+ path: string;
685
+ oldMode: string;
686
+ newMode: string;
687
+ oldBlob: string;
688
+ newBlob: string;
689
+ };
690
+
691
+ /**
692
+ * The changed files of `fork..head` (or the working tree) with their modes
693
+ * and blob ids, read NUL-separated so no path is ever quoted or split.
694
+ * Null when git fails.
695
+ */
696
+ export function diffRaw(cwd: string, fork: string, head: string | null): RawEntry[] | null {
697
+ const range = laneRange(fork, head);
698
+ if (!range) return null;
699
+ const run = git(cwd, [...LANE_DIFF, "--raw", "-z", "--no-abbrev", ...range, "--"]);
700
+ if (!run.ok) return null;
701
+ const fields = run.stdout.split("\0");
702
+ const out: RawEntry[] = [];
703
+ for (let index = 0; index + 1 < fields.length; index += 2) {
704
+ const meta = /^:(\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) [A-Z]\d*$/u.exec(
705
+ fields[index].replace(/^\n/u, ""),
706
+ );
707
+ if (!meta) return null;
708
+ out.push({
709
+ path: fields[index + 1],
710
+ oldMode: meta[1],
711
+ newMode: meta[2],
712
+ oldBlob: meta[3],
713
+ newBlob: meta[4],
714
+ });
715
+ }
716
+ return out;
717
+ }
718
+
719
+ /** The contents of `ids` (blob ids), by id; null when git fails. Missing objects are left out. */
720
+ export function readBlobs(cwd: string, ids: readonly string[]): Map<string, Buffer> | null {
721
+ const out = new Map<string, Buffer>();
722
+ const wanted = [...new Set(ids)].filter((id) => /^[0-9a-f]{40,64}$/u.test(id));
723
+ if (!wanted.length) return out;
724
+ const run = spawnSync("git", ["cat-file", "--batch"], {
725
+ cwd,
726
+ input: `${wanted.join("\n")}\n`,
727
+ maxBuffer: MAX_BUFFER,
728
+ timeout: GIT_TIMEOUTS.local,
729
+ killSignal: "SIGKILL",
730
+ windowsHide: true,
731
+ });
732
+ if (run.status !== 0 || !run.stdout) return null;
733
+ const bytes = run.stdout;
734
+ let at = 0;
735
+ for (const id of wanted) {
736
+ const end = bytes.indexOf(0x0a, at);
737
+ if (end < 0) return null;
738
+ const header = bytes.subarray(at, end).toString("utf8").split(" ");
739
+ at = end + 1;
740
+ if (header[1] === "missing") continue;
741
+ const size = Number(header[2]);
742
+ if (header[0] !== id || !Number.isSafeInteger(size)) return null;
743
+ out.set(id, bytes.subarray(at, at + size));
744
+ at += size + 1;
745
+ }
746
+ return out;
747
+ }
748
+
749
+ /** Blob ids of the files named `name` anywhere in the tree of `rev`, by path; null when git fails. */
750
+ export function treeFilesNamed(cwd: string, rev: string, name: string): Map<string, string> | null {
751
+ if (!safeArg(rev)) return null;
752
+ const run = git(cwd, ["ls-tree", "-r", "-z", "--full-tree", rev]);
753
+ if (!run.ok) return null;
754
+ const out = new Map<string, string>();
755
+ for (const record of run.stdout.split("\0")) {
756
+ const match = /^\d{6} blob ([0-9a-f]+)\t(.+)$/su.exec(record);
757
+ if (match && (match[2] === name || match[2].endsWith(`/${name}`))) out.set(match[2], match[1]);
758
+ }
759
+ return out;
760
+ }
761
+
762
+ /** The text of `file` (a fixed repo path) at `rev`; null when it is absent or git fails. */
763
+ export function fileAt(cwd: string, rev: string, file: string): string | null {
764
+ if (!safeArg(rev)) return null;
765
+ const run = git(cwd, ["cat-file", "blob", `${rev}:${file}`]);
766
+ return run.ok ? run.stdout : null;
767
+ }
768
+
769
+ /** Untracked, not ignored files of the working tree (repo-relative). */
770
+ export function untrackedFiles(cwd: string): string[] {
771
+ const run = git(cwd, ["ls-files", "-z", "--others", "--exclude-standard"]);
772
+ return run.ok ? run.stdout.split("\0").filter(Boolean) : [];
773
+ }
774
+
775
+ /**
776
+ * Commits `head` lacks from `base` (behind) and has on top of it (ahead), and
777
+ * whether `base` is already an ancestor of `head`. Null when either side
778
+ * does not resolve locally.
779
+ */
780
+ export function aheadBehind(
781
+ cwd: string,
782
+ base: string,
783
+ head: string,
784
+ ): { ahead: number; behind: number; upToDate: boolean } | null {
785
+ if (!safeArg(base) || !safeArg(head)) return null;
786
+ const counted = line(git(cwd, ["rev-list", "--left-right", "--count", `${base}...${head}`]));
787
+ const match = counted ? /^(\d+)\s+(\d+)$/u.exec(counted) : null;
788
+ if (!match) return null;
789
+ const upToDate = git(cwd, ["merge-base", "--is-ancestor", base, head]).ok;
790
+ return { behind: Number(match[1]), ahead: Number(match[2]), upToDate };
791
+ }
792
+
793
+ const config = (cwd: string, key: string): string | null =>
794
+ line(git(cwd, ["config", "--get", key]));
795
+
796
+ /** The current branch name (also on an unborn branch), or null when detached. */
797
+ export function currentBranch(cwd: string): string | null {
798
+ const read = readHeadBranch(cwd);
799
+ return read ? read.branch : line(git(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]));
800
+ }
801
+
802
+ /**
803
+ * The remote `git push` would use for `branch` (default: the current branch),
804
+ * in git's own precedence: branch.<b>.pushRemote, remote.pushDefault,
805
+ * branch.<b>.remote, then `origin`, then the only remote. Null when none.
806
+ */
807
+ export function pushRemoteName(cwd: string, branch?: string | null): string | null {
808
+ const name = branch ?? currentBranch(cwd);
809
+ const remotes = remoteNames(cwd);
810
+ const candidates = [
811
+ name ? config(cwd, `branch.${name}.pushRemote`) : null,
812
+ config(cwd, "remote.pushDefault"),
813
+ name ? config(cwd, `branch.${name}.remote`) : null,
814
+ ];
815
+ for (const candidate of candidates)
816
+ if (candidate && candidate !== "." && remotes.includes(candidate)) return candidate;
817
+ if (remotes.includes("origin")) return "origin";
818
+ return remotes.length === 1 ? remotes[0] : null;
819
+ }
820
+
821
+ /**
822
+ * The single push URL of `remote` after git's insteadOf/pushInsteadOf
823
+ * rewrites. Null when the remote is missing or pushes to several URLs (the
824
+ * target would be ambiguous).
825
+ */
826
+ export function pushUrl(cwd: string, remote: string): string | null {
827
+ if (!safeArg(remote)) return null;
828
+ const urls = git(cwd, ["remote", "get-url", "--push", "--all", remote])
829
+ .stdout.split(/\r?\n/u)
830
+ .map((value) => value.trim())
831
+ .filter(Boolean);
832
+ return urls.length === 1 ? urls[0] : null;
833
+ }
834
+
835
+ export type RemoteUrl = {
836
+ protocol: "ssh" | "https" | "http" | "git" | "file";
837
+ user: string | null;
838
+ /** Host as written in the URL (an SSH alias stays unresolved, case kept). */
839
+ host: string;
840
+ port: string | null;
841
+ /** Repository path without leading slash, trailing slash or `.git`. */
842
+ path: string;
843
+ };
844
+
845
+ const cleanPath = (value: string): string =>
846
+ value
847
+ .replace(/^\/+/u, "")
848
+ .replace(/\/+$/u, "")
849
+ .replace(/\.git$/u, "");
850
+
851
+ /** Parse a git remote URL (scp-like `user@host:path` or URL syntax). */
852
+ export function parseRemoteUrl(raw: string): RemoteUrl | null {
853
+ const value = raw.trim();
854
+ if (!value || /^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return null;
855
+ // Local paths, including Windows drive paths that look scp-like (C:\repo).
856
+ if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith(".")) {
857
+ return { protocol: "file", user: null, host: "", port: null, path: value };
858
+ }
859
+ if (!value.includes("://")) {
860
+ // `user:secret@host:path` is not scp syntax (see redactRemote).
861
+ if (/^[^/@]*:[^/]*@/u.test(value)) return null;
862
+ const scp = /^(?:([^@\s/:]+)@)?([^:/\s]+):(.+)$/u.exec(value);
863
+ if (!scp) return null;
864
+ return {
865
+ protocol: "ssh",
866
+ user: scp[1] ?? null,
867
+ // As written: ssh matches Host patterns case-insensitively and expands
868
+ // %h with the original spelling.
869
+ host: scp[2],
870
+ port: null,
871
+ path: cleanPath(scp[3]),
872
+ };
873
+ }
874
+ let url: URL;
875
+ try {
876
+ url = new URL(value);
877
+ } catch {
878
+ return null;
879
+ }
880
+ const scheme = url.protocol.replace(/:$/u, "");
881
+ const protocol =
882
+ scheme === "ssh" || scheme === "git+ssh" || scheme === "ssh+git"
883
+ ? "ssh"
884
+ : scheme === "https" || scheme === "http" || scheme === "git" || scheme === "file"
885
+ ? scheme
886
+ : null;
887
+ if (!protocol) return null;
888
+ return {
889
+ protocol,
890
+ // Never keep an HTTPS credential; an SSH user is part of the identity.
891
+ user: protocol === "ssh" && url.username ? decodeURIComponent(url.username) : null,
892
+ host: url.hostname.replace(/\.$/u, ""),
893
+ port: url.port || null,
894
+ path: cleanPath(decodeURIComponent(url.pathname)),
895
+ };
896
+ }
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // ~/.ssh/config Host alias resolution: enough of ssh_config(5) for HostName
900
+ // and Port, with OpenSSH's semantics.
901
+ // - The first value obtained for each option wins.
902
+ // - Host patterns support `*`, `?` and `!negation`. They match the alias
903
+ // case-insensitively, and %h expands to the alias as written.
904
+ // - Include is read where it appears. Its directives apply only when the
905
+ // enclosing block matches (or at top level). Inside an inactive block,
906
+ // Host lines in the included file never match. After the Include, the
907
+ // enclosing block's state is restored, so its later directives still apply.
908
+ // - `Match` blocks are treated as non-matching: they depend on runtime state
909
+ // that a static read cannot evaluate.
910
+
911
+ const globToRegExp = (pattern: string): RegExp =>
912
+ new RegExp(
913
+ `^${pattern
914
+ .split("")
915
+ .map((char) =>
916
+ char === "*" ? ".*" : char === "?" ? "." : char.replace(/[.+^${}()|[\]\\]/gu, "\\$&"),
917
+ )
918
+ .join("")}$`,
919
+ "iu",
920
+ );
921
+
922
+ const hostMatches = (patterns: string[], host: string): boolean => {
923
+ let matched = false;
924
+ for (const pattern of patterns) {
925
+ const negated = pattern.startsWith("!");
926
+ if (globToRegExp(negated ? pattern.slice(1) : pattern).test(host)) {
927
+ if (negated) return false;
928
+ matched = true;
929
+ }
930
+ }
931
+ return matched;
932
+ };
933
+
934
+ const tokens = (rest: string): string[] =>
935
+ [...rest.matchAll(/"([^"]*)"|(\S+)/gu)].map((match) => match[1] ?? match[2]);
936
+
937
+ type SshDirective = { key: string; args: string[] };
938
+
939
+ const directives = (text: string): SshDirective[] =>
940
+ text.split(/\r?\n/u).flatMap((raw) => {
941
+ const trimmed = raw.trim();
942
+ if (!trimmed || trimmed.startsWith("#")) return [];
943
+ const match = /^(\S+?)(?:\s*=\s*|\s+)(.*)$/u.exec(trimmed);
944
+ if (!match) return [];
945
+ return [{ key: match[1].toLowerCase(), args: tokens(match[2]) }];
946
+ });
947
+
948
+ /** An ssh_config text plus the home its relative `Include`s resolve against (~/.ssh). */
949
+ export type SshConfig = { text: string; home: string };
950
+
951
+ /** The user's ~/.ssh/config (empty when missing); Includes resolve lazily. */
952
+ export function readSshConfig(home: string = os.homedir()): SshConfig {
953
+ try {
954
+ return { text: fs.readFileSync(path.join(home, ".ssh", "config"), "utf8"), home };
955
+ } catch {
956
+ return { text: "", home };
957
+ }
958
+ }
959
+
960
+ const includeFiles = (spec: string, home: string): string[] => {
961
+ const expanded = spec.startsWith("~") ? path.join(home, spec.slice(1)) : spec;
962
+ const absolute = path.isAbsolute(expanded) ? expanded : path.join(home, ".ssh", expanded);
963
+ const dir = path.dirname(absolute);
964
+ const base = path.basename(absolute);
965
+ if (!/[*?]/u.test(base)) return [absolute];
966
+ try {
967
+ const pattern = globToRegExp(base);
968
+ return fs
969
+ .readdirSync(dir)
970
+ .filter((name) => pattern.test(name))
971
+ .toSorted()
972
+ .map((name) => path.join(dir, name));
973
+ } catch {
974
+ return [];
975
+ }
976
+ };
977
+
978
+ /**
979
+ * Resolve an SSH host alias: the effective HostName (with %h / %% expanded)
980
+ * and Port. An alias with no HostName resolves to itself. A plain string is
981
+ * a config text whose relative Includes resolve against the real home.
982
+ */
983
+ export function resolveSshHost(
984
+ alias: string,
985
+ sshConfig: string | SshConfig,
986
+ ): { hostname: string; port: string | null } {
987
+ const source =
988
+ typeof sshConfig === "string" ? { text: sshConfig, home: os.homedir() } : sshConfig;
989
+ let hostname: string | null = null;
990
+ let port: string | null = null;
991
+ const evaluate = (text: string, initiallyActive: boolean, neverMatch: boolean, depth: number) => {
992
+ let active = initiallyActive;
993
+ for (const { key, args } of directives(text)) {
994
+ if (key === "host") {
995
+ active = !neverMatch && hostMatches(args, alias);
996
+ continue;
997
+ }
998
+ if (key === "match") {
999
+ active = false;
1000
+ continue;
1001
+ }
1002
+ if (key === "include") {
1003
+ if (depth >= 16) continue;
1004
+ for (const spec of args)
1005
+ for (const file of includeFiles(spec, source.home)) {
1006
+ let included: string;
1007
+ try {
1008
+ included = fs.readFileSync(file, "utf8");
1009
+ } catch {
1010
+ continue;
1011
+ }
1012
+ // OpenSSH: an Include inside an inactive block can never match,
1013
+ // and the enclosing block's state is restored afterwards.
1014
+ evaluate(included, active, neverMatch || !active, depth + 1);
1015
+ }
1016
+ continue;
1017
+ }
1018
+ if (!active || args.length === 0) continue;
1019
+ if (key === "hostname" && hostname === null)
1020
+ hostname = args[0].replace(/%(%|h)/gu, (_, token: string) => (token === "h" ? alias : "%"));
1021
+ if (key === "port" && port === null) port = args[0];
1022
+ }
1023
+ };
1024
+ evaluate(source.text, true, false, 0);
1025
+ return { hostname: (hostname ?? alias).replace(/\.$/u, ""), port };
1026
+ }
1027
+
1028
+ // ---------------------------------------------------------------------------
1029
+ // Forge derivation (D16): the push remote host decides github vs gitlab.
1030
+
1031
+ export type ForgeKind = "github" | "gitlab";
1032
+
1033
+ /** Self-hosted / enterprise hosts known from vcs config (`github.host`, `gitlab.host`). */
1034
+ export type ForgeHosts = { github?: readonly string[]; gitlab?: readonly string[] };
1035
+
1036
+ export type DerivedForge = {
1037
+ kind: ForgeKind;
1038
+ /** Host as written in the remote URL (may be an SSH alias). */
1039
+ host: string;
1040
+ /** Host after SSH alias resolution. */
1041
+ hostname: string;
1042
+ /** Host the gh/glab API calls must target (GH_HOST / GITLAB_HOST). */
1043
+ apiHost: string;
1044
+ /** owner/name (GitHub) or group[/subgroup]/project (GitLab). */
1045
+ repo: string;
1046
+ /** How the kind was decided. */
1047
+ via: "known_host" | "configured_host" | "host_name";
1048
+ };
1049
+
1050
+ // Public forges and their SSH-over-443 endpoints.
1051
+ const KNOWN_HOSTS: Record<string, { kind: ForgeKind; apiHost: string }> = {
1052
+ "github.com": { kind: "github", apiHost: "github.com" },
1053
+ "ssh.github.com": { kind: "github", apiHost: "github.com" },
1054
+ "gitlab.com": { kind: "gitlab", apiHost: "gitlab.com" },
1055
+ "altssh.gitlab.com": { kind: "gitlab", apiHost: "gitlab.com" },
1056
+ };
1057
+
1058
+ const normalizeHost = (value: string): string =>
1059
+ value
1060
+ .trim()
1061
+ .toLowerCase()
1062
+ .replace(/^https?:\/\//u, "")
1063
+ .replace(/\/.*$/u, "")
1064
+ .replace(/\.$/u, "");
1065
+
1066
+ /**
1067
+ * Derive the forge from a remote URL. SSH aliases resolve through `sshConfig`
1068
+ * (default: the user's ~/.ssh/config), so `git@github-work.com:org/repo` with
1069
+ * `Host github-work.com / HostName github.com` is GitHub at github.com.
1070
+ * Configured enterprise hosts win next; finally a host whose first label is
1071
+ * exactly `github` or `gitlab` (gitlab.example.com) is taken at face value.
1072
+ * Null when the host is unknown or the path has no owner/name.
1073
+ */
1074
+ export function deriveForge(
1075
+ url: string,
1076
+ options: { hosts?: ForgeHosts; sshConfig?: string | SshConfig } = {},
1077
+ ): DerivedForge | null {
1078
+ const parsed = parseRemoteUrl(url);
1079
+ if (!parsed || parsed.protocol === "file" || !parsed.host) return null;
1080
+ const segments = parsed.path.split("/").filter(Boolean);
1081
+ if (segments.length < 2) return null;
1082
+ const hostname = (
1083
+ parsed.protocol === "ssh"
1084
+ ? resolveSshHost(parsed.host, options.sshConfig ?? readSshConfig()).hostname
1085
+ : parsed.host
1086
+ ).toLowerCase();
1087
+ const withPort =
1088
+ parsed.protocol !== "ssh" && parsed.port ? `${hostname}:${parsed.port}` : hostname;
1089
+ const base = { host: parsed.host, hostname, repo: segments.join("/") };
1090
+ const known = KNOWN_HOSTS[hostname];
1091
+ if (known) {
1092
+ if (known.kind === "github" && segments.length !== 2) return null;
1093
+ return { ...base, kind: known.kind, apiHost: known.apiHost, via: "known_host" };
1094
+ }
1095
+ for (const kind of ["github", "gitlab"] as const) {
1096
+ const hosts = new Set((options.hosts?.[kind] ?? []).map(normalizeHost));
1097
+ if (hosts.has(withPort) || hosts.has(hostname)) {
1098
+ const apiHost = hosts.has(withPort) ? withPort : hostname;
1099
+ return { ...base, kind, apiHost, via: "configured_host" };
1100
+ }
1101
+ }
1102
+ const label = hostname.split(".")[0];
1103
+ if ((label === "github" || label === "gitlab") && hostname.includes("."))
1104
+ return { ...base, kind: label, apiHost: withPort, via: "host_name" };
1105
+ return null;
1106
+ }
1107
+
1108
+ export type PushForgeResult =
1109
+ | { ok: true; remote: string; /** Redacted push URL. */ url: string; forge: DerivedForge }
1110
+ | { ok: false; code: "not_found" | "unavailable"; error: string; unblock: string };
1111
+
1112
+ /**
1113
+ * The forge behind the current branch's push remote. Fails closed with an
1114
+ * unblock hint when there is no push remote, the push URL is ambiguous, or
1115
+ * the host is not a recognizable GitHub/GitLab.
1116
+ */
1117
+ export function pushForge(
1118
+ cwd: string,
1119
+ options: { branch?: string | null; hosts?: ForgeHosts; sshConfig?: string | SshConfig } = {},
1120
+ ): PushForgeResult {
1121
+ const remote = pushRemoteName(cwd, options.branch);
1122
+ if (!remote)
1123
+ return {
1124
+ ok: false,
1125
+ code: "not_found",
1126
+ error: "no push remote is configured for this branch",
1127
+ unblock: "git remote add origin <url>",
1128
+ };
1129
+ const url = pushUrl(cwd, remote);
1130
+ if (!url)
1131
+ return {
1132
+ ok: false,
1133
+ code: "unavailable",
1134
+ error: `remote "${remote}" has no single push URL`,
1135
+ unblock: `git remote get-url --push --all ${remote} # keep exactly one push URL`,
1136
+ };
1137
+ const forge = deriveForge(url, options);
1138
+ if (!forge) {
1139
+ // Never echo the raw URL: it may carry a token (https://user:tok@…).
1140
+ const parsed = parseRemoteUrl(url);
1141
+ const host = parsed?.host || null;
1142
+ return {
1143
+ ok: false,
1144
+ code: "unavailable",
1145
+ error: host
1146
+ ? parsed?.protocol === "ssh" && !host.includes(".")
1147
+ ? `unsupported_forge: could not resolve ssh alias "${host}" to GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
1148
+ : parsed?.protocol === "ssh"
1149
+ ? `unsupported_forge: ssh host "${host}" is not a GitHub/GitLab host or an alias workit can resolve; PR and CI verbs need one (workit git push still pushes with git)`
1150
+ : `unsupported_forge: cannot tell whether push host "${host}" is GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
1151
+ : `push URL ${redactRemote(url)} of remote "${remote}" is not a GitHub/GitLab URL`,
1152
+ unblock: host
1153
+ ? `if ${host} is an ssh alias of GitHub/GitLab, add "Host ${host}" with "HostName github.com" (or gitlab.com) to ~/.ssh/config; if it is a self-hosted GitHub/GitLab, set github.host or gitlab.host in ${configFilePath("vcs.json")}; other forges (Bitbucket, Gitea…) have no PR/CI verbs`
1154
+ : `git remote set-url --push ${remote} <ssh-or-https-url>`,
1155
+ };
1156
+ }
1157
+ return { ok: true, remote, url: redactRemote(url), forge };
1158
+ }
1159
+
1160
+ /**
1161
+ * D16: the push remote decides. A configured provider that disagrees is not
1162
+ * silently overridden; it is `blocked` with the exact fix.
1163
+ */
1164
+ export function forgeConflict(
1165
+ derived: DerivedForge,
1166
+ configuredProvider: string | null | undefined,
1167
+ ): { code: "blocked"; error: string; unblock: string } | null {
1168
+ const configured = configuredProvider?.trim().toLowerCase();
1169
+ if (!configured || configured === derived.kind) return null;
1170
+ return {
1171
+ code: "blocked",
1172
+ error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}, so its account and grants do not apply; PR and CI verbs stop here (workit git push still pushes with git)`,
1173
+ unblock: `add a workspace whose glob matches only this repo, with "vcs": {"provider": "${derived.kind}"} (and its account), to ${configFilePath("workspaces.json")}; the most specific glob wins`,
1174
+ };
1175
+ }