@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.
- package/README.md +24 -2
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +10 -1
- package/src/cli-parser.test.ts +47 -0
- package/src/cli-parser.ts +110 -2
- package/src/cli.test.ts +34 -0
- package/src/commands/repo.test.ts +44 -0
- package/src/commands/repo.ts +107 -1
- package/src/commands/workbase.test.ts +47 -0
- package/src/commands/workbase.ts +52 -1
- package/src/commands/worktree.ts +138 -0
- package/src/services/FileSystemService.ts +26 -0
- package/src/services/RepositoryService.test.ts +129 -1
- package/src/services/RepositoryService.ts +181 -0
- package/src/services/WorkbaseService.test.ts +46 -0
- package/src/services/WorkbaseService.ts +94 -25
- package/src/services/WorktreeService.test.ts +453 -2
- package/src/services/WorktreeService.ts +1510 -655
package/src/commands/workbase.ts
CHANGED
|
@@ -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")
|