@markjaquith/agency 2.54.4 → 2.55.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 +18 -1
- package/cli-main.ts +20 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +9 -0
- package/src/cli-parser.ts +10 -0
- package/src/cli.test.ts +1 -0
- package/src/commands/push.ts +25 -0
- package/src/protocol.ts +1 -0
- package/src/services/IntegrationService.test.ts +2 -0
- package/src/services/PushService.test.ts +516 -0
- package/src/services/PushService.ts +576 -0
- package/src/test-utils.ts +2 -0
- package/src/workbase/AGENTS.md +5 -0
package/README.md
CHANGED
|
@@ -812,12 +812,13 @@ restore preflight all destinations and graph references before changing files.
|
|
|
812
812
|
Versioned lifecycle provenance preserves parent declarations and dependency edges
|
|
813
813
|
for restoration. Archived IDs are reserved until restored.
|
|
814
814
|
|
|
815
|
-
### Work and Pull Requests
|
|
815
|
+
### Work, Publication, and Pull Requests
|
|
816
816
|
|
|
817
817
|
```text
|
|
818
818
|
agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
|
|
819
819
|
agency work prepare [target] [--dry-run] [--json]
|
|
820
820
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
821
|
+
agency push [--json]
|
|
821
822
|
agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
822
823
|
agency pr [args...]
|
|
823
824
|
```
|
|
@@ -865,6 +866,22 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
|
|
|
865
866
|
reference worktrees are reused only while their commit still matches the declared
|
|
866
867
|
ref; use a commit SHA as `ref` when reproducibility matters.
|
|
867
868
|
|
|
869
|
+
`agency push` publishes the execution unit identified by current Agency context
|
|
870
|
+
without creating a pull request. It requires a valid, registered writable
|
|
871
|
+
checkout in `working` state, fetches the configured delivery remote, verifies
|
|
872
|
+
that the declared base is in the publication history, validates every outgoing
|
|
873
|
+
commit's description, author, and conflict state, and refuses non-fast-forward
|
|
874
|
+
updates.
|
|
875
|
+
|
|
876
|
+
For Git, YAML `branch` must exactly match the checked-out local branch, the
|
|
877
|
+
worktree must be clean, and `HEAD` is pushed with upstream tracking. For jj, YAML
|
|
878
|
+
`branch` is the authoritative delivery bookmark and need not exist before
|
|
879
|
+
publication. Agency publishes `@` unless it is the canonical empty, undescribed
|
|
880
|
+
post-commit working copy, in which case it publishes `@-`; described empty
|
|
881
|
+
changes remain intentional publication tips. Missing descriptions or authors
|
|
882
|
+
stop with exact change IDs and remediation commands. Agency creates or safely
|
|
883
|
+
advances only the declared bookmark and never invents a `push-*` bookmark.
|
|
884
|
+
|
|
868
885
|
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
869
886
|
including readiness checks and durable PR recording. Other `agency pr`
|
|
870
887
|
invocations forward every argument to `gh pr`. From an execution task or phase
|
package/cli-main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parseCli } from "./src/cli-parser"
|
|
|
6
6
|
import { init, help as initHelp } from "./src/commands/init"
|
|
7
7
|
import { task, help as taskHelp } from "./src/commands/task"
|
|
8
8
|
import { pr, prCreate, help as prHelp } from "./src/commands/pr"
|
|
9
|
+
import { push, help as pushHelp } from "./src/commands/push"
|
|
9
10
|
import { work, workPrepare, help as workHelp } from "./src/commands/work"
|
|
10
11
|
import { worktree, help as worktreeHelp } from "./src/commands/worktree"
|
|
11
12
|
import { status, help as statusHelp } from "./src/commands/status"
|
|
@@ -34,6 +35,7 @@ import { TaskService } from "./src/services/TaskService"
|
|
|
34
35
|
import { PhaseService } from "./src/services/PhaseService"
|
|
35
36
|
import { WorktreeService } from "./src/services/WorktreeService"
|
|
36
37
|
import { PullRequestService } from "./src/services/PullRequestService"
|
|
38
|
+
import { PushService } from "./src/services/PushService"
|
|
37
39
|
import { ArchiveService } from "./src/services/ArchiveService"
|
|
38
40
|
import { IntegrationService } from "./src/services/IntegrationService"
|
|
39
41
|
import { ContextService } from "./src/services/ContextService"
|
|
@@ -79,6 +81,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
79
81
|
PhaseService.Default,
|
|
80
82
|
WorktreeService.Default,
|
|
81
83
|
PullRequestService.Default,
|
|
84
|
+
PushService.Default,
|
|
82
85
|
ArchiveService.Default,
|
|
83
86
|
IntegrationService.Default,
|
|
84
87
|
ContextService.Default,
|
|
@@ -305,6 +308,22 @@ const commands: Record<string, Command> = {
|
|
|
305
308
|
)
|
|
306
309
|
},
|
|
307
310
|
},
|
|
311
|
+
push: {
|
|
312
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
313
|
+
if (options.help) {
|
|
314
|
+
console.log(pushHelp)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
await runCommand(
|
|
318
|
+
push({
|
|
319
|
+
json: options.json,
|
|
320
|
+
silent: options.silent,
|
|
321
|
+
verbose: options.verbose,
|
|
322
|
+
cwd: options.cwd,
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
},
|
|
326
|
+
},
|
|
308
327
|
phase: {
|
|
309
328
|
run: async (args: string[], options: Record<string, any>) => {
|
|
310
329
|
if (options.help) return console.log(phaseHelp)
|
|
@@ -742,6 +761,7 @@ Commands:
|
|
|
742
761
|
vcs <subcommand> Inspect or migrate the version-control backend
|
|
743
762
|
next List or select ready execution units
|
|
744
763
|
pr create / pr [...] Create an Agency PR or run gh pr with repository focus
|
|
764
|
+
push Validate and publish the current execution unit
|
|
745
765
|
review refresh Explicitly refresh a pinned review task
|
|
746
766
|
repo <subcommand> Manage workbase repositories
|
|
747
767
|
status Show status for the current workbase
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -616,6 +616,15 @@ describe("strict CLI parsing", () => {
|
|
|
616
616
|
).toThrow("cannot be combined")
|
|
617
617
|
})
|
|
618
618
|
|
|
619
|
+
test("parses push without accepting an alternate target", () => {
|
|
620
|
+
expect(parseCli(["push", "--json"])).toMatchObject({
|
|
621
|
+
commandName: "push",
|
|
622
|
+
args: [],
|
|
623
|
+
values: { json: true },
|
|
624
|
+
})
|
|
625
|
+
expectUsageError(["push", "example"], "agency push")
|
|
626
|
+
})
|
|
627
|
+
|
|
619
628
|
test("accepts runner selection and command inspection for work", () => {
|
|
620
629
|
expect(
|
|
621
630
|
parseCli([
|
package/src/cli-parser.ts
CHANGED
|
@@ -913,6 +913,16 @@ const commands = {
|
|
|
913
913
|
},
|
|
914
914
|
},
|
|
915
915
|
},
|
|
916
|
+
push: {
|
|
917
|
+
usage: "agency push [--json]",
|
|
918
|
+
options: outputOptions,
|
|
919
|
+
command: {
|
|
920
|
+
usage: "agency push [--json]",
|
|
921
|
+
minArgs: 0,
|
|
922
|
+
maxArgs: 0,
|
|
923
|
+
options: ["json"],
|
|
924
|
+
},
|
|
925
|
+
},
|
|
916
926
|
next: {
|
|
917
927
|
usage: "agency next [--select] [--json]",
|
|
918
928
|
options: {
|
package/src/cli.test.ts
CHANGED
|
@@ -502,6 +502,7 @@ describe("CLI", () => {
|
|
|
502
502
|
["archive", "Usage: agency archive"],
|
|
503
503
|
["restore", "Usage: agency restore"],
|
|
504
504
|
["work", "Usage: agency work"],
|
|
505
|
+
["push", "Usage: agency push"],
|
|
505
506
|
["pr", "Work with GitHub pull requests."],
|
|
506
507
|
["status", "Usage: agency status"],
|
|
507
508
|
["validate", "Usage: agency validate"],
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { PushService } from "../services/PushService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
export const push = (options: BaseCommandOptions = {}) =>
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const publications = yield* PushService
|
|
9
|
+
const { log } = createLoggers(options)
|
|
10
|
+
const result = yield* publications.publish(options.cwd ?? process.cwd())
|
|
11
|
+
log(
|
|
12
|
+
options.json
|
|
13
|
+
? JSON.stringify(result, null, 2)
|
|
14
|
+
: `Published ${result.vcs} ${result.branch} at ${result.tip} to ${result.remote}`,
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const help = `
|
|
19
|
+
Usage: agency push [--json]
|
|
20
|
+
|
|
21
|
+
Validate and publish the current Agency execution unit without creating a pull
|
|
22
|
+
request. The command uses the durable base and branch declarations, requires the
|
|
23
|
+
managed writable checkout and working status, refreshes remote state, validates
|
|
24
|
+
every outgoing commit, and rejects non-fast-forward publication.
|
|
25
|
+
`
|
package/src/protocol.ts
CHANGED
|
@@ -99,6 +99,7 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
99
99
|
},
|
|
100
100
|
ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
|
|
101
101
|
WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
|
|
102
|
+
PushError: { code: "PUSH_ERROR", retryable: false },
|
|
102
103
|
PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },
|
|
103
104
|
ReviewError: { code: "REVIEW_ERROR", retryable: false },
|
|
104
105
|
ContextError: {
|
|
@@ -422,6 +422,8 @@ describe("IntegrationService", () => {
|
|
|
422
422
|
expect(body).toContain("without creating a claim")
|
|
423
423
|
expect(body).toContain("formatting, type checks, build, dead-code checks")
|
|
424
424
|
expect(body).toContain("Review and commit the diff")
|
|
425
|
+
expect(body).toContain("Use `agency push`")
|
|
426
|
+
expect(body).toContain("never authors semantic commit descriptions")
|
|
425
427
|
expect(body).toContain("Run `agency validate`")
|
|
426
428
|
expect(body).toContain("only with explicit user intent")
|
|
427
429
|
expect(body).toContain("An execution unit remains `working`")
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { TaskService } from "./TaskService"
|
|
7
|
+
import { PushService } from "./PushService"
|
|
8
|
+
import { WorktreeService } from "./WorktreeService"
|
|
9
|
+
|
|
10
|
+
interface CommandResult {
|
|
11
|
+
readonly stdout: string
|
|
12
|
+
readonly stderr: string
|
|
13
|
+
readonly exitCode: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const runCommand = async (
|
|
17
|
+
args: readonly string[],
|
|
18
|
+
cwd?: string,
|
|
19
|
+
): Promise<CommandResult> => {
|
|
20
|
+
const child = Bun.spawn([...args], {
|
|
21
|
+
cwd,
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
})
|
|
25
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
26
|
+
new Response(child.stdout).text(),
|
|
27
|
+
new Response(child.stderr).text(),
|
|
28
|
+
child.exited,
|
|
29
|
+
])
|
|
30
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const requireCommand = async (args: readonly string[], cwd?: string) => {
|
|
34
|
+
const result = await runCommand(args, cwd)
|
|
35
|
+
if (result.exitCode !== 0) {
|
|
36
|
+
throw new Error(`${args.join(" ")} failed: ${result.stderr}`)
|
|
37
|
+
}
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("PushService", () => {
|
|
42
|
+
const roots: string[] = []
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const setup = async (vcs: "git" | "jj") => {
|
|
49
|
+
const root = await createTempDir()
|
|
50
|
+
roots.push(root)
|
|
51
|
+
const remote = join(root, "remote.git")
|
|
52
|
+
const seed = join(root, "seed")
|
|
53
|
+
const repository = join(root, "repos", "agency")
|
|
54
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
55
|
+
await Bun.write(
|
|
56
|
+
join(root, "agency.json"),
|
|
57
|
+
`${JSON.stringify({ version: 2, vcs }, null, 2)}\n`,
|
|
58
|
+
)
|
|
59
|
+
await requireCommand([
|
|
60
|
+
"git",
|
|
61
|
+
"init",
|
|
62
|
+
"--bare",
|
|
63
|
+
"--initial-branch=main",
|
|
64
|
+
remote,
|
|
65
|
+
])
|
|
66
|
+
await requireCommand(["git", "init", "--initial-branch=main", seed])
|
|
67
|
+
await requireCommand(["git", "config", "user.name", "Agency Test"], seed)
|
|
68
|
+
await requireCommand(
|
|
69
|
+
["git", "config", "user.email", "agency@example.com"],
|
|
70
|
+
seed,
|
|
71
|
+
)
|
|
72
|
+
await Bun.write(join(seed, "README.md"), "# Test repository\n")
|
|
73
|
+
await requireCommand(["git", "add", "README.md"], seed)
|
|
74
|
+
await requireCommand(["git", "commit", "-m", "Initial commit"], seed)
|
|
75
|
+
await requireCommand(["git", "remote", "add", "origin", remote], seed)
|
|
76
|
+
await requireCommand(["git", "push", "-u", "origin", "main"], seed)
|
|
77
|
+
await requireCommand(["git", "clone", "--bare", remote, repository])
|
|
78
|
+
if (vcs === "jj") {
|
|
79
|
+
await requireCommand(["jj", "git", "init", "--colocate", repository])
|
|
80
|
+
await requireCommand(
|
|
81
|
+
["jj", "git", "remote", "add", "origin", remote],
|
|
82
|
+
repository,
|
|
83
|
+
)
|
|
84
|
+
await requireCommand(
|
|
85
|
+
["jj", "git", "fetch", "--remote", "origin"],
|
|
86
|
+
repository,
|
|
87
|
+
)
|
|
88
|
+
await requireCommand(
|
|
89
|
+
["jj", "bookmark", "create", "main", "-r", "main@origin"],
|
|
90
|
+
repository,
|
|
91
|
+
)
|
|
92
|
+
await requireCommand(
|
|
93
|
+
["jj", "config", "set", "--repo", "user.name", "Agency Test"],
|
|
94
|
+
repository,
|
|
95
|
+
)
|
|
96
|
+
await requireCommand(
|
|
97
|
+
["jj", "config", "set", "--repo", "user.email", "agency@example.com"],
|
|
98
|
+
repository,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await runTestEffect(
|
|
103
|
+
TaskService.pipe(
|
|
104
|
+
Effect.flatMap((service) =>
|
|
105
|
+
service.create(
|
|
106
|
+
{
|
|
107
|
+
id: "example",
|
|
108
|
+
ticketUrl: null,
|
|
109
|
+
repo: "agency",
|
|
110
|
+
branch: "task/example",
|
|
111
|
+
base: "main",
|
|
112
|
+
},
|
|
113
|
+
root,
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
await runTestEffect(
|
|
119
|
+
TaskService.pipe(
|
|
120
|
+
Effect.flatMap((service) =>
|
|
121
|
+
service.setStatus("example", "working", root),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
const workspace = await runTestEffect(
|
|
126
|
+
WorktreeService.pipe(
|
|
127
|
+
Effect.flatMap((service) =>
|
|
128
|
+
service.materialize("example", undefined, root),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
return {
|
|
133
|
+
root,
|
|
134
|
+
remote,
|
|
135
|
+
checkout: workspace.writablePath!,
|
|
136
|
+
taskPath: join(root, "tasks", "example"),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const publish = (taskPath: string) =>
|
|
141
|
+
runTestEffect(
|
|
142
|
+
PushService.pipe(Effect.flatMap((service) => service.publish(taskPath))),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const remoteBranch = async (remote: string, branch = "task/example") =>
|
|
146
|
+
(
|
|
147
|
+
await requireCommand([
|
|
148
|
+
"git",
|
|
149
|
+
"--git-dir",
|
|
150
|
+
remote,
|
|
151
|
+
"rev-parse",
|
|
152
|
+
`refs/heads/${branch}`,
|
|
153
|
+
])
|
|
154
|
+
).stdout
|
|
155
|
+
|
|
156
|
+
test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
|
|
157
|
+
const fixture = await setup("git")
|
|
158
|
+
await requireCommand(
|
|
159
|
+
["git", "config", "user.name", "Agency Test"],
|
|
160
|
+
fixture.checkout,
|
|
161
|
+
)
|
|
162
|
+
await requireCommand(
|
|
163
|
+
["git", "config", "user.email", "agency@example.com"],
|
|
164
|
+
fixture.checkout,
|
|
165
|
+
)
|
|
166
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
|
|
167
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
168
|
+
await requireCommand(
|
|
169
|
+
["git", "commit", "-m", "Add published feature"],
|
|
170
|
+
fixture.checkout,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const result = await publish(fixture.taskPath)
|
|
174
|
+
expect(result).toMatchObject({
|
|
175
|
+
vcs: "git",
|
|
176
|
+
branch: "task/example",
|
|
177
|
+
base: "main",
|
|
178
|
+
remote: "origin",
|
|
179
|
+
})
|
|
180
|
+
expect(await remoteBranch(fixture.remote)).toBe(result.tip)
|
|
181
|
+
expect(
|
|
182
|
+
(
|
|
183
|
+
await requireCommand(
|
|
184
|
+
["git", "rev-parse", "--abbrev-ref", "@{upstream}"],
|
|
185
|
+
fixture.checkout,
|
|
186
|
+
)
|
|
187
|
+
).stdout,
|
|
188
|
+
).toBe("origin/task/example")
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("rejects dirty, mismatched, and undescribed Git publication", async () => {
|
|
192
|
+
const dirty = await setup("git")
|
|
193
|
+
await Bun.write(join(dirty.checkout, "dirty.txt"), "dirty\n")
|
|
194
|
+
await expect(publish(dirty.taskPath)).rejects.toThrow("dirty Git worktree")
|
|
195
|
+
|
|
196
|
+
const mismatched = await setup("git")
|
|
197
|
+
await requireCommand(
|
|
198
|
+
["git", "branch", "-m", "wrong-branch"],
|
|
199
|
+
mismatched.checkout,
|
|
200
|
+
)
|
|
201
|
+
await expect(publish(mismatched.taskPath)).rejects.toThrow(
|
|
202
|
+
"does not match checked-out Git branch",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const undescribed = await setup("git")
|
|
206
|
+
await requireCommand(
|
|
207
|
+
["git", "config", "user.name", "Agency Test"],
|
|
208
|
+
undescribed.checkout,
|
|
209
|
+
)
|
|
210
|
+
await requireCommand(
|
|
211
|
+
["git", "config", "user.email", "agency@example.com"],
|
|
212
|
+
undescribed.checkout,
|
|
213
|
+
)
|
|
214
|
+
await requireCommand(
|
|
215
|
+
["git", "commit", "--allow-empty", "--allow-empty-message", "-m", ""],
|
|
216
|
+
undescribed.checkout,
|
|
217
|
+
)
|
|
218
|
+
await expect(publish(undescribed.taskPath)).rejects.toThrow(
|
|
219
|
+
"has an empty message",
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test("rejects Git remote divergence after refreshing remote state", async () => {
|
|
224
|
+
const fixture = await setup("git")
|
|
225
|
+
for (const [key, value] of [
|
|
226
|
+
["user.name", "Agency Test"],
|
|
227
|
+
["user.email", "agency@example.com"],
|
|
228
|
+
] as const) {
|
|
229
|
+
await requireCommand(["git", "config", key, value], fixture.checkout)
|
|
230
|
+
}
|
|
231
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "first\n")
|
|
232
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
233
|
+
await requireCommand(
|
|
234
|
+
["git", "commit", "-m", "First change"],
|
|
235
|
+
fixture.checkout,
|
|
236
|
+
)
|
|
237
|
+
await publish(fixture.taskPath)
|
|
238
|
+
|
|
239
|
+
const other = join(fixture.root, "other")
|
|
240
|
+
await requireCommand(["git", "clone", fixture.remote, other])
|
|
241
|
+
await requireCommand(["git", "checkout", "task/example"], other)
|
|
242
|
+
await requireCommand(["git", "config", "user.name", "Other"], other)
|
|
243
|
+
await requireCommand(
|
|
244
|
+
["git", "config", "user.email", "other@example.com"],
|
|
245
|
+
other,
|
|
246
|
+
)
|
|
247
|
+
await Bun.write(join(other, "remote.txt"), "remote\n")
|
|
248
|
+
await requireCommand(["git", "add", "remote.txt"], other)
|
|
249
|
+
await requireCommand(["git", "commit", "-m", "Remote change"], other)
|
|
250
|
+
await requireCommand(["git", "push", "origin", "task/example"], other)
|
|
251
|
+
|
|
252
|
+
await Bun.write(join(fixture.checkout, "local.txt"), "local\n")
|
|
253
|
+
await requireCommand(["git", "add", "local.txt"], fixture.checkout)
|
|
254
|
+
await requireCommand(
|
|
255
|
+
["git", "commit", "-m", "Local change"],
|
|
256
|
+
fixture.checkout,
|
|
257
|
+
)
|
|
258
|
+
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
259
|
+
"refusing a non-fast-forward update",
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test("requires working status and the declared Git base in history", async () => {
|
|
264
|
+
const open = await setup("git")
|
|
265
|
+
await runTestEffect(
|
|
266
|
+
TaskService.pipe(
|
|
267
|
+
Effect.flatMap((service) =>
|
|
268
|
+
service.setStatus("example", "open", open.root),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
await expect(publish(open.taskPath)).rejects.toThrow(
|
|
273
|
+
"status 'open'; status must be working",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const unrelated = await setup("git")
|
|
277
|
+
await requireCommand(
|
|
278
|
+
["git", "config", "user.name", "Agency Test"],
|
|
279
|
+
unrelated.checkout,
|
|
280
|
+
)
|
|
281
|
+
await requireCommand(
|
|
282
|
+
["git", "config", "user.email", "agency@example.com"],
|
|
283
|
+
unrelated.checkout,
|
|
284
|
+
)
|
|
285
|
+
await requireCommand(
|
|
286
|
+
["git", "checkout", "--orphan", "unrelated"],
|
|
287
|
+
unrelated.checkout,
|
|
288
|
+
)
|
|
289
|
+
await requireCommand(["git", "rm", "-rf", "."], unrelated.checkout)
|
|
290
|
+
await Bun.write(join(unrelated.checkout, "unrelated.txt"), "unrelated\n")
|
|
291
|
+
await requireCommand(["git", "add", "unrelated.txt"], unrelated.checkout)
|
|
292
|
+
await requireCommand(
|
|
293
|
+
["git", "commit", "-m", "Unrelated history"],
|
|
294
|
+
unrelated.checkout,
|
|
295
|
+
)
|
|
296
|
+
await requireCommand(
|
|
297
|
+
["git", "branch", "-M", "task/example"],
|
|
298
|
+
unrelated.checkout,
|
|
299
|
+
)
|
|
300
|
+
await expect(publish(unrelated.taskPath)).rejects.toThrow(
|
|
301
|
+
"is not an ancestor of Git HEAD",
|
|
302
|
+
)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test("rejects invalid Git and jj authors", async () => {
|
|
306
|
+
const git = await setup("git")
|
|
307
|
+
await requireCommand(
|
|
308
|
+
["git", "config", "user.name", "Agency Test"],
|
|
309
|
+
git.checkout,
|
|
310
|
+
)
|
|
311
|
+
await requireCommand(
|
|
312
|
+
["git", "config", "user.email", "agency@example.com"],
|
|
313
|
+
git.checkout,
|
|
314
|
+
)
|
|
315
|
+
await requireCommand(
|
|
316
|
+
[
|
|
317
|
+
"git",
|
|
318
|
+
"commit",
|
|
319
|
+
"--allow-empty",
|
|
320
|
+
"--author",
|
|
321
|
+
"Bad <bad>",
|
|
322
|
+
"-m",
|
|
323
|
+
"Invalid author",
|
|
324
|
+
],
|
|
325
|
+
git.checkout,
|
|
326
|
+
)
|
|
327
|
+
await expect(publish(git.taskPath)).rejects.toThrow("has an invalid author")
|
|
328
|
+
|
|
329
|
+
if (!Bun.which("jj")) return
|
|
330
|
+
const jj = await setup("jj")
|
|
331
|
+
await requireCommand(
|
|
332
|
+
["jj", "describe", "-m", "Invalid author"],
|
|
333
|
+
jj.checkout,
|
|
334
|
+
)
|
|
335
|
+
await requireCommand(
|
|
336
|
+
["jj", "metaedit", "--author", "Bad <bad>"],
|
|
337
|
+
jj.checkout,
|
|
338
|
+
)
|
|
339
|
+
const changeId = (
|
|
340
|
+
await requireCommand(
|
|
341
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "change_id"],
|
|
342
|
+
jj.checkout,
|
|
343
|
+
)
|
|
344
|
+
).stdout
|
|
345
|
+
await expect(publish(jj.taskPath)).rejects.toThrow(
|
|
346
|
+
`Change ${changeId} has an invalid author. Run: jj metaedit -r ${changeId} --author 'Name <email>'`,
|
|
347
|
+
)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test("publishes a described jj working-copy change under the declared bookmark", async () => {
|
|
351
|
+
if (!Bun.which("jj")) return
|
|
352
|
+
const fixture = await setup("jj")
|
|
353
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
|
|
354
|
+
await requireCommand(
|
|
355
|
+
["jj", "describe", "-m", "Add published feature"],
|
|
356
|
+
fixture.checkout,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const result = await publish(fixture.taskPath)
|
|
360
|
+
expect(result).toMatchObject({
|
|
361
|
+
vcs: "jj",
|
|
362
|
+
branch: "task/example",
|
|
363
|
+
base: "main",
|
|
364
|
+
remote: "origin",
|
|
365
|
+
})
|
|
366
|
+
expect(await remoteBranch(fixture.remote)).toBe(result.tip)
|
|
367
|
+
expect(
|
|
368
|
+
(
|
|
369
|
+
await requireCommand(
|
|
370
|
+
[
|
|
371
|
+
"jj",
|
|
372
|
+
"bookmark",
|
|
373
|
+
"list",
|
|
374
|
+
"--all-remotes",
|
|
375
|
+
"-T",
|
|
376
|
+
'if(name == "task/example" && remote == "origin", tracked, "")',
|
|
377
|
+
],
|
|
378
|
+
fixture.checkout,
|
|
379
|
+
)
|
|
380
|
+
).stdout,
|
|
381
|
+
).toBe("true")
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test("selects the described parent of a canonical jj post-commit working copy", async () => {
|
|
385
|
+
if (!Bun.which("jj")) return
|
|
386
|
+
const fixture = await setup("jj")
|
|
387
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
|
|
388
|
+
await requireCommand(
|
|
389
|
+
["jj", "commit", "-m", "Add published feature"],
|
|
390
|
+
fixture.checkout,
|
|
391
|
+
)
|
|
392
|
+
const parent = (
|
|
393
|
+
await requireCommand(
|
|
394
|
+
["jj", "log", "--no-graph", "-r", "@-", "-T", "commit_id"],
|
|
395
|
+
fixture.checkout,
|
|
396
|
+
)
|
|
397
|
+
).stdout
|
|
398
|
+
|
|
399
|
+
const result = await publish(fixture.taskPath)
|
|
400
|
+
expect(result.tip).toBe(parent)
|
|
401
|
+
expect(await remoteBranch(fixture.remote)).toBe(parent)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test("preserves a described empty jj change and diagnoses missing semantics", async () => {
|
|
405
|
+
if (!Bun.which("jj")) return
|
|
406
|
+
const described = await setup("jj")
|
|
407
|
+
await requireCommand(
|
|
408
|
+
["jj", "describe", "-m", "Record intentional empty change"],
|
|
409
|
+
described.checkout,
|
|
410
|
+
)
|
|
411
|
+
const describedTip = (
|
|
412
|
+
await requireCommand(
|
|
413
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
|
414
|
+
described.checkout,
|
|
415
|
+
)
|
|
416
|
+
).stdout
|
|
417
|
+
expect((await publish(described.taskPath)).tip).toBe(describedTip)
|
|
418
|
+
|
|
419
|
+
const undescribed = await setup("jj")
|
|
420
|
+
await Bun.write(join(undescribed.checkout, "feature.txt"), "missing\n")
|
|
421
|
+
const changeId = (
|
|
422
|
+
await requireCommand(
|
|
423
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "change_id"],
|
|
424
|
+
undescribed.checkout,
|
|
425
|
+
)
|
|
426
|
+
).stdout
|
|
427
|
+
await expect(publish(undescribed.taskPath)).rejects.toThrow(
|
|
428
|
+
`Change ${changeId} has no description. Run: jj describe -r ${changeId}`,
|
|
429
|
+
)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test("rejects an empty jj task and remote bookmark divergence", async () => {
|
|
433
|
+
if (!Bun.which("jj")) return
|
|
434
|
+
const empty = await setup("jj")
|
|
435
|
+
await expect(publish(empty.taskPath)).rejects.toThrow(
|
|
436
|
+
"No changes to publish after base 'main'",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
const fixture = await setup("jj")
|
|
440
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "first\n")
|
|
441
|
+
await requireCommand(
|
|
442
|
+
["jj", "describe", "-m", "First change"],
|
|
443
|
+
fixture.checkout,
|
|
444
|
+
)
|
|
445
|
+
await publish(fixture.taskPath)
|
|
446
|
+
|
|
447
|
+
const other = join(fixture.root, "other")
|
|
448
|
+
await requireCommand(["git", "clone", fixture.remote, other])
|
|
449
|
+
await requireCommand(["git", "checkout", "task/example"], other)
|
|
450
|
+
await requireCommand(["git", "config", "user.name", "Other"], other)
|
|
451
|
+
await requireCommand(
|
|
452
|
+
["git", "config", "user.email", "other@example.com"],
|
|
453
|
+
other,
|
|
454
|
+
)
|
|
455
|
+
await Bun.write(join(other, "remote.txt"), "remote\n")
|
|
456
|
+
await requireCommand(["git", "add", "remote.txt"], other)
|
|
457
|
+
await requireCommand(["git", "commit", "-m", "Remote change"], other)
|
|
458
|
+
await requireCommand(["git", "push", "origin", "task/example"], other)
|
|
459
|
+
|
|
460
|
+
await requireCommand(["jj", "new", "@"], fixture.checkout)
|
|
461
|
+
await Bun.write(join(fixture.checkout, "local.txt"), "local\n")
|
|
462
|
+
await requireCommand(
|
|
463
|
+
["jj", "describe", "-m", "Local change"],
|
|
464
|
+
fixture.checkout,
|
|
465
|
+
)
|
|
466
|
+
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
467
|
+
"refusing to move it",
|
|
468
|
+
)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test("rejects conflicted jj changes with an actionable change ID", async () => {
|
|
472
|
+
if (!Bun.which("jj")) return
|
|
473
|
+
const fixture = await setup("jj")
|
|
474
|
+
await Bun.write(join(fixture.checkout, "README.md"), "left\n")
|
|
475
|
+
await requireCommand(
|
|
476
|
+
["jj", "describe", "-m", "Left change"],
|
|
477
|
+
fixture.checkout,
|
|
478
|
+
)
|
|
479
|
+
await requireCommand(
|
|
480
|
+
["jj", "bookmark", "create", "left", "-r", "@"],
|
|
481
|
+
fixture.checkout,
|
|
482
|
+
)
|
|
483
|
+
await requireCommand(["jj", "new", "main@origin"], fixture.checkout)
|
|
484
|
+
await Bun.write(join(fixture.checkout, "README.md"), "right\n")
|
|
485
|
+
await requireCommand(
|
|
486
|
+
["jj", "describe", "-m", "Right change"],
|
|
487
|
+
fixture.checkout,
|
|
488
|
+
)
|
|
489
|
+
await requireCommand(
|
|
490
|
+
["jj", "bookmark", "create", "right", "-r", "@"],
|
|
491
|
+
fixture.checkout,
|
|
492
|
+
)
|
|
493
|
+
await requireCommand(["jj", "new", "left", "right"], fixture.checkout)
|
|
494
|
+
await requireCommand(
|
|
495
|
+
["jj", "describe", "-m", "Conflicted merge"],
|
|
496
|
+
fixture.checkout,
|
|
497
|
+
)
|
|
498
|
+
const changeId = (
|
|
499
|
+
await requireCommand(
|
|
500
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "change_id"],
|
|
501
|
+
fixture.checkout,
|
|
502
|
+
)
|
|
503
|
+
).stdout
|
|
504
|
+
expect(
|
|
505
|
+
(
|
|
506
|
+
await requireCommand(
|
|
507
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "conflict"],
|
|
508
|
+
fixture.checkout,
|
|
509
|
+
)
|
|
510
|
+
).stdout,
|
|
511
|
+
).toBe("true")
|
|
512
|
+
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
513
|
+
`Change ${changeId} contains conflicts. Run: jj resolve -r ${changeId}`,
|
|
514
|
+
)
|
|
515
|
+
})
|
|
516
|
+
})
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { ContextService } from "./ContextService"
|
|
3
|
+
import { FileSystemService } from "./FileSystemService"
|
|
4
|
+
import { PhaseService } from "./PhaseService"
|
|
5
|
+
import { TaskService } from "./TaskService"
|
|
6
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
7
|
+
|
|
8
|
+
class PushError extends Data.TaggedError("PushError")<{
|
|
9
|
+
readonly message: string
|
|
10
|
+
}> {}
|
|
11
|
+
|
|
12
|
+
interface CommandResult {
|
|
13
|
+
readonly exitCode: number
|
|
14
|
+
readonly stdout: string
|
|
15
|
+
readonly stderr: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CommitMetadata {
|
|
19
|
+
readonly commitId: string
|
|
20
|
+
readonly changeId?: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly empty: boolean
|
|
23
|
+
readonly authorName: string
|
|
24
|
+
readonly authorEmail: string
|
|
25
|
+
readonly conflict: boolean
|
|
26
|
+
readonly parents: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PushResult {
|
|
30
|
+
readonly vcs: "git" | "jj"
|
|
31
|
+
readonly taskId: string
|
|
32
|
+
readonly phaseId?: string
|
|
33
|
+
readonly branch: string
|
|
34
|
+
readonly base: string
|
|
35
|
+
readonly remote: string
|
|
36
|
+
readonly tip: string
|
|
37
|
+
readonly changeId?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
41
|
+
|
|
42
|
+
const requireCommand = (
|
|
43
|
+
fs: FileSystemService,
|
|
44
|
+
args: readonly string[],
|
|
45
|
+
cwd: string,
|
|
46
|
+
label: string,
|
|
47
|
+
) =>
|
|
48
|
+
fs.runCommand(args, { cwd, captureOutput: true }).pipe(
|
|
49
|
+
Effect.flatMap((result) =>
|
|
50
|
+
result.exitCode === 0
|
|
51
|
+
? Effect.succeed(result)
|
|
52
|
+
: Effect.fail(
|
|
53
|
+
new PushError({
|
|
54
|
+
message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const git = (
|
|
61
|
+
fs: FileSystemService,
|
|
62
|
+
cwd: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
label: string,
|
|
65
|
+
) => requireCommand(fs, ["git", ...args], cwd, label)
|
|
66
|
+
|
|
67
|
+
const gitRevision = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
68
|
+
fs
|
|
69
|
+
.runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
|
|
70
|
+
cwd,
|
|
71
|
+
captureOutput: true,
|
|
72
|
+
})
|
|
73
|
+
.pipe(
|
|
74
|
+
Effect.map((result) =>
|
|
75
|
+
result.exitCode === 0 ? result.stdout.trim() || null : null,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const gitAncestor = (
|
|
80
|
+
fs: FileSystemService,
|
|
81
|
+
cwd: string,
|
|
82
|
+
ancestor: string,
|
|
83
|
+
descendant: string,
|
|
84
|
+
) =>
|
|
85
|
+
fs
|
|
86
|
+
.runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
87
|
+
cwd,
|
|
88
|
+
captureOutput: true,
|
|
89
|
+
})
|
|
90
|
+
.pipe(
|
|
91
|
+
Effect.flatMap((result) => {
|
|
92
|
+
if (result.exitCode === 0) return Effect.succeed(true)
|
|
93
|
+
if (result.exitCode === 1) return Effect.succeed(false)
|
|
94
|
+
return Effect.fail(
|
|
95
|
+
new PushError({
|
|
96
|
+
message: `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const parseGitCommits = (output: string): readonly CommitMetadata[] =>
|
|
103
|
+
output
|
|
104
|
+
.split("\x1e")
|
|
105
|
+
.map((record) => record.replace(/^\n+|\n+$/g, ""))
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((record) => {
|
|
108
|
+
const [
|
|
109
|
+
commitId = "",
|
|
110
|
+
authorName = "",
|
|
111
|
+
authorEmail = "",
|
|
112
|
+
description = "",
|
|
113
|
+
] = record.split("\0")
|
|
114
|
+
return {
|
|
115
|
+
commitId,
|
|
116
|
+
description,
|
|
117
|
+
empty: false,
|
|
118
|
+
authorName,
|
|
119
|
+
authorEmail,
|
|
120
|
+
conflict: false,
|
|
121
|
+
parents: [],
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const validateGitCommits = (
|
|
126
|
+
commits: readonly CommitMetadata[],
|
|
127
|
+
base: string,
|
|
128
|
+
) => {
|
|
129
|
+
if (commits.length === 0) {
|
|
130
|
+
throw new PushError({
|
|
131
|
+
message: `No commits to publish after base '${base}'`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
const issues: string[] = []
|
|
135
|
+
for (const commit of commits) {
|
|
136
|
+
if (!commit.description.trim()) {
|
|
137
|
+
issues.push(
|
|
138
|
+
`Commit ${commit.commitId} has an empty message. Run: git rebase -i ${base}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
|
|
142
|
+
issues.push(
|
|
143
|
+
`Commit ${commit.commitId} has an invalid author. Run: git rebase -i ${base}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const publishGit = (
|
|
151
|
+
fs: FileSystemService,
|
|
152
|
+
checkout: string,
|
|
153
|
+
remote: string,
|
|
154
|
+
branch: string,
|
|
155
|
+
base: string,
|
|
156
|
+
) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const currentBranch = yield* git(
|
|
159
|
+
fs,
|
|
160
|
+
checkout,
|
|
161
|
+
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
162
|
+
"Git checkout must be attached to the declared branch",
|
|
163
|
+
)
|
|
164
|
+
if (currentBranch.stdout.trim() !== branch) {
|
|
165
|
+
return yield* new PushError({
|
|
166
|
+
message: `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const status = yield* git(
|
|
170
|
+
fs,
|
|
171
|
+
checkout,
|
|
172
|
+
["status", "--porcelain=v1"],
|
|
173
|
+
"Failed to inspect Git status",
|
|
174
|
+
)
|
|
175
|
+
if (status.stdout.length > 0) {
|
|
176
|
+
return yield* new PushError({
|
|
177
|
+
message:
|
|
178
|
+
"Cannot publish a dirty Git worktree; commit or discard changes first",
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
yield* git(
|
|
183
|
+
fs,
|
|
184
|
+
checkout,
|
|
185
|
+
["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
|
|
186
|
+
`Failed to fetch remote '${remote}'`,
|
|
187
|
+
)
|
|
188
|
+
const tip = yield* gitRevision(fs, checkout, "HEAD")
|
|
189
|
+
const baseRevision = yield* gitRevision(
|
|
190
|
+
fs,
|
|
191
|
+
checkout,
|
|
192
|
+
`refs/remotes/${remote}/${base}`,
|
|
193
|
+
)
|
|
194
|
+
if (!tip || !baseRevision) {
|
|
195
|
+
return yield* new PushError({
|
|
196
|
+
message: `Declared base '${base}' was not found on remote '${remote}'`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
|
|
200
|
+
return yield* new PushError({
|
|
201
|
+
message: `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const log = yield* git(
|
|
206
|
+
fs,
|
|
207
|
+
checkout,
|
|
208
|
+
[
|
|
209
|
+
"log",
|
|
210
|
+
"--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
|
|
211
|
+
`${baseRevision}..${tip}`,
|
|
212
|
+
],
|
|
213
|
+
"Failed to inspect outgoing Git commits",
|
|
214
|
+
)
|
|
215
|
+
yield* Effect.try({
|
|
216
|
+
try: () => validateGitCommits(parseGitCommits(log.stdout), base),
|
|
217
|
+
catch: (cause) => cause as PushError,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const remoteTip = yield* gitRevision(
|
|
221
|
+
fs,
|
|
222
|
+
checkout,
|
|
223
|
+
`refs/remotes/${remote}/${branch}`,
|
|
224
|
+
)
|
|
225
|
+
if (remoteTip && !(yield* gitAncestor(fs, checkout, remoteTip, tip))) {
|
|
226
|
+
return yield* new PushError({
|
|
227
|
+
message: `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
yield* git(
|
|
232
|
+
fs,
|
|
233
|
+
checkout,
|
|
234
|
+
["push", remote, `HEAD:refs/heads/${branch}`],
|
|
235
|
+
`Failed to push declared branch '${branch}'`,
|
|
236
|
+
)
|
|
237
|
+
yield* git(
|
|
238
|
+
fs,
|
|
239
|
+
checkout,
|
|
240
|
+
[
|
|
241
|
+
"config",
|
|
242
|
+
`remote.${remote}.fetch`,
|
|
243
|
+
`+refs/heads/*:refs/remotes/${remote}/*`,
|
|
244
|
+
],
|
|
245
|
+
`Failed to configure remote '${remote}' tracking`,
|
|
246
|
+
)
|
|
247
|
+
yield* git(
|
|
248
|
+
fs,
|
|
249
|
+
checkout,
|
|
250
|
+
[
|
|
251
|
+
"fetch",
|
|
252
|
+
remote,
|
|
253
|
+
`+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
254
|
+
],
|
|
255
|
+
`Failed to refresh published branch '${branch}'`,
|
|
256
|
+
)
|
|
257
|
+
yield* git(
|
|
258
|
+
fs,
|
|
259
|
+
checkout,
|
|
260
|
+
["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
|
|
261
|
+
`Failed to establish upstream tracking for branch '${branch}'`,
|
|
262
|
+
)
|
|
263
|
+
return { tip }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const jjTemplate =
|
|
267
|
+
'"{\\"commitId\\":" ++ json(commit_id) ++ ",\\"changeId\\":" ++ json(change_id) ++ ",\\"description\\":" ++ json(description) ++ ",\\"empty\\":" ++ json(empty) ++ ",\\"authorName\\":" ++ json(author.name()) ++ ",\\"authorEmail\\":" ++ json(author.email()) ++ ",\\"conflict\\":" ++ json(conflict) ++ ",\\"parents\\":" ++ json(parents.map(|parent| parent.commit_id())) ++ "}\\n"'
|
|
268
|
+
|
|
269
|
+
const jjExact = (value: string) =>
|
|
270
|
+
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
|
|
271
|
+
|
|
272
|
+
const jj = (
|
|
273
|
+
fs: FileSystemService,
|
|
274
|
+
cwd: string,
|
|
275
|
+
args: readonly string[],
|
|
276
|
+
label: string,
|
|
277
|
+
) => requireCommand(fs, ["jj", "--no-pager", ...args], cwd, label)
|
|
278
|
+
|
|
279
|
+
const jjCommits = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
280
|
+
jj(
|
|
281
|
+
fs,
|
|
282
|
+
cwd,
|
|
283
|
+
["log", "--no-graph", "-r", revision, "-T", jjTemplate],
|
|
284
|
+
"Failed to inspect jj changes",
|
|
285
|
+
).pipe(
|
|
286
|
+
Effect.map((result) =>
|
|
287
|
+
result.stdout
|
|
288
|
+
.split("\n")
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.map((line) => JSON.parse(line) as CommitMetadata),
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const jjRevision = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
295
|
+
jjCommits(fs, cwd, revision).pipe(
|
|
296
|
+
Effect.map((commits) => {
|
|
297
|
+
if (commits.length === 0) return null
|
|
298
|
+
if (commits.length === 1) return commits[0]!
|
|
299
|
+
throw new PushError({
|
|
300
|
+
message: `Revision '${revision}' resolves to multiple jj commits`,
|
|
301
|
+
})
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const optionalJjRevision = (
|
|
306
|
+
fs: FileSystemService,
|
|
307
|
+
cwd: string,
|
|
308
|
+
revision: string,
|
|
309
|
+
) =>
|
|
310
|
+
jjCommits(fs, cwd, revision).pipe(
|
|
311
|
+
Effect.catchTag("PushError", () => Effect.succeed([])),
|
|
312
|
+
Effect.map((commits) => {
|
|
313
|
+
if (commits.length === 0) return null
|
|
314
|
+
if (commits.length === 1) return commits[0]!
|
|
315
|
+
throw new PushError({
|
|
316
|
+
message: `Bookmark '${revision}' is conflicted or resolves to multiple commits`,
|
|
317
|
+
})
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const jjAncestor = (
|
|
322
|
+
fs: FileSystemService,
|
|
323
|
+
cwd: string,
|
|
324
|
+
ancestor: string,
|
|
325
|
+
descendant: string,
|
|
326
|
+
) =>
|
|
327
|
+
jjCommits(fs, cwd, `${ancestor} & ::${descendant}`).pipe(
|
|
328
|
+
Effect.map((commits) => commits.length === 1),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const validateJjCommits = (
|
|
332
|
+
commits: readonly CommitMetadata[],
|
|
333
|
+
base: string,
|
|
334
|
+
) => {
|
|
335
|
+
if (commits.length === 0) {
|
|
336
|
+
throw new PushError({
|
|
337
|
+
message: `No changes to publish after base '${base}'`,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
const issues: string[] = []
|
|
341
|
+
for (const commit of commits) {
|
|
342
|
+
const id = commit.changeId!
|
|
343
|
+
if (commit.conflict) {
|
|
344
|
+
issues.push(`Change ${id} contains conflicts. Run: jj resolve -r ${id}`)
|
|
345
|
+
}
|
|
346
|
+
if (!commit.description.trim()) {
|
|
347
|
+
issues.push(`Change ${id} has no description. Run: jj describe -r ${id}`)
|
|
348
|
+
}
|
|
349
|
+
if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
|
|
350
|
+
issues.push(
|
|
351
|
+
`Change ${id} has an invalid author. Run: jj metaedit -r ${id} --author 'Name <email>'`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const publishJj = (
|
|
359
|
+
fs: FileSystemService,
|
|
360
|
+
checkout: string,
|
|
361
|
+
remote: string,
|
|
362
|
+
branch: string,
|
|
363
|
+
base: string,
|
|
364
|
+
) =>
|
|
365
|
+
Effect.gen(function* () {
|
|
366
|
+
yield* jj(
|
|
367
|
+
fs,
|
|
368
|
+
checkout,
|
|
369
|
+
["git", "fetch", "--remote", remote],
|
|
370
|
+
`Failed to fetch remote '${remote}'`,
|
|
371
|
+
)
|
|
372
|
+
const workingCopy = yield* jjRevision(fs, checkout, "@")
|
|
373
|
+
if (!workingCopy) {
|
|
374
|
+
return yield* new PushError({
|
|
375
|
+
message: "jj working copy commit was not found",
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
const canonicalPostCommit =
|
|
379
|
+
workingCopy.empty &&
|
|
380
|
+
!workingCopy.description.trim() &&
|
|
381
|
+
workingCopy.parents.length === 1
|
|
382
|
+
const tip = canonicalPostCommit
|
|
383
|
+
? yield* jjRevision(fs, checkout, "@-")
|
|
384
|
+
: workingCopy
|
|
385
|
+
if (!tip) {
|
|
386
|
+
return yield* new PushError({
|
|
387
|
+
message: "jj publication tip was not found",
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const baseBookmark = `${base}@${remote}`
|
|
392
|
+
const baseRevision = yield* optionalJjRevision(
|
|
393
|
+
fs,
|
|
394
|
+
checkout,
|
|
395
|
+
`remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
|
|
396
|
+
)
|
|
397
|
+
if (!baseRevision) {
|
|
398
|
+
return yield* new PushError({
|
|
399
|
+
message: `Declared base '${base}' was not found on remote '${remote}'`,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
if (
|
|
403
|
+
!(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
|
|
404
|
+
) {
|
|
405
|
+
return yield* new PushError({
|
|
406
|
+
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const outgoing = yield* jjCommits(
|
|
411
|
+
fs,
|
|
412
|
+
checkout,
|
|
413
|
+
`${baseRevision.commitId}..${tip.commitId}`,
|
|
414
|
+
)
|
|
415
|
+
yield* Effect.try({
|
|
416
|
+
try: () => validateJjCommits(outgoing, base),
|
|
417
|
+
catch: (cause) => cause as PushError,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const localBookmark = yield* optionalJjRevision(
|
|
421
|
+
fs,
|
|
422
|
+
checkout,
|
|
423
|
+
`bookmarks(exact:"${jjExact(branch)}")`,
|
|
424
|
+
)
|
|
425
|
+
if (
|
|
426
|
+
localBookmark &&
|
|
427
|
+
!(yield* jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId))
|
|
428
|
+
) {
|
|
429
|
+
return yield* new PushError({
|
|
430
|
+
message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
const remoteBookmark = yield* optionalJjRevision(
|
|
434
|
+
fs,
|
|
435
|
+
checkout,
|
|
436
|
+
`remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
|
|
437
|
+
)
|
|
438
|
+
if (
|
|
439
|
+
remoteBookmark &&
|
|
440
|
+
!(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
|
|
441
|
+
) {
|
|
442
|
+
return yield* new PushError({
|
|
443
|
+
message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
yield* jj(
|
|
448
|
+
fs,
|
|
449
|
+
checkout,
|
|
450
|
+
["bookmark", "set", branch, "-r", tip.commitId],
|
|
451
|
+
`Failed to set declared bookmark '${branch}'`,
|
|
452
|
+
)
|
|
453
|
+
yield* jj(
|
|
454
|
+
fs,
|
|
455
|
+
checkout,
|
|
456
|
+
["git", "push", "--remote", remote, "--bookmark", branch],
|
|
457
|
+
`Failed to push declared bookmark '${branch}'`,
|
|
458
|
+
)
|
|
459
|
+
yield* jj(
|
|
460
|
+
fs,
|
|
461
|
+
checkout,
|
|
462
|
+
["bookmark", "track", `${branch}@${remote}`],
|
|
463
|
+
`Failed to track remote bookmark '${branch}@${remote}'`,
|
|
464
|
+
)
|
|
465
|
+
return { tip: tip.commitId, changeId: tip.changeId }
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
469
|
+
sync: () => ({
|
|
470
|
+
publish: (startPath: string = process.cwd()) =>
|
|
471
|
+
Effect.gen(function* () {
|
|
472
|
+
const contexts = yield* ContextService
|
|
473
|
+
const fs = yield* FileSystemService
|
|
474
|
+
const tasks = yield* TaskService
|
|
475
|
+
const phases = yield* PhaseService
|
|
476
|
+
const workbase = yield* WorkbaseService
|
|
477
|
+
const context = yield* contexts.get({
|
|
478
|
+
cwd: startPath,
|
|
479
|
+
target: ".",
|
|
480
|
+
compact: true,
|
|
481
|
+
})
|
|
482
|
+
if (!context.validation.valid) {
|
|
483
|
+
return yield* new PushError({
|
|
484
|
+
message: "Cannot publish from an invalid Agency workbase",
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
if (context.target.kind !== "task" && context.target.kind !== "phase") {
|
|
488
|
+
return yield* new PushError({
|
|
489
|
+
message: "agency push must run from an execution task or phase",
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
if (
|
|
493
|
+
context.authority.mode !== "execution" ||
|
|
494
|
+
!context.authority.writable
|
|
495
|
+
) {
|
|
496
|
+
return yield* new PushError({
|
|
497
|
+
message:
|
|
498
|
+
"Current Agency target has no writable execution authority",
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
!context.workspace?.writable?.materialized ||
|
|
503
|
+
!context.workspace.writable.registered
|
|
504
|
+
) {
|
|
505
|
+
return yield* new PushError({
|
|
506
|
+
message:
|
|
507
|
+
"Current Agency writable checkout is not materialized and registered",
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
const blockers = context.graph.readiness.blockers.filter(
|
|
511
|
+
(blocker) =>
|
|
512
|
+
blocker.kind === "dependency" || blocker.kind === "validation",
|
|
513
|
+
)
|
|
514
|
+
if (blockers.length > 0) {
|
|
515
|
+
return yield* new PushError({
|
|
516
|
+
message: `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const taskId = context.target.taskId
|
|
521
|
+
if (!taskId) {
|
|
522
|
+
return yield* new PushError({
|
|
523
|
+
message: "Current Agency execution target has no task ID",
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
const phaseId =
|
|
527
|
+
context.target.kind === "phase" ? context.target.phaseId : undefined
|
|
528
|
+
const task = yield* tasks.show(taskId, context.workbase.root)
|
|
529
|
+
const execution =
|
|
530
|
+
"phases" in task.data
|
|
531
|
+
? phaseId
|
|
532
|
+
? (yield* phases.show(taskId, phaseId, context.workbase.root))
|
|
533
|
+
.data
|
|
534
|
+
: null
|
|
535
|
+
: task.data
|
|
536
|
+
if (!execution || "review" in execution) {
|
|
537
|
+
return yield* new PushError({
|
|
538
|
+
message: "Current Agency target is not a delivery execution unit",
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
if (execution.status !== "working") {
|
|
542
|
+
return yield* new PushError({
|
|
543
|
+
message: `Cannot publish Agency work with status '${execution.status}'; status must be working`,
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
const checkout = context.authority.writable.checkoutPath
|
|
547
|
+
const { config } = yield* workbase.loadConfig(context.workbase.root)
|
|
548
|
+
const remote = config.delivery?.remote ?? "origin"
|
|
549
|
+
const published =
|
|
550
|
+
context.workbase.vcs === "jj"
|
|
551
|
+
? yield* publishJj(
|
|
552
|
+
fs,
|
|
553
|
+
checkout,
|
|
554
|
+
remote,
|
|
555
|
+
execution.branch,
|
|
556
|
+
execution.base,
|
|
557
|
+
)
|
|
558
|
+
: yield* publishGit(
|
|
559
|
+
fs,
|
|
560
|
+
checkout,
|
|
561
|
+
remote,
|
|
562
|
+
execution.branch,
|
|
563
|
+
execution.base,
|
|
564
|
+
)
|
|
565
|
+
return {
|
|
566
|
+
vcs: context.workbase.vcs,
|
|
567
|
+
taskId,
|
|
568
|
+
...(phaseId ? { phaseId } : {}),
|
|
569
|
+
branch: execution.branch,
|
|
570
|
+
base: execution.base,
|
|
571
|
+
remote,
|
|
572
|
+
...published,
|
|
573
|
+
} satisfies PushResult
|
|
574
|
+
}),
|
|
575
|
+
}),
|
|
576
|
+
}) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { TaskService } from "./services/TaskService"
|
|
|
11
11
|
import { PhaseService } from "./services/PhaseService"
|
|
12
12
|
import { WorktreeService } from "./services/WorktreeService"
|
|
13
13
|
import { PullRequestService } from "./services/PullRequestService"
|
|
14
|
+
import { PushService } from "./services/PushService"
|
|
14
15
|
import { ArchiveService } from "./services/ArchiveService"
|
|
15
16
|
import { IntegrationService } from "./services/IntegrationService"
|
|
16
17
|
import { ContextService } from "./services/ContextService"
|
|
@@ -46,6 +47,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
46
47
|
PhaseService.Default,
|
|
47
48
|
WorktreeService.Default,
|
|
48
49
|
PullRequestService.Default,
|
|
50
|
+
PushService.Default,
|
|
49
51
|
ArchiveService.Default,
|
|
50
52
|
IntegrationService.Default,
|
|
51
53
|
ContextService.Default,
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -69,6 +69,11 @@ change only the writable checkout, keep durable decisions current, and run the
|
|
|
69
69
|
repository's formatting, type checks, build, dead-code checks, and focused tests.
|
|
70
70
|
Review and commit the diff according to the repository's instructions.
|
|
71
71
|
|
|
72
|
+
Use `agency push` from an execution task or phase to validate and publish its
|
|
73
|
+
declared delivery branch or bookmark without creating a pull request. The
|
|
74
|
+
command never authors semantic commit descriptions; resolve its reported change
|
|
75
|
+
IDs and remediation commands before retrying.
|
|
76
|
+
|
|
72
77
|
`agency work` is the human launch flow: it reconciles managed integration,
|
|
73
78
|
selects work, checks readiness, prepares checkouts, marks execution work
|
|
74
79
|
`working` without creating a claim, and starts the runner. Epic and multi-phase
|