@pmelab/gtd 63.0.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 +4 -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-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +3890 -1294
- package/dist/gtd.core.mjs +119 -118
- 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 +9 -27
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +11 -30
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +30 -10
- 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 +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -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
package/src/platform/PinFile.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process"
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
3
|
-
import { registerHooks } from "node:module"
|
|
4
|
-
import { join } from "node:path"
|
|
5
|
-
import { pathToFileURL } from "node:url"
|
|
6
|
-
|
|
7
1
|
/** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
|
|
8
2
|
export interface FoundPin {
|
|
9
3
|
readonly path: string
|
|
@@ -15,47 +9,3 @@ export interface FoundPin {
|
|
|
15
9
|
* the landing after an unpin beat deleted the file still runs on the pin.
|
|
16
10
|
*/
|
|
17
11
|
export type PinFile = () => FoundPin | undefined
|
|
18
|
-
|
|
19
|
-
// `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
|
|
20
|
-
// a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
|
|
21
|
-
// read counts as no pin, so the launcher refuses it rather than crashing.
|
|
22
|
-
export const livePinFile =
|
|
23
|
-
(cwd: string, path: string): PinFile =>
|
|
24
|
-
() => {
|
|
25
|
-
const file = join(cwd, path)
|
|
26
|
-
if (existsSync(file)) {
|
|
27
|
-
try {
|
|
28
|
-
return { path: file, bytes: readFileSync(file) }
|
|
29
|
-
} catch {
|
|
30
|
-
return { path: file, bytes: new Uint8Array() }
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
|
|
35
|
-
cwd,
|
|
36
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
-
})
|
|
38
|
-
return { path: file, bytes }
|
|
39
|
-
} catch {
|
|
40
|
-
return undefined
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Runs a pin's module as this process's main program. Node refuses an unknown
|
|
46
|
-
* extension, so a load hook serves the unpacked source under the pin's own
|
|
47
|
-
* URL — `import.meta.url` stays a file in the repository.
|
|
48
|
-
*/
|
|
49
|
-
export const runPinModule = async (path: string, source: string): Promise<void> => {
|
|
50
|
-
const url = pathToFileURL(path).href
|
|
51
|
-
// A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
|
|
52
|
-
registerHooks({
|
|
53
|
-
resolve: (specifier, context, nextResolve) =>
|
|
54
|
-
specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
|
|
55
|
-
load: (requested, context, nextLoad) =>
|
|
56
|
-
requested === url
|
|
57
|
-
? { format: "module", source, shortCircuit: true }
|
|
58
|
-
: nextLoad(requested, context),
|
|
59
|
-
})
|
|
60
|
-
await import(url)
|
|
61
|
-
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { HashAlgorithm } from "./SyncIo.js"
|
|
2
|
+
|
|
3
|
+
// The digest for a runtime without Node's crypto, which the in-memory tier runs on.
|
|
4
|
+
|
|
5
|
+
const K256 = Uint32Array.from([
|
|
6
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
7
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
8
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
9
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
10
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
11
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
12
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
13
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
const rotl = (x: number, n: number): number => (x << n) | (x >>> (32 - n))
|
|
17
|
+
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n))
|
|
18
|
+
|
|
19
|
+
/** `data` padded to whole 64-byte blocks, its bit length big-endian at the end. */
|
|
20
|
+
const padded = (data: Uint8Array): DataView => {
|
|
21
|
+
const length = Math.ceil((data.length + 9) / 64) * 64
|
|
22
|
+
const bytes = new Uint8Array(length)
|
|
23
|
+
bytes.set(data)
|
|
24
|
+
bytes[data.length] = 0x80
|
|
25
|
+
const view = new DataView(bytes.buffer)
|
|
26
|
+
view.setUint32(length - 8, Math.floor(data.length / 0x20000000))
|
|
27
|
+
view.setUint32(length - 4, (data.length * 8) >>> 0)
|
|
28
|
+
return view
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Round `i`'s boolean function of `b`, `c`, `d`, plus its constant. */
|
|
32
|
+
const sha1Mix = (i: number, b: number, c: number, d: number): number =>
|
|
33
|
+
i < 20
|
|
34
|
+
? ((b & c) | (~b & d)) + 0x5a827999
|
|
35
|
+
: i < 40
|
|
36
|
+
? (b ^ c ^ d) + 0x6ed9eba1
|
|
37
|
+
: i < 60
|
|
38
|
+
? ((b & c) | (b & d) | (c & d)) + 0x8f1bbcdc
|
|
39
|
+
: (b ^ c ^ d) + 0xca62c1d6
|
|
40
|
+
|
|
41
|
+
const sha1 = (view: DataView): Uint32Array => {
|
|
42
|
+
const h = Uint32Array.from([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0])
|
|
43
|
+
const w = new Uint32Array(80)
|
|
44
|
+
for (let block = 0; block < view.byteLength; block += 64) {
|
|
45
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4)
|
|
46
|
+
for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3]! ^ w[i - 8]! ^ w[i - 14]! ^ w[i - 16]!, 1)
|
|
47
|
+
let [a, b, c, d, e] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!]
|
|
48
|
+
for (let i = 0; i < 80; i++) {
|
|
49
|
+
const t = (rotl(a, 5) + sha1Mix(i, b, c, d) + e + w[i]!) | 0
|
|
50
|
+
e = d
|
|
51
|
+
d = c
|
|
52
|
+
c = rotl(b, 30)
|
|
53
|
+
b = a
|
|
54
|
+
a = t
|
|
55
|
+
}
|
|
56
|
+
h[0]! += a
|
|
57
|
+
h[1]! += b
|
|
58
|
+
h[2]! += c
|
|
59
|
+
h[3]! += d
|
|
60
|
+
h[4]! += e
|
|
61
|
+
}
|
|
62
|
+
return h
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sha256 = (view: DataView): Uint32Array => {
|
|
66
|
+
const h = Uint32Array.from([
|
|
67
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
68
|
+
])
|
|
69
|
+
const w = new Uint32Array(64)
|
|
70
|
+
for (let block = 0; block < view.byteLength; block += 64) {
|
|
71
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(block + i * 4)
|
|
72
|
+
for (let i = 16; i < 64; i++) {
|
|
73
|
+
const x = w[i - 15]!
|
|
74
|
+
const y = w[i - 2]!
|
|
75
|
+
const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3)
|
|
76
|
+
const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10)
|
|
77
|
+
w[i] = w[i - 16]! + s0 + w[i - 7]! + s1
|
|
78
|
+
}
|
|
79
|
+
let [a, b, c, d, e, f, g, hh] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!]
|
|
80
|
+
for (let i = 0; i < 64; i++) {
|
|
81
|
+
const t1 =
|
|
82
|
+
(hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K256[i]! + w[i]!) |
|
|
83
|
+
0
|
|
84
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
|
|
85
|
+
hh = g
|
|
86
|
+
g = f
|
|
87
|
+
f = e
|
|
88
|
+
e = (d + t1) | 0
|
|
89
|
+
d = c
|
|
90
|
+
c = b
|
|
91
|
+
b = a
|
|
92
|
+
a = (t1 + t2) | 0
|
|
93
|
+
}
|
|
94
|
+
h[0]! += a
|
|
95
|
+
h[1]! += b
|
|
96
|
+
h[2]! += c
|
|
97
|
+
h[3]! += d
|
|
98
|
+
h[4]! += e
|
|
99
|
+
h[5]! += f
|
|
100
|
+
h[6]! += g
|
|
101
|
+
h[7]! += hh
|
|
102
|
+
}
|
|
103
|
+
return h
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const encoder = new TextEncoder()
|
|
107
|
+
|
|
108
|
+
export const pureHash = (
|
|
109
|
+
algorithm: HashAlgorithm,
|
|
110
|
+
...parts: readonly (string | Uint8Array)[]
|
|
111
|
+
): string => {
|
|
112
|
+
const chunks = parts.map((part) => (typeof part === "string" ? encoder.encode(part) : part))
|
|
113
|
+
const data = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0))
|
|
114
|
+
let at = 0
|
|
115
|
+
for (const chunk of chunks) {
|
|
116
|
+
data.set(chunk, at)
|
|
117
|
+
at += chunk.length
|
|
118
|
+
}
|
|
119
|
+
const words = (algorithm === "sha1" ? sha1 : sha256)(padded(data))
|
|
120
|
+
return [...words].map((word) => word.toString(16).padStart(8, "0")).join("")
|
|
121
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type HashAlgorithm = "sha1" | "sha256"
|
|
2
|
+
|
|
3
|
+
export interface FileInfo {
|
|
4
|
+
readonly type: "file" | "directory" | "symlink" | "other"
|
|
5
|
+
/** `undefined` where the platform has no POSIX user ids. */
|
|
6
|
+
readonly uid: number | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The machine reads that must stay synchronous: workflows read the tree
|
|
11
|
+
* between two awaits during replay, so neither the reads nor the hashing
|
|
12
|
+
* behind them can wait on IO. Absence is a value (`undefined`); any other
|
|
13
|
+
* fault throws.
|
|
14
|
+
*/
|
|
15
|
+
export interface SyncIo {
|
|
16
|
+
readonly readFile: (path: string) => Uint8Array | undefined
|
|
17
|
+
readonly readText: (path: string) => string | undefined
|
|
18
|
+
readonly writeText: (path: string, content: string) => void
|
|
19
|
+
/** Creates every missing parent too. */
|
|
20
|
+
readonly makeDirectory: (path: string) => void
|
|
21
|
+
readonly rename: (from: string, to: string) => void
|
|
22
|
+
/** Follows a symlink. */
|
|
23
|
+
readonly stat: (path: string) => FileInfo | undefined
|
|
24
|
+
/** Reports a symlink as one. */
|
|
25
|
+
readonly lstat: (path: string) => FileInfo | undefined
|
|
26
|
+
readonly readLink: (path: string) => string
|
|
27
|
+
/** Throws when `path` does not exist. */
|
|
28
|
+
readonly realPath: (path: string) => string
|
|
29
|
+
/** git's stdout; throws on a non-zero exit, with git's stderr passed through. */
|
|
30
|
+
readonly git: (cwd: string, args: readonly string[]) => string
|
|
31
|
+
/** `git`'s raw stdout, with its stderr discarded. */
|
|
32
|
+
readonly gitBytes: (cwd: string, args: readonly string[]) => Uint8Array
|
|
33
|
+
/** Lowercase hex digest of the parts, concatenated; a string part is UTF-8. */
|
|
34
|
+
readonly hash: (algorithm: HashAlgorithm, ...parts: readonly (string | Uint8Array)[]) => string
|
|
35
|
+
}
|
|
@@ -54,7 +54,9 @@ const makeLiveTier = (): WorkspaceTier => {
|
|
|
54
54
|
|
|
55
55
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
56
56
|
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
57
|
-
const workspaceLayer = Workspace.Live.pipe(
|
|
57
|
+
const workspaceLayer = Workspace.Live.pipe(
|
|
58
|
+
Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
|
|
59
|
+
)
|
|
58
60
|
|
|
59
61
|
return {
|
|
60
62
|
root,
|
|
@@ -270,8 +272,9 @@ describe("Workspace [Live] worktreeSync", () => {
|
|
|
270
272
|
Effect.provide(
|
|
271
273
|
Workspace.Live.pipe(
|
|
272
274
|
Layer.provide(
|
|
273
|
-
Layer.
|
|
275
|
+
Layer.mergeAll(
|
|
274
276
|
Host.layer({ root, home: root, env: {} }),
|
|
277
|
+
NodeServices.layer,
|
|
275
278
|
GitService.Live.pipe(
|
|
276
279
|
Layer.provide(
|
|
277
280
|
Layer.merge(Host.layer({ root, home: root, env: {} }), NodeServices.layer),
|
|
@@ -361,7 +364,9 @@ const makeLiveEpisodeTier = () => {
|
|
|
361
364
|
|
|
362
365
|
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
363
366
|
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
|
|
364
|
-
const workspaceLayer = Workspace.Live.pipe(
|
|
367
|
+
const workspaceLayer = Workspace.Live.pipe(
|
|
368
|
+
Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
|
|
369
|
+
)
|
|
365
370
|
const ops = (): Promise<WorkspaceOps> =>
|
|
366
371
|
Effect.runPromise(
|
|
367
372
|
Effect.gen(function* () {
|
|
@@ -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
|
}
|