@pmelab/gtd 23.2.0 → 24.1.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 +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs"
|
|
10
|
+
import * as childProcess from "node:child_process"
|
|
11
|
+
import { execSync } from "node:child_process"
|
|
12
|
+
import { tmpdir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
15
|
+
import { Effect, Layer } from "effect"
|
|
16
|
+
import { NodeContext } from "@effect/platform-node"
|
|
17
|
+
import { GitService, Host, Workspace, type WorkspaceOps } from "./index.js"
|
|
18
|
+
import { InMemRepo, makeInMemoryWorkspaceOps } from "../testkit/index.js"
|
|
19
|
+
|
|
20
|
+
// `execFileSync` is a named export of the built-in `node:child_process` ESM
|
|
21
|
+
// module — not configurable, so `vi.spyOn` can't wrap it directly. `vi.mock`
|
|
22
|
+
// with `importOriginal` swaps in a `vi.fn` that still calls straight through,
|
|
23
|
+
// so every test in this file (not just the ones counting calls) behaves
|
|
24
|
+
// exactly as before; only tests that inspect `execFileSyncSpy.mock.calls`
|
|
25
|
+
// (after `.mockClear()`) actually observe it.
|
|
26
|
+
const execFileSyncSpy = vi.hoisted(() => vi.fn())
|
|
27
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
28
|
+
const actual = await importOriginal<typeof import("node:child_process")>()
|
|
29
|
+
execFileSyncSpy.mockImplementation(actual.execFileSync)
|
|
30
|
+
return { ...actual, execFileSync: execFileSyncSpy }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One `Workspace` tier, in the shape `src/testkit/GitTiers.ts` establishes
|
|
35
|
+
* for `GitOperations`: a `root`, a way to seed working-tree/committed
|
|
36
|
+
* content, and `provide` to run an Effect against this tier's `Workspace`.
|
|
37
|
+
*/
|
|
38
|
+
interface WorkspaceTier {
|
|
39
|
+
readonly root: string
|
|
40
|
+
readonly writeWorking: (path: string, content: string) => void
|
|
41
|
+
readonly commit: (path: string, content: string) => void
|
|
42
|
+
readonly provide: <A>(eff: Effect.Effect<A, Error, Workspace>) => Promise<A>
|
|
43
|
+
readonly dispose: () => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const gitExecIn = (dir: string, ...args: string[]): string =>
|
|
47
|
+
execSync(`git ${args.join(" ")}`, { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim()
|
|
48
|
+
|
|
49
|
+
const makeLiveTier = (): WorkspaceTier => {
|
|
50
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-workspace-tier-")))
|
|
51
|
+
gitExecIn(root, "init", "-q")
|
|
52
|
+
gitExecIn(root, "config", "user.email", "test@test.com")
|
|
53
|
+
gitExecIn(root, "config", "user.name", "Test")
|
|
54
|
+
gitExecIn(root, "config", "commit.gpgsign", "false")
|
|
55
|
+
|
|
56
|
+
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
57
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
|
|
58
|
+
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
root,
|
|
62
|
+
writeWorking: (path, content) => {
|
|
63
|
+
mkdirSync(join(root, path, ".."), { recursive: true })
|
|
64
|
+
writeFileSync(join(root, path), content)
|
|
65
|
+
},
|
|
66
|
+
commit: (path, content) => {
|
|
67
|
+
mkdirSync(join(root, path, ".."), { recursive: true })
|
|
68
|
+
writeFileSync(join(root, path), content)
|
|
69
|
+
gitExecIn(root, "add", "-A")
|
|
70
|
+
gitExecIn(root, "commit", "-m", `commit ${path}`)
|
|
71
|
+
},
|
|
72
|
+
provide: (eff) => Effect.runPromise(eff.pipe(Effect.provide(workspaceLayer))),
|
|
73
|
+
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const makeInMemTier = (): WorkspaceTier => {
|
|
78
|
+
const root = "/repo"
|
|
79
|
+
const repo = new InMemRepo()
|
|
80
|
+
const workspaceOps = makeInMemoryWorkspaceOps(repo, root)
|
|
81
|
+
return {
|
|
82
|
+
root,
|
|
83
|
+
writeWorking: (path, content) => repo.writeFile(path, content),
|
|
84
|
+
commit: (path, content) => {
|
|
85
|
+
repo.writeFile(path, content)
|
|
86
|
+
repo.commitAllWithPrefix(`commit ${path}`)
|
|
87
|
+
},
|
|
88
|
+
provide: (eff) =>
|
|
89
|
+
Effect.runPromise(eff.pipe(Effect.provide(Layer.succeed(Workspace, workspaceOps)))),
|
|
90
|
+
dispose: () => {},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `{ name, make }` rather than a bare factory array: `describe`'s title needs a name at COLLECTION time, before any test body runs — calling `make()` just to read a `.name` off the built tier would construct (and, for Live, `git init` a real tmpdir) a tier nothing then disposes. */
|
|
95
|
+
const tiers: ReadonlyArray<{
|
|
96
|
+
readonly name: "Live" | "InMemory"
|
|
97
|
+
readonly make: () => WorkspaceTier
|
|
98
|
+
}> = [
|
|
99
|
+
{ name: "Live", make: makeLiveTier },
|
|
100
|
+
{ name: "InMemory", make: makeInMemTier },
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
for (const { name, make } of tiers) {
|
|
104
|
+
let t: WorkspaceTier
|
|
105
|
+
|
|
106
|
+
describe(`Workspace [${name}]`, () => {
|
|
107
|
+
afterEach(() => t?.dispose())
|
|
108
|
+
|
|
109
|
+
it("readSync returns undefined for a missing repo-relative path", () => {
|
|
110
|
+
t = make()
|
|
111
|
+
const result = t.provide(
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
return (yield* Workspace).readSync("missing.txt")
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
return expect(result).resolves.toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("readSync returns a present working-tree file's content", async () => {
|
|
120
|
+
t = make()
|
|
121
|
+
t.writeWorking("a.txt", "hello\n")
|
|
122
|
+
const result = await t.provide(
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
return (yield* Workspace).readSync("a.txt")
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
expect(result).toBe("hello\n")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("read (the Effect shape) mirrors readSync — undefined for absence, content when present", async () => {
|
|
131
|
+
t = make()
|
|
132
|
+
t.writeWorking("a.txt", "hello\n")
|
|
133
|
+
const [present, absent] = await t.provide(
|
|
134
|
+
Effect.gen(function* () {
|
|
135
|
+
const workspace = yield* Workspace
|
|
136
|
+
return [yield* workspace.read("a.txt"), yield* workspace.read("missing.txt")]
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
expect(present).toBe("hello\n")
|
|
140
|
+
expect(absent).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("a non-ENOENT read failure still fails rather than reading as absent", async () => {
|
|
144
|
+
t = make()
|
|
145
|
+
t.writeWorking("adir/inside.txt", "x")
|
|
146
|
+
const exit = await t
|
|
147
|
+
.provide(
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
return (yield* Workspace).readSync("adir")
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
.then(
|
|
153
|
+
(v) => ({ ok: true, v }) as const,
|
|
154
|
+
(e) => ({ ok: false, e }) as const,
|
|
155
|
+
)
|
|
156
|
+
// The Live tier's `readSync("adir")` hits EISDIR — a genuine fault, not
|
|
157
|
+
// absence. The InMemory tier models no directories, so this assertion
|
|
158
|
+
// only bites for Live; skip it there via the tier name.
|
|
159
|
+
if (name === "Live") {
|
|
160
|
+
expect(exit.ok).toBe(false)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("committed reads a file's contents at a given ref, undefined when absent there", async () => {
|
|
165
|
+
t = make()
|
|
166
|
+
t.commit("a.txt", "committed\n")
|
|
167
|
+
const [atHead, missing] = await t.provide(
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const workspace = yield* Workspace
|
|
170
|
+
return [yield* workspace.committed("a.txt"), yield* workspace.committed("missing.txt")]
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
expect(atHead).toBe("committed\n")
|
|
174
|
+
expect(missing).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("readCommittedSync reads a file's contents at a given ref, undefined when absent there — committed's synchronous twin, for replay", async () => {
|
|
178
|
+
t = make()
|
|
179
|
+
t.commit("a.txt", "committed\n")
|
|
180
|
+
const [atHead, missing] = await t.provide(
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const workspace = yield* Workspace
|
|
183
|
+
return [workspace.readCommittedSync("a.txt"), workspace.readCommittedSync("missing.txt")]
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
expect(atHead).toBe("committed\n")
|
|
187
|
+
expect(missing).toBeUndefined()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("readCommittedSync is undefined for a path that's only in the working tree, never committed — the evidence rule's whole point", async () => {
|
|
191
|
+
t = make()
|
|
192
|
+
t.writeWorking("scratch.txt", "freshly gathered, ungoverned\n")
|
|
193
|
+
const result = await t.provide(
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
return (yield* Workspace).readCommittedSync("scratch.txt")
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
expect(result).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("write then read round-trips a repo-relative path", async () => {
|
|
202
|
+
t = make()
|
|
203
|
+
const content = await t.provide(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const workspace = yield* Workspace
|
|
206
|
+
yield* workspace.write("out.txt", "written\n")
|
|
207
|
+
return yield* workspace.read("out.txt")
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
expect(content).toBe("written\n")
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("readSync rejects an absolute path — repo-relative only, atPath is the deliberate escape", () => {
|
|
214
|
+
t = make()
|
|
215
|
+
const attempt = t.provide(
|
|
216
|
+
Effect.gen(function* () {
|
|
217
|
+
return (yield* Workspace).readSync(join(t.root, "outside-call.txt"))
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
return expect(attempt).rejects.toThrow(/repo-relative paths only/)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("write rejects an absolute path the same way", async () => {
|
|
224
|
+
t = make()
|
|
225
|
+
const attempt = t.provide(
|
|
226
|
+
Effect.gen(function* () {
|
|
227
|
+
const workspace = yield* Workspace
|
|
228
|
+
yield* workspace.write(join(t.root, "outside-write.txt"), "x")
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
await expect(attempt).rejects.toThrow(/repo-relative paths only/)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("atPath reads an absolute path INSIDE root, the same content readSync's relative form sees", async () => {
|
|
235
|
+
t = make()
|
|
236
|
+
t.writeWorking("nested/a.txt", "inside\n")
|
|
237
|
+
const content = await t.provide(
|
|
238
|
+
Effect.gen(function* () {
|
|
239
|
+
return (yield* Workspace).atPath(join(t.root, "nested/a.txt"))
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
expect(content).toBe("inside\n")
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("writeAtPath writes an absolute path, readable back through atPath (gtd uncheck's own shape)", async () => {
|
|
246
|
+
t = make()
|
|
247
|
+
const content = await t.provide(
|
|
248
|
+
Effect.gen(function* () {
|
|
249
|
+
const workspace = yield* Workspace
|
|
250
|
+
yield* workspace.writeAtPath(join(t.root, "written-abs.txt"), "abs\n")
|
|
251
|
+
return workspace.atPath(join(t.root, "written-abs.txt"))
|
|
252
|
+
}),
|
|
253
|
+
)
|
|
254
|
+
expect(content).toBe("abs\n")
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
describe("Workspace [Live] worktreeSync", () => {
|
|
260
|
+
let root: string
|
|
261
|
+
const git = (...args: string[]) => gitExecIn(root, ...args)
|
|
262
|
+
const write = (path: string, content: string) => {
|
|
263
|
+
mkdirSync(join(root, path, ".."), { recursive: true })
|
|
264
|
+
writeFileSync(join(root, path), content)
|
|
265
|
+
}
|
|
266
|
+
const ops = () =>
|
|
267
|
+
Effect.runPromise(
|
|
268
|
+
Effect.gen(function* () {
|
|
269
|
+
return yield* Workspace
|
|
270
|
+
}).pipe(
|
|
271
|
+
Effect.provide(
|
|
272
|
+
Workspace.Live.pipe(
|
|
273
|
+
Layer.provide(
|
|
274
|
+
Layer.merge(
|
|
275
|
+
Host.layer({ root, home: root, env: {} }),
|
|
276
|
+
GitService.Live.pipe(
|
|
277
|
+
Layer.provide(
|
|
278
|
+
Layer.merge(Host.layer({ root, home: root, env: {} }), NodeContext.layer),
|
|
279
|
+
),
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
/** The tree `git add -A` would commit, read back through git itself. */
|
|
288
|
+
const stagedTree = async (): Promise<ReadonlyMap<string, string>> => {
|
|
289
|
+
git("add", "-A")
|
|
290
|
+
const tree = git("write-tree")
|
|
291
|
+
git("reset", "-q")
|
|
292
|
+
return (await ops()).treeSync(tree)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
|
296
|
+
|
|
297
|
+
const seed = () => {
|
|
298
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-worktree-")))
|
|
299
|
+
git("init", "-q")
|
|
300
|
+
git("config", "user.email", "test@test.com")
|
|
301
|
+
git("config", "user.name", "Test")
|
|
302
|
+
git("config", "commit.gpgsign", "false")
|
|
303
|
+
write(".gitattributes", "*.crlf text eol=crlf\n")
|
|
304
|
+
write("notes.crlf", "one\ntwo\n")
|
|
305
|
+
write("big.txt", "x".repeat(2 * 1024 * 1024))
|
|
306
|
+
write("keep.txt", "keep\n")
|
|
307
|
+
write("gone.txt", "gone\n")
|
|
308
|
+
symlinkSync("keep.txt", join(root, "link"))
|
|
309
|
+
const sub = join(root, "sub")
|
|
310
|
+
mkdirSync(sub)
|
|
311
|
+
gitExecIn(sub, "init", "-q")
|
|
312
|
+
writeFileSync(join(sub, "s.txt"), "s\n")
|
|
313
|
+
gitExecIn(sub, "add", "-A")
|
|
314
|
+
gitExecIn(sub, "-c", "user.email=t@t", "-c", "user.name=T", "commit", "-q", "-m", "s")
|
|
315
|
+
git("add", "-A")
|
|
316
|
+
git("commit", "-q", "-m", "init")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it("a clean working tree maps every committed path to HEAD's blob id — eol, symlink, large file and submodule alike", async () => {
|
|
320
|
+
seed()
|
|
321
|
+
rmSync(join(root, "notes.crlf"))
|
|
322
|
+
git("checkout", "--", "notes.crlf")
|
|
323
|
+
const workspace = await ops()
|
|
324
|
+
expect(workspace.worktreeSync()).toEqual(workspace.treeSync("HEAD"))
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("a dirty working tree maps to exactly the tree `git add -A` would commit", async () => {
|
|
328
|
+
seed()
|
|
329
|
+
write("keep.txt", "changed\n")
|
|
330
|
+
write("notes.crlf", "one\ntwo\nthree\n")
|
|
331
|
+
write("new/added.txt", "added\n")
|
|
332
|
+
write("new/line\nbreak.txt", "a newline in its name\n")
|
|
333
|
+
rmSync(join(root, "gone.txt"))
|
|
334
|
+
symlinkSync("big.txt", join(root, "link2"))
|
|
335
|
+
const sub = join(root, "sub")
|
|
336
|
+
writeFileSync(join(sub, "s.txt"), "s2\n")
|
|
337
|
+
gitExecIn(sub, "-c", "user.email=t@t", "-c", "user.name=T", "commit", "-q", "-am", "s2")
|
|
338
|
+
const actual = (await ops()).worktreeSync()
|
|
339
|
+
expect(actual).toEqual(await stagedTree())
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("readCommittedSync reads a committed file larger than execFileSync's default buffer", async () => {
|
|
343
|
+
seed()
|
|
344
|
+
expect((await ops()).readCommittedSync("big.txt")?.length).toBe(2 * 1024 * 1024)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// ── episodeTrees ──────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/** A minimal `Workspace [Live]` fixture, giving direct git access for seeding. */
|
|
351
|
+
const makeLiveEpisodeTier = () => {
|
|
352
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-episode-tier-")))
|
|
353
|
+
// Array-args `execFileSync`, never a shell-joined string: a commit message
|
|
354
|
+
// with spaces (every message below has one) would otherwise be split into
|
|
355
|
+
// extra pathspec arguments by `gitExecIn`'s naive `join(" ")`.
|
|
356
|
+
const git = (...args: string[]): string =>
|
|
357
|
+
childProcess.execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim()
|
|
358
|
+
git("init", "-q")
|
|
359
|
+
git("config", "user.email", "test@test.com")
|
|
360
|
+
git("config", "user.name", "Test")
|
|
361
|
+
git("config", "commit.gpgsign", "false")
|
|
362
|
+
|
|
363
|
+
const hostLayer = Host.layer({ root, home: root, env: {} })
|
|
364
|
+
const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeContext.layer)))
|
|
365
|
+
const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
|
|
366
|
+
const ops = (): Promise<WorkspaceOps> =>
|
|
367
|
+
Effect.runPromise(
|
|
368
|
+
Effect.gen(function* () {
|
|
369
|
+
return yield* Workspace
|
|
370
|
+
}).pipe(Effect.provide(workspaceLayer)),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const write = (path: string, content: string) => {
|
|
374
|
+
mkdirSync(join(root, path, ".."), { recursive: true })
|
|
375
|
+
writeFileSync(join(root, path), content)
|
|
376
|
+
}
|
|
377
|
+
const commit = (message: string): string => {
|
|
378
|
+
git("add", "-A")
|
|
379
|
+
git("commit", "-q", "-m", message)
|
|
380
|
+
return git("rev-parse", "HEAD")
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
root,
|
|
385
|
+
git,
|
|
386
|
+
write,
|
|
387
|
+
commit,
|
|
388
|
+
ops,
|
|
389
|
+
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
describe("Workspace [Live] episodeTrees", () => {
|
|
394
|
+
let t: ReturnType<typeof makeLiveEpisodeTier>
|
|
395
|
+
afterEach(() => t?.dispose())
|
|
396
|
+
|
|
397
|
+
it("reconstructs every commit's tree in an episode exactly like `git ls-tree` itself — mode-only change, deletion, rename, gitlink, and a merge commit on the first-parent path", async () => {
|
|
398
|
+
t = makeLiveEpisodeTier()
|
|
399
|
+
t.write("keep.txt", "keep\n")
|
|
400
|
+
t.write("mode.txt", "mode\n")
|
|
401
|
+
t.write("gone.txt", "gone\n")
|
|
402
|
+
t.write("old-name.txt", "renamed content\n")
|
|
403
|
+
const base = t.commit("base")
|
|
404
|
+
|
|
405
|
+
// c1: a mode-only change (chmod +x, no content change).
|
|
406
|
+
childProcess.execFileSync("chmod", ["+x", join(t.root, "mode.txt")])
|
|
407
|
+
const c1 = t.commit("c1: chmod +x mode.txt")
|
|
408
|
+
|
|
409
|
+
// c2: a deletion.
|
|
410
|
+
rmSync(join(t.root, "gone.txt"))
|
|
411
|
+
const c2 = t.commit("c2: delete gone.txt")
|
|
412
|
+
|
|
413
|
+
// c3: a rename.
|
|
414
|
+
childProcess.execFileSync("git", ["mv", "old-name.txt", "new-name.txt"], { cwd: t.root })
|
|
415
|
+
const c3 = t.commit("c3: rename old-name.txt to new-name.txt")
|
|
416
|
+
|
|
417
|
+
// c4: a gitlink (an embedded repository).
|
|
418
|
+
const sub = join(t.root, "sub")
|
|
419
|
+
mkdirSync(sub)
|
|
420
|
+
gitExecIn(sub, "init", "-q")
|
|
421
|
+
writeFileSync(join(sub, "s.txt"), "s\n")
|
|
422
|
+
gitExecIn(sub, "add", "-A")
|
|
423
|
+
gitExecIn(sub, "-c", "user.email=t@t", "-c", "user.name=T", "commit", "-q", "-m", "s")
|
|
424
|
+
const c4 = t.commit("c4: add submodule")
|
|
425
|
+
|
|
426
|
+
// c5: a merge commit — first-parent diff carries the feature branch's own addition.
|
|
427
|
+
t.git("checkout", "-q", "-b", "feature")
|
|
428
|
+
t.write("feature.txt", "feature\n")
|
|
429
|
+
t.commit("feature: add feature.txt")
|
|
430
|
+
t.git("checkout", "-q", "-")
|
|
431
|
+
t.git("merge", "-q", "--no-ff", "-m", "c5: merge feature", "feature")
|
|
432
|
+
const c5 = t.git("rev-parse", "HEAD")
|
|
433
|
+
|
|
434
|
+
const workspace = await t.ops()
|
|
435
|
+
const commits = [c1, c2, c3, c4, c5]
|
|
436
|
+
const result = await Effect.runPromise(workspace.episodeTrees(base, commits))
|
|
437
|
+
|
|
438
|
+
expect(result.get(base)).toEqual(workspace.treeSync(base))
|
|
439
|
+
for (const hash of commits) {
|
|
440
|
+
expect(result.get(hash), `mismatch at ${hash}`).toEqual(workspace.treeSync(hash))
|
|
441
|
+
}
|
|
442
|
+
// Sanity: the reconstruction actually exercised every kind of change,
|
|
443
|
+
// not just carried the base tree forward unchanged.
|
|
444
|
+
expect(result.get(c2)!.has("gone.txt")).toBe(false)
|
|
445
|
+
expect(result.get(c3)!.has("old-name.txt")).toBe(false)
|
|
446
|
+
expect(result.get(c3)!.has("new-name.txt")).toBe(true)
|
|
447
|
+
expect(result.get(c4)!.get("sub")).toBeDefined()
|
|
448
|
+
expect(result.get(c5)!.has("feature.txt")).toBe(true)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it("works for an episode starting at the repository's root commit, with the empty tree as its base", async () => {
|
|
452
|
+
t = makeLiveEpisodeTier()
|
|
453
|
+
t.write("a.txt", "a\n")
|
|
454
|
+
const c1 = t.commit("c1: root commit")
|
|
455
|
+
t.write("b.txt", "b\n")
|
|
456
|
+
const c2 = t.commit("c2: second commit")
|
|
457
|
+
|
|
458
|
+
const workspace = await t.ops()
|
|
459
|
+
const result = await Effect.runPromise(workspace.episodeTrees(undefined, [c1, c2]))
|
|
460
|
+
expect(result.get(c1)).toEqual(workspace.treeSync(c1))
|
|
461
|
+
expect(result.get(c2)).toEqual(workspace.treeSync(c2))
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it("issues exactly two git subprocesses for a cold-cache episode — one ls-tree, one log --raw — regardless of episode length", async () => {
|
|
465
|
+
t = makeLiveEpisodeTier()
|
|
466
|
+
t.write("a.txt", "a\n")
|
|
467
|
+
const base = t.commit("base")
|
|
468
|
+
const commits: string[] = []
|
|
469
|
+
for (let i = 0; i < 8; i++) {
|
|
470
|
+
t.write(`file-${i}.txt`, `${i}\n`)
|
|
471
|
+
commits.push(t.commit(`c${i}`))
|
|
472
|
+
}
|
|
473
|
+
const workspace = await t.ops()
|
|
474
|
+
execFileSyncSpy.mockClear()
|
|
475
|
+
const result = await Effect.runPromise(workspace.episodeTrees(base, commits))
|
|
476
|
+
expect(result.size).toBe(commits.length + 1)
|
|
477
|
+
const gitCalls = execFileSyncSpy.mock.calls.filter(([cmd]) => cmd === "git")
|
|
478
|
+
const lsTreeCalls = gitCalls.filter(([, args]) => (args as string[]).includes("ls-tree"))
|
|
479
|
+
const logCalls = gitCalls.filter(([, args]) => (args as string[])[0] === "log")
|
|
480
|
+
expect(lsTreeCalls.length).toBe(1)
|
|
481
|
+
expect(logCalls.length).toBe(1)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it("reads full, unabbreviated ids off the raw diff — an abbreviated id would misread as a modification against ls-tree's own full one on every path", async () => {
|
|
485
|
+
t = makeLiveEpisodeTier()
|
|
486
|
+
t.write("a.txt", "a\n")
|
|
487
|
+
const base = t.commit("base")
|
|
488
|
+
t.write("b.txt", "b\n")
|
|
489
|
+
const c1 = t.commit("c1: add b.txt")
|
|
490
|
+
const workspace = await t.ops()
|
|
491
|
+
const result = await Effect.runPromise(workspace.episodeTrees(base, [c1]))
|
|
492
|
+
const expectedLength = workspace.treeSync(c1).get("a.txt")!.length
|
|
493
|
+
expect(expectedLength).toBeGreaterThanOrEqual(40)
|
|
494
|
+
// `a.txt` is untouched by c1 — its id must equal the base's byte-for-byte
|
|
495
|
+
// AND be full-length, never an abbreviated prefix `git log --raw`
|
|
496
|
+
// defaults to without `--no-abbrev`.
|
|
497
|
+
expect(result.get(c1)!.get("a.txt")).toBe(workspace.treeSync(base).get("a.txt"))
|
|
498
|
+
expect(result.get(c1)!.get("a.txt")!.length).toBe(expectedLength)
|
|
499
|
+
expect(result.get(c1)!.get("b.txt")!.length).toBe(expectedLength)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it("issues NO subprocess at all once every requested hash is already cached", async () => {
|
|
503
|
+
t = makeLiveEpisodeTier()
|
|
504
|
+
t.write("a.txt", "a\n")
|
|
505
|
+
const base = t.commit("base")
|
|
506
|
+
t.write("b.txt", "b\n")
|
|
507
|
+
const c1 = t.commit("c1")
|
|
508
|
+
const workspace = await t.ops()
|
|
509
|
+
await Effect.runPromise(workspace.episodeTrees(base, [c1])) // warms the cache
|
|
510
|
+
execFileSyncSpy.mockClear()
|
|
511
|
+
const result = await Effect.runPromise(workspace.episodeTrees(base, [c1]))
|
|
512
|
+
expect(result.get(c1)).toEqual(workspace.treeSync(c1))
|
|
513
|
+
expect(execFileSyncSpy.mock.calls.filter(([cmd]) => cmd === "git")).toEqual([])
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("caches on disk under the git COMMON directory (`gtd-cache/`), surviving a brand-new Workspace instance", async () => {
|
|
517
|
+
t = makeLiveEpisodeTier()
|
|
518
|
+
t.write("a.txt", "a\n")
|
|
519
|
+
const base = t.commit("base")
|
|
520
|
+
t.write("b.txt", "b\n")
|
|
521
|
+
const c1 = t.commit("c1")
|
|
522
|
+
const first = await t.ops()
|
|
523
|
+
await Effect.runPromise(first.episodeTrees(base, [c1]))
|
|
524
|
+
const cacheFile = join(t.root, ".git", "gtd-cache", "trees", c1)
|
|
525
|
+
expect(readFileSync(cacheFile, "utf8").length).toBeGreaterThan(0)
|
|
526
|
+
|
|
527
|
+
// A second, independent Workspace instance (a fresh process would build
|
|
528
|
+
// one just like this) reads the same commit with zero subprocesses.
|
|
529
|
+
const second = await t.ops()
|
|
530
|
+
execFileSyncSpy.mockClear()
|
|
531
|
+
const result = await Effect.runPromise(second.episodeTrees(base, [c1]))
|
|
532
|
+
expect(result.get(c1)).toEqual(
|
|
533
|
+
new Map(Object.entries(JSON.parse(readFileSync(cacheFile, "utf8")))),
|
|
534
|
+
)
|
|
535
|
+
expect(execFileSyncSpy.mock.calls.filter(([cmd]) => cmd === "git")).toEqual([])
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it("a corrupted cache file is treated as a miss, falling back to git rather than surfacing an error", async () => {
|
|
539
|
+
t = makeLiveEpisodeTier()
|
|
540
|
+
t.write("a.txt", "a\n")
|
|
541
|
+
const base = t.commit("base")
|
|
542
|
+
t.write("b.txt", "b\n")
|
|
543
|
+
const c1 = t.commit("c1")
|
|
544
|
+
const workspace = await t.ops()
|
|
545
|
+
await Effect.runPromise(workspace.episodeTrees(base, [c1]))
|
|
546
|
+
const cacheFile = join(t.root, ".git", "gtd-cache", "trees", c1)
|
|
547
|
+
writeFileSync(cacheFile, "{ not json")
|
|
548
|
+
|
|
549
|
+
const fresh = await t.ops()
|
|
550
|
+
const result = await Effect.runPromise(fresh.episodeTrees(base, [c1]))
|
|
551
|
+
expect(result.get(c1)).toEqual(
|
|
552
|
+
new Map([
|
|
553
|
+
["a.txt", fresh.treeSync(base).get("a.txt")!],
|
|
554
|
+
["b.txt", fresh.treeSync(c1).get("b.txt")!],
|
|
555
|
+
]),
|
|
556
|
+
)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it("deleting the cache directory mid-run is safe: the next read falls back to git", async () => {
|
|
560
|
+
t = makeLiveEpisodeTier()
|
|
561
|
+
t.write("a.txt", "a\n")
|
|
562
|
+
const base = t.commit("base")
|
|
563
|
+
t.write("b.txt", "b\n")
|
|
564
|
+
const c1 = t.commit("c1")
|
|
565
|
+
const workspace = await t.ops()
|
|
566
|
+
await Effect.runPromise(workspace.episodeTrees(base, [c1]))
|
|
567
|
+
rmSync(join(t.root, ".git", "gtd-cache"), { recursive: true, force: true })
|
|
568
|
+
|
|
569
|
+
const fresh = await t.ops()
|
|
570
|
+
const result = await Effect.runPromise(fresh.episodeTrees(base, [c1]))
|
|
571
|
+
expect(result.get(c1)).toEqual(fresh.treeSync(c1))
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it("two processes writing the same key concurrently leave a readable file and neither fails", async () => {
|
|
575
|
+
t = makeLiveEpisodeTier()
|
|
576
|
+
t.write("a.txt", "a\n")
|
|
577
|
+
const base = t.commit("base")
|
|
578
|
+
t.write("b.txt", "b\n")
|
|
579
|
+
const c1 = t.commit("c1")
|
|
580
|
+
const [a, b] = await Promise.all([t.ops(), t.ops()])
|
|
581
|
+
const [resultA, resultB] = await Promise.all([
|
|
582
|
+
Effect.runPromise(a.episodeTrees(base, [c1])),
|
|
583
|
+
Effect.runPromise(b.episodeTrees(base, [c1])),
|
|
584
|
+
])
|
|
585
|
+
expect(resultA.get(c1)).toEqual(resultB.get(c1))
|
|
586
|
+
const cacheFile = join(t.root, ".git", "gtd-cache", "trees", c1)
|
|
587
|
+
expect(() => JSON.parse(readFileSync(cacheFile, "utf8"))).not.toThrow()
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it("a transient `ls-tree` failure on the base is never cached — the episode fails loudly instead of poisoning every commit's tree with a wrong empty base", async () => {
|
|
591
|
+
t = makeLiveEpisodeTier()
|
|
592
|
+
t.write("a.txt", "a\n")
|
|
593
|
+
const base = t.commit("base")
|
|
594
|
+
t.write("b.txt", "b\n")
|
|
595
|
+
const c1 = t.commit("c1")
|
|
596
|
+
const workspace = await t.ops()
|
|
597
|
+
|
|
598
|
+
execFileSyncSpy.mockImplementationOnce(() => {
|
|
599
|
+
throw new Error("simulated transient ls-tree failure (EAGAIN/ENOMEM/signal)")
|
|
600
|
+
})
|
|
601
|
+
await expect(Effect.runPromise(workspace.episodeTrees(base, [c1]))).rejects.toThrow()
|
|
602
|
+
|
|
603
|
+
const baseCacheFile = join(t.root, ".git", "gtd-cache", "trees", base)
|
|
604
|
+
const c1CacheFile = join(t.root, ".git", "gtd-cache", "trees", c1)
|
|
605
|
+
expect(() => readFileSync(baseCacheFile, "utf8")).toThrow()
|
|
606
|
+
expect(() => readFileSync(c1CacheFile, "utf8")).toThrow()
|
|
607
|
+
|
|
608
|
+
// A clean retry succeeds and caches the correct (non-empty) trees.
|
|
609
|
+
const result = await Effect.runPromise(workspace.episodeTrees(base, [c1]))
|
|
610
|
+
expect(result.get(base)!.get("a.txt")).toBeDefined()
|
|
611
|
+
expect(result.get(c1)!.get("a.txt")).toBeDefined()
|
|
612
|
+
expect(result.get(c1)!.get("b.txt")).toBeDefined()
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it("`treeSync`'s own transient `ls-tree` failure is never cached, unlike a git-confirmed empty tree", async () => {
|
|
616
|
+
t = makeLiveEpisodeTier()
|
|
617
|
+
t.write("a.txt", "a\n")
|
|
618
|
+
const base = t.commit("base")
|
|
619
|
+
const workspace = await t.ops()
|
|
620
|
+
const cacheFile = join(t.root, ".git", "gtd-cache", "trees", base)
|
|
621
|
+
|
|
622
|
+
execFileSyncSpy.mockImplementationOnce(() => {
|
|
623
|
+
throw new Error("simulated transient ls-tree failure")
|
|
624
|
+
})
|
|
625
|
+
expect(workspace.treeSync(base).size).toBe(0)
|
|
626
|
+
expect(() => readFileSync(cacheFile, "utf8")).toThrow()
|
|
627
|
+
|
|
628
|
+
// The next (unmocked) call actually asks git, succeeds, and THIS result
|
|
629
|
+
// — git-confirmed, not folded-from-a-failure — is what gets cached.
|
|
630
|
+
expect(workspace.treeSync(base).get("a.txt")).toBeDefined()
|
|
631
|
+
expect(readFileSync(cacheFile, "utf8").length).toBeGreaterThan(0)
|
|
632
|
+
})
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
describe("Workspace [Live] blob-id read cache", () => {
|
|
636
|
+
let t: ReturnType<typeof makeLiveEpisodeTier>
|
|
637
|
+
afterEach(() => t?.dispose())
|
|
638
|
+
|
|
639
|
+
it("reads the same content across two commits once — commitTree-style access via readCommittedSync never spawns git show twice for the same blob id", async () => {
|
|
640
|
+
t = makeLiveEpisodeTier()
|
|
641
|
+
t.write("shared.txt", "unchanged across both commits\n")
|
|
642
|
+
const c1 = t.commit("c1")
|
|
643
|
+
t.write("other.txt", "irrelevant change\n")
|
|
644
|
+
const c2 = t.commit("c2")
|
|
645
|
+
const workspace = await t.ops()
|
|
646
|
+
|
|
647
|
+
const first = workspace.readCommittedSync("shared.txt", c1)
|
|
648
|
+
execFileSyncSpy.mockClear()
|
|
649
|
+
const second = workspace.readCommittedSync("shared.txt", c2)
|
|
650
|
+
expect(second).toBe(first)
|
|
651
|
+
// The tree for c2 must be read (a new commit), but no second blob read
|
|
652
|
+
// for the identical content `shared.txt` carries at both commits.
|
|
653
|
+
const catFileCalls = execFileSyncSpy.mock.calls.filter(
|
|
654
|
+
([cmd, args]) => cmd === "git" && (args as string[])[0] === "cat-file",
|
|
655
|
+
)
|
|
656
|
+
expect(catFileCalls.length).toBe(0)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it("caches blob content on disk, keyed by blob id, under gtd-cache/blobs/", async () => {
|
|
660
|
+
t = makeLiveEpisodeTier()
|
|
661
|
+
t.write("shared.txt", "cached content\n")
|
|
662
|
+
const c1 = t.commit("c1")
|
|
663
|
+
const workspace = await t.ops()
|
|
664
|
+
const id = workspace.treeSync(c1).get("shared.txt")!
|
|
665
|
+
workspace.readCommittedSync("shared.txt", c1)
|
|
666
|
+
const cacheFile = join(t.root, ".git", "gtd-cache", "blobs", id)
|
|
667
|
+
expect(readFileSync(cacheFile, "utf8")).toBe("cached content\n")
|
|
668
|
+
})
|
|
669
|
+
})
|