@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
@@ -0,0 +1,130 @@
1
+ // The default base for patch-ids, author trailers and review lanes. Its own
2
+ // module so the lane (core/policy/lane.ts) and the ledger can both read it
3
+ // without importing each other.
4
+ import { spawnSync } from "node:child_process";
5
+ import { vcsConfig } from "../core/vcs-config";
6
+ import { originHeadRef, resolveRef } from "./rev";
7
+
8
+ const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
9
+
10
+ /**
11
+ * The PR target of the release track the checkout is on, when the workspace
12
+ * configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
13
+ * plain workspace default does not override origin's default branch.
14
+ */
15
+ const trackTrunk = (cwd: string): string | null => {
16
+ try {
17
+ const resolved = vcsConfig("resolve", cwd);
18
+ if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
19
+ return null;
20
+ const target = String(resolved.defaultTargetBranch ?? "").trim();
21
+ return target && safeRef(target) ? target : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ /** Every ref by full name: true when it peels to a commit. */
28
+ type RefTable = { refs: Map<string, boolean>; originHead: string | null };
29
+
30
+ // gitrevisions(7): where a short name is looked up, in order.
31
+ const RULES = [
32
+ (n: string) => `refs/${n}`,
33
+ (n: string) => `refs/tags/${n}`,
34
+ (n: string) => `refs/heads/${n}`,
35
+ (n: string) => `refs/remotes/${n}`,
36
+ (n: string) => `refs/remotes/${n}/HEAD`,
37
+ ];
38
+
39
+ /**
40
+ * `<name>^{commit}` resolves: git takes the FIRST existing ref in rule order
41
+ * (whatever it points at) and then needs a commit, so a tag `main` on a tree
42
+ * hides the branch `main`.
43
+ */
44
+ const resolves = (table: RefTable, name: string): boolean => {
45
+ if (!safeRef(name)) return false;
46
+ for (const rule of RULES) {
47
+ const isCommit = table.refs.get(rule(name));
48
+ if (isCommit !== undefined) return isCommit;
49
+ }
50
+ return false;
51
+ };
52
+
53
+ /**
54
+ * `git symbolic-ref --short`'s name for a full ref: the shortest form that no
55
+ * earlier rule resolves to another ref (git's shorten_unambiguous_ref), so an
56
+ * `origin/main` branch or tag makes origin's HEAD `remotes/origin/main`.
57
+ */
58
+ const shorten = (table: RefTable, full: string): string => {
59
+ const patterns = ["refs/", "refs/tags/", "refs/heads/", "refs/remotes/"];
60
+ for (let rule = RULES.length - 1; rule >= 0; rule -= 1) {
61
+ let short: string | null = null;
62
+ if (rule === 4) {
63
+ const match = /^refs\/remotes\/(.+)\/HEAD$/u.exec(full);
64
+ short = match ? match[1] : null;
65
+ } else if (full.startsWith(patterns[rule])) short = full.slice(patterns[rule].length);
66
+ if (!short) continue;
67
+ let clash = false;
68
+ for (let earlier = 0; earlier < rule && !clash; earlier += 1)
69
+ clash = table.refs.has(RULES[earlier](short));
70
+ if (!clash) return short;
71
+ }
72
+ return full;
73
+ };
74
+
75
+ /**
76
+ * Every ref and origin's HEAD target from one `git for-each-ref` (the
77
+ * candidates were probed one spawn each: #284 N6). Read fresh on every call:
78
+ * refs move during a workit call. Null when git refuses (a ref naming a
79
+ * missing object makes for-each-ref fatal), so the caller probes instead.
80
+ */
81
+ function refTable(cwd: string): RefTable | null {
82
+ const run = spawnSync(
83
+ "git",
84
+ ["for-each-ref", "--format=%(refname)%00%(objecttype)%00%(*objecttype)%00%(symref)"],
85
+ { cwd, encoding: "utf8", timeout: 20_000, killSignal: "SIGKILL", windowsHide: true },
86
+ );
87
+ if (run.status !== 0) return null;
88
+ const refs = new Map<string, boolean>();
89
+ let target: string | null = null;
90
+ for (const row of (run.stdout ?? "").split(/\r?\n/u)) {
91
+ const [name, type, peeled, symref] = row.split("\0");
92
+ if (!name) continue;
93
+ refs.set(name, type === "commit" || peeled === "commit");
94
+ if (name === "refs/remotes/origin/HEAD" && symref) target = symref;
95
+ }
96
+ const table: RefTable = { refs, originHead: null };
97
+ if (target) table.originHead = shorten(table, target);
98
+ return table;
99
+ }
100
+
101
+ const CANDIDATES = ["origin/main", "origin/master", "main", "master", "origin/develop", "develop"];
102
+
103
+ /** The base from probing each candidate with its own git call (the old way, kept for a repo for-each-ref refuses). */
104
+ function probedBase(cwd: string, trunk: string | null): string | null {
105
+ if (trunk)
106
+ for (const candidate of [`origin/${trunk}`, trunk])
107
+ if (resolveRef(cwd, candidate)) return candidate;
108
+ const originHead = originHeadRef(cwd);
109
+ if (originHead && resolveRef(cwd, originHead)) return originHead;
110
+ for (const candidate of CANDIDATES) if (resolveRef(cwd, candidate)) return candidate;
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * The default base for patch-ids and author trailers: the release track's PR
116
+ * target (origin's copy, else the local one) when tracks are configured, else
117
+ * origin's HEAD branch, else the first of origin/main, origin/master, main,
118
+ * master, origin/develop, develop that exists.
119
+ */
120
+ export function defaultBase(cwd: string): string | null {
121
+ const trunk = trackTrunk(cwd);
122
+ const table = refTable(cwd);
123
+ if (!table) return probedBase(cwd, trunk);
124
+ if (trunk)
125
+ for (const candidate of [`origin/${trunk}`, trunk])
126
+ if (resolves(table, candidate)) return candidate;
127
+ if (table.originHead && resolves(table, table.originHead)) return table.originHead;
128
+ for (const candidate of CANDIDATES) if (resolves(table, candidate)) return candidate;
129
+ return null;
130
+ }
@@ -0,0 +1,28 @@
1
+ // Environment that points git at another repository, index, object store or
2
+ // ref namespace than the one at the working directory (#545). Grants resolve
3
+ // from the working directory; git follows these, so a grant-gated verb run
4
+ // with any of them set could act on a repository whose grants were never read.
5
+
6
+ export const GIT_REDIRECT_ENV = [
7
+ "GIT_DIR",
8
+ "GIT_WORK_TREE",
9
+ "GIT_COMMON_DIR",
10
+ "GIT_INDEX_FILE",
11
+ "GIT_OBJECT_DIRECTORY",
12
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
13
+ "GIT_NAMESPACE",
14
+ ] as const;
15
+
16
+ /** The redirecting variables set (even empty) in any of `envs`, in GIT_REDIRECT_ENV order. */
17
+ export const gitRedirectsIn = (...envs: ReadonlyArray<NodeJS.ProcessEnv>): string[] =>
18
+ GIT_REDIRECT_ENV.filter((name) => envs.some((env) => env[name] !== undefined));
19
+
20
+ /** Why a grant-gated `workit <verb>` does not run under them, with its unblock. */
21
+ export const gitRedirectRefusal = (
22
+ names: readonly string[],
23
+ verb: string,
24
+ cwd: string,
25
+ ): { error: string; unblock: string } => ({
26
+ error: `git_env_redirect: ${names.join(", ")} ${names.length === 1 ? "is" : "are"} set, so git would act on the repository ${names.length === 1 ? "it names" : "they name"} while grants are checked for ${cwd}; workit ${verb} does not run`,
27
+ unblock: `unset ${names.join(" ")}, or cd into the repository and run workit ${verb} there`,
28
+ });
@@ -0,0 +1,122 @@
1
+ // Git facts that cannot change while one workit process runs, asked once.
2
+ //
3
+ // A checkout's top level, git dir and common dir are fixed for the life of a
4
+ // workit call (no verb moves or re-initialises the repository it runs in), yet
5
+ // readers across the CLI asked git for them again and again: in one ledger
6
+ // test file, 141 of 1,285 spawned processes (#284 N6). On Windows each spawn
7
+ // costs 10-30x more than on Linux.
8
+ //
9
+ // Only successful answers are cached, so a directory that becomes a repository
10
+ // later is asked again. A cached entry whose checkout no longer leads to its
11
+ // git dir (removed, or re-added as a new worktree) is dropped. HEAD,
12
+ // branches and other refs are never cached here: they move during a call, and
13
+ // a stale HEAD would mis-key a SHA-keyed verdict.
14
+ import { spawnSync } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ /** Absolute paths as git prints them with --path-format=absolute; `top` is null in a bare repository or a git dir. */
19
+ export type GitDirs = { top: string | null; gitDir: string; commonDir: string };
20
+
21
+ const cache = new Map<string, GitDirs>();
22
+
23
+ const revParse = (cwd: string, args: string[]): string[] | null => {
24
+ const run = spawnSync("git", ["rev-parse", "--path-format=absolute", ...args], {
25
+ cwd,
26
+ encoding: "utf8",
27
+ timeout: 20_000,
28
+ killSignal: "SIGKILL",
29
+ windowsHide: true,
30
+ });
31
+ if (run.status !== 0) return null;
32
+ const lines = (run.stdout ?? "").split(/\r?\n/u).filter(Boolean);
33
+ return lines.length === args.length ? lines : null;
34
+ };
35
+
36
+ const realOrNull = (file: string): string | null => {
37
+ try {
38
+ return fs.realpathSync.native(file);
39
+ } catch {
40
+ return null;
41
+ }
42
+ };
43
+
44
+ /**
45
+ * Whether a cached entry still describes the checkout: its `.git` (the git
46
+ * dir itself, or a `gitdir:` file) must still lead to the cached git dir. A
47
+ * worktree removed and re-added at the same path gets a new admin dir, and
48
+ * git may hand the old name to another worktree, so the git dir existing is
49
+ * not enough.
50
+ */
51
+ const stillValid = (facts: GitDirs): boolean => {
52
+ const gitDir = realOrNull(facts.gitDir);
53
+ if (!gitDir) return false;
54
+ if (!facts.top) return true;
55
+ const dotGit = path.join(facts.top, ".git");
56
+ let target: string | null = null;
57
+ try {
58
+ if (fs.statSync(dotGit).isDirectory()) target = dotGit;
59
+ else {
60
+ const match = /^gitdir:\s*(.+?)\s*$/mu.exec(fs.readFileSync(dotGit, "utf8"));
61
+ target = match ? path.resolve(facts.top, match[1]) : null;
62
+ }
63
+ } catch {
64
+ return false;
65
+ }
66
+ return target !== null && realOrNull(target) === gitDir;
67
+ };
68
+
69
+ /** The checkout's top level, git dir and common dir, from one git call per directory and process; null outside a repository. */
70
+ export function gitDirs(cwd: string): GitDirs | null {
71
+ const key = path.resolve(cwd);
72
+ const hit = cache.get(key);
73
+ if (hit && stillValid(hit)) return hit;
74
+ cache.delete(key);
75
+ const full = revParse(key, ["--git-dir", "--git-common-dir", "--show-toplevel"]);
76
+ // A bare repository (or a cwd inside the git dir) has no top level, and git
77
+ // refuses the whole call: ask again without it.
78
+ const dirs = full ?? revParse(key, ["--git-dir", "--git-common-dir"]);
79
+ if (!dirs) return null;
80
+ const facts = { gitDir: dirs[0], commonDir: dirs[1], top: dirs[2] ?? null };
81
+ cache.set(key, facts);
82
+ return facts;
83
+ }
84
+
85
+ /** For tests that rebuild a repository at a path this process already asked about. */
86
+ export function forgetGitDirs(): void {
87
+ cache.clear();
88
+ }
89
+
90
+ // One read per call, never across calls: the origin URL can be changed by the
91
+ // user between two workit calls, but one verdict gate asked git for it twice
92
+ // (the provider in vcs-config, the verdict mirror's identity in the ledger).
93
+ // Inside `withinCall`, the first answer per directory is reused until `fn`
94
+ // returns; outside it every read asks git.
95
+ let callReads: Map<string, string | null> | null = null;
96
+
97
+ /** Run `fn` with per-call reads (originUrl) shared, and forgotten when it returns. Nested calls join the outer one. */
98
+ export function withinCall<T>(fn: () => T): T {
99
+ if (callReads) return fn();
100
+ callReads = new Map();
101
+ try {
102
+ return fn();
103
+ } finally {
104
+ callReads = null;
105
+ }
106
+ }
107
+
108
+ /** `git remote get-url origin`, trimmed; null without an origin. */
109
+ export function originUrl(cwd: string): string | null {
110
+ const key = path.resolve(cwd);
111
+ if (callReads?.has(key)) return callReads.get(key) ?? null;
112
+ const run = spawnSync("git", ["remote", "get-url", "origin"], {
113
+ cwd,
114
+ encoding: "utf8",
115
+ timeout: 20_000,
116
+ killSignal: "SIGKILL",
117
+ windowsHide: true,
118
+ });
119
+ const url = run.status === 0 ? (run.stdout ?? "").trim() || null : null;
120
+ callReads?.set(key, url);
121
+ return url;
122
+ }