@markjaquith/agency 2.52.1 → 2.53.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 +11 -10
- package/cli-main.ts +845 -0
- package/cli.ts +15 -850
- package/package.json +2 -1
- package/src/cli-parser.test.ts +9 -5
- package/src/cli-parser.ts +35 -17
- package/src/cli.test.ts +30 -16
- package/src/commands/pr.test.ts +139 -37
- package/src/commands/pr.ts +23 -35
- package/src/services/FileSystemService.ts +17 -8
- package/src/services/TaskService.ts +1 -1
- package/src/services/VcsMigrationService.test.ts +46 -0
- package/src/services/VcsMigrationService.ts +35 -21
- package/src/services/VersionControlService.ts +9 -3
- package/src/services/WorktreeService.ts +234 -40
- package/src/vcs-status-fast.ts +315 -0
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/opencode-plugin-file.ts +31 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.53.0",
|
|
4
4
|
"description": "Manage agentic work across repositories with durable workbases",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"index.ts",
|
|
22
22
|
"cli.ts",
|
|
23
|
+
"cli-main.ts",
|
|
23
24
|
"src",
|
|
24
25
|
"schemas",
|
|
25
26
|
"fixtures/protocol",
|
package/src/cli-parser.test.ts
CHANGED
|
@@ -292,7 +292,6 @@ describe("strict CLI parsing", () => {
|
|
|
292
292
|
[["restore", "task", "one", "two"], "agency restore task"],
|
|
293
293
|
[["restore", "phase", "one", "two", "three"], "agency restore phase"],
|
|
294
294
|
[["work", "one", "two"], "agency work"],
|
|
295
|
-
[["pr", "create", "one", "two", "three"], "agency pr create"],
|
|
296
295
|
[["status", "extra"], "agency status"],
|
|
297
296
|
[["validate", "one", "two"], "agency validate"],
|
|
298
297
|
[["context", "one", "two"], "agency context"],
|
|
@@ -358,15 +357,20 @@ describe("strict CLI parsing", () => {
|
|
|
358
357
|
commandName: "work",
|
|
359
358
|
values: { force: true },
|
|
360
359
|
})
|
|
361
|
-
expect(parseCli(["pr", "create", "example", "--force"])).toMatchObject({
|
|
362
|
-
commandName: "pr",
|
|
363
|
-
values: { force: true },
|
|
364
|
-
})
|
|
365
360
|
expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
|
|
366
361
|
"cannot be combined",
|
|
367
362
|
)
|
|
368
363
|
})
|
|
369
364
|
|
|
365
|
+
test("preserves every argument after pr without parsing it", () => {
|
|
366
|
+
const args = ["create", "--title", "two words", "--", "--literal"]
|
|
367
|
+
expect(parseCli(["--cwd", "/workbase", "pr", ...args])).toEqual({
|
|
368
|
+
commandName: "pr",
|
|
369
|
+
args,
|
|
370
|
+
values: { cwd: "/workbase" },
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
370
374
|
test("parses reconciliation modes and rejects conflicting modes", () => {
|
|
371
375
|
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
372
376
|
commandName: "sync",
|
package/src/cli-parser.ts
CHANGED
|
@@ -897,22 +897,12 @@ const commands = {
|
|
|
897
897
|
},
|
|
898
898
|
},
|
|
899
899
|
pr: {
|
|
900
|
-
usage: "agency pr
|
|
901
|
-
options:
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
phase: { type: "string" },
|
|
907
|
-
},
|
|
908
|
-
subcommands: {
|
|
909
|
-
create: {
|
|
910
|
-
usage:
|
|
911
|
-
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
912
|
-
minArgs: 1,
|
|
913
|
-
maxArgs: 2,
|
|
914
|
-
options: ["draft", "force", "json", "task", "phase"],
|
|
915
|
-
},
|
|
900
|
+
usage: "agency pr [args...]",
|
|
901
|
+
options: commonOptions,
|
|
902
|
+
command: {
|
|
903
|
+
usage: "agency pr [args...]",
|
|
904
|
+
minArgs: 0,
|
|
905
|
+
maxArgs: Number.POSITIVE_INFINITY,
|
|
916
906
|
},
|
|
917
907
|
},
|
|
918
908
|
next: {
|
|
@@ -1103,7 +1093,6 @@ const targetSlots = (
|
|
|
1103
1093
|
if (commandName === "worktree" && subcommand !== "list") {
|
|
1104
1094
|
return ["task", "phase"]
|
|
1105
1095
|
}
|
|
1106
|
-
if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
|
|
1107
1096
|
return []
|
|
1108
1097
|
}
|
|
1109
1098
|
|
|
@@ -1324,6 +1313,35 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1324
1313
|
"agency <command> [options]",
|
|
1325
1314
|
)
|
|
1326
1315
|
}
|
|
1316
|
+
if (commandName === "pr") {
|
|
1317
|
+
const parsed = parse(
|
|
1318
|
+
args.slice(0, commandIndex),
|
|
1319
|
+
rootOptions,
|
|
1320
|
+
"agency <command> [options]",
|
|
1321
|
+
)
|
|
1322
|
+
assertNoDuplicateOptions(
|
|
1323
|
+
parsed.tokens,
|
|
1324
|
+
new Set(),
|
|
1325
|
+
"agency <command> [options]",
|
|
1326
|
+
)
|
|
1327
|
+
if (parsed.values.silent && parsed.values.verbose) {
|
|
1328
|
+
throw usageError(
|
|
1329
|
+
"Options '--silent' and '--verbose' cannot be combined.",
|
|
1330
|
+
"agency <command> [options]",
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1333
|
+
if (parsed.values.workbase && parsed.values.cwd) {
|
|
1334
|
+
throw usageError(
|
|
1335
|
+
"Options '--workbase' and '--cwd' cannot be combined.",
|
|
1336
|
+
"agency <command> [options]",
|
|
1337
|
+
)
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
commandName,
|
|
1341
|
+
args: [...args.slice(commandIndex + 1)],
|
|
1342
|
+
values: parsed.values,
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1327
1345
|
const commandArgs = [
|
|
1328
1346
|
...args.slice(0, commandIndex),
|
|
1329
1347
|
...args.slice(commandIndex + 1),
|
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { access, mkdir, realpath, stat, symlink } from "node:fs/promises"
|
|
2
|
+
import { access, chmod, mkdir, realpath, stat, symlink } from "node:fs/promises"
|
|
3
3
|
import { join, sep } from "node:path"
|
|
4
4
|
import errorFixture from "../fixtures/protocol/error.json"
|
|
5
5
|
import successFixture from "../fixtures/protocol/success.json"
|
|
@@ -502,7 +502,7 @@ describe("CLI", () => {
|
|
|
502
502
|
["archive", "Usage: agency archive"],
|
|
503
503
|
["restore", "Usage: agency restore"],
|
|
504
504
|
["work", "Usage: agency work"],
|
|
505
|
-
["pr", "
|
|
505
|
+
["pr", "Work with GitHub pull requests."],
|
|
506
506
|
["status", "Usage: agency status"],
|
|
507
507
|
["validate", "Usage: agency validate"],
|
|
508
508
|
["context", "Usage: agency context"],
|
|
@@ -537,6 +537,34 @@ describe("CLI", () => {
|
|
|
537
537
|
expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
|
|
538
538
|
}, 30_000)
|
|
539
539
|
|
|
540
|
+
test("passes pr arguments and exit status through to gh", async () => {
|
|
541
|
+
const root = await createTempDir()
|
|
542
|
+
tempDirs.push(root)
|
|
543
|
+
const bin = join(root, "bin")
|
|
544
|
+
const capture = join(root, "capture")
|
|
545
|
+
await mkdir(bin)
|
|
546
|
+
await Bun.write(
|
|
547
|
+
join(bin, "gh"),
|
|
548
|
+
`#!/bin/sh
|
|
549
|
+
printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
|
|
550
|
+
exit 23
|
|
551
|
+
`,
|
|
552
|
+
)
|
|
553
|
+
await chmod(join(bin, "gh"), 0o755)
|
|
554
|
+
const args = ["pr", "create", "--title", "two words", "--", "literal"]
|
|
555
|
+
|
|
556
|
+
const result = await runCli(args, root, {
|
|
557
|
+
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
|
558
|
+
GH_CAPTURE: capture,
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
expect(result).toEqual({ exitCode: 23, stdout: "", stderr: "" })
|
|
562
|
+
expect((await Bun.file(capture).text()).trim().split("\n")).toEqual([
|
|
563
|
+
await realpath(root),
|
|
564
|
+
...args,
|
|
565
|
+
])
|
|
566
|
+
})
|
|
567
|
+
|
|
540
568
|
test("lists ready work and exposes excluded blockers through one result", async () => {
|
|
541
569
|
const root = await createTempDir()
|
|
542
570
|
tempDirs.push(root)
|
|
@@ -570,20 +598,6 @@ describe("CLI", () => {
|
|
|
570
598
|
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
571
599
|
},
|
|
572
600
|
])
|
|
573
|
-
|
|
574
|
-
const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
|
|
575
|
-
expect(blockedPr.exitCode).toBe(1)
|
|
576
|
-
expect(blockedPr.stderr).toBe("")
|
|
577
|
-
expect(JSON.parse(blockedPr.stdout)).toMatchObject({
|
|
578
|
-
ok: false,
|
|
579
|
-
error: {
|
|
580
|
-
code: "EXECUTION_BLOCKED",
|
|
581
|
-
fields: {
|
|
582
|
-
status: "dropped",
|
|
583
|
-
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
584
|
-
},
|
|
585
|
-
},
|
|
586
|
-
})
|
|
587
601
|
})
|
|
588
602
|
|
|
589
603
|
test("reports and synchronizes managed integration files", async () => {
|
package/src/commands/pr.test.ts
CHANGED
|
@@ -1,45 +1,147 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { chmod, mkdir, realpath } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
5
5
|
import { pr } from "./pr"
|
|
6
6
|
|
|
7
|
+
const write = async (root: string, path: string, content: string) => {
|
|
8
|
+
const fullPath = join(root, path)
|
|
9
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
10
|
+
await Bun.write(fullPath, content)
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
describe("pr command", () => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
let tempRoot: string
|
|
15
|
+
let capturePath: string
|
|
16
|
+
let originalPath: string | undefined
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tempRoot = await createTempDir()
|
|
20
|
+
capturePath = join(tempRoot, "capture")
|
|
21
|
+
const bin = join(tempRoot, "bin")
|
|
22
|
+
await mkdir(bin)
|
|
23
|
+
await Bun.write(
|
|
24
|
+
join(bin, "gh"),
|
|
25
|
+
`#!/bin/sh
|
|
26
|
+
printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
|
|
27
|
+
exit "\${GH_EXIT:-0}"
|
|
28
|
+
`,
|
|
29
|
+
)
|
|
30
|
+
await chmod(join(bin, "gh"), 0o755)
|
|
31
|
+
originalPath = process.env.PATH
|
|
32
|
+
process.env.PATH = `${bin}:${originalPath ?? ""}`
|
|
33
|
+
process.env.GH_CAPTURE = capturePath
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
process.env.PATH = originalPath
|
|
38
|
+
delete process.env.GH_CAPTURE
|
|
39
|
+
delete process.env.GH_EXIT
|
|
40
|
+
await cleanupTempDir(tempRoot)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const captured = async () =>
|
|
44
|
+
(await Bun.file(capturePath).text()).trim().split("\n")
|
|
45
|
+
|
|
46
|
+
const createExecutionWorkbase = async () => {
|
|
47
|
+
const root = join(tempRoot, "workbase")
|
|
48
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
49
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
50
|
+
await mkdir(join(root, "tasks/single/code/agency"), { recursive: true })
|
|
51
|
+
await write(
|
|
52
|
+
root,
|
|
53
|
+
"tasks/single/TASK.md",
|
|
54
|
+
`---
|
|
55
|
+
ticketUrl: null
|
|
56
|
+
repo: agency
|
|
57
|
+
branch: feat/single
|
|
58
|
+
base: main
|
|
59
|
+
pr: null
|
|
60
|
+
status: open
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
# Single
|
|
64
|
+
`,
|
|
30
65
|
)
|
|
66
|
+
await mkdir(join(root, "tasks/multi/phases/build/code/agency"), {
|
|
67
|
+
recursive: true,
|
|
68
|
+
})
|
|
69
|
+
await write(
|
|
70
|
+
root,
|
|
71
|
+
"tasks/multi/TASK.md",
|
|
72
|
+
`---
|
|
73
|
+
ticketUrl: null
|
|
74
|
+
phases:
|
|
75
|
+
- id: build
|
|
76
|
+
---
|
|
31
77
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
# Multi
|
|
79
|
+
`,
|
|
80
|
+
)
|
|
81
|
+
await write(
|
|
82
|
+
root,
|
|
83
|
+
"tasks/multi/phases/build/PHASE.md",
|
|
84
|
+
`---
|
|
85
|
+
repo: agency
|
|
86
|
+
branch: feat/build
|
|
87
|
+
base: main
|
|
88
|
+
pr: null
|
|
89
|
+
status: open
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# Build
|
|
93
|
+
`,
|
|
94
|
+
)
|
|
95
|
+
return root
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test("focuses task and descendant invocations on the writable checkout", async () => {
|
|
99
|
+
const root = await createExecutionWorkbase()
|
|
100
|
+
const task = join(root, "tasks/single")
|
|
101
|
+
const descendant = join(task, "notes/deep")
|
|
102
|
+
await mkdir(descendant, { recursive: true })
|
|
103
|
+
|
|
104
|
+
for (const cwd of [task, descendant]) {
|
|
105
|
+
expect(await runTestEffect(pr(["view"], cwd))).toBe(0)
|
|
106
|
+
expect(await captured()).toEqual([
|
|
107
|
+
await realpath(join(task, "code/agency")),
|
|
108
|
+
"pr",
|
|
109
|
+
"view",
|
|
110
|
+
])
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("focuses a phase invocation on its writable checkout", async () => {
|
|
115
|
+
const root = await createExecutionWorkbase()
|
|
116
|
+
const phase = join(root, "tasks/multi/phases/build")
|
|
117
|
+
|
|
118
|
+
expect(await runTestEffect(pr(["status"], phase))).toBe(0)
|
|
119
|
+
expect(await captured()).toEqual([
|
|
120
|
+
await realpath(join(phase, "code/agency")),
|
|
121
|
+
"pr",
|
|
122
|
+
"status",
|
|
43
123
|
])
|
|
44
124
|
})
|
|
125
|
+
|
|
126
|
+
test("falls back to the invocation directory without execution authority", async () => {
|
|
127
|
+
const root = await createExecutionWorkbase()
|
|
128
|
+
const orchestration = join(root, "tasks/multi")
|
|
129
|
+
const outside = join(tempRoot, "outside")
|
|
130
|
+
await mkdir(outside)
|
|
131
|
+
|
|
132
|
+
for (const cwd of [orchestration, outside]) {
|
|
133
|
+
expect(await runTestEffect(pr(["list"], cwd))).toBe(0)
|
|
134
|
+
expect(await captured()).toEqual([await realpath(cwd), "pr", "list"])
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("forwards arguments unchanged and returns the gh exit code", async () => {
|
|
139
|
+
const outside = join(tempRoot, "outside")
|
|
140
|
+
await mkdir(outside)
|
|
141
|
+
process.env.GH_EXIT = "23"
|
|
142
|
+
const args = ["create", "--title", "two words", "--", "literal"]
|
|
143
|
+
|
|
144
|
+
expect(await runTestEffect(pr(args, outside))).toBe(23)
|
|
145
|
+
expect(await captured()).toEqual([await realpath(outside), "pr", ...args])
|
|
146
|
+
})
|
|
45
147
|
})
|
package/src/commands/pr.ts
CHANGED
|
@@ -1,43 +1,31 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { ContextService } from "../services/ContextService"
|
|
4
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
readonly subcommand?: string
|
|
8
|
-
readonly taskId?: string
|
|
9
|
-
readonly phaseId?: string
|
|
10
|
-
readonly draft?: boolean
|
|
11
|
-
readonly force?: boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const pr = (options: PrOptions) =>
|
|
6
|
+
export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
15
7
|
Effect.gen(function* () {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
8
|
+
const contexts = yield* ContextService
|
|
9
|
+
const fs = yield* FileSystemService
|
|
10
|
+
const invocationCwd = resolve(cwd)
|
|
11
|
+
const context = yield* contexts
|
|
12
|
+
.get({ cwd: invocationCwd, target: ".", compact: true })
|
|
13
|
+
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
14
|
+
const writableCheckout =
|
|
15
|
+
context?.validation.valid && context.workspace?.writable?.materialized
|
|
16
|
+
? context.authority.writable?.checkoutPath
|
|
17
|
+
: null
|
|
18
|
+
const focusedCwd = writableCheckout ?? invocationCwd
|
|
19
|
+
const result = yield* fs.runCommand(["gh", "pr", ...args], {
|
|
20
|
+
cwd: focusedCwd,
|
|
21
|
+
passthrough: true,
|
|
22
|
+
})
|
|
23
|
+
return result.exitCode
|
|
31
24
|
})
|
|
32
25
|
|
|
33
26
|
export const help = `
|
|
34
|
-
Usage: agency pr
|
|
35
|
-
|
|
36
|
-
Push the execution branch, create a pull request with the delivery provider,
|
|
37
|
-
and update its task or phase document.
|
|
27
|
+
Usage: agency pr [args...]
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
--force Override readiness and terminal-state guards
|
|
42
|
-
--json Output the pull request URL as JSON
|
|
29
|
+
Run gh pr unchanged, focusing the writable repository checkout when invoked
|
|
30
|
+
from an Agency execution task or phase.
|
|
43
31
|
`
|
|
@@ -218,25 +218,34 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
218
218
|
readonly cwd?: string
|
|
219
219
|
readonly captureOutput?: boolean
|
|
220
220
|
readonly forwardOutput?: boolean
|
|
221
|
+
readonly passthrough?: boolean
|
|
221
222
|
readonly env?: Record<string, string>
|
|
222
223
|
},
|
|
223
224
|
) =>
|
|
224
225
|
pipe(
|
|
225
226
|
spawnProcess(args, {
|
|
226
227
|
cwd: options?.cwd,
|
|
227
|
-
stdin: "pipe",
|
|
228
|
-
stdout: options?.
|
|
229
|
-
? "
|
|
230
|
-
: options?.
|
|
231
|
-
? "
|
|
232
|
-
:
|
|
233
|
-
|
|
228
|
+
stdin: options?.passthrough ? "inherit" : "pipe",
|
|
229
|
+
stdout: options?.passthrough
|
|
230
|
+
? "inherit"
|
|
231
|
+
: options?.forwardOutput
|
|
232
|
+
? "tee"
|
|
233
|
+
: options?.captureOutput
|
|
234
|
+
? "pipe"
|
|
235
|
+
: "inherit",
|
|
236
|
+
stderr: options?.passthrough
|
|
237
|
+
? "inherit"
|
|
238
|
+
: options?.forwardOutput
|
|
239
|
+
? "tee"
|
|
240
|
+
: "pipe",
|
|
234
241
|
env: options?.env,
|
|
235
242
|
}),
|
|
236
243
|
Effect.mapError(
|
|
237
244
|
(processError) =>
|
|
238
245
|
new FileSystemError({
|
|
239
|
-
message:
|
|
246
|
+
message: options?.passthrough
|
|
247
|
+
? processError.message
|
|
248
|
+
: `Failed to run command: ${args.join(" ")}`,
|
|
240
249
|
cause: processError,
|
|
241
250
|
}),
|
|
242
251
|
),
|
|
@@ -7,6 +7,7 @@ import { ClaimService } from "./ClaimService"
|
|
|
7
7
|
import { TaskService } from "./TaskService"
|
|
8
8
|
import { VcsMigrationService } from "./VcsMigrationService"
|
|
9
9
|
import { WorktreeService } from "./WorktreeService"
|
|
10
|
+
import { runVcsStatusFast } from "../vcs-status-fast"
|
|
10
11
|
|
|
11
12
|
const run = async (args: string[], cwd?: string) => {
|
|
12
13
|
const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
@@ -72,6 +73,51 @@ describe("VcsMigrationService", () => {
|
|
|
72
73
|
|
|
73
74
|
afterEach(async () => cleanupTempDir(root))
|
|
74
75
|
|
|
76
|
+
test("reports same-backend status for materialized workspaces", async () => {
|
|
77
|
+
const status = await runTestEffect(
|
|
78
|
+
VcsMigrationService.pipe(
|
|
79
|
+
Effect.flatMap((service) => service.status(root)),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
expect(status).toMatchObject({
|
|
83
|
+
configured: "git",
|
|
84
|
+
source: "git",
|
|
85
|
+
target: "git",
|
|
86
|
+
workspaceCount: 2,
|
|
87
|
+
blockers: [],
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("fast jj status matches the validated service result", async () => {
|
|
92
|
+
if (!Bun.which("jj")) return
|
|
93
|
+
await runTestEffect(
|
|
94
|
+
VcsMigrationService.pipe(
|
|
95
|
+
Effect.flatMap((service) =>
|
|
96
|
+
service.migrate("jj", root, { apply: true }),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
const taskPath = join(root, "tasks/example/TASK.md")
|
|
101
|
+
const content = await Bun.file(taskPath).text()
|
|
102
|
+
const withoutReference = content.replace(
|
|
103
|
+
/\nrepos:\n(?: .+\n)+(?=branch:)/,
|
|
104
|
+
"\n",
|
|
105
|
+
)
|
|
106
|
+
expect(withoutReference).not.toBe(content)
|
|
107
|
+
await Bun.write(taskPath, withoutReference)
|
|
108
|
+
|
|
109
|
+
const validated = await runTestEffect(
|
|
110
|
+
VcsMigrationService.pipe(
|
|
111
|
+
Effect.flatMap((service) => service.status(root)),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
const output: string[] = []
|
|
115
|
+
expect(
|
|
116
|
+
await runVcsStatusFast(true, root, (line) => output.push(line)),
|
|
117
|
+
).toBe(true)
|
|
118
|
+
expect(JSON.parse(output.join("\n")).result).toEqual(validated)
|
|
119
|
+
})
|
|
120
|
+
|
|
75
121
|
test("migrates Git worktrees to jj workspaces and back", async () => {
|
|
76
122
|
if (!Bun.which("jj")) return
|
|
77
123
|
const checkout = join(root, "tasks/example/code/agency")
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
runLifecycleTransaction,
|
|
9
9
|
} from "./LifecycleTransaction"
|
|
10
10
|
import { FileSystemService } from "./FileSystemService"
|
|
11
|
-
import { PhaseService } from "./PhaseService"
|
|
11
|
+
import { PhaseService, type PhaseRecord } from "./PhaseService"
|
|
12
12
|
import { RepositoryService } from "./RepositoryService"
|
|
13
13
|
import { TaskService } from "./TaskService"
|
|
14
14
|
import {
|
|
@@ -182,15 +182,19 @@ const executionRecords = (root: string) =>
|
|
|
182
182
|
Effect.gen(function* () {
|
|
183
183
|
const tasks = yield* TaskService
|
|
184
184
|
const phases = yield* PhaseService
|
|
185
|
+
const taskRecords = yield* tasks.list(root)
|
|
186
|
+
const phasesByTask = new Map<string, readonly PhaseRecord[]>()
|
|
185
187
|
const records: {
|
|
186
188
|
taskId: string
|
|
187
189
|
phaseId?: string
|
|
188
190
|
status: WorkStatus
|
|
189
191
|
claimActive: boolean
|
|
190
192
|
}[] = []
|
|
191
|
-
for (const task of
|
|
193
|
+
for (const task of taskRecords) {
|
|
192
194
|
if ("phases" in task.data) {
|
|
193
|
-
|
|
195
|
+
const phaseRecords = yield* phases.list(task.id, root)
|
|
196
|
+
phasesByTask.set(task.id, phaseRecords)
|
|
197
|
+
for (const phase of phaseRecords) {
|
|
194
198
|
records.push({
|
|
195
199
|
taskId: task.id,
|
|
196
200
|
phaseId: phase.id,
|
|
@@ -206,7 +210,7 @@ const executionRecords = (root: string) =>
|
|
|
206
210
|
})
|
|
207
211
|
}
|
|
208
212
|
}
|
|
209
|
-
return records
|
|
213
|
+
return { records, taskRecords, phasesByTask }
|
|
210
214
|
})
|
|
211
215
|
|
|
212
216
|
const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
|
|
@@ -241,7 +245,8 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
|
|
|
241
245
|
})
|
|
242
246
|
}
|
|
243
247
|
|
|
244
|
-
const
|
|
248
|
+
const execution = yield* executionRecords(root)
|
|
249
|
+
const records = execution.records
|
|
245
250
|
for (const record of records) {
|
|
246
251
|
if (record.claimActive) {
|
|
247
252
|
const label = record.phaseId
|
|
@@ -339,7 +344,15 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
|
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
const workspacePlans: WorkspacePlan[] = []
|
|
342
|
-
const inspected = yield* Effect.either(
|
|
347
|
+
const inspected = yield* Effect.either(
|
|
348
|
+
worktrees.list(root, {
|
|
349
|
+
materializedOnly: !blockers.some(
|
|
350
|
+
(blocker) => blocker.kind === "repository",
|
|
351
|
+
),
|
|
352
|
+
tasks: execution.taskRecords,
|
|
353
|
+
phasesByTask: execution.phasesByTask,
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
343
356
|
if (Either.isLeft(inspected)) {
|
|
344
357
|
blockers.push({
|
|
345
358
|
kind: "workspace-conflict",
|
|
@@ -377,26 +390,27 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
|
|
|
377
390
|
continue
|
|
378
391
|
}
|
|
379
392
|
const sourceName =
|
|
380
|
-
source === "jj"
|
|
393
|
+
source !== target && source === "jj"
|
|
381
394
|
? ((yield* sourceBackend.listWorkspaces(
|
|
382
395
|
join(root, "repos", checkout.repo),
|
|
383
396
|
)).find((item) => item.path === checkout.registeredPath)
|
|
384
397
|
?.name ?? null)
|
|
385
398
|
: null
|
|
386
|
-
const previousBranchCommit =
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
399
|
+
const previousBranchCommit =
|
|
400
|
+
source !== target && checkout.actualBranch
|
|
401
|
+
? yield* command(
|
|
402
|
+
fs,
|
|
403
|
+
[
|
|
404
|
+
"git",
|
|
405
|
+
"-C",
|
|
406
|
+
join(root, "repos", checkout.repo),
|
|
407
|
+
"rev-parse",
|
|
408
|
+
"--verify",
|
|
409
|
+
`${checkout.actualBranch}^{commit}`,
|
|
410
|
+
],
|
|
411
|
+
`Failed to inspect branch '${checkout.actualBranch}'`,
|
|
412
|
+
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
413
|
+
: null
|
|
400
414
|
workspacePlans.push({
|
|
401
415
|
taskId: inspection.owner.taskId,
|
|
402
416
|
...(inspection.owner.phaseId
|