@markjaquith/agency 2.49.0 → 2.51.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 +56 -9
- package/cli.ts +56 -1
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +142 -38
- package/src/cli-parser.test.ts +81 -0
- package/src/cli-parser.ts +78 -1
- package/src/cli.test.ts +4 -4
- package/src/commands/init.test.ts +2 -0
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +27 -5
- package/src/commands/vcs.test.ts +75 -0
- package/src/commands/vcs.ts +72 -0
- package/src/commands/work.test.ts +2 -0
- package/src/commands/work.ts +2 -2
- package/src/commands/worktree.ts +1 -1
- package/src/graph-schema.test.ts +1 -1
- package/src/graph-schema.ts +36 -13
- package/src/protocol.ts +1 -0
- package/src/services/ArchiveService.test.ts +2 -2
- package/src/services/ArchiveService.ts +24 -0
- package/src/services/ClaimService.ts +3 -2
- package/src/services/ContextService.ts +118 -17
- package/src/services/DoctorService.ts +58 -7
- package/src/services/GraphMutationService.ts +5 -0
- package/src/services/GraphService.ts +119 -54
- package/src/services/PhaseService.ts +196 -70
- package/src/services/PullRequestService.test.ts +2 -2
- package/src/services/PullRequestService.ts +37 -33
- package/src/services/ReadinessService.ts +7 -3
- package/src/services/RepositoryService.test.ts +31 -1
- package/src/services/RepositoryService.ts +63 -31
- package/src/services/ReviewService.test.ts +472 -0
- package/src/services/ReviewService.ts +427 -0
- package/src/services/SyncService.test.ts +69 -2
- package/src/services/SyncService.ts +161 -90
- package/src/services/TaskPhaseService.test.ts +80 -0
- package/src/services/TaskService.ts +82 -9
- package/src/services/VcsMigrationService.test.ts +211 -0
- package/src/services/VcsMigrationService.ts +816 -0
- package/src/services/VersionControlService.test.ts +100 -0
- package/src/services/VersionControlService.ts +479 -0
- package/src/services/WorkbaseService.ts +7 -2
- package/src/services/WorktreeService.test.ts +88 -17
- package/src/services/WorktreeService.ts +865 -329
- package/src/test-utils.ts +12 -0
- package/src/work-view.ts +14 -6
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/schemas.test.ts +57 -0
- package/src/workbase/schemas.ts +57 -0
- package/src/workbase/version-control.ts +5 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { Data, Effect, Layer } from "effect"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { lstat, mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname } from "node:path"
|
|
5
|
+
import { FileSystemService } from "./FileSystemService"
|
|
6
|
+
import { PhaseService } from "./PhaseService"
|
|
7
|
+
import { RepositoryService } from "./RepositoryService"
|
|
8
|
+
import { TaskService } from "./TaskService"
|
|
9
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
10
|
+
import {
|
|
11
|
+
WorktreeService,
|
|
12
|
+
type WorktreeRemovalSnapshot,
|
|
13
|
+
} from "./WorktreeService"
|
|
14
|
+
import {
|
|
15
|
+
GitVersionControlService,
|
|
16
|
+
JjVersionControlService,
|
|
17
|
+
VersionControlService,
|
|
18
|
+
} from "./VersionControlService"
|
|
19
|
+
import { withWorktreeLocks } from "./WorktreeLock"
|
|
20
|
+
import {
|
|
21
|
+
documentWriteStep,
|
|
22
|
+
runLifecycleTransaction,
|
|
23
|
+
type TransactionStep,
|
|
24
|
+
} from "./LifecycleTransaction"
|
|
25
|
+
import { RevisionConflictError } from "../workbase/document-revision"
|
|
26
|
+
import {
|
|
27
|
+
formatMarkdownDocument,
|
|
28
|
+
parseFrontmatter,
|
|
29
|
+
} from "../workbase/frontmatter"
|
|
30
|
+
import type { ReviewRecord, ReviewSource } from "../workbase/schemas"
|
|
31
|
+
|
|
32
|
+
class ReviewError extends Data.TaggedError("ReviewError")<{
|
|
33
|
+
readonly message: string
|
|
34
|
+
readonly cause?: unknown
|
|
35
|
+
}> {}
|
|
36
|
+
|
|
37
|
+
const githubRepository = (remote: string) => {
|
|
38
|
+
const match = remote
|
|
39
|
+
.replace(/\.git\/?$/, "")
|
|
40
|
+
.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i)
|
|
41
|
+
return match?.[1]?.toLowerCase() ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pinRef = (taskId: string) =>
|
|
45
|
+
`refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
|
|
46
|
+
|
|
47
|
+
const runGit = async (args: readonly string[]) => {
|
|
48
|
+
const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
49
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
50
|
+
child.exited,
|
|
51
|
+
new Response(child.stdout).text(),
|
|
52
|
+
new Response(child.stderr).text(),
|
|
53
|
+
])
|
|
54
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
55
|
+
return stdout.trim()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const WorktreeLayer = Layer.mergeAll(
|
|
59
|
+
FileSystemService.Default,
|
|
60
|
+
WorkbaseService.Default,
|
|
61
|
+
GitVersionControlService.Default,
|
|
62
|
+
JjVersionControlService.Default,
|
|
63
|
+
VersionControlService.Default,
|
|
64
|
+
TaskService.Default,
|
|
65
|
+
PhaseService.Default,
|
|
66
|
+
WorktreeService.Default,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
|
|
70
|
+
Effect.runPromise(
|
|
71
|
+
effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const restoreSnapshots = async (
|
|
75
|
+
snapshots: readonly WorktreeRemovalSnapshot[],
|
|
76
|
+
) => {
|
|
77
|
+
for (const snapshot of snapshots) {
|
|
78
|
+
try {
|
|
79
|
+
await lstat(snapshot.path)
|
|
80
|
+
continue
|
|
81
|
+
} catch {}
|
|
82
|
+
await mkdir(dirname(snapshot.path), { recursive: true })
|
|
83
|
+
if (snapshot.vcs === "jj") {
|
|
84
|
+
await runGit([
|
|
85
|
+
"jj",
|
|
86
|
+
"-R",
|
|
87
|
+
snapshot.repositoryPath,
|
|
88
|
+
"workspace",
|
|
89
|
+
"add",
|
|
90
|
+
"--name",
|
|
91
|
+
snapshot.workspaceName!,
|
|
92
|
+
"-r",
|
|
93
|
+
snapshot.head,
|
|
94
|
+
snapshot.path,
|
|
95
|
+
])
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
await runGit(
|
|
99
|
+
snapshot.branch
|
|
100
|
+
? [
|
|
101
|
+
"git",
|
|
102
|
+
"-C",
|
|
103
|
+
snapshot.repositoryPath,
|
|
104
|
+
"worktree",
|
|
105
|
+
"add",
|
|
106
|
+
snapshot.path,
|
|
107
|
+
snapshot.branch,
|
|
108
|
+
]
|
|
109
|
+
: [
|
|
110
|
+
"git",
|
|
111
|
+
"-C",
|
|
112
|
+
snapshot.repositoryPath,
|
|
113
|
+
"worktree",
|
|
114
|
+
"add",
|
|
115
|
+
"--detach",
|
|
116
|
+
snapshot.path,
|
|
117
|
+
snapshot.head,
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const normalizeBranch = (input: string, repositoryPath: string) =>
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const fs = yield* FileSystemService
|
|
126
|
+
if (
|
|
127
|
+
input.startsWith("refs/") &&
|
|
128
|
+
!input.startsWith("refs/heads/") &&
|
|
129
|
+
!input.startsWith("refs/remotes/origin/")
|
|
130
|
+
) {
|
|
131
|
+
return yield* new ReviewError({
|
|
132
|
+
message: `Invalid review branch '${input}'`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
const name = input
|
|
136
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
137
|
+
.replace(/^origin\//, "")
|
|
138
|
+
.replace(/^refs\/heads\//, "")
|
|
139
|
+
if (
|
|
140
|
+
!name ||
|
|
141
|
+
name === "HEAD" ||
|
|
142
|
+
name.startsWith("-") ||
|
|
143
|
+
/[\s:*?\[\\^~]/.test(name) ||
|
|
144
|
+
name.includes("..") ||
|
|
145
|
+
name.includes("@{")
|
|
146
|
+
) {
|
|
147
|
+
return yield* new ReviewError({
|
|
148
|
+
message: `Invalid review branch '${input}'`,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
const checked = yield* fs.runCommand(
|
|
152
|
+
["git", "-C", repositoryPath, "check-ref-format", "--branch", name],
|
|
153
|
+
{ captureOutput: true },
|
|
154
|
+
)
|
|
155
|
+
if (checked.exitCode !== 0) {
|
|
156
|
+
return yield* new ReviewError({
|
|
157
|
+
message: `Invalid review branch '${input}'`,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
return `refs/heads/${name}`
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const fetchCommit = (repoPath: string, sourceRef: string) =>
|
|
164
|
+
Effect.gen(function* () {
|
|
165
|
+
const fs = yield* FileSystemService
|
|
166
|
+
const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
|
|
167
|
+
const fetched = yield* fs.runCommand(
|
|
168
|
+
[
|
|
169
|
+
"git",
|
|
170
|
+
"-C",
|
|
171
|
+
repoPath,
|
|
172
|
+
"fetch",
|
|
173
|
+
"--no-tags",
|
|
174
|
+
"origin",
|
|
175
|
+
`+${sourceRef}:${temporaryRef}`,
|
|
176
|
+
],
|
|
177
|
+
{ captureOutput: true },
|
|
178
|
+
)
|
|
179
|
+
if (fetched.exitCode !== 0) {
|
|
180
|
+
const cleanup = yield* fs.runCommand(
|
|
181
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
182
|
+
{ captureOutput: true },
|
|
183
|
+
)
|
|
184
|
+
return yield* new ReviewError({
|
|
185
|
+
message: `Review source '${sourceRef}' could not be fetched: ${fetched.stderr.trim()}${cleanup.exitCode === 0 ? "" : `; temporary ref cleanup failed: ${cleanup.stderr.trim()}`}`,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
const resolved = yield* fs.runCommand(
|
|
189
|
+
[
|
|
190
|
+
"git",
|
|
191
|
+
"-C",
|
|
192
|
+
repoPath,
|
|
193
|
+
"rev-parse",
|
|
194
|
+
"--verify",
|
|
195
|
+
`${temporaryRef}^{commit}`,
|
|
196
|
+
],
|
|
197
|
+
{ captureOutput: true },
|
|
198
|
+
)
|
|
199
|
+
const cleanup = yield* fs.runCommand(
|
|
200
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
201
|
+
{ captureOutput: true },
|
|
202
|
+
)
|
|
203
|
+
if (cleanup.exitCode !== 0) {
|
|
204
|
+
return yield* new ReviewError({
|
|
205
|
+
message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const commit = resolved.stdout.trim()
|
|
209
|
+
if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
|
|
210
|
+
return yield* new ReviewError({
|
|
211
|
+
message: `Review source '${sourceRef}' did not resolve to a commit`,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return commit
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
export class ReviewService extends Effect.Service<ReviewService>()(
|
|
218
|
+
"ReviewService",
|
|
219
|
+
{
|
|
220
|
+
sync: () => ({
|
|
221
|
+
resolve: (
|
|
222
|
+
repo: string,
|
|
223
|
+
input: { readonly pullRequest?: string; readonly ref?: string },
|
|
224
|
+
startPath: string = process.cwd(),
|
|
225
|
+
) =>
|
|
226
|
+
Effect.gen(function* () {
|
|
227
|
+
const repositories = yield* RepositoryService
|
|
228
|
+
const repository = yield* repositories.show(repo, startPath)
|
|
229
|
+
if (!repository.remote || repository.states.includes("missing")) {
|
|
230
|
+
return yield* new ReviewError({
|
|
231
|
+
message: `Repository alias '${repo}' must be materialized with an origin remote`,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
let source: ReviewSource
|
|
235
|
+
let sourceRef: string
|
|
236
|
+
if (input.pullRequest) {
|
|
237
|
+
const urlMatch = input.pullRequest.match(
|
|
238
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/i,
|
|
239
|
+
)
|
|
240
|
+
const identifier =
|
|
241
|
+
urlMatch?.[2] ??
|
|
242
|
+
(/^\d+$/.test(input.pullRequest) ? input.pullRequest : null)
|
|
243
|
+
if (!identifier) {
|
|
244
|
+
return yield* new ReviewError({
|
|
245
|
+
message: `Invalid GitHub pull request '${input.pullRequest}'`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
const originRepository = githubRepository(repository.remote)
|
|
249
|
+
if (!originRepository) {
|
|
250
|
+
return yield* new ReviewError({
|
|
251
|
+
message: `Repository alias '${repo}' does not use a GitHub origin`,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
if (urlMatch && urlMatch[1]!.toLowerCase() !== originRepository) {
|
|
255
|
+
return yield* new ReviewError({
|
|
256
|
+
message: `Pull request repository '${urlMatch[1]}' does not match alias '${repo}' origin '${originRepository}'`,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
sourceRef = `refs/pull/${identifier}/head`
|
|
260
|
+
source = {
|
|
261
|
+
kind: "pull-request",
|
|
262
|
+
provider: "github",
|
|
263
|
+
repository: originRepository,
|
|
264
|
+
identifier,
|
|
265
|
+
url: `https://github.com/${originRepository}/pull/${identifier}`,
|
|
266
|
+
fetchRef: sourceRef,
|
|
267
|
+
}
|
|
268
|
+
} else if (input.ref) {
|
|
269
|
+
sourceRef = yield* normalizeBranch(input.ref, repository.path)
|
|
270
|
+
source = { kind: "branch", ref: sourceRef }
|
|
271
|
+
} else {
|
|
272
|
+
return yield* new ReviewError({
|
|
273
|
+
message: "Exactly one review source is required",
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
const commit = yield* fetchCommit(repository.path, sourceRef)
|
|
277
|
+
return {
|
|
278
|
+
repo,
|
|
279
|
+
source,
|
|
280
|
+
commit,
|
|
281
|
+
refreshedAt: new Date().toISOString(),
|
|
282
|
+
} satisfies ReviewRecord
|
|
283
|
+
}),
|
|
284
|
+
|
|
285
|
+
refresh: (
|
|
286
|
+
taskId: string,
|
|
287
|
+
startPath: string = process.cwd(),
|
|
288
|
+
ifRevision?: string,
|
|
289
|
+
) =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
const workbase = yield* WorkbaseService
|
|
292
|
+
const tasks = yield* TaskService
|
|
293
|
+
const worktrees = yield* WorktreeService
|
|
294
|
+
const service = yield* ReviewService
|
|
295
|
+
const repositories = yield* RepositoryService
|
|
296
|
+
const root = yield* workbase.discover(startPath)
|
|
297
|
+
return yield* withWorktreeLocks(
|
|
298
|
+
root,
|
|
299
|
+
[{ taskId }],
|
|
300
|
+
Effect.gen(function* () {
|
|
301
|
+
const task = yield* tasks.show(taskId, root)
|
|
302
|
+
if (!("review" in task.data)) {
|
|
303
|
+
return yield* new ReviewError({
|
|
304
|
+
message: `Task '${taskId}' is not a review task`,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
const previousReview = task.data.review
|
|
308
|
+
if (task.data.claim?.state === "active") {
|
|
309
|
+
return yield* new ReviewError({
|
|
310
|
+
message: `Review task '${taskId}' has an active claim; release or finish it before refreshing`,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
if (ifRevision && task.revision !== ifRevision) {
|
|
314
|
+
return yield* new RevisionConflictError({
|
|
315
|
+
path: task.path,
|
|
316
|
+
target: `task '${taskId}'`,
|
|
317
|
+
expectedRevision: ifRevision,
|
|
318
|
+
currentRevision: task.revision,
|
|
319
|
+
message: `Revision conflict for task '${taskId}'`,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
const inspection = yield* worktrees.inspect(
|
|
323
|
+
taskId,
|
|
324
|
+
undefined,
|
|
325
|
+
root,
|
|
326
|
+
)
|
|
327
|
+
if (
|
|
328
|
+
inspection.conflicts.length ||
|
|
329
|
+
inspection.checkouts.some((checkout) => checkout.dirty)
|
|
330
|
+
) {
|
|
331
|
+
return yield* new ReviewError({
|
|
332
|
+
message: `Cannot refresh review task '${taskId}'; its checkout is dirty or structurally unexpected`,
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
const latest = yield* service.resolve(
|
|
336
|
+
task.data.review.repo,
|
|
337
|
+
task.data.review.source.kind === "pull-request"
|
|
338
|
+
? { pullRequest: task.data.review.source.url }
|
|
339
|
+
: { ref: task.data.review.source.ref },
|
|
340
|
+
root,
|
|
341
|
+
)
|
|
342
|
+
const parsed = yield* parseFrontmatter(task.content, task.path)
|
|
343
|
+
const content = formatMarkdownDocument(
|
|
344
|
+
{ ...task.data, review: latest },
|
|
345
|
+
parsed.body,
|
|
346
|
+
)
|
|
347
|
+
const repository = yield* repositories.show(
|
|
348
|
+
task.data.review.repo,
|
|
349
|
+
root,
|
|
350
|
+
)
|
|
351
|
+
const hadCheckout = inspection.checkouts.some(
|
|
352
|
+
(checkout) => checkout.exists || checkout.registered,
|
|
353
|
+
)
|
|
354
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
355
|
+
const steps: TransactionStep[] = []
|
|
356
|
+
if (hadCheckout) {
|
|
357
|
+
steps.push({
|
|
358
|
+
label: `remove review checkout for ${taskId}`,
|
|
359
|
+
apply: () =>
|
|
360
|
+
runWorktreeEffect(
|
|
361
|
+
worktrees.remove(taskId, undefined, root, {
|
|
362
|
+
snapshots,
|
|
363
|
+
lockHeld: true,
|
|
364
|
+
}),
|
|
365
|
+
).then(() => undefined),
|
|
366
|
+
rollback: () => restoreSnapshots(snapshots),
|
|
367
|
+
manualRecovery: `Restore the detached checkout under ${inspection.codePath}`,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
steps.push(
|
|
371
|
+
documentWriteStep(root, [{ path: task.path, content }]),
|
|
372
|
+
)
|
|
373
|
+
steps.push({
|
|
374
|
+
label: `advance review pin for ${taskId}`,
|
|
375
|
+
apply: () =>
|
|
376
|
+
runGit([
|
|
377
|
+
"git",
|
|
378
|
+
"-C",
|
|
379
|
+
repository.path,
|
|
380
|
+
"update-ref",
|
|
381
|
+
pinRef(taskId),
|
|
382
|
+
latest.commit,
|
|
383
|
+
previousReview.commit,
|
|
384
|
+
]).then(() => undefined),
|
|
385
|
+
rollback: () =>
|
|
386
|
+
runGit([
|
|
387
|
+
"git",
|
|
388
|
+
"-C",
|
|
389
|
+
repository.path,
|
|
390
|
+
"update-ref",
|
|
391
|
+
pinRef(taskId),
|
|
392
|
+
previousReview.commit,
|
|
393
|
+
latest.commit,
|
|
394
|
+
]).then(() => undefined),
|
|
395
|
+
manualRecovery: `Reset ${pinRef(taskId)} to ${previousReview.commit}`,
|
|
396
|
+
})
|
|
397
|
+
if (hadCheckout) {
|
|
398
|
+
steps.push({
|
|
399
|
+
label: `create refreshed review checkout for ${taskId}`,
|
|
400
|
+
apply: () =>
|
|
401
|
+
runWorktreeEffect(
|
|
402
|
+
worktrees.materialize(taskId, undefined, root, {
|
|
403
|
+
lockHeld: true,
|
|
404
|
+
}),
|
|
405
|
+
).then(() => undefined),
|
|
406
|
+
manualRecovery: `Run agency work prepare for review task '${taskId}'`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
yield* runLifecycleTransaction({
|
|
410
|
+
root,
|
|
411
|
+
preconditions: [{ path: task.path, revision: task.revision }],
|
|
412
|
+
steps,
|
|
413
|
+
})
|
|
414
|
+
return {
|
|
415
|
+
taskId,
|
|
416
|
+
previousCommit: task.data.review.commit,
|
|
417
|
+
commit: latest.commit,
|
|
418
|
+
changed: latest.commit !== task.data.review.commit,
|
|
419
|
+
refreshedAt: latest.refreshedAt,
|
|
420
|
+
revision: (yield* tasks.show(taskId, root)).revision,
|
|
421
|
+
}
|
|
422
|
+
}),
|
|
423
|
+
)
|
|
424
|
+
}),
|
|
425
|
+
}),
|
|
426
|
+
},
|
|
427
|
+
) {}
|
|
@@ -22,6 +22,18 @@ const git = async (args: string[], cwd?: string) => {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const jj = async (args: string[], cwd?: string) => {
|
|
26
|
+
const process = Bun.spawn(["jj", ...args], {
|
|
27
|
+
cwd,
|
|
28
|
+
stdout: "pipe",
|
|
29
|
+
stderr: "pipe",
|
|
30
|
+
})
|
|
31
|
+
await process.exited
|
|
32
|
+
if (process.exitCode !== 0) {
|
|
33
|
+
throw new Error(await new Response(process.stderr).text())
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
describe("SyncService", () => {
|
|
26
38
|
let root: string
|
|
27
39
|
let originalPath: string | undefined
|
|
@@ -109,6 +121,61 @@ pr: null
|
|
|
109
121
|
expect(await Bun.file(join(root, "repos/agency")).exists()).toBe(false)
|
|
110
122
|
})
|
|
111
123
|
|
|
124
|
+
test("reconciles jj workspaces through the jj backend", async () => {
|
|
125
|
+
if (!Bun.which("jj")) return
|
|
126
|
+
const repository = join(root, "repos/agency")
|
|
127
|
+
await rm(repository, { recursive: true, force: true })
|
|
128
|
+
await git(["clone", join(root, "source"), repository])
|
|
129
|
+
await jj(["git", "init", "--colocate", repository])
|
|
130
|
+
await Bun.write(
|
|
131
|
+
join(root, "agency.json"),
|
|
132
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
133
|
+
)
|
|
134
|
+
await runTestEffect(
|
|
135
|
+
TaskService.pipe(
|
|
136
|
+
Effect.flatMap((service) =>
|
|
137
|
+
service.create(
|
|
138
|
+
{
|
|
139
|
+
id: "jj-sync",
|
|
140
|
+
ticketUrl: null,
|
|
141
|
+
repo: "agency",
|
|
142
|
+
branch: "task/jj-sync",
|
|
143
|
+
base: "main",
|
|
144
|
+
},
|
|
145
|
+
root,
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const planned = await runTestEffect(
|
|
152
|
+
SyncService.pipe(
|
|
153
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
expect(planned.changes).toContainEqual(
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
kind: "materialize-workspace",
|
|
159
|
+
target: "task:jj-sync",
|
|
160
|
+
status: "planned",
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
const applied = await runTestEffect(
|
|
165
|
+
SyncService.pipe(
|
|
166
|
+
Effect.flatMap((service) =>
|
|
167
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
expect(applied.executions[0]?.checkouts[0]).toMatchObject({
|
|
172
|
+
exists: true,
|
|
173
|
+
registered: true,
|
|
174
|
+
branch: "task/jj-sync",
|
|
175
|
+
dirty: false,
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
112
179
|
test("observes drift without mutation and applies only safe transitions", async () => {
|
|
113
180
|
await runTestEffect(
|
|
114
181
|
TaskService.pipe(
|
|
@@ -583,7 +650,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
583
650
|
),
|
|
584
651
|
),
|
|
585
652
|
)
|
|
586
|
-
await rm(workspace.writablePath
|
|
653
|
+
await rm(workspace.writablePath!, { recursive: true, force: true })
|
|
587
654
|
|
|
588
655
|
const observed = await runTestEffect(
|
|
589
656
|
SyncService.pipe(
|
|
@@ -607,7 +674,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
607
674
|
)
|
|
608
675
|
expect(applied.changes).toEqual([])
|
|
609
676
|
expect(
|
|
610
|
-
await Bun.file(join(workspace.writablePath
|
|
677
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
611
678
|
).toBe(false)
|
|
612
679
|
})
|
|
613
680
|
|