@markjaquith/agency 2.25.0 → 2.27.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.
@@ -51,6 +51,54 @@ export const workbase = (options: WorkbaseOptions) =>
51
51
  }
52
52
  return
53
53
  }
54
+ case "show": {
55
+ const selector = options.args[0]
56
+ if (!selector) {
57
+ return yield* Effect.fail(
58
+ new Error("Usage: agency workbase show <selector>"),
59
+ )
60
+ }
61
+ const entry = yield* service.showRegistered(
62
+ selector,
63
+ options.configDirectory,
64
+ options.cwd,
65
+ )
66
+ log(
67
+ options.json
68
+ ? JSON.stringify(entry, null, 2)
69
+ : `${entry.name ?? entry.id}\t${entry.path}`,
70
+ )
71
+ return
72
+ }
73
+ case "name": {
74
+ const selector = options.args[0]
75
+ const name = options.clear ? null : options.args[1]
76
+ if (
77
+ !selector ||
78
+ name === undefined ||
79
+ (options.clear && options.args[1] !== undefined)
80
+ ) {
81
+ return yield* Effect.fail(
82
+ new Error(
83
+ "Usage: agency workbase name <selector> <name> | --clear",
84
+ ),
85
+ )
86
+ }
87
+ const entry = yield* service.nameRegistered(
88
+ selector,
89
+ name,
90
+ options.configDirectory,
91
+ options.cwd,
92
+ )
93
+ log(
94
+ options.json
95
+ ? JSON.stringify(entry, null, 2)
96
+ : name === null
97
+ ? `Cleared name for ${entry.id}`
98
+ : `Named workbase ${name}`,
99
+ )
100
+ return
101
+ }
54
102
  case "remove": {
55
103
  const entry = yield* service.removeRegistered(
56
104
  options.args[0]!,
@@ -89,6 +137,7 @@ export const workbase = (options: WorkbaseOptions) =>
89
137
  const entry = yield* service.setDefault(
90
138
  selector,
91
139
  options.configDirectory,
140
+ options.cwd,
92
141
  )
93
142
  log(
94
143
  options.json
@@ -102,7 +151,7 @@ export const workbase = (options: WorkbaseOptions) =>
102
151
  default:
103
152
  return yield* Effect.fail(
104
153
  new Error(
105
- "Subcommand is required. Available: add, list, remove, prune, default",
154
+ "Subcommand is required. Available: add, list, show, name, remove, prune, default",
106
155
  ),
107
156
  )
108
157
  }
@@ -114,6 +163,8 @@ Usage: agency workbase <subcommand>
114
163
  Subcommands:
115
164
  add <path> Register an Agency workbase
116
165
  list List registered workbases
166
+ show <selector> Show a registered workbase
167
+ name <selector> Set or clear a registered workbase name
117
168
  remove <selector> Remove a registered workbase
118
169
  prune Remove registrations whose paths no longer exist
119
170
  default [selector] Show or set the default workbase
@@ -0,0 +1,138 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { createLoggers } from "../utils/effect"
4
+ import { WorktreeService } from "../services/WorktreeService"
5
+
6
+ interface WorktreeOptions extends BaseCommandOptions {
7
+ readonly subcommand?: string
8
+ readonly args?: readonly string[]
9
+ }
10
+
11
+ const targetLabel = (owner: {
12
+ readonly kind: "task" | "phase"
13
+ readonly taskId: string
14
+ readonly phaseId?: string
15
+ }) =>
16
+ owner.kind === "phase"
17
+ ? `phase:${owner.taskId}/${owner.phaseId}`
18
+ : `task:${owner.taskId}`
19
+
20
+ export const worktree = (options: WorktreeOptions = {}) =>
21
+ Effect.gen(function* () {
22
+ const worktrees = yield* WorktreeService
23
+ const { log } = createLoggers(options)
24
+ const root = options.cwd ?? process.cwd()
25
+ const subcommand = options.subcommand
26
+ const taskId = options.args?.[0]
27
+ const phaseId = options.args?.[1]
28
+
29
+ if (subcommand === "list") {
30
+ const inspections = yield* worktrees.list(root)
31
+ if (options.json) return log(JSON.stringify(inspections, null, 2))
32
+ for (const inspection of inspections) {
33
+ for (const checkout of inspection.checkouts) {
34
+ const state = checkout.conflicts.length
35
+ ? `conflict:${checkout.conflicts.map(({ kind }) => kind).join(",")}`
36
+ : checkout.exists
37
+ ? checkout.dirty
38
+ ? "dirty"
39
+ : "ready"
40
+ : "missing"
41
+ log(
42
+ `${targetLabel(inspection.owner)}\t${checkout.kind}\t${checkout.repo}\t${state}\t${checkout.path}`,
43
+ )
44
+ }
45
+ }
46
+ return
47
+ }
48
+
49
+ if (!taskId) {
50
+ return yield* Effect.fail(
51
+ new Error("Worktree command requires a task ID"),
52
+ )
53
+ }
54
+ if (subcommand === "inspect") {
55
+ const inspection = yield* worktrees.inspect(taskId, phaseId, root)
56
+ if (options.json) return log(JSON.stringify(inspection, null, 2))
57
+ for (const checkout of inspection.checkouts) {
58
+ const owners = checkout.owners
59
+ .map((owner) => targetLabel(owner))
60
+ .join(",")
61
+ log(
62
+ `${checkout.kind} ${checkout.repo}: path=${checkout.path} registered=${checkout.registeredPath ?? "no"} branch=${checkout.actualBranch ?? "detached"} commit=${checkout.actualCommit ?? "unknown"} owner=${owners || "none"} dirty=${checkout.dirty ?? "unknown"}`,
63
+ )
64
+ for (const conflict of checkout.conflicts) {
65
+ log(` conflict ${conflict.kind}: ${conflict.message}`)
66
+ }
67
+ }
68
+ return
69
+ }
70
+ if (subcommand === "prepare") {
71
+ const workspace = yield* worktrees.materialize(
72
+ taskId,
73
+ phaseId,
74
+ root,
75
+ options,
76
+ )
77
+ return log(
78
+ options.json
79
+ ? JSON.stringify(workspace, null, 2)
80
+ : `${options.dryRun ? "Worktree plan" : "Worktrees ready"}: ${workspace.codePath}`,
81
+ )
82
+ }
83
+ if (subcommand === "remove") {
84
+ const inspection = yield* worktrees.inspect(taskId, phaseId, root)
85
+ const paths = yield* worktrees.remove(taskId, phaseId, root, options)
86
+ const result = {
87
+ operation: "remove",
88
+ dryRun: options.dryRun === true,
89
+ inspection,
90
+ actions: paths.map((path) => `remove ${path}`),
91
+ }
92
+ return log(
93
+ options.json
94
+ ? JSON.stringify(result, null, 2)
95
+ : `${options.dryRun ? "Would remove" : "Removed"} ${paths.length} worktree${paths.length === 1 ? "" : "s"}`,
96
+ )
97
+ }
98
+ if (subcommand === "rebuild") {
99
+ const result = yield* worktrees.rebuild(taskId, phaseId, root, options)
100
+ return log(
101
+ options.json
102
+ ? JSON.stringify(result, null, 2)
103
+ : `${options.dryRun ? "Would rebuild" : "Rebuilt"} ${result.inspection.codePath}`,
104
+ )
105
+ }
106
+ if (subcommand === "repair") {
107
+ const result = yield* worktrees.repair(taskId, phaseId, root, options)
108
+ return log(
109
+ options.json
110
+ ? JSON.stringify(result, null, 2)
111
+ : `${options.dryRun ? "Would repair" : "Repaired"} ${result.inspection.codePath}`,
112
+ )
113
+ }
114
+
115
+ return yield* Effect.fail(
116
+ new Error(`Unknown worktree subcommand '${subcommand ?? ""}'`),
117
+ )
118
+ })
119
+
120
+ export const help = `
121
+ Usage: agency worktree <list|inspect|prepare|remove|rebuild|repair>
122
+
123
+ Inspect and maintain Agency-managed writable and reference worktrees.
124
+
125
+ Commands:
126
+ list List every managed checkout
127
+ inspect <task-id> [phase-id] Show registration, branch, commit, ownership, and dirtiness
128
+ prepare <task-id> [phase-id] Create or reuse declared worktrees
129
+ remove <task-id> [phase-id] Remove clean worktrees while preserving branches
130
+ rebuild <task-id> [phase-id] Remove and recreate clean, conflict-free worktrees
131
+ repair <task-id> [phase-id] Repair safe registration issues or missing worktrees
132
+
133
+ Options:
134
+ --task <id> Select a task without positional IDs
135
+ --phase <id> Select a phase with --task
136
+ --dry-run Preflight and report changes without applying them
137
+ --json Print structured output
138
+ `
@@ -6,6 +6,7 @@ import {
6
6
  readdir,
7
7
  realpath,
8
8
  rename,
9
+ rmdir,
9
10
  stat,
10
11
  symlink,
11
12
  } from "node:fs/promises"
@@ -175,6 +176,31 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
175
176
  ),
176
177
  ),
