@markjaquith/agency 2.71.26 → 2.72.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/README.md +6 -1
- package/package.json +1 -1
- package/src/cli.test.ts +43 -0
- package/src/commands/archive.test.ts +17 -0
- package/src/commands/repo.test.ts +11 -1
- package/src/commands/repo.ts +17 -1
- package/src/services/RepositoryService.test.ts +150 -1
- package/src/services/RepositoryService.ts +290 -1
- package/src/services/TaskService.ts +3 -1
package/README.md
CHANGED
|
@@ -752,6 +752,7 @@ agency integration sync [--json]
|
|
|
752
752
|
agency repo setup [--dry-run | --apply] [--json]
|
|
753
753
|
agency repo add <alias> <remote> [--json]
|
|
754
754
|
agency repo link <alias> <path> [--json]
|
|
755
|
+
agency repo materialize <alias> [--json]
|
|
755
756
|
agency repo list [--json]
|
|
756
757
|
agency repo show <alias> [--json]
|
|
757
758
|
agency repo fetch <alias> [--json]
|
|
@@ -784,7 +785,11 @@ Each registration has a stable ID and may have a unique name. A default workbase
|
|
|
784
785
|
is used when the current directory is outside every workbase. `prune` removes
|
|
785
786
|
registrations whose workbase configuration no longer exists.
|
|
786
787
|
`repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
|
|
787
|
-
repository.
|
|
788
|
+
repository. For Git workbases, `repo materialize` replaces a linked alias with a
|
|
789
|
+
managed bare clone while migrating its registered worktrees in place; active
|
|
790
|
+
Agency references continue to use the same alias. The command refuses remote
|
|
791
|
+
drift and stale worktree registrations. Alias names are then used by all
|
|
792
|
+
documents and commands. Remove,
|
|
788
793
|
unlink, and rename refuse aliases referenced by active work or backed by linked
|
|
789
794
|
worktrees, and report each blocker.
|
|
790
795
|
|
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -507,6 +507,49 @@ describe("CLI", () => {
|
|
|
507
507
|
})
|
|
508
508
|
})
|
|
509
509
|
|
|
510
|
+
test("preserves the JSON error contract when archiving an already archived task", async () => {
|
|
511
|
+
const root = await createTempDir()
|
|
512
|
+
tempDirs.push(root)
|
|
513
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
514
|
+
expect(
|
|
515
|
+
Bun.spawnSync(["git", "init", "--bare", join(root, "repos/agency")])
|
|
516
|
+
.exitCode,
|
|
517
|
+
).toBe(0)
|
|
518
|
+
await mkdir(join(root, "tasks/example"), { recursive: true })
|
|
519
|
+
await Bun.write(
|
|
520
|
+
join(root, "tasks/example/TASK.md"),
|
|
521
|
+
`---
|
|
522
|
+
ticketUrl: null
|
|
523
|
+
repo: agency
|
|
524
|
+
branch: task/example
|
|
525
|
+
base: main
|
|
526
|
+
pr: null
|
|
527
|
+
status: dropped
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
# Example
|
|
531
|
+
`,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
expect((await runCli(["archive", "task", "example"], root)).exitCode).toBe(
|
|
535
|
+
0,
|
|
536
|
+
)
|
|
537
|
+
const result = await runCli(["archive", "task", "example", "--json"], root)
|
|
538
|
+
|
|
539
|
+
expect(result.exitCode).toBe(1)
|
|
540
|
+
expect(result.stderr).toBe("")
|
|
541
|
+
expect(JSON.parse(result.stdout)).toEqual({
|
|
542
|
+
version: 1,
|
|
543
|
+
ok: false,
|
|
544
|
+
error: {
|
|
545
|
+
code: "TASK_ERROR",
|
|
546
|
+
message: "Task 'example' is already archived",
|
|
547
|
+
fields: {},
|
|
548
|
+
retryable: false,
|
|
549
|
+
},
|
|
550
|
+
})
|
|
551
|
+
})
|
|
552
|
+
|
|
510
553
|
test("rejects malformed input before running a command", async () => {
|
|
511
554
|
const parent = await createTempDir()
|
|
512
555
|
tempDirs.push(parent)
|
|
@@ -93,6 +93,23 @@ describe("archive command", () => {
|
|
|
93
93
|
)
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
+
test("reports an already archived task", async () => {
|
|
97
|
+
await runTestEffect(
|
|
98
|
+
archive({ type: "task", args: ["example"], cwd: root, silent: true }),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
runTestEffect(
|
|
103
|
+
archive({
|
|
104
|
+
type: "task",
|
|
105
|
+
args: ["example"],
|
|
106
|
+
cwd: root,
|
|
107
|
+
silent: true,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
).rejects.toThrow("Task 'example' is already archived")
|
|
111
|
+
})
|
|
112
|
+
|
|
96
113
|
test("outputs one deterministic bulk archive object and a concise human summary", async () => {
|
|
97
114
|
const jsonLogs = await captureLogs(() =>
|
|
98
115
|
runTestEffect(
|
|
@@ -23,7 +23,9 @@ describe("repo command", () => {
|
|
|
23
23
|
test("requires a subcommand", async () => {
|
|
24
24
|
await expect(
|
|
25
25
|
runTestEffect(repo({ args: [], silent: true })),
|
|
26
|
-
).rejects.toThrow(
|
|
26
|
+
).rejects.toThrow(
|
|
27
|
+
"Available subcommands: setup, add, link, materialize, list",
|
|
28
|
+
)
|
|
27
29
|
})
|
|
28
30
|
|
|
29
31
|
test("requires add arguments", async () => {
|
|
@@ -34,6 +36,14 @@ describe("repo command", () => {
|
|
|
34
36
|
).rejects.toThrow("Usage: agency repo add")
|
|
35
37
|
})
|
|
36
38
|
|
|
39
|
+
test("requires a materialize alias", async () => {
|
|
40
|
+
await expect(
|
|
41
|
+
runTestEffect(
|
|
42
|
+
repo({ subcommand: "materialize", args: [], silent: true }),
|
|
43
|
+
),
|
|
44
|
+
).rejects.toThrow("Usage: agency repo materialize <alias>")
|
|
45
|
+
})
|
|
46
|
+
|
|
37
47
|
test("lists repository metadata as JSON", async () => {
|
|
38
48
|
const logs = await captureLogs(() =>
|
|
39
49
|
runTestEffect(
|
package/src/commands/repo.ts
CHANGED
|
@@ -74,6 +74,21 @@ export const repo = (options: RepoOptions) =>
|
|
|
74
74
|
return
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
case "materialize": {
|
|
78
|
+
const alias = yield* requireArg(
|
|
79
|
+
options.args,
|
|
80
|
+
0,
|
|
81
|
+
"Usage: agency repo materialize <alias>",
|
|
82
|
+
)
|
|
83
|
+
const item = yield* repositories.materialize(alias, cwd)
|
|
84
|
+
log(
|
|
85
|
+
options.json
|
|
86
|
+
? JSON.stringify(item, null, 2)
|
|
87
|
+
: `Materialized repository '${alias}'`,
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
case "list": {
|
|
78
93
|
const items = yield* repositories.list(cwd)
|
|
79
94
|
if (options.json) {
|
|
@@ -187,7 +202,7 @@ export const repo = (options: RepoOptions) =>
|
|
|
187
202
|
default:
|
|
188
203
|
return yield* Effect.fail(
|
|
189
204
|
new Error(
|
|
190
|
-
"Subcommand is required. Available subcommands: setup, add, link, list, show, fetch, remove, unlink, rename, remote, verify",
|
|
205
|
+
"Subcommand is required. Available subcommands: setup, add, link, materialize, list, show, fetch, remove, unlink, rename, remote, verify",
|
|
191
206
|
),
|
|
192
207
|
)
|
|
193
208
|
}
|
|
@@ -200,6 +215,7 @@ Subcommands:
|
|
|
200
215
|
setup Plan or apply portable repository setup
|
|
201
216
|
add <alias> <remote> Create a bare clone
|
|
202
217
|
link <alias> <path> Link an existing Git repository
|
|
218
|
+
materialize <alias> Replace a linked alias with a managed bare clone
|
|
203
219
|
list List repository aliases
|
|
204
220
|
show <alias> Show a repository alias
|
|
205
221
|
fetch <alias> Fetch and prune a repository
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir, stat } from "node:fs/promises"
|
|
3
|
+
import { mkdir, realpath, stat } from "node:fs/promises"
|
|
4
4
|
import { dirname, join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { RepositoryService } from "./RepositoryService"
|
|
@@ -16,6 +16,18 @@ const runGit = async (args: string[]) => {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const gitOutput = async (args: string[]) => {
|
|
20
|
+
const process = Bun.spawn(["git", ...args], {
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
})
|
|
24
|
+
await process.exited
|
|
25
|
+
if (process.exitCode !== 0) {
|
|
26
|
+
throw new Error(await new Response(process.stderr).text())
|
|
27
|
+
}
|
|
28
|
+
return (await new Response(process.stdout).text()).trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
const portableRemote = (name: string) =>
|
|
20
32
|
`https://example.com/agency-tests/${name}.git`
|
|
21
33
|
|
|
@@ -159,6 +171,143 @@ describe("RepositoryService", () => {
|
|
|
159
171
|
})
|
|
160
172
|
})
|
|
161
173
|
|
|
174
|
+
test("materializes a linked alias without invalidating active worktrees", async () => {
|
|
175
|
+
const target = join(root, "linked-repository")
|
|
176
|
+
const checkout = join(root, "tasks/active/code/linked")
|
|
177
|
+
await mkdir(target, { recursive: true })
|
|
178
|
+
await runGit(["init", "--initial-branch=main", target])
|
|
179
|
+
await runGit(["-C", target, "config", "user.email", "test@example.com"])
|
|
180
|
+
await runGit(["-C", target, "config", "user.name", "Test"])
|
|
181
|
+
await Bun.write(join(target, "README.md"), "linked\n")
|
|
182
|
+
await runGit(["-C", target, "add", "README.md"])
|
|
183
|
+
await runGit(["-C", target, "commit", "-m", "initial"])
|
|
184
|
+
await setPortableOrigin(target, "materialized")
|
|
185
|
+
await runTestEffect(
|
|
186
|
+
RepositoryService.pipe(
|
|
187
|
+
Effect.flatMap((service) => service.link("linked", target, root)),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
await write(
|
|
191
|
+
root,
|
|
192
|
+
"tasks/active/TASK.md",
|
|
193
|
+
`---
|
|
194
|
+
ticketUrl: null
|
|
195
|
+
repo: linked
|
|
196
|
+
branch: task/active
|
|
197
|
+
base: main
|
|
198
|
+
pr: null
|
|
199
|
+
status: working
|
|
200
|
+
---
|
|
201
|
+
`,
|
|
202
|
+
)
|
|
203
|
+
await mkdir(dirname(checkout), { recursive: true })
|
|
204
|
+
await runGit([
|
|
205
|
+
"-C",
|
|
206
|
+
target,
|
|
207
|
+
"worktree",
|
|
208
|
+
"add",
|
|
209
|
+
"-b",
|
|
210
|
+
"task/active",
|
|
211
|
+
checkout,
|
|
212
|
+
"main",
|
|
213
|
+
])
|
|
214
|
+
await Bun.write(join(checkout, "dirty.txt"), "preserved\n")
|
|
215
|
+
|
|
216
|
+
const result = await runTestEffect(
|
|
217
|
+
RepositoryService.pipe(
|
|
218
|
+
Effect.flatMap((service) => service.materialize("linked", root)),
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(result.kind).toBe("bare")
|
|
223
|
+
expect(result.target).toBeNull()
|
|
224
|
+
expect(result.states).toEqual(["declared", "materialized"])
|
|
225
|
+
expect(await Bun.file(join(checkout, "dirty.txt")).text()).toBe(
|
|
226
|
+
"preserved\n",
|
|
227
|
+
)
|
|
228
|
+
expect(await gitOutput(["-C", checkout, "branch", "--show-current"])).toBe(
|
|
229
|
+
"task/active",
|
|
230
|
+
)
|
|
231
|
+
expect(
|
|
232
|
+
await gitOutput(["-C", checkout, "rev-parse", "--git-common-dir"]),
|
|
233
|
+
).toBe(await realpath(join(root, "repos/linked")))
|
|
234
|
+
expect(
|
|
235
|
+
await gitOutput(["-C", target, "worktree", "list", "--porcelain"]),
|
|
236
|
+
).not.toContain(checkout)
|
|
237
|
+
expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
|
|
238
|
+
expect(await Bun.file(join(root, "tasks/active/TASK.md")).text()).toContain(
|
|
239
|
+
"repo: linked",
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test("materializes an alias linked through an existing worktree", async () => {
|
|
244
|
+
const primary = join(root, "primary-repository")
|
|
245
|
+
const linkedWorktree = join(root, "linked-worktree")
|
|
246
|
+
await mkdir(primary, { recursive: true })
|
|
247
|
+
await runGit(["init", "--initial-branch=main", primary])
|
|
248
|
+
await runGit(["-C", primary, "config", "user.email", "test@example.com"])
|
|
249
|
+
await runGit(["-C", primary, "config", "user.name", "Test"])
|
|
250
|
+
await Bun.write(join(primary, "README.md"), "linked worktree\n")
|
|
251
|
+
await runGit(["-C", primary, "add", "README.md"])
|
|
252
|
+
await runGit(["-C", primary, "commit", "-m", "initial"])
|
|
253
|
+
await setPortableOrigin(primary, "linked-worktree")
|
|
254
|
+
await runGit([
|
|
255
|
+
"-C",
|
|
256
|
+
primary,
|
|
257
|
+
"worktree",
|
|
258
|
+
"add",
|
|
259
|
+
"-b",
|
|
260
|
+
"linked-branch",
|
|
261
|
+
linkedWorktree,
|
|
262
|
+
"main",
|
|
263
|
+
])
|
|
264
|
+
await runTestEffect(
|
|
265
|
+
RepositoryService.pipe(
|
|
266
|
+
Effect.flatMap((service) =>
|
|
267
|
+
service.link("linked", linkedWorktree, root),
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
await runTestEffect(
|
|
273
|
+
RepositoryService.pipe(
|
|
274
|
+
Effect.flatMap((service) => service.materialize("linked", root)),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
expect(
|
|
279
|
+
await gitOutput(["-C", linkedWorktree, "rev-parse", "--git-common-dir"]),
|
|
280
|
+
).toBe(await realpath(join(root, "repos/linked")))
|
|
281
|
+
expect(
|
|
282
|
+
await gitOutput(["-C", primary, "worktree", "list", "--porcelain"]),
|
|
283
|
+
).not.toContain(linkedWorktree)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test("refuses to materialize linked aliases in jj workbases", async () => {
|
|
287
|
+
if (!Bun.which("jj")) return
|
|
288
|
+
await Bun.write(
|
|
289
|
+
join(root, "agency.json"),
|
|
290
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
291
|
+
)
|
|
292
|
+
const target = join(root, "linked-jj-repository")
|
|
293
|
+
await mkdir(target, { recursive: true })
|
|
294
|
+
await runGit(["init", "--initial-branch=main", target])
|
|
295
|
+
await setPortableOrigin(target, "linked-jj")
|
|
296
|
+
await runTestEffect(
|
|
297
|
+
RepositoryService.pipe(
|
|
298
|
+
Effect.flatMap((service) => service.link("linked", target, root)),
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
await expect(
|
|
303
|
+
runTestEffect(
|
|
304
|
+
RepositoryService.pipe(
|
|
305
|
+
Effect.flatMap((service) => service.materialize("linked", root)),
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
).rejects.toThrow("only supported for Git workbases")
|
|
309
|
+
})
|
|
310
|
+
|
|
162
311
|
test("rejects invalid and duplicate aliases", async () => {
|
|
163
312
|
const source = join(root, "source.git")
|
|
164
313
|
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
2
|
import { Data, Effect, Either } from "effect"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { lstat, rename, rm } from "node:fs/promises"
|
|
4
|
+
import { cp, lstat, realpath, rename, rm } from "node:fs/promises"
|
|
5
5
|
import { FileSystemService } from "./FileSystemService"
|
|
6
6
|
import { GraphService } from "./GraphService"
|
|
7
7
|
import { WorkbaseService } from "./WorkbaseService"
|
|
@@ -327,6 +327,27 @@ const replaceWithMoveStep = (
|
|
|
327
327
|
manualRecovery: `Restore ${backup} to ${current}`,
|
|
328
328
|
})
|
|
329
329
|
|
|
330
|
+
const runGit = (
|
|
331
|
+
fs: Effect.Effect.Success<typeof FileSystemService>,
|
|
332
|
+
args: readonly string[],
|
|
333
|
+
label: string,
|
|
334
|
+
) =>
|
|
335
|
+
Effect.runPromise(
|
|
336
|
+
fs
|
|
337
|
+
.runCommand(["git", ...args], { captureOutput: true })
|
|
338
|
+
.pipe(
|
|
339
|
+
Effect.flatMap((result) =>
|
|
340
|
+
result.exitCode === 0
|
|
341
|
+
? Effect.void
|
|
342
|
+
: Effect.fail(
|
|
343
|
+
new Error(
|
|
344
|
+
`${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
),
|
|
348
|
+
) as Effect.Effect<void, unknown, never>,
|
|
349
|
+
)
|
|
350
|
+
|
|
330
351
|
const runTransaction = (
|
|
331
352
|
state: Effect.Effect.Success<ReturnType<typeof configState>>,
|
|
332
353
|
config: WorkbaseConfig,
|
|
@@ -501,6 +522,274 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
501
522
|
return destination
|
|
502
523
|
}),
|
|
503
524
|
|
|
525
|
+
materialize: (alias: string, startPath: string = process.cwd()) =>
|
|
526
|
+
Effect.gen(function* () {
|
|
527
|
+
const fs = yield* FileSystemService
|
|
528
|
+
const versionControl = yield* VersionControlService
|
|
529
|
+
const repository = yield* find(alias, startPath)
|
|
530
|
+
if (repository.kind !== "symlink" || !repository.target) {
|
|
531
|
+
return yield* new RepositoryError({
|
|
532
|
+
message: `Repository alias '${alias}' is not linked`,
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
if (repository.states.includes("invalid")) {
|
|
536
|
+
return yield* new RepositoryError({
|
|
537
|
+
message: `Repository alias '${alias}' has an invalid linked repository`,
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
if (repository.states.includes("remote-drifted")) {
|
|
541
|
+
return yield* new RepositoryError({
|
|
542
|
+
message: `Repository alias '${alias}' cannot be materialized while its origin differs from the portable declaration`,
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
if (!repository.declaredRemote) {
|
|
546
|
+
return yield* new RepositoryError({
|
|
547
|
+
message: `Repository alias '${alias}' has no portable remote declaration`,
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const state = yield* configState(startPath)
|
|
552
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
553
|
+
if (backend.kind !== "git") {
|
|
554
|
+
return yield* new RepositoryError({
|
|
555
|
+
message: `Repository alias materialization is only supported for Git workbases`,
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const source = yield* fs.realPath(repository.path)
|
|
560
|
+
const registered = yield* backend.listWorkspaces(repository.path)
|
|
561
|
+
const registeredPaths = registered
|
|
562
|
+
.map((workspace) => workspace.path)
|
|
563
|
+
.sort()
|
|
564
|
+
const worktrees: (typeof registered)[number][] = []
|
|
565
|
+
for (const workspace of registered) {
|
|
566
|
+
if (!(yield* fs.isDirectory(workspace.path))) {
|
|
567
|
+
return yield* new RepositoryError({
|
|
568
|
+
message: `Repository alias '${alias}' has a stale worktree registration: ${workspace.path}`,
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
if (
|
|
572
|
+
(yield* fs.inspectFile(join(workspace.path, ".git"))).kind ===
|
|
573
|
+
"file"
|
|
574
|
+
)
|
|
575
|
+
worktrees.push(workspace)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const commonDirectory = yield* fs
|
|
579
|
+
.runCommand(
|
|
580
|
+
[
|
|
581
|
+
"git",
|
|
582
|
+
"-C",
|
|
583
|
+
source,
|
|
584
|
+
"rev-parse",
|
|
585
|
+
"--path-format=absolute",
|
|
586
|
+
"--git-common-dir",
|
|
587
|
+
],
|
|
588
|
+
{ captureOutput: true },
|
|
589
|
+
)
|
|
590
|
+
.pipe(
|
|
591
|
+
Effect.flatMap((result) =>
|
|
592
|
+
result.exitCode === 0
|
|
593
|
+
? Effect.succeed(result.stdout.trim())
|
|
594
|
+
: Effect.fail(
|
|
595
|
+
new RepositoryError({
|
|
596
|
+
message: `Failed to locate Git metadata for repository alias '${alias}'`,
|
|
597
|
+
}),
|
|
598
|
+
),
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
const sourceWorktrees = join(commonDirectory, "worktrees")
|
|
602
|
+
const hasWorktreeMetadata = yield* fs.isDirectory(sourceWorktrees)
|
|
603
|
+
if (worktrees.length > 0 && !hasWorktreeMetadata) {
|
|
604
|
+
return yield* new RepositoryError({
|
|
605
|
+
message: `Repository alias '${alias}' is missing Git metadata for its registered worktrees`,
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const suffix = `${process.pid}-${Date.now()}`
|
|
610
|
+
const staging = join(
|
|
611
|
+
state.root,
|
|
612
|
+
"repos",
|
|
613
|
+
`.agency-materialize-${repository.alias}-${suffix}`,
|
|
614
|
+
)
|
|
615
|
+
const aliasBackup = join(
|
|
616
|
+
state.root,
|
|
617
|
+
"repos",
|
|
618
|
+
`.agency-linked-${repository.alias}-${suffix}`,
|
|
619
|
+
)
|
|
620
|
+
const metadataBackup = `${sourceWorktrees}.agency-materialize-${suffix}`
|
|
621
|
+
yield* fs.createDirectory(join(state.root, "repos"))
|
|
622
|
+
yield* backend.cloneRepository(source, staging).pipe(
|
|
623
|
+
Effect.catchAll((cause) =>
|
|
624
|
+
fs.deleteDirectory(staging).pipe(
|
|
625
|
+
Effect.ignore,
|
|
626
|
+
Effect.zipRight(
|
|
627
|
+
Effect.fail(
|
|
628
|
+
new RepositoryError({
|
|
629
|
+
message: `Failed to materialize repository '${alias}': ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
630
|
+
cause,
|
|
631
|
+
}),
|
|
632
|
+
),
|
|
633
|
+
),
|
|
634
|
+
),
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
yield* backend
|
|
638
|
+
.setRemoteUrl(staging, "origin", repository.declaredRemote)
|
|
639
|
+
.pipe(
|
|
640
|
+
Effect.catchAll((cause) =>
|
|
641
|
+
fs
|
|
642
|
+
.deleteDirectory(staging)
|
|
643
|
+
.pipe(Effect.ignore, Effect.zipRight(Effect.fail(cause))),
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
for (const workspace of worktrees) {
|
|
647
|
+
if (!workspace.commit) continue
|
|
648
|
+
const object = yield* fs.runCommand(
|
|
649
|
+
[
|
|
650
|
+
"git",
|
|
651
|
+
"--git-dir",
|
|
652
|
+
staging,
|
|
653
|
+
"cat-file",
|
|
654
|
+
"-e",
|
|
655
|
+
`${workspace.commit}^{commit}`,
|
|
656
|
+
],
|
|
657
|
+
{ captureOutput: true },
|
|
658
|
+
)
|
|
659
|
+
if (object.exitCode !== 0) {
|
|
660
|
+
yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
|
|
661
|
+
return yield* new RepositoryError({
|
|
662
|
+
message: `Registered worktree commit '${workspace.commit}' is missing from the materialized repository`,
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let metadataMoved = false
|
|
668
|
+
let aliasMoved = false
|
|
669
|
+
let cloneInstalled = false
|
|
670
|
+
const workspacePaths = worktrees.map((workspace) => workspace.path)
|
|
671
|
+
const repair = (gitDirectory: string) =>
|
|
672
|
+
workspacePaths.length === 0
|
|
673
|
+
? Promise.resolve()
|
|
674
|
+
: runGit(
|
|
675
|
+
fs,
|
|
676
|
+
[
|
|
677
|
+
"--git-dir",
|
|
678
|
+
gitDirectory,
|
|
679
|
+
"worktree",
|
|
680
|
+
"repair",
|
|
681
|
+
...workspacePaths,
|
|
682
|
+
],
|
|
683
|
+
"Failed to repair Git worktrees",
|
|
684
|
+
)
|
|
685
|
+
const rollbackMigration = async () => {
|
|
686
|
+
const errors: unknown[] = []
|
|
687
|
+
if (metadataMoved) {
|
|
688
|
+
try {
|
|
689
|
+
await rename(metadataBackup, sourceWorktrees)
|
|
690
|
+
metadataMoved = false
|
|
691
|
+
await repair(commonDirectory)
|
|
692
|
+
} catch (error) {
|
|
693
|
+
errors.push(error)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (cloneInstalled) {
|
|
697
|
+
try {
|
|
698
|
+
await rename(repository.path, staging)
|
|
699
|
+
cloneInstalled = false
|
|
700
|
+
} catch (error) {
|
|
701
|
+
errors.push(error)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (aliasMoved) {
|
|
705
|
+
try {
|
|
706
|
+
await rename(aliasBackup, repository.path)
|
|
707
|
+
aliasMoved = false
|
|
708
|
+
} catch (error) {
|
|
709
|
+
errors.push(error)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (errors.length > 0) throw new AggregateError(errors)
|
|
713
|
+
}
|
|
714
|
+
const migration: TransactionStep = {
|
|
715
|
+
label: `materialize linked repository ${repository.alias}`,
|
|
716
|
+
preflight: async () => {
|
|
717
|
+
const stats = await lstat(repository.path)
|
|
718
|
+
if (
|
|
719
|
+
!stats.isSymbolicLink() ||
|
|
720
|
+
(await realpath(repository.path)) !== source
|
|
721
|
+
)
|
|
722
|
+
throw new Error(
|
|
723
|
+
`Repository alias '${repository.alias}' changed during materialization`,
|
|
724
|
+
)
|
|
725
|
+
const current = await Effect.runPromise(
|
|
726
|
+
backend
|
|
727
|
+
.listWorkspaces(repository.path)
|
|
728
|
+
.pipe(
|
|
729
|
+
Effect.provideService(FileSystemService, fs),
|
|
730
|
+
) as unknown as Effect.Effect<
|
|
731
|
+
readonly { readonly path: string }[],
|
|
732
|
+
unknown,
|
|
733
|
+
never
|
|
734
|
+
>,
|
|
735
|
+
)
|
|
736
|
+
const currentPaths = current
|
|
737
|
+
.map((workspace) => workspace.path)
|
|
738
|
+
.sort()
|
|
739
|
+
if (
|
|
740
|
+
JSON.stringify(currentPaths) !== JSON.stringify(registeredPaths)
|
|
741
|
+
)
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Git worktree registrations changed during materialization`,
|
|
744
|
+
)
|
|
745
|
+
},
|
|
746
|
+
apply: async () => {
|
|
747
|
+
try {
|
|
748
|
+
if (hasWorktreeMetadata) {
|
|
749
|
+
await rename(sourceWorktrees, metadataBackup)
|
|
750
|
+
metadataMoved = true
|
|
751
|
+
await cp(metadataBackup, join(staging, "worktrees"), {
|
|
752
|
+
recursive: true,
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
await rename(repository.path, aliasBackup)
|
|
756
|
+
aliasMoved = true
|
|
757
|
+
await rename(staging, repository.path)
|
|
758
|
+
cloneInstalled = true
|
|
759
|
+
await repair(repository.path)
|
|
760
|
+
} catch (cause) {
|
|
761
|
+
try {
|
|
762
|
+
await rollbackMigration()
|
|
763
|
+
} catch (rollbackCause) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`Repository materialization failed and rollback requires manual recovery: restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
|
|
766
|
+
{ cause: new AggregateError([cause, rollbackCause]) },
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
throw cause
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
rollback: rollbackMigration,
|
|
773
|
+
finalize: async () => {
|
|
774
|
+
await rm(aliasBackup, { recursive: true, force: true })
|
|
775
|
+
await rm(metadataBackup, { recursive: true, force: true })
|
|
776
|
+
},
|
|
777
|
+
manualRecovery: `Restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
yield* runLifecycleTransaction({
|
|
781
|
+
root: state.root,
|
|
782
|
+
preconditions: [{ path: state.path, revision: state.revision }],
|
|
783
|
+
steps: [migration],
|
|
784
|
+
}).pipe(
|
|
785
|
+
Effect.mapError(
|
|
786
|
+
(cause) => new RepositoryError({ message: cause.message, cause }),
|
|
787
|
+
),
|
|
788
|
+
Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
|
|
789
|
+
)
|
|
790
|
+
return yield* find(alias, startPath)
|
|
791
|
+
}),
|
|
792
|
+
|
|
504
793
|
list: (startPath: string = process.cwd()) =>
|
|
505
794
|
Effect.gen(function* () {
|
|
506
795
|
const fs = yield* FileSystemService
|
|
@@ -589,7 +589,9 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
589
589
|
const path = join(root, "tasks", validId, "TASK.md")
|
|
590
590
|
if (!(yield* fs.exists(path))) {
|
|
591
591
|
return yield* new TaskError({
|
|
592
|
-
message:
|
|
592
|
+
message: (yield* fs.exists(archivedTaskDirectory(root, validId)))
|
|
593
|
+
? `Task '${validId}' is already archived`
|
|
594
|
+
: `Task '${validId}' does not exist`,
|
|
593
595
|
})
|
|
594
596
|
}
|
|
595
597
|
const content = yield* fs.readFile(path)
|