@pmelab/gtd 62.1.0 → 64.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -3
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
- package/dist/chunks/{testkit-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +4023 -1326
- package/dist/gtd.core.mjs +120 -119
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +10 -28
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +36 -45
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +98 -13
- package/src/edge/index.ts +1 -0
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +10 -9
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +32 -10
- package/src/replay/Format.ts +5 -2
- package/src/replay/Replay.test.ts +110 -3
- package/src/replay/Replay.ts +120 -28
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +32 -1
- package/src/replay/index.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- package/src/workflow/pin.ts +17 -11
|
@@ -1,38 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createHash } from "node:crypto"
|
|
3
|
-
import {
|
|
4
|
-
lstatSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
readlinkSync,
|
|
8
|
-
renameSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from "node:fs"
|
|
11
|
-
import { dirname, isAbsolute, join } from "node:path"
|
|
12
|
-
import { Context, Effect, Layer } from "effect"
|
|
1
|
+
import { Context, Effect, Layer, Path } from "effect"
|
|
13
2
|
import { GitService, type GitOperations } from "./Git.js"
|
|
14
|
-
import { Host } from "./Host.js"
|
|
3
|
+
import { Host, type HostOps } from "./Host.js"
|
|
4
|
+
import { nodeSyncIo } from "./NodeSync.js"
|
|
5
|
+
import type { SyncIo } from "./SyncIo.js"
|
|
15
6
|
|
|
16
7
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
17
8
|
|
|
18
|
-
// A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
|
|
19
|
-
const MAX_BUFFER = 256 * 1024 * 1024
|
|
20
9
|
const HASH_CHUNK = 500
|
|
10
|
+
const encoder = new TextEncoder()
|
|
21
11
|
|
|
22
12
|
/** A full sha1 or sha256 object id — the only refs an immutable, disk-cached read is safe to key on. */
|
|
23
13
|
const isObjectHash = (ref: string): boolean => /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ref)
|
|
24
14
|
|
|
25
15
|
const GTD_CACHE_SUBDIR = "gtd-cache"
|
|
26
16
|
|
|
27
|
-
const
|
|
28
|
-
join(commonDir, GTD_CACHE_SUBDIR, "trees", hash)
|
|
29
|
-
|
|
30
|
-
const blobCachePath = (commonDir: string, id: string): string =>
|
|
31
|
-
join(commonDir, GTD_CACHE_SUBDIR, "blobs", id)
|
|
32
|
-
|
|
33
|
-
const readTextCache = (path: string): string | undefined => {
|
|
17
|
+
const readTextCache = (io: SyncIo, path: string): string | undefined => {
|
|
34
18
|
try {
|
|
35
|
-
return
|
|
19
|
+
return io.readText(path)
|
|
36
20
|
} catch {
|
|
37
21
|
return undefined
|
|
38
22
|
}
|
|
@@ -46,20 +30,26 @@ const readTextCache = (path: string): string | undefined => {
|
|
|
46
30
|
* cache is a best-effort speedup, never a correctness dependency, and the
|
|
47
31
|
* alternative — failing a gtd command over a cache write — is worse.
|
|
48
32
|
*/
|
|
49
|
-
const writeTextCacheAtomic = (
|
|
33
|
+
const writeTextCacheAtomic = (
|
|
34
|
+
io: SyncIo,
|
|
35
|
+
paths: Path.Path,
|
|
36
|
+
pid: number,
|
|
37
|
+
path: string,
|
|
38
|
+
content: string,
|
|
39
|
+
): void => {
|
|
50
40
|
try {
|
|
51
|
-
|
|
52
|
-
const tmp = `${path}.tmp-${
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
io.makeDirectory(paths.dirname(path))
|
|
42
|
+
const tmp = `${path}.tmp-${pid}-${Math.random().toString(36).slice(2)}`
|
|
43
|
+
io.writeText(tmp, content)
|
|
44
|
+
io.rename(tmp, path)
|
|
55
45
|
} catch {
|
|
56
46
|
// A full disk or read-only git dir silently disables the cache.
|
|
57
47
|
}
|
|
58
48
|
}
|
|
59
49
|
|
|
60
50
|
/** A commit's `path → blob id` map, serialized as JSON. `undefined` on ANY read failure — missing, truncated, unparseable — so the caller falls back to asking git. */
|
|
61
|
-
const readTreeCache = (
|
|
62
|
-
const raw = readTextCache(
|
|
51
|
+
const readTreeCache = (io: SyncIo, file: string): Map<string, string> | undefined => {
|
|
52
|
+
const raw = readTextCache(io, file)
|
|
63
53
|
if (raw === undefined) return undefined
|
|
64
54
|
try {
|
|
65
55
|
const parsed: unknown = JSON.parse(raw)
|
|
@@ -75,8 +65,13 @@ const readTreeCache = (commonDir: string, hash: string): Map<string, string> | u
|
|
|
75
65
|
}
|
|
76
66
|
}
|
|
77
67
|
|
|
78
|
-
const writeTreeCache = (
|
|
79
|
-
|
|
68
|
+
const writeTreeCache = (
|
|
69
|
+
io: SyncIo,
|
|
70
|
+
paths: Path.Path,
|
|
71
|
+
pid: number,
|
|
72
|
+
file: string,
|
|
73
|
+
tree: ReadonlyMap<string, string>,
|
|
74
|
+
): void => writeTextCacheAtomic(io, paths, pid, file, JSON.stringify(Object.fromEntries(tree)))
|
|
80
75
|
|
|
81
76
|
/**
|
|
82
77
|
* One raw `git log --raw` diff entry, already resolved to what it does to a
|
|
@@ -177,19 +172,14 @@ const parseLsTree = (out: string): Map<string, string> => {
|
|
|
177
172
|
* check `ok` before doing so.
|
|
178
173
|
*/
|
|
179
174
|
const fetchTree = (
|
|
175
|
+
io: SyncIo,
|
|
180
176
|
root: string,
|
|
181
177
|
ref: string,
|
|
182
178
|
): { readonly ok: boolean; readonly entries: Map<string, string> } => {
|
|
183
179
|
try {
|
|
184
180
|
return {
|
|
185
181
|
ok: true,
|
|
186
|
-
entries: parseLsTree(
|
|
187
|
-
execFileSync("git", ["ls-tree", "-r", "-z", "--full-tree", ref], {
|
|
188
|
-
cwd: root,
|
|
189
|
-
encoding: "utf8",
|
|
190
|
-
maxBuffer: MAX_BUFFER,
|
|
191
|
-
}),
|
|
192
|
-
),
|
|
182
|
+
entries: parseLsTree(io.git(root, ["ls-tree", "-r", "-z", "--full-tree", ref])),
|
|
193
183
|
}
|
|
194
184
|
} catch {
|
|
195
185
|
return { ok: false, entries: new Map() }
|
|
@@ -263,8 +253,8 @@ export interface WorkspaceOps {
|
|
|
263
253
|
readonly writeAtPath: (path: string, content: string) => Effect.Effect<void, Error>
|
|
264
254
|
}
|
|
265
255
|
|
|
266
|
-
const assertRepoRelative = (path: string): string => {
|
|
267
|
-
if (isAbsolute(path)) {
|
|
256
|
+
const assertRepoRelative = (paths: Path.Path, path: string): string => {
|
|
257
|
+
if (paths.isAbsolute(path)) {
|
|
268
258
|
throw new Error(
|
|
269
259
|
`Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath"/"writeAtPath" to read/write outside the repo)`,
|
|
270
260
|
)
|
|
@@ -272,24 +262,6 @@ const assertRepoRelative = (path: string): string => {
|
|
|
272
262
|
return path
|
|
273
263
|
}
|
|
274
264
|
|
|
275
|
-
const readFileOrAbsent = (path: string): string | undefined => {
|
|
276
|
-
try {
|
|
277
|
-
return readFileSync(path, "utf8")
|
|
278
|
-
} catch (e) {
|
|
279
|
-
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
280
|
-
throw e
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const lstatOrAbsent = (path: string): ReturnType<typeof lstatSync> | undefined => {
|
|
285
|
-
try {
|
|
286
|
-
return lstatSync(path)
|
|
287
|
-
} catch (e) {
|
|
288
|
-
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
289
|
-
throw e
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
265
|
/** One replayed commit: its hash and the (already-`\n`-stripped) `--raw -z` body `parseRawDiff` consumes. */
|
|
294
266
|
interface RawLogEntry {
|
|
295
267
|
readonly hash: string
|
|
@@ -304,27 +276,24 @@ interface RawLogEntry {
|
|
|
304
276
|
* bounds no longer match git's own history.
|
|
305
277
|
*/
|
|
306
278
|
const runRawLogReplay = (
|
|
279
|
+
io: SyncIo,
|
|
307
280
|
root: string,
|
|
308
281
|
base: string | undefined,
|
|
309
282
|
head: string,
|
|
310
283
|
commits: readonly string[],
|
|
311
284
|
): readonly RawLogEntry[] => {
|
|
312
285
|
const range = base !== undefined ? `${base}..${head}` : head
|
|
313
|
-
const out =
|
|
314
|
-
"
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
range,
|
|
325
|
-
],
|
|
326
|
-
{ cwd: root, encoding: "utf8", maxBuffer: MAX_BUFFER },
|
|
327
|
-
)
|
|
286
|
+
const out = io.git(root, [
|
|
287
|
+
"log",
|
|
288
|
+
"--first-parent",
|
|
289
|
+
"--reverse",
|
|
290
|
+
"-z",
|
|
291
|
+
"--raw",
|
|
292
|
+
"-r",
|
|
293
|
+
"--no-abbrev",
|
|
294
|
+
"--format=%x01%H",
|
|
295
|
+
range,
|
|
296
|
+
])
|
|
328
297
|
const chunks = out.split("\x01").filter((c) => c.length > 0)
|
|
329
298
|
if (chunks.length !== commits.length) {
|
|
330
299
|
throw new Error(
|
|
@@ -363,11 +332,19 @@ const foldRawDiffs = (
|
|
|
363
332
|
}
|
|
364
333
|
|
|
365
334
|
const makeWorkspaceOps = (
|
|
366
|
-
root:
|
|
335
|
+
{ root, pid }: HostOps,
|
|
367
336
|
git: GitOperations,
|
|
368
337
|
commonDir: string | undefined,
|
|
338
|
+
paths: Path.Path,
|
|
339
|
+
io: SyncIo,
|
|
369
340
|
): WorkspaceOps => {
|
|
370
|
-
const resolveAny = (path: string): string =>
|
|
341
|
+
const resolveAny = (path: string): string =>
|
|
342
|
+
paths.isAbsolute(path) ? path : paths.join(root, path)
|
|
343
|
+
const repoPath = (path: string): string => paths.join(root, assertRepoRelative(paths, path))
|
|
344
|
+
const treeCachePath = (dir: string, hash: string): string =>
|
|
345
|
+
paths.join(dir, GTD_CACHE_SUBDIR, "trees", hash)
|
|
346
|
+
const blobCachePath = (dir: string, id: string): string =>
|
|
347
|
+
paths.join(dir, GTD_CACHE_SUBDIR, "blobs", id)
|
|
371
348
|
|
|
372
349
|
// Per-PROCESS memo, on the workspace instance rather than any one replay's
|
|
373
350
|
// `TreeView` — an immutable key (a commit hash, a blob id) can never serve
|
|
@@ -381,18 +358,18 @@ const makeWorkspaceOps = (
|
|
|
381
358
|
const memoHit = treeMemo.get(hash)
|
|
382
359
|
if (memoHit !== undefined) return memoHit
|
|
383
360
|
if (commonDir === undefined) return undefined
|
|
384
|
-
const cached = readTreeCache(commonDir, hash)
|
|
361
|
+
const cached = readTreeCache(io, treeCachePath(commonDir, hash))
|
|
385
362
|
if (cached !== undefined) treeMemo.set(hash, cached)
|
|
386
363
|
return cached
|
|
387
364
|
}
|
|
388
365
|
|
|
389
366
|
const rememberTree = (hash: string, tree: ReadonlyMap<string, string>): void => {
|
|
390
367
|
treeMemo.set(hash, tree)
|
|
391
|
-
if (commonDir !== undefined)
|
|
368
|
+
if (commonDir !== undefined)
|
|
369
|
+
writeTreeCache(io, paths, pid, treeCachePath(commonDir, hash), tree)
|
|
392
370
|
}
|
|
393
371
|
|
|
394
|
-
const readSync = (path: string): string | undefined =>
|
|
395
|
-
readFileOrAbsent(join(root, assertRepoRelative(path)))
|
|
372
|
+
const readSync = (path: string): string | undefined => io.readText(repoPath(path))
|
|
396
373
|
|
|
397
374
|
/**
|
|
398
375
|
* A blob's content, keyed on its OWN id rather than a (path, ref) pair —
|
|
@@ -405,7 +382,7 @@ const makeWorkspaceOps = (
|
|
|
405
382
|
const memoHit = blobMemo.get(id)
|
|
406
383
|
if (memoHit !== undefined) return memoHit
|
|
407
384
|
if (commonDir !== undefined) {
|
|
408
|
-
const cached = readTextCache(blobCachePath(commonDir, id))
|
|
385
|
+
const cached = readTextCache(io, blobCachePath(commonDir, id))
|
|
409
386
|
if (cached !== undefined) {
|
|
410
387
|
blobMemo.set(id, cached)
|
|
411
388
|
return cached
|
|
@@ -413,23 +390,20 @@ const makeWorkspaceOps = (
|
|
|
413
390
|
}
|
|
414
391
|
let content: string
|
|
415
392
|
try {
|
|
416
|
-
content =
|
|
417
|
-
cwd: root,
|
|
418
|
-
encoding: "utf8",
|
|
419
|
-
maxBuffer: MAX_BUFFER,
|
|
420
|
-
})
|
|
393
|
+
content = io.git(root, ["cat-file", "-p", id])
|
|
421
394
|
} catch {
|
|
422
395
|
return undefined
|
|
423
396
|
}
|
|
424
397
|
blobMemo.set(id, content)
|
|
425
|
-
if (commonDir !== undefined)
|
|
398
|
+
if (commonDir !== undefined)
|
|
399
|
+
writeTextCacheAtomic(io, paths, pid, blobCachePath(commonDir, id), content)
|
|
426
400
|
return content
|
|
427
401
|
}
|
|
428
402
|
|
|
429
403
|
// A raw, synchronous `git show <ref>:<path>` — the sync counterpart to
|
|
430
404
|
// `committed`, for replay, which can't `yield*` mid-flow. Bypasses the Effect-based `GitOperations`
|
|
431
405
|
// entirely, the same escape `readSync` already takes around the Effect
|
|
432
|
-
// `FileSystem` — both
|
|
406
|
+
// `FileSystem` — both go through the sync port, not a second git abstraction.
|
|
433
407
|
// ANY failure (missing ref, missing path at that ref, git not on PATH)
|
|
434
408
|
// folds to `undefined`, matching `committed`'s own catch-all-as-absence.
|
|
435
409
|
//
|
|
@@ -439,17 +413,13 @@ const makeWorkspaceOps = (
|
|
|
439
413
|
// is never cached here, since it can move mid-process (a landing commit),
|
|
440
414
|
// and goes straight to `git show` as before.
|
|
441
415
|
const readCommittedSync = (path: string, ref = "HEAD"): string | undefined => {
|
|
442
|
-
const relPath = assertRepoRelative(path)
|
|
416
|
+
const relPath = assertRepoRelative(paths, path)
|
|
443
417
|
if (isObjectHash(ref)) {
|
|
444
418
|
const id = treeSync(ref).get(relPath)
|
|
445
419
|
return id === undefined ? undefined : readBlobSync(id)
|
|
446
420
|
}
|
|
447
421
|
try {
|
|
448
|
-
return
|
|
449
|
-
cwd: root,
|
|
450
|
-
encoding: "utf8",
|
|
451
|
-
maxBuffer: MAX_BUFFER,
|
|
452
|
-
})
|
|
422
|
+
return io.git(root, ["show", `${ref}:${relPath}`])
|
|
453
423
|
} catch {
|
|
454
424
|
return undefined
|
|
455
425
|
}
|
|
@@ -464,7 +434,7 @@ const makeWorkspaceOps = (
|
|
|
464
434
|
const cached = peekTree(ref)
|
|
465
435
|
if (cached !== undefined) return cached
|
|
466
436
|
}
|
|
467
|
-
const { ok, entries } = fetchTree(root, ref)
|
|
437
|
+
const { ok, entries } = fetchTree(io, root, ref)
|
|
468
438
|
if (!ok) {
|
|
469
439
|
if (onFailure === "throw") {
|
|
470
440
|
throw new Error(`gtd: failed to read the tree at "${ref}" — git ls-tree did not succeed`)
|
|
@@ -510,7 +480,7 @@ const makeWorkspaceOps = (
|
|
|
510
480
|
}
|
|
511
481
|
|
|
512
482
|
const head = commits[commits.length - 1]!
|
|
513
|
-
const replay = runRawLogReplay(root, base, head, commits)
|
|
483
|
+
const replay = runRawLogReplay(io, root, base, head, commits)
|
|
514
484
|
for (const [hash, tree] of foldRawDiffs(baseTree, replay, rememberTree))
|
|
515
485
|
result.set(hash, tree)
|
|
516
486
|
return result
|
|
@@ -518,13 +488,7 @@ const makeWorkspaceOps = (
|
|
|
518
488
|
catch: toError,
|
|
519
489
|
})
|
|
520
490
|
|
|
521
|
-
const gitSync = (args: readonly string[]
|
|
522
|
-
execFileSync("git", args, {
|
|
523
|
-
cwd: root,
|
|
524
|
-
encoding: "utf8",
|
|
525
|
-
maxBuffer: MAX_BUFFER,
|
|
526
|
-
...(input !== undefined ? { input } : {}),
|
|
527
|
-
})
|
|
491
|
+
const gitSync = (args: readonly string[]): string => io.git(root, args)
|
|
528
492
|
|
|
529
493
|
// A path git status reports is re-hashed the way `git add` would store it;
|
|
530
494
|
// every other path keeps HEAD's blob id, so an untouched file is never
|
|
@@ -544,13 +508,13 @@ const makeWorkspaceOps = (
|
|
|
544
508
|
for (const record of status.split("\0")) {
|
|
545
509
|
if (record.length < 4) continue
|
|
546
510
|
const path = record.slice(3).replace(/\/$/, "")
|
|
547
|
-
const stat =
|
|
511
|
+
const stat = io.lstat(paths.join(root, path))
|
|
548
512
|
if (stat === undefined) entries.delete(path)
|
|
549
|
-
else if (stat.
|
|
550
|
-
entries.set(path, blobId(
|
|
551
|
-
} else if (stat.
|
|
513
|
+
else if (stat.type === "symlink") {
|
|
514
|
+
entries.set(path, blobId(io.readLink(paths.join(root, path))))
|
|
515
|
+
} else if (stat.type === "directory") {
|
|
552
516
|
// Only a directory holding its own repository is committed, as a gitlink.
|
|
553
|
-
if (
|
|
517
|
+
if (io.lstat(paths.join(root, path, ".git")) === undefined) entries.delete(path)
|
|
554
518
|
else entries.set(path, gitlinkOf(path))
|
|
555
519
|
} else files.push(path)
|
|
556
520
|
}
|
|
@@ -567,10 +531,7 @@ const makeWorkspaceOps = (
|
|
|
567
531
|
/** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
|
|
568
532
|
const gitlinkOf = (path: string): string | undefined => {
|
|
569
533
|
try {
|
|
570
|
-
return
|
|
571
|
-
cwd: join(root, path),
|
|
572
|
-
encoding: "utf8",
|
|
573
|
-
}).trim()
|
|
534
|
+
return io.git(paths.join(root, path), ["rev-parse", "HEAD"]).trim()
|
|
574
535
|
} catch {
|
|
575
536
|
return undefined
|
|
576
537
|
}
|
|
@@ -579,11 +540,8 @@ const makeWorkspaceOps = (
|
|
|
579
540
|
let objectFormat: string | undefined
|
|
580
541
|
const blobId = (content: string): string => {
|
|
581
542
|
objectFormat ??= gitSync(["rev-parse", "--show-object-format"]).trim()
|
|
582
|
-
const bytes =
|
|
583
|
-
return
|
|
584
|
-
.update(`blob ${bytes.length}\0`)
|
|
585
|
-
.update(bytes)
|
|
586
|
-
.digest("hex")
|
|
543
|
+
const bytes = encoder.encode(content)
|
|
544
|
+
return io.hash(objectFormat === "sha256" ? "sha256" : "sha1", `blob ${bytes.length}\0`, bytes)
|
|
587
545
|
}
|
|
588
546
|
|
|
589
547
|
return {
|
|
@@ -594,19 +552,19 @@ const makeWorkspaceOps = (
|
|
|
594
552
|
read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
|
|
595
553
|
write: (path, content) =>
|
|
596
554
|
Effect.try({
|
|
597
|
-
try: () =>
|
|
555
|
+
try: () => io.writeText(repoPath(path), content),
|
|
598
556
|
catch: toError,
|
|
599
557
|
}),
|
|
600
558
|
committed: (path, ref = "HEAD") =>
|
|
601
|
-
Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
|
|
559
|
+
Effect.try({ try: () => assertRepoRelative(paths, path), catch: toError }).pipe(
|
|
602
560
|
Effect.flatMap((relPath) =>
|
|
603
561
|
git.readFileAtRef(ref, relPath).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
|
604
562
|
),
|
|
605
563
|
),
|
|
606
564
|
readCommittedSync,
|
|
607
|
-
atPath: (path) =>
|
|
565
|
+
atPath: (path) => io.readText(resolveAny(path)),
|
|
608
566
|
writeAtPath: (path, content) =>
|
|
609
|
-
Effect.try({ try: () =>
|
|
567
|
+
Effect.try({ try: () => io.writeText(resolveAny(path), content), catch: toError }),
|
|
610
568
|
}
|
|
611
569
|
}
|
|
612
570
|
|
|
@@ -614,8 +572,9 @@ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Works
|
|
|
614
572
|
static Live = Layer.effect(
|
|
615
573
|
Workspace,
|
|
616
574
|
Effect.gen(function* () {
|
|
617
|
-
const
|
|
575
|
+
const host = yield* Host
|
|
618
576
|
const git = yield* GitService
|
|
577
|
+
const paths = yield* Path.Path
|
|
619
578
|
// Resolved once per process, not lazily inside a sync method: the
|
|
620
579
|
// common dir is git's own concern to answer, and every cache read/
|
|
621
580
|
// write below is synchronous. A repo whose common dir can't be
|
|
@@ -625,7 +584,7 @@ export class Workspace extends Context.Service<Workspace, WorkspaceOps>()("Works
|
|
|
625
584
|
const commonDir = yield* git
|
|
626
585
|
.gitCommonDir()
|
|
627
586
|
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
628
|
-
return makeWorkspaceOps(
|
|
587
|
+
return makeWorkspaceOps(host, git, commonDir, paths, nodeSyncIo)
|
|
629
588
|
}),
|
|
630
589
|
)
|
|
631
590
|
}
|
|
@@ -11,14 +11,14 @@ import { join } from "node:path"
|
|
|
11
11
|
import { mkdtempSync, realpathSync } from "node:fs"
|
|
12
12
|
import { execSync } from "node:child_process"
|
|
13
13
|
import { tmpdir } from "node:os"
|
|
14
|
-
import { Effect, Layer } from "effect"
|
|
14
|
+
import { Effect, Layer, type Path } from "effect"
|
|
15
15
|
import { describe, expect, it } from "vitest"
|
|
16
16
|
import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
|
|
17
17
|
import { GitService, Host, Workspace, NodeServices } from "./index.js"
|
|
18
18
|
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
19
19
|
|
|
20
20
|
const provide = <A>(
|
|
21
|
-
eff: Effect.Effect<A, never, Host | Workspace>,
|
|
21
|
+
eff: Effect.Effect<A, never, Host | Path.Path | Workspace>,
|
|
22
22
|
repo: InMemRepo,
|
|
23
23
|
opts: {
|
|
24
24
|
readonly root?: string
|
|
@@ -112,9 +112,13 @@ describe("worktreeGitDir [real git]", () => {
|
|
|
112
112
|
const runIn = (root: string) => {
|
|
113
113
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
114
114
|
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
115
|
-
const workspaceLayer = Workspace.Live.pipe(
|
|
115
|
+
const workspaceLayer = Workspace.Live.pipe(
|
|
116
|
+
Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
|
|
117
|
+
)
|
|
116
118
|
return Effect.runPromise(
|
|
117
|
-
worktreeGitDir.pipe(
|
|
119
|
+
worktreeGitDir.pipe(
|
|
120
|
+
Effect.provide(Layer.mergeAll(hostLayer, workspaceLayer, NodeServices.layer)),
|
|
121
|
+
),
|
|
118
122
|
)
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Effect } from "effect"
|
|
1
|
+
import { Effect, Path } from "effect"
|
|
3
2
|
import { Host } from "./Host.js"
|
|
4
3
|
import { Workspace } from "./Workspace.js"
|
|
5
4
|
|
|
@@ -11,9 +10,10 @@ const GITDIR_POINTER = /^gitdir:\s*(.+)$/m
|
|
|
11
10
|
* read failure or a `.git` file with no `gitdir:` line both fall back to
|
|
12
11
|
* `".git"`, which is also correct for the in-memory test tier.
|
|
13
12
|
*/
|
|
14
|
-
export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> =
|
|
15
|
-
function* () {
|
|
13
|
+
export const worktreeGitDir: Effect.Effect<string, never, Host | Path.Path | Workspace> =
|
|
14
|
+
Effect.gen(function* () {
|
|
16
15
|
const { root } = yield* Host
|
|
16
|
+
const path = yield* Path.Path
|
|
17
17
|
const workspace = yield* Workspace
|
|
18
18
|
const content = yield* workspace
|
|
19
19
|
.read(".git")
|
|
@@ -21,9 +21,8 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
|
|
|
21
21
|
const pointer = content === undefined ? null : GITDIR_POINTER.exec(content)
|
|
22
22
|
if (pointer === null) return ".git"
|
|
23
23
|
const gitdir = pointer[1]!.trim()
|
|
24
|
-
return isAbsolute(gitdir) ? gitdir : join(root, gitdir)
|
|
25
|
-
}
|
|
26
|
-
)
|
|
24
|
+
return path.isAbsolute(gitdir) ? gitdir : path.join(root, gitdir)
|
|
25
|
+
})
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* The per-worktree loop log path `gtd next --json` reports as `log`.
|
|
@@ -34,17 +33,20 @@ export const worktreeGitDir: Effect.Effect<string, never, Host | Workspace> = Ef
|
|
|
34
33
|
* doesn't want that (e.g. the e2e suite) must scrub it before invoking gtd.
|
|
35
34
|
* Advisory only — gtd neither creates nor truncates the file.
|
|
36
35
|
*/
|
|
37
|
-
export const loopLogPath: Effect.Effect<string, never, Host | Workspace> = Effect.gen(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
export const loopLogPath: Effect.Effect<string, never, Host | Path.Path | Workspace> = Effect.gen(
|
|
37
|
+
function* () {
|
|
38
|
+
const { root, env } = yield* Host
|
|
39
|
+
const path = yield* Path.Path
|
|
40
|
+
const override = env.GTD_LOOP_LOG
|
|
41
|
+
if (override !== undefined && override !== "") return override
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const gitDirEnv = env.GIT_DIR
|
|
44
|
+
if (gitDirEnv !== undefined && gitDirEnv !== "") {
|
|
45
|
+
const gitDir = path.isAbsolute(gitDirEnv) ? gitDirEnv : path.join(root, gitDirEnv)
|
|
46
|
+
return path.join(gitDir, "gtd-loop.log")
|
|
47
|
+
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
49
|
+
const gitDir = yield* worktreeGitDir
|
|
50
|
+
return path.join(gitDir, "gtd-loop.log")
|
|
51
|
+
},
|
|
52
|
+
)
|
package/src/platform/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* gtd's
|
|
3
|
-
* working-tree reads/writes plus the read-at-ref path)
|
|
4
|
-
* home, env, scratch
|
|
2
|
+
* gtd's ports onto its environment: `Workspace` (repo file content —
|
|
3
|
+
* working-tree reads/writes plus the read-at-ref path), `Host` (root,
|
|
4
|
+
* home, env, scratch, process identity, stdio) and `SyncIo`, the reads that
|
|
5
|
+
* must stay synchronous, which only `Node*.ts` implements against Node. `GitService` moved in alongside `Workspace` since
|
|
5
6
|
* `committed` IS `git.readFileAtRef` — one module owns every read.
|
|
6
7
|
*
|
|
7
8
|
* Seam rule, stated once: a seam is a `Context.Service` unless the consuming
|
|
@@ -9,7 +10,14 @@
|
|
|
9
10
|
* reads are that exception, and use the `*Sync` members.
|
|
10
11
|
*/
|
|
11
12
|
export { Workspace, type WorkspaceOps } from "./Workspace.js"
|
|
12
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
Host,
|
|
15
|
+
nodeStdio,
|
|
16
|
+
scratchDirOf,
|
|
17
|
+
type HostOps,
|
|
18
|
+
type NodeProcess,
|
|
19
|
+
type Stdio,
|
|
20
|
+
} from "./Host.js"
|
|
13
21
|
export {
|
|
14
22
|
GitService,
|
|
15
23
|
type GitOperations,
|
|
@@ -29,4 +37,9 @@ export {
|
|
|
29
37
|
readRetainedHistory,
|
|
30
38
|
restorability,
|
|
31
39
|
} from "./RetainedHistory.js"
|
|
32
|
-
export { livePinFile, runPinModule
|
|
40
|
+
export { livePinFile, runPinModule } from "./NodePinFile.js"
|
|
41
|
+
export type { PinFile } from "./PinFile.js"
|
|
42
|
+
export type { FileInfo, SyncIo } from "./SyncIo.js"
|
|
43
|
+
export { nodeSyncIo } from "./NodeSync.js"
|
|
44
|
+
export { pureHash } from "./Sha.js"
|
|
45
|
+
export * as NodeRuntime from "./NodeRuntime.js"
|
|
@@ -3,11 +3,13 @@ import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js
|
|
|
3
3
|
|
|
4
4
|
describe("the history format", () => {
|
|
5
5
|
it("accepts a history in the current format and in the one before it", () => {
|
|
6
|
-
expect(formatFault([{ hash: "a".repeat(40), format:
|
|
6
|
+
expect(formatFault([{ hash: "a".repeat(40), format: 4 }])).toBeUndefined()
|
|
7
7
|
expect(
|
|
8
8
|
formatFault([
|
|
9
9
|
{ hash: "a".repeat(40), format: 1 },
|
|
10
10
|
{ hash: "b".repeat(40), format: 2 },
|
|
11
|
+
{ hash: "c".repeat(40), format: 3 },
|
|
12
|
+
{ hash: "d".repeat(40), format: 4 },
|
|
11
13
|
]),
|
|
12
14
|
).toBeUndefined()
|
|
13
15
|
expect(formatFault([])).toBeUndefined()
|
|
@@ -16,11 +18,11 @@ describe("the history format", () => {
|
|
|
16
18
|
it("names the first foreign commit, the found and readable formats, and what to do", () => {
|
|
17
19
|
const fault = formatFault([
|
|
18
20
|
{ hash: "a".repeat(40), format: 1 },
|
|
19
|
-
{ hash: "b".repeat(40), format:
|
|
20
|
-
{ hash: "c".repeat(40), format:
|
|
21
|
+
{ hash: "b".repeat(40), format: 5 },
|
|
22
|
+
{ hash: "c".repeat(40), format: 6 },
|
|
21
23
|
])
|
|
22
24
|
expect(fault).toBe(
|
|
23
|
-
"gtd: commit bbbbbbb is written in history format
|
|
25
|
+
"gtd: commit bbbbbbb is written in history format 5, but this gtd reads formats 1 to 4 — run a gtd release that reads format 5 to continue this process",
|
|
24
26
|
)
|
|
25
27
|
})
|
|
26
28
|
})
|
|
@@ -35,8 +37,8 @@ describe("Gtd-Approved and the history format", () => {
|
|
|
35
37
|
expect(message).toContain(
|
|
36
38
|
'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
|
|
37
39
|
)
|
|
38
|
-
expect(message).toContain("Gtd-Format:
|
|
39
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
40
|
+
expect(message).toContain("Gtd-Format: 4")
|
|
41
|
+
expect(parseCommitMessage(message).format).toBe(4)
|
|
40
42
|
})
|
|
41
43
|
})
|
|
42
44
|
|
|
@@ -44,19 +46,39 @@ describe("Gtd-Pin", () => {
|
|
|
44
46
|
it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
|
|
45
47
|
const message = formatCommitMessage({
|
|
46
48
|
actor: "check",
|
|
47
|
-
from: "unpin",
|
|
49
|
+
from: "gtd:unpin",
|
|
48
50
|
to: "idle",
|
|
49
51
|
pin: "remove",
|
|
50
52
|
branch: "main",
|
|
51
53
|
})
|
|
52
54
|
expect(message).toBe(
|
|
53
|
-
"gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format:
|
|
55
|
+
"gtd(check): gtd:unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 4",
|
|
54
56
|
)
|
|
55
57
|
expect(parseCommitMessage(message).pin).toBe("remove")
|
|
56
|
-
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
58
|
+
expect(parseCommitMessage("gtd(check): gtd:pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
57
59
|
})
|
|
58
60
|
|
|
59
61
|
it("reads an unknown value as no pin", () => {
|
|
60
|
-
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
62
|
+
expect(parseCommitMessage("gtd(check): gtd:pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("Gtd-Anchor", () => {
|
|
67
|
+
it("round-trips after Gtd-Pin, and reads a non-hash value as no anchor", () => {
|
|
68
|
+
const anchor = "a".repeat(40)
|
|
69
|
+
const message = formatCommitMessage({
|
|
70
|
+
actor: "check",
|
|
71
|
+
from: "gtd:pin",
|
|
72
|
+
to: "b",
|
|
73
|
+
pin: "add",
|
|
74
|
+
anchor,
|
|
75
|
+
})
|
|
76
|
+
expect(message).toBe(
|
|
77
|
+
`gtd(check): gtd:pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format: 4`,
|
|
78
|
+
)
|
|
79
|
+
expect(parseCommitMessage(message).anchor).toBe(anchor)
|
|
80
|
+
expect(
|
|
81
|
+
parseCommitMessage("gtd(check): gtd:pin → b\n\nGtd-Anchor: HEAD~2").anchor,
|
|
82
|
+
).toBeUndefined()
|
|
61
83
|
})
|
|
62
84
|
})
|
package/src/replay/Format.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Bump only when what replay reads back out of git changes incompatibly — the
|
|
2
2
|
// package version must not stand in for it, since most majors leave history
|
|
3
|
-
// untouched. Format 2 adds pin commits, which format 1 would replay as turns
|
|
4
|
-
|
|
3
|
+
// untouched. Format 2 adds pin commits, which format 1 would replay as turns;
|
|
4
|
+
// format 3 adds the anchor, whose set-aside commits format 2 would replay;
|
|
5
|
+
// format 4 renames the pin beats `gtd:pin`/`gtd:unpin`, which format 3 would
|
|
6
|
+
// read as steps.
|
|
7
|
+
export const HISTORY_FORMAT = 4
|
|
5
8
|
const OLDEST_READABLE_FORMAT = 1
|
|
6
9
|
|
|
7
10
|
const short = (hash: string): string => hash.slice(0, 7)
|