@pmelab/gtd 24.0.0 → 24.2.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 +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- 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 +43339 -55575
- 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 +8 -6
- package/package.json +69 -8
- 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/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/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtempSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
rmSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
} from "node:fs"
|
|
10
|
+
import { join, dirname, basename } from "node:path"
|
|
11
|
+
import { tmpdir } from "node:os"
|
|
12
|
+
import { execSync, execFileSync } from "node:child_process"
|
|
13
|
+
// Imported directly (not just from *.test.ts) — a leak of this module into
|
|
14
|
+
// the shipped CLI bundle would try to inline `vitest` and fail the build
|
|
15
|
+
// loudly (`deps.alwaysBundle` in `tsdown.config.ts`), a guard alongside the
|
|
16
|
+
// `TEST_DOUBLE_SENTINEL` check.
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
18
|
+
import { Effect, Exit, Option } from "effect"
|
|
19
|
+
import { NodeContext } from "@effect/platform-node"
|
|
20
|
+
import { Narrator, GitService, Host, type GitOperations } from "../platform/index.js"
|
|
21
|
+
import {
|
|
22
|
+
commitAll,
|
|
23
|
+
commitAsIs,
|
|
24
|
+
softResetTo,
|
|
25
|
+
mixedResetTo,
|
|
26
|
+
hardResetTo,
|
|
27
|
+
discardPending,
|
|
28
|
+
updateRef,
|
|
29
|
+
deleteRef,
|
|
30
|
+
} from "../emit/index.js"
|
|
31
|
+
import { InMemRepo } from "./InMemRepo.js"
|
|
32
|
+
import { gitTestLayer } from "./Layers.js"
|
|
33
|
+
|
|
34
|
+
export interface GitTierCapabilities {
|
|
35
|
+
/** Assert file BYTES on disk, not just index/HEAD state (`hardResetTo`) — only the Live tier has a real working-tree file to read back. */
|
|
36
|
+
readonly onDiskContent: boolean
|
|
37
|
+
/** Install a real `.git/hooks/pre-commit` and prove the `--no-verify` retry — only the Live tier has a hooks directory. */
|
|
38
|
+
readonly commitHooks: boolean
|
|
39
|
+
/** `git worktree add` — sibling linked worktrees sharing one `.git`; the fake models a single worktree only. */
|
|
40
|
+
readonly linkedWorktrees: boolean
|
|
41
|
+
/** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
|
|
42
|
+
readonly directoryChainConfig: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GitTierSeed {
|
|
46
|
+
readonly commit: (message: string, files?: Record<string, string>) => void
|
|
47
|
+
readonly writeFile: (path: string, content: string) => void
|
|
48
|
+
readonly deleteFile: (path: string) => void
|
|
49
|
+
readonly commitDeletion: (path: string, message: string) => void
|
|
50
|
+
readonly stageAll: () => void
|
|
51
|
+
readonly updateRef: (ref: string, hash: string) => void
|
|
52
|
+
readonly mixedReset: (ref: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GitTierObserve {
|
|
56
|
+
readonly resolveRef: (ref: string) => string
|
|
57
|
+
readonly statusPorcelain: () => string
|
|
58
|
+
readonly refExists: (ref: string) => boolean
|
|
59
|
+
readonly readWorktreeFile: (path: string) => string
|
|
60
|
+
readonly existsPath: (path: string) => boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GitTier {
|
|
64
|
+
readonly name: "Live" | "InMemory"
|
|
65
|
+
readonly root: string
|
|
66
|
+
readonly capabilities: GitTierCapabilities
|
|
67
|
+
/** Provide `GitService` (retry-wrapped, exactly as production wires it) + a no-op `Narrator`. */
|
|
68
|
+
readonly provide: <A>(eff: Effect.Effect<A, Error, GitService | Narrator>) => Promise<A>
|
|
69
|
+
readonly provideExit: <A>(
|
|
70
|
+
eff: Effect.Effect<A, Error, GitService | Narrator>,
|
|
71
|
+
) => Promise<Exit.Exit<A, Error>>
|
|
72
|
+
/** A second, commit-less repo of the same tier — for the empty-repo edge cases (`commitHistory`, `hasCommits`). */
|
|
73
|
+
readonly emptyRepo: () => GitTier
|
|
74
|
+
readonly seed: GitTierSeed
|
|
75
|
+
readonly observe: GitTierObserve
|
|
76
|
+
readonly dispose: () => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** No-op — these tests assert on git/config behavior, not narration. */
|
|
80
|
+
const noopNarratorLayer = Narrator.layer(() => {}, false)
|
|
81
|
+
|
|
82
|
+
const gitExecIn = (dir: string, ...args: string[]): string =>
|
|
83
|
+
execSync(`git ${args.join(" ")}`, { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim()
|
|
84
|
+
|
|
85
|
+
const makeLiveTier = (initialCommit = true): GitTier => {
|
|
86
|
+
// realpath'd once here so `t.root` matches `git rev-parse --show-toplevel`'s
|
|
87
|
+
// OWN symlink resolution byte-for-byte (macOS's tmpdir is under `/tmp`, a
|
|
88
|
+
// symlink to `/private/tmp` — `git` reports the resolved path).
|
|
89
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")))
|
|
90
|
+
const gitExec = (...args: string[]) => gitExecIn(root, ...args)
|
|
91
|
+
gitExec("init")
|
|
92
|
+
gitExec(`config user.email "test@test.com"`)
|
|
93
|
+
gitExec(`config user.name "Test"`)
|
|
94
|
+
gitExec("config", "commit.gpgsign", "false")
|
|
95
|
+
if (initialCommit) {
|
|
96
|
+
writeFileSync(join(root, "readme.txt"), "hello")
|
|
97
|
+
gitExec("add", "-A")
|
|
98
|
+
gitExec(`commit -m "init: first commit"`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
|
|
102
|
+
Effect.runPromise(
|
|
103
|
+
eff.pipe(
|
|
104
|
+
Effect.provide(GitService.Live),
|
|
105
|
+
Effect.provide(Host.layer({ root, home: root, env: {} })),
|
|
106
|
+
Effect.provide(NodeContext.layer),
|
|
107
|
+
Effect.provide(noopNarratorLayer),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const provideExit = <A>(
|
|
112
|
+
eff: Effect.Effect<A, Error, GitService | Narrator>,
|
|
113
|
+
): Promise<Exit.Exit<A, Error>> =>
|
|
114
|
+
Effect.runPromiseExit(
|
|
115
|
+
eff.pipe(
|
|
116
|
+
Effect.provide(GitService.Live),
|
|
117
|
+
Effect.provide(Host.layer({ root, home: root, env: {} })),
|
|
118
|
+
Effect.provide(NodeContext.layer),
|
|
119
|
+
Effect.provide(noopNarratorLayer),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name: "Live",
|
|
125
|
+
root,
|
|
126
|
+
capabilities: {
|
|
127
|
+
onDiskContent: true,
|
|
128
|
+
commitHooks: true,
|
|
129
|
+
linkedWorktrees: true,
|
|
130
|
+
directoryChainConfig: true,
|
|
131
|
+
},
|
|
132
|
+
provide,
|
|
133
|
+
provideExit,
|
|
134
|
+
emptyRepo: () => makeLiveTier(false),
|
|
135
|
+
seed: {
|
|
136
|
+
commit: (message, files = { "file.txt": message }) => {
|
|
137
|
+
for (const [path, content] of Object.entries(files)) {
|
|
138
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
139
|
+
writeFileSync(join(root, path), content)
|
|
140
|
+
}
|
|
141
|
+
gitExec("add", "-A")
|
|
142
|
+
// --allow-empty: a caller passing `files: {}` (no prior staged
|
|
143
|
+
// change either) means a deliberate empty commit — the state-entry
|
|
144
|
+
// shape many test seeders rely on.
|
|
145
|
+
gitExec(`commit --allow-empty -m "${message}"`)
|
|
146
|
+
},
|
|
147
|
+
writeFile: (path, content) => {
|
|
148
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
149
|
+
writeFileSync(join(root, path), content)
|
|
150
|
+
},
|
|
151
|
+
// A plain unlink, NOT `git rm` — the index is left exactly as it stood,
|
|
152
|
+
// the only way to model a reviewer deleting an untracked file (`git rm`
|
|
153
|
+
// refuses an untracked pathspec). Callers that want the deletion staged
|
|
154
|
+
// say `stageAll()`.
|
|
155
|
+
deleteFile: (path) => rmSync(join(root, path), { force: true }),
|
|
156
|
+
commitDeletion: (path, message) => {
|
|
157
|
+
gitExec("rm", path)
|
|
158
|
+
gitExec(`commit -m "${message}"`)
|
|
159
|
+
},
|
|
160
|
+
stageAll: () => gitExec("add", "-A"),
|
|
161
|
+
updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
|
|
162
|
+
mixedReset: (ref) => gitExec("reset", "--mixed", ref),
|
|
163
|
+
},
|
|
164
|
+
observe: {
|
|
165
|
+
resolveRef: (ref) => gitExec("rev-parse", ref),
|
|
166
|
+
statusPorcelain: () => gitExec("status", "--porcelain", "-uall"),
|
|
167
|
+
refExists: (ref) => {
|
|
168
|
+
try {
|
|
169
|
+
gitExec("rev-parse", "--verify", "--quiet", ref)
|
|
170
|
+
return true
|
|
171
|
+
} catch {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
|
|
176
|
+
existsPath: (path) => existsSync(join(root, path)),
|
|
177
|
+
},
|
|
178
|
+
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const IN_MEM_ROOT = "/repo"
|
|
183
|
+
|
|
184
|
+
const makeInMemTier = (initialCommit = true): GitTier => {
|
|
185
|
+
const repo = new InMemRepo()
|
|
186
|
+
if (initialCommit) {
|
|
187
|
+
repo.writeFile("readme.txt", "hello")
|
|
188
|
+
repo.commitAllWithPrefix("init: first commit")
|
|
189
|
+
}
|
|
190
|
+
const gitLayer = gitTestLayer(repo, IN_MEM_ROOT)
|
|
191
|
+
|
|
192
|
+
const provide = <A>(eff: Effect.Effect<A, Error, GitService | Narrator>): Promise<A> =>
|
|
193
|
+
Effect.runPromise(eff.pipe(Effect.provide(gitLayer), Effect.provide(noopNarratorLayer)))
|
|
194
|
+
|
|
195
|
+
const provideExit = <A>(
|
|
196
|
+
eff: Effect.Effect<A, Error, GitService | Narrator>,
|
|
197
|
+
): Promise<Exit.Exit<A, Error>> =>
|
|
198
|
+
Effect.runPromiseExit(eff.pipe(Effect.provide(gitLayer), Effect.provide(noopNarratorLayer)))
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
name: "InMemory",
|
|
202
|
+
root: IN_MEM_ROOT,
|
|
203
|
+
capabilities: {
|
|
204
|
+
onDiskContent: false,
|
|
205
|
+
commitHooks: false,
|
|
206
|
+
linkedWorktrees: false,
|
|
207
|
+
directoryChainConfig: true,
|
|
208
|
+
},
|
|
209
|
+
provide,
|
|
210
|
+
provideExit,
|
|
211
|
+
emptyRepo: () => makeInMemTier(false),
|
|
212
|
+
seed: {
|
|
213
|
+
commit: (message, files = { "file.txt": message }) => {
|
|
214
|
+
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
215
|
+
repo.commitAllWithPrefix(message)
|
|
216
|
+
},
|
|
217
|
+
writeFile: (path, content) => repo.writeFile(path, content),
|
|
218
|
+
deleteFile: (path) => repo.deleteFile(path),
|
|
219
|
+
commitDeletion: (path, message) => {
|
|
220
|
+
repo.deleteFile(path)
|
|
221
|
+
repo.commitAllWithPrefix(message)
|
|
222
|
+
},
|
|
223
|
+
stageAll: () => repo.stageAll(),
|
|
224
|
+
updateRef: (ref, hash) => repo.updateRef(ref, hash),
|
|
225
|
+
mixedReset: (ref) => repo.mixedResetTo(ref),
|
|
226
|
+
},
|
|
227
|
+
observe: {
|
|
228
|
+
resolveRef: (ref) => {
|
|
229
|
+
const hash = repo.resolveRef(ref)
|
|
230
|
+
if (hash === null) throw new Error(`Cannot resolve ref: ${ref}`)
|
|
231
|
+
return hash
|
|
232
|
+
},
|
|
233
|
+
statusPorcelain: () => repo.statusPorcelain(),
|
|
234
|
+
refExists: (ref) => repo.resolveRef(ref) !== null,
|
|
235
|
+
readWorktreeFile: (path) => {
|
|
236
|
+
const content = repo.readFile(path)
|
|
237
|
+
if (content === undefined)
|
|
238
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`)
|
|
239
|
+
return content
|
|
240
|
+
},
|
|
241
|
+
existsPath: (path) => repo.hasPath(path),
|
|
242
|
+
},
|
|
243
|
+
dispose: () => {},
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Both tiers, freshly constructed per call — `beforeEach(() => { t = makeTier() })` in every consuming test file. */
|
|
248
|
+
export const gitTiers: ReadonlyArray<() => GitTier> = [makeLiveTier, makeInMemTier]
|
|
249
|
+
|
|
250
|
+
const runGit = <A>(t: GitTier, f: (g: GitOperations) => Effect.Effect<A, Error>): Promise<A> =>
|
|
251
|
+
t.provide(Effect.flatMap(GitService, f))
|
|
252
|
+
|
|
253
|
+
const runGitExit = <A>(
|
|
254
|
+
t: GitTier,
|
|
255
|
+
f: (g: GitOperations) => Effect.Effect<A, Error>,
|
|
256
|
+
): Promise<Exit.Exit<A, Error>> => t.provideExit(Effect.flatMap(GitService, f))
|
|
257
|
+
|
|
258
|
+
/** Every `GitOperations` method the contract below exercises — asserted (in `GitTiers.test.ts`) to equal `fakeGitOperations`'s own key set, so a new port method reds until it is listed here. Listing it is not writing the contract case: this fires on a forgotten method, never on an unexercised one. */
|
|
259
|
+
export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new Set([
|
|
260
|
+
"lastCommitSubject",
|
|
261
|
+
"lastCommitMessage",
|
|
262
|
+
"hasCommits",
|
|
263
|
+
"resolveRef",
|
|
264
|
+
"readRefOption",
|
|
265
|
+
"isAncestor",
|
|
266
|
+
"mergeBase",
|
|
267
|
+
"defaultBranch",
|
|
268
|
+
"topLevel",
|
|
269
|
+
"gitDir",
|
|
270
|
+
"gitCommonDir",
|
|
271
|
+
"commitHistory",
|
|
272
|
+
"subjectHistory",
|
|
273
|
+
"readFileAtRef",
|
|
274
|
+
"changedPaths",
|
|
275
|
+
"commitAllWithPrefix",
|
|
276
|
+
"softResetTo",
|
|
277
|
+
"commitAsIs",
|
|
278
|
+
"discardPending",
|
|
279
|
+
"updateRef",
|
|
280
|
+
"deleteRef",
|
|
281
|
+
"mixedResetTo",
|
|
282
|
+
"hardResetTo",
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Filenames real `git diff --name-status` would mangle (or misparse) without
|
|
287
|
+
* `-z`: an embedded newline splits a `\n`-joined parser into two bogus
|
|
288
|
+
* entries, a double quote and non-ASCII byte trigger git's C-quoting of the
|
|
289
|
+
* path. `InMemRepo` needs none of this — it returns paths verbatim as plain
|
|
290
|
+
* JS strings — which is exactly why running these through both tiers is the
|
|
291
|
+
* point: only the Live tier's parser can regress here.
|
|
292
|
+
*/
|
|
293
|
+
const PATHOLOGICAL_PATHS: ReadonlyArray<{ label: string; path: string }> = [
|
|
294
|
+
{ label: "an embedded newline", path: "weird\nname.txt" },
|
|
295
|
+
{ label: "a double quote", path: 'weird"name.txt' },
|
|
296
|
+
{ label: "a non-ASCII UTF-8 byte", path: "café.txt" },
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Exercise all 21 `GitOperations` methods identically against `makeTier()` —
|
|
301
|
+
* called once per tier by `src/platform/Git.test.ts`. A capability-gated group
|
|
302
|
+
* (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
|
|
303
|
+
*/
|
|
304
|
+
// fallow-ignore-next-line complexity
|
|
305
|
+
export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
306
|
+
// Capabilities are static per tier type — probed once so `it.skipIf(...)`
|
|
307
|
+
// predicates below can read them at DESCRIBE time (`t` itself isn't
|
|
308
|
+
// assigned until the first `beforeEach` runs, which is too late for a
|
|
309
|
+
// skipIf predicate evaluated while the suite is still being built).
|
|
310
|
+
const probe = makeTier()
|
|
311
|
+
const capabilities = probe.capabilities
|
|
312
|
+
probe.dispose()
|
|
313
|
+
|
|
314
|
+
let t: GitTier
|
|
315
|
+
|
|
316
|
+
beforeEach(() => {
|
|
317
|
+
t = makeTier()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
t.dispose()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
describe("resolveRef", () => {
|
|
325
|
+
it("resolves HEAD to a 40-char hash", async () => {
|
|
326
|
+
const hash = await runGit(t, (g) => g.resolveRef("HEAD"))
|
|
327
|
+
expect(hash).toMatch(/^[0-9a-f]{40}$/)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("resolves HEAD~1 when two commits exist", async () => {
|
|
331
|
+
t.seed.commit("feat: second commit", { "extra.txt": "extra" })
|
|
332
|
+
const headHash = t.observe.resolveRef("HEAD~1")
|
|
333
|
+
const resolved = await runGit(t, (g) => g.resolveRef("HEAD~1"))
|
|
334
|
+
expect(resolved).toBe(headHash)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it("errors on invalid ref", async () => {
|
|
338
|
+
const result = await runGitExit(t, (g) => g.resolveRef("totally-invalid-ref-xyz"))
|
|
339
|
+
expect(Exit.isFailure(result)).toBe(true)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe("lastCommitSubject", () => {
|
|
344
|
+
it("returns HEAD's subject with no ref argument", async () => {
|
|
345
|
+
const subject = await runGit(t, (g) => g.lastCommitSubject())
|
|
346
|
+
expect(subject).toBe("init: first commit")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("returns the given ref's subject, not HEAD's", async () => {
|
|
350
|
+
t.seed.commit("feat: second commit", { "extra.txt": "extra" })
|
|
351
|
+
const subject = await runGit(t, (g) => g.lastCommitSubject("HEAD~1"))
|
|
352
|
+
expect(subject).toBe("init: first commit")
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe("lastCommitMessage", () => {
|
|
357
|
+
it("returns the full commit message (subject + body) of HEAD", async () => {
|
|
358
|
+
t.seed.commit("feat: add thing\n\nA body explaining why.")
|
|
359
|
+
const message = await runGit(t, (g) => g.lastCommitMessage())
|
|
360
|
+
expect(message).toBe("feat: add thing\n\nA body explaining why.")
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it("returns just the subject when HEAD carries no body", async () => {
|
|
364
|
+
t.seed.commit("feat: subject only")
|
|
365
|
+
const message = await runGit(t, (g) => g.lastCommitMessage())
|
|
366
|
+
expect(message).toBe("feat: subject only")
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe("hasCommits", () => {
|
|
371
|
+
it("returns false in an empty repo", async () => {
|
|
372
|
+
const empty = t.emptyRepo()
|
|
373
|
+
try {
|
|
374
|
+
expect(await runGit(empty, (g) => g.hasCommits())).toBe(false)
|
|
375
|
+
} finally {
|
|
376
|
+
empty.dispose()
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it("returns true once a commit exists", async () => {
|
|
381
|
+
expect(await runGit(t, (g) => g.hasCommits())).toBe(true)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
describe("commitHistory", () => {
|
|
386
|
+
it("returns [] for an empty repo", async () => {
|
|
387
|
+
const empty = t.emptyRepo()
|
|
388
|
+
try {
|
|
389
|
+
expect(await runGit(empty, (g) => g.commitHistory())).toEqual([])
|
|
390
|
+
} finally {
|
|
391
|
+
empty.dispose()
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("returns all commits oldest to newest with their messages", async () => {
|
|
396
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
397
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
398
|
+
const result = await runGit(t, (g) => g.commitHistory())
|
|
399
|
+
expect(result.length).toBe(3)
|
|
400
|
+
expect(result[0]?.message).toBe("init: first commit")
|
|
401
|
+
expect(result[2]?.message).toBe("feat: third")
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it("limits to base..HEAD range when base is provided", async () => {
|
|
405
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
406
|
+
const base = t.observe.resolveRef("HEAD")
|
|
407
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
408
|
+
const result = await runGit(t, (g) => g.commitHistory(base))
|
|
409
|
+
expect(result.length).toBe(1)
|
|
410
|
+
expect(result[0]?.message).toBe("feat: third")
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it("resolves a `<head>~1` base — never just an already-resolved hash — to exactly the head commit", async () => {
|
|
414
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
415
|
+
const head = t.observe.resolveRef("HEAD")
|
|
416
|
+
const result = await runGit(t, (g) => g.commitHistory(`${head}~1`, head))
|
|
417
|
+
expect(result.length).toBe(1)
|
|
418
|
+
expect(result[0]?.message).toBe("feat: second")
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it("reports the paths each commit's name-status diff touched, without extra subprocesses", async () => {
|
|
422
|
+
t.seed.commit("feat: add two files", { "a.txt": "a", "b.txt": "b" })
|
|
423
|
+
t.seed.commitDeletion("a.txt", "chore: remove a")
|
|
424
|
+
const result = await runGit(t, (g) => g.commitHistory())
|
|
425
|
+
const addTwo = result.find((c) => c.message === "feat: add two files")
|
|
426
|
+
const removeA = result.find((c) => c.message === "chore: remove a")
|
|
427
|
+
expect(addTwo?.touched).toEqual(expect.arrayContaining(["a.txt", "b.txt"]))
|
|
428
|
+
expect(removeA?.touched).toEqual(["a.txt"])
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it("reports touched: [] for an empty commit — Edge.ts's headTurn.empty depends on this", async () => {
|
|
432
|
+
await runGit(t, (g) => g.commitAllWithPrefix("gtd(agent): working"))
|
|
433
|
+
const result = await runGit(t, (g) => g.commitHistory())
|
|
434
|
+
expect(result[result.length - 1]?.touched).toEqual([])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it("reads through an explicit head ref instead of literal HEAD when given one", async () => {
|
|
438
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
439
|
+
const earlierHead = t.observe.resolveRef("HEAD")
|
|
440
|
+
// Moves real HEAD forward — a `head` argument must stop the walk at
|
|
441
|
+
// `earlierHead` instead, proving the read goes through the given ref
|
|
442
|
+
// rather than the literal `HEAD` the git CLI would default to.
|
|
443
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
444
|
+
const result = await runGit(t, (g) => g.commitHistory(undefined, earlierHead))
|
|
445
|
+
expect(result.map((c) => c.message)).toEqual(["init: first commit", "feat: second"])
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
for (const { label, path } of PATHOLOGICAL_PATHS) {
|
|
449
|
+
it(`reports a touched path containing ${label}, verbatim`, async () => {
|
|
450
|
+
t.seed.commit("feat: add pathological file", { [path]: "content" })
|
|
451
|
+
const result = await runGit(t, (g) => g.commitHistory())
|
|
452
|
+
expect(result[result.length - 1]?.touched).toEqual([path])
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
it("expands a rename into a deletion of the old path and an addition of the new one", async () => {
|
|
457
|
+
t.seed.commit("chore: seed old", { "old.txt": "same content unique-marker\n" })
|
|
458
|
+
t.seed.deleteFile("old.txt")
|
|
459
|
+
t.seed.writeFile("new.txt", "same content unique-marker\n")
|
|
460
|
+
t.seed.stageAll()
|
|
461
|
+
t.seed.commit("feat: rename old to new", {})
|
|
462
|
+
const result = await runGit(t, (g) => g.commitHistory())
|
|
463
|
+
const renameCommit = result.find((c) => c.message === "feat: rename old to new")
|
|
464
|
+
expect([...(renameCommit?.touched ?? [])].sort()).toEqual(["new.txt", "old.txt"])
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
describe("subjectHistory", () => {
|
|
469
|
+
it("returns [] for an empty repo", async () => {
|
|
470
|
+
const empty = t.emptyRepo()
|
|
471
|
+
try {
|
|
472
|
+
expect(await runGit(empty, (g) => g.subjectHistory(32, 0))).toEqual([])
|
|
473
|
+
} finally {
|
|
474
|
+
empty.dispose()
|
|
475
|
+
}
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it("returns hash+subject pairs oldest to newest, no body", async () => {
|
|
479
|
+
t.seed.commit("feat: second\n\nA body line.", { "b.txt": "b" })
|
|
480
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
481
|
+
const result = await runGit(t, (g) => g.subjectHistory(32, 0))
|
|
482
|
+
expect(result.map((c) => c.subject)).toEqual([
|
|
483
|
+
"init: first commit",
|
|
484
|
+
"feat: second",
|
|
485
|
+
"feat: third",
|
|
486
|
+
])
|
|
487
|
+
expect(result[2]?.hash).toBe(t.observe.resolveRef("HEAD"))
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it("pages backward from head: skip drops the newest commits kept in an earlier page", async () => {
|
|
491
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
492
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
493
|
+
const firstPage = await runGit(t, (g) => g.subjectHistory(1, 0))
|
|
494
|
+
const secondPage = await runGit(t, (g) => g.subjectHistory(1, 1))
|
|
495
|
+
expect(firstPage.map((c) => c.subject)).toEqual(["feat: third"])
|
|
496
|
+
expect(secondPage.map((c) => c.subject)).toEqual(["feat: second"])
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it("an unbounded page (non-finite pageSize) returns the whole history", async () => {
|
|
500
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
501
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
502
|
+
const result = await runGit(t, (g) => g.subjectHistory(Infinity, 0))
|
|
503
|
+
expect(result.map((c) => c.subject)).toEqual([
|
|
504
|
+
"init: first commit",
|
|
505
|
+
"feat: second",
|
|
506
|
+
"feat: third",
|
|
507
|
+
])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it("reads through an explicit head ref instead of literal HEAD when given one", async () => {
|
|
511
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
512
|
+
const earlierHead = t.observe.resolveRef("HEAD")
|
|
513
|
+
t.seed.commit("feat: third", { "c.txt": "c" })
|
|
514
|
+
const result = await runGit(t, (g) => g.subjectHistory(32, 0, earlierHead))
|
|
515
|
+
expect(result.map((c) => c.subject)).toEqual(["init: first commit", "feat: second"])
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it("is no worse handled than commitHistory for a subject containing the \\x02 delimiter", async () => {
|
|
519
|
+
t.seed.commit("feat: has a \x02 byte in it", { "d.txt": "d" })
|
|
520
|
+
const result = await runGit(t, (g) => g.subjectHistory(32, 0))
|
|
521
|
+
expect(result[result.length - 1]?.hash).toBe(t.observe.resolveRef("HEAD"))
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
describe("commitAllWithPrefix", () => {
|
|
526
|
+
it("stages all pending changes and commits with the given prefix as the message", async () => {
|
|
527
|
+
t.seed.writeFile("new.ts", "export const x = 1")
|
|
528
|
+
t.seed.writeFile("TODO.md", "# Plan")
|
|
529
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
530
|
+
await runGit(t, (g) => g.commitAllWithPrefix("gtd: building"))
|
|
531
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
532
|
+
const history = await runGit(t, (g) => g.commitHistory())
|
|
533
|
+
expect(history[history.length - 1]?.message).toBe("gtd: building")
|
|
534
|
+
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it("commits even on a clean tree (--allow-empty) so a fixed-prefix commit never throws", async () => {
|
|
538
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
539
|
+
await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"))
|
|
540
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
541
|
+
const history = await runGit(t, (g) => g.commitHistory())
|
|
542
|
+
expect(history[history.length - 1]?.message).toBe("gtd: grilled")
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it.skipIf(!capabilities.commitHooks)(
|
|
546
|
+
"retries with --no-verify when a hook blocks the empty commit",
|
|
547
|
+
async () => {
|
|
548
|
+
const hookPath = join(t.root, ".git/hooks/pre-commit")
|
|
549
|
+
writeFileSync(
|
|
550
|
+
hookPath,
|
|
551
|
+
`#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`,
|
|
552
|
+
)
|
|
553
|
+
execSync(`chmod +x "${hookPath}"`)
|
|
554
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
555
|
+
await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"))
|
|
556
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
557
|
+
const history = await runGit(t, (g) => g.commitHistory())
|
|
558
|
+
expect(history[history.length - 1]?.message).toBe("gtd: grilled")
|
|
559
|
+
},
|
|
560
|
+
)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
describe("commitAsIs", () => {
|
|
564
|
+
it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
|
|
565
|
+
t.seed.writeFile("a.txt", "a")
|
|
566
|
+
t.seed.stageAll()
|
|
567
|
+
t.seed.writeFile("b.txt", "b")
|
|
568
|
+
await runGit(t, (g) => g.commitAsIs("feat: squash"))
|
|
569
|
+
const history = await runGit(t, (g) => g.commitHistory())
|
|
570
|
+
expect(history[history.length - 1]?.message).toBe("feat: squash")
|
|
571
|
+
// HEAD's tree has a.txt (staged before the commit)...
|
|
572
|
+
expect(await runGit(t, (g) => g.readFileAtRef("HEAD", "a.txt"))).toContain("a")
|
|
573
|
+
// ...and not b.txt (written to the worktree AFTER staging).
|
|
574
|
+
const bAtHead = await runGitExit(t, (g) => g.readFileAtRef("HEAD", "b.txt"))
|
|
575
|
+
expect(Exit.isFailure(bAtHead)).toBe(true)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it("commits even on a clean index (--allow-empty)", async () => {
|
|
579
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
580
|
+
await runGit(t, (g) => g.commitAsIs("gtd: empty squash"))
|
|
581
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
582
|
+
})
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
describe("softResetTo", () => {
|
|
586
|
+
it("moves HEAD back but keeps worktree changes from second commit", async () => {
|
|
587
|
+
const firstHash = t.observe.resolveRef("HEAD")
|
|
588
|
+
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
589
|
+
await runGit(t, (g) => g.softResetTo(firstHash))
|
|
590
|
+
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
591
|
+
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
describe("hardResetTo", () => {
|
|
596
|
+
it("moves HEAD, index, and working tree content back to the target ref", async () => {
|
|
597
|
+
const firstHash = t.observe.resolveRef("HEAD")
|
|
598
|
+
t.seed.commit("feat: second", { "readme.txt": "modified content" })
|
|
599
|
+
await runGit(t, (g) => g.hardResetTo(firstHash))
|
|
600
|
+
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
601
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
602
|
+
if (t.capabilities.onDiskContent) {
|
|
603
|
+
expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
describe("mixedResetTo", () => {
|
|
609
|
+
it("moves HEAD and index, leaving the working tree untouched — the reset commit's content re-surfaces as pending", async () => {
|
|
610
|
+
const firstHash = t.observe.resolveRef("HEAD")
|
|
611
|
+
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
612
|
+
await runGit(t, (g) => g.mixedResetTo(firstHash))
|
|
613
|
+
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
614
|
+
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
describe("discardPending", () => {
|
|
619
|
+
it("discards every pending change, tracked or untracked", async () => {
|
|
620
|
+
t.seed.writeFile("readme.txt", "modified")
|
|
621
|
+
t.seed.writeFile("untracked.txt", "new")
|
|
622
|
+
await runGit(t, (g) => g.discardPending())
|
|
623
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
describe("updateRef / deleteRef / readRefOption", () => {
|
|
628
|
+
it("updateRef points a ref at a commit; readRefOption resolves it", async () => {
|
|
629
|
+
const head = t.observe.resolveRef("HEAD")
|
|
630
|
+
await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
|
|
631
|
+
const resolved = await runGit(t, (g) => g.readRefOption("refs/gtd/marker"))
|
|
632
|
+
expect(resolved._tag).toBe("Some")
|
|
633
|
+
if (resolved._tag === "Some") expect(resolved.value).toBe(head)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
|
|
637
|
+
const resolved = await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent"))
|
|
638
|
+
expect(resolved._tag).toBe("None")
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
|
|
642
|
+
const head = t.observe.resolveRef("HEAD")
|
|
643
|
+
await runGit(t, (g) => g.updateRef("refs/gtd/marker", head))
|
|
644
|
+
await runGit(t, (g) => g.deleteRef("refs/gtd/marker"))
|
|
645
|
+
await runGit(t, (g) => g.deleteRef("refs/gtd/marker"))
|
|
646
|
+
const resolved = await runGit(t, (g) => g.readRefOption("refs/gtd/marker"))
|
|
647
|
+
expect(resolved._tag).toBe("None")
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
describe("isAncestor", () => {
|
|
652
|
+
it("is true for an ancestor commit", async () => {
|
|
653
|
+
const base = t.observe.resolveRef("HEAD")
|
|
654
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
655
|
+
const tip = t.observe.resolveRef("HEAD")
|
|
656
|
+
expect(await runGit(t, (g) => g.isAncestor(base, tip))).toBe(true)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it("is false for a diverged tip (neither is the other's ancestor)", async () => {
|
|
660
|
+
const base = t.observe.resolveRef("HEAD")
|
|
661
|
+
t.seed.updateRef("refs/gtd/side", base)
|
|
662
|
+
t.seed.commit("feat: main-branch-work", { "m.txt": "m" })
|
|
663
|
+
const mainTip = t.observe.resolveRef("HEAD")
|
|
664
|
+
await runGit(t, (g) => g.mixedResetTo(base))
|
|
665
|
+
t.seed.writeFile("s.txt", "s")
|
|
666
|
+
t.seed.stageAll()
|
|
667
|
+
t.seed.commit("feat: side-branch-work", {})
|
|
668
|
+
const sideTip = t.observe.resolveRef("HEAD")
|
|
669
|
+
expect(await runGit(t, (g) => g.isAncestor(mainTip, sideTip))).toBe(false)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it("is false (never a failure) for a nonexistent ref", async () => {
|
|
673
|
+
expect(await runGit(t, (g) => g.isAncestor("totally-invalid-ref", "HEAD"))).toBe(false)
|
|
674
|
+
})
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
describe("mergeBase", () => {
|
|
678
|
+
it("is the fork point of two diverged tips", async () => {
|
|
679
|
+
const base = t.observe.resolveRef("HEAD")
|
|
680
|
+
t.seed.updateRef("refs/gtd/side", base)
|
|
681
|
+
t.seed.commit("feat: main-branch-work", { "m.txt": "m" })
|
|
682
|
+
const mainTip = t.observe.resolveRef("HEAD")
|
|
683
|
+
await runGit(t, (g) => g.mixedResetTo(base))
|
|
684
|
+
t.seed.writeFile("s.txt", "s")
|
|
685
|
+
t.seed.stageAll()
|
|
686
|
+
t.seed.commit("feat: side-branch-work", {})
|
|
687
|
+
const sideTip = t.observe.resolveRef("HEAD")
|
|
688
|
+
const found = await runGit(t, (g) => g.mergeBase(mainTip, sideTip))
|
|
689
|
+
expect(found).toEqual(Option.some(base))
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
it("is the ancestor itself when one tip is the other's ancestor", async () => {
|
|
693
|
+
const base = t.observe.resolveRef("HEAD")
|
|
694
|
+
t.seed.commit("feat: second", { "b.txt": "b" })
|
|
695
|
+
const found = await runGit(t, (g) => g.mergeBase(base, "HEAD"))
|
|
696
|
+
expect(found).toEqual(Option.some(base))
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it("is none (never a failure) for a nonexistent ref", async () => {
|
|
700
|
+
expect(await runGit(t, (g) => g.mergeBase("totally-invalid-ref", "HEAD"))).toEqual(
|
|
701
|
+
Option.none(),
|
|
702
|
+
)
|
|
703
|
+
})
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
describe("defaultBranch", () => {
|
|
707
|
+
it("falls back to main when no remote names a default", async () => {
|
|
708
|
+
expect(await runGit(t, (g) => g.defaultBranch())).toBe("main")
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
describe("readFileAtRef", () => {
|
|
713
|
+
it("reads a file's content as it stood at a given ref", async () => {
|
|
714
|
+
const base = t.observe.resolveRef("HEAD")
|
|
715
|
+
t.seed.commit("feat: change readme", { "readme.txt": "changed" })
|
|
716
|
+
const content = await runGit(t, (g) => g.readFileAtRef(base, "readme.txt"))
|
|
717
|
+
expect(content.trim()).toBe("hello")
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it("fails when the path does not exist at that ref", async () => {
|
|
721
|
+
const result = await runGitExit(t, (g) => g.readFileAtRef("HEAD", "nonexistent.txt"))
|
|
722
|
+
expect(Exit.isFailure(result)).toBe(true)
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
describe("changedPaths", () => {
|
|
727
|
+
it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
|
|
728
|
+
t.seed.commit("chore: seed", { "modme.txt": "orig", "delme.txt": "bye" })
|
|
729
|
+
t.seed.writeFile("modme.txt", "changed")
|
|
730
|
+
t.seed.deleteFile("delme.txt")
|
|
731
|
+
t.seed.writeFile("newfile.txt", "new")
|
|
732
|
+
const changed = await runGit(t, (g) => g.changedPaths())
|
|
733
|
+
const byPath = Object.fromEntries(changed.map((c) => [c.path, c.status]))
|
|
734
|
+
expect(byPath["modme.txt"]).toBe("M")
|
|
735
|
+
expect(byPath["delme.txt"]).toBe("D")
|
|
736
|
+
expect(byPath["newfile.txt"]).toBe("A")
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it("returns [] on a clean tree", async () => {
|
|
740
|
+
expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
for (const { label, path } of PATHOLOGICAL_PATHS) {
|
|
744
|
+
it(`reports an untracked path containing ${label}, verbatim`, async () => {
|
|
745
|
+
t.seed.writeFile(path, "content")
|
|
746
|
+
const changed = await runGit(t, (g) => g.changedPaths())
|
|
747
|
+
expect(changed).toEqual([{ path, status: "A" }])
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
it("expands a rename into a deletion of the old path and an addition of the new one", async () => {
|
|
752
|
+
t.seed.commit("chore: seed old", { "old.txt": "same content unique-marker\n" })
|
|
753
|
+
t.seed.deleteFile("old.txt")
|
|
754
|
+
t.seed.writeFile("new.txt", "same content unique-marker\n")
|
|
755
|
+
t.seed.stageAll()
|
|
756
|
+
const changed = await runGit(t, (g) => g.changedPaths())
|
|
757
|
+
expect([...changed].sort((a, b) => a.path.localeCompare(b.path))).toEqual([
|
|
758
|
+
{ path: "new.txt", status: "A" },
|
|
759
|
+
{ path: "old.txt", status: "D" },
|
|
760
|
+
])
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
// `git reset --mixed <base>` drops every path a commit added out of the
|
|
764
|
+
// index, leaving it untracked but present on disk. An index-based answer
|
|
765
|
+
// calls each of those a deletion (`git diff --name-status <base>` compares
|
|
766
|
+
// base to the INDEX). The port answers by CONTENT instead — these four
|
|
767
|
+
// cases are that contract.
|
|
768
|
+
describe("with a base, over paths the index no longer carries", () => {
|
|
769
|
+
/** Commit `REVIEW.md` on top of a seed commit, then mixed-reset the index back to that seed. Returns the head the caller measures against. */
|
|
770
|
+
const commitThenRewindIndex = (content: string): string => {
|
|
771
|
+
t.seed.commit("chore: seed", { "kept.txt": "seed" })
|
|
772
|
+
const base = t.observe.resolveRef("HEAD")
|
|
773
|
+
t.seed.commit("gtd(agent): reviewing", { "REVIEW.md": content })
|
|
774
|
+
const head = t.observe.resolveRef("HEAD")
|
|
775
|
+
t.seed.mixedReset(base)
|
|
776
|
+
return head
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
it("omits an untracked path whose bytes match the base — present and unchanged is not a change", async () => {
|
|
780
|
+
const head = commitThenRewindIndex("- [ ] one\n")
|
|
781
|
+
expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([])
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it("reports an untracked path edited since the base as M, never D", async () => {
|
|
785
|
+
const head = commitThenRewindIndex("- [ ] one\n")
|
|
786
|
+
t.seed.writeFile("REVIEW.md", "- [x] one\n")
|
|
787
|
+
expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
|
|
788
|
+
{ path: "REVIEW.md", status: "M" },
|
|
789
|
+
])
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it("reports an untracked path REMOVED from disk as D — the deletion the require-revert guard must catch", async () => {
|
|
793
|
+
const head = commitThenRewindIndex("- [ ] one\n")
|
|
794
|
+
t.seed.deleteFile("REVIEW.md")
|
|
795
|
+
expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
|
|
796
|
+
{ path: "REVIEW.md", status: "D" },
|
|
797
|
+
])
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it("still reports a genuinely new untracked path as A", async () => {
|
|
801
|
+
const head = commitThenRewindIndex("- [ ] one\n")
|
|
802
|
+
t.seed.writeFile("NOTE.md", "a reviewer's note\n")
|
|
803
|
+
expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
|
|
804
|
+
{ path: "NOTE.md", status: "A" },
|
|
805
|
+
])
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
// "Different bytes" has to mean what GIT means by it. Under
|
|
809
|
+
// `text=auto` the committed blob is normalized to LF while the working
|
|
810
|
+
// tree legitimately holds CRLF, so a RAW byte comparison calls an
|
|
811
|
+
// untouched file modified — and a spurious `M` on the review doc is a
|
|
812
|
+
// spurious "the human edited something real", which flips a clean
|
|
813
|
+
// sign-off onto the feedback edge — a full re-plan nobody asked for. The fake has no filters at all, so it answers
|
|
814
|
+
// "unchanged" by construction; this pins real git to the same answer.
|
|
815
|
+
it("omits an untracked path that only differs by a clean filter's normalization", async () => {
|
|
816
|
+
t.seed.writeFile(".gitattributes", "* text=auto\n")
|
|
817
|
+
t.seed.commit("chore: normalize line endings", {})
|
|
818
|
+
const base = t.observe.resolveRef("HEAD")
|
|
819
|
+
// Committed through `git add`, so the blob is normalized to LF while
|
|
820
|
+
// the file on disk keeps its CRLF bytes.
|
|
821
|
+
t.seed.writeFile("REVIEW.md", "- [ ] one\r\n- [ ] two\r\n")
|
|
822
|
+
t.seed.commit("gtd(agent): reviewing", {})
|
|
823
|
+
const head = t.observe.resolveRef("HEAD")
|
|
824
|
+
t.seed.mixedReset(base)
|
|
825
|
+
expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([])
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it("still reports a real edit to such a path as M", async () => {
|
|
829
|
+
t.seed.writeFile(".gitattributes", "* text=auto\n")
|
|
830
|
+
t.seed.commit("chore: normalize line endings", {})
|
|
831
|
+
const base = t.observe.resolveRef("HEAD")
|
|
832
|
+
t.seed.writeFile("REVIEW.md", "- [ ] one\r\n- [ ] two\r\n")
|
|
833
|
+
t.seed.commit("gtd(agent): reviewing", {})
|
|
834
|
+
const head = t.observe.resolveRef("HEAD")
|
|
835
|
+
t.seed.mixedReset(base)
|
|
836
|
+
t.seed.writeFile("REVIEW.md", "- [x] one\r\n- [ ] two\r\n")
|
|
837
|
+
expect(await runGit(t, (g) => g.changedPaths(head))).toEqual([
|
|
838
|
+
{ path: "REVIEW.md", status: "M" },
|
|
839
|
+
])
|
|
840
|
+
})
|
|
841
|
+
})
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
describe("topLevel", () => {
|
|
845
|
+
it("resolves to this tier's own root", async () => {
|
|
846
|
+
expect(await runGit(t, (g) => g.topLevel())).toBe(t.root)
|
|
847
|
+
})
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
describe("gitDir", () => {
|
|
851
|
+
it("resolves to an absolute path ending in .git, for the main worktree", async () => {
|
|
852
|
+
const dir = await runGit(t, (g) => g.gitDir())
|
|
853
|
+
expect(dir).toBe(join(t.root, ".git"))
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it.skipIf(!capabilities.linkedWorktrees)(
|
|
857
|
+
"resolves to a linked worktree's own private .git/worktrees/<name> dir, not the main worktree's",
|
|
858
|
+
async () => {
|
|
859
|
+
const siblingDir = `${t.root}-gitdir-sibling`
|
|
860
|
+
gitExecIn(t.root, "worktree", "add", "-q", "-b", "gitdir-sibling", siblingDir, "HEAD")
|
|
861
|
+
try {
|
|
862
|
+
const mainGitDir = await runGit(t, (g) => g.gitDir())
|
|
863
|
+
const siblingGitDir = await Effect.runPromise(
|
|
864
|
+
Effect.flatMap(GitService, (g) => g.gitDir()).pipe(
|
|
865
|
+
Effect.provide(GitService.Live),
|
|
866
|
+
Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
|
|
867
|
+
Effect.provide(NodeContext.layer),
|
|
868
|
+
),
|
|
869
|
+
)
|
|
870
|
+
expect(siblingGitDir).not.toBe(mainGitDir)
|
|
871
|
+
// git names a linked worktree's internal directory after the
|
|
872
|
+
// worktree PATH's basename, not the `-b` branch name given below.
|
|
873
|
+
expect(siblingGitDir).toBe(join(mainGitDir, "worktrees", basename(siblingDir)))
|
|
874
|
+
} finally {
|
|
875
|
+
rmSync(siblingDir, { recursive: true, force: true })
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
describe("gitCommonDir", () => {
|
|
882
|
+
it("resolves to the same absolute path as gitDir, for the main worktree", async () => {
|
|
883
|
+
const [common, dir] = await runGit(t, (g) => Effect.all([g.gitCommonDir(), g.gitDir()]))
|
|
884
|
+
expect(common).toBe(dir)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it.skipIf(!capabilities.linkedWorktrees)(
|
|
888
|
+
"resolves to the SAME shared directory from a linked worktree, unlike gitDir",
|
|
889
|
+
async () => {
|
|
890
|
+
const siblingDir = `${t.root}-commondir-sibling`
|
|
891
|
+
gitExecIn(t.root, "worktree", "add", "-q", "-b", "commondir-sibling", siblingDir, "HEAD")
|
|
892
|
+
try {
|
|
893
|
+
const mainCommonDir = await runGit(t, (g) => g.gitCommonDir())
|
|
894
|
+
const siblingCommonDir = await Effect.runPromise(
|
|
895
|
+
Effect.flatMap(GitService, (g) => g.gitCommonDir()).pipe(
|
|
896
|
+
Effect.provide(GitService.Live),
|
|
897
|
+
Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
|
|
898
|
+
Effect.provide(NodeContext.layer),
|
|
899
|
+
),
|
|
900
|
+
)
|
|
901
|
+
const siblingGitDir = await Effect.runPromise(
|
|
902
|
+
Effect.flatMap(GitService, (g) => g.gitDir()).pipe(
|
|
903
|
+
Effect.provide(GitService.Live),
|
|
904
|
+
Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
|
|
905
|
+
Effect.provide(NodeContext.layer),
|
|
906
|
+
),
|
|
907
|
+
)
|
|
908
|
+
expect(siblingCommonDir).toBe(mainCommonDir)
|
|
909
|
+
expect(siblingCommonDir).not.toBe(siblingGitDir)
|
|
910
|
+
} finally {
|
|
911
|
+
rmSync(siblingDir, { recursive: true, force: true })
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
)
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Runs a `GitScript.ts` builder's output through a REAL shell against `t.root`
|
|
920
|
+
* — the same execution model the eventual driver uses (`bash -c <script>`),
|
|
921
|
+
* so a builder that's syntactically fine but semantically wrong (a missing
|
|
922
|
+
* `&&`, a misquoted path) fails here exactly as it would in production.
|
|
923
|
+
*/
|
|
924
|
+
const execScript = (t: GitTier, script: string): void => {
|
|
925
|
+
execFileSync("bash", ["-c", script], { cwd: t.root, stdio: "pipe" })
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Names touched by HEAD's own commit — `git show`'s diff against its parent, not the whole tree. */
|
|
929
|
+
const headTouchedPaths = (t: GitTier): ReadonlyArray<string> =>
|
|
930
|
+
gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD")
|
|
931
|
+
.split("\n")
|
|
932
|
+
.filter((line) => line.length > 0)
|
|
933
|
+
|
|
934
|
+
const headSubject = (t: GitTier): string => gitExecIn(t.root, "log", "-1", "--pretty=%s")
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* The SAME 9 writer postconditions `runGitServiceContract` asserts against
|
|
938
|
+
* `GitOperations`, asserted here against `GitScript.ts`'s bash builders
|
|
939
|
+
* instead — driven through real `bash` against a real repo (`makeLiveTier`),
|
|
940
|
+
* never the in-memory fake, since a fake root (`/repo`) has no shell to run
|
|
941
|
+
* against.
|
|
942
|
+
*/
|
|
943
|
+
export const runGitScriptContract = (): void => {
|
|
944
|
+
let t: GitTier
|
|
945
|
+
|
|
946
|
+
beforeEach(() => {
|
|
947
|
+
t = makeLiveTier()
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
afterEach(() => {
|
|
951
|
+
t.dispose()
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
describe("commitAll", () => {
|
|
955
|
+
it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
|
|
956
|
+
t.seed.writeFile("new.ts", "export const x = 1")
|
|
957
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
958
|
+
execScript(t, commitAll("gtd: building"))
|
|
959
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
960
|
+
expect(headSubject(t)).toBe("gtd: building")
|
|
961
|
+
expect(headTouchedPaths(t)).toEqual(["new.ts"])
|
|
962
|
+
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
|
|
966
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
967
|
+
execScript(t, commitAll("gtd: grilled"))
|
|
968
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
969
|
+
expect(headSubject(t)).toBe("gtd: grilled")
|
|
970
|
+
})
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
describe("commitAsIs", () => {
|
|
974
|
+
it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
|
|
975
|
+
t.seed.writeFile("a.txt", "a")
|
|
976
|
+
t.seed.stageAll()
|
|
977
|
+
t.seed.writeFile("b.txt", "b")
|
|
978
|
+
execScript(t, commitAsIs("feat: squash"))
|
|
979
|
+
expect(headSubject(t)).toBe("feat: squash")
|
|
980
|
+
expect(headTouchedPaths(t)).toEqual(["a.txt"])
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
|
|
984
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
985
|
+
execScript(t, commitAsIs("gtd: empty squash"))
|
|
986
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
987
|
+
expect(headSubject(t)).toBe("gtd: empty squash")
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
describe("softResetTo", () => {
|
|
992
|
+
it("moves HEAD back but keeps worktree changes from the second commit", () => {
|
|
993
|
+
const firstHash = t.observe.resolveRef("HEAD")
|
|
994
|
+
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
995
|
+
execScript(t, softResetTo(firstHash))
|
|
996
|
+
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
997
|
+
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
998
|
+
})
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
describe("hardResetTo", () => {
|
|
1002
|
+
it("moves HEAD, index, and working tree content back to the target ref", () => {
|
|
1003
|
+
const firstHash = t.observe.resolveRef("HEAD")
|
|
1004
|
+
t.seed.commit("feat: second", { "readme.txt": "modified content" })
|
|
1005
|
+
execScript(t, hardResetTo(firstHash))
|
|
1006
|
+
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1007
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
1008
|
+
expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
|
|
1009
|
+
})
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
describe("mixedResetTo", () => {
|
|
1013
|
+
it("moves HEAD and index, leaving the working tree untouched", () => {
|
|
1014
|
+
const firstHash = t.observe.resolveRef("HEAD")
|
|
1015
|
+
t.seed.commit("feat: second", { "second.txt": "second content" })
|
|
1016
|
+
execScript(t, mixedResetTo(firstHash))
|
|
1017
|
+
expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
|
|
1018
|
+
expect(t.observe.statusPorcelain()).toContain("second.txt")
|
|
1019
|
+
})
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
describe("discardPending", () => {
|
|
1023
|
+
it("drops both tracked modifications and untracked files", () => {
|
|
1024
|
+
t.seed.writeFile("readme.txt", "modified")
|
|
1025
|
+
t.seed.writeFile("untracked.txt", "new")
|
|
1026
|
+
execScript(t, discardPending())
|
|
1027
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
1028
|
+
})
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
describe("updateRef / deleteRef", () => {
|
|
1032
|
+
it("updateRef points a ref at a commit", () => {
|
|
1033
|
+
const head = t.observe.resolveRef("HEAD")
|
|
1034
|
+
execScript(t, updateRef("refs/gtd/marker", head))
|
|
1035
|
+
expect(t.observe.refExists("refs/gtd/marker")).toBe(true)
|
|
1036
|
+
expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
|
|
1040
|
+
const head = t.observe.resolveRef("HEAD")
|
|
1041
|
+
execScript(t, updateRef("refs/gtd/marker", head))
|
|
1042
|
+
execScript(t, deleteRef("refs/gtd/marker"))
|
|
1043
|
+
execScript(t, deleteRef("refs/gtd/marker"))
|
|
1044
|
+
expect(t.observe.refExists("refs/gtd/marker")).toBe(false)
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
it("deleteRef tolerates a ref that was never created", () => {
|
|
1048
|
+
expect(() => execScript(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
|
|
1049
|
+
expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
|
|
1050
|
+
})
|
|
1051
|
+
})
|
|
1052
|
+
}
|