@brainervirus/workit-core 2.9.0 → 4.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.
@@ -0,0 +1,135 @@
1
+ // Structural JSON diff for the task event log (design §4.1). One mutation
2
+ // becomes the smallest set of operations that turns the previous record into
3
+ // the next one, so the log grows with the change, never with the record:
4
+ //
5
+ // set replace the value at `path`
6
+ // del remove the key at `path`
7
+ // push append `values` to the array at `path`
8
+ // cut truncate the array at `path` to `length`
9
+ //
10
+ // Arrays are compared element by element, so appending an entry (evidence,
11
+ // findings, decisions) is one `push`, and editing one entry in place is a
12
+ // diff inside that entry. Paths are arrays of keys and indices.
13
+
14
+ export type PatchPath = (string | number)[];
15
+ export type PatchOp =
16
+ | { op: "set"; path: PatchPath; value: unknown }
17
+ | { op: "del"; path: PatchPath }
18
+ | { op: "push"; path: PatchPath; values: unknown[] }
19
+ | { op: "cut"; path: PatchPath; length: number };
20
+
21
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
22
+ typeof value === "object" && value !== null && !Array.isArray(value);
23
+
24
+ const equal = (left: unknown, right: unknown): boolean => {
25
+ if (left === right) return true;
26
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
27
+ return false;
28
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
29
+ if (Array.isArray(left)) {
30
+ const other = right as unknown[];
31
+ return left.length === other.length && left.every((item, index) => equal(item, other[index]));
32
+ }
33
+ const leftKeys = Object.keys(left).filter(
34
+ (key) => (left as Record<string, unknown>)[key] !== undefined,
35
+ );
36
+ const rightKeys = Object.keys(right).filter(
37
+ (key) => (right as Record<string, unknown>)[key] !== undefined,
38
+ );
39
+ return (
40
+ leftKeys.length === rightKeys.length &&
41
+ leftKeys.every((key) =>
42
+ equal((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]),
43
+ )
44
+ );
45
+ };
46
+
47
+ /** Operations that turn `before` into `after` (JSON values only). */
48
+ export function diff(before: unknown, after: unknown, path: PatchPath = []): PatchOp[] {
49
+ if (equal(before, after)) return [];
50
+ if (isPlainObject(before) && isPlainObject(after)) {
51
+ const ops: PatchOp[] = [];
52
+ for (const key of Object.keys(before))
53
+ if (before[key] !== undefined && after[key] === undefined)
54
+ ops.push({ op: "del", path: [...path, key] });
55
+ for (const key of Object.keys(after)) {
56
+ if (after[key] === undefined) continue;
57
+ if (before[key] === undefined)
58
+ ops.push({ op: "set", path: [...path, key], value: after[key] });
59
+ else ops.push(...diff(before[key], after[key], [...path, key]));
60
+ }
61
+ return ops;
62
+ }
63
+ if (Array.isArray(before) && Array.isArray(after)) {
64
+ const ops: PatchOp[] = [];
65
+ if (after.length < before.length) ops.push({ op: "cut", path, length: after.length });
66
+ const shared = Math.min(before.length, after.length);
67
+ // A rewrite of most of the array is cheaper as one value.
68
+ let changed = 0;
69
+ for (let index = 0; index < shared; index += 1)
70
+ if (!equal(before[index], after[index])) changed += 1;
71
+ if (shared > 0 && changed > shared / 2 && changed > 4)
72
+ return [{ op: "set", path, value: after }];
73
+ for (let index = 0; index < shared; index += 1)
74
+ ops.push(...diff(before[index], after[index], [...path, index]));
75
+ if (after.length > before.length)
76
+ ops.push({ op: "push", path, values: after.slice(before.length) });
77
+ return ops;
78
+ }
79
+ return [{ op: "set", path, value: after }];
80
+ }
81
+
82
+ const clone = <T>(value: T): T => structuredClone(value);
83
+
84
+ /** Apply `ops` to `target` in place; throws on a path that does not exist. */
85
+ export function apply(target: unknown, ops: readonly PatchOp[]): unknown {
86
+ let root = target;
87
+ for (const op of ops) {
88
+ if (op.path.length === 0) {
89
+ if (op.op === "set") root = clone(op.value);
90
+ else if (op.op === "push" && Array.isArray(root)) root.push(...clone(op.values));
91
+ else if (op.op === "cut" && Array.isArray(root)) root.length = op.length;
92
+ else throw new Error(`patch: ${op.op} at the root is invalid`);
93
+ continue;
94
+ }
95
+ let parent: unknown = root;
96
+ for (const key of op.path.slice(0, -1)) {
97
+ if (typeof parent !== "object" || parent === null)
98
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
99
+ parent = (parent as Record<string | number, unknown>)[key];
100
+ }
101
+ if (typeof parent !== "object" || parent === null)
102
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
103
+ const key = op.path.at(-1)!;
104
+ const container = parent as Record<string | number, unknown>;
105
+ if (op.op === "set") container[key] = clone(op.value);
106
+ else if (op.op === "del") delete container[key];
107
+ else {
108
+ const array = container[key];
109
+ if (!Array.isArray(array)) throw new Error(`patch: ${op.path.join(".")} is not an array`);
110
+ if (op.op === "push") array.push(...clone(op.values));
111
+ else array.length = op.length;
112
+ }
113
+ }
114
+ return root;
115
+ }
116
+
117
+ const isPath = (value: unknown): value is PatchPath =>
118
+ Array.isArray(value) &&
119
+ value.every(
120
+ (key) =>
121
+ typeof key === "string" || (typeof key === "number" && Number.isInteger(key) && key >= 0),
122
+ );
123
+
124
+ /** Structural check of untrusted ops (from disk). */
125
+ export const isPatch = (value: unknown): value is PatchOp[] =>
126
+ Array.isArray(value) &&
127
+ value.every((op) => {
128
+ if (!isPlainObject(op) || !isPath(op.path)) return false;
129
+ if (op.op === "set") return "value" in op;
130
+ if (op.op === "del") return op.path.length > 0;
131
+ if (op.op === "push") return Array.isArray(op.values);
132
+ if (op.op === "cut")
133
+ return typeof op.length === "number" && Number.isInteger(op.length) && op.length >= 0;
134
+ return false;
135
+ });
@@ -0,0 +1,368 @@
1
+ // Where the task event store lives and which task a checkout's work belongs
2
+ // to (design §4.1, D3, D13).
3
+ //
4
+ // - The store directory is `<git common dir>/workit` in a git repository, so
5
+ // every worktree of the repo shares it and it survives worktree removal and
6
+ // `git clean`; outside git it is `<root>/.workit`.
7
+ // - The implicit task key is the branch name when HEAD is attached,
8
+ // `detached-<hash of the worktree>` on a detached HEAD, and
9
+ // `dir-<hash of the root>` outside git.
10
+ //
11
+ // Per-turn hooks resolve this on every event, so the common layouts (a `.git`
12
+ // directory, or a worktree's `.git` file with its `commondir`) are read from
13
+ // the filesystem. Anything else, or any GIT_* discovery override in the
14
+ // environment, asks git itself.
15
+ import { spawnSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ export type StoreLocation = {
21
+ /** The store directory. */
22
+ dir: string;
23
+ /** True in a git repository (shared by its worktrees). */
24
+ shared: boolean;
25
+ /** The worktree top level (null outside git, or in a bare repository). */
26
+ top: string | null;
27
+ /** The worktree's own git dir, when read from the filesystem. */
28
+ git?: { gitDir: string; top: string | null; reftable: boolean };
29
+ };
30
+
31
+ export type TaskKey = {
32
+ key: string;
33
+ kind: "branch" | "detached" | "dir";
34
+ /** The branch name when HEAD is attached. */
35
+ branch: string | null;
36
+ /** When a task was bound to this key (stored with the binding). */
37
+ boundAt?: string;
38
+ };
39
+
40
+ const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
41
+
42
+ type GitRun = { ok: boolean; stdout: string; stderr: string; missing: boolean };
43
+ const gitRun = (cwd: string, args: string[]): GitRun => {
44
+ const run = spawnSync("git", args, {
45
+ cwd,
46
+ encoding: "utf8",
47
+ timeout: 20_000,
48
+ killSignal: "SIGKILL",
49
+ windowsHide: true,
50
+ env: {
51
+ ...process.env,
52
+ GIT_TERMINAL_PROMPT: "0",
53
+ GIT_OPTIONAL_LOCKS: "0",
54
+ LC_ALL: "C",
55
+ LANGUAGE: "C",
56
+ },
57
+ });
58
+ const error: NodeJS.ErrnoException | undefined = run.error;
59
+ return {
60
+ ok: run.status === 0,
61
+ stdout: run.stdout ?? "",
62
+ stderr: error ? String(error.message) : (run.stderr ?? ""),
63
+ missing: error?.code === "ENOENT",
64
+ };
65
+ };
66
+
67
+ const shortHash = (value: string): string =>
68
+ createHash("sha256").update(value).digest("hex").slice(0, 12);
69
+
70
+ /** The canonical path (the native resolver also expands Windows 8.3 short names). */
71
+ const real = (file: string): string => {
72
+ try {
73
+ return fs.realpathSync.native(file);
74
+ } catch {
75
+ return file;
76
+ }
77
+ };
78
+
79
+ /** Environment that changes how git discovers a repository. */
80
+ const DISCOVERY_ENV = [
81
+ "GIT_DIR",
82
+ "GIT_COMMON_DIR",
83
+ "GIT_WORK_TREE",
84
+ "GIT_CEILING_DIRECTORIES",
85
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
86
+ ];
87
+
88
+ type FastGit =
89
+ | { kind: "none" }
90
+ | { kind: "repo"; common: string; gitDir: string; top: string | null; reftable: boolean };
91
+
92
+ /** A directory that is itself a git dir (a bare repository, or inside `.git`). */
93
+ const isGitDir = (dir: string): boolean => {
94
+ try {
95
+ return (
96
+ fs.statSync(path.join(dir, "HEAD")).isFile() &&
97
+ fs.statSync(path.join(dir, "objects")).isDirectory() &&
98
+ fs.statSync(path.join(dir, "refs")).isDirectory()
99
+ );
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+
105
+ const reftableRefs = (common: string): boolean => {
106
+ try {
107
+ return /^\s*refstorage\s*=\s*reftable\s*$/imu.test(
108
+ fs.readFileSync(path.join(common, "config"), "utf8"),
109
+ );
110
+ } catch {
111
+ return false;
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Find the repository the way git does for the plain layouts, without
117
+ * spawning git: from the canonical path (symlinks resolved first, as git
118
+ * does), walk up to the first `.git` or git dir (stopping at a filesystem
119
+ * boundary, like git), then follow a `.git` file and `commondir`. Null when
120
+ * the layout is anything else, so the caller asks git.
121
+ */
122
+ function fastGit(root: string): FastGit | null {
123
+ if (DISCOVERY_ENV.some((name) => process.env[name])) return null;
124
+ let dir = real(root);
125
+ let device: number | null = null;
126
+ for (;;) {
127
+ let stat: fs.Stats;
128
+ try {
129
+ stat = fs.statSync(dir);
130
+ } catch {
131
+ return null;
132
+ }
133
+ if (device !== null && stat.dev !== device) return { kind: "none" };
134
+ device = stat.dev;
135
+ const dotGit = path.join(dir, ".git");
136
+ let entry: fs.Stats | null = null;
137
+ try {
138
+ entry = fs.statSync(dotGit);
139
+ } catch {}
140
+ let gitDir: string | null = null;
141
+ let top: string | null = null;
142
+ if (entry) {
143
+ top = dir;
144
+ if (entry.isDirectory()) gitDir = dotGit;
145
+ else if (entry.isFile()) {
146
+ let text = "";
147
+ try {
148
+ text = fs.readFileSync(dotGit, "utf8");
149
+ } catch {
150
+ return null;
151
+ }
152
+ const match = /^gitdir: (.+)$/mu.exec(text);
153
+ if (!match) return null;
154
+ gitDir = path.resolve(dir, match[1].trim());
155
+ } else return null;
156
+ if (!fs.existsSync(path.join(gitDir, "HEAD"))) return null;
157
+ } else if (isGitDir(dir)) gitDir = dir;
158
+ if (gitDir) {
159
+ let common = gitDir;
160
+ try {
161
+ common = path.resolve(
162
+ gitDir,
163
+ fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim(),
164
+ );
165
+ } catch {}
166
+ if (!fs.existsSync(path.join(common, "objects"))) return null;
167
+ return {
168
+ kind: "repo",
169
+ common: real(common),
170
+ gitDir: real(gitDir),
171
+ top: top === null ? null : real(top),
172
+ reftable: reftableRefs(common),
173
+ };
174
+ }
175
+ const parent = path.dirname(dir);
176
+ if (parent === dir) return { kind: "none" };
177
+ dir = parent;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The store directory for a checkout root, or an error message when git
183
+ * cannot answer for a directory that is (or may be) inside a repository.
184
+ * `top` is the worktree's top level (null for a bare repository or a git
185
+ * dir); a checkout is identified by it, so every subdirectory of a worktree
186
+ * is the same checkout.
187
+ */
188
+ export function resolveStore(root: string): StoreLocation | Error {
189
+ root = path.resolve(root);
190
+ // A root that is not (yet) a directory has no repository: keep the 2.x
191
+ // per-directory layout, so reads and writes fail or create as before.
192
+ let directory = false;
193
+ try {
194
+ directory = fs.statSync(root).isDirectory();
195
+ } catch {}
196
+ if (!directory) return { dir: path.join(root, ".workit"), shared: false, top: null };
197
+ const fast = fastGit(root);
198
+ if (fast?.kind === "none") return { dir: path.join(root, ".workit"), shared: false, top: null };
199
+ if (fast?.kind === "repo")
200
+ return {
201
+ dir: path.join(fast.common, "workit"),
202
+ shared: true,
203
+ top: fast.top,
204
+ git: { gitDir: fast.gitDir, top: fast.top, reftable: fast.reftable },
205
+ };
206
+ const run = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
207
+ const common = run.stdout.trim();
208
+ if (run.ok && common) {
209
+ const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
210
+ return {
211
+ dir: path.join(real(path.resolve(root, common)), "workit"),
212
+ shared: true,
213
+ top: top.ok && top.stdout.trim() ? real(top.stdout.trim()) : null,
214
+ };
215
+ }
216
+ if (run.missing || (!run.ok && NOT_A_REPO.test(run.stderr)))
217
+ return { dir: path.join(root, ".workit"), shared: false, top: null };
218
+ return new Error(
219
+ `cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"}); fix the repository (git status should work here)`,
220
+ );
221
+ }
222
+
223
+ /** The branch a rebase in progress will update (HEAD is detached meanwhile). */
224
+ const rebasingBranch = (gitDir: string): string | null => {
225
+ for (const state of ["rebase-merge", "rebase-apply"]) {
226
+ try {
227
+ const ref = fs.readFileSync(path.join(gitDir, state, "head-name"), "utf8").trim();
228
+ const match = /^refs\/heads\/(.+)$/u.exec(ref);
229
+ if (match) return match[1];
230
+ } catch {}
231
+ }
232
+ return null;
233
+ };
234
+
235
+ /** The implicit task key for `root` (see the module comment). */
236
+ export function resolveTaskKey(root: string, location: StoreLocation): TaskKey {
237
+ if (!location.shared) return { key: `dir-${shortHash(root)}`, kind: "dir", branch: null };
238
+ const branchKey = (name: string): TaskKey => ({ key: name, kind: "branch", branch: name });
239
+ // reftable keeps refs out of the filesystem; HEAD is a `refs/heads/.invalid` stub.
240
+ if (location.git && !location.git.reftable) {
241
+ let head = "";
242
+ try {
243
+ head = fs.readFileSync(path.join(location.git.gitDir, "HEAD"), "utf8").trim();
244
+ } catch {}
245
+ const ref = /^ref: refs\/heads\/(.+)$/u.exec(head);
246
+ if (ref && ref[1] !== ".invalid") return branchKey(ref[1]);
247
+ if (/^[0-9a-f]{40,64}$/u.test(head)) {
248
+ const rebasing = rebasingBranch(location.git.gitDir);
249
+ if (rebasing) return branchKey(rebasing);
250
+ return {
251
+ key: `detached-${shortHash(location.git.top ?? location.git.gitDir)}`,
252
+ kind: "detached",
253
+ branch: null,
254
+ };
255
+ }
256
+ }
257
+ const branch = gitRun(root, ["symbolic-ref", "-q", "--short", "HEAD"]);
258
+ const name = branch.ok ? branch.stdout.trim() : "";
259
+ if (name && name !== ".invalid") return branchKey(name);
260
+ const gitDir = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
261
+ if (gitDir.ok && gitDir.stdout.trim()) {
262
+ const rebasing = rebasingBranch(gitDir.stdout.trim());
263
+ if (rebasing) return branchKey(rebasing);
264
+ }
265
+ const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
266
+ const worktree = top.ok && top.stdout.trim() ? top.stdout.trim() : root;
267
+ return { key: `detached-${shortHash(real(worktree))}`, kind: "detached", branch: null };
268
+ }
269
+
270
+ /**
271
+ * The checkout a path belongs to: its worktree's top level in a git
272
+ * repository (so every subdirectory is the same checkout), else the path.
273
+ */
274
+ export function checkoutRootOf(root: string, location?: StoreLocation | Error): string {
275
+ const resolved = path.resolve(root);
276
+ let canonical = resolved;
277
+ try {
278
+ canonical = fs.realpathSync(resolved);
279
+ } catch {
280
+ return resolved;
281
+ }
282
+ const found = location ?? resolveStore(canonical);
283
+ if (found instanceof Error || !found.shared || !found.top) return canonical;
284
+ // Keep the caller's spelling (e.g. a Windows short name) when it already is the top.
285
+ if (real(canonical) === found.top) return canonical;
286
+ try {
287
+ return fs.realpathSync(found.top);
288
+ } catch {
289
+ return canonical;
290
+ }
291
+ }
292
+
293
+ export type BranchRename = { from: string; to: string; /** Unix seconds. */ at: number };
294
+
295
+ /**
296
+ * Branch renames recorded in the reflogs ("Branch: renamed refs/heads/a to
297
+ * refs/heads/b"): every branch log in the common dir, plus this worktree's
298
+ * HEAD log. Oldest first.
299
+ */
300
+ export function branchRenames(location: StoreLocation): BranchRename[] {
301
+ if (!location.shared) return [];
302
+ const files: string[] = [];
303
+ const walk = (dir: string) => {
304
+ let entries: fs.Dirent[];
305
+ try {
306
+ entries = fs.readdirSync(dir, { withFileTypes: true });
307
+ } catch {
308
+ return;
309
+ }
310
+ for (const entry of entries)
311
+ if (entry.isDirectory()) walk(path.join(dir, entry.name));
312
+ else files.push(path.join(dir, entry.name));
313
+ };
314
+ walk(path.join(path.dirname(location.dir), "logs", "refs", "heads"));
315
+ if (location.git) files.push(path.join(location.git.gitDir, "logs", "HEAD"));
316
+ const seen = new Set<string>();
317
+ const renames: BranchRename[] = [];
318
+ for (const file of files) {
319
+ let text = "";
320
+ try {
321
+ text = fs.readFileSync(file, "utf8");
322
+ } catch {
323
+ continue;
324
+ }
325
+ for (const match of text.matchAll(
326
+ / (\d+) [+-]\d{4}\tBranch: renamed refs\/heads\/(\S+) to refs\/heads\/(\S+)/gu,
327
+ )) {
328
+ const rename = { from: match[2], to: match[3], at: Number(match[1]) };
329
+ const id = `${rename.at}\0${rename.from}\0${rename.to}`;
330
+ if (seen.has(id)) continue;
331
+ seen.add(id);
332
+ renames.push(rename);
333
+ }
334
+ }
335
+ return renames.toSorted((left, right) => left.at - right.at);
336
+ }
337
+
338
+ /**
339
+ * The key a task bound to `key` at `boundAt` (ISO time) follows to: a branch
340
+ * renamed after the task was bound takes its task along, so a new branch
341
+ * created later under the old name does not inherit it.
342
+ */
343
+ export function followRenames(key: string, boundAt: string, renames: BranchRename[]): string {
344
+ let current = key;
345
+ let since = Math.floor(Date.parse(boundAt) / 1000);
346
+ for (const rename of renames)
347
+ if (rename.from === current && rename.at >= since) {
348
+ current = rename.to;
349
+ since = rename.at;
350
+ }
351
+ return current;
352
+ }
353
+
354
+ /**
355
+ * A filesystem-safe directory name for one checkout's records, from its
356
+ * canonical path, so every spelling of the same directory (symlinks, Windows
357
+ * short names, drive-letter case) maps to one checkout.
358
+ */
359
+ export const checkoutSlug = (root: string): string => {
360
+ let canonical = real(root);
361
+ if (process.platform === "win32") canonical = canonical.toLowerCase();
362
+ const base =
363
+ path
364
+ .basename(canonical)
365
+ .replace(/[^A-Za-z0-9._-]/g, "_")
366
+ .slice(0, 40) || "root";
367
+ return `${base}-${shortHash(canonical)}`;
368
+ };