@markjaquith/agency 2.50.0 → 2.52.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 +34 -8
- package/cli.ts +33 -1
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +1 -0
- package/src/cli-parser.test.ts +19 -0
- package/src/cli-parser.ts +23 -0
- package/src/cli.test.ts +4 -4
- package/src/commands/init.test.ts +2 -0
- package/src/commands/vcs.test.ts +75 -0
- package/src/commands/vcs.ts +72 -0
- package/src/commands/worktree.ts +1 -1
- package/src/graph-schema.test.ts +1 -1
- package/src/graph-schema.ts +1 -0
- package/src/services/ArchiveService.ts +23 -0
- package/src/services/ContextService.ts +62 -13
- package/src/services/DoctorService.ts +13 -6
- package/src/services/GraphService.ts +1 -0
- package/src/services/PhaseService.ts +191 -70
- package/src/services/PullRequestService.ts +14 -30
- package/src/services/RepositoryService.test.ts +31 -1
- package/src/services/RepositoryService.ts +63 -31
- package/src/services/ReviewService.ts +23 -0
- package/src/services/SyncService.test.ts +67 -0
- package/src/services/SyncService.ts +51 -84
- package/src/services/TaskPhaseService.test.ts +80 -0
- package/src/services/VcsMigrationService.test.ts +245 -0
- package/src/services/VcsMigrationService.ts +812 -0
- package/src/services/VersionControlService.test.ts +100 -0
- package/src/services/VersionControlService.ts +479 -0
- package/src/services/WorkbaseService.ts +5 -1
- package/src/services/WorktreeService.test.ts +72 -1
- package/src/services/WorktreeService.ts +808 -308
- package/src/test-utils.ts +10 -0
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/version-control.ts +5 -0
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
WorkbaseConfig,
|
|
18
18
|
} from "../workbase/schemas"
|
|
19
19
|
import { documentRevision } from "../workbase/document-revision"
|
|
20
|
+
import {
|
|
21
|
+
VersionControlService,
|
|
22
|
+
type VersionControlBackend,
|
|
23
|
+
} from "./VersionControlService"
|
|
20
24
|
|
|
21
25
|
class RepositoryError extends Data.TaggedError("RepositoryError")<{
|
|
22
26
|
readonly message: string
|
|
@@ -207,7 +211,11 @@ const requireMaterialized = (repository: RepositoryInfo) =>
|
|
|
207
211
|
)
|
|
208
212
|
: Effect.succeed(repository)
|
|
209
213
|
|
|
210
|
-
const removalBlockers = (
|
|
214
|
+
const removalBlockers = (
|
|
215
|
+
repository: RepositoryInfo,
|
|
216
|
+
startPath: string,
|
|
217
|
+
backend: VersionControlBackend,
|
|
218
|
+
) =>
|
|
211
219
|
Effect.gen(function* () {
|
|
212
220
|
const fs = yield* FileSystemService
|
|
213
221
|
const graph = yield* GraphService
|
|
@@ -223,28 +231,29 @@ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
|
|
|
223
231
|
.sort()
|
|
224
232
|
const worktrees: string[] = []
|
|
225
233
|
if (repository.kind !== null && !repository.states.includes("invalid")) {
|
|
234
|
+
const repositoryTarget = yield* fs.realPath(repository.path)
|
|
226
235
|
const linkedTarget =
|
|
227
236
|
repository.kind === "symlink"
|
|
228
237
|
? yield* fs.realPath(repository.path)
|
|
229
238
|
: null
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
|
|
237
|
-
const path = block.match(/^worktree (.+)$/m)?.[1]
|
|
238
|
-
if (path && path !== linkedTarget) worktrees.push(path)
|
|
239
|
-
}
|
|
239
|
+
for (const workspace of yield* backend.listWorkspaces(repository.path)) {
|
|
240
|
+
if (
|
|
241
|
+
workspace.path !== repositoryTarget &&
|
|
242
|
+
workspace.path !== linkedTarget
|
|
243
|
+
)
|
|
244
|
+
worktrees.push(workspace.path)
|
|
240
245
|
}
|
|
241
246
|
}
|
|
242
247
|
return { references, worktrees }
|
|
243
248
|
})
|
|
244
249
|
|
|
245
|
-
const assertRemovable = (
|
|
250
|
+
const assertRemovable = (
|
|
251
|
+
repository: RepositoryInfo,
|
|
252
|
+
startPath: string,
|
|
253
|
+
backend: VersionControlBackend,
|
|
254
|
+
) =>
|
|
246
255
|
Effect.gen(function* () {
|
|
247
|
-
const blockers = yield* removalBlockers(repository, startPath)
|
|
256
|
+
const blockers = yield* removalBlockers(repository, startPath, backend)
|
|
248
257
|
const details = [
|
|
249
258
|
...blockers.references.map((item) => `active reference ${item}`),
|
|
250
259
|
...blockers.worktrees.map((item) => `linked worktree ${item}`),
|
|
@@ -258,10 +267,11 @@ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
|
|
|
258
267
|
|
|
259
268
|
const effectPreflightStep = (
|
|
260
269
|
label: string,
|
|
261
|
-
check: Effect.Effect<void, unknown,
|
|
270
|
+
check: Effect.Effect<void, unknown, any>,
|
|
262
271
|
): TransactionStep => ({
|
|
263
272
|
label,
|
|
264
|
-
preflight: () =>
|
|
273
|
+
preflight: () =>
|
|
274
|
+
Effect.runPromise(check as Effect.Effect<void, unknown, never>),
|
|
265
275
|
apply: async () => undefined,
|
|
266
276
|
})
|
|
267
277
|
|
|
@@ -341,8 +351,10 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
341
351
|
add: (alias: string, remote: string, startPath: string = process.cwd()) =>
|
|
342
352
|
Effect.gen(function* () {
|
|
343
353
|
const fs = yield* FileSystemService
|
|
354
|
+
const versionControl = yield* VersionControlService
|
|
344
355
|
const validAlias = yield* validateAlias(alias)
|
|
345
356
|
const state = yield* configState(startPath)
|
|
357
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
346
358
|
const destination = join(state.root, "repos", validAlias)
|
|
347
359
|
if (
|
|
348
360
|
state.config.repositories?.[validAlias] ||
|
|
@@ -369,7 +381,14 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
369
381
|
)
|
|
370
382
|
yield* fs.createDirectory(join(state.root, "repos"))
|
|
371
383
|
const cloned = yield* fs.runCommand(
|
|
372
|
-
[
|
|
384
|
+
[
|
|
385
|
+
"git",
|
|
386
|
+
"clone",
|
|
387
|
+
...(backend.kind === "git" ? ["--bare"] : []),
|
|
388
|
+
"--",
|
|
389
|
+
cloneSource,
|
|
390
|
+
staging,
|
|
391
|
+
],
|
|
373
392
|
{ captureOutput: true },
|
|
374
393
|
)
|
|
375
394
|
if (cloned.exitCode !== 0) {
|
|
@@ -378,6 +397,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
378
397
|
message: `Failed to clone repository '${remote}': ${cloned.stderr.trim()}`,
|
|
379
398
|
})
|
|
380
399
|
}
|
|
400
|
+
yield* backend.initializeRepository(staging)
|
|
381
401
|
if (declaredRemote !== remote) {
|
|
382
402
|
const setRemote = yield* fs.runCommand(
|
|
383
403
|
[
|
|
@@ -417,10 +437,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
417
437
|
) =>
|
|
418
438
|
Effect.gen(function* () {
|
|
419
439
|
const fs = yield* FileSystemService
|
|
440
|
+
const versionControl = yield* VersionControlService
|
|
420
441
|
const graph = yield* GraphService
|
|
421
442
|
const workbase = yield* WorkbaseService
|
|
422
443
|
const validAlias = yield* validateAlias(alias)
|
|
423
444
|
const state = yield* configState(startPath)
|
|
445
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
424
446
|
const destination = join(state.root, "repos", validAlias)
|
|
425
447
|
const resolvedTarget = resolve(startPath, target)
|
|
426
448
|
const existing = (yield* RepositoryService)
|
|
@@ -452,6 +474,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
452
474
|
message: `Path is not a Git repository: ${resolvedTarget}`,
|
|
453
475
|
})
|
|
454
476
|
}
|
|
477
|
+
yield* backend.initializeRepository(resolvedTarget)
|
|
455
478
|
const declaredRemote =
|
|
456
479
|
state.config.repositories?.[validAlias]?.remote ??
|
|
457
480
|
(yield* portableRemote(resolvedTarget))
|
|
@@ -474,7 +497,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
474
497
|
const safety = localCurrent
|
|
475
498
|
? effectPreflightStep(
|
|
476
499
|
`verify repository safety for ${validAlias}`,
|
|
477
|
-
assertRemovable(localCurrent, state.root).pipe(
|
|
500
|
+
assertRemovable(localCurrent, state.root, backend).pipe(
|
|
478
501
|
Effect.provideService(FileSystemService, fs),
|
|
479
502
|
Effect.provideService(GraphService, graph),
|
|
480
503
|
Effect.provideService(WorkbaseService, workbase),
|
|
@@ -584,19 +607,12 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
584
607
|
|
|
585
608
|
fetch: (alias: string, startPath: string = process.cwd()) =>
|
|
586
609
|
Effect.gen(function* () {
|
|
587
|
-
const
|
|
610
|
+
const versionControl = yield* VersionControlService
|
|
588
611
|
const repository = yield* find(alias, startPath).pipe(
|
|
589
612
|
Effect.flatMap(requireMaterialized),
|
|
590
613
|
)
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
{ captureOutput: true },
|
|
594
|
-
)
|
|
595
|
-
if (result.exitCode !== 0) {
|
|
596
|
-
return yield* new RepositoryError({
|
|
597
|
-
message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
|
|
598
|
-
})
|
|
599
|
-
}
|
|
614
|
+
const backend = yield* versionControl.forWorkbase(startPath)
|
|
615
|
+
yield* backend.fetch(repository.path, undefined, undefined)
|
|
600
616
|
return repository
|
|
601
617
|
}),
|
|
602
618
|
|
|
@@ -605,8 +621,10 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
605
621
|
const fs = yield* FileSystemService
|
|
606
622
|
const graph = yield* GraphService
|
|
607
623
|
const workbase = yield* WorkbaseService
|
|
624
|
+
const versionControl = yield* VersionControlService
|
|
608
625
|
const repository = yield* find(alias, startPath)
|
|
609
626
|
const state = yield* configState(startPath)
|
|
627
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
610
628
|
const declarations = { ...(state.config.repositories ?? {}) }
|
|
611
629
|
delete declarations[repository.alias]
|
|
612
630
|
const config = withDeclarations(state.config, declarations)
|
|
@@ -618,7 +636,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
618
636
|
)
|
|
619
637
|
const safety = effectPreflightStep(
|
|
620
638
|
`verify repository safety for ${repository.alias}`,
|
|
621
|
-
assertRemovable(repository, startPath).pipe(
|
|
639
|
+
assertRemovable(repository, startPath, backend).pipe(
|
|
622
640
|
Effect.provideService(FileSystemService, fs),
|
|
623
641
|
Effect.provideService(GraphService, graph),
|
|
624
642
|
Effect.provideService(WorkbaseService, workbase),
|
|
@@ -642,6 +660,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
642
660
|
const fs = yield* FileSystemService
|
|
643
661
|
const graph = yield* GraphService
|
|
644
662
|
const workbase = yield* WorkbaseService
|
|
663
|
+
const versionControl = yield* VersionControlService
|
|
645
664
|
const repository = yield* find(alias, startPath)
|
|
646
665
|
if (repository.kind !== "symlink") {
|
|
647
666
|
return yield* new RepositoryError({
|
|
@@ -649,6 +668,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
649
668
|
})
|
|
650
669
|
}
|
|
651
670
|
const state = yield* configState(startPath)
|
|
671
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
652
672
|
const staging = join(
|
|
653
673
|
state.root,
|
|
654
674
|
"repos",
|
|
@@ -660,7 +680,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
660
680
|
steps: [
|
|
661
681
|
effectPreflightStep(
|
|
662
682
|
`verify repository safety for ${repository.alias}`,
|
|
663
|
-
assertRemovable(repository, startPath).pipe(
|
|
683
|
+
assertRemovable(repository, startPath, backend).pipe(
|
|
664
684
|
Effect.provideService(FileSystemService, fs),
|
|
665
685
|
Effect.provideService(GraphService, graph),
|
|
666
686
|
Effect.provideService(WorkbaseService, workbase),
|
|
@@ -685,9 +705,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
685
705
|
const fs = yield* FileSystemService
|
|
686
706
|
const graph = yield* GraphService
|
|
687
707
|
const workbase = yield* WorkbaseService
|
|
708
|
+
const versionControl = yield* VersionControlService
|
|
688
709
|
const repository = yield* find(alias, startPath)
|
|
689
710
|
const validNewAlias = yield* validateAlias(newAlias)
|
|
690
711
|
const state = yield* configState(startPath)
|
|
712
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
691
713
|
const destination = join(state.root, "repos", validNewAlias)
|
|
692
714
|
if (
|
|
693
715
|
state.config.repositories?.[validNewAlias] ||
|
|
@@ -712,7 +734,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
712
734
|
const exists = yield* fs.exists(repository.path)
|
|
713
735
|
const safety = effectPreflightStep(
|
|
714
736
|
`verify repository safety for ${repository.alias}`,
|
|
715
|
-
assertRemovable(repository, startPath).pipe(
|
|
737
|
+
assertRemovable(repository, startPath, backend).pipe(
|
|
716
738
|
Effect.provideService(FileSystemService, fs),
|
|
717
739
|
Effect.provideService(GraphService, graph),
|
|
718
740
|
Effect.provideService(WorkbaseService, workbase),
|
|
@@ -843,7 +865,9 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
843
865
|
Effect.gen(function* () {
|
|
844
866
|
const service = yield* RepositoryService
|
|
845
867
|
const fs = yield* FileSystemService
|
|
868
|
+
const versionControl = yield* VersionControlService
|
|
846
869
|
const state = yield* configState(options.cwd ?? process.cwd())
|
|
870
|
+
const backend = yield* versionControl.forWorkbase(state.root)
|
|
847
871
|
const repositories = yield* service.list(state.root)
|
|
848
872
|
const planned: Omit<RepositorySetupAction, "status">[] = []
|
|
849
873
|
const unresolved: RepositorySetupIssue[] = []
|
|
@@ -913,7 +937,14 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
913
937
|
)
|
|
914
938
|
yield* fs.createDirectory(join(state.root, "repos"))
|
|
915
939
|
const cloned = yield* fs.runCommand(
|
|
916
|
-
[
|
|
940
|
+
[
|
|
941
|
+
"git",
|
|
942
|
+
"clone",
|
|
943
|
+
...(backend.kind === "git" ? ["--bare"] : []),
|
|
944
|
+
"--",
|
|
945
|
+
action.remote,
|
|
946
|
+
from,
|
|
947
|
+
],
|
|
917
948
|
{ captureOutput: true },
|
|
918
949
|
)
|
|
919
950
|
if (cloned.exitCode !== 0) {
|
|
@@ -923,6 +954,7 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
923
954
|
message: `Failed to materialize repository '${action.alias}': ${cloned.stderr.trim()}`,
|
|
924
955
|
})
|
|
925
956
|
}
|
|
957
|
+
yield* backend.initializeRepository(from)
|
|
926
958
|
staging.push({
|
|
927
959
|
alias: action.alias,
|
|
928
960
|
from,
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
WorktreeService,
|
|
12
12
|
type WorktreeRemovalSnapshot,
|
|
13
13
|
} from "./WorktreeService"
|
|
14
|
+
import {
|
|
15
|
+
GitVersionControlService,
|
|
16
|
+
JjVersionControlService,
|
|
17
|
+
VersionControlService,
|
|
18
|
+
} from "./VersionControlService"
|
|
14
19
|
import { withWorktreeLocks } from "./WorktreeLock"
|
|
15
20
|
import {
|
|
16
21
|
documentWriteStep,
|
|
@@ -53,6 +58,9 @@ const runGit = async (args: readonly string[]) => {
|
|
|
53
58
|
const WorktreeLayer = Layer.mergeAll(
|
|
54
59
|
FileSystemService.Default,
|
|
55
60
|
WorkbaseService.Default,
|
|
61
|
+
GitVersionControlService.Default,
|
|
62
|
+
JjVersionControlService.Default,
|
|
63
|
+
VersionControlService.Default,
|
|
56
64
|
TaskService.Default,
|
|
57
65
|
PhaseService.Default,
|
|
58
66
|
WorktreeService.Default,
|
|
@@ -72,6 +80,21 @@ const restoreSnapshots = async (
|
|
|
72
80
|
continue
|
|
73
81
|
} catch {}
|
|
74
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
|
+
}
|
|
75
98
|
await runGit(
|
|
76
99
|
snapshot.branch
|
|
77
100
|
? [
|
|
@@ -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(
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
RepositoryService,
|
|
26
26
|
type RepositorySetupResult,
|
|
27
27
|
} from "./RepositoryService"
|
|
28
|
+
import { VersionControlService } from "./VersionControlService"
|
|
28
29
|
|
|
29
30
|
class SyncError extends Data.TaggedError("SyncError")<{
|
|
30
31
|
readonly message: string
|
|
@@ -161,7 +162,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
161
162
|
const worktrees = yield* WorktreeService
|
|
162
163
|
const claims = yield* ClaimService
|
|
163
164
|
const repositories = yield* RepositoryService
|
|
165
|
+
const versionControl = yield* VersionControlService
|
|
164
166
|
const { root, config } = yield* workbase.loadConfig(options.cwd)
|
|
167
|
+
const backend = yield* versionControl.forWorkbase(root)
|
|
165
168
|
const validation = yield* workbase.validate(root)
|
|
166
169
|
if (!validation.valid) {
|
|
167
170
|
return yield* new SyncError({
|
|
@@ -268,33 +271,27 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
268
271
|
workspaceConflict = true
|
|
269
272
|
continue
|
|
270
273
|
}
|
|
271
|
-
const listed = yield*
|
|
272
|
-
|
|
273
|
-
"git",
|
|
274
|
-
"-C",
|
|
275
|
-
repositoryPath,
|
|
276
|
-
"worktree",
|
|
277
|
-
"list",
|
|
278
|
-
"--porcelain",
|
|
279
|
-
"-z",
|
|
280
|
-
],
|
|
281
|
-
{ captureOutput: true },
|
|
274
|
+
const listed = yield* Effect.either(
|
|
275
|
+
backend.listWorkspaces(repositoryPath),
|
|
282
276
|
)
|
|
283
|
-
if (listed
|
|
277
|
+
if (Either.isLeft(listed)) {
|
|
284
278
|
unresolved.push({
|
|
285
279
|
kind: "worktree-inspection-failed",
|
|
286
280
|
target: record.key,
|
|
287
|
-
message:
|
|
288
|
-
listed.stderr.trim() || `Cannot inspect '${checkout.repo}'`,
|
|
281
|
+
message: `Cannot inspect '${checkout.repo}'`,
|
|
289
282
|
})
|
|
290
283
|
workspaceConflict = true
|
|
291
284
|
continue
|
|
292
285
|
}
|
|
293
286
|
const exists = yield* fs.isDirectory(checkoutPath)
|
|
294
287
|
const registered: RegisteredWorktree[] = []
|
|
295
|
-
for (const item of
|
|
288
|
+
for (const item of listed.right) {
|
|
296
289
|
registered.push({
|
|
297
|
-
|
|
290
|
+
head: item.commit,
|
|
291
|
+
branch:
|
|
292
|
+
backend.kind === "jj" && "branch" in checkout
|
|
293
|
+
? checkout.branch
|
|
294
|
+
: item.branch,
|
|
298
295
|
path: (yield* fs.exists(item.path))
|
|
299
296
|
? yield* fs.realPath(item.path)
|
|
300
297
|
: resolve(item.path),
|
|
@@ -306,39 +303,36 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
306
303
|
? join(yield* fs.realPath(codePath), checkout.repo)
|
|
307
304
|
: resolve(checkoutPath)
|
|
308
305
|
let atPath = registered.find((item) => item.path === expectedPath)
|
|
306
|
+
if (backend.kind === "jj" && atPath && exists) {
|
|
307
|
+
atPath = {
|
|
308
|
+
...atPath,
|
|
309
|
+
head: yield* backend.workspaceHead(checkoutPath),
|
|
310
|
+
}
|
|
311
|
+
}
|
|
309
312
|
const branchRef =
|
|
310
|
-
"branch" in checkout
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
313
|
+
"branch" in checkout
|
|
314
|
+
? backend.kind === "jj"
|
|
315
|
+
? checkout.branch
|
|
316
|
+
: `refs/heads/${checkout.branch}`
|
|
317
|
+
: null
|
|
318
|
+
const branchElsewhere =
|
|
319
|
+
branchRef && backend.kind !== "jj"
|
|
320
|
+
? registered.find(
|
|
321
|
+
(item) =>
|
|
322
|
+
item.branch === branchRef && item.path !== expectedPath,
|
|
323
|
+
)
|
|
324
|
+
: undefined
|
|
317
325
|
if ("branch" in checkout && !exists && !branchElsewhere) {
|
|
318
|
-
const branch = yield*
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"-C",
|
|
322
|
-
repositoryPath,
|
|
323
|
-
"rev-parse",
|
|
324
|
-
"--verify",
|
|
325
|
-
`${checkout.branch}^{commit}`,
|
|
326
|
-
],
|
|
327
|
-
{ captureOutput: true },
|
|
326
|
+
const branch = yield* backend.resolveRevision(
|
|
327
|
+
repositoryPath,
|
|
328
|
+
checkout.branch,
|
|
328
329
|
)
|
|
329
|
-
if (branch
|
|
330
|
-
const base = yield*
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
"-C",
|
|
334
|
-
repositoryPath,
|
|
335
|
-
"rev-parse",
|
|
336
|
-
"--verify",
|
|
337
|
-
`${data.base}^{commit}`,
|
|
338
|
-
],
|
|
339
|
-
{ captureOutput: true },
|
|
330
|
+
if (!branch) {
|
|
331
|
+
const base = yield* backend.resolveRevision(
|
|
332
|
+
repositoryPath,
|
|
333
|
+
data.base,
|
|
340
334
|
)
|
|
341
|
-
if (base
|
|
335
|
+
if (!base) {
|
|
342
336
|
unresolved.push({
|
|
343
337
|
kind: "unresolved-base",
|
|
344
338
|
target: record.key,
|
|
@@ -404,22 +398,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
404
398
|
})
|
|
405
399
|
}
|
|
406
400
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
"git",
|
|
412
|
-
"-C",
|
|
413
|
-
repositoryPath,
|
|
414
|
-
"rev-parse",
|
|
415
|
-
"--verify",
|
|
416
|
-
`${checkout.ref}^{commit}`,
|
|
417
|
-
],
|
|
418
|
-
{ captureOutput: true },
|
|
419
|
-
)
|
|
420
|
-
if (resolvedRef?.exitCode === 0) {
|
|
421
|
-
resolvedCommit = resolvedRef.stdout.trim()
|
|
422
|
-
}
|
|
401
|
+
resolvedCommit ??= yield* backend.resolveRevision(
|
|
402
|
+
repositoryPath,
|
|
403
|
+
checkout.ref,
|
|
404
|
+
)
|
|
423
405
|
if (!resolvedCommit) {
|
|
424
406
|
unresolved.push({
|
|
425
407
|
kind: "unresolved-reference",
|
|
@@ -431,23 +413,15 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
431
413
|
}
|
|
432
414
|
}
|
|
433
415
|
|
|
434
|
-
const
|
|
416
|
+
const dirty =
|
|
435
417
|
exists && atPath
|
|
436
|
-
? yield*
|
|
437
|
-
["git", "-C", checkoutPath, "status", "--porcelain"],
|
|
438
|
-
{ captureOutput: true },
|
|
439
|
-
)
|
|
440
|
-
: null
|
|
441
|
-
const dirty = dirtyResult
|
|
442
|
-
? dirtyResult.exitCode === 0
|
|
443
|
-
? dirtyResult.stdout.length > 0
|
|
418
|
+
? yield* backend.workspaceDirty(checkoutPath)
|
|
444
419
|
: null
|
|
445
|
-
|
|
446
|
-
if (dirtyResult && dirtyResult.exitCode !== 0) {
|
|
420
|
+
if (exists && atPath && dirty === null) {
|
|
447
421
|
warnings.push({
|
|
448
422
|
kind: "status-inspection-failed",
|
|
449
423
|
target: record.key,
|
|
450
|
-
message: `Could not inspect dirtiness for ${checkoutPath}
|
|
424
|
+
message: `Could not inspect dirtiness for ${checkoutPath}`,
|
|
451
425
|
action: "Inspect the checkout manually before changing it",
|
|
452
426
|
})
|
|
453
427
|
}
|
|
@@ -607,15 +581,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
607
581
|
let prConflict = false
|
|
608
582
|
const repositoryPath = join(root, "repos", data.repo)
|
|
609
583
|
const remoteName = config.delivery?.remote ?? "origin"
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
"-C",
|
|
613
|
-
repositoryPath,
|
|
614
|
-
"remote",
|
|
615
|
-
"get-url",
|
|
616
|
-
remoteName,
|
|
617
|
-
])
|
|
618
|
-
const remoteRepository = remote.stdout
|
|
584
|
+
const remoteUrl = yield* backend.remoteUrl(repositoryPath, remoteName)
|
|
585
|
+
const remoteRepository = (remoteUrl ?? "")
|
|
619
586
|
.trim()
|
|
620
587
|
.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
|
|
621
588
|
.replace(/^[^:]+:/, "")
|
|
@@ -635,11 +602,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
635
602
|
})
|
|
636
603
|
}
|
|
637
604
|
|
|
638
|
-
if (config.delivery &&
|
|
605
|
+
if (config.delivery && !remoteUrl) {
|
|
639
606
|
warnings.push({
|
|
640
607
|
kind: "delivery-remote-unavailable",
|
|
641
608
|
target: record.key,
|
|
642
|
-
message: `Could not inspect delivery remote '${remoteName}'
|
|
609
|
+
message: `Could not inspect delivery remote '${remoteName}'`,
|
|
643
610
|
})
|
|
644
611
|
} else if (config.delivery) {
|
|
645
612
|
const resolved = resolveDeliveryCommand(config.delivery, "query", {
|
|
@@ -7,6 +7,13 @@ import { EpicService } from "./EpicService"
|
|
|
7
7
|
import { TaskService } from "./TaskService"
|
|
8
8
|
import { PhaseService } from "./PhaseService"
|
|
9
9
|
import { PullRequestService } from "./PullRequestService"
|
|
10
|
+
import { WorktreeService } from "./WorktreeService"
|
|
11
|
+
|
|
12
|
+
const run = async (args: string[], cwd?: string) => {
|
|
13
|
+
const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
14
|
+
const exitCode = await child.exited
|
|
15
|
+
if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
|
|
16
|
+
}
|
|
10
17
|
|
|
11
18
|
describe("task and phase services", () => {
|
|
12
19
|
let root: string
|
|
@@ -283,6 +290,79 @@ describe("task and phase services", () => {
|
|
|
283
290
|
})
|
|
284
291
|
})
|
|
285
292
|
|
|
293
|
+
test("recreates jj workspaces when converting a task to phases", async () => {
|
|
294
|
+
if (!Bun.which("jj")) return
|
|
295
|
+
const source = join(root, "source")
|
|
296
|
+
const repository = join(root, "repos/agency")
|
|
297
|
+
await rm(repository, { recursive: true, force: true })
|
|
298
|
+
await mkdir(source)
|
|
299
|
+
await run(["git", "init", "--initial-branch=main"], source)
|
|
300
|
+
await run(["git", "config", "user.email", "test@example.com"], source)
|
|
301
|
+
await run(["git", "config", "user.name", "Test"], source)
|
|
302
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
303
|
+
await run(["git", "add", "README.md"], source)
|
|
304
|
+
await run(["git", "commit", "-m", "initial"], source)
|
|
305
|
+
await run(["git", "clone", source, repository])
|
|
306
|
+
await run(["jj", "git", "init", "--colocate", repository])
|
|
307
|
+
await Bun.write(
|
|
308
|
+
join(root, "agency.json"),
|
|
309
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
310
|
+
)
|
|
311
|
+
await runTestEffect(
|
|
312
|
+
TaskService.pipe(
|
|
313
|
+
Effect.flatMap((service) =>
|
|
314
|
+
service.create(
|
|
315
|
+
{
|
|
316
|
+
id: "jj-single",
|
|
317
|
+
ticketUrl: null,
|
|
318
|
+
repo: "agency",
|
|
319
|
+
branch: "task/jj-single",
|
|
320
|
+
base: "main",
|
|
321
|
+
},
|
|
322
|
+
root,
|
|
323
|
+
),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
const workspace = await runTestEffect(
|
|
328
|
+
WorktreeService.pipe(
|
|
329
|
+
Effect.flatMap((service) =>
|
|
330
|
+
service.materialize("jj-single", undefined, root),
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
await runTestEffect(
|
|
336
|
+
PhaseService.pipe(
|
|
337
|
+
Effect.flatMap((service) =>
|
|
338
|
+
service.create(
|
|
339
|
+
{
|
|
340
|
+
taskId: "jj-single",
|
|
341
|
+
id: "extra",
|
|
342
|
+
firstPhase: "implementation",
|
|
343
|
+
repo: "agency",
|
|
344
|
+
branch: "task/extra",
|
|
345
|
+
base: "main",
|
|
346
|
+
},
|
|
347
|
+
root,
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const moved = join(
|
|
354
|
+
root,
|
|
355
|
+
"tasks/jj-single/phases/implementation/code/agency",
|
|
356
|
+
)
|
|
357
|
+
expect(await Bun.file(workspace.codePath).exists()).toBe(false)
|
|
358
|
+
expect(await Bun.file(join(moved, "README.md")).text()).toBe("example\n")
|
|
359
|
+
const listed = Bun.spawnSync(
|
|
360
|
+
["jj", "-R", repository, "workspace", "list", "-T", 'root ++ "\\n"'],
|
|
361
|
+
{ stdout: "pipe" },
|
|
362
|
+
)
|
|
363
|
+
expect(new TextDecoder().decode(listed.stdout)).toContain(moved)
|
|
364
|
+
})
|
|
365
|
+
|
|
286
366
|
test("preserves non-PR completion when converting a task to phases", async () => {
|
|
287
367
|
await runTestEffect(
|
|
288
368
|
TaskService.pipe(
|