@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,552 @@
|
|
|
1
|
+
import { Command, CommandExecutor } from "@effect/platform"
|
|
2
|
+
import { Context, Effect, Layer, Option, Stream } from "effect"
|
|
3
|
+
import { Host } from "./Host.js"
|
|
4
|
+
|
|
5
|
+
export interface GitReaderOperations {
|
|
6
|
+
readonly lastCommitSubject: (ref?: string) => Effect.Effect<string, Error>
|
|
7
|
+
/**
|
|
8
|
+
* Full commit message (subject + body) of HEAD — keeps the body, unlike
|
|
9
|
+
* `lastCommitSubject`. No current caller: it read back a `Gtd-History:`
|
|
10
|
+
* trailer for the now-removed squash finale's `restorability` check; kept
|
|
11
|
+
* deliberately rather than deleted, as a plain, reusable git primitive a
|
|
12
|
+
* future edge concern may need again.
|
|
13
|
+
*/
|
|
14
|
+
readonly lastCommitMessage: () => Effect.Effect<string, Error>
|
|
15
|
+
readonly hasCommits: () => Effect.Effect<boolean, Error>
|
|
16
|
+
readonly resolveRef: (ref: string) => Effect.Effect<string, Error>
|
|
17
|
+
/** The ref's hash if it resolves, `Option.none` if it doesn't exist (never fails). Used to read the retained-history ref. */
|
|
18
|
+
readonly readRefOption: (ref: string) => Effect.Effect<Option.Option<string>, Error>
|
|
19
|
+
/** Never fails (a non-zero exit reports `false`). */
|
|
20
|
+
readonly isAncestor: (a: string, b: string) => Effect.Effect<boolean, Error>
|
|
21
|
+
/** The best common ancestor of `a` and `b`; `Option.none` when they share none or either does not resolve (never fails). */
|
|
22
|
+
readonly mergeBase: (a: string, b: string) => Effect.Effect<Option.Option<string>, Error>
|
|
23
|
+
/** `origin`'s default branch (`origin/main`, say) when the remote names one, else `main`. */
|
|
24
|
+
readonly defaultBranch: () => Effect.Effect<string, Error>
|
|
25
|
+
readonly topLevel: () => Effect.Effect<string, Error>
|
|
26
|
+
/**
|
|
27
|
+
* The absolute, per-worktree git directory — not derived from
|
|
28
|
+
* `Host.root + "/.git"`, since a linked worktree's `.git` is a FILE pointing
|
|
29
|
+
* elsewhere, and two worktrees looping concurrently must never collide on
|
|
30
|
+
* one file.
|
|
31
|
+
*/
|
|
32
|
+
readonly gitDir: () => Effect.Effect<string, Error>
|
|
33
|
+
/**
|
|
34
|
+
* The absolute git COMMON directory — shared by every linked worktree of
|
|
35
|
+
* one clone, unlike `gitDir` above. A commit hash or blob id means the
|
|
36
|
+
* same thing in every worktree, so caches keyed on one (`gtd-cache/` under
|
|
37
|
+
* this directory) are deliberately shared rather than duplicated per
|
|
38
|
+
* worktree.
|
|
39
|
+
*/
|
|
40
|
+
readonly gitCommonDir: () => Effect.Effect<string, Error>
|
|
41
|
+
/**
|
|
42
|
+
* First-parent history from `base..head` (or through `head` if no base),
|
|
43
|
+
* oldest→newest; `head` defaults to `"HEAD"`. Pass a resolved hash to walk a
|
|
44
|
+
* head other than the literal `HEAD`.
|
|
45
|
+
* `touched` lists the paths the commit's name-status diff mentions, from
|
|
46
|
+
* the same git invocation.
|
|
47
|
+
*/
|
|
48
|
+
readonly commitHistory: (
|
|
49
|
+
base?: string,
|
|
50
|
+
head?: string,
|
|
51
|
+
) => Effect.Effect<
|
|
52
|
+
ReadonlyArray<{
|
|
53
|
+
readonly hash: string
|
|
54
|
+
readonly message: string
|
|
55
|
+
readonly touched: ReadonlyArray<string>
|
|
56
|
+
}>,
|
|
57
|
+
Error
|
|
58
|
+
>
|
|
59
|
+
/**
|
|
60
|
+
* `{ hash, subject }` pairs, first-parent, for one PAGE of history: `skip`
|
|
61
|
+
* commits are dropped and at most `pageSize` kept, walking backward from
|
|
62
|
+
* `head` (default `"HEAD"`) newest-first before the kept page is reversed
|
|
63
|
+
* to oldest→newest — the same selection `git log --skip --max-count
|
|
64
|
+
* --reverse` makes. No commit body or diff is read, which is the point:
|
|
65
|
+
* `Edge.ts` uses this to find an episode's boundary commit without paying
|
|
66
|
+
* for `commitHistory`'s `%B`/`--name-status` over the whole repository.
|
|
67
|
+
* `pageSize` may be non-finite (e.g. `Infinity`) for an unbounded page —
|
|
68
|
+
* the whole history from `skip` onward, with no `--max-count` at all.
|
|
69
|
+
*/
|
|
70
|
+
readonly subjectHistory: (
|
|
71
|
+
pageSize: number,
|
|
72
|
+
skip: number,
|
|
73
|
+
head?: string,
|
|
74
|
+
) => Effect.Effect<ReadonlyArray<{ readonly hash: string; readonly subject: string }>, Error>
|
|
75
|
+
/** Fails when the path doesn't exist at `ref` — a caller expecting that (e.g. the review sign-off gate) handles it with an explicit `catchAll`. */
|
|
76
|
+
readonly readFileAtRef: (ref: string, path: string) => Effect.Effect<string, Error>
|
|
77
|
+
/**
|
|
78
|
+
* Pending working-tree changes vs `base` (default `HEAD`), as
|
|
79
|
+
* `{path, status}` pairs: tracked diff unioned with untracked files.
|
|
80
|
+
*
|
|
81
|
+
* An untracked path is classified by CONTENT against `base`, not the index:
|
|
82
|
+
* the index's view calls a present-but-untracked file `D` whenever it
|
|
83
|
+
* doesn't match the working tree. A REAL deletion still reports `D` — it is
|
|
84
|
+
* absent from the untracked list, so the tracked diff's own `D` stands.
|
|
85
|
+
*/
|
|
86
|
+
readonly changedPaths: (
|
|
87
|
+
base?: string,
|
|
88
|
+
) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface GitWriterOperations {
|
|
92
|
+
/**
|
|
93
|
+
* `git add -A` then commit. `--allow-empty` is load-bearing: the machine
|
|
94
|
+
* emits a fixed subject even on a clean tree, and neither that nor an
|
|
95
|
+
* empty feedback-fixing commit must throw "nothing to commit". `message`
|
|
96
|
+
* may carry a trailing `Gtd-Cost:` trailer body — `-m` preserves embedded
|
|
97
|
+
* newlines verbatim.
|
|
98
|
+
*/
|
|
99
|
+
readonly commitAllWithPrefix: (message: string) => Effect.Effect<void, Error>
|
|
100
|
+
/**
|
|
101
|
+
* `git reset --soft <ref>` — moves HEAD (and only HEAD; index/worktree stay
|
|
102
|
+
* put). No current caller: it was the first half of the now-removed squash
|
|
103
|
+
* finale, kept on the port deliberately rather than deleted, since it's a
|
|
104
|
+
* plain, reusable git primitive a future edge concern may need again.
|
|
105
|
+
*/
|
|
106
|
+
readonly softResetTo: (ref: string) => Effect.Effect<void, Error>
|
|
107
|
+
/**
|
|
108
|
+
* Commits whatever is CURRENTLY STAGED, without an implicit `git add` first
|
|
109
|
+
* (unlike `commitAllWithPrefix`). No current caller: it paired with
|
|
110
|
+
* `softResetTo` as the squash finale's second half (re-committing the
|
|
111
|
+
* pre-reset index verbatim); kept for the same reason `softResetTo` is.
|
|
112
|
+
* Retries once without the pre-commit hook, same as `commitAllWithPrefix`.
|
|
113
|
+
*/
|
|
114
|
+
readonly commitAsIs: (message: string) => Effect.Effect<void, Error>
|
|
115
|
+
/**
|
|
116
|
+
* Discards every pending change, tracked or untracked, by staging first
|
|
117
|
+
* (`git add -A`) so the hard reset also drops untracked survivors. No
|
|
118
|
+
* current caller: it discarded the squash finale's leftover
|
|
119
|
+
* message-template file after `commitAsIs` landed; kept for the same
|
|
120
|
+
* reason `softResetTo` is.
|
|
121
|
+
*/
|
|
122
|
+
readonly discardPending: () => Effect.Effect<void, Error>
|
|
123
|
+
readonly updateRef: (ref: string, hash: string) => Effect.Effect<void, Error>
|
|
124
|
+
/** Idempotent: deleting a missing ref is a no-op. */
|
|
125
|
+
readonly deleteRef: (ref: string) => Effect.Effect<void, Error>
|
|
126
|
+
/** Used by `gtd abandon`'s reset. */
|
|
127
|
+
readonly mixedResetTo: (ref: string) => Effect.Effect<void, Error>
|
|
128
|
+
readonly hardResetTo: (ref: string) => Effect.Effect<void, Error>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface GitOperations extends GitReaderOperations, GitWriterOperations {}
|
|
132
|
+
|
|
133
|
+
const splitNul = (out: string): Array<string> => out.split("\0").filter((s) => s.length > 0)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse a `git diff --name-status -z` token stream into `{ path, status }`
|
|
137
|
+
* pairs. Statuses are identified by POSITION, never shape, so a file legally
|
|
138
|
+
* named e.g. `M` is never misread as a status token. A rename/copy status is
|
|
139
|
+
* followed by two paths; expanded into a deletion of the old one and an
|
|
140
|
+
* addition of the new one.
|
|
141
|
+
*/
|
|
142
|
+
const parseNameStatus = (
|
|
143
|
+
tokens: ReadonlyArray<string>,
|
|
144
|
+
): Array<{ path: string; status: string }> => {
|
|
145
|
+
const result: Array<{ path: string; status: string }> = []
|
|
146
|
+
let i = 0
|
|
147
|
+
while (i < tokens.length) {
|
|
148
|
+
const status = tokens[i]!
|
|
149
|
+
i += 1
|
|
150
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
151
|
+
const oldPath = tokens[i]
|
|
152
|
+
const newPath = tokens[i + 1]
|
|
153
|
+
i += 2
|
|
154
|
+
if (oldPath !== undefined) result.push({ path: oldPath, status: "D" })
|
|
155
|
+
if (newPath !== undefined) result.push({ path: newPath, status: "A" })
|
|
156
|
+
} else {
|
|
157
|
+
const path = tokens[i]
|
|
158
|
+
i += 1
|
|
159
|
+
if (path !== undefined) result.push({ path, status: status[0]! })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `git log -z --format=…` NUL-terminates the commit entry in place of the
|
|
167
|
+
* blank line before its diff, and (when a diff follows) still emits that
|
|
168
|
+
* blank line's own newline byte literally. Both are seam artifacts, not diff
|
|
169
|
+
* data — stripped here before `splitNul`/`parseNameStatus`.
|
|
170
|
+
*/
|
|
171
|
+
const stripCommitSeam = (tail: string): string => {
|
|
172
|
+
const afterNul = tail.startsWith("\x00") ? tail.slice(1) : tail
|
|
173
|
+
return afterNul.startsWith("\n") ? afterNul.slice(1) : afterNul
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type GitExec = (...args: [string, ...Array<string>]) => Effect.Effect<string, Error>
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `path → blob object id` at `ref`; a path absent from the map doesn't exist
|
|
180
|
+
* there. An unresolvable `ref` (an empty repo) yields an empty map, so every
|
|
181
|
+
* candidate reads as "not at `ref`".
|
|
182
|
+
*/
|
|
183
|
+
const blobsAtRef = (
|
|
184
|
+
exec: GitExec,
|
|
185
|
+
ref: string,
|
|
186
|
+
paths: ReadonlyArray<string>,
|
|
187
|
+
): Effect.Effect<Map<string, string>, Error> =>
|
|
188
|
+
exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(
|
|
189
|
+
Effect.catchAll(() => Effect.succeed("")),
|
|
190
|
+
Effect.map((out) => {
|
|
191
|
+
const blobs = new Map<string, string>()
|
|
192
|
+
for (const entry of splitNul(out)) {
|
|
193
|
+
const tab = entry.indexOf("\t")
|
|
194
|
+
if (tab === -1) continue
|
|
195
|
+
const [, type, oid] = entry.slice(0, tab).split(" ")
|
|
196
|
+
if (type === "blob" && oid !== undefined) blobs.set(entry.slice(tab + 1), oid)
|
|
197
|
+
}
|
|
198
|
+
return blobs
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `path → object id` for every path's CURRENT bytes, in one call. An empty map
|
|
204
|
+
* on failure, so every candidate reads as "differs" — the safe direction,
|
|
205
|
+
* since a file that EXISTS must never be reported deleted.
|
|
206
|
+
*
|
|
207
|
+
* NEVER add `--no-filters`: these ids are comparable with `git ls-tree`'s only
|
|
208
|
+
* because hash-object applies the repo's own clean filters, and without them a
|
|
209
|
+
* `text=auto` repo's untouched CRLF file reports `M`, flipping a clean
|
|
210
|
+
* sign-off onto the feedback edge. Both tiers pin this.
|
|
211
|
+
*
|
|
212
|
+
* A symlink is the residual inexactness: hash-object hashes the target's
|
|
213
|
+
* content rather than the link text, and reads as `M`, never `D`.
|
|
214
|
+
*/
|
|
215
|
+
const hashObjects = (
|
|
216
|
+
exec: GitExec,
|
|
217
|
+
paths: ReadonlyArray<string>,
|
|
218
|
+
): Effect.Effect<Map<string, string>, Error> =>
|
|
219
|
+
exec("git", "hash-object", "--", ...paths).pipe(
|
|
220
|
+
Effect.map(
|
|
221
|
+
(out) =>
|
|
222
|
+
new Map(
|
|
223
|
+
out
|
|
224
|
+
.trim()
|
|
225
|
+
.split("\n")
|
|
226
|
+
.map((oid, i) => [paths[i] ?? "", oid.trim()] as const),
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
Effect.catchAll(() => Effect.succeed(new Map<string, string>())),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Classify every untracked path against `ref`'s tree, by CONTENT: not at
|
|
234
|
+
* `ref` → `A`; different bytes → `M`; identical bytes → no entry. The latter
|
|
235
|
+
* two exist because "untracked" does not mean "new": against a `ref` older
|
|
236
|
+
* than HEAD, a path can be absent from the index yet already present in
|
|
237
|
+
* `ref`'s tree with the same or different bytes. "Different bytes" goes
|
|
238
|
+
* through `hashObjects`'s clean filters, matching how git itself
|
|
239
|
+
* decides it, so an untouched `text=auto` file is never over-reported `M`.
|
|
240
|
+
*/
|
|
241
|
+
const classifyUntracked = (
|
|
242
|
+
exec: GitExec,
|
|
243
|
+
ref: string,
|
|
244
|
+
untrackedPaths: ReadonlyArray<string>,
|
|
245
|
+
): Effect.Effect<Array<{ path: string; status: string }>, Error> =>
|
|
246
|
+
Effect.gen(function* () {
|
|
247
|
+
const atRef = yield* blobsAtRef(exec, ref, untrackedPaths)
|
|
248
|
+
const candidates = untrackedPaths.filter((path) => atRef.has(path))
|
|
249
|
+
const onDisk =
|
|
250
|
+
candidates.length === 0 ? new Map<string, string>() : yield* hashObjects(exec, candidates)
|
|
251
|
+
return untrackedPaths.flatMap((path) => {
|
|
252
|
+
const at = atRef.get(path)
|
|
253
|
+
if (at === undefined) return [{ path, status: "A" }]
|
|
254
|
+
return onDisk.get(path) === at ? [] : [{ path, status: "M" }]
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Run a command and return its stdout — FAILING on a non-zero exit code with
|
|
260
|
+
* the command line and stderr in the error message. `Command.string` alone
|
|
261
|
+
* silently ignores exit codes, which used to make gtd report success on
|
|
262
|
+
* rejected commits and lose files whose quoted paths broke a swallowed
|
|
263
|
+
* `git add`. Callers that expect a probe to fail handle it with an explicit
|
|
264
|
+
* `catchAll`.
|
|
265
|
+
*/
|
|
266
|
+
const run = (
|
|
267
|
+
root: string,
|
|
268
|
+
...args: [string, ...Array<string>]
|
|
269
|
+
): Effect.Effect<string, Error, CommandExecutor.CommandExecutor> =>
|
|
270
|
+
Effect.scoped(
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
const executor = yield* CommandExecutor.CommandExecutor
|
|
273
|
+
const process = yield* executor.start(
|
|
274
|
+
Command.make(...args).pipe(
|
|
275
|
+
Command.workingDirectory(root),
|
|
276
|
+
Command.stdout("pipe"),
|
|
277
|
+
Command.stderr("pipe"),
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
const collect = (stream: typeof process.stdout) =>
|
|
281
|
+
stream.pipe(Stream.decodeText(), Stream.mkString)
|
|
282
|
+
const [stdout, stderr, exitCode] = yield* Effect.all(
|
|
283
|
+
[collect(process.stdout), collect(process.stderr), process.exitCode],
|
|
284
|
+
{ concurrency: "unbounded" },
|
|
285
|
+
)
|
|
286
|
+
if (exitCode !== 0) {
|
|
287
|
+
return yield* Effect.fail(
|
|
288
|
+
new Error(`${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}`),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
return stdout
|
|
292
|
+
}),
|
|
293
|
+
).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e)))))
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Same shape as `Commentary.ts`'s `GtdError` (a `message` plus remediation
|
|
297
|
+
* `detail` lines, duck-typed by its `renderFailure`) — a LOCAL class rather
|
|
298
|
+
* than importing `GtdError` itself: `platform` may not depend on root
|
|
299
|
+
* (no outward target in `.fallowrc.json`'s `boundaries`), and `Git.ts` moved
|
|
300
|
+
* here with it.
|
|
301
|
+
*/
|
|
302
|
+
export class InvalidRefError extends Error {
|
|
303
|
+
readonly detail: readonly string[]
|
|
304
|
+
|
|
305
|
+
constructor(message: string, detail: readonly string[] = []) {
|
|
306
|
+
super(message)
|
|
307
|
+
this.name = "InvalidRefError"
|
|
308
|
+
this.detail = detail
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `resolveRef`'s validation for when `git rev-parse --verify <ref>` exits
|
|
314
|
+
* zero but its stdout isn't a 40-hex-char hash — a corrupted ref, not a
|
|
315
|
+
* missing one. A separate pure function so this rare branch is unit-testable
|
|
316
|
+
* without a real git repository contriving the condition.
|
|
317
|
+
*/
|
|
318
|
+
export const resolvedRefOrCorrupted = (
|
|
319
|
+
ref: string,
|
|
320
|
+
hash: string,
|
|
321
|
+
): Effect.Effect<string, InvalidRefError> =>
|
|
322
|
+
/^[0-9a-f]{40}$/.test(hash)
|
|
323
|
+
? Effect.succeed(hash)
|
|
324
|
+
: Effect.fail(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]))
|
|
325
|
+
|
|
326
|
+
const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): GitOperations => {
|
|
327
|
+
const exec = (...args: [string, ...Array<string>]) =>
|
|
328
|
+
run(root, ...args).pipe(
|
|
329
|
+
Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
lastCommitSubject: (ref = "HEAD") =>
|
|
334
|
+
exec("git", "log", "-1", "--pretty=%s", ref).pipe(Effect.map((s) => s.trim())),
|
|
335
|
+
|
|
336
|
+
lastCommitMessage: () =>
|
|
337
|
+
exec("git", "log", "-1", "--pretty=%B").pipe(Effect.map((s) => s.trim())),
|
|
338
|
+
|
|
339
|
+
hasCommits: () =>
|
|
340
|
+
exec("git", "rev-parse", "--verify", "HEAD").pipe(
|
|
341
|
+
Effect.map(() => true),
|
|
342
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
343
|
+
),
|
|
344
|
+
|
|
345
|
+
readFileAtRef: (ref: string, path: string) => exec("git", "show", `${ref}:${path}`),
|
|
346
|
+
|
|
347
|
+
changedPaths: (base?: string) =>
|
|
348
|
+
Effect.gen(function* () {
|
|
349
|
+
const ref = base ?? "HEAD"
|
|
350
|
+
// Tolerates the empty-repo case (no HEAD), where the diff has no ref
|
|
351
|
+
// to compare against and reports no tracked changes.
|
|
352
|
+
const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(
|
|
353
|
+
Effect.catchAll(() => Effect.succeed("")),
|
|
354
|
+
)
|
|
355
|
+
const trackedPaths = parseNameStatus(splitNul(nameStatusOut))
|
|
356
|
+
|
|
357
|
+
const untrackedRaw = yield* exec("git", "ls-files", "--others", "--exclude-standard", "-z")
|
|
358
|
+
const untrackedPaths = splitNul(untrackedRaw)
|
|
359
|
+
// Nothing untracked: the index-based diff above already IS the answer,
|
|
360
|
+
// and the two classification reads below are not paid for at all.
|
|
361
|
+
if (untrackedPaths.length === 0) return trackedPaths
|
|
362
|
+
|
|
363
|
+
const untrackedChanges = yield* classifyUntracked(exec, ref, untrackedPaths)
|
|
364
|
+
|
|
365
|
+
// The untracked classification OVERRIDES the tracked diff for its own
|
|
366
|
+
// paths: `git diff --name-status <ref>` compares `ref` to the INDEX, so
|
|
367
|
+
// a path the index no longer carries reports `D` even while sitting
|
|
368
|
+
// right there on disk — a phantom deletion, not a real one.
|
|
369
|
+
const untrackedSet = new Set(untrackedPaths)
|
|
370
|
+
const seen = new Set<string>()
|
|
371
|
+
const all: Array<{ path: string; status: string }> = []
|
|
372
|
+
for (const entry of [
|
|
373
|
+
...trackedPaths.filter((e) => !untrackedSet.has(e.path)),
|
|
374
|
+
...untrackedChanges,
|
|
375
|
+
]) {
|
|
376
|
+
if (!seen.has(entry.path)) {
|
|
377
|
+
seen.add(entry.path)
|
|
378
|
+
all.push(entry)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return all
|
|
382
|
+
}),
|
|
383
|
+
|
|
384
|
+
resolveRef: (ref: string) =>
|
|
385
|
+
exec("git", "rev-parse", "--verify", ref).pipe(
|
|
386
|
+
Effect.map((s) => s.trim()),
|
|
387
|
+
Effect.flatMap((hash) => resolvedRefOrCorrupted(ref, hash)),
|
|
388
|
+
),
|
|
389
|
+
|
|
390
|
+
readRefOption: (ref: string) =>
|
|
391
|
+
exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(
|
|
392
|
+
Effect.map((s) => Option.some(s.trim())),
|
|
393
|
+
Effect.catchAll(() => Effect.succeed(Option.none<string>())),
|
|
394
|
+
),
|
|
395
|
+
|
|
396
|
+
isAncestor: (a: string, b: string) =>
|
|
397
|
+
run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(
|
|
398
|
+
Effect.provide(Layer.succeed(CommandExecutor.CommandExecutor, executor)),
|
|
399
|
+
Effect.map(() => true),
|
|
400
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
401
|
+
),
|
|
402
|
+
|
|
403
|
+
mergeBase: (a: string, b: string) =>
|
|
404
|
+
exec("git", "merge-base", a, b).pipe(
|
|
405
|
+
Effect.map((s) => Option.some(s.trim())),
|
|
406
|
+
Effect.catchAll(() => Effect.succeed(Option.none<string>())),
|
|
407
|
+
),
|
|
408
|
+
|
|
409
|
+
defaultBranch: () =>
|
|
410
|
+
exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(
|
|
411
|
+
Effect.map((s) => s.trim()),
|
|
412
|
+
Effect.catchAll(() => Effect.succeed("")),
|
|
413
|
+
Effect.map((name) => (name === "" ? "main" : name)),
|
|
414
|
+
),
|
|
415
|
+
|
|
416
|
+
topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(Effect.map((s) => s.trim())),
|
|
417
|
+
|
|
418
|
+
gitDir: () => exec("git", "rev-parse", "--absolute-git-dir").pipe(Effect.map((s) => s.trim())),
|
|
419
|
+
|
|
420
|
+
gitCommonDir: () =>
|
|
421
|
+
exec("git", "rev-parse", "--path-format=absolute", "--git-common-dir").pipe(
|
|
422
|
+
Effect.map((s) => s.trim()),
|
|
423
|
+
),
|
|
424
|
+
|
|
425
|
+
commitHistory: (base?: string, head = "HEAD") => {
|
|
426
|
+
const range = base !== undefined ? `${base}..${head}` : head !== "HEAD" ? head : undefined
|
|
427
|
+
const args: [string, ...Array<string>] = [
|
|
428
|
+
"git",
|
|
429
|
+
"log",
|
|
430
|
+
"--first-parent",
|
|
431
|
+
"--reverse",
|
|
432
|
+
"-z",
|
|
433
|
+
"--format=%x01%H%x02%B%x02",
|
|
434
|
+
"--name-status",
|
|
435
|
+
...(range !== undefined ? [range] : []),
|
|
436
|
+
]
|
|
437
|
+
return exec(...args).pipe(
|
|
438
|
+
Effect.map((out) =>
|
|
439
|
+
out
|
|
440
|
+
.split("\x01")
|
|
441
|
+
.filter((chunk) => chunk.length > 0)
|
|
442
|
+
.map((chunk) => {
|
|
443
|
+
const parts = chunk.split("\x02")
|
|
444
|
+
const hash = (parts[0] ?? "").trim()
|
|
445
|
+
const message = (parts[1] ?? "").trim()
|
|
446
|
+
// A literal \x02 byte inside the commit message (the documented
|
|
447
|
+
// residual risk) would otherwise silently drop the extra split
|
|
448
|
+
// pieces — rejoining keeps that failure mode no worse than
|
|
449
|
+
// before, without pretending to fix it.
|
|
450
|
+
const tail = parts.slice(2).join("")
|
|
451
|
+
const entries = parseNameStatus(splitNul(stripCommitSeam(tail)))
|
|
452
|
+
const touched = entries.map((e) => e.path)
|
|
453
|
+
return { hash, message, touched }
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
// Empty repo (no HEAD) makes `git log` fail; treat as no commits.
|
|
457
|
+
Effect.catchAll(() =>
|
|
458
|
+
Effect.succeed(
|
|
459
|
+
[] as ReadonlyArray<{
|
|
460
|
+
readonly hash: string
|
|
461
|
+
readonly message: string
|
|
462
|
+
readonly touched: ReadonlyArray<string>
|
|
463
|
+
}>,
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
subjectHistory: (pageSize: number, skip: number, head = "HEAD") => {
|
|
470
|
+
const ref = head !== "HEAD" ? head : undefined
|
|
471
|
+
const args: [string, ...Array<string>] = [
|
|
472
|
+
"git",
|
|
473
|
+
"log",
|
|
474
|
+
"--first-parent",
|
|
475
|
+
"--reverse",
|
|
476
|
+
"-z",
|
|
477
|
+
"--format=%H%x02%s",
|
|
478
|
+
`--skip=${skip}`,
|
|
479
|
+
...(Number.isFinite(pageSize) ? [`--max-count=${pageSize}`] : []),
|
|
480
|
+
...(ref !== undefined ? [ref] : []),
|
|
481
|
+
]
|
|
482
|
+
return exec(...args).pipe(
|
|
483
|
+
Effect.map((out) =>
|
|
484
|
+
splitNul(out).map((chunk) => {
|
|
485
|
+
const sep = chunk.indexOf("\x02")
|
|
486
|
+
const hash = (sep === -1 ? chunk : chunk.slice(0, sep)).trim()
|
|
487
|
+
const subject = (sep === -1 ? "" : chunk.slice(sep + 1)).trim()
|
|
488
|
+
return { hash, subject }
|
|
489
|
+
}),
|
|
490
|
+
),
|
|
491
|
+
// Empty repo (no HEAD), or an unresolvable `head`, makes `git log`
|
|
492
|
+
// fail; treat as no commits — the same fold `commitHistory` applies.
|
|
493
|
+
Effect.catchAll(() =>
|
|
494
|
+
Effect.succeed([] as ReadonlyArray<{ readonly hash: string; readonly subject: string }>),
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
commitAllWithPrefix: (prefix: string) =>
|
|
500
|
+
Effect.gen(function* () {
|
|
501
|
+
yield* exec("git", "add", "-A")
|
|
502
|
+
yield* exec("git", "commit", "--allow-empty", "-m", prefix).pipe(
|
|
503
|
+
Effect.catchAll((error) =>
|
|
504
|
+
// Hooks like lint-staged block empty commits even with --allow-empty.
|
|
505
|
+
// gtd's workflow commits have nothing for code-quality hooks to validate,
|
|
506
|
+
// so retry without the pre-commit hook when that guard fires.
|
|
507
|
+
error.message.includes("empty git commit")
|
|
508
|
+
? exec("git", "commit", "--allow-empty", "--no-verify", "-m", prefix)
|
|
509
|
+
: Effect.fail(error),
|
|
510
|
+
),
|
|
511
|
+
)
|
|
512
|
+
}).pipe(Effect.asVoid),
|
|
513
|
+
|
|
514
|
+
softResetTo: (ref: string) => exec("git", "reset", "--soft", ref).pipe(Effect.asVoid),
|
|
515
|
+
|
|
516
|
+
commitAsIs: (message: string) =>
|
|
517
|
+
exec("git", "commit", "--allow-empty", "-m", message)
|
|
518
|
+
.pipe(
|
|
519
|
+
Effect.catchAll((error) =>
|
|
520
|
+
error.message.includes("empty git commit")
|
|
521
|
+
? exec("git", "commit", "--allow-empty", "--no-verify", "-m", message)
|
|
522
|
+
: Effect.fail(error),
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
.pipe(Effect.asVoid),
|
|
526
|
+
|
|
527
|
+
discardPending: () =>
|
|
528
|
+
Effect.gen(function* () {
|
|
529
|
+
yield* exec("git", "add", "-A")
|
|
530
|
+
yield* exec("git", "reset", "--hard", "HEAD")
|
|
531
|
+
}).pipe(Effect.asVoid),
|
|
532
|
+
|
|
533
|
+
updateRef: (ref: string, hash: string) =>
|
|
534
|
+
exec("git", "update-ref", ref, hash).pipe(Effect.asVoid),
|
|
535
|
+
|
|
536
|
+
deleteRef: (ref: string) => exec("git", "update-ref", "-d", ref).pipe(Effect.asVoid),
|
|
537
|
+
|
|
538
|
+
mixedResetTo: (ref: string) => exec("git", "reset", "--mixed", ref).pipe(Effect.asVoid),
|
|
539
|
+
|
|
540
|
+
hardResetTo: (ref: string) => exec("git", "reset", "--hard", ref).pipe(Effect.asVoid),
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const makeLiveEffect = Effect.gen(function* () {
|
|
545
|
+
const executor = yield* CommandExecutor.CommandExecutor
|
|
546
|
+
const { root } = yield* Host
|
|
547
|
+
return makeGitImpl(executor, root)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
export class GitService extends Context.Tag("GitService")<GitService, GitOperations>() {
|
|
551
|
+
static Live = Layer.effect(GitService, makeLiveEffect)
|
|
552
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs"
|
|
2
|
+
import { homedir, tmpdir } from "node:os"
|
|
3
|
+
import { Context, Effect, Layer } from "effect"
|
|
4
|
+
|
|
5
|
+
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
6
|
+
|
|
7
|
+
export interface HostOps {
|
|
8
|
+
readonly root: string
|
|
9
|
+
readonly home: string
|
|
10
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
11
|
+
/** `env["TMPDIR"]` when set, `node:os`'s `tmpdir()` otherwise — never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
|
|
12
|
+
readonly scratchDir: string
|
|
13
|
+
/** The real, symlink-resolved form of `path` — used to compare a possibly-symlinked cwd (macOS `/tmp` → `/private/tmp`) against git's own resolution. */
|
|
14
|
+
readonly realPath: (path: string) => Effect.Effect<string, Error>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface HostOpsInput {
|
|
18
|
+
readonly root: string
|
|
19
|
+
readonly home: string
|
|
20
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
21
|
+
readonly scratchDir?: string
|
|
22
|
+
readonly realPath?: (path: string) => Effect.Effect<string, Error>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const makeHostOps = (opts: HostOpsInput): HostOps => ({
|
|
26
|
+
root: opts.root,
|
|
27
|
+
home: opts.home,
|
|
28
|
+
env: opts.env,
|
|
29
|
+
scratchDir:
|
|
30
|
+
opts.scratchDir ??
|
|
31
|
+
(opts.env["TMPDIR"] !== undefined && opts.env["TMPDIR"]!.length > 0
|
|
32
|
+
? opts.env["TMPDIR"]!
|
|
33
|
+
: tmpdir()),
|
|
34
|
+
realPath:
|
|
35
|
+
opts.realPath ??
|
|
36
|
+
((path: string) => Effect.try({ try: () => realpathSync(path), catch: toError })),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export class Host extends Context.Tag("Host")<Host, HostOps>() {
|
|
40
|
+
static layer = (opts: HostOpsInput): Layer.Layer<Host> => Layer.succeed(Host, makeHostOps(opts))
|
|
41
|
+
static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect, Option } from "effect"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { strictGitOperations as stubGit } from "../testkit/index.js"
|
|
4
|
+
import {
|
|
5
|
+
clearRetainedHistory,
|
|
6
|
+
HISTORY_REF,
|
|
7
|
+
readRetainedHistory,
|
|
8
|
+
restorability,
|
|
9
|
+
} from "./RetainedHistory.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unit coverage for the retention edge module (`src/platform/RetainedHistory.ts`),
|
|
13
|
+
* using `strictGitOperations` (`src/testkit/StrictGitOperations.ts`) as its
|
|
14
|
+
* `GitOperations` double — a Proxy failing on any method a test didn't
|
|
15
|
+
* override, so an unexpected call fails loudly instead of silently succeeding.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const run = <A>(effect: Effect.Effect<A, Error>): Promise<A> => Effect.runPromise(effect)
|
|
19
|
+
|
|
20
|
+
describe("readRetainedHistory", () => {
|
|
21
|
+
it("returns Option.none() when readRefOption resolves to none", async () => {
|
|
22
|
+
const git = stubGit({ readRefOption: () => Effect.succeed(Option.none()) })
|
|
23
|
+
const result = await run(readRetainedHistory(git))
|
|
24
|
+
expect(Option.isNone(result)).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("returns Option.some(hash) when readRefOption resolves to some hash", async () => {
|
|
28
|
+
const git = stubGit({ readRefOption: () => Effect.succeed(Option.some("abc123")) })
|
|
29
|
+
const result = await run(readRetainedHistory(git))
|
|
30
|
+
expect(result).toEqual(Option.some("abc123"))
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("restorability", () => {
|
|
35
|
+
it("HEAD === tipHash (the degenerate ancestor case) is safe", async () => {
|
|
36
|
+
const git = stubGit({ isAncestor: () => Effect.succeed(true) })
|
|
37
|
+
const result = await run(restorability(git, "tip123", "tip123"))
|
|
38
|
+
expect(result).toEqual({ ok: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("cleaned abandon / fast-forward — headHash !== tipHash, isAncestor true — is safe", async () => {
|
|
42
|
+
const git = stubGit({ isAncestor: () => Effect.succeed(true) })
|
|
43
|
+
const result = await run(restorability(git, "head789", "tip123"))
|
|
44
|
+
expect(result).toEqual({ ok: true })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("refuses when HEAD is not an ancestor of the retained tip", async () => {
|
|
48
|
+
const git = stubGit({ isAncestor: () => Effect.succeed(false) })
|
|
49
|
+
const result = await run(restorability(git, "head789", "tip123"))
|
|
50
|
+
expect(result.ok).toBe(false)
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
expect(typeof result.reason).toBe("string")
|
|
53
|
+
expect(result.reason.length).toBeGreaterThan(0)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("clearRetainedHistory", () => {
|
|
59
|
+
it("deletes HISTORY_REF", async () => {
|
|
60
|
+
const deleteRef = vi.fn(() => Effect.succeed(undefined))
|
|
61
|
+
const git = stubGit({ deleteRef })
|
|
62
|
+
await run(clearRetainedHistory(git))
|
|
63
|
+
expect(deleteRef).toHaveBeenCalledWith(HISTORY_REF)
|
|
64
|
+
})
|
|
65
|
+
})
|