@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,349 @@
1
+ // The Keep a Changelog gate `workit pr create` runs when a workspace opts in
2
+ // (`changelog: update|create` in workspaces.json, default off). A user-facing
3
+ // PR (any Conventional Commit type but chore, test, docs, ci, build, style,
4
+ // or a breaking `!`) needs at least one bullet under `## [Unreleased]` that
5
+ // the branch adds against its PR base. The gate only reads: entries are
6
+ // written by `workit changelog apply`, never here, so existing entries are
7
+ // never rewritten or reordered.
8
+ //
9
+ // - The changelog is CHANGELOG.md at the root unless the workspace sets
10
+ // `changelogPath` (one path, or a list: an entry in any of them counts).
11
+ // A glob entry (`projects/*/CHANGELOG.md`) makes each matched file the
12
+ // changelog of its directory: every such directory the diff touches needs
13
+ // its own entry; a diff touching none of them needs one in any.
14
+ // - `update` asks for an entry only where a changelog exists at HEAD.
15
+ // - `create` asks for it always (`workit changelog apply` creates the file).
16
+ // - `--no-changelog "<reason>"` skips it; the reason goes on the PR.
17
+ // - The base is fetched from the base remote first (as `--fill` does); one
18
+ // that still cannot be compared blocks with its own reason and a fetch fix,
19
+ // since an unchecked PR is exactly the forgotten entry the gate exists for.
20
+ import { spawnSync } from "node:child_process";
21
+ import path from "node:path";
22
+ import { GIT_TIMEOUTS, fetchRefs, mergeBase, pushRemoteName, resolveRef } from "../git/rev";
23
+ import { DEFAULT_CHANGELOG_PATH, type ChangelogMode } from "./workspaces";
24
+
25
+ /** Conventional Commit types whose change no user sees: no entry needed. */
26
+ const NON_USER_FACING_TYPES = ["chore", "test", "docs", "ci", "build", "style"] as const;
27
+
28
+ export type ChangelogGate =
29
+ | { status: "off" }
30
+ | { status: "not-user-facing"; mode: ChangelogMode; type: string }
31
+ | { status: "skipped"; mode: ChangelogMode; reason: string }
32
+ | { status: "no-file"; mode: ChangelogMode; paths: string[]; candidates: string[] }
33
+ | { status: "base-unknown"; mode: ChangelogMode; base: string; remote: string; fetch: string }
34
+ | {
35
+ status: "has-entry";
36
+ mode: ChangelogMode;
37
+ file: string;
38
+ added: string[];
39
+ /** Every changelog that gained an entry, when more than one did. */
40
+ files?: string[];
41
+ /** The fetch failed but a local copy of the base resolved: compared with that copy. */
42
+ staleBase?: string;
43
+ }
44
+ | {
45
+ status: "missing";
46
+ mode: ChangelogMode;
47
+ file: string;
48
+ category: string;
49
+ command: string;
50
+ candidates: string[];
51
+ /** The changelog files the check read (the configured paths when none exists). */
52
+ looked: string[];
53
+ /** Package changelogs the diff touches that gained no entry (glob paths only). */
54
+ lacking?: string[];
55
+ staleBase?: string;
56
+ };
57
+
58
+ const TITLE_RE = /^(\w+)(?:\([^)]*\))?(!)?:/u;
59
+ const UNRELEASED_RE = /^##\s+\[Unreleased\][^\n]*\n([\s\S]*?)(?=^##\s+\[|(?![\s\S]))/imu;
60
+ const BULLET_RE = /^[-*]\s+(.+?)\s*$/u;
61
+
62
+ /**
63
+ * The title's Conventional Commit type when it is not user-facing, else null.
64
+ * A breaking change (`chore!:`) and a title that is not a Conventional Commit
65
+ * count as user-facing.
66
+ */
67
+ export function nonUserFacingType(title: string): string | null {
68
+ const parsed = TITLE_RE.exec(title.split("\n", 1)[0] ?? "");
69
+ if (!parsed || parsed[2]) return null;
70
+ const type = parsed[1].toLowerCase();
71
+ return (NON_USER_FACING_TYPES as readonly string[]).includes(type) ? type : null;
72
+ }
73
+
74
+ /** The bullets under `## [Unreleased]`, trimmed, in file order. */
75
+ export function unreleasedBullets(text: string): string[] {
76
+ const body = UNRELEASED_RE.exec(text.replaceAll("\r\n", "\n"))?.[1] ?? "";
77
+ return body
78
+ .split("\n")
79
+ .map((line) => BULLET_RE.exec(line)?.[1])
80
+ .filter((bullet): bullet is string => Boolean(bullet));
81
+ }
82
+
83
+ /** The Keep a Changelog category a title's change most likely belongs to. */
84
+ const categoryFor = (title: string): string => {
85
+ const type = TITLE_RE.exec(title)?.[1]?.toLowerCase();
86
+ return type === "feat" ? "Added" : type === "fix" ? "Fixed" : "Changed";
87
+ };
88
+
89
+ /** The command that writes the missing entry into `file`. */
90
+ const applyCommand = (category: string, file: string): string =>
91
+ `workit changelog apply --entries '{"${category}": ["<what users can now do>"]}'${
92
+ file === DEFAULT_CHANGELOG_PATH ? "" : ` --path ${file}`
93
+ }`;
94
+
95
+ /** `file` (repo-relative) at `rev`, or null when it is not in that commit. */
96
+ const fileAt = (cwd: string, rev: string, file: string): string | null => {
97
+ const run = spawnSync("git", ["show", `${rev}:${file}`], {
98
+ cwd,
99
+ encoding: "utf8",
100
+ timeout: GIT_TIMEOUTS.local,
101
+ maxBuffer: 64 * 1024 * 1024,
102
+ });
103
+ return run.status === 0 ? run.stdout : null;
104
+ };
105
+
106
+ const CHANGELOG_NAME = /(?:^|\/)(?:changelog|changes|history)(?:\.md)?$/iu;
107
+
108
+ /** Tracked changelog-like files other than the root one, for a hint to set `changelogPath`. */
109
+ const candidatesAt = (cwd: string): string[] => {
110
+ const run = spawnSync("git", ["ls-files", "-z"], {
111
+ cwd,
112
+ encoding: "utf8",
113
+ timeout: GIT_TIMEOUTS.local,
114
+ maxBuffer: 64 * 1024 * 1024,
115
+ });
116
+ if (run.status !== 0) return [];
117
+ return run.stdout
118
+ .split("\0")
119
+ .filter((file) => CHANGELOG_NAME.test(file) && file !== DEFAULT_CHANGELOG_PATH)
120
+ .filter((file) => !file.split("/").includes("node_modules"))
121
+ .slice(0, 5);
122
+ };
123
+
124
+ /**
125
+ * The PR base, fetched from the base remote first so a fork or a stale ref
126
+ * never moves the fork point: that remote's ref, else the local branch. The
127
+ * fetch error (offline, no such branch) is kept for the block message.
128
+ */
129
+ const baseCommit = (
130
+ cwd: string,
131
+ remote: string,
132
+ base: string,
133
+ ): { rev: string | null; fetchError: string | null } => {
134
+ const tracking = `refs/remotes/${remote}/${base}`;
135
+ if (base.startsWith("-")) return { rev: null, fetchError: "a branch name cannot start with -" };
136
+ const fetched = fetchRefs(cwd, remote, [`+refs/heads/${base}:${tracking}`]);
137
+ return {
138
+ rev: resolveRef(cwd, tracking) ?? resolveRef(cwd, `refs/heads/${base}`),
139
+ fetchError: fetched.ok ? null : fetched.error,
140
+ };
141
+ };
142
+
143
+ /** Where HEAD forked from the PR base, resolved once for every `pr create` check. */
144
+ export type ForkPoint = {
145
+ remote: string;
146
+ base: string;
147
+ /** The merge base of the PR base and HEAD; null when the base is unknown here. */
148
+ fork: string | null;
149
+ /** The base resolved but its fetch failed: the comparison used the older local copy. */
150
+ staleBase: string | null;
151
+ fetchError: string | null;
152
+ };
153
+
154
+ export function resolveForkPoint(cwd: string, base: string, baseRemote?: string): ForkPoint {
155
+ const remote = baseRemote ?? pushRemoteName(cwd) ?? "origin";
156
+ const resolved = baseCommit(cwd, remote, base);
157
+ return {
158
+ remote,
159
+ base,
160
+ fork: resolved.rev ? mergeBase(cwd, resolved.rev, "HEAD") : null,
161
+ staleBase: resolved.rev ? resolved.fetchError : null,
162
+ fetchError: resolved.fetchError,
163
+ };
164
+ }
165
+
166
+ const gitList = (cwd: string, args: string[]): string[] => {
167
+ const run = spawnSync("git", args, {
168
+ cwd,
169
+ encoding: "utf8",
170
+ timeout: GIT_TIMEOUTS.local,
171
+ maxBuffer: 64 * 1024 * 1024,
172
+ });
173
+ return run.status === 0 ? run.stdout.split("\0").filter(Boolean) : [];
174
+ };
175
+
176
+ /** The files the branch changed since `fork`, repo-relative. */
177
+ export const changedSince = (cwd: string, fork: string): string[] =>
178
+ gitList(cwd, ["diff", "--name-only", "-z", "--no-renames", fork, "HEAD"]);
179
+
180
+ /** Whether a `changelogPath` entry is a glob (`*`, `**`, `?`) rather than one file. */
181
+ export const isChangelogGlob = (pattern: string): boolean => /[*?]/u.test(pattern);
182
+
183
+ const escapeRe = (text: string): string => text.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
184
+
185
+ /** `*` and `?` stay inside one path segment; `**` spans any number of them. */
186
+ const globRe = (pattern: string): RegExp => {
187
+ const source = pattern
188
+ .split(/(\*\*\/|\*\*|\*|\?)/u)
189
+ .map((part) =>
190
+ part === "**/"
191
+ ? "(?:[^/]+/)*"
192
+ : part === "**"
193
+ ? ".*"
194
+ : part === "*"
195
+ ? "[^/]*"
196
+ : part === "?"
197
+ ? "[^/]"
198
+ : escapeRe(part),
199
+ )
200
+ .join("");
201
+ return new RegExp(`^${source}$`, "u");
202
+ };
203
+
204
+ /** One changelog a `changelogPath` entry names; `pkg` is its directory when a glob matched it. */
205
+ type ChangelogFile = { file: string; pkg: string | null };
206
+
207
+ const expand = (paths: readonly string[], tracked: () => string[]): ChangelogFile[] => {
208
+ const seen = new Set<string>();
209
+ const out: ChangelogFile[] = [];
210
+ for (const pattern of paths) {
211
+ const matches = isChangelogGlob(pattern)
212
+ ? tracked()
213
+ .filter((file) => globRe(pattern).test(file))
214
+ .toSorted()
215
+ .map((file) => ({ file, pkg: file.includes("/") ? path.posix.dirname(file) : null }))
216
+ : [{ file: pattern, pkg: null }];
217
+ for (const match of matches)
218
+ if (!seen.has(match.file)) {
219
+ seen.add(match.file);
220
+ out.push(match);
221
+ }
222
+ }
223
+ // Globs that match nothing here (a single-package repo under a monorepo setting) mean the root file.
224
+ if (!out.length && paths.every(isChangelogGlob))
225
+ out.push({ file: DEFAULT_CHANGELOG_PATH, pkg: null });
226
+ return out;
227
+ };
228
+
229
+ const trackedAt = (cwd: string, rev: string) => {
230
+ let files: string[] | null = null;
231
+ return () => (files ??= gitList(cwd, ["ls-tree", "-r", "-z", "--name-only", rev]));
232
+ };
233
+
234
+ /**
235
+ * The changelog files `paths` resolves to at `rev` (default HEAD): each plain
236
+ * path as given, each glob as the tracked files it matches.
237
+ */
238
+ export const resolveChangelogPaths = (cwd: string, paths: readonly string[], rev = "HEAD") =>
239
+ expand(paths, trackedAt(cwd, rev)).map(({ file }) => file);
240
+
241
+ /** The package changelog that owns `file`: the deepest glob-matched directory holding it. */
242
+ const ownerOf = (packages: readonly ChangelogFile[], file: string): string | null => {
243
+ let owner: ChangelogFile | null = null;
244
+ for (const candidate of packages)
245
+ if (
246
+ candidate.pkg &&
247
+ file.startsWith(`${candidate.pkg}/`) &&
248
+ candidate.pkg.length > (owner?.pkg?.length ?? -1)
249
+ )
250
+ owner = candidate;
251
+ return owner?.file ?? null;
252
+ };
253
+
254
+ /** Whether a PR titled `title` from HEAD into `base` may open under `mode`. */
255
+ export function changelogGate(input: {
256
+ cwd: string;
257
+ mode: ChangelogMode;
258
+ title: string;
259
+ base: string;
260
+ /** The remote the PR targets (the forge's base remote); default the push remote, else origin. */
261
+ baseRemote?: string;
262
+ /**
263
+ * Repo-relative changelog paths or globs (default: CHANGELOG.md). A file a
264
+ * glob matched is its directory's changelog: each such directory the diff
265
+ * touches needs its own entry.
266
+ */
267
+ paths?: string[];
268
+ /** Whether `paths` came from the workspace; if not, a missing root file names candidates. */
269
+ pathConfigured?: boolean;
270
+ skipReason?: string | null;
271
+ /** The fork point, when the caller resolved it already (`pr create` shares it with the docs check). */
272
+ forkPoint?: () => ForkPoint;
273
+ }): ChangelogGate {
274
+ const { cwd, mode, title, base } = input;
275
+ const paths = input.paths?.length ? input.paths : [DEFAULT_CHANGELOG_PATH];
276
+ if (mode === "off") return { status: "off" };
277
+ const type = nonUserFacingType(title);
278
+ if (type) return { status: "not-user-facing", mode, type };
279
+ if (input.skipReason) return { status: "skipped", mode, reason: input.skipReason };
280
+ const atHead = expand(paths, trackedAt(cwd, "HEAD"));
281
+ const present = atHead.flatMap((entry) => {
282
+ const text = fileAt(cwd, "HEAD", entry.file);
283
+ return text === null ? [] : [{ ...entry, text }];
284
+ });
285
+ const point = input.forkPoint?.() ?? resolveForkPoint(cwd, base, input.baseRemote);
286
+ const { fork, remote } = point;
287
+ const unknown: ChangelogGate = {
288
+ status: "base-unknown",
289
+ mode,
290
+ base,
291
+ remote,
292
+ fetch: point.fetchError ?? "the base has no common history with this branch here",
293
+ };
294
+ // The fetch failed but an older local copy resolved: the comparison stands, and says so.
295
+ const stale = point.staleBase ? { staleBase: point.staleBase } : {};
296
+ const category = categoryFor(title);
297
+ // A file the base has and the branch deleted is an entry removed, not "nothing to update".
298
+ const deleted = fork
299
+ ? expand(paths, trackedAt(cwd, fork)).find(({ file }) => fileAt(cwd, fork, file) !== null)?.file
300
+ : undefined;
301
+ const looked = present.length ? present.map(({ file }) => file) : paths;
302
+ const candidates = present.length || deleted || input.pathConfigured ? [] : candidatesAt(cwd);
303
+ const missingOf = (file: string, lacking?: string[]): ChangelogGate => ({
304
+ status: "missing",
305
+ mode,
306
+ file,
307
+ category,
308
+ command: applyCommand(category, file),
309
+ candidates,
310
+ looked,
311
+ ...(lacking ? { lacking } : {}),
312
+ ...stale,
313
+ });
314
+ if (!present.length) {
315
+ const file = deleted ?? atHead.find(({ pkg }) => pkg === null)?.file;
316
+ if (file && (mode === "create" || deleted)) return missingOf(file);
317
+ // Without a base the deletion check cannot run; `update` asks nothing of a repo with no file.
318
+ return { status: "no-file", mode, paths, candidates };
319
+ }
320
+ if (!fork) return unknown;
321
+ const entries = present.map(({ file, text }) => {
322
+ const before = new Set(
323
+ unreleasedBullets(fileAt(cwd, fork, file) ?? "").map((bullet) => bullet.toLowerCase()),
324
+ );
325
+ return { file, added: unreleasedBullets(text).filter((b) => !before.has(b.toLowerCase())) };
326
+ });
327
+ const expected = [
328
+ ...new Set(
329
+ changedSince(cwd, fork)
330
+ .map((file) => ownerOf(present, file))
331
+ .filter((file): file is string => file !== null),
332
+ ),
333
+ ];
334
+ const withEntry = entries.filter(({ added }) => added.length);
335
+ if (expected.length) {
336
+ const lacking = expected.filter((file) => !withEntry.some((entry) => entry.file === file));
337
+ if (lacking.length) return missingOf(lacking[0], lacking);
338
+ }
339
+ if (!withEntry.length) return missingOf(present[0].file);
340
+ const [first] = withEntry;
341
+ return {
342
+ status: "has-entry",
343
+ mode,
344
+ file: first.file,
345
+ added: first.added,
346
+ ...(withEntry.length > 1 ? { files: withEntry.map(({ file }) => file) } : {}),
347
+ ...stale,
348
+ };
349
+ }
@@ -0,0 +1,214 @@
1
+ // Three-way merge of a Keep a Changelog file for `workit changelog merge`, a git
2
+ // merge driver: two branches that each add bullets under `## [Unreleased]` merge
3
+ // without a conflict. Only the Unreleased body is merged here; the caller hands
4
+ // the rest of the file to `git merge-file`, so a conflict elsewhere keeps git's
5
+ // normal markers.
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { gitError, git } from "../git/ops";
10
+ import { workspaceChangelog } from "./workspaces";
11
+ import { bulletBlocks, hasUnreleasedHeading, splitSections, splitUnreleased } from "./changelog";
12
+
13
+ type Section = { heading: string; id: string; preamble: string[]; blocks: Map<string, string[]> };
14
+
15
+ const headingId = (heading: string): string =>
16
+ heading
17
+ .replace(/\r?\n$/u, "")
18
+ .replace(/^###\s+/u, "")
19
+ .trim()
20
+ .toLowerCase();
21
+
22
+ const withNewline = (line: string): string => (line.endsWith("\n") ? line : `${line}\n`);
23
+
24
+ const parseBody = (body: string): { preamble: string[]; sections: Section[] } => {
25
+ const [preamble, raw] = splitSections(body);
26
+ const sections: Section[] = [];
27
+ for (const one of raw) {
28
+ const id = headingId(one.heading);
29
+ let section = sections.find((s) => s.id === id);
30
+ if (!section) {
31
+ section = { heading: one.heading, id, preamble: [], blocks: new Map() };
32
+ sections.push(section);
33
+ }
34
+ for (const [key, lines] of bulletBlocks(one.body)) {
35
+ const kept = lines.map(withNewline);
36
+ if (key === null) {
37
+ if (kept.some((line) => line.trim())) section.preamble.push(...kept);
38
+ continue;
39
+ }
40
+ while (kept.length > 1 && !kept[kept.length - 1].trim()) kept.pop();
41
+ if (!section.blocks.has(key)) section.blocks.set(key, kept);
42
+ }
43
+ }
44
+ return { preamble: preamble.map(withNewline), sections };
45
+ };
46
+
47
+ /** Ours, then theirs; a bullet one side dropped from base stays dropped. */
48
+ const mergeBlocks = (
49
+ base: Map<string, string[]> | undefined,
50
+ ours: Map<string, string[]> | undefined,
51
+ theirs: Map<string, string[]> | undefined,
52
+ ): string[][] => {
53
+ const out = new Map<string, string[]>();
54
+ for (const [key, lines] of ours ?? []) {
55
+ if (base?.has(key) && theirs && !theirs.has(key)) continue;
56
+ out.set(key, lines);
57
+ }
58
+ for (const [key, lines] of theirs ?? []) {
59
+ if (out.has(key) || base?.has(key)) continue;
60
+ out.set(key, lines);
61
+ }
62
+ return [...out.values()];
63
+ };
64
+
65
+ /** The merged `## [Unreleased]` body (everything after that heading line). */
66
+ export const mergeUnreleasedBody = (base: string, ours: string, theirs: string): string => {
67
+ if (ours === theirs || theirs === base) return ours;
68
+ if (ours === base) return theirs;
69
+ const b = parseBody(base);
70
+ const o = parseBody(ours);
71
+ const t = parseBody(theirs);
72
+ const ids = [...new Set([...o.sections, ...t.sections].map((s) => s.id))];
73
+ const sectionOf = (side: { sections: Section[] }, id: string) =>
74
+ side.sections.find((s) => s.id === id);
75
+ let out = o.preamble.join("") || t.preamble.join("");
76
+ for (const id of ids) {
77
+ const mine = sectionOf(o, id);
78
+ const theirsOne = sectionOf(t, id);
79
+ const baseOne = sectionOf(b, id);
80
+ const blocks = mergeBlocks(baseOne?.blocks, mine?.blocks, theirsOne?.blocks);
81
+ // A section both sides emptied (or one emptied that the other left alone) is dropped.
82
+ if (!blocks.length && baseOne && (!mine || !theirsOne)) continue;
83
+ const prose = (mine ?? theirsOne)?.preamble.join("") ?? "";
84
+ const heading = (mine ?? theirsOne)!.heading;
85
+ out += `${withNewline(heading)}\n${prose}${blocks.flat().join("")}\n`;
86
+ }
87
+ return out;
88
+ };
89
+
90
+ /**
91
+ * Replace each file's Unreleased body with the merged body, so only text
92
+ * outside Unreleased can still differ. null when a file has no Unreleased
93
+ * heading (the caller falls back to a plain three-way merge).
94
+ */
95
+ export const withMergedUnreleased = (
96
+ base: string,
97
+ ours: string,
98
+ theirs: string,
99
+ ): { base: string; ours: string; theirs: string } | null => {
100
+ if (![base, ours, theirs].every(hasUnreleasedHeading)) return null;
101
+ const [bBefore, bBody, bAfter] = splitUnreleased(base);
102
+ const [oBefore, oBody, oAfter] = splitUnreleased(ours);
103
+ const [tBefore, tBody, tAfter] = splitUnreleased(theirs);
104
+ const merged = mergeUnreleasedBody(bBody, oBody, tBody);
105
+ return {
106
+ base: bBefore + merged + bAfter,
107
+ ours: oBefore + merged + oAfter,
108
+ theirs: tBefore + merged + tAfter,
109
+ };
110
+ };
111
+
112
+ export const CHANGELOG_DRIVER = "workit-changelog";
113
+ /**
114
+ * Git runs the driver through sh. When `workit` cannot run (not on PATH: 127,
115
+ * an older CLI without the verb: 2), git would leave a conflicted file holding
116
+ * only ours with no markers, and a quick `git add` would drop theirs; falling
117
+ * back to `git merge-file` leaves normal markers instead. Exit 0 or 1 is the
118
+ * driver's own answer and is passed through untouched.
119
+ */
120
+ export const CHANGELOG_DRIVER_COMMAND =
121
+ 'workit changelog merge %O %A %B; s=$?; [ "$s" -le 1 ] && exit "$s"; git merge-file %A %O %B';
122
+
123
+ /** The `.gitattributes` file `driver install` edits: the one at the work tree root. */
124
+ export const gitattributesPath = (root: string): string => path.join(root, ".gitattributes");
125
+
126
+ /**
127
+ * Merge the three files git hands a merge driver, writing the result over
128
+ * `ours`. Returns the exit code git expects: 0 clean, 1 conflict markers left.
129
+ */
130
+ export const mergeChangelogFiles = (files: {
131
+ base: string;
132
+ ours: string;
133
+ theirs: string;
134
+ }): { code: 0 | 1; error?: string } => {
135
+ const read = (file: string) => fs.readFileSync(file, "utf8");
136
+ const [base, ours, theirs] = [read(files.base), read(files.ours), read(files.theirs)];
137
+ const merged = withMergedUnreleased(base, ours, theirs) ?? { base, ours, theirs };
138
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workit-changelog-merge-"));
139
+ try {
140
+ const one = (name: string, text: string) => {
141
+ const file = path.join(dir, name);
142
+ fs.writeFileSync(file, text, "utf8");
143
+ return file;
144
+ };
145
+ const o = one("ours", merged.ours);
146
+ const run = git(
147
+ dir,
148
+ [
149
+ "merge-file",
150
+ "-L",
151
+ "ours",
152
+ "-L",
153
+ "base",
154
+ "-L",
155
+ "theirs",
156
+ o,
157
+ one("base", merged.base),
158
+ one("theirs", merged.theirs),
159
+ ],
160
+ { timeoutMs: 30_000 },
161
+ );
162
+ if (run.status === null || run.status > 127) return { code: 1, error: gitError(run) };
163
+ fs.writeFileSync(files.ours, fs.readFileSync(o));
164
+ return { code: run.status === 0 ? 0 : 1 };
165
+ } finally {
166
+ fs.rmSync(dir, { recursive: true, force: true });
167
+ }
168
+ };
169
+
170
+ /**
171
+ * Register the driver in the repo's local git config and give the configured
172
+ * changelog paths a `merge=` attribute. Running it again changes nothing.
173
+ */
174
+ export const installChangelogDriver = (
175
+ cwd: string,
176
+ ): { root: string; paths: string[]; attributes: string; changed: string[] } | { error: string } => {
177
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
178
+ if (!top.ok) return { error: "not inside a git work tree" };
179
+ const root = fs.realpathSync(top.stdout.trim());
180
+ const changed: string[] = [];
181
+ const settings: Array<[string, string]> = [
182
+ [`merge.${CHANGELOG_DRIVER}.name`, "Workit changelog: keep both Unreleased entries"],
183
+ [`merge.${CHANGELOG_DRIVER}.driver`, CHANGELOG_DRIVER_COMMAND],
184
+ ];
185
+ for (const [key, value] of settings) {
186
+ const current = git(root, ["config", "--local", "--get", key]);
187
+ if (current.ok && current.stdout.trim() === value) continue;
188
+ const set = git(root, ["config", "--local", key, value]);
189
+ if (!set.ok) return { error: `git config ${key}: ${gitError(set)}` };
190
+ changed.push(`git config ${key}`);
191
+ }
192
+
193
+ const { paths } = workspaceChangelog(root);
194
+ const attributes = gitattributesPath(root);
195
+ const existing = fs.existsSync(attributes) ? fs.readFileSync(attributes, "utf8") : "";
196
+ const have = new Set(
197
+ existing
198
+ .split(/\r?\n/u)
199
+ .map((line) => line.trim().split(/\s+/u))
200
+ .filter((fields) => fields.slice(1).includes(`merge=${CHANGELOG_DRIVER}`))
201
+ .map((fields) => fields[0]),
202
+ );
203
+ const missing = paths.filter((one) => !have.has(one));
204
+ if (missing.length) {
205
+ const lead = existing && !existing.endsWith("\n") ? "\n" : "";
206
+ fs.writeFileSync(
207
+ attributes,
208
+ `${existing}${lead}${missing.map((one) => `${one} merge=${CHANGELOG_DRIVER}\n`).join("")}`,
209
+ "utf8",
210
+ );
211
+ changed.push(".gitattributes");
212
+ }
213
+ return { root, paths, attributes, changed };
214
+ };