@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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir, realpath, stat } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { VersionControlService } from "./VersionControlService"
|
|
7
|
+
import { preferredVersionControl } from "../workbase/version-control"
|
|
8
|
+
|
|
9
|
+
const run = async (args: string[], cwd?: string) => {
|
|
10
|
+
const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
11
|
+
const exitCode = await child.exited
|
|
12
|
+
if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("VersionControlService", () => {
|
|
16
|
+
const roots: string[] = []
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("prefers jj when available and falls back to Git", () => {
|
|
23
|
+
expect(preferredVersionControl(() => "/usr/bin/jj")).toBe("jj")
|
|
24
|
+
expect(preferredVersionControl(() => null)).toBe("git")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("selects the backend persisted by the workbase", async () => {
|
|
28
|
+
for (const kind of ["git", "jj"] as const) {
|
|
29
|
+
const root = await createTempDir()
|
|
30
|
+
roots.push(root)
|
|
31
|
+
await Bun.write(
|
|
32
|
+
join(root, "agency.json"),
|
|
33
|
+
JSON.stringify({ version: 2, vcs: kind }),
|
|
34
|
+
)
|
|
35
|
+
const selected = await runTestEffect(
|
|
36
|
+
VersionControlService.pipe(
|
|
37
|
+
Effect.flatMap((service) => service.forWorkbase(root)),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
expect(selected.kind).toBe(kind)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("initializes and manages jj workspaces", async () => {
|
|
45
|
+
if (!Bun.which("jj")) return
|
|
46
|
+
const root = await createTempDir()
|
|
47
|
+
roots.push(root)
|
|
48
|
+
await Bun.write(
|
|
49
|
+
join(root, "agency.json"),
|
|
50
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
51
|
+
)
|
|
52
|
+
const repository = join(root, "repository")
|
|
53
|
+
const workspace = join(root, "workspace")
|
|
54
|
+
await mkdir(repository)
|
|
55
|
+
await run(["git", "init", "--initial-branch=main"], repository)
|
|
56
|
+
await run(["git", "config", "user.email", "test@example.com"], repository)
|
|
57
|
+
await run(["git", "config", "user.name", "Test"], repository)
|
|
58
|
+
await Bun.write(join(repository, "README.md"), "example\n")
|
|
59
|
+
await run(["git", "add", "README.md"], repository)
|
|
60
|
+
await run(["git", "commit", "-m", "initial"], repository)
|
|
61
|
+
|
|
62
|
+
const backend = await runTestEffect(
|
|
63
|
+
VersionControlService.pipe(
|
|
64
|
+
Effect.flatMap((service) => service.forWorkbase(root)),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
await runTestEffect(backend.initializeRepository(repository))
|
|
68
|
+
expect((await stat(join(repository, ".jj"))).isDirectory()).toBe(true)
|
|
69
|
+
|
|
70
|
+
const revision = await runTestEffect(
|
|
71
|
+
backend.resolveRevision(repository, "main"),
|
|
72
|
+
)
|
|
73
|
+
expect(revision).toMatch(/^[0-9a-f]{40}$/)
|
|
74
|
+
await runTestEffect(
|
|
75
|
+
backend.createWorkspace({
|
|
76
|
+
repositoryPath: repository,
|
|
77
|
+
workspacePath: workspace,
|
|
78
|
+
workspaceName: "agency-test",
|
|
79
|
+
revision: revision!,
|
|
80
|
+
branch: "task/test",
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
const canonicalWorkspace = await realpath(workspace)
|
|
84
|
+
expect(
|
|
85
|
+
(await runTestEffect(backend.listWorkspaces(repository))).some(
|
|
86
|
+
(item) =>
|
|
87
|
+
item.name === "agency-test" && item.path === canonicalWorkspace,
|
|
88
|
+
),
|
|
89
|
+
).toBe(true)
|
|
90
|
+
|
|
91
|
+
await runTestEffect(
|
|
92
|
+
backend.removeWorkspace({
|
|
93
|
+
repositoryPath: repository,
|
|
94
|
+
workspacePath: workspace,
|
|
95
|
+
workspaceName: "agency-test",
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
expect(await Bun.file(workspace).exists()).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { FileSystemService } from "./FileSystemService"
|
|
3
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
4
|
+
import type { VersionControlKind } from "../workbase/version-control"
|
|
5
|
+
|
|
6
|
+
interface RegisteredWorkspace {
|
|
7
|
+
readonly name: string | null
|
|
8
|
+
readonly path: string
|
|
9
|
+
readonly commit: string | null
|
|
10
|
+
readonly branch: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface VersionControlBackend {
|
|
14
|
+
readonly kind: VersionControlKind
|
|
15
|
+
readonly initializeRepository: (
|
|
16
|
+
path: string,
|
|
17
|
+
) => Effect.Effect<void, unknown, any>
|
|
18
|
+
readonly listWorkspaces: (
|
|
19
|
+
repositoryPath: string,
|
|
20
|
+
) => Effect.Effect<readonly RegisteredWorkspace[], unknown, any>
|
|
21
|
+
readonly resolveRevision: (
|
|
22
|
+
repositoryPath: string,
|
|
23
|
+
revision: string,
|
|
24
|
+
) => Effect.Effect<string | null, unknown, any>
|
|
25
|
+
readonly workspaceHead: (
|
|
26
|
+
workspacePath: string,
|
|
27
|
+
) => Effect.Effect<string | null, unknown, any>
|
|
28
|
+
readonly workspaceDirty: (
|
|
29
|
+
workspacePath: string,
|
|
30
|
+
) => Effect.Effect<boolean | null, unknown, any>
|
|
31
|
+
readonly createWorkspace: (options: {
|
|
32
|
+
readonly repositoryPath: string
|
|
33
|
+
readonly workspacePath: string
|
|
34
|
+
readonly workspaceName: string
|
|
35
|
+
readonly revision: string
|
|
36
|
+
readonly branch?: string
|
|
37
|
+
}) => Effect.Effect<void, unknown, any>
|
|
38
|
+
readonly removeWorkspace: (options: {
|
|
39
|
+
readonly repositoryPath: string
|
|
40
|
+
readonly workspacePath: string
|
|
41
|
+
readonly workspaceName: string | null
|
|
42
|
+
}) => Effect.Effect<void, unknown, any>
|
|
43
|
+
readonly fetch: (
|
|
44
|
+
repositoryPath: string,
|
|
45
|
+
remote?: string,
|
|
46
|
+
branch?: string,
|
|
47
|
+
) => Effect.Effect<void, unknown, any>
|
|
48
|
+
readonly push: (
|
|
49
|
+
workspacePath: string,
|
|
50
|
+
remote: string,
|
|
51
|
+
branch: string,
|
|
52
|
+
) => Effect.Effect<void, unknown, any>
|
|
53
|
+
readonly remoteUrl: (
|
|
54
|
+
repositoryPath: string,
|
|
55
|
+
remote: string,
|
|
56
|
+
) => Effect.Effect<string | null, unknown, any>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class VersionControlError extends Data.TaggedError("VersionControlError")<{
|
|
60
|
+
readonly message: string
|
|
61
|
+
}> {}
|
|
62
|
+
|
|
63
|
+
const requireSuccess = (
|
|
64
|
+
label: string,
|
|
65
|
+
effect: Effect.Effect<
|
|
66
|
+
{
|
|
67
|
+
readonly exitCode: number
|
|
68
|
+
readonly stdout: string
|
|
69
|
+
readonly stderr: string
|
|
70
|
+
},
|
|
71
|
+
unknown,
|
|
72
|
+
any
|
|
73
|
+
>,
|
|
74
|
+
) =>
|
|
75
|
+
effect.pipe(
|
|
76
|
+
Effect.flatMap((result) =>
|
|
77
|
+
result.exitCode === 0
|
|
78
|
+
? Effect.succeed(result)
|
|
79
|
+
: Effect.fail(
|
|
80
|
+
new VersionControlError({
|
|
81
|
+
message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const parseGitWorktrees = (output: string): readonly RegisteredWorkspace[] => {
|
|
88
|
+
const workspaces: RegisteredWorkspace[] = []
|
|
89
|
+
let current: RegisteredWorkspace | null = null
|
|
90
|
+
for (const field of output.split("\0")) {
|
|
91
|
+
if (field.startsWith("worktree ")) {
|
|
92
|
+
if (current) workspaces.push(current)
|
|
93
|
+
current = {
|
|
94
|
+
name: null,
|
|
95
|
+
path: field.slice("worktree ".length),
|
|
96
|
+
commit: null,
|
|
97
|
+
branch: null,
|
|
98
|
+
}
|
|
99
|
+
} else if (current && field.startsWith("HEAD ")) {
|
|
100
|
+
const workspace: RegisteredWorkspace = current
|
|
101
|
+
current = { ...workspace, commit: field.slice("HEAD ".length) }
|
|
102
|
+
} else if (current && field.startsWith("branch ")) {
|
|
103
|
+
const workspace: RegisteredWorkspace = current
|
|
104
|
+
current = {
|
|
105
|
+
...workspace,
|
|
106
|
+
branch: field.slice("branch ".length),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (current) workspaces.push(current)
|
|
111
|
+
return workspaces
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class GitVersionControlService extends Effect.Service<GitVersionControlService>()(
|
|
115
|
+
"GitVersionControlService",
|
|
116
|
+
{
|
|
117
|
+
sync: () =>
|
|
118
|
+
({
|
|
119
|
+
kind: "git",
|
|
120
|
+
initializeRepository: () => Effect.void,
|
|
121
|
+
listWorkspaces: (repositoryPath) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
const fs = yield* FileSystemService
|
|
124
|
+
const result = yield* requireSuccess(
|
|
125
|
+
"Failed to inspect Git worktrees",
|
|
126
|
+
fs.runCommand(
|
|
127
|
+
[
|
|
128
|
+
"git",
|
|
129
|
+
"-C",
|
|
130
|
+
repositoryPath,
|
|
131
|
+
"worktree",
|
|
132
|
+
"list",
|
|
133
|
+
"--porcelain",
|
|
134
|
+
"-z",
|
|
135
|
+
],
|
|
136
|
+
{ captureOutput: true },
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
return parseGitWorktrees(result.stdout)
|
|
140
|
+
}),
|
|
141
|
+
resolveRevision: (repositoryPath, revision) =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
const fs = yield* FileSystemService
|
|
144
|
+
const result = yield* fs.runCommand(
|
|
145
|
+
[
|
|
146
|
+
"git",
|
|
147
|
+
"-C",
|
|
148
|
+
repositoryPath,
|
|
149
|
+
"rev-parse",
|
|
150
|
+
"--verify",
|
|
151
|
+
`${revision}^{commit}`,
|
|
152
|
+
],
|
|
153
|
+
{ captureOutput: true },
|
|
154
|
+
)
|
|
155
|
+
return result.exitCode === 0 ? result.stdout.trim() : null
|
|
156
|
+
}),
|
|
157
|
+
workspaceHead: (workspacePath) =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const fs = yield* FileSystemService
|
|
160
|
+
const result = yield* fs.runCommand(
|
|
161
|
+
["git", "-C", workspacePath, "rev-parse", "HEAD"],
|
|
162
|
+
{ captureOutput: true },
|
|
163
|
+
)
|
|
164
|
+
return result.exitCode === 0 ? result.stdout.trim() : null
|
|
165
|
+
}),
|
|
166
|
+
workspaceDirty: (workspacePath) =>
|
|
167
|
+
Effect.gen(function* () {
|
|
168
|
+
const fs = yield* FileSystemService
|
|
169
|
+
const result = yield* fs.runCommand(
|
|
170
|
+
["git", "-C", workspacePath, "status", "--porcelain"],
|
|
171
|
+
{ captureOutput: true },
|
|
172
|
+
)
|
|
173
|
+
return result.exitCode === 0 ? result.stdout.length > 0 : null
|
|
174
|
+
}),
|
|
175
|
+
createWorkspace: (options) =>
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
const fs = yield* FileSystemService
|
|
178
|
+
const command = options.branch
|
|
179
|
+
? [
|
|
180
|
+
"git",
|
|
181
|
+
"-C",
|
|
182
|
+
options.repositoryPath,
|
|
183
|
+
"worktree",
|
|
184
|
+
"add",
|
|
185
|
+
"-b",
|
|
186
|
+
options.branch,
|
|
187
|
+
options.workspacePath,
|
|
188
|
+
options.revision,
|
|
189
|
+
]
|
|
190
|
+
: [
|
|
191
|
+
"git",
|
|
192
|
+
"-C",
|
|
193
|
+
options.repositoryPath,
|
|
194
|
+
"worktree",
|
|
195
|
+
"add",
|
|
196
|
+
"--detach",
|
|
197
|
+
options.workspacePath,
|
|
198
|
+
options.revision,
|
|
199
|
+
]
|
|
200
|
+
yield* requireSuccess(
|
|
201
|
+
"Failed to create Git worktree",
|
|
202
|
+
fs.runCommand(command, { captureOutput: true }),
|
|
203
|
+
)
|
|
204
|
+
}),
|
|
205
|
+
removeWorkspace: (options) =>
|
|
206
|
+
Effect.gen(function* () {
|
|
207
|
+
const fs = yield* FileSystemService
|
|
208
|
+
yield* requireSuccess(
|
|
209
|
+
"Failed to remove Git worktree",
|
|
210
|
+
fs.runCommand(
|
|
211
|
+
[
|
|
212
|
+
"git",
|
|
213
|
+
"-C",
|
|
214
|
+
options.repositoryPath,
|
|
215
|
+
"worktree",
|
|
216
|
+
"remove",
|
|
217
|
+
options.workspacePath,
|
|
218
|
+
],
|
|
219
|
+
{ captureOutput: true },
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
}),
|
|
223
|
+
fetch: (repositoryPath, remote = "origin", branch) =>
|
|
224
|
+
Effect.gen(function* () {
|
|
225
|
+
const fs = yield* FileSystemService
|
|
226
|
+
yield* requireSuccess(
|
|
227
|
+
"Failed to fetch Git repository",
|
|
228
|
+
fs.runCommand(
|
|
229
|
+
[
|
|
230
|
+
"git",
|
|
231
|
+
"-C",
|
|
232
|
+
repositoryPath,
|
|
233
|
+
"fetch",
|
|
234
|
+
remote,
|
|
235
|
+
...(branch ? [branch] : []),
|
|
236
|
+
],
|
|
237
|
+
{ captureOutput: true },
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
}),
|
|
241
|
+
push: (workspacePath, remote, branch) =>
|
|
242
|
+
Effect.gen(function* () {
|
|
243
|
+
const fs = yield* FileSystemService
|
|
244
|
+
yield* requireSuccess(
|
|
245
|
+
"Failed to push branch",
|
|
246
|
+
fs.runCommand(
|
|
247
|
+
[
|
|
248
|
+
"git",
|
|
249
|
+
"-C",
|
|
250
|
+
workspacePath,
|
|
251
|
+
"push",
|
|
252
|
+
"--set-upstream",
|
|
253
|
+
remote,
|
|
254
|
+
branch,
|
|
255
|
+
],
|
|
256
|
+
{ captureOutput: true },
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
}),
|
|
260
|
+
remoteUrl: (repositoryPath, remote) =>
|
|
261
|
+
Effect.gen(function* () {
|
|
262
|
+
const fs = yield* FileSystemService
|
|
263
|
+
const result = yield* fs.runCommand(
|
|
264
|
+
["git", "-C", repositoryPath, "remote", "get-url", remote],
|
|
265
|
+
{ captureOutput: true },
|
|
266
|
+
)
|
|
267
|
+
return result.exitCode === 0 ? result.stdout.trim() : null
|
|
268
|
+
}),
|
|
269
|
+
}) satisfies VersionControlBackend,
|
|
270
|
+
},
|
|
271
|
+
) {}
|
|
272
|
+
|
|
273
|
+
const jjCommand = (repositoryPath: string, args: readonly string[]) => [
|
|
274
|
+
"jj",
|
|
275
|
+
"-R",
|
|
276
|
+
repositoryPath,
|
|
277
|
+
"--no-pager",
|
|
278
|
+
...args,
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
export class JjVersionControlService extends Effect.Service<JjVersionControlService>()(
|
|
282
|
+
"JjVersionControlService",
|
|
283
|
+
{
|
|
284
|
+
sync: () =>
|
|
285
|
+
({
|
|
286
|
+
kind: "jj",
|
|
287
|
+
initializeRepository: (path) =>
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
const fs = yield* FileSystemService
|
|
290
|
+
if (yield* fs.exists(`${path}/.jj`)) return
|
|
291
|
+
yield* requireSuccess(
|
|
292
|
+
"Failed to initialize jj repository",
|
|
293
|
+
fs.runCommand(["jj", "git", "init", "--colocate", path], {
|
|
294
|
+
captureOutput: true,
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
}),
|
|
298
|
+
listWorkspaces: (repositoryPath) =>
|
|
299
|
+
Effect.gen(function* () {
|
|
300
|
+
const fs = yield* FileSystemService
|
|
301
|
+
const result = yield* requireSuccess(
|
|
302
|
+
"Failed to inspect jj workspaces",
|
|
303
|
+
fs.runCommand(
|
|
304
|
+
jjCommand(repositoryPath, [
|
|
305
|
+
"workspace",
|
|
306
|
+
"list",
|
|
307
|
+
"-T",
|
|
308
|
+
'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\n"',
|
|
309
|
+
]),
|
|
310
|
+
{ captureOutput: true },
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
return result.stdout
|
|
314
|
+
.trim()
|
|
315
|
+
.split("\n")
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.map((line) => {
|
|
318
|
+
const [name, path, commit] = line.split("\t")
|
|
319
|
+
return {
|
|
320
|
+
name: name || null,
|
|
321
|
+
path: path!,
|
|
322
|
+
commit: commit || null,
|
|
323
|
+
branch: null,
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
}),
|
|
327
|
+
resolveRevision: (repositoryPath, revision) =>
|
|
328
|
+
Effect.gen(function* () {
|
|
329
|
+
const fs = yield* FileSystemService
|
|
330
|
+
const result = yield* fs.runCommand(
|
|
331
|
+
jjCommand(repositoryPath, [
|
|
332
|
+
"log",
|
|
333
|
+
"--ignore-working-copy",
|
|
334
|
+
"--no-graph",
|
|
335
|
+
"-r",
|
|
336
|
+
revision,
|
|
337
|
+
"-T",
|
|
338
|
+
'commit_id ++ "\\n"',
|
|
339
|
+
]),
|
|
340
|
+
{ captureOutput: true },
|
|
341
|
+
)
|
|
342
|
+
return result.exitCode === 0 ? result.stdout.trim() || null : null
|
|
343
|
+
}),
|
|
344
|
+
workspaceHead: (workspacePath) =>
|
|
345
|
+
Effect.gen(function* () {
|
|
346
|
+
const fs = yield* FileSystemService
|
|
347
|
+
const result = yield* fs.runCommand(
|
|
348
|
+
jjCommand(workspacePath, [
|
|
349
|
+
"log",
|
|
350
|
+
"--ignore-working-copy",
|
|
351
|
+
"--no-graph",
|
|
352
|
+
"-r",
|
|
353
|
+
"@-",
|
|
354
|
+
"-T",
|
|
355
|
+
'commit_id ++ "\\n"',
|
|
356
|
+
]),
|
|
357
|
+
{ captureOutput: true },
|
|
358
|
+
)
|
|
359
|
+
return result.exitCode === 0 ? result.stdout.trim() || null : null
|
|
360
|
+
}),
|
|
361
|
+
workspaceDirty: (workspacePath) =>
|
|
362
|
+
Effect.gen(function* () {
|
|
363
|
+
const fs = yield* FileSystemService
|
|
364
|
+
const result = yield* fs.runCommand(
|
|
365
|
+
jjCommand(workspacePath, ["diff", "--summary", "-r", "@"]),
|
|
366
|
+
{ captureOutput: true },
|
|
367
|
+
)
|
|
368
|
+
return result.exitCode === 0 ? result.stdout.length > 0 : null
|
|
369
|
+
}),
|
|
370
|
+
createWorkspace: (options) =>
|
|
371
|
+
Effect.gen(function* () {
|
|
372
|
+
const fs = yield* FileSystemService
|
|
373
|
+
yield* requireSuccess(
|
|
374
|
+
"Failed to create jj workspace",
|
|
375
|
+
fs.runCommand(
|
|
376
|
+
jjCommand(options.repositoryPath, [
|
|
377
|
+
"workspace",
|
|
378
|
+
"add",
|
|
379
|
+
"--name",
|
|
380
|
+
options.workspaceName,
|
|
381
|
+
"-r",
|
|
382
|
+
options.revision,
|
|
383
|
+
options.workspacePath,
|
|
384
|
+
]),
|
|
385
|
+
{ captureOutput: true },
|
|
386
|
+
),
|
|
387
|
+
)
|
|
388
|
+
}),
|
|
389
|
+
removeWorkspace: (options) =>
|
|
390
|
+
Effect.gen(function* () {
|
|
391
|
+
const fs = yield* FileSystemService
|
|
392
|
+
if (!options.workspaceName) {
|
|
393
|
+
return yield* new VersionControlError({
|
|
394
|
+
message: `Cannot remove unregistered jj workspace ${options.workspacePath}`,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
yield* requireSuccess(
|
|
398
|
+
"Failed to forget jj workspace",
|
|
399
|
+
fs.runCommand(
|
|
400
|
+
jjCommand(options.repositoryPath, [
|
|
401
|
+
"workspace",
|
|
402
|
+
"forget",
|
|
403
|
+
options.workspaceName,
|
|
404
|
+
]),
|
|
405
|
+
{ captureOutput: true },
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
yield* fs.deleteDirectory(options.workspacePath)
|
|
409
|
+
}),
|
|
410
|
+
fetch: (repositoryPath, remote = "origin", branch) =>
|
|
411
|
+
Effect.gen(function* () {
|
|
412
|
+
const fs = yield* FileSystemService
|
|
413
|
+
yield* requireSuccess(
|
|
414
|
+
"Failed to fetch jj repository",
|
|
415
|
+
fs.runCommand(
|
|
416
|
+
jjCommand(repositoryPath, [
|
|
417
|
+
"git",
|
|
418
|
+
"fetch",
|
|
419
|
+
"--remote",
|
|
420
|
+
remote,
|
|
421
|
+
...(branch ? ["--branch", branch] : []),
|
|
422
|
+
]),
|
|
423
|
+
{ captureOutput: true },
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
}),
|
|
427
|
+
push: (workspacePath, remote, branch) =>
|
|
428
|
+
Effect.gen(function* () {
|
|
429
|
+
const fs = yield* FileSystemService
|
|
430
|
+
yield* requireSuccess(
|
|
431
|
+
"Failed to push branch",
|
|
432
|
+
fs.runCommand(
|
|
433
|
+
jjCommand(workspacePath, [
|
|
434
|
+
"git",
|
|
435
|
+
"push",
|
|
436
|
+
"--remote",
|
|
437
|
+
remote,
|
|
438
|
+
"--named",
|
|
439
|
+
`${branch}=@-`,
|
|
440
|
+
]),
|
|
441
|
+
{ captureOutput: true },
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
}),
|
|
445
|
+
remoteUrl: (repositoryPath, remote) =>
|
|
446
|
+
Effect.gen(function* () {
|
|
447
|
+
const fs = yield* FileSystemService
|
|
448
|
+
const result = yield* fs.runCommand(
|
|
449
|
+
jjCommand(repositoryPath, ["git", "remote", "list"]),
|
|
450
|
+
{ captureOutput: true },
|
|
451
|
+
)
|
|
452
|
+
if (result.exitCode !== 0) return null
|
|
453
|
+
return (
|
|
454
|
+
result.stdout
|
|
455
|
+
.split("\n")
|
|
456
|
+
.find((line) => line.startsWith(`${remote} `))
|
|
457
|
+
?.slice(remote.length + 1)
|
|
458
|
+
.trim() ?? null
|
|
459
|
+
)
|
|
460
|
+
}),
|
|
461
|
+
}) satisfies VersionControlBackend,
|
|
462
|
+
},
|
|
463
|
+
) {}
|
|
464
|
+
|
|
465
|
+
export class VersionControlService extends Effect.Service<VersionControlService>()(
|
|
466
|
+
"VersionControlService",
|
|
467
|
+
{
|
|
468
|
+
sync: () => ({
|
|
469
|
+
forWorkbase: (root: string) =>
|
|
470
|
+
Effect.gen(function* () {
|
|
471
|
+
const workbase = yield* WorkbaseService
|
|
472
|
+
const git = yield* GitVersionControlService
|
|
473
|
+
const jj = yield* JjVersionControlService
|
|
474
|
+
const { config } = yield* workbase.loadConfig(root)
|
|
475
|
+
return config.vcs === "jj" ? jj : git
|
|
476
|
+
}),
|
|
477
|
+
}),
|
|
478
|
+
},
|
|
479
|
+
) {}
|
|
@@ -23,6 +23,7 @@ import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
|
23
23
|
import { validateRunners } from "../workbase/runner-command"
|
|
24
24
|
import { findDependencyCycles } from "../workbase/dependency-graph"
|
|
25
25
|
import { validateDelivery } from "../workbase/delivery-command"
|
|
26
|
+
import { preferredVersionControl } from "../workbase/version-control"
|
|
26
27
|
|
|
27
28
|
class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
|
|
28
29
|
readonly message: string
|
|
@@ -189,7 +190,10 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
yield* fs.createDirectory(root)
|
|
192
|
-
yield* fs.writeJSON(configPath, {
|
|
193
|
+
yield* fs.writeJSON(configPath, {
|
|
194
|
+
version: 2,
|
|
195
|
+
vcs: preferredVersionControl(),
|
|
196
|
+
})
|
|
193
197
|
for (const directory of ["repos", "epics", "tasks"]) {
|
|
194
198
|
yield* fs.createDirectory(join(root, directory))
|
|
195
199
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir, realpath, rename, rm } from "node:fs/promises"
|
|
3
|
+
import { mkdir, realpath, rename, rm, stat } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import {
|
|
6
6
|
captureErrors,
|
|
@@ -23,6 +23,17 @@ const git = async (args: string[], cwd?: string) => {
|
|
|
23
23
|
throw new Error(await new Response(process.stderr).text())
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const jj = async (args: string[], cwd?: string) => {
|
|
27
|
+
const process = Bun.spawn(["jj", ...args], {
|
|
28
|
+
cwd,
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
})
|
|
32
|
+
await process.exited
|
|
33
|
+
if (process.exitCode !== 0)
|
|
34
|
+
throw new Error(await new Response(process.stderr).text())
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
describe("WorktreeService", () => {
|
|
27
38
|
let root: string
|
|
28
39
|
let source: string
|
|
@@ -93,6 +104,66 @@ describe("WorktreeService", () => {
|
|
|
93
104
|
expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
|
|
94
105
|
})
|
|
95
106
|
|
|
107
|
+
test("materializes and removes a jj workspace for a jj workbase", async () => {
|
|
108
|
+
if (!Bun.which("jj")) return
|
|
109
|
+
const repository = join(root, "repos/agency")
|
|
110
|
+
await rm(repository, { recursive: true, force: true })
|
|
111
|
+
await git(["clone", source, repository])
|
|
112
|
+
await jj(["git", "init", "--colocate", repository])
|
|
113
|
+
await Bun.write(
|
|
114
|
+
join(root, "agency.json"),
|
|
115
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
116
|
+
)
|
|
117
|
+
await runTestEffect(
|
|
118
|
+
TaskService.pipe(
|
|
119
|
+
Effect.flatMap((service) =>
|
|
120
|
+
service.create(
|
|
121
|
+
{
|
|
122
|
+
id: "jj-example",
|
|
123
|
+
ticketUrl: null,
|
|
124
|
+
repo: "agency",
|
|
125
|
+
branch: "task/jj-example",
|
|
126
|
+
base: "main",
|
|
127
|
+
},
|
|
128
|
+
root,
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const workspace = await runTestEffect(
|
|
135
|
+
WorktreeService.pipe(
|
|
136
|
+
Effect.flatMap((service) =>
|
|
137
|
+
service.materialize("jj-example", undefined, root),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
const jjMetadata = await stat(join(workspace.writablePath!, ".jj"))
|
|
142
|
+
expect(jjMetadata.isFile() || jjMetadata.isDirectory()).toBe(true)
|
|
143
|
+
const inspection = await runTestEffect(
|
|
144
|
+
WorktreeService.pipe(
|
|
145
|
+
Effect.flatMap((service) =>
|
|
146
|
+
service.inspect("jj-example", undefined, root),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
expect(inspection.conflicts).toEqual([])
|
|
151
|
+
expect(inspection.checkouts[0]).toMatchObject({
|
|
152
|
+
exists: true,
|
|
153
|
+
registered: true,
|
|
154
|
+
actualBranch: "task/jj-example",
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
await runTestEffect(
|
|
158
|
+
WorktreeService.pipe(
|
|
159
|
+
Effect.flatMap((service) =>
|
|
160
|
+
service.remove("jj-example", undefined, root),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
|
|
96
167
|
test("does not fetch the origin for an existing writable worktree", async () => {
|
|
97
168
|
await runTestEffect(
|
|
98
169
|
TaskService.pipe(
|