177
178
 
179
+ deleteDirectoryIfEmpty: (path: string) =>
180
+ Effect.tryPromise({
181
+ try: async () => {
182
+ try {
183
+ await rmdir(path)
184
+ return true
185
+ } catch (error) {
186
+ if (
187
+ typeof error === "object" &&
188
+ error !== null &&
189
+ "code" in error &&
190
+ ["ENOENT", "ENOTEMPTY", "EEXIST"].includes(String(error.code))
191
+ ) {
192
+ return false
193
+ }
194
+ throw error
195
+ }
196
+ },
197
+ catch: (error) =>
198
+ new FileSystemError({
199
+ message: `Failed to delete empty directory: ${path}`,
200
+ cause: error,
201
+ }),
202
+ }),
203
+
178
204
  runCommand: (
179
205
  args: readonly string[],
180
206
  options?: {
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { mkdir } from "node:fs/promises"
4
- import { join } from "node:path"
4
+ import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
7
7
 
@@ -16,6 +16,12 @@ const runGit = async (args: string[]) => {
16
16
  }
17
17
  }
18
18
 
19
+ const write = async (root: string, path: string, content: string) => {
20
+ const fullPath = join(root, path)
21
+ await mkdir(dirname(fullPath), { recursive: true })
22
+ await Bun.write(fullPath, content)
23
+ }
24
+
19
25
  describe("RepositoryService", () => {
20
26
  let root: string
21
27
 
@@ -103,4 +109,126 @@ describe("RepositoryService", () => {
103
109
  ),
104
110
  ).rejects.toThrow("already exists")
105
111
  })
112
+
113
+ test("shows, fetches, updates, and verifies a repository", async () => {
114
+ const source = join(root, "source")
115
+ const replacement = join(root, "replacement.git")
116
+ await runGit(["init", "--initial-branch=main", source])
117
+ await Bun.write(join(source, "README.md"), "# Source\n")
118
+ await runGit(["-C", source, "add", "README.md"])
119
+ await runGit([
120
+ "-C",
121
+ source,
122
+ "-c",
123
+ "user.name=Agency Tests",
124
+ "-c",
125
+ "user.email=agency@example.com",
126
+ "commit",
127
+ "-m",
128
+ "Initial commit",
129
+ ])
130
+ await runGit(["init", "--bare", "--initial-branch=main", replacement])
131
+ await runTestEffect(
132
+ RepositoryService.pipe(
133
+ Effect.flatMap((service) => service.add("agency", source, root)),
134
+ ),
135
+ )
136
+
137
+ const result = await runTestEffect(
138
+ RepositoryService.pipe(
139
+ Effect.flatMap((service) =>
140
+ Effect.gen(function* () {
141
+ const shown = yield* service.show("agency", root)
142
+ yield* service.fetch("agency", root)
143
+ const updated = yield* service.remote("agency", replacement, root)
144
+ const verified = yield* service.verify("agency", root)
145
+ return { shown, updated, verified }
146
+ }),
147
+ ),
148
+ ),
149
+ )
150
+
151
+ expect(result.shown.remote).toBe(source)
152
+ expect(result.updated.remote).toBe(replacement)
153
+ expect(result.verified.valid).toBe(true)
154
+ expect(result.verified.issues).toEqual([])
155
+ })
156
+
157
+ test("renames and removes an unused repository", async () => {
158
+ const source = join(root, "source.git")
159
+ await runGit(["init", "--bare", "--initial-branch=main", source])
160
+ await runTestEffect(
161
+ RepositoryService.pipe(
162
+ Effect.flatMap((service) => service.add("old", source, root)),
163
+ ),
164
+ )
165
+
166
+ const removed = await runTestEffect(
167
+ RepositoryService.pipe(
168
+ Effect.flatMap((service) =>
169
+ Effect.gen(function* () {
170
+ const renamed = yield* service.rename("old", "new", root)
171
+ expect(renamed.alias).toBe("new")
172
+ return yield* service.remove("new", root)
173
+ }),
174
+ ),
175
+ ),
176
+ )
177
+
178
+ expect(removed.alias).toBe("new")
179
+ expect(await Bun.file(join(root, "repos/new/HEAD")).exists()).toBe(false)
180
+ })
181
+
182
+ test("unlinks a symlink without deleting its target", async () => {
183
+ const target = join(root, "linked-repository")
184
+ await mkdir(target, { recursive: true })
185
+ await runGit(["init", "--initial-branch=main", target])
186
+ await runTestEffect(
187
+ RepositoryService.pipe(
188
+ Effect.flatMap((service) => service.link("linked", target, root)),
189
+ ),
190
+ )
191
+
192
+ await runTestEffect(
193
+ RepositoryService.pipe(
194
+ Effect.flatMap((service) => service.unlink("linked", root)),
195
+ ),
196
+ )
197
+
198
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
199
+ expect(await Bun.file(join(root, "repos/linked/.git/HEAD")).exists()).toBe(
200
+ false,
201
+ )
202
+ })
203
+
204
+ test("reports active references and refuses unsafe removal", async () => {
205
+ const source = join(root, "source.git")
206
+ await runGit(["init", "--bare", "--initial-branch=main", source])
207
+ await runTestEffect(
208
+ RepositoryService.pipe(
209
+ Effect.flatMap((service) => service.add("agency", source, root)),
210
+ ),
211
+ )
212
+ await write(
213
+ root,
214
+ "tasks/active/TASK.md",
215
+ `---
216
+ ticketUrl: null
217
+ repo: agency
218
+ branch: task/active
219
+ base: main
220
+ pr: null
221
+ ---
222
+ `,
223
+ )
224
+
225
+ await expect(
226
+ runTestEffect(
227
+ RepositoryService.pipe(
228
+ Effect.flatMap((service) => service.remove("agency", root)),
229
+ ),
230
+ ),
231
+ ).rejects.toThrow("active reference execution-unit:task/active")
232
+ expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
233
+ })
106
234
  })
