@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,631 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import {
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readlinkSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs"
|
|
11
|
+
import { dirname, isAbsolute, join } from "node:path"
|
|
12
|
+
import { Context, Effect, Layer } from "effect"
|
|
13
|
+
import { GitService, type GitOperations } from "./Git.js"
|
|
14
|
+
import { Host } from "./Host.js"
|
|
15
|
+
|
|
16
|
+
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
17
|
+
|
|
18
|
+
// A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
|
|
19
|
+
const MAX_BUFFER = 256 * 1024 * 1024
|
|
20
|
+
const HASH_CHUNK = 500
|
|
21
|
+
|
|
22
|
+
/** A full sha1 or sha256 object id — the only refs an immutable, disk-cached read is safe to key on. */
|
|
23
|
+
const isObjectHash = (ref: string): boolean => /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ref)
|
|
24
|
+
|
|
25
|
+
const GTD_CACHE_SUBDIR = "gtd-cache"
|
|
26
|
+
|
|
27
|
+
const treeCachePath = (commonDir: string, hash: string): string =>
|
|
28
|
+
join(commonDir, GTD_CACHE_SUBDIR, "trees", hash)
|
|
29
|
+
|
|
30
|
+
const blobCachePath = (commonDir: string, id: string): string =>
|
|
31
|
+
join(commonDir, GTD_CACHE_SUBDIR, "blobs", id)
|
|
32
|
+
|
|
33
|
+
const readTextCache = (path: string): string | undefined => {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(path, "utf8")
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Atomic write: a temp name in the SAME directory, `rename`d into place —
|
|
43
|
+
* rename is atomic on POSIX, so a concurrent reader sees either the old file
|
|
44
|
+
* or the whole new one, never a half-written one. Any failure (full disk, a
|
|
45
|
+
* read-only git directory, a raced directory removal) is swallowed: the
|
|
46
|
+
* cache is a best-effort speedup, never a correctness dependency, and the
|
|
47
|
+
* alternative — failing a gtd command over a cache write — is worse.
|
|
48
|
+
*/
|
|
49
|
+
const writeTextCacheAtomic = (path: string, content: string): void => {
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
52
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
53
|
+
writeFileSync(tmp, content, "utf8")
|
|
54
|
+
renameSync(tmp, path)
|
|
55
|
+
} catch {
|
|
56
|
+
// A full disk or read-only git dir silently disables the cache.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A commit's `path → blob id` map, serialized as JSON. `undefined` on ANY read failure — missing, truncated, unparseable — so the caller falls back to asking git. */
|
|
61
|
+
const readTreeCache = (commonDir: string, hash: string): Map<string, string> | undefined => {
|
|
62
|
+
const raw = readTextCache(treeCachePath(commonDir, hash))
|
|
63
|
+
if (raw === undefined) return undefined
|
|
64
|
+
try {
|
|
65
|
+
const parsed: unknown = JSON.parse(raw)
|
|
66
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
|
|
67
|
+
const map = new Map<string, string>()
|
|
68
|
+
for (const [path, id] of Object.entries(parsed as Record<string, unknown>)) {
|
|
69
|
+
if (typeof id !== "string") return undefined
|
|
70
|
+
map.set(path, id)
|
|
71
|
+
}
|
|
72
|
+
return map
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const writeTreeCache = (commonDir: string, hash: string, tree: ReadonlyMap<string, string>): void =>
|
|
79
|
+
writeTextCacheAtomic(treeCachePath(commonDir, hash), JSON.stringify(Object.fromEntries(tree)))
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One raw `git log --raw` diff entry, already resolved to what it does to a
|
|
83
|
+
* `path → blob id` map — a rename deletes its old path, a copy does not.
|
|
84
|
+
*/
|
|
85
|
+
interface RawDiffEntry {
|
|
86
|
+
readonly status: string
|
|
87
|
+
readonly oldPath?: string
|
|
88
|
+
readonly path: string
|
|
89
|
+
readonly newId: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parses a `:`-prefixed raw-diff header token into its status letter and new blob id. */
|
|
93
|
+
const parseRawDiffHeader = (
|
|
94
|
+
header: string,
|
|
95
|
+
): { readonly letter: string; readonly newId: string } => {
|
|
96
|
+
const fields = header
|
|
97
|
+
.slice(1)
|
|
98
|
+
.split(" ")
|
|
99
|
+
.filter((f) => f.length > 0)
|
|
100
|
+
const status = fields[4] ?? ""
|
|
101
|
+
const newId = fields[3] ?? ""
|
|
102
|
+
return { letter: status[0] ?? "", newId }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Consumes the 1 (or, for rename/copy, 2) NUL-separated path tokens that
|
|
107
|
+
* follow a raw-diff header, starting at `tokens[i]`. Returns the resulting
|
|
108
|
+
* entry (`undefined` if a path token is missing) and the next index.
|
|
109
|
+
*/
|
|
110
|
+
const consumeRawDiffPaths = (
|
|
111
|
+
tokens: readonly string[],
|
|
112
|
+
i: number,
|
|
113
|
+
letter: string,
|
|
114
|
+
newId: string,
|
|
115
|
+
): { readonly entry: RawDiffEntry | undefined; readonly next: number } => {
|
|
116
|
+
if (letter === "R" || letter === "C") {
|
|
117
|
+
const oldPath = tokens[i]
|
|
118
|
+
const path = tokens[i + 1]
|
|
119
|
+
const entry =
|
|
120
|
+
oldPath !== undefined && path !== undefined
|
|
121
|
+
? { status: letter, oldPath, path, newId }
|
|
122
|
+
: undefined
|
|
123
|
+
return { entry, next: i + 2 }
|
|
124
|
+
}
|
|
125
|
+
const path = tokens[i]
|
|
126
|
+
const entry = path !== undefined ? { status: letter, path, newId } : undefined
|
|
127
|
+
return { entry, next: i + 1 }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Parses the NUL-separated body of ONE commit's `--raw -z` diff (the format string's own separator already stripped). */
|
|
131
|
+
const parseRawDiff = (body: string): readonly RawDiffEntry[] => {
|
|
132
|
+
const tokens = body.split("\0").filter((t) => t.length > 0)
|
|
133
|
+
const entries: RawDiffEntry[] = []
|
|
134
|
+
let i = 0
|
|
135
|
+
while (i < tokens.length) {
|
|
136
|
+
const header = tokens[i]!
|
|
137
|
+
i += 1
|
|
138
|
+
if (!header.startsWith(":")) continue
|
|
139
|
+
const { letter, newId } = parseRawDiffHeader(header)
|
|
140
|
+
const { entry, next } = consumeRawDiffPaths(tokens, i, letter, newId)
|
|
141
|
+
i = next
|
|
142
|
+
if (entry !== undefined) entries.push(entry)
|
|
143
|
+
}
|
|
144
|
+
return entries
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Applies one commit's raw-diff entries to a (mutable, working-copy) tree map. */
|
|
148
|
+
const applyRawDiff = (tree: Map<string, string>, entries: readonly RawDiffEntry[]): void => {
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (entry.status === "D") tree.delete(entry.path)
|
|
151
|
+
else {
|
|
152
|
+
if (entry.status === "R") tree.delete(entry.oldPath!)
|
|
153
|
+
tree.set(entry.path, entry.newId)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
|
|
159
|
+
const parseLsTree = (out: string): Map<string, string> => {
|
|
160
|
+
const entries = new Map<string, string>()
|
|
161
|
+
for (const record of out.split("\0")) {
|
|
162
|
+
const tab = record.indexOf("\t")
|
|
163
|
+
if (tab === -1) continue
|
|
164
|
+
const id = record.slice(0, tab).split(" ")[2]
|
|
165
|
+
if (id !== undefined) entries.set(record.slice(tab + 1), id)
|
|
166
|
+
}
|
|
167
|
+
return entries
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Runs `git ls-tree` for `ref` and reports whether it actually succeeded —
|
|
172
|
+
* `ok: false` on ANY subprocess failure (a transient `EAGAIN`/`ENOMEM` on
|
|
173
|
+
* fork, a `git gc` race, a signal), never folded into an empty tree here.
|
|
174
|
+
* The disk/memo cache must only ever hold a git-CONFIRMED tree: an immutable
|
|
175
|
+
* key can serve a wrong answer forever if what's stored under it came from a
|
|
176
|
+
* failed read rather than from the key itself, so callers that cache must
|
|
177
|
+
* check `ok` before doing so.
|
|
178
|
+
*/
|
|
179
|
+
const fetchTree = (
|
|
180
|
+
root: string,
|
|
181
|
+
ref: string,
|
|
182
|
+
): { readonly ok: boolean; readonly entries: Map<string, string> } => {
|
|
183
|
+
try {
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
entries: parseLsTree(
|
|
187
|
+
execFileSync("git", ["ls-tree", "-r", "-z", "--full-tree", ref], {
|
|
188
|
+
cwd: root,
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
maxBuffer: MAX_BUFFER,
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: false, entries: new Map() }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The one port onto repo file content, in four REPO-RELATIVE read shapes:
|
|
201
|
+
* `readSync` and `readCommittedSync` (replay reads the tree between two
|
|
202
|
+
* awaits and cannot `yield*` — see the seam rule in `index.ts`), `read` (the
|
|
203
|
+
* Effect shape everything else uses) and `committed` (the async read-at-ref
|
|
204
|
+
* path, `git show <ref>:<path>`). Absence is
|
|
205
|
+
* a VALUE (`undefined`) on every read shape, never an error — a
|
|
206
|
+
* genuine fault (EACCES, EISDIR) still fails/throws for the two working-tree
|
|
207
|
+
* shapes; `committed`/`readCommittedSync` fold EVERY git failure (missing
|
|
208
|
+
* ref, missing path, a real fault) into absence, matching `git show`'s own
|
|
209
|
+
* flat non-zero-exit signal. An ABSOLUTE `path` on any of these four (plus
|
|
210
|
+
* `write`) fails loudly rather than silently escaping the repo — that escape
|
|
211
|
+
* is `atPath`/`writeAtPath`'s job alone, named so it reads as deliberate at
|
|
212
|
+
* each call site: `src/workflow/load.ts`'s cwd→home discovery walk
|
|
213
|
+
* (read-only); and `Program.ts`'s `gtd check`/`gtd uncheck`,
|
|
214
|
+
* whose `<file>` argument is an arbitrary CLI-given path — absolute or
|
|
215
|
+
* relative to `root` — never a repo-scoped one.
|
|
216
|
+
* Keeping the escape on separately-named members is what makes "a
|
|
217
|
+
* repo-relative path reaching `/tmp`" (the failure mode `Host`'s separate
|
|
218
|
+
* scratch port exists to prevent) a loud misuse of the wrong method, not a
|
|
219
|
+
* silent path-shape coincidence.
|
|
220
|
+
*/
|
|
221
|
+
export interface WorkspaceOps {
|
|
222
|
+
readonly readSync: (path: string) => string | undefined
|
|
223
|
+
readonly read: (path: string) => Effect.Effect<string | undefined, Error>
|
|
224
|
+
readonly write: (path: string, content: string) => Effect.Effect<void, Error>
|
|
225
|
+
readonly committed: (path: string, ref?: string) => Effect.Effect<string | undefined, Error>
|
|
226
|
+
/** `committed`, synchronous — see the interface doc comment. */
|
|
227
|
+
readonly readCommittedSync: (path: string, ref?: string) => string | undefined
|
|
228
|
+
/**
|
|
229
|
+
* Every path committed at `ref`, with its blob id — sync for the same
|
|
230
|
+
* reason `readCommittedSync` is: flow code reads a commit's tree between
|
|
231
|
+
* two awaits and cannot wait on IO.
|
|
232
|
+
*/
|
|
233
|
+
readonly treeSync: (ref: string) => ReadonlyMap<string, string>
|
|
234
|
+
/**
|
|
235
|
+
* Every path `git add -A` would commit from the working tree, with the blob
|
|
236
|
+
* id it would get — `undefined` where no id could be worked out, so a
|
|
237
|
+
* reader compares contents instead.
|
|
238
|
+
*/
|
|
239
|
+
readonly worktreeSync: () => ReadonlyMap<string, string | undefined>
|
|
240
|
+
/**
|
|
241
|
+
* `hash → (path → blob id)` for `base` (when given) and every one of
|
|
242
|
+
* `commits`, in ONE call regardless of how many commits that is: a single
|
|
243
|
+
* `git ls-tree` for `base` plus a single `git log --raw` walking
|
|
244
|
+
* `base..last` (or all of `last`'s first-parent history when `base` is
|
|
245
|
+
* `undefined` — the repository-root episode), each commit's delta applied
|
|
246
|
+
* to the previous map in memory. Every resulting map is also cached (in
|
|
247
|
+
* process and on disk, keyed by commit hash — see `treeSync`), so a
|
|
248
|
+
* commit already known from a prior call, or a prior `gtd` invocation,
|
|
249
|
+
* costs no subprocess at all.
|
|
250
|
+
*/
|
|
251
|
+
readonly episodeTrees: (
|
|
252
|
+
base: string | undefined,
|
|
253
|
+
commits: readonly string[],
|
|
254
|
+
) => Effect.Effect<ReadonlyMap<string, ReadonlyMap<string, string>>, Error>
|
|
255
|
+
/**
|
|
256
|
+
* Reads an ARBITRARY path — repo-relative or already-absolute, inside the
|
|
257
|
+
* repo, above it, or anywhere else on disk — with the same absence-is-a-
|
|
258
|
+
* value/fault-still-throws contract as `readSync`. The deliberate escape
|
|
259
|
+
* from the repo-relative contract above.
|
|
260
|
+
*/
|
|
261
|
+
readonly atPath: (path: string) => string | undefined
|
|
262
|
+
/** `atPath`'s write counterpart — `gtd uncheck`'s one caller needing it. */
|
|
263
|
+
readonly writeAtPath: (path: string, content: string) => Effect.Effect<void, Error>
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const assertRepoRelative = (path: string): string => {
|
|
267
|
+
if (isAbsolute(path)) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath"/"writeAtPath" to read/write outside the repo)`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
return path
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const readFileOrAbsent = (path: string): string | undefined => {
|
|
276
|
+
try {
|
|
277
|
+
return readFileSync(path, "utf8")
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
280
|
+
throw e
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const lstatOrAbsent = (path: string): ReturnType<typeof lstatSync> | undefined => {
|
|
285
|
+
try {
|
|
286
|
+
return lstatSync(path)
|
|
287
|
+
} catch (e) {
|
|
288
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined
|
|
289
|
+
throw e
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** One replayed commit: its hash and the (already-`\n`-stripped) `--raw -z` body `parseRawDiff` consumes. */
|
|
294
|
+
interface RawLogEntry {
|
|
295
|
+
readonly hash: string
|
|
296
|
+
readonly tail: string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Runs `git log --raw` for `base..head` (or all of `head`'s first-parent
|
|
301
|
+
* history when `base` is `undefined`) and splits the output into one
|
|
302
|
+
* `RawLogEntry` per commit, validating the result against `commits` — same
|
|
303
|
+
* count, same hashes, in order — since the two disagreeing means the episode
|
|
304
|
+
* bounds no longer match git's own history.
|
|
305
|
+
*/
|
|
306
|
+
const runRawLogReplay = (
|
|
307
|
+
root: string,
|
|
308
|
+
base: string | undefined,
|
|
309
|
+
head: string,
|
|
310
|
+
commits: readonly string[],
|
|
311
|
+
): readonly RawLogEntry[] => {
|
|
312
|
+
const range = base !== undefined ? `${base}..${head}` : head
|
|
313
|
+
const out = execFileSync(
|
|
314
|
+
"git",
|
|
315
|
+
[
|
|
316
|
+
"log",
|
|
317
|
+
"--first-parent",
|
|
318
|
+
"--reverse",
|
|
319
|
+
"-z",
|
|
320
|
+
"--raw",
|
|
321
|
+
"-r",
|
|
322
|
+
"--no-abbrev",
|
|
323
|
+
"--format=%x01%H",
|
|
324
|
+
range,
|
|
325
|
+
],
|
|
326
|
+
{ cwd: root, encoding: "utf8", maxBuffer: MAX_BUFFER },
|
|
327
|
+
)
|
|
328
|
+
const chunks = out.split("\x01").filter((c) => c.length > 0)
|
|
329
|
+
if (chunks.length !== commits.length) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`gtd: episode reconstruction expected ${commits.length} commit(s) from git log, got ${chunks.length} — the episode bounds disagree with git's own history`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
return chunks.map((chunk, i) => {
|
|
335
|
+
const nul = chunk.indexOf("\0")
|
|
336
|
+
const hash = nul === -1 ? chunk : chunk.slice(0, nul)
|
|
337
|
+
if (hash !== commits[i]) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`gtd: episode reconstruction hash mismatch at position ${i}: expected ${commits[i]}, got ${hash}`,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
const tail = nul === -1 ? "" : chunk.slice(nul + 1)
|
|
343
|
+
return { hash, tail: tail.startsWith("\n") ? tail.slice(1) : tail }
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Applies each `RawLogEntry`'s diff in order onto `baseTree`, caching (and returning) every resulting tree. */
|
|
348
|
+
const foldRawDiffs = (
|
|
349
|
+
baseTree: ReadonlyMap<string, string>,
|
|
350
|
+
replay: readonly RawLogEntry[],
|
|
351
|
+
rememberTree: (hash: string, tree: ReadonlyMap<string, string>) => void,
|
|
352
|
+
): Map<string, ReadonlyMap<string, string>> => {
|
|
353
|
+
const result = new Map<string, ReadonlyMap<string, string>>()
|
|
354
|
+
let current = new Map(baseTree)
|
|
355
|
+
for (const { hash, tail } of replay) {
|
|
356
|
+
const next = new Map(current)
|
|
357
|
+
applyRawDiff(next, parseRawDiff(tail))
|
|
358
|
+
current = next
|
|
359
|
+
result.set(hash, current)
|
|
360
|
+
rememberTree(hash, current)
|
|
361
|
+
}
|
|
362
|
+
return result
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const makeWorkspaceOps = (
|
|
366
|
+
root: string,
|
|
367
|
+
git: GitOperations,
|
|
368
|
+
commonDir: string | undefined,
|
|
369
|
+
): WorkspaceOps => {
|
|
370
|
+
const resolveAny = (path: string): string => (isAbsolute(path) ? path : join(root, path))
|
|
371
|
+
|
|
372
|
+
// Per-PROCESS memo, on the workspace instance rather than any one replay's
|
|
373
|
+
// `TreeView` — an immutable key (a commit hash, a blob id) can never serve
|
|
374
|
+
// a wrong answer, so the two replays one `gtd` command runs (and every
|
|
375
|
+
// `treeSync`/`readCommittedSync` call in between) share it for free.
|
|
376
|
+
const treeMemo = new Map<string, ReadonlyMap<string, string>>()
|
|
377
|
+
const blobMemo = new Map<string, string>()
|
|
378
|
+
|
|
379
|
+
/** A commit's tree, from the in-process memo or the on-disk cache — no subprocess. `undefined` on a cold miss. */
|
|
380
|
+
const peekTree = (hash: string): ReadonlyMap<string, string> | undefined => {
|
|
381
|
+
const memoHit = treeMemo.get(hash)
|
|
382
|
+
if (memoHit !== undefined) return memoHit
|
|
383
|
+
if (commonDir === undefined) return undefined
|
|
384
|
+
const cached = readTreeCache(commonDir, hash)
|
|
385
|
+
if (cached !== undefined) treeMemo.set(hash, cached)
|
|
386
|
+
return cached
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const rememberTree = (hash: string, tree: ReadonlyMap<string, string>): void => {
|
|
390
|
+
treeMemo.set(hash, tree)
|
|
391
|
+
if (commonDir !== undefined) writeTreeCache(commonDir, hash, tree)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const readSync = (path: string): string | undefined =>
|
|
395
|
+
readFileOrAbsent(join(root, assertRepoRelative(path)))
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A blob's content, keyed on its OWN id rather than a (path, ref) pair —
|
|
399
|
+
* the same content read from two different commits costs one read, not
|
|
400
|
+
* two. `git cat-file -p` reads any object by id directly, no pathspec
|
|
401
|
+
* needed. Any failure folds to `undefined`, matching `readCommittedSync`'s
|
|
402
|
+
* own catch-all-as-absence.
|
|
403
|
+
*/
|
|
404
|
+
const readBlobSync = (id: string): string | undefined => {
|
|
405
|
+
const memoHit = blobMemo.get(id)
|
|
406
|
+
if (memoHit !== undefined) return memoHit
|
|
407
|
+
if (commonDir !== undefined) {
|
|
408
|
+
const cached = readTextCache(blobCachePath(commonDir, id))
|
|
409
|
+
if (cached !== undefined) {
|
|
410
|
+
blobMemo.set(id, cached)
|
|
411
|
+
return cached
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
let content: string
|
|
415
|
+
try {
|
|
416
|
+
content = execFileSync("git", ["cat-file", "-p", id], {
|
|
417
|
+
cwd: root,
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
maxBuffer: MAX_BUFFER,
|
|
420
|
+
})
|
|
421
|
+
} catch {
|
|
422
|
+
return undefined
|
|
423
|
+
}
|
|
424
|
+
blobMemo.set(id, content)
|
|
425
|
+
if (commonDir !== undefined) writeTextCacheAtomic(blobCachePath(commonDir, id), content)
|
|
426
|
+
return content
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// A raw, synchronous `git show <ref>:<path>` — the sync counterpart to
|
|
430
|
+
// `committed`, for replay, which can't `yield*` mid-flow. Bypasses the Effect-based `GitOperations`
|
|
431
|
+
// entirely, the same escape `readSync` already takes around the Effect
|
|
432
|
+
// `FileSystem` — both are raw Node calls, not a second git abstraction.
|
|
433
|
+
// ANY failure (missing ref, missing path at that ref, git not on PATH)
|
|
434
|
+
// folds to `undefined`, matching `committed`'s own catch-all-as-absence.
|
|
435
|
+
//
|
|
436
|
+
// A full object-hash `ref` routes through the tree + blob caches (the
|
|
437
|
+
// path's blob id is looked up in the already-cached tree, then its
|
|
438
|
+
// content through `readBlobSync`) — a symbolic ref (`HEAD`, a branch name)
|
|
439
|
+
// is never cached here, since it can move mid-process (a landing commit),
|
|
440
|
+
// and goes straight to `git show` as before.
|
|
441
|
+
const readCommittedSync = (path: string, ref = "HEAD"): string | undefined => {
|
|
442
|
+
const relPath = assertRepoRelative(path)
|
|
443
|
+
if (isObjectHash(ref)) {
|
|
444
|
+
const id = treeSync(ref).get(relPath)
|
|
445
|
+
return id === undefined ? undefined : readBlobSync(id)
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
return execFileSync("git", ["show", `${ref}:${relPath}`], {
|
|
449
|
+
cwd: root,
|
|
450
|
+
encoding: "utf8",
|
|
451
|
+
maxBuffer: MAX_BUFFER,
|
|
452
|
+
})
|
|
453
|
+
} catch {
|
|
454
|
+
return undefined
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// `entries` is git-confirmed (`ok`) exactly when `fetchTree`'s subprocess
|
|
459
|
+
// actually succeeded — only that result is ever cached; a transient
|
|
460
|
+
// failure folds to an empty tree for THIS call only, never written to the
|
|
461
|
+
// memo or disk (see `fetchTree`'s doc comment).
|
|
462
|
+
const readTree = (ref: string, onFailure: "empty" | "throw"): ReadonlyMap<string, string> => {
|
|
463
|
+
if (isObjectHash(ref)) {
|
|
464
|
+
const cached = peekTree(ref)
|
|
465
|
+
if (cached !== undefined) return cached
|
|
466
|
+
}
|
|
467
|
+
const { ok, entries } = fetchTree(root, ref)
|
|
468
|
+
if (!ok) {
|
|
469
|
+
if (onFailure === "throw") {
|
|
470
|
+
throw new Error(`gtd: failed to read the tree at "${ref}" — git ls-tree did not succeed`)
|
|
471
|
+
}
|
|
472
|
+
return entries
|
|
473
|
+
}
|
|
474
|
+
if (isObjectHash(ref)) rememberTree(ref, entries)
|
|
475
|
+
return entries
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const treeSync = (ref: string): ReadonlyMap<string, string> => readTree(ref, "empty")
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* `treeSync`'s starting point when a failed read must not silently
|
|
482
|
+
* proceed: a cache hit or a git-confirmed `ls-tree` succeeds; any
|
|
483
|
+
* subprocess failure THROWS rather than folding to an empty tree, because
|
|
484
|
+
* `episodeTrees` folds every subsequent commit's diff on top of this one —
|
|
485
|
+
* a wrong empty base would poison every commit's cached tree in the
|
|
486
|
+
* episode, not just this one's.
|
|
487
|
+
*/
|
|
488
|
+
const confirmedTree = (ref: string): ReadonlyMap<string, string> => readTree(ref, "throw")
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The delta-stream reconstruction (package 4's Requirement A): one
|
|
492
|
+
* `ls-tree` for `base`, one `--raw` `git log` for every commit, applied in
|
|
493
|
+
* order. Skipped entirely when every requested hash is already known
|
|
494
|
+
* (memo or on-disk cache) — then this issues NO subprocess at all.
|
|
495
|
+
*/
|
|
496
|
+
const episodeTrees = (
|
|
497
|
+
base: string | undefined,
|
|
498
|
+
commits: readonly string[],
|
|
499
|
+
): Effect.Effect<ReadonlyMap<string, ReadonlyMap<string, string>>, Error> =>
|
|
500
|
+
Effect.try({
|
|
501
|
+
try: () => {
|
|
502
|
+
const result = new Map<string, ReadonlyMap<string, string>>()
|
|
503
|
+
const baseTree = base !== undefined ? confirmedTree(base) : new Map<string, string>()
|
|
504
|
+
if (base !== undefined) result.set(base, baseTree)
|
|
505
|
+
if (commits.length === 0) return result
|
|
506
|
+
|
|
507
|
+
if (commits.every((hash) => peekTree(hash) !== undefined)) {
|
|
508
|
+
for (const hash of commits) result.set(hash, treeSync(hash))
|
|
509
|
+
return result
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const head = commits[commits.length - 1]!
|
|
513
|
+
const replay = runRawLogReplay(root, base, head, commits)
|
|
514
|
+
for (const [hash, tree] of foldRawDiffs(baseTree, replay, rememberTree))
|
|
515
|
+
result.set(hash, tree)
|
|
516
|
+
return result
|
|
517
|
+
},
|
|
518
|
+
catch: toError,
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
const gitSync = (args: readonly string[], input?: string): string =>
|
|
522
|
+
execFileSync("git", args, {
|
|
523
|
+
cwd: root,
|
|
524
|
+
encoding: "utf8",
|
|
525
|
+
maxBuffer: MAX_BUFFER,
|
|
526
|
+
...(input !== undefined ? { input } : {}),
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
// A path git status reports is re-hashed the way `git add` would store it;
|
|
530
|
+
// every other path keeps HEAD's blob id, so an untouched file is never
|
|
531
|
+
// read, and never compared by working-tree bytes that eol or smudge
|
|
532
|
+
// filters, or a symlink, make differ from the blob.
|
|
533
|
+
const worktreeSync = (): ReadonlyMap<string, string | undefined> => {
|
|
534
|
+
const entries = new Map<string, string | undefined>(treeSync("HEAD"))
|
|
535
|
+
const status = gitSync([
|
|
536
|
+
"status",
|
|
537
|
+
"--porcelain=v1",
|
|
538
|
+
"-z",
|
|
539
|
+
"--untracked-files=all",
|
|
540
|
+
"--no-renames",
|
|
541
|
+
"--ignore-submodules=none",
|
|
542
|
+
])
|
|
543
|
+
const files: string[] = []
|
|
544
|
+
for (const record of status.split("\0")) {
|
|
545
|
+
if (record.length < 4) continue
|
|
546
|
+
const path = record.slice(3).replace(/\/$/, "")
|
|
547
|
+
const stat = lstatOrAbsent(join(root, path))
|
|
548
|
+
if (stat === undefined) entries.delete(path)
|
|
549
|
+
else if (stat.isSymbolicLink()) {
|
|
550
|
+
entries.set(path, blobId(readlinkSync(join(root, path), "utf8")))
|
|
551
|
+
} else if (stat.isDirectory()) {
|
|
552
|
+
// Only a directory holding its own repository is committed, as a gitlink.
|
|
553
|
+
if (lstatOrAbsent(join(root, path, ".git")) === undefined) entries.delete(path)
|
|
554
|
+
else entries.set(path, gitlinkOf(path))
|
|
555
|
+
} else files.push(path)
|
|
556
|
+
}
|
|
557
|
+
// Paths go as arguments, never newline-separated on stdin: a path may
|
|
558
|
+
// hold a newline. Chunked to stay under the argument-list limit.
|
|
559
|
+
for (let i = 0; i < files.length; i += HASH_CHUNK) {
|
|
560
|
+
const chunk = files.slice(i, i + HASH_CHUNK)
|
|
561
|
+
const ids = gitSync(["hash-object", "--", ...chunk]).split("\n")
|
|
562
|
+
chunk.forEach((path, j) => entries.set(path, ids[j] || undefined))
|
|
563
|
+
}
|
|
564
|
+
return new Map([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
|
|
568
|
+
const gitlinkOf = (path: string): string | undefined => {
|
|
569
|
+
try {
|
|
570
|
+
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
571
|
+
cwd: join(root, path),
|
|
572
|
+
encoding: "utf8",
|
|
573
|
+
}).trim()
|
|
574
|
+
} catch {
|
|
575
|
+
return undefined
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
let objectFormat: string | undefined
|
|
580
|
+
const blobId = (content: string): string => {
|
|
581
|
+
objectFormat ??= gitSync(["rev-parse", "--show-object-format"]).trim()
|
|
582
|
+
const bytes = Buffer.from(content, "utf8")
|
|
583
|
+
return createHash(objectFormat === "sha256" ? "sha256" : "sha1")
|
|
584
|
+
.update(`blob ${bytes.length}\0`)
|
|
585
|
+
.update(bytes)
|
|
586
|
+
.digest("hex")
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
treeSync,
|
|
591
|
+
worktreeSync,
|
|
592
|
+
episodeTrees,
|
|
593
|
+
readSync,
|
|
594
|
+
read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
|
|
595
|
+
write: (path, content) =>
|
|
596
|
+
Effect.try({
|
|
597
|
+
try: () => writeFileSync(join(root, assertRepoRelative(path)), content, "utf8"),
|
|
598
|
+
catch: toError,
|
|
599
|
+
}),
|
|
600
|
+
committed: (path, ref = "HEAD") =>
|
|
601
|
+
Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
|
|
602
|
+
Effect.flatMap((relPath) =>
|
|
603
|
+
git.readFileAtRef(ref, relPath).pipe(Effect.catchAll(() => Effect.succeed(undefined))),
|
|
604
|
+
),
|
|
605
|
+
),
|
|
606
|
+
readCommittedSync,
|
|
607
|
+
atPath: (path) => readFileOrAbsent(resolveAny(path)),
|
|
608
|
+
writeAtPath: (path, content) =>
|
|
609
|
+
Effect.try({ try: () => writeFileSync(resolveAny(path), content, "utf8"), catch: toError }),
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export class Workspace extends Context.Tag("Workspace")<Workspace, WorkspaceOps>() {
|
|
614
|
+
static Live = Layer.effect(
|
|
615
|
+
Workspace,
|
|
616
|
+
Effect.gen(function* () {
|
|
617
|
+
const { root } = yield* Host
|
|
618
|
+
const git = yield* GitService
|
|
619
|
+
// Resolved once per process, not lazily inside a sync method: the
|
|
620
|
+
// common dir is git's own concern to answer, and every cache read/
|
|
621
|
+
// write below is synchronous. A repo whose common dir can't be
|
|
622
|
+
// resolved (git missing, a corrupted `.git`) still constructs a
|
|
623
|
+
// working `Workspace` — it just runs without the disk cache, exactly
|
|
624
|
+
// like a full-disk failure does.
|
|
625
|
+
const commonDir = yield* git
|
|
626
|
+
.gitCommonDir()
|
|
627
|
+
.pipe(Effect.catchAll(() => Effect.succeed(undefined)))
|
|
628
|
+
return makeWorkspaceOps(root, git, commonDir)
|
|
629
|
+
}),
|
|
630
|
+
)
|
|
631
|
+
}
|