@markjaquith/agency 2.7.0 → 2.7.2
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/cli.ts
CHANGED
|
@@ -240,7 +240,9 @@ const commands: Record<string, Command> = {
|
|
|
240
240
|
return
|
|
241
241
|
}
|
|
242
242
|
if (args.length > 1) {
|
|
243
|
-
throw new Error(
|
|
243
|
+
throw new Error(
|
|
244
|
+
"Usage: agency work [<directory-or-task-id> | --epic <epic-id>]",
|
|
245
|
+
)
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
await runCommand(
|
|
@@ -301,7 +303,7 @@ Commands:
|
|
|
301
303
|
phase <subcommand> Manage task phases
|
|
302
304
|
archive <type> Archive a work item
|
|
303
305
|
task <subcommand> Manage tasks
|
|
304
|
-
work [directory]
|
|
306
|
+
work [directory|task] Work on an epic, task, or phase
|
|
305
307
|
pr create Create a pull request for an execution unit
|
|
306
308
|
repo <subcommand> Manage workbase repositories
|
|
307
309
|
status Show status for the current workbase
|
package/package.json
CHANGED
|
@@ -46,12 +46,14 @@ interface HarnessOptions {
|
|
|
46
46
|
readonly phaseRecords?: readonly any[]
|
|
47
47
|
readonly outsideWorkbase?: boolean
|
|
48
48
|
readonly registeredWorkbases?: readonly string[]
|
|
49
|
+
readonly existingDirectories?: readonly string[]
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
52
53
|
const events: string[] = []
|
|
53
54
|
const probes: string[] = []
|
|
54
55
|
const statusUpdates: string[] = []
|
|
56
|
+
const shownTasks: string[] = []
|
|
55
57
|
const progressUpdates: string[] = []
|
|
56
58
|
const launches: Array<{
|
|
57
59
|
cli: string
|
|
@@ -95,14 +97,16 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
95
97
|
list: () => Effect.succeed(options.epicRecords ?? []),
|
|
96
98
|
}
|
|
97
99
|
const tasks = {
|
|
98
|
-
show: (id: string) =>
|
|
99
|
-
|
|
100
|
+
show: (id: string) => {
|
|
101
|
+
shownTasks.push(id)
|
|
102
|
+
return Effect.succeed({
|
|
100
103
|
id,
|
|
101
104
|
path: `/workbase/tasks/${id}/TASK.md`,
|
|
102
105
|
data: options.multiPhaseTasks?.includes(id)
|
|
103
106
|
? { phases: [] }
|
|
104
107
|
: { repo: "agency", branch: `task/${id}`, base: "main" },
|
|
105
|
-
})
|
|
108
|
+
})
|
|
109
|
+
},
|
|
106
110
|
list: () => Effect.succeed(options.taskRecords ?? []),
|
|
107
111
|
setStatus: (id: string, status: string) => {
|
|
108
112
|
statusUpdates.push(`task:${id}:${status}`)
|
|
@@ -124,7 +128,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
124
128
|
},
|
|
125
129
|
}
|
|
126
130
|
const fs = {
|
|
127
|
-
isDirectory: () =>
|
|
131
|
+
isDirectory: (path: string) =>
|
|
132
|
+
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
128
133
|
runCommand: (args: readonly string[]) => {
|
|
129
134
|
const cli = args[1] as "opencode" | "claude"
|
|
130
135
|
events.push(`probe:${cli}`)
|
|
@@ -169,6 +174,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
169
174
|
launches,
|
|
170
175
|
materializeOptions,
|
|
171
176
|
statusUpdates,
|
|
177
|
+
shownTasks,
|
|
172
178
|
progressUpdates,
|
|
173
179
|
run,
|
|
174
180
|
}
|
|
@@ -187,11 +193,44 @@ describe("work command", () => {
|
|
|
187
193
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
188
194
|
expect(harness.launches[0]).toEqual({
|
|
189
195
|
cli: "opencode",
|
|
190
|
-
args: [
|
|
196
|
+
args: [
|
|
197
|
+
"opencode",
|
|
198
|
+
"--continue",
|
|
199
|
+
"--prompt",
|
|
200
|
+
"Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
|
|
201
|
+
],
|
|
191
202
|
cwd: "/workbase/epics/delivery",
|
|
192
203
|
})
|
|
193
204
|
})
|
|
194
205
|
|
|
206
|
+
test("resolves an existing positional path before treating it as a task ID", async () => {
|
|
207
|
+
const harness = createHarness({
|
|
208
|
+
existingDirectories: ["/workbase/tasks/delivery"],
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await harness.run({
|
|
212
|
+
cwd: "/workbase/tasks/delivery",
|
|
213
|
+
directory: ".",
|
|
214
|
+
opencode: true,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(harness.shownTasks).toEqual(["delivery"])
|
|
218
|
+
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test("treats a positional value as a task ID when it is not a directory", async () => {
|
|
222
|
+
const harness = createHarness({ existingDirectories: [] })
|
|
223
|
+
|
|
224
|
+
await harness.run({
|
|
225
|
+
cwd: "/workbase",
|
|
226
|
+
directory: "delivery",
|
|
227
|
+
opencode: true,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(harness.shownTasks).toEqual(["delivery"])
|
|
231
|
+
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
232
|
+
})
|
|
233
|
+
|
|
195
234
|
test("launches a multi-phase task agent without materializing", async () => {
|
|
196
235
|
const harness = createHarness({ multiPhaseTasks: ["delivery"] })
|
|
197
236
|
|
|
@@ -204,7 +243,12 @@ describe("work command", () => {
|
|
|
204
243
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
205
244
|
expect(harness.launches[0]).toEqual({
|
|
206
245
|
cli: "opencode",
|
|
207
|
-
args: [
|
|
246
|
+
args: [
|
|
247
|
+
"opencode",
|
|
248
|
+
"--continue",
|
|
249
|
+
"--prompt",
|
|
250
|
+
"Work on the task. Read /workbase/tasks/delivery/TASK.md.",
|
|
251
|
+
],
|
|
208
252
|
cwd: "/workbase/tasks/delivery",
|
|
209
253
|
})
|
|
210
254
|
})
|
|
@@ -223,7 +267,16 @@ describe("work command", () => {
|
|
|
223
267
|
"probe:opencode",
|
|
224
268
|
"launch:opencode",
|
|
225
269
|
])
|
|
226
|
-
expect(harness.launches[0]
|
|
270
|
+
expect(harness.launches[0]).toEqual({
|
|
271
|
+
cli: "opencode",
|
|
272
|
+
args: [
|
|
273
|
+
"opencode",
|
|
274
|
+
"--continue",
|
|
275
|
+
"--prompt",
|
|
276
|
+
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
277
|
+
],
|
|
278
|
+
cwd: multiPhaseWorkspace.writablePath,
|
|
279
|
+
})
|
|
227
280
|
expect(harness.statusUpdates).toEqual([
|
|
228
281
|
"phase:example:implementation:working",
|
|
229
282
|
])
|
|
@@ -239,7 +292,7 @@ describe("work command", () => {
|
|
|
239
292
|
})
|
|
240
293
|
|
|
241
294
|
expect(harness.events[0]).toBe("materialize")
|
|
242
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
295
|
+
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
243
296
|
})
|
|
244
297
|
|
|
245
298
|
test("selects a target with fzf when no directory is provided", async () => {
|
|
@@ -273,9 +326,7 @@ describe("work command", () => {
|
|
|
273
326
|
"probe:opencode",
|
|
274
327
|
"launch:opencode",
|
|
275
328
|
])
|
|
276
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
277
|
-
"/workbase/tasks/delivery/phases/build",
|
|
278
|
-
)
|
|
329
|
+
expect(harness.launches[0]?.cwd).toBe(multiPhaseWorkspace.writablePath)
|
|
279
330
|
})
|
|
280
331
|
|
|
281
332
|
test("selects a registered workbase when local discovery fails", async () => {
|
|
@@ -353,7 +404,7 @@ describe("work command", () => {
|
|
|
353
404
|
expect(harness.events).toEqual([])
|
|
354
405
|
})
|
|
355
406
|
|
|
356
|
-
test("launches OpenCode in the
|
|
407
|
+
test("launches OpenCode in the writable checkout with explicit context", async () => {
|
|
357
408
|
const harness = createHarness()
|
|
358
409
|
|
|
359
410
|
await harness.run({ taskId: "example", opencode: true })
|
|
@@ -366,8 +417,13 @@ describe("work command", () => {
|
|
|
366
417
|
expect(harness.launches).toEqual([
|
|
367
418
|
{
|
|
368
419
|
cli: "opencode",
|
|
369
|
-
args: [
|
|
370
|
-
|
|
420
|
+
args: [
|
|
421
|
+
"opencode",
|
|
422
|
+
"--continue",
|
|
423
|
+
"--prompt",
|
|
424
|
+
"Start the task. Read /workbase/tasks/example/TASK.md.",
|
|
425
|
+
],
|
|
426
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
371
427
|
},
|
|
372
428
|
])
|
|
373
429
|
expect(harness.statusUpdates).toEqual(["task:example:working"])
|
|
@@ -377,7 +433,7 @@ describe("work command", () => {
|
|
|
377
433
|
])
|
|
378
434
|
})
|
|
379
435
|
|
|
380
|
-
test("continues OpenCode
|
|
436
|
+
test("continues OpenCode with explicit task and phase context", async () => {
|
|
381
437
|
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
382
438
|
|
|
383
439
|
await harness.run({
|
|
@@ -386,7 +442,16 @@ describe("work command", () => {
|
|
|
386
442
|
opencode: true,
|
|
387
443
|
})
|
|
388
444
|
|
|
389
|
-
expect(harness.launches[0]
|
|
445
|
+
expect(harness.launches[0]).toEqual({
|
|
446
|
+
cli: "opencode",
|
|
447
|
+
args: [
|
|
448
|
+
"opencode",
|
|
449
|
+
"--continue",
|
|
450
|
+
"--prompt",
|
|
451
|
+
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
452
|
+
],
|
|
453
|
+
cwd: multiPhaseWorkspace.writablePath,
|
|
454
|
+
})
|
|
390
455
|
})
|
|
391
456
|
|
|
392
457
|
test("automatically falls back to Claude", async () => {
|
|
@@ -398,7 +463,7 @@ describe("work command", () => {
|
|
|
398
463
|
expect(harness.launches[0]).toEqual({
|
|
399
464
|
cli: "claude",
|
|
400
465
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
401
|
-
cwd:
|
|
466
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
402
467
|
})
|
|
403
468
|
})
|
|
404
469
|
|
|
@@ -422,7 +487,7 @@ describe("work command", () => {
|
|
|
422
487
|
expect(harness.launches[0]).toEqual({
|
|
423
488
|
cli: "claude",
|
|
424
489
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
425
|
-
cwd:
|
|
490
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
426
491
|
})
|
|
427
492
|
})
|
|
428
493
|
|
|
@@ -459,7 +524,7 @@ describe("work command", () => {
|
|
|
459
524
|
verboseHarness.run({ taskId: "example", verbose: true }),
|
|
460
525
|
)
|
|
461
526
|
expect(verboseLogs).toEqual([
|
|
462
|
-
"Launching command: opencode --continue (cwd: /workbase/tasks/example)",
|
|
527
|
+
"Launching command: opencode --continue --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
|
|
463
528
|
])
|
|
464
529
|
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
465
530
|
|
package/src/commands/work.ts
CHANGED
|
@@ -77,12 +77,13 @@ export const work = (
|
|
|
77
77
|
const phases = yield* PhaseService
|
|
78
78
|
const { log, verboseLog } = createLoggers(options)
|
|
79
79
|
const cwd = options.cwd ?? process.cwd()
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
|
|
80
|
+
const directoryPath = options.directory
|
|
81
|
+
? resolve(cwd, options.directory)
|
|
82
|
+
: undefined
|
|
83
|
+
const isDirectory = directoryPath
|
|
84
|
+
? yield* fs.isDirectory(directoryPath)
|
|
85
|
+
: false
|
|
86
|
+
const startPath = isDirectory && directoryPath ? directoryPath : cwd
|
|
86
87
|
const root = yield* resolveWorkbase(startPath, log, pickBase)
|
|
87
88
|
if (!root) return
|
|
88
89
|
|
|
@@ -108,7 +109,15 @@ export const work = (
|
|
|
108
109
|
multiPhase: "phases" in task.data,
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
|
-
} else if (options.directory) {
|
|
112
|
+
} else if (options.directory && !isDirectory) {
|
|
113
|
+
const task = yield* tasks.show(options.directory, root)
|
|
114
|
+
target = {
|
|
115
|
+
kind: "task",
|
|
116
|
+
taskId: task.id,
|
|
117
|
+
path: task.path,
|
|
118
|
+
multiPhase: "phases" in task.data,
|
|
119
|
+
}
|
|
120
|
+
} else if (directoryPath) {
|
|
112
121
|
const path = relative(root, startPath)
|
|
113
122
|
const parts =
|
|
114
123
|
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
@@ -197,7 +206,7 @@ export const work = (
|
|
|
197
206
|
prompt = workspace.phasePath
|
|
198
207
|
? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
199
208
|
: `Start the task. Read ${workspace.taskPath}.`
|
|
200
|
-
launchPath =
|
|
209
|
+
launchPath = workspace.writablePath
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
const requested = options.claude ? "claude" : "opencode"
|
|
@@ -218,7 +227,8 @@ export const work = (
|
|
|
218
227
|
yield* tasks.setStatus(target.taskId, "working", root)
|
|
219
228
|
}
|
|
220
229
|
|
|
221
|
-
const args =
|
|
230
|
+
const args =
|
|
231
|
+
cli === "opencode" ? ["--continue", "--prompt", prompt] : [prompt]
|
|
222
232
|
verboseLog(
|
|
223
233
|
`Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
|
|
224
234
|
)
|
|
@@ -226,11 +236,12 @@ export const work = (
|
|
|
226
236
|
})
|
|
227
237
|
|
|
228
238
|
export const help = `
|
|
229
|
-
Usage: agency work [<directory> | --epic <epic-id>]
|
|
239
|
+
Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
|
|
230
240
|
|
|
231
241
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
232
|
-
with fzf.
|
|
233
|
-
|
|
242
|
+
with fzf. A positional argument resolves as a directory first, then as a task
|
|
243
|
+
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
244
|
+
workbase first.
|
|
234
245
|
|
|
235
246
|
Options:
|
|
236
247
|
--epic <id> Work on an epic
|
|
@@ -314,6 +314,20 @@ process.exit(${exitCode})
|
|
|
314
314
|
expect(await Bun.file(ghCallPath).exists()).toBe(false)
|
|
315
315
|
})
|
|
316
316
|
|
|
317
|
+
test("blocks PR creation when workbase validation fails", async () => {
|
|
318
|
+
await createTask()
|
|
319
|
+
await materialize()
|
|
320
|
+
await mkdir(join(root, "tasks", "missing-document"), { recursive: true })
|
|
321
|
+
await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
|
|
322
|
+
|
|
323
|
+
await expect(createPullRequest()).rejects.toThrow(
|
|
324
|
+
"Required document is missing",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
await expectRemoteBranch("task/example", false)
|
|
328
|
+
expect(await Bun.file(ghCallPath).exists()).toBe(false)
|
|
329
|
+
})
|
|
330
|
+
|
|
317
331
|
test("reports push failure and does not invoke gh", async () => {
|
|
318
332
|
await createTask()
|
|
319
333
|
await materialize()
|
|
@@ -85,7 +85,7 @@ describe("WorktreeService", () => {
|
|
|
85
85
|
expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
test("does not fetch
|
|
88
|
+
test("does not fetch the origin for an existing writable worktree", async () => {
|
|
89
89
|
await runTestEffect(
|
|
90
90
|
TaskService.pipe(
|
|
91
91
|
Effect.flatMap((service) =>
|
|
@@ -94,7 +94,6 @@ describe("WorktreeService", () => {
|
|
|
94
94
|
id: "existing",
|
|
95
95
|
ticketUrl: "https://example.com/task",
|
|
96
96
|
repo: "agency",
|
|
97
|
-
repos: [{ repo: "effect", ref: "main" }],
|
|
98
97
|
branch: "task/existing",
|
|
99
98
|
base: "main",
|
|
100
99
|
},
|
|
@@ -114,6 +113,50 @@ describe("WorktreeService", () => {
|
|
|
114
113
|
["remote", "set-url", "origin", join(root, "missing")],
|
|
115
114
|
join(root, "repos/agency"),
|
|
116
115
|
)
|
|
116
|
+
await expect(
|
|
117
|
+
runTestEffect(
|
|
118
|
+
WorktreeService.pipe(
|
|
119
|
+
Effect.flatMap((service) =>
|
|
120
|
+
service.materialize("existing", undefined, root),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
).resolves.toMatchObject({ repo: "agency" })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("reuses an immutable reference checkout without fetching", async () => {
|
|
128
|
+
const commit = new TextDecoder()
|
|
129
|
+
.decode(
|
|
130
|
+
Bun.spawnSync(
|
|
131
|
+
["git", "-C", join(root, "repos/effect"), "rev-parse", "main"],
|
|
132
|
+
{ stdout: "pipe" },
|
|
133
|
+
).stdout,
|
|
134
|
+
)
|
|
135
|
+
.trim()
|
|
136
|
+
await runTestEffect(
|
|
137
|
+
TaskService.pipe(
|
|
138
|
+
Effect.flatMap((service) =>
|
|
139
|
+
service.create(
|
|
140
|
+
{
|
|
141
|
+
id: "immutable-reference",
|
|
142
|
+
ticketUrl: "https://example.com/task",
|
|
143
|
+
repo: "agency",
|
|
144
|
+
repos: [{ repo: "effect", ref: commit }],
|
|
145
|
+
branch: "task/immutable-reference",
|
|
146
|
+
base: "main",
|
|
147
|
+
},
|
|
148
|
+
root,
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
await runTestEffect(
|
|
154
|
+
WorktreeService.pipe(
|
|
155
|
+
Effect.flatMap((service) =>
|
|
156
|
+
service.materialize("immutable-reference", undefined, root),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
117
160
|
await git(
|
|
118
161
|
["remote", "set-url", "origin", join(root, "missing")],
|
|
119
162
|
join(root, "repos/effect"),
|
|
@@ -123,11 +166,44 @@ describe("WorktreeService", () => {
|
|
|
123
166
|
runTestEffect(
|
|
124
167
|
WorktreeService.pipe(
|
|
125
168
|
Effect.flatMap((service) =>
|
|
126
|
-
service.materialize("
|
|
169
|
+
service.materialize("immutable-reference", undefined, root),
|
|
127
170
|
),
|
|
128
171
|
),
|
|
129
172
|
),
|
|
130
|
-
).resolves.toMatchObject({ repo: "
|
|
173
|
+
).resolves.toMatchObject({ repos: [{ repo: "effect", ref: commit }] })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("stops before materializing when workbase validation fails", async () => {
|
|
177
|
+
await runTestEffect(
|
|
178
|
+
TaskService.pipe(
|
|
179
|
+
Effect.flatMap((service) =>
|
|
180
|
+
service.create(
|
|
181
|
+
{
|
|
182
|
+
id: "valid-target",
|
|
183
|
+
ticketUrl: "https://example.com/task",
|
|
184
|
+
repo: "agency",
|
|
185
|
+
branch: "task/valid-target",
|
|
186
|
+
base: "main",
|
|
187
|
+
},
|
|
188
|
+
root,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
await mkdir(join(root, "tasks", "missing-document"), { recursive: true })
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
runTestEffect(
|
|
197
|
+
WorktreeService.pipe(
|
|
198
|
+
Effect.flatMap((service) =>
|
|
199
|
+
service.materialize("valid-target", undefined, root),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
).rejects.toThrow("Required document is missing")
|
|
204
|
+
expect(
|
|
205
|
+
await Bun.file(join(root, "tasks", "valid-target", "code")).exists(),
|
|
206
|
+
).toBe(false)
|
|
131
207
|
})
|
|
132
208
|
|
|
133
209
|
test("uses a configured worktree creation command", async () => {
|
|
@@ -330,7 +406,7 @@ describe("WorktreeService", () => {
|
|
|
330
406
|
repo: "missing",
|
|
331
407
|
base: "main",
|
|
332
408
|
config: { version: 2 },
|
|
333
|
-
expected: "
|
|
409
|
+
expected: "Unknown repository alias 'missing'",
|
|
334
410
|
},
|
|
335
411
|
{
|
|
336
412
|
id: "bad-base",
|
|
@@ -392,6 +468,7 @@ pr: null
|
|
|
392
468
|
),
|
|
393
469
|
),
|
|
394
470
|
).rejects.toThrow(fixture.expected)
|
|
471
|
+
await rm(taskDirectory, { recursive: true, force: true })
|
|
395
472
|
}
|
|
396
473
|
})
|
|
397
474
|
|
|
@@ -561,7 +638,7 @@ pr: null
|
|
|
561
638
|
).rejects.toThrow("is not registered to branch 'task/expected'")
|
|
562
639
|
})
|
|
563
640
|
|
|
564
|
-
test("
|
|
641
|
+
test("fetches a moving ref before checking a reused reference checkout", async () => {
|
|
565
642
|
await runTestEffect(
|
|
566
643
|
TaskService.pipe(
|
|
567
644
|
Effect.flatMap((service) =>
|
|
@@ -570,7 +647,7 @@ pr: null
|
|
|
570
647
|
id: "pinned-reference",
|
|
571
648
|
ticketUrl: "https://example.com/task",
|
|
572
649
|
repo: "agency",
|
|
573
|
-
repos: [{ repo: "effect", ref: "main" }],
|
|
650
|
+
repos: [{ repo: "effect", ref: "origin/main" }],
|
|
574
651
|
branch: "task/pinned-reference",
|
|
575
652
|
base: "main",
|
|
576
653
|
},
|
|
@@ -590,7 +667,6 @@ pr: null
|
|
|
590
667
|
await Bun.write(join(source, "README.md"), "updated\n")
|
|
591
668
|
await git(["add", "README.md"], source)
|
|
592
669
|
await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
|
|
593
|
-
await git(["push", join(root, "repos/effect"), "main"], source)
|
|
594
670
|
|
|
595
671
|
await expect(
|
|
596
672
|
runTestEffect(
|
|
@@ -600,7 +676,7 @@ pr: null
|
|
|
600
676
|
),
|
|
601
677
|
),
|
|
602
678
|
),
|
|
603
|
-
).rejects.toThrow("does not match reference 'main'")
|
|
679
|
+
).rejects.toThrow("does not match reference 'origin/main'")
|
|
604
680
|
})
|
|
605
681
|
|
|
606
682
|
test("rejects a reference checkout attached to a branch", async () => {
|
|
@@ -60,6 +60,11 @@ const formatCommand = (args: readonly string[]) =>
|
|
|
60
60
|
)
|
|
61
61
|
.join(" ")
|
|
62
62
|
|
|
63
|
+
const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
|
|
64
|
+
|
|
65
|
+
const originRef = (ref: string) =>
|
|
66
|
+
ref.replace(/^refs\/remotes\/origin\//, "").replace(/^origin\//, "")
|
|
67
|
+
|
|
63
68
|
export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
64
69
|
"WorktreeService",
|
|
65
70
|
{
|
|
@@ -80,12 +85,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
80
85
|
options.verbose === true && !options.silent && !options.json
|
|
81
86
|
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
82
87
|
const report = yield* workbase.validate(root)
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
)
|
|
86
|
-
if (ownershipIssue) {
|
|
88
|
+
const validationIssue = report.issues[0]
|
|
89
|
+
if (validationIssue) {
|
|
87
90
|
return yield* new WorktreeError({
|
|
88
|
-
message: `${
|
|
91
|
+
message: `${validationIssue.path}: ${validationIssue.message}`,
|
|
89
92
|
})
|
|
90
93
|
}
|
|
91
94
|
const task = yield* tasks.show(taskId, root)
|
|
@@ -136,23 +139,32 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
136
139
|
})
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
const fetchOrigin =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
const fetchOrigin = (ref?: string) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const remote = yield* fs.runCommand(
|
|
145
|
+
["git", "-C", repositoryPath, "remote", "get-url", "origin"],
|
|
146
|
+
{ captureOutput: true },
|
|
147
|
+
)
|
|
148
|
+
if (remote.exitCode !== 0) return false
|
|
145
149
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
const fetch = yield* fs.runCommand(
|
|
151
|
+
[
|
|
152
|
+
"git",
|
|
153
|
+
"-C",
|
|
154
|
+
repositoryPath,
|
|
155
|
+
"fetch",
|
|
156
|
+
"origin",
|
|
157
|
+
...(ref ? [ref] : []),
|
|
158
|
+
],
|
|
159
|
+
{ captureOutput: true },
|
|
160
|
+
)
|
|
161
|
+
if (fetch.exitCode !== 0) {
|
|
162
|
+
return yield* new WorktreeError({
|
|
163
|
+
message: `Failed to fetch '${alias}': ${fetch.stderr}`,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return true
|
|
167
|
+
})
|
|
156
168
|
|
|
157
169
|
const listed = yield* fs.runCommand(
|
|
158
170
|
[
|
|
@@ -210,7 +222,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
210
222
|
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
211
223
|
})
|
|
212
224
|
}
|
|
213
|
-
yield* fetchOrigin
|
|
225
|
+
yield* fetchOrigin()
|
|
214
226
|
|
|
215
227
|
let args: string[]
|
|
216
228
|
let env: Record<string, string> | undefined
|
|
@@ -297,6 +309,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
297
309
|
})
|
|
298
310
|
}
|
|
299
311
|
} else {
|
|
312
|
+
const fetched = isCommitId(checkout.ref)
|
|
313
|
+
? false
|
|
314
|
+
: yield* fetchOrigin(originRef(checkout.ref))
|
|
315
|
+
const resolvedRefName = fetched ? "FETCH_HEAD" : checkout.ref
|
|
300
316
|
const resolvedRef = yield* fs.runCommand(
|
|
301
317
|
[
|
|
302
318
|
"git",
|
|
@@ -304,7 +320,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
304
320
|
repositoryPath,
|
|
305
321
|
"rev-parse",
|
|
306
322
|
"--verify",
|
|
307
|
-
`${
|
|
323
|
+
`${resolvedRefName}^{commit}`,
|
|
308
324
|
],
|
|
309
325
|
{ captureOutput: true },
|
|
310
326
|
)
|
|
@@ -344,7 +360,6 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
344
360
|
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
345
361
|
})
|
|
346
362
|
}
|
|
347
|
-
yield* fetchOrigin
|
|
348
363
|
const result = yield* fs.runCommand(
|
|
349
364
|
[
|
|
350
365
|
"git",
|