@@ -2,6 +2,7 @@ import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { join, resolve } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
+ import { GraphService } from "./GraphService"
5
6
  import { WorkbaseService } from "./WorkbaseService"
6
7
  import { RepositoryAlias } from "../workbase/schemas"
7
8
 
@@ -18,6 +19,11 @@ interface RepositoryInfo {
18
19
  readonly target: string | null
19
20
  }
20
21
 
22
+ interface RepositoryVerification extends RepositoryInfo {
23
+ readonly valid: boolean
24
+ readonly issues: readonly string[]
25
+ }
26
+
21
27
  const validateAlias = (alias: string) => {
22
28
  const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
23
29
  return Either.isLeft(result)
@@ -29,6 +35,65 @@ const validateAlias = (alias: string) => {
29
35
  : Effect.succeed(result.right)
30
36
  }
31
37
 
38
+ const find = (alias: string, startPath: string) =>
39
+ Effect.gen(function* () {
40
+ const service = yield* RepositoryService
41
+ const validAlias = yield* validateAlias(alias)
42
+ const repositories = yield* service.list(startPath)
43
+ const repository = repositories.find((item) => item.alias === validAlias)
44
+ if (!repository) {
45
+ return yield* new RepositoryError({
46
+ message: `Unknown repository alias '${validAlias}'`,
47
+ })
48
+ }
49
+ return repository
50
+ })
51
+
52
+ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
53
+ Effect.gen(function* () {
54
+ const fs = yield* FileSystemService
55
+ const graph = yield* GraphService
56
+ const report = yield* graph.get({ cwd: startPath })
57
+ const repositoryId = `repository:${repository.alias}`
58
+ const references = report.edges
59
+ .filter(
60
+ (edge) =>
61
+ edge.to === repositoryId &&
62
+ (edge.kind === "writes" || edge.kind === "references"),
63
+ )
64
+ .map((edge) => edge.from)
65
+ .sort()
66
+ const worktrees: string[] = []
67
+ if (repository.kind !== "symlink") {
68
+ const result = yield* fs.runCommand(
69
+ ["git", "-C", repository.path, "worktree", "list", "--porcelain"],
70
+ { captureOutput: true },
71
+ )
72
+ if (result.exitCode === 0) {
73
+ for (const block of result.stdout.trim().split(/\n\n+/)) {
74
+ if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
75
+ const path = block.match(/^worktree (.+)$/m)?.[1]
76
+ if (path) worktrees.push(path)
77
+ }
78
+ }
79
+ }
80
+ return { references, worktrees }
81
+ })
82
+
83
+ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
84
+ Effect.gen(function* () {
85
+ const blockers = yield* removalBlockers(repository, startPath)
86
+ const details = [
87
+ ...blockers.references.map((item) => `active reference ${item}`),
88
+ ...blockers.worktrees.map((item) => `linked worktree ${item}`),
89
+ ]
90
+ if (details.length > 0) {
91
+ return yield* new RepositoryError({
92
+ message: `Repository alias '${repository.alias}' is in use and cannot be removed or renamed:\n${details.map((item) => `- ${item}`).join("\n")}`,
93
+ })
94
+ }
95
+ })
96
+
32
97
  export class RepositoryService extends Effect.Service<RepositoryService>()(
33
98
  "RepositoryService",
34
99
  {
@@ -154,6 +219,122 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
154
219
 
155
220
  return repositories
156
221
  }),
222
+
223
+ show: (alias: string, startPath: string = process.cwd()) =>
224
+ find(alias, startPath),
225
+
226
+ fetch: (alias: string, startPath: string = process.cwd()) =>
227
+ Effect.gen(function* () {
228
+ const fs = yield* FileSystemService
229
+ const repository = yield* find(alias, startPath)
230
+ const result = yield* fs.runCommand(
231
+ ["git", "-C", repository.path, "fetch", "--prune"],
232
+ { captureOutput: true },
233
+ )
234
+ if (result.exitCode !== 0) {
235
+ return yield* new RepositoryError({
236
+ message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
237
+ })
238
+ }
239
+ return repository
240
+ }),
241
+
242
+ remove: (alias: string, startPath: string = process.cwd()) =>
243
+ Effect.gen(function* () {
244
+ const fs = yield* FileSystemService
245
+ const repository = yield* find(alias, startPath)
246
+ yield* assertRemovable(repository, startPath)
247
+ yield* fs.deleteDirectory(repository.path)
248
+ return repository
249
+ }),
250
+
251
+ unlink: (alias: string, startPath: string = process.cwd()) =>
252
+ Effect.gen(function* () {
253
+ const service = yield* RepositoryService
254
+ const repository = yield* find(alias, startPath)
255
+ if (repository.kind !== "symlink") {
256
+ return yield* new RepositoryError({
257
+ message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
258
+ })
259
+ }
260
+ return yield* service.remove(alias, startPath)
261
+ }),
262
+
263
+ rename: (
264
+ alias: string,
265
+ newAlias: string,
266
+ startPath: string = process.cwd(),
267
+ ) =>
268
+ Effect.gen(function* () {
269
+ const fs = yield* FileSystemService
270
+ const workbase = yield* WorkbaseService
271
+ const repository = yield* find(alias, startPath)
272
+ const validNewAlias = yield* validateAlias(newAlias)
273
+ const root = yield* workbase.discover(startPath)
274
+ const destination = join(root, "repos", validNewAlias)
275
+ if (yield* fs.exists(destination)) {
276
+ return yield* new RepositoryError({
277
+ message: `Repository alias '${validNewAlias}' already exists`,
278
+ })
279
+ }
280
+ yield* assertRemovable(repository, startPath)
281
+ yield* fs.moveDirectory(repository.path, destination)
282
+ return yield* find(validNewAlias, startPath)
283
+ }),
284
+
285
+ remote: (
286
+ alias: string,
287
+ remote: string | undefined,
288
+ startPath: string = process.cwd(),
289
+ ) =>
290
+ Effect.gen(function* () {
291
+ const fs = yield* FileSystemService
292
+ const repository = yield* find(alias, startPath)
293
+ if (remote === undefined) return repository
294
+ if (!remote.trim()) {
295
+ return yield* new RepositoryError({
296
+ message: "Repository remote is required",
297
+ })
298
+ }
299
+ const hasOrigin = repository.remote !== null
300
+ const result = yield* fs.runCommand(
301
+ [
302
+ "git",
303
+ "-C",
304
+ repository.path,
305
+ "remote",
306
+ hasOrigin ? "set-url" : "add",
307
+ "origin",
308
+ remote,
309
+ ],
310
+ { captureOutput: true },
311
+ )
312
+ if (result.exitCode !== 0) {
313
+ return yield* new RepositoryError({
314
+ message: `Failed to update remote for repository '${alias}': ${result.stderr.trim()}`,
315
+ })
316
+ }
317
+ return yield* find(alias, startPath)
318
+ }),
319
+
320
+ verify: (alias: string, startPath: string = process.cwd()) =>
321
+ Effect.gen(function* () {
322
+ const fs = yield* FileSystemService
323
+ const repository = yield* find(alias, startPath)
324
+ const issues: string[] = []
325
+ const git = yield* fs.runCommand(
326
+ ["git", "-C", repository.path, "rev-parse", "--git-dir"],
327
+ { captureOutput: true },
328
+ )
329
+ if (git.exitCode !== 0) issues.push("Path is not a Git repository")
330
+ if (repository.remote === null)
331
+ issues.push("Origin remote is not configured")
332
+ return {
333
+ ...repository,
334
+ valid: issues.length === 0,
335
+ issues,
336
+ } satisfies RepositoryVerification
337
+ }),
157
338
  }),
