@markjaquith/agency 2.65.0 → 2.65.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/README.md +15 -4
- package/cli-main.ts +7 -1
- package/package.json +1 -1
- package/src/cli-parser.test.ts +31 -1
- package/src/cli-parser.ts +22 -7
- package/src/commands/context.test.ts +67 -0
- package/src/commands/pr.test.ts +13 -1
- package/src/commands/pr.ts +17 -3
- package/src/commands/push.test.ts +55 -0
- package/src/commands/push.ts +29 -2
- package/src/commands/sync.test.ts +35 -0
- package/src/commands/sync.ts +7 -1
- package/src/services/ArchiveBulkService.test.ts +99 -0
- package/src/services/ContextService.ts +12 -8
- package/src/services/PullRequestService.test.ts +47 -1
- package/src/services/PullRequestService.ts +26 -0
- package/src/services/PushService.test.ts +38 -2
- package/src/services/PushService.ts +21 -2
- package/src/services/RepositoryService.ts +17 -3
- package/src/services/SyncService.ts +59 -27
- package/src/services/WorktreeService.ts +22 -8
- package/src/workbase/delivery-command.test.ts +49 -0
- package/src/workbase/delivery-command.ts +13 -3
package/README.md
CHANGED
|
@@ -650,8 +650,8 @@ detailed dependency, validation, or status blockers for orchestrators.
|
|
|
650
650
|
|
|
651
651
|
`agency work` consults this shared readiness model before materializing. Blocked,
|
|
652
652
|
done, and dropped targets are rejected unless `--force` is supplied explicitly.
|
|
653
|
-
`agency pr
|
|
654
|
-
|
|
653
|
+
Task-aware `agency pr create` applies Agency readiness and validation checks;
|
|
654
|
+
untargeted `agency pr` invocations leave command semantics to `gh`.
|
|
655
655
|
|
|
656
656
|
### Reconciliation
|
|
657
657
|
|
|
@@ -663,6 +663,10 @@ structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
|
663
663
|
default mode applies safe reconciliation transitions; `--dry-run` is explicitly
|
|
664
664
|
observational.
|
|
665
665
|
|
|
666
|
+
Pass `<task-id>` to scope reconciliation to one task and its repositories. A
|
|
667
|
+
multi-phase task scope includes all of its phases; add `[phase-id]` to select one
|
|
668
|
+
phase. Scoped sync does not query, materialize, or reconcile unrelated work.
|
|
669
|
+
|
|
666
670
|
`agency sync` performs only these safe transitions:
|
|
667
671
|
|
|
668
672
|
- materialize declared but missing repositories from their canonical remotes;
|
|
@@ -1050,7 +1054,7 @@ agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print
|
|
|
1050
1054
|
agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
|
|
1051
1055
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
1052
1056
|
agency push [--json]
|
|
1053
|
-
agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
1057
|
+
agency pr create <task-id> [phase-id] [--draft] [--title <title>] [--head <branch>] [--base <branch>] [--label <label>] [--force] [--json]
|
|
1054
1058
|
agency pr [args...]
|
|
1055
1059
|
```
|
|
1056
1060
|
|
|
@@ -1137,9 +1141,16 @@ post-commit working copy, in which case it publishes `@-`; described empty
|
|
|
1137
1141
|
changes remain intentional publication tips. Missing descriptions or authors
|
|
1138
1142
|
stop with exact change IDs and remediation commands. Agency creates or safely
|
|
1139
1143
|
advances only the declared bookmark and never invents a `push-*` bookmark.
|
|
1144
|
+
If the fetched remote base advanced outside the local stack, Agency prints the
|
|
1145
|
+
exact `jj rebase` command needed to move the stack onto `<base>@<remote>`. Push
|
|
1146
|
+
reports deterministic fetch, inspection, validation, and publication progress
|
|
1147
|
+
on stderr, including while `--json` reserves stdout for one machine result.
|
|
1140
1148
|
|
|
1141
1149
|
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
1142
|
-
including readiness checks and durable PR recording.
|
|
1150
|
+
including readiness checks and durable PR recording. It accepts draft, title,
|
|
1151
|
+
declared head/base confirmation, and repeatable label options; a contradicting
|
|
1152
|
+
head or base is rejected rather than recording inconsistent delivery metadata.
|
|
1153
|
+
Other `agency pr`
|
|
1143
1154
|
invocations forward every argument to `gh pr`. From an execution task or phase
|
|
1144
1155
|
directory, including descendants, passthrough runs in that execution unit's
|
|
1145
1156
|
authoritative writable checkout. Otherwise it runs in the caller's current
|
package/cli-main.ts
CHANGED
|
@@ -322,6 +322,10 @@ const commands: Record<string, Command> = {
|
|
|
322
322
|
phaseId: args[2],
|
|
323
323
|
draft: options.draft,
|
|
324
324
|
force: options.force,
|
|
325
|
+
title: options.title,
|
|
326
|
+
head: options.head,
|
|
327
|
+
base: options.base,
|
|
328
|
+
labels: options.label,
|
|
325
329
|
json: options.json,
|
|
326
330
|
silent: options.silent,
|
|
327
331
|
verbose: options.verbose,
|
|
@@ -750,13 +754,15 @@ const commands: Record<string, Command> = {
|
|
|
750
754
|
},
|
|
751
755
|
},
|
|
752
756
|
sync: {
|
|
753
|
-
run: async (
|
|
757
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
754
758
|
if (options.help) {
|
|
755
759
|
console.log(syncHelp)
|
|
756
760
|
return
|
|
757
761
|
}
|
|
758
762
|
await runCommand(
|
|
759
763
|
sync({
|
|
764
|
+
taskId: args[0],
|
|
765
|
+
phaseId: args[1],
|
|
760
766
|
dryRun: options["dry-run"],
|
|
761
767
|
json: options.json,
|
|
762
768
|
silent: options.silent,
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -334,7 +334,7 @@ describe("strict CLI parsing", () => {
|
|
|
334
334
|
[["context", "one", "two"], "agency context"],
|
|
335
335
|
[["graph", "extra"], "agency graph"],
|
|
336
336
|
[["next", "extra"], "agency next"],
|
|
337
|
-
[["sync", "
|
|
337
|
+
[["sync", "one", "two", "three"], "agency sync"],
|
|
338
338
|
[
|
|
339
339
|
[
|
|
340
340
|
"claim",
|
|
@@ -430,6 +430,31 @@ describe("strict CLI parsing", () => {
|
|
|
430
430
|
args: ["create", "ship"],
|
|
431
431
|
})
|
|
432
432
|
expect(selected.passthrough).toBeUndefined()
|
|
433
|
+
expect(
|
|
434
|
+
parseCli([
|
|
435
|
+
"pr",
|
|
436
|
+
"create",
|
|
437
|
+
"ship",
|
|
438
|
+
"--title",
|
|
439
|
+
"Ship it",
|
|
440
|
+
"--head",
|
|
441
|
+
"task/ship",
|
|
442
|
+
"--base",
|
|
443
|
+
"main",
|
|
444
|
+
"--label",
|
|
445
|
+
"ai-assisted",
|
|
446
|
+
"--label",
|
|
447
|
+
"product-area:platform",
|
|
448
|
+
]),
|
|
449
|
+
).toMatchObject({
|
|
450
|
+
args: ["create", "ship"],
|
|
451
|
+
values: {
|
|
452
|
+
title: "Ship it",
|
|
453
|
+
head: "task/ship",
|
|
454
|
+
base: "main",
|
|
455
|
+
label: ["ai-assisted", "product-area:platform"],
|
|
456
|
+
},
|
|
457
|
+
})
|
|
433
458
|
})
|
|
434
459
|
|
|
435
460
|
test("parses explicit dry-run reconciliation", () => {
|
|
@@ -441,6 +466,11 @@ describe("strict CLI parsing", () => {
|
|
|
441
466
|
commandName: "sync",
|
|
442
467
|
values: { "dry-run": true, json: true },
|
|
443
468
|
})
|
|
469
|
+
expect(parseCli(["sync", "ship", "release", "--json"])).toMatchObject({
|
|
470
|
+
commandName: "sync",
|
|
471
|
+
args: ["ship", "release"],
|
|
472
|
+
values: { json: true },
|
|
473
|
+
})
|
|
444
474
|
expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
|
|
445
475
|
})
|
|
446
476
|
|
package/src/cli-parser.ts
CHANGED
|
@@ -732,16 +732,17 @@ const commands = {
|
|
|
732
732
|
},
|
|
733
733
|
},
|
|
734
734
|
sync: {
|
|
735
|
-
usage: "agency sync [--dry-run] [--json]",
|
|
735
|
+
usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
|
|
736
736
|
options: {
|
|
737
737
|
...outputOptions,
|
|
738
|
+
...entitySelectorOptions,
|
|
738
739
|
"dry-run": { type: "boolean" },
|
|
739
740
|
},
|
|
740
741
|
command: {
|
|
741
|
-
usage: "agency sync [--dry-run] [--json]",
|
|
742
|
+
usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
|
|
742
743
|
minArgs: 0,
|
|
743
|
-
maxArgs:
|
|
744
|
-
options: ["dry-run", "json"],
|
|
744
|
+
maxArgs: 2,
|
|
745
|
+
options: ["dry-run", "json", "task", "phase"],
|
|
745
746
|
},
|
|
746
747
|
},
|
|
747
748
|
archive: {
|
|
@@ -964,14 +965,28 @@ const commands = {
|
|
|
964
965
|
force: { type: "boolean" },
|
|
965
966
|
task: { type: "string" },
|
|
966
967
|
phase: { type: "string" },
|
|
968
|
+
title: { type: "string" },
|
|
969
|
+
head: { type: "string" },
|
|
970
|
+
base: { type: "string" },
|
|
971
|
+
label: { type: "string", multiple: true },
|
|
967
972
|
},
|
|
968
973
|
subcommands: {
|
|
969
974
|
create: {
|
|
970
|
-
usage:
|
|
971
|
-
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
975
|
+
usage: "agency pr create <task-id> [phase-id] [options]",
|
|
972
976
|
minArgs: 1,
|
|
973
977
|
maxArgs: 2,
|
|
974
|
-
options: [
|
|
978
|
+
options: [
|
|
979
|
+
"draft",
|
|
980
|
+
"force",
|
|
981
|
+
"json",
|
|
982
|
+
"task",
|
|
983
|
+
"phase",
|
|
984
|
+
"title",
|
|
985
|
+
"head",
|
|
986
|
+
"base",
|
|
987
|
+
"label",
|
|
988
|
+
],
|
|
989
|
+
repeatable: ["label"],
|
|
975
990
|
},
|
|
976
991
|
},
|
|
977
992
|
},
|
|
@@ -219,6 +219,73 @@ Phase prose.
|
|
|
219
219
|
)
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
+
test("reports the current jj change without inventing a missing bookmark", async () => {
|
|
223
|
+
if (!Bun.which("jj")) return
|
|
224
|
+
await cleanupTempDir(root)
|
|
225
|
+
root = await createTempDir()
|
|
226
|
+
const remote = join(root, "remote.git")
|
|
227
|
+
const seed = join(root, "seed")
|
|
228
|
+
const repository = join(root, "repos/agency")
|
|
229
|
+
const checkout = join(root, "tasks/example/code/agency")
|
|
230
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
231
|
+
await write(root, "agency.json", '{"version":2,"vcs":"jj"}\n')
|
|
232
|
+
await run(root, ["git", "init", "--bare", "--initial-branch=main", remote])
|
|
233
|
+
await run(root, ["git", "init", "--initial-branch=main", seed])
|
|
234
|
+
await run(seed, ["git", "config", "user.email", "test@example.com"])
|
|
235
|
+
await run(seed, ["git", "config", "user.name", "Test"])
|
|
236
|
+
await Bun.write(join(seed, "README.md"), "example\n")
|
|
237
|
+
await run(seed, ["git", "add", "README.md"])
|
|
238
|
+
await run(seed, ["git", "commit", "-m", "initial"])
|
|
239
|
+
await run(seed, ["git", "remote", "add", "origin", remote])
|
|
240
|
+
await run(seed, ["git", "push", "origin", "main"])
|
|
241
|
+
await run(root, ["jj", "git", "clone", "--no-colocate", remote, repository])
|
|
242
|
+
await write(
|
|
243
|
+
root,
|
|
244
|
+
"tasks/example/TASK.md",
|
|
245
|
+
`---
|
|
246
|
+
ticketUrl: null
|
|
247
|
+
repo: agency
|
|
248
|
+
branch: task/example
|
|
249
|
+
base: main
|
|
250
|
+
pr: null
|
|
251
|
+
status: working
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
# Example
|
|
255
|
+
`,
|
|
256
|
+
)
|
|
257
|
+
await mkdir(dirname(checkout), { recursive: true })
|
|
258
|
+
await run(repository, [
|
|
259
|
+
"jj",
|
|
260
|
+
"workspace",
|
|
261
|
+
"add",
|
|
262
|
+
"--name",
|
|
263
|
+
"task-example",
|
|
264
|
+
"-r",
|
|
265
|
+
"main",
|
|
266
|
+
checkout,
|
|
267
|
+
])
|
|
268
|
+
await Bun.write(join(checkout, "feature.txt"), "current change\n")
|
|
269
|
+
const current = Bun.spawn(
|
|
270
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
|
271
|
+
{ cwd: checkout, stdout: "pipe", stderr: "pipe" },
|
|
272
|
+
)
|
|
273
|
+
const currentCommit = (await new Response(current.stdout).text()).trim()
|
|
274
|
+
expect(await current.exited).toBe(0)
|
|
275
|
+
|
|
276
|
+
const result = await readContext(root, "tasks/example")
|
|
277
|
+
expect(result.workspace.writable).toMatchObject({
|
|
278
|
+
branchCommit: null,
|
|
279
|
+
checkoutCommit: currentCommit,
|
|
280
|
+
checkoutBranch: null,
|
|
281
|
+
detached: true,
|
|
282
|
+
dirty: false,
|
|
283
|
+
})
|
|
284
|
+
expect(result.workspace.warnings).toContain(
|
|
285
|
+
`Unable to resolve branch 'task/example' in ${repository}`,
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
222
289
|
test("makes compact projection explicit without omitting essential identity", async () => {
|
|
223
290
|
const result = await readContext(
|
|
224
291
|
root,
|
package/src/commands/pr.test.ts
CHANGED
|
@@ -158,6 +158,10 @@ status: open
|
|
|
158
158
|
phaseId: "implementation",
|
|
159
159
|
draft: true,
|
|
160
160
|
force: true,
|
|
161
|
+
title: "Ship it",
|
|
162
|
+
head: "task/example",
|
|
163
|
+
base: "main",
|
|
164
|
+
labels: ["ai-assisted"],
|
|
161
165
|
cwd: "/workbase",
|
|
162
166
|
json: true,
|
|
163
167
|
}).pipe(
|
|
@@ -177,7 +181,15 @@ status: open
|
|
|
177
181
|
"implementation",
|
|
178
182
|
true,
|
|
179
183
|
"/workbase",
|
|
180
|
-
expect.objectContaining({
|
|
184
|
+
expect.objectContaining({
|
|
185
|
+
force: true,
|
|
186
|
+
draft: true,
|
|
187
|
+
title: "Ship it",
|
|
188
|
+
head: "task/example",
|
|
189
|
+
base: "main",
|
|
190
|
+
labels: ["ai-assisted"],
|
|
191
|
+
json: true,
|
|
192
|
+
}),
|
|
181
193
|
])
|
|
182
194
|
})
|
|
183
195
|
|
package/src/commands/pr.ts
CHANGED
|
@@ -14,6 +14,10 @@ interface PrCreateOptions extends BaseCommandOptions {
|
|
|
14
14
|
readonly phaseId?: string
|
|
15
15
|
readonly draft?: boolean
|
|
16
16
|
readonly force?: boolean
|
|
17
|
+
readonly title?: string
|
|
18
|
+
readonly head?: string
|
|
19
|
+
readonly base?: string
|
|
20
|
+
readonly labels?: readonly string[]
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const branchTargetCommands = new Set([
|
|
@@ -126,11 +130,21 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
|
126
130
|
})
|
|
127
131
|
|
|
128
132
|
export const help = `
|
|
129
|
-
Usage: agency pr create <task-id> [phase-id] [
|
|
133
|
+
Usage: agency pr create <task-id> [phase-id] [options]
|
|
130
134
|
agency pr [args...]
|
|
131
135
|
|
|
132
|
-
Create records a pull request for an Agency execution unit
|
|
133
|
-
|
|
136
|
+
Create records a pull request for an Agency execution unit and accepts the
|
|
137
|
+
options listed below. Invocations without an Agency task target run gh pr
|
|
138
|
+
unchanged, focusing the writable repository checkout when invoked from
|
|
134
139
|
an Agency execution task or phase. In jj workbases, Agency supplies the declared
|
|
135
140
|
branch and repository to gh subcommands that would otherwise infer Git context.
|
|
141
|
+
|
|
142
|
+
Options:
|
|
143
|
+
--draft Create the pull request as a draft
|
|
144
|
+
--title <title> Override the generated pull request title
|
|
145
|
+
--head <branch> Confirm the declared head branch
|
|
146
|
+
--base <branch> Confirm the declared base branch
|
|
147
|
+
--label <label> Add a label (repeatable)
|
|
148
|
+
--force Override readiness checks
|
|
149
|
+
--json Output one versioned machine result
|
|
136
150
|
`
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { PushService } from "../services/PushService"
|
|
4
|
+
import { captureLogs } from "../test-utils"
|
|
5
|
+
import type { Progress } from "../utils/progress"
|
|
6
|
+
import { push } from "./push"
|
|
7
|
+
|
|
8
|
+
describe("push command", () => {
|
|
9
|
+
test("reports deterministic progress while preserving one JSON result", async () => {
|
|
10
|
+
const updates: string[] = []
|
|
11
|
+
const progress: Progress = {
|
|
12
|
+
start: (message) => updates.push(`start:${message}`),
|
|
13
|
+
succeed: (message) => updates.push(`succeed:${message}`),
|
|
14
|
+
fail: (message) => updates.push(`fail:${message}`),
|
|
15
|
+
}
|
|
16
|
+
const result = {
|
|
17
|
+
vcs: "jj" as const,
|
|
18
|
+
taskId: "example",
|
|
19
|
+
branch: "task/example",
|
|
20
|
+
base: "main",
|
|
21
|
+
remote: "origin",
|
|
22
|
+
tip: "abc123",
|
|
23
|
+
changeId: "change",
|
|
24
|
+
}
|
|
25
|
+
const logs = await captureLogs(() =>
|
|
26
|
+
Effect.runPromise(
|
|
27
|
+
push({ cwd: "/workbase", json: true }, progress).pipe(
|
|
28
|
+
Effect.provideService(PushService, {
|
|
29
|
+
publish: (_cwd: string, options: any) => {
|
|
30
|
+
for (const stage of [
|
|
31
|
+
"context",
|
|
32
|
+
"fetch",
|
|
33
|
+
"inspect",
|
|
34
|
+
"validate",
|
|
35
|
+
"publish",
|
|
36
|
+
] as const)
|
|
37
|
+
options.onProgress(stage)
|
|
38
|
+
return Effect.succeed(result)
|
|
39
|
+
},
|
|
40
|
+
} as never),
|
|
41
|
+
) as Effect.Effect<void, unknown, never>,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expect(logs).toEqual([JSON.stringify(result, null, 2)])
|
|
46
|
+
expect(updates).toEqual([
|
|
47
|
+
"start:Inspecting Agency execution context",
|
|
48
|
+
"start:Fetching remote state",
|
|
49
|
+
"start:Selecting the publication tip",
|
|
50
|
+
"start:Validating outgoing changes",
|
|
51
|
+
"start:Publishing the declared branch",
|
|
52
|
+
"succeed:Published task/example to origin",
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/commands/push.ts
CHANGED
|
@@ -2,12 +2,39 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { PushService } from "../services/PushService"
|
|
3
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
4
|
import { createLoggers } from "../utils/effect"
|
|
5
|
+
import { createProgress, type Progress } from "../utils/progress"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
const stageMessage = {
|
|
8
|
+
context: "Inspecting Agency execution context",
|
|
9
|
+
fetch: "Fetching remote state",
|
|
10
|
+
inspect: "Selecting the publication tip",
|
|
11
|
+
validate: "Validating outgoing changes",
|
|
12
|
+
publish: "Publishing the declared branch",
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export const push = (
|
|
16
|
+
options: BaseCommandOptions = {},
|
|
17
|
+
progress: Progress = createProgress({ silent: options.silent }),
|
|
18
|
+
) =>
|
|
7
19
|
Effect.gen(function* () {
|
|
8
20
|
const publications = yield* PushService
|
|
9
21
|
const { log } = createLoggers(options)
|
|
10
|
-
const
|
|
22
|
+
const showProgress = !options.silent
|
|
23
|
+
const result = yield* publications
|
|
24
|
+
.publish(options.cwd ?? process.cwd(), {
|
|
25
|
+
onProgress: showProgress
|
|
26
|
+
? (stage) => progress.start(stageMessage[stage])
|
|
27
|
+
: undefined,
|
|
28
|
+
})
|
|
29
|
+
.pipe(
|
|
30
|
+
Effect.tapError(() =>
|
|
31
|
+
Effect.sync(() => {
|
|
32
|
+
if (showProgress) progress.fail("Publication failed")
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
if (showProgress)
|
|
37
|
+
progress.succeed(`Published ${result.branch} to ${result.remote}`)
|
|
11
38
|
log(
|
|
12
39
|
options.json
|
|
13
40
|
? JSON.stringify(result, null, 2)
|
|
@@ -82,6 +82,41 @@ describe("sync command", () => {
|
|
|
82
82
|
).toBe(false)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test("scopes reconciliation to one task", async () => {
|
|
86
|
+
await runTestEffect(
|
|
87
|
+
TaskService.pipe(
|
|
88
|
+
Effect.flatMap((service) =>
|
|
89
|
+
service.create(
|
|
90
|
+
{
|
|
91
|
+
id: "second",
|
|
92
|
+
ticketUrl: null,
|
|
93
|
+
repo: "agency",
|
|
94
|
+
branch: "task/second",
|
|
95
|
+
base: "main",
|
|
96
|
+
},
|
|
97
|
+
root,
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const logs = await captureLogs(() =>
|
|
104
|
+
runTestEffect(sync({ cwd: root, taskId: "example", json: true })),
|
|
105
|
+
)
|
|
106
|
+
const result = JSON.parse(logs[0]!)
|
|
107
|
+
expect(result.executions.map((execution: any) => execution.target)).toEqual(
|
|
108
|
+
["task:example"],
|
|
109
|
+
)
|
|
110
|
+
expect(
|
|
111
|
+
await Bun.file(
|
|
112
|
+
join(root, "tasks/example/code/agency/README.md"),
|
|
113
|
+
).exists(),
|
|
114
|
+
).toBe(true)
|
|
115
|
+
expect(
|
|
116
|
+
await Bun.file(join(root, "tasks/second/code/agency/README.md")).exists(),
|
|
117
|
+
).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
85
120
|
test("preserves structured output behind --json", async () => {
|
|
86
121
|
const logs = await captureLogs(() =>
|
|
87
122
|
runTestEffect(sync({ cwd: root, dryRun: true, json: true })),
|
package/src/commands/sync.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { createProgress, type Progress } from "../utils/progress"
|
|
|
6
6
|
|
|
7
7
|
interface SyncCommandOptions extends BaseCommandOptions {
|
|
8
8
|
readonly dryRun?: boolean
|
|
9
|
+
readonly taskId?: string
|
|
10
|
+
readonly phaseId?: string
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
interface Notice {
|
|
@@ -44,6 +46,8 @@ export const sync = (
|
|
|
44
46
|
.reconcile({
|
|
45
47
|
cwd: options.cwd,
|
|
46
48
|
apply: options.dryRun !== true,
|
|
49
|
+
taskId: options.taskId,
|
|
50
|
+
phaseId: options.phaseId,
|
|
47
51
|
onProgress: showProgress
|
|
48
52
|
? ({ stage, current, total, target }) => {
|
|
49
53
|
if (stage === "repositories") {
|
|
@@ -118,11 +122,13 @@ export const sync = (
|
|
|
118
122
|
})
|
|
119
123
|
|
|
120
124
|
export const help = `
|
|
121
|
-
Usage: agency sync [--dry-run] [--json]
|
|
125
|
+
Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
|
|
122
126
|
|
|
123
127
|
Compare portable repository declarations and execution state with local Git
|
|
124
128
|
repositories, worktrees, branches, references, claims, and pull requests.
|
|
125
129
|
Safe reconciliation transitions are applied by default.
|
|
130
|
+
When a task or phase is provided, only that target and its repositories are
|
|
131
|
+
queried or reconciled. Task scope includes all phases of a multi-phase task.
|
|
126
132
|
|
|
127
133
|
Options:
|
|
128
134
|
--dry-run Report planned safe transitions without changing state
|
|
@@ -17,6 +17,13 @@ const git = (args: readonly string[], cwd?: string) => {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const jj = (args: readonly string[], cwd?: string) => {
|
|
21
|
+
const result = Bun.spawnSync(["jj", ...args], { cwd })
|
|
22
|
+
if (result.exitCode !== 0) {
|
|
23
|
+
throw new Error(new TextDecoder().decode(result.stderr))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
describe("ArchiveService bulk task archive", () => {
|
|
21
28
|
let root: string
|
|
22
29
|
let source: string
|
|
@@ -436,6 +443,98 @@ claim:
|
|
|
436
443
|
).toBe(true)
|
|
437
444
|
})
|
|
438
445
|
|
|
446
|
+
test("archives a jj working-copy commit preserved by its task bookmark", async () => {
|
|
447
|
+
if (!Bun.which("jj")) return
|
|
448
|
+
const repository = join(root, "repos/agency")
|
|
449
|
+
await rm(repository, { recursive: true, force: true })
|
|
450
|
+
git(["clone", source, repository])
|
|
451
|
+
jj(["git", "init", "--colocate", repository])
|
|
452
|
+
await Bun.write(
|
|
453
|
+
join(root, "agency.json"),
|
|
454
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
455
|
+
)
|
|
456
|
+
await createTask("jj-bookmarked")
|
|
457
|
+
await dropTask("jj-bookmarked")
|
|
458
|
+
const workspace = await runTestEffect(
|
|
459
|
+
WorktreeService.pipe(
|
|
460
|
+
Effect.flatMap((service) =>
|
|
461
|
+
service.materialize("jj-bookmarked", undefined, root),
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
|
|
466
|
+
jj(
|
|
467
|
+
["bookmark", "set", "task/jj-bookmarked", "-r", "@"],
|
|
468
|
+
workspace.writablePath!,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
const preview = await archiveTasks(true)
|
|
472
|
+
expect(preview.tasks[0]).toMatchObject({
|
|
473
|
+
id: "jj-bookmarked",
|
|
474
|
+
disposition: "planned",
|
|
475
|
+
removedWorktrees: [workspace.writablePath!],
|
|
476
|
+
})
|
|
477
|
+
expect(
|
|
478
|
+
await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
|
|
479
|
+
).toBe("keep\n")
|
|
480
|
+
|
|
481
|
+
const result = await archiveTasks()
|
|
482
|
+
expect(result.tasks[0]).toMatchObject({
|
|
483
|
+
id: "jj-bookmarked",
|
|
484
|
+
disposition: "archived",
|
|
485
|
+
})
|
|
486
|
+
expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
|
|
487
|
+
const preserved = Bun.spawnSync([
|
|
488
|
+
"jj",
|
|
489
|
+
"-R",
|
|
490
|
+
repository,
|
|
491
|
+
"file",
|
|
492
|
+
"show",
|
|
493
|
+
"-r",
|
|
494
|
+
"task/jj-bookmarked",
|
|
495
|
+
'root:"preserved.txt"',
|
|
496
|
+
])
|
|
497
|
+
if (preserved.exitCode !== 0) {
|
|
498
|
+
throw new Error(preserved.stderr.toString())
|
|
499
|
+
}
|
|
500
|
+
expect(preserved.stdout.toString()).toBe("keep\n")
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test("archives a forgotten jj workspace preserved by its task bookmark", async () => {
|
|
504
|
+
if (!Bun.which("jj")) return
|
|
505
|
+
const repository = join(root, "repos/agency")
|
|
506
|
+
await rm(repository, { recursive: true, force: true })
|
|
507
|
+
git(["clone", source, repository])
|
|
508
|
+
jj(["git", "init", "--colocate", repository])
|
|
509
|
+
await Bun.write(
|
|
510
|
+
join(root, "agency.json"),
|
|
511
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
512
|
+
)
|
|
513
|
+
await createTask("jj-stale")
|
|
514
|
+
await dropTask("jj-stale")
|
|
515
|
+
const workspace = await runTestEffect(
|
|
516
|
+
WorktreeService.pipe(
|
|
517
|
+
Effect.flatMap((service) =>
|
|
518
|
+
service.materialize("jj-stale", undefined, root),
|
|
519
|
+
),
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
|
|
523
|
+
jj(["bookmark", "set", "task/jj-stale", "-r", "@"], workspace.writablePath!)
|
|
524
|
+
jj(["-R", repository, "workspace", "forget", "agency-jj-stale-task-agency"])
|
|
525
|
+
|
|
526
|
+
const result = await archiveTasks(true)
|
|
527
|
+
|
|
528
|
+
expect(result.tasks[0]).toMatchObject({
|
|
529
|
+
id: "jj-stale",
|
|
530
|
+
disposition: "planned",
|
|
531
|
+
removedWorktrees: [workspace.writablePath!],
|
|
532
|
+
})
|
|
533
|
+
expect(
|
|
534
|
+
await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
|
|
535
|
+
).toBe("keep\n")
|
|
536
|
+
})
|
|
537
|
+
|
|
439
538
|
test("rolls back the entire cohort when application fails", async () => {
|
|
440
539
|
await runTestEffect(
|
|
441
540
|
EpicService.pipe(
|
|
@@ -898,6 +898,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
898
898
|
repositoryPath: string,
|
|
899
899
|
checkoutPath: string,
|
|
900
900
|
expectedBranch: string | null = null,
|
|
901
|
+
branchCommit: string | null = null,
|
|
901
902
|
) =>
|
|
902
903
|
Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
|
|
903
904
|
const materialized = yield* fs.isDirectory(checkoutPath)
|
|
@@ -914,9 +915,10 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
914
915
|
const canonicalCheckoutPath = materialized
|
|
915
916
|
? yield* fs.realPath(checkoutPath)
|
|
916
917
|
: resolve(checkoutPath)
|
|
917
|
-
const
|
|
918
|
+
const workspace = workspaces.find(
|
|
918
919
|
(workspace) => workspace.path === canonicalCheckoutPath,
|
|
919
920
|
)
|
|
921
|
+
const registered = workspace !== undefined
|
|
920
922
|
if (!materialized) {
|
|
921
923
|
return {
|
|
922
924
|
materialized: false,
|
|
@@ -930,10 +932,14 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
930
932
|
return {
|
|
931
933
|
materialized: true,
|
|
932
934
|
registered,
|
|
933
|
-
checkoutCommit:
|
|
934
|
-
checkoutBranch:
|
|
935
|
-
|
|
936
|
-
|
|
935
|
+
checkoutCommit: workspace?.commit ?? null,
|
|
936
|
+
checkoutBranch:
|
|
937
|
+
expectedBranch && branchCommit === workspace?.commit
|
|
938
|
+
? expectedBranch
|
|
939
|
+
: null,
|
|
940
|
+
detached:
|
|
941
|
+
!expectedBranch || branchCommit !== workspace?.commit,
|
|
942
|
+
dirty: workspace ? false : null,
|
|
937
943
|
}
|
|
938
944
|
}
|
|
939
945
|
const listed = yield* runGit(fs, repositoryPath, [
|
|
@@ -1014,10 +1020,8 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1014
1020
|
repositoryPath,
|
|
1015
1021
|
checkoutPath,
|
|
1016
1022
|
executionData.branch,
|
|
1023
|
+
branchCommit,
|
|
1017
1024
|
)
|
|
1018
|
-
if (backend.kind === "jj" && branchCommit === null) {
|
|
1019
|
-
branchCommit = checkout.checkoutCommit
|
|
1020
|
-
}
|
|
1021
1025
|
if (branchCommit === null) {
|
|
1022
1026
|
inspectionWarnings.push(
|
|
1023
1027
|
`Unable to resolve branch '${executionData.branch}' in ${repositoryPath}`,
|
|
@@ -68,11 +68,12 @@ describe("PullRequestService", () => {
|
|
|
68
68
|
phaseId?: string,
|
|
69
69
|
draft = false,
|
|
70
70
|
force = false,
|
|
71
|
+
options: Record<string, unknown> = {},
|
|
71
72
|
) =>
|
|
72
73
|
runTestEffect(
|
|
73
74
|
PullRequestService.pipe(
|
|
74
75
|
Effect.flatMap((service) =>
|
|
75
|
-
service.create(taskId, phaseId, draft, root, { force }),
|
|
76
|
+
service.create(taskId, phaseId, draft, root, { force, ...options }),
|
|
76
77
|
),
|
|
77
78
|
),
|
|
78
79
|
)
|
|
@@ -303,6 +304,51 @@ process.exit(${exitCode})
|
|
|
303
304
|
])
|
|
304
305
|
})
|
|
305
306
|
|
|
307
|
+
test("passes task-aware GitHub title and labels with declared refs", async () => {
|
|
308
|
+
await createTask()
|
|
309
|
+
await writeFakeGh({
|
|
310
|
+
stdout: "https://github.com/example/agency/pull/45\n",
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
await createPullRequest("example", undefined, true, false, {
|
|
314
|
+
title: "Ship the workflow",
|
|
315
|
+
head: "task/example",
|
|
316
|
+
base: "main",
|
|
317
|
+
labels: ["ai-assisted", "platform"],
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
expect((await readGhCall()).args).toEqual([
|
|
321
|
+
"pr",
|
|
322
|
+
"create",
|
|
323
|
+
"--fill",
|
|
324
|
+
"--title",
|
|
325
|
+
"Ship the workflow",
|
|
326
|
+
"--base",
|
|
327
|
+
"main",
|
|
328
|
+
"--head",
|
|
329
|
+
"task/example",
|
|
330
|
+
"--draft",
|
|
331
|
+
"--label",
|
|
332
|
+
"ai-assisted",
|
|
333
|
+
"--label",
|
|
334
|
+
"platform",
|
|
335
|
+
])
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test("rejects task-aware head and base values that contradict declarations", async () => {
|
|
339
|
+
await createTask()
|
|
340
|
+
await expect(
|
|
341
|
+
createPullRequest("example", undefined, false, false, {
|
|
342
|
+
head: "other",
|
|
343
|
+
}),
|
|
344
|
+
).rejects.toThrow("does not match declared branch")
|
|
345
|
+
await expect(
|
|
346
|
+
createPullRequest("example", undefined, false, false, {
|
|
347
|
+
base: "release",
|
|
348
|
+
}),
|
|
349
|
+
).rejects.toThrow("does not match declared base")
|
|
350
|
+
})
|
|
351
|
+
|
|
306
352
|
test("guards terminal targets before materializing unless forced", async () => {
|
|
307
353
|
await createTask()
|
|
308
354
|
await runTestEffect(
|
|
@@ -27,6 +27,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
|
27
27
|
|
|
28
28
|
interface PullRequestOptions extends BaseCommandOptions {
|
|
29
29
|
readonly force?: boolean
|
|
30
|
+
readonly title?: string
|
|
31
|
+
readonly head?: string
|
|
32
|
+
readonly base?: string
|
|
33
|
+
readonly labels?: readonly string[]
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
@@ -158,6 +162,25 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
158
162
|
})
|
|
159
163
|
}
|
|
160
164
|
const remote = config.delivery?.remote ?? "origin"
|
|
165
|
+
if (options.head && options.head !== execution.branch) {
|
|
166
|
+
return yield* new PullRequestError({
|
|
167
|
+
message: `Requested head '${options.head}' does not match declared branch '${execution.branch}'`,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
if (options.base && options.base !== execution.base) {
|
|
171
|
+
return yield* new PullRequestError({
|
|
172
|
+
message: `Requested base '${options.base}' does not match declared base '${execution.base}'`,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
config.delivery &&
|
|
177
|
+
(options.title || (options.labels?.length ?? 0) > 0)
|
|
178
|
+
) {
|
|
179
|
+
return yield* new PullRequestError({
|
|
180
|
+
message:
|
|
181
|
+
"Task-aware --title and --label options require the default GitHub delivery provider",
|
|
182
|
+
})
|
|
183
|
+
}
|
|
161
184
|
|
|
162
185
|
const dirty = yield* backend.workspaceDirty(workspace.writablePath)
|
|
163
186
|
if (dirty === null) {
|
|
@@ -211,6 +234,9 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
211
234
|
repository,
|
|
212
235
|
draft,
|
|
213
236
|
vcs: config.vcs ?? "git",
|
|
237
|
+
title: options.title,
|
|
238
|
+
head: options.head,
|
|
239
|
+
labels: options.labels,
|
|
214
240
|
...(defaults ? { defaults } : {}),
|
|
215
241
|
})
|
|
216
242
|
const gitEnvironment = config.delivery
|
|
@@ -376,6 +376,42 @@ describe("PushService", () => {
|
|
|
376
376
|
)
|
|
377
377
|
).stdout,
|
|
378
378
|
).toBe("true")
|
|
379
|
+
|
|
380
|
+
await requireCommand(["jj", "new", "@"], fixture.checkout)
|
|
381
|
+
await Bun.write(join(fixture.checkout, "follow-up.txt"), "follow up\n")
|
|
382
|
+
await requireCommand(
|
|
383
|
+
["jj", "describe", "-m", "Add follow-up"],
|
|
384
|
+
fixture.checkout,
|
|
385
|
+
)
|
|
386
|
+
const advanced = await publish(fixture.taskPath)
|
|
387
|
+
expect(advanced.tip).not.toBe(result.tip)
|
|
388
|
+
expect(await remoteBranch(fixture.remote)).toBe(advanced.tip)
|
|
389
|
+
}, 15_000)
|
|
390
|
+
|
|
391
|
+
test("diagnoses a jj stack whose remote base advanced", async () => {
|
|
392
|
+
if (!Bun.which("jj")) return
|
|
393
|
+
const fixture = await setup("jj")
|
|
394
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "local\n")
|
|
395
|
+
await requireCommand(
|
|
396
|
+
["jj", "describe", "-m", "Local feature"],
|
|
397
|
+
fixture.checkout,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
const other = join(fixture.root, "other-main")
|
|
401
|
+
await requireCommand(["git", "clone", fixture.remote, other])
|
|
402
|
+
await requireCommand(["git", "config", "user.name", "Other"], other)
|
|
403
|
+
await requireCommand(
|
|
404
|
+
["git", "config", "user.email", "other@example.com"],
|
|
405
|
+
other,
|
|
406
|
+
)
|
|
407
|
+
await Bun.write(join(other, "remote.txt"), "remote\n")
|
|
408
|
+
await requireCommand(["git", "add", "remote.txt"], other)
|
|
409
|
+
await requireCommand(["git", "commit", "-m", "Advance main"], other)
|
|
410
|
+
await requireCommand(["git", "push", "origin", "main"], other)
|
|
411
|
+
|
|
412
|
+
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
413
|
+
"jj rebase -s 'roots(main@origin..@)' -d main@origin",
|
|
414
|
+
)
|
|
379
415
|
})
|
|
380
416
|
|
|
381
417
|
test("selects the described parent of a canonical jj post-commit working copy", async () => {
|
|
@@ -424,7 +460,7 @@ describe("PushService", () => {
|
|
|
424
460
|
await expect(publish(undescribed.taskPath)).rejects.toThrow(
|
|
425
461
|
`Change ${changeId} has no description. Run: jj describe -r ${changeId}`,
|
|
426
462
|
)
|
|
427
|
-
})
|
|
463
|
+
}, 15_000)
|
|
428
464
|
|
|
429
465
|
test("rejects an empty jj task and remote bookmark divergence", async () => {
|
|
430
466
|
if (!Bun.which("jj")) return
|
|
@@ -463,7 +499,7 @@ describe("PushService", () => {
|
|
|
463
499
|
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
464
500
|
"refusing to move it",
|
|
465
501
|
)
|
|
466
|
-
})
|
|
502
|
+
}, 15_000)
|
|
467
503
|
|
|
468
504
|
test("rejects conflicted jj changes with an actionable change ID", async () => {
|
|
469
505
|
if (!Bun.which("jj")) return
|
|
@@ -37,6 +37,12 @@ interface PushResult {
|
|
|
37
37
|
readonly changeId?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
type PushStage = "context" | "fetch" | "inspect" | "validate" | "publish"
|
|
41
|
+
|
|
42
|
+
interface PushOptions {
|
|
43
|
+
readonly onProgress?: (stage: PushStage) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
41
47
|
|
|
42
48
|
const requireCommand = (
|
|
@@ -153,8 +159,10 @@ const publishGit = (
|
|
|
153
159
|
remote: string,
|
|
154
160
|
branch: string,
|
|
155
161
|
base: string,
|
|
162
|
+
onProgress?: (stage: PushStage) => void,
|
|
156
163
|
) =>
|
|
157
164
|
Effect.gen(function* () {
|
|
165
|
+
onProgress?.("inspect")
|
|
158
166
|
const currentBranch = yield* git(
|
|
159
167
|
fs,
|
|
160
168
|
checkout,
|
|
@@ -179,6 +187,7 @@ const publishGit = (
|
|
|
179
187
|
})
|
|
180
188
|
}
|
|
181
189
|
|
|
190
|
+
onProgress?.("fetch")
|
|
182
191
|
yield* git(
|
|
183
192
|
fs,
|
|
184
193
|
checkout,
|
|
@@ -202,6 +211,7 @@ const publishGit = (
|
|
|
202
211
|
})
|
|
203
212
|
}
|
|
204
213
|
|
|
214
|
+
onProgress?.("validate")
|
|
205
215
|
const log = yield* git(
|
|
206
216
|
fs,
|
|
207
217
|
checkout,
|
|
@@ -228,6 +238,7 @@ const publishGit = (
|
|
|
228
238
|
})
|
|
229
239
|
}
|
|
230
240
|
|
|
241
|
+
onProgress?.("publish")
|
|
231
242
|
yield* git(
|
|
232
243
|
fs,
|
|
233
244
|
checkout,
|
|
@@ -361,14 +372,17 @@ const publishJj = (
|
|
|
361
372
|
remote: string,
|
|
362
373
|
branch: string,
|
|
363
374
|
base: string,
|
|
375
|
+
onProgress?: (stage: PushStage) => void,
|
|
364
376
|
) =>
|
|
365
377
|
Effect.gen(function* () {
|
|
378
|
+
onProgress?.("fetch")
|
|
366
379
|
yield* jj(
|
|
367
380
|
fs,
|
|
368
381
|
checkout,
|
|
369
382
|
["git", "fetch", "--remote", remote],
|
|
370
383
|
`Failed to fetch remote '${remote}'`,
|
|
371
384
|
)
|
|
385
|
+
onProgress?.("inspect")
|
|
372
386
|
const workingCopy = yield* jjRevision(fs, checkout, "@")
|
|
373
387
|
if (!workingCopy) {
|
|
374
388
|
return yield* new PushError({
|
|
@@ -403,10 +417,11 @@ const publishJj = (
|
|
|
403
417
|
!(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
|
|
404
418
|
) {
|
|
405
419
|
return yield* new PushError({
|
|
406
|
-
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
|
|
420
|
+
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId}). Rebase the stack with: jj rebase -s 'roots(${baseBookmark}..@)' -d ${baseBookmark}`,
|
|
407
421
|
})
|
|
408
422
|
}
|
|
409
423
|
|
|
424
|
+
onProgress?.("validate")
|
|
410
425
|
const outgoing = yield* jjCommits(
|
|
411
426
|
fs,
|
|
412
427
|
checkout,
|
|
@@ -444,6 +459,7 @@ const publishJj = (
|
|
|
444
459
|
})
|
|
445
460
|
}
|
|
446
461
|
|
|
462
|
+
onProgress?.("publish")
|
|
447
463
|
yield* jj(
|
|
448
464
|
fs,
|
|
449
465
|
checkout,
|
|
@@ -467,8 +483,9 @@ const publishJj = (
|
|
|
467
483
|
|
|
468
484
|
export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
469
485
|
sync: () => ({
|
|
470
|
-
publish: (startPath: string = process.cwd()) =>
|
|
486
|
+
publish: (startPath: string = process.cwd(), options: PushOptions = {}) =>
|
|
471
487
|
Effect.gen(function* () {
|
|
488
|
+
options.onProgress?.("context")
|
|
472
489
|
const contexts = yield* ContextService
|
|
473
490
|
const fs = yield* FileSystemService
|
|
474
491
|
const tasks = yield* TaskService
|
|
@@ -554,6 +571,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
554
571
|
remote,
|
|
555
572
|
execution.branch,
|
|
556
573
|
execution.base,
|
|
574
|
+
options.onProgress,
|
|
557
575
|
)
|
|
558
576
|
: yield* publishGit(
|
|
559
577
|
fs,
|
|
@@ -561,6 +579,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
561
579
|
remote,
|
|
562
580
|
execution.branch,
|
|
563
581
|
execution.base,
|
|
582
|
+
options.onProgress,
|
|
564
583
|
)
|
|
565
584
|
return {
|
|
566
585
|
vcs: context.workbase.vcs,
|
|
@@ -811,7 +811,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
811
811
|
}),
|
|
812
812
|
|
|
813
813
|
setup: (
|
|
814
|
-
options: {
|
|
814
|
+
options: {
|
|
815
|
+
readonly cwd?: string
|
|
816
|
+
readonly apply?: boolean
|
|
817
|
+
readonly aliases?: readonly string[]
|
|
818
|
+
} = {},
|
|
815
819
|
) =>
|
|
816
820
|
Effect.gen(function* () {
|
|
817
821
|
const service = yield* RepositoryService
|
|
@@ -819,7 +823,13 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
819
823
|
const versionControl = yield* VersionControlService
|
|
820
824
|
const state = yield* configState(options.cwd ?? process.cwd())
|
|
821
825
|
const backend = yield* versionControl.forWorkbase(state.root)
|
|
822
|
-
const
|
|
826
|
+
const requestedAliases = options.aliases
|
|
827
|
+
? new Set(options.aliases)
|
|
828
|
+
: null
|
|
829
|
+
const repositories = (yield* service.list(state.root)).filter(
|
|
830
|
+
(repository) =>
|
|
831
|
+
!requestedAliases || requestedAliases.has(repository.alias),
|
|
832
|
+
)
|
|
823
833
|
const planned: Omit<RepositorySetupAction, "status">[] = []
|
|
824
834
|
const unresolved: RepositorySetupIssue[] = []
|
|
825
835
|
|
|
@@ -933,7 +943,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
933
943
|
unresolved,
|
|
934
944
|
repositories:
|
|
935
945
|
options.apply === true
|
|
936
|
-
? yield* service.list(state.root)
|
|
946
|
+
? (yield* service.list(state.root)).filter(
|
|
947
|
+
(repository) =>
|
|
948
|
+
!requestedAliases ||
|
|
949
|
+
requestedAliases.has(repository.alias),
|
|
950
|
+
)
|
|
937
951
|
: repositories,
|
|
938
952
|
} satisfies RepositorySetupResult
|
|
939
953
|
}),
|
|
@@ -170,6 +170,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
170
170
|
readonly apply?: boolean
|
|
171
171
|
readonly now?: Date
|
|
172
172
|
readonly onProgress?: (progress: SyncProgress) => void
|
|
173
|
+
readonly taskId?: string
|
|
174
|
+
readonly phaseId?: string
|
|
173
175
|
} = {},
|
|
174
176
|
) =>
|
|
175
177
|
Effect.gen(function* () {
|
|
@@ -191,9 +193,66 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
191
193
|
.join("\n"),
|
|
192
194
|
})
|
|
193
195
|
}
|
|
196
|
+
if (options.phaseId && !options.taskId) {
|
|
197
|
+
return yield* new SyncError({
|
|
198
|
+
message: "A phase sync scope requires a task ID",
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
const allTaskRecords = yield* tasks.list(root)
|
|
202
|
+
const taskRecords = options.taskId
|
|
203
|
+
? allTaskRecords.filter((task) => task.id === options.taskId)
|
|
204
|
+
: allTaskRecords
|
|
205
|
+
if (options.taskId && taskRecords.length === 0) {
|
|
206
|
+
return yield* new SyncError({
|
|
207
|
+
message: `Task '${options.taskId}' does not exist`,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
const records: ExecutionRecord[] = []
|
|
211
|
+
for (const task of taskRecords) {
|
|
212
|
+
if ("phases" in task.data) {
|
|
213
|
+
for (const phase of yield* phases.list(task.id, root)) {
|
|
214
|
+
if (options.phaseId && phase.id !== options.phaseId) continue
|
|
215
|
+
records.push({
|
|
216
|
+
key: `phase:${task.id}/${phase.id}`,
|
|
217
|
+
taskId: task.id,
|
|
218
|
+
phaseId: phase.id,
|
|
219
|
+
path: phase.path,
|
|
220
|
+
revision: documentRevision(phase.content),
|
|
221
|
+
data: phase.data,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
} else if (!options.phaseId && !("review" in task.data)) {
|
|
225
|
+
records.push({
|
|
226
|
+
key: `task:${task.id}`,
|
|
227
|
+
taskId: task.id,
|
|
228
|
+
path: task.path,
|
|
229
|
+
revision: documentRevision(task.content),
|
|
230
|
+
data: task.data,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const reviewRecords = options.phaseId
|
|
235
|
+
? []
|
|
236
|
+
: taskRecords.filter((task) => "review" in task.data)
|
|
237
|
+
if (options.phaseId && records.length === 0) {
|
|
238
|
+
return yield* new SyncError({
|
|
239
|
+
message: `Phase '${options.taskId}/${options.phaseId}' does not exist`,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
const repositoryAliases = new Set<string>()
|
|
243
|
+
for (const record of records) {
|
|
244
|
+
repositoryAliases.add(record.data.repo)
|
|
245
|
+
for (const reference of record.data.repos ?? [])
|
|
246
|
+
repositoryAliases.add(reference.repo)
|
|
247
|
+
}
|
|
248
|
+
for (const task of reviewRecords) {
|
|
249
|
+
if ("review" in task.data)
|
|
250
|
+
repositoryAliases.add(task.data.review.repo)
|
|
251
|
+
}
|
|
194
252
|
const repositorySetup = yield* repositories.setup({
|
|
195
253
|
cwd: root,
|
|
196
254
|
apply: options.apply === true,
|
|
255
|
+
...(options.taskId ? { aliases: [...repositoryAliases] } : {}),
|
|
197
256
|
})
|
|
198
257
|
options.onProgress?.({
|
|
199
258
|
stage: "repositories",
|
|
@@ -241,30 +300,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
241
300
|
}),
|
|
242
301
|
),
|
|
243
302
|
)
|
|
244
|
-
const taskRecords = yield* tasks.list(root)
|
|
245
|
-
const records: ExecutionRecord[] = []
|
|
246
|
-
for (const task of taskRecords) {
|
|
247
|
-
if ("phases" in task.data) {
|
|
248
|
-
for (const phase of yield* phases.list(task.id, root)) {
|
|
249
|
-
records.push({
|
|
250
|
-
key: `phase:${task.id}/${phase.id}`,
|
|
251
|
-
taskId: task.id,
|
|
252
|
-
phaseId: phase.id,
|
|
253
|
-
path: phase.path,
|
|
254
|
-
revision: documentRevision(phase.content),
|
|
255
|
-
data: phase.data,
|
|
256
|
-
})
|
|
257
|
-
}
|
|
258
|
-
} else if (!("review" in task.data)) {
|
|
259
|
-
records.push({
|
|
260
|
-
key: `task:${task.id}`,
|
|
261
|
-
taskId: task.id,
|
|
262
|
-
path: task.path,
|
|
263
|
-
revision: documentRevision(task.content),
|
|
264
|
-
data: task.data,
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
303
|
const listRegistered = (repositoryPath: string) =>
|
|
269
304
|
Effect.gen(function* () {
|
|
270
305
|
if (registeredByRepository.has(repositoryPath))
|
|
@@ -409,9 +444,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
409
444
|
{ concurrency: 8 },
|
|
410
445
|
),
|
|
411
446
|
)
|
|
412
|
-
const reviewRecords = taskRecords.filter(
|
|
413
|
-
(task) => "review" in task.data,
|
|
414
|
-
)
|
|
415
447
|
const executionTotal = records.length + reviewRecords.length
|
|
416
448
|
let reconciledExecutions = 0
|
|
417
449
|
const reportExecution = (target: string) => {
|
|
@@ -474,7 +474,9 @@ const inspectExecution = (
|
|
|
474
474
|
: yield* backend.workspaceHead(checkoutPath)
|
|
475
475
|
: null
|
|
476
476
|
const dirty =
|
|
477
|
-
exists && atPath
|
|
477
|
+
exists && atPath
|
|
478
|
+
? yield* backend.workspaceDirty(checkoutPath)
|
|
479
|
+
: (atPath?.dirty ?? null)
|
|
478
480
|
const expectedCommit =
|
|
479
481
|
"branch" in checkout && context?.skipWritableRevisionResolution
|
|
480
482
|
? actualCommit
|
|
@@ -1659,10 +1661,26 @@ const removeJj = (
|
|
|
1659
1661
|
bookmark: string
|
|
1660
1662
|
}[] = []
|
|
1661
1663
|
for (const checkout of inspection.checkouts) {
|
|
1664
|
+
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1665
|
+
const registered = checkout.registeredPath
|
|
1666
|
+
? (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1667
|
+
(workspace) => workspace.path === checkout.registeredPath,
|
|
1668
|
+
)
|
|
1669
|
+
: undefined
|
|
1662
1670
|
if (checkout.dirty === true && options.persistResume === false) {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1671
|
+
const preservedByBookmark =
|
|
1672
|
+
checkout.kind === "writable" &&
|
|
1673
|
+
registered?.commit !== null &&
|
|
1674
|
+
registered?.commit !== undefined &&
|
|
1675
|
+
(yield* backend.resolveRevision(
|
|
1676
|
+
repositoryPath,
|
|
1677
|
+
checkout.requestedRef,
|
|
1678
|
+
)) === registered.commit
|
|
1679
|
+
if (!preservedByBookmark) {
|
|
1680
|
+
return yield* new WorktreeError({
|
|
1681
|
+
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1682
|
+
})
|
|
1683
|
+
}
|
|
1666
1684
|
}
|
|
1667
1685
|
if (
|
|
1668
1686
|
checkout.exists &&
|
|
@@ -1673,7 +1691,6 @@ const removeJj = (
|
|
|
1673
1691
|
message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
|
|
1674
1692
|
})
|
|
1675
1693
|
}
|
|
1676
|
-
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1677
1694
|
if (!checkout.registeredPath) {
|
|
1678
1695
|
const recoveryRevision = recoverable.get(checkout.path)
|
|
1679
1696
|
if (!recoveryRevision) continue
|
|
@@ -1696,9 +1713,6 @@ const removeJj = (
|
|
|
1696
1713
|
})
|
|
1697
1714
|
continue
|
|
1698
1715
|
}
|
|
1699
|
-
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1700
|
-
(workspace) => workspace.path === checkout.registeredPath,
|
|
1701
|
-
)
|
|
1702
1716
|
if (!registered?.name || !checkout.actualCommit || !registered.commit) {
|
|
1703
1717
|
return yield* new WorktreeError({
|
|
1704
1718
|
message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
|
|
@@ -67,6 +67,55 @@ describe("delivery commands", () => {
|
|
|
67
67
|
argv: ["gh", "pr", "create", "--fill", "--base", "main"],
|
|
68
68
|
environment: {},
|
|
69
69
|
})
|
|
70
|
+
expect(
|
|
71
|
+
resolveGitHubCreateCommand({
|
|
72
|
+
...input,
|
|
73
|
+
vcs: "git",
|
|
74
|
+
head: "feat/example",
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({
|
|
77
|
+
argv: [
|
|
78
|
+
"gh",
|
|
79
|
+
"pr",
|
|
80
|
+
"create",
|
|
81
|
+
"--fill",
|
|
82
|
+
"--base",
|
|
83
|
+
"main",
|
|
84
|
+
"--head",
|
|
85
|
+
"feat/example",
|
|
86
|
+
],
|
|
87
|
+
environment: {},
|
|
88
|
+
})
|
|
89
|
+
expect(
|
|
90
|
+
resolveGitHubCreateCommand({
|
|
91
|
+
...input,
|
|
92
|
+
vcs: "jj",
|
|
93
|
+
defaults: { title: "Generated", body: "Details" },
|
|
94
|
+
title: "Requested",
|
|
95
|
+
labels: ["ai-assisted", "platform"],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual({
|
|
98
|
+
argv: [
|
|
99
|
+
"gh",
|
|
100
|
+
"pr",
|
|
101
|
+
"create",
|
|
102
|
+
"--title",
|
|
103
|
+
"Requested",
|
|
104
|
+
"--body",
|
|
105
|
+
"Details",
|
|
106
|
+
"--base",
|
|
107
|
+
"main",
|
|
108
|
+
"--head",
|
|
109
|
+
"feat/example",
|
|
110
|
+
"--repo",
|
|
111
|
+
"example/agency",
|
|
112
|
+
"--label",
|
|
113
|
+
"ai-assisted",
|
|
114
|
+
"--label",
|
|
115
|
+
"platform",
|
|
116
|
+
],
|
|
117
|
+
environment: {},
|
|
118
|
+
})
|
|
70
119
|
})
|
|
71
120
|
|
|
72
121
|
test("rejects unknown placeholders", () => {
|
|
@@ -25,6 +25,9 @@ export const resolveGitHubCreateCommand = ({
|
|
|
25
25
|
draft,
|
|
26
26
|
vcs,
|
|
27
27
|
defaults,
|
|
28
|
+
title,
|
|
29
|
+
head,
|
|
30
|
+
labels = [],
|
|
28
31
|
}: {
|
|
29
32
|
readonly base: string
|
|
30
33
|
readonly branch: string
|
|
@@ -32,18 +35,25 @@ export const resolveGitHubCreateCommand = ({
|
|
|
32
35
|
readonly draft: boolean
|
|
33
36
|
readonly vcs: "git" | "jj"
|
|
34
37
|
readonly defaults?: { readonly title: string; readonly body: string }
|
|
38
|
+
readonly title?: string
|
|
39
|
+
readonly head?: string
|
|
40
|
+
readonly labels?: readonly string[]
|
|
35
41
|
}) => ({
|
|
36
42
|
argv: [
|
|
37
43
|
"gh",
|
|
38
44
|
"pr",
|
|
39
45
|
"create",
|
|
40
46
|
...(defaults
|
|
41
|
-
? ["--title", defaults.title, "--body", defaults.body]
|
|
42
|
-
:
|
|
47
|
+
? ["--title", title ?? defaults.title, "--body", defaults.body]
|
|
48
|
+
: title
|
|
49
|
+
? ["--fill", "--title", title]
|
|
50
|
+
: ["--fill"]),
|
|
43
51
|
"--base",
|
|
44
52
|
base,
|
|
45
|
-
...(vcs === "jj" ? ["--head",
|
|
53
|
+
...(vcs === "jj" || head ? ["--head", head ?? branch] : []),
|
|
54
|
+
...(vcs === "jj" ? ["--repo", repository] : []),
|
|
46
55
|
...(draft ? ["--draft"] : []),
|
|
56
|
+
...labels.flatMap((label) => ["--label", label]),
|
|
47
57
|
],
|
|
48
58
|
environment: {},
|
|
49
59
|
})
|