158
339
  },
159
340
  ) {}
@@ -111,6 +111,52 @@ describe("WorkbaseService", () => {
111
111
  expect(result.defaultWorkbase).toEqual(registered)
112
112
  })
113
113
 
114
+ test("shows, names, clears names, and defaults by path", async () => {
115
+ const workbaseRoot = join(root, "workbase")
116
+ const configDirectory = join(root, "config")
117
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
118
+ const registered = await runTestEffect(
119
+ WorkbaseService.pipe(
120
+ Effect.flatMap((service) =>
121
+ service.register(workbaseRoot, configDirectory),
122
+ ),
123
+ ),
124
+ )
125
+
126
+ const result = await runTestEffect(
127
+ WorkbaseService.pipe(
128
+ Effect.flatMap((service) =>
129
+ Effect.gen(function* () {
130
+ const named = yield* service.nameRegistered(
131
+ workbaseRoot,
132
+ "primary",
133
+ configDirectory,
134
+ )
135
+ const shown = yield* service.showRegistered(
136
+ "primary",
137
+ configDirectory,
138
+ )
139
+ const defaultWorkbase = yield* service.setDefault(
140
+ workbaseRoot,
141
+ configDirectory,
142
+ )
143
+ const unnamed = yield* service.nameRegistered(
144
+ registered.id,
145
+ null,
146
+ configDirectory,
147
+ )
148
+ return { named, shown, defaultWorkbase, unnamed }
149
+ }),
150
+ ),
151
+ ),
152
+ )
153
+
154
+ expect(result.named.name).toBe("primary")
155
+ expect(result.shown).toEqual(result.named)
156
+ expect(result.defaultWorkbase?.id).toBe(registered.id)
157
+ expect(result.unnamed).toEqual({ id: registered.id, path: registered.path })
158
+ })
159
+
114
160
  test("rejects names that collide with stable IDs", async () => {
115
161
  const firstRoot = join(root, "first")
116
162
  const secondRoot = join(root, "second")