@markjaquith/agency 2.29.0 → 2.30.1
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 +72 -16
- package/cli.ts +3 -0
- package/fixtures/protocol/skill-setup-commands.json +18 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +30 -15
- package/skills/agency/references/commands.md +38 -17
- package/skills/agency/references/contracts.md +46 -19
- package/skills/agency/references/recipes.md +52 -12
- package/src/cli-parser.test.ts +7 -0
- package/src/cli-parser.ts +13 -2
- package/src/cli.test.ts +422 -3
- package/src/commands/doctor.test.ts +22 -0
- package/src/commands/init.test.ts +3 -2
- package/src/commands/integration.test.ts +21 -1
- package/src/commands/integration.ts +8 -4
- package/src/commands/pr.test.ts +20 -1
- package/src/commands/repo.test.ts +66 -1
- package/src/commands/repo.ts +35 -8
- package/src/commands/status.test.ts +22 -0
- package/src/commands/status.ts +1 -0
- package/src/commands/sync.ts +5 -3
- package/src/commands/work.test.ts +74 -1
- package/src/commands/work.ts +26 -3
- package/src/graph-schema.test.ts +52 -4
- package/src/protocol.test.ts +41 -8
- package/src/readiness.test.ts +75 -17
- package/src/services/DoctorService.ts +32 -18
- package/src/services/EpicService.ts +1 -1
- package/src/services/GraphMutationService.ts +3 -3
- package/src/services/GraphService.ts +13 -4
- package/src/services/IntegrationService.test.ts +37 -12
- package/src/services/IntegrationService.ts +70 -21
- package/src/services/PhaseService.ts +1 -1
- package/src/services/ReadinessService.test.ts +47 -0
- package/src/services/RepositoryService.test.ts +299 -5
- package/src/services/RepositoryService.ts +725 -98
- package/src/services/SyncService.test.ts +36 -0
- package/src/services/SyncService.ts +20 -1
- package/src/services/TaskService.ts +1 -1
- package/src/services/WorkbaseService.test.ts +32 -0
- package/src/services/WorkbaseService.ts +22 -14
- package/src/services/WorktreeLock.test.ts +122 -0
- package/src/services/WorktreeService.test.ts +17 -2
- package/src/services/WorktreeService.ts +3 -3
- package/src/utils/process.test.ts +4 -3
- package/src/workbase/AGENTS.md +9 -1
- package/src/workbase/dependency-graph.test.ts +50 -0
- package/src/workbase/opencode-file.ts +3 -13
- package/src/workbase/schemas.test.ts +52 -0
- package/src/workbase/schemas.ts +19 -0
|
@@ -1,22 +1,44 @@
|
|
|
1
1
|
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
2
|
import { Data, Effect, Either } from "effect"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
+
import { lstat, rename, rm } from "node:fs/promises"
|
|
4
5
|
import { FileSystemService } from "./FileSystemService"
|
|
5
6
|
import { GraphService } from "./GraphService"
|
|
6
7
|
import { WorkbaseService } from "./WorkbaseService"
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
directoryMoveStep,
|
|
10
|
+
documentWriteStep,
|
|
11
|
+
runLifecycleTransaction,
|
|
12
|
+
type TransactionStep,
|
|
13
|
+
} from "./LifecycleTransaction"
|
|
14
|
+
import {
|
|
15
|
+
RepositoryAlias,
|
|
16
|
+
RepositoryRemote,
|
|
17
|
+
WorkbaseConfig,
|
|
18
|
+
} from "../workbase/schemas"
|
|
19
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
8
20
|
|
|
9
21
|
class RepositoryError extends Data.TaggedError("RepositoryError")<{
|
|
10
22
|
readonly message: string
|
|
11
23
|
readonly cause?: unknown
|
|
12
24
|
}> {}
|
|
13
25
|
|
|
26
|
+
type RepositoryState =
|
|
27
|
+
| "declared"
|
|
28
|
+
| "materialized"
|
|
29
|
+
| "linked"
|
|
30
|
+
| "missing"
|
|
31
|
+
| "invalid"
|
|
32
|
+
| "remote-drifted"
|
|
33
|
+
|
|
14
34
|
interface RepositoryInfo {
|
|
15
35
|
readonly alias: string
|
|
16
36
|
readonly path: string
|
|
17
|
-
readonly kind: "bare" | "repository" | "symlink"
|
|
37
|
+
readonly kind: "bare" | "repository" | "symlink" | null
|
|
18
38
|
readonly remote: string | null
|
|
39
|
+
readonly declaredRemote: string | null
|
|
19
40
|
readonly target: string | null
|
|
41
|
+
readonly states: readonly RepositoryState[]
|
|
20
42
|
}
|
|
21
43
|
|
|
22
44
|
interface RepositoryVerification extends RepositoryInfo {
|
|
@@ -24,17 +46,138 @@ interface RepositoryVerification extends RepositoryInfo {
|
|
|
24
46
|
readonly issues: readonly string[]
|
|
25
47
|
}
|
|
26
48
|
|
|
27
|
-
|
|
28
|
-
|
|
49
|
+
interface RepositorySetupAction {
|
|
50
|
+
readonly kind: "materialize" | "adopt"
|
|
51
|
+
readonly alias: string
|
|
52
|
+
readonly remote: string
|
|
53
|
+
readonly status: "planned" | "applied"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RepositorySetupIssue {
|
|
57
|
+
readonly alias: string
|
|
58
|
+
readonly state: "invalid" | "remote-drifted" | "undeclared"
|
|
59
|
+
readonly message: string
|
|
60
|
+
readonly action: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RepositorySetupResult {
|
|
64
|
+
readonly root: string
|
|
65
|
+
readonly mode: "dry-run" | "apply"
|
|
66
|
+
readonly actions: readonly RepositorySetupAction[]
|
|
67
|
+
readonly unresolved: readonly RepositorySetupIssue[]
|
|
68
|
+
readonly repositories: readonly RepositoryInfo[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const validate = <S extends Schema.Schema.AnyNoContext>(
|
|
72
|
+
schema: S,
|
|
73
|
+
value: unknown,
|
|
74
|
+
label: string,
|
|
75
|
+
) => {
|
|
76
|
+
const result = Schema.decodeUnknownEither(schema)(value)
|
|
29
77
|
return Either.isLeft(result)
|
|
30
78
|
? Effect.fail(
|
|
31
79
|
new RepositoryError({
|
|
32
|
-
message: `Invalid
|
|
80
|
+
message: `Invalid ${label} '${String(value)}': ${TreeFormatter.formatErrorSync(result.left)}`,
|
|
33
81
|
}),
|
|
34
82
|
)
|
|
35
83
|
: Effect.succeed(result.right)
|
|
36
84
|
}
|
|
37
85
|
|
|
86
|
+
const validateAlias = (alias: string) =>
|
|
87
|
+
validate(RepositoryAlias, alias, "repository alias")
|
|
88
|
+
|
|
89
|
+
const validateRemote = (remote: string) => {
|
|
90
|
+
if (!remote.trim()) {
|
|
91
|
+
return Effect.fail(
|
|
92
|
+
new RepositoryError({ message: "Repository remote is required" }),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return validate(RepositoryRemote, remote, "portable repository remote")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sortedDeclarations = (
|
|
99
|
+
repositories: WorkbaseConfig["repositories"] | undefined,
|
|
100
|
+
) =>
|
|
101
|
+
Object.fromEntries(
|
|
102
|
+
Object.entries(repositories ?? {}).sort(([left], [right]) =>
|
|
103
|
+
left.localeCompare(right),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const configContent = (config: WorkbaseConfig) =>
|
|
108
|
+
JSON.stringify(
|
|
109
|
+
{
|
|
110
|
+
...config,
|
|
111
|
+
...(config.repositories
|
|
112
|
+
? { repositories: sortedDeclarations(config.repositories) }
|
|
113
|
+
: {}),
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2,
|
|
117
|
+
) + "\n"
|
|
118
|
+
|
|
119
|
+
const configState = (startPath: string) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
const fs = yield* FileSystemService
|
|
122
|
+
const workbase = yield* WorkbaseService
|
|
123
|
+
const root = yield* workbase.discover(startPath)
|
|
124
|
+
const path = join(root, "agency.json")
|
|
125
|
+
const content = yield* fs.readFile(path)
|
|
126
|
+
let input: unknown
|
|
127
|
+
try {
|
|
128
|
+
input = JSON.parse(content)
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
return yield* new RepositoryError({
|
|
131
|
+
message: `Invalid JSON in ${path}`,
|
|
132
|
+
cause,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
const decoded = Schema.decodeUnknownEither(WorkbaseConfig, {
|
|
136
|
+
errors: "all",
|
|
137
|
+
onExcessProperty: "error",
|
|
138
|
+
})(input)
|
|
139
|
+
if (Either.isLeft(decoded)) {
|
|
140
|
+
return yield* new RepositoryError({
|
|
141
|
+
message: `Invalid workbase configuration in ${path}:\n${TreeFormatter.formatErrorSync(decoded.left)}`,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
root,
|
|
146
|
+
config: decoded.right,
|
|
147
|
+
path,
|
|
148
|
+
revision: documentRevision(content),
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const withDeclarations = (
|
|
153
|
+
config: WorkbaseConfig,
|
|
154
|
+
repositories: NonNullable<WorkbaseConfig["repositories"]>,
|
|
155
|
+
): WorkbaseConfig => ({
|
|
156
|
+
...config,
|
|
157
|
+
repositories: sortedDeclarations(repositories),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const inspectRemote = (path: string) =>
|
|
161
|
+
Effect.gen(function* () {
|
|
162
|
+
const fs = yield* FileSystemService
|
|
163
|
+
const result = yield* fs.runCommand(
|
|
164
|
+
["git", "-C", path, "remote", "get-url", "origin"],
|
|
165
|
+
{ captureOutput: true },
|
|
166
|
+
)
|
|
167
|
+
return result.exitCode === 0 ? result.stdout.trim() : null
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const portableRemote = (path: string) =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const remote = yield* inspectRemote(path)
|
|
173
|
+
if (!remote) {
|
|
174
|
+
return yield* new RepositoryError({
|
|
175
|
+
message: `Repository '${path}' has no portable origin remote`,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
return yield* validateRemote(remote)
|
|
179
|
+
})
|
|
180
|
+
|
|
38
181
|
const find = (alias: string, startPath: string) =>
|
|
39
182
|
Effect.gen(function* () {
|
|
40
183
|
const service = yield* RepositoryService
|
|
@@ -49,6 +192,21 @@ const find = (alias: string, startPath: string) =>
|
|
|
49
192
|
return repository
|
|
50
193
|
})
|
|
51
194
|
|
|
195
|
+
const requireMaterialized = (repository: RepositoryInfo) =>
|
|
196
|
+
repository.states.includes("missing")
|
|
197
|
+
? Effect.fail(
|
|
198
|
+
new RepositoryError({
|
|
199
|
+
message: `Repository alias '${repository.alias}' is declared but missing; run 'agency repo setup --apply'`,
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
: repository.states.includes("invalid")
|
|
203
|
+
? Effect.fail(
|
|
204
|
+
new RepositoryError({
|
|
205
|
+
message: `Repository alias '${repository.alias}' has an invalid local path`,
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
: Effect.succeed(repository)
|
|
209
|
+
|
|
52
210
|
const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
|
|
53
211
|
Effect.gen(function* () {
|
|
54
212
|
const fs = yield* FileSystemService
|
|
@@ -64,7 +222,11 @@ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
|
|
|
64
222
|
.map((edge) => edge.from)
|
|
65
223
|
.sort()
|
|
66
224
|
const worktrees: string[] = []
|
|
67
|
-
if (repository.kind !== "
|
|
225
|
+
if (repository.kind !== null && !repository.states.includes("invalid")) {
|
|
226
|
+
const linkedTarget =
|
|
227
|
+
repository.kind === "symlink"
|
|
228
|
+
? yield* fs.realPath(repository.path)
|
|
229
|
+
: null
|
|
68
230
|
const result = yield* fs.runCommand(
|
|
69
231
|
["git", "-C", repository.path, "worktree", "list", "--porcelain"],
|
|
70
232
|
{ captureOutput: true },
|
|
@@ -73,7 +235,7 @@ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
|
|
|
73
235
|
for (const block of result.stdout.trim().split(/\n\n+/)) {
|
|
74
236
|
if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
|
|
75
237
|
const path = block.match(/^worktree (.+)$/m)?.[1]
|
|
76
|
-
if (path) worktrees.push(path)
|
|
238
|
+
if (path && path !== linkedTarget) worktrees.push(path)
|
|
77
239
|
}
|
|
78
240
|
}
|
|
79
241
|
}
|
|
@@ -94,6 +256,84 @@ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
|
|
|
94
256
|
}
|
|
95
257
|
})
|
|
96
258
|
|
|
259
|
+
const effectPreflightStep = (
|
|
260
|
+
label: string,
|
|
261
|
+
check: Effect.Effect<void, unknown, never>,
|
|
262
|
+
): TransactionStep => ({
|
|
263
|
+
label,
|
|
264
|
+
preflight: () => Effect.runPromise(check),
|
|
265
|
+
apply: async () => undefined,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const deleteAfterMoveStep = (
|
|
269
|
+
root: string,
|
|
270
|
+
from: string,
|
|
271
|
+
to: string,
|
|
272
|
+
): TransactionStep => ({
|
|
273
|
+
...directoryMoveStep(root, from, to),
|
|
274
|
+
finalize: () => rm(to, { recursive: true, force: true }),
|
|
275
|
+
manualRecovery: `Remove ${to} or move it back to ${from}`,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const replaceWithMoveStep = (
|
|
279
|
+
current: string,
|
|
280
|
+
replacement: string,
|
|
281
|
+
backup: string,
|
|
282
|
+
): TransactionStep => ({
|
|
283
|
+
label: `replace ${current} with ${replacement}`,
|
|
284
|
+
preflight: async () => {
|
|
285
|
+
await lstat(current)
|
|
286
|
+
await lstat(replacement)
|
|
287
|
+
try {
|
|
288
|
+
await lstat(backup)
|
|
289
|
+
throw new Error(`Replacement backup already exists: ${backup}`)
|
|
290
|
+
} catch (cause) {
|
|
291
|
+
if (
|
|
292
|
+
typeof cause !== "object" ||
|
|
293
|
+
cause === null ||
|
|
294
|
+
!("code" in cause) ||
|
|
295
|
+
cause.code !== "ENOENT"
|
|
296
|
+
)
|
|
297
|
+
throw cause
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
apply: async () => {
|
|
301
|
+
await rename(current, backup)
|
|
302
|
+
try {
|
|
303
|
+
await rename(replacement, current)
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
await rename(backup, current)
|
|
306
|
+
throw cause
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
rollback: async () => {
|
|
310
|
+
await rename(current, replacement)
|
|
311
|
+
await rename(backup, current)
|
|
312
|
+
},
|
|
313
|
+
finalize: () => rm(backup, { recursive: true, force: true }),
|
|
314
|
+
manualRecovery: `Restore ${backup} to ${current}`,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const runTransaction = (
|
|
318
|
+
state: Effect.Effect.Success<ReturnType<typeof configState>>,
|
|
319
|
+
config: WorkbaseConfig,
|
|
320
|
+
steps: readonly TransactionStep[],
|
|
321
|
+
) =>
|
|
322
|
+
runLifecycleTransaction({
|
|
323
|
+
root: state.root,
|
|
324
|
+
preconditions: [{ path: state.path, revision: state.revision }],
|
|
325
|
+
steps: [
|
|
326
|
+
...steps,
|
|
327
|
+
documentWriteStep(state.root, [
|
|
328
|
+
{ path: state.path, content: configContent(config) },
|
|
329
|
+
]),
|
|
330
|
+
],
|
|
331
|
+
}).pipe(
|
|
332
|
+
Effect.mapError(
|
|
333
|
+
(cause) => new RepositoryError({ message: cause.message, cause }),
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
97
337
|
export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
98
338
|
"RepositoryService",
|
|
99
339
|
{
|
|
@@ -101,35 +341,72 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
101
341
|
add: (alias: string, remote: string, startPath: string = process.cwd()) =>
|
|
102
342
|
Effect.gen(function* () {
|
|
103
343
|
const fs = yield* FileSystemService
|
|
104
|
-
const workbase = yield* WorkbaseService
|
|
105
344
|
const validAlias = yield* validateAlias(alias)
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
message: "Repository remote is required",
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
if (yield* fs.exists(destination)) {
|
|
345
|
+
const state = yield* configState(startPath)
|
|
346
|
+
const destination = join(state.root, "repos", validAlias)
|
|
347
|
+
if (
|
|
348
|
+
state.config.repositories?.[validAlias] ||
|
|
349
|
+
(yield* fs.exists(destination))
|
|
350
|
+
) {
|
|
116
351
|
return yield* new RepositoryError({
|
|
117
352
|
message: `Repository alias '${validAlias}' already exists`,
|
|
118
353
|
})
|
|
119
354
|
}
|
|
120
355
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
356
|
+
const inputIsPortable = Either.isRight(
|
|
357
|
+
Schema.decodeUnknownEither(RepositoryRemote)(remote),
|
|
358
|
+
)
|
|
359
|
+
const cloneSource = inputIsPortable
|
|
360
|
+
? remote
|
|
361
|
+
: resolve(startPath, remote)
|
|
362
|
+
const declaredRemote = inputIsPortable
|
|
363
|
+
? yield* validateRemote(remote)
|
|
364
|
+
: yield* portableRemote(cloneSource)
|
|
365
|
+
const staging = join(
|
|
366
|
+
state.root,
|
|
367
|
+
"repos",
|
|
368
|
+
`.agency-clone-${validAlias}-${process.pid}-${Date.now()}`,
|
|
369
|
+
)
|
|
370
|
+
yield* fs.createDirectory(join(state.root, "repos"))
|
|
371
|
+
const cloned = yield* fs.runCommand(
|
|
372
|
+
["git", "clone", "--bare", "--", cloneSource, staging],
|
|
124
373
|
{ captureOutput: true },
|
|
125
374
|
)
|
|
126
|
-
if (
|
|
127
|
-
yield* fs.deleteDirectory(
|
|
375
|
+
if (cloned.exitCode !== 0) {
|
|
376
|
+
yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
|
|
128
377
|
return yield* new RepositoryError({
|
|
129
|
-
message: `Failed to clone repository '${remote}': ${
|
|
378
|
+
message: `Failed to clone repository '${remote}': ${cloned.stderr.trim()}`,
|
|
130
379
|
})
|
|
131
380
|
}
|
|
132
|
-
|
|
381
|
+
if (declaredRemote !== remote) {
|
|
382
|
+
const setRemote = yield* fs.runCommand(
|
|
383
|
+
[
|
|
384
|
+
"git",
|
|
385
|
+
"-C",
|
|
386
|
+
staging,
|
|
387
|
+
"remote",
|
|
388
|
+
"set-url",
|
|
389
|
+
"origin",
|
|
390
|
+
declaredRemote,
|
|
391
|
+
],
|
|
392
|
+
{ captureOutput: true },
|
|
393
|
+
)
|
|
394
|
+
if (setRemote.exitCode !== 0) {
|
|
395
|
+
yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
|
|
396
|
+
return yield* new RepositoryError({
|
|
397
|
+
message: `Failed to record portable remote for repository '${validAlias}': ${setRemote.stderr.trim()}`,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const config = withDeclarations(state.config, {
|
|
402
|
+
...(state.config.repositories ?? {}),
|
|
403
|
+
[validAlias]: { remote: declaredRemote },
|
|
404
|
+
})
|
|
405
|
+
yield* runTransaction(state, config, [
|
|
406
|
+
directoryMoveStep(state.root, staging, destination),
|
|
407
|
+
]).pipe(
|
|
408
|
+
Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
|
|
409
|
+
)
|
|
133
410
|
return destination
|
|
134
411
|
}),
|
|
135
412
|
|
|
@@ -140,16 +417,25 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
140
417
|
) =>
|
|
141
418
|
Effect.gen(function* () {
|
|
142
419
|
const fs = yield* FileSystemService
|
|
420
|
+
const graph = yield* GraphService
|
|
143
421
|
const workbase = yield* WorkbaseService
|
|
144
422
|
const validAlias = yield* validateAlias(alias)
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const destination = join(reposPath, validAlias)
|
|
423
|
+
const state = yield* configState(startPath)
|
|
424
|
+
const destination = join(state.root, "repos", validAlias)
|
|
148
425
|
const resolvedTarget = resolve(startPath, target)
|
|
149
|
-
|
|
150
|
-
|
|
426
|
+
const existing = (yield* RepositoryService)
|
|
427
|
+
.list(state.root)
|
|
428
|
+
.pipe(
|
|
429
|
+
Effect.map((items) =>
|
|
430
|
+
items.find((item) => item.alias === validAlias),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
const current = yield* existing
|
|
434
|
+
const localCurrent =
|
|
435
|
+
current && !current.states.includes("missing") ? current : undefined
|
|
436
|
+
if (localCurrent?.kind === "symlink") {
|
|
151
437
|
return yield* new RepositoryError({
|
|
152
|
-
message: `Repository alias '${validAlias}' already
|
|
438
|
+
message: `Repository alias '${validAlias}' is already linked`,
|
|
153
439
|
})
|
|
154
440
|
}
|
|
155
441
|
if (!(yield* fs.isDirectory(resolvedTarget))) {
|
|
@@ -157,66 +443,139 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
157
443
|
message: `Repository path does not exist: ${resolvedTarget}`,
|
|
158
444
|
})
|
|
159
445
|
}
|
|
160
|
-
|
|
161
|
-
const result = yield* fs.runCommand(
|
|
446
|
+
const git = yield* fs.runCommand(
|
|
162
447
|
["git", "-C", resolvedTarget, "rev-parse", "--git-dir"],
|
|
163
448
|
{ captureOutput: true },
|
|
164
449
|
)
|
|
165
|
-
if (
|
|
450
|
+
if (git.exitCode !== 0) {
|
|
166
451
|
return yield* new RepositoryError({
|
|
167
452
|
message: `Path is not a Git repository: ${resolvedTarget}`,
|
|
168
453
|
})
|
|
169
454
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
455
|
+
const declaredRemote =
|
|
456
|
+
state.config.repositories?.[validAlias]?.remote ??
|
|
457
|
+
(yield* portableRemote(resolvedTarget))
|
|
458
|
+
const staging = join(
|
|
459
|
+
state.root,
|
|
460
|
+
"repos",
|
|
461
|
+
`.agency-link-${validAlias}-${process.pid}-${Date.now()}`,
|
|
462
|
+
)
|
|
463
|
+
yield* fs.createDirectory(join(state.root, "repos"))
|
|
464
|
+
yield* fs.createSymlink(resolvedTarget, staging)
|
|
465
|
+
const config = withDeclarations(state.config, {
|
|
466
|
+
...(state.config.repositories ?? {}),
|
|
467
|
+
[validAlias]: { remote: declaredRemote },
|
|
468
|
+
})
|
|
469
|
+
const replaced = join(
|
|
470
|
+
state.root,
|
|
471
|
+
"repos",
|
|
472
|
+
`.agency-replaced-${validAlias}-${process.pid}-${Date.now()}`,
|
|
473
|
+
)
|
|
474
|
+
const safety = localCurrent
|
|
475
|
+
? effectPreflightStep(
|
|
476
|
+
`verify repository safety for ${validAlias}`,
|
|
477
|
+
assertRemovable(localCurrent, state.root).pipe(
|
|
478
|
+
Effect.provideService(FileSystemService, fs),
|
|
479
|
+
Effect.provideService(GraphService, graph),
|
|
480
|
+
Effect.provideService(WorkbaseService, workbase),
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
: null
|
|
484
|
+
yield* runTransaction(
|
|
485
|
+
state,
|
|
486
|
+
config,
|
|
487
|
+
localCurrent
|
|
488
|
+
? [safety!, replaceWithMoveStep(destination, staging, replaced)]
|
|
489
|
+
: [directoryMoveStep(state.root, staging, destination)],
|
|
490
|
+
).pipe(
|
|
491
|
+
Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
|
|
492
|
+
)
|
|
173
493
|
return destination
|
|
174
494
|
}),
|
|
175
495
|
|
|
176
496
|
list: (startPath: string = process.cwd()) =>
|
|
177
497
|
Effect.gen(function* () {
|
|
178
498
|
const fs = yield* FileSystemService
|
|
179
|
-
const
|
|
180
|
-
|
|
499
|
+
const { root, config } = yield* WorkbaseService.pipe(
|
|
500
|
+
Effect.flatMap((service) => service.loadConfig(startPath)),
|
|
501
|
+
)
|
|
181
502
|
const reposPath = join(root, "repos")
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
.
|
|
503
|
+
const entries = (yield* fs.isDirectory(reposPath))
|
|
504
|
+
? (yield* fs.readDirectory(reposPath)).filter(
|
|
505
|
+
(entry) => !entry.name.startsWith(".agency-"),
|
|
506
|
+
)
|
|
507
|
+
: []
|
|
508
|
+
const local = new Map(entries.map((entry) => [entry.name, entry]))
|
|
509
|
+
const aliases = new Set([
|
|
510
|
+
...Object.keys(config.repositories ?? {}),
|
|
511
|
+
...local.keys(),
|
|
512
|
+
])
|
|
190
513
|
const repositories: RepositoryInfo[] = []
|
|
191
514
|
|
|
192
|
-
for (const
|
|
193
|
-
const path = join(reposPath,
|
|
515
|
+
for (const alias of [...aliases].sort()) {
|
|
516
|
+
const path = join(reposPath, alias)
|
|
517
|
+
const entry = local.get(alias)
|
|
518
|
+
const declaredRemote = config.repositories?.[alias]?.remote ?? null
|
|
519
|
+
if (!entry) {
|
|
520
|
+
repositories.push({
|
|
521
|
+
alias,
|
|
522
|
+
path,
|
|
523
|
+
kind: null,
|
|
524
|
+
remote: null,
|
|
525
|
+
declaredRemote,
|
|
526
|
+
target: null,
|
|
527
|
+
states: ["declared", "missing"],
|
|
528
|
+
})
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
if (!entry.isDirectory && !entry.isSymlink) {
|
|
532
|
+
repositories.push({
|
|
533
|
+
alias,
|
|
534
|
+
path,
|
|
535
|
+
kind: null,
|
|
536
|
+
remote: null,
|
|
537
|
+
declaredRemote,
|
|
538
|
+
target: null,
|
|
539
|
+
states: [
|
|
540
|
+
...(declaredRemote ? (["declared"] as const) : []),
|
|
541
|
+
"invalid",
|
|
542
|
+
],
|
|
543
|
+
})
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
194
546
|
const target = entry.isSymlink
|
|
195
547
|
? yield* fs.readSymlinkTarget(path)
|
|
196
548
|
: null
|
|
197
|
-
const
|
|
198
|
-
["git", "-C", path, "rev-parse", "--
|
|
549
|
+
const git = yield* fs.runCommand(
|
|
550
|
+
["git", "-C", path, "rev-parse", "--git-dir"],
|
|
199
551
|
{ captureOutput: true },
|
|
200
552
|
)
|
|
201
|
-
const
|
|
202
|
-
["git", "-C", path, "
|
|
553
|
+
const bare = yield* fs.runCommand(
|
|
554
|
+
["git", "-C", path, "rev-parse", "--is-bare-repository"],
|
|
203
555
|
{ captureOutput: true },
|
|
204
556
|
)
|
|
205
|
-
|
|
557
|
+
const remote =
|
|
558
|
+
git.exitCode === 0 ? yield* inspectRemote(path) : null
|
|
559
|
+
const states: RepositoryState[] = []
|
|
560
|
+
if (declaredRemote) states.push("declared")
|
|
561
|
+
states.push(entry.isSymlink ? "linked" : "materialized")
|
|
562
|
+
if (git.exitCode !== 0) states.push("invalid")
|
|
563
|
+
if (declaredRemote && remote !== declaredRemote)
|
|
564
|
+
states.push("remote-drifted")
|
|
206
565
|
repositories.push({
|
|
207
|
-
alias
|
|
566
|
+
alias,
|
|
208
567
|
path,
|
|
209
568
|
kind: entry.isSymlink
|
|
210
569
|
? "symlink"
|
|
211
|
-
:
|
|
570
|
+
: bare.stdout.trim() === "true"
|
|
212
571
|
? "bare"
|
|
213
572
|
: "repository",
|
|
214
|
-
remote
|
|
215
|
-
|
|
573
|
+
remote,
|
|
574
|
+
declaredRemote,
|
|
216
575
|
target,
|
|
576
|
+
states,
|
|
217
577
|
})
|
|
218
578
|
}
|
|
219
|
-
|
|
220
579
|
return repositories
|
|
221
580
|
}),
|
|
222
581
|
|
|
@@ -226,7 +585,9 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
226
585
|
fetch: (alias: string, startPath: string = process.cwd()) =>
|
|
227
586
|
Effect.gen(function* () {
|
|
228
587
|
const fs = yield* FileSystemService
|
|
229
|
-
const repository = yield* find(alias, startPath)
|
|
588
|
+
const repository = yield* find(alias, startPath).pipe(
|
|
589
|
+
Effect.flatMap(requireMaterialized),
|
|
590
|
+
)
|
|
230
591
|
const result = yield* fs.runCommand(
|
|
231
592
|
["git", "-C", repository.path, "fetch", "--prune"],
|
|
232
593
|
{ captureOutput: true },
|
|
@@ -242,22 +603,77 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
242
603
|
remove: (alias: string, startPath: string = process.cwd()) =>
|
|
243
604
|
Effect.gen(function* () {
|
|
244
605
|
const fs = yield* FileSystemService
|
|
606
|
+
const graph = yield* GraphService
|
|
607
|
+
const workbase = yield* WorkbaseService
|
|
245
608
|
const repository = yield* find(alias, startPath)
|
|
246
|
-
yield*
|
|
247
|
-
|
|
609
|
+
const state = yield* configState(startPath)
|
|
610
|
+
const declarations = { ...(state.config.repositories ?? {}) }
|
|
611
|
+
delete declarations[repository.alias]
|
|
612
|
+
const config = withDeclarations(state.config, declarations)
|
|
613
|
+
const exists = yield* fs.exists(repository.path)
|
|
614
|
+
const staging = join(
|
|
615
|
+
state.root,
|
|
616
|
+
"repos",
|
|
617
|
+
`.agency-remove-${repository.alias}-${process.pid}-${Date.now()}`,
|
|
618
|
+
)
|
|
619
|
+
const safety = effectPreflightStep(
|
|
620
|
+
`verify repository safety for ${repository.alias}`,
|
|
621
|
+
assertRemovable(repository, startPath).pipe(
|
|
622
|
+
Effect.provideService(FileSystemService, fs),
|
|
623
|
+
Effect.provideService(GraphService, graph),
|
|
624
|
+
Effect.provideService(WorkbaseService, workbase),
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
yield* runTransaction(
|
|
628
|
+
state,
|
|
629
|
+
config,
|
|
630
|
+
exists
|
|
631
|
+
? [
|
|
632
|
+
safety,
|
|
633
|
+
deleteAfterMoveStep(state.root, repository.path, staging),
|
|
634
|
+
]
|
|
635
|
+
: [safety],
|
|
636
|
+
)
|
|
248
637
|
return repository
|
|
249
638
|
}),
|
|
250
639
|
|
|
251
640
|
unlink: (alias: string, startPath: string = process.cwd()) =>
|
|
252
641
|
Effect.gen(function* () {
|
|
253
|
-
const
|
|
642
|
+
const fs = yield* FileSystemService
|
|
643
|
+
const graph = yield* GraphService
|
|
644
|
+
const workbase = yield* WorkbaseService
|
|
254
645
|
const repository = yield* find(alias, startPath)
|
|
255
646
|
if (repository.kind !== "symlink") {
|
|
256
647
|
return yield* new RepositoryError({
|
|
257
648
|
message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
|
|
258
649
|
})
|
|
259
650
|
}
|
|
260
|
-
|
|
651
|
+
const state = yield* configState(startPath)
|
|
652
|
+
const staging = join(
|
|
653
|
+
state.root,
|
|
654
|
+
"repos",
|
|
655
|
+
`.agency-unlink-${repository.alias}-${process.pid}-${Date.now()}`,
|
|
656
|
+
)
|
|
657
|
+
yield* runLifecycleTransaction({
|
|
658
|
+
root: state.root,
|
|
659
|
+
preconditions: [{ path: state.path, revision: state.revision }],
|
|
660
|
+
steps: [
|
|
661
|
+
effectPreflightStep(
|
|
662
|
+
`verify repository safety for ${repository.alias}`,
|
|
663
|
+
assertRemovable(repository, startPath).pipe(
|
|
664
|
+
Effect.provideService(FileSystemService, fs),
|
|
665
|
+
Effect.provideService(GraphService, graph),
|
|
666
|
+
Effect.provideService(WorkbaseService, workbase),
|
|
667
|
+
),
|
|
668
|
+
),
|
|
669
|
+
deleteAfterMoveStep(state.root, repository.path, staging),
|
|
670
|
+
],
|
|
671
|
+
}).pipe(
|
|
672
|
+
Effect.mapError(
|
|
673
|
+
(cause) => new RepositoryError({ message: cause.message, cause }),
|
|
674
|
+
),
|
|
675
|
+
)
|
|
676
|
+
return repository
|
|
261
677
|
}),
|
|
262
678
|
|
|
263
679
|
rename: (
|
|
@@ -267,18 +683,51 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
267
683
|
) =>
|
|
268
684
|
Effect.gen(function* () {
|
|
269
685
|
const fs = yield* FileSystemService
|
|
686
|
+
const graph = yield* GraphService
|
|
270
687
|
const workbase = yield* WorkbaseService
|
|
271
688
|
const repository = yield* find(alias, startPath)
|
|
272
689
|
const validNewAlias = yield* validateAlias(newAlias)
|
|
273
|
-
const
|
|
274
|
-
const destination = join(root, "repos", validNewAlias)
|
|
275
|
-
if (
|
|
690
|
+
const state = yield* configState(startPath)
|
|
691
|
+
const destination = join(state.root, "repos", validNewAlias)
|
|
692
|
+
if (
|
|
693
|
+
state.config.repositories?.[validNewAlias] ||
|
|
694
|
+
(yield* fs.exists(destination))
|
|
695
|
+
) {
|
|
276
696
|
return yield* new RepositoryError({
|
|
277
697
|
message: `Repository alias '${validNewAlias}' already exists`,
|
|
278
698
|
})
|
|
279
699
|
}
|
|
280
|
-
|
|
281
|
-
|
|
700
|
+
const remote =
|
|
701
|
+
state.config.repositories?.[repository.alias]?.remote ??
|
|
702
|
+
repository.remote
|
|
703
|
+
if (!remote) {
|
|
704
|
+
return yield* new RepositoryError({
|
|
705
|
+
message: `Repository alias '${alias}' has no portable remote and cannot be renamed before adoption`,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
const portable = yield* validateRemote(remote)
|
|
709
|
+
const declarations = { ...(state.config.repositories ?? {}) }
|
|
710
|
+
delete declarations[repository.alias]
|
|
711
|
+
declarations[validNewAlias] = { remote: portable }
|
|
712
|
+
const exists = yield* fs.exists(repository.path)
|
|
713
|
+
const safety = effectPreflightStep(
|
|
714
|
+
`verify repository safety for ${repository.alias}`,
|
|
715
|
+
assertRemovable(repository, startPath).pipe(
|
|
716
|
+
Effect.provideService(FileSystemService, fs),
|
|
717
|
+
Effect.provideService(GraphService, graph),
|
|
718
|
+
Effect.provideService(WorkbaseService, workbase),
|
|
719
|
+
),
|
|
720
|
+
)
|
|
721
|
+
yield* runTransaction(
|
|
722
|
+
state,
|
|
723
|
+
withDeclarations(state.config, declarations),
|
|
724
|
+
exists
|
|
725
|
+
? [
|
|
726
|
+
safety,
|
|
727
|
+
directoryMoveStep(state.root, repository.path, destination),
|
|
728
|
+
]
|
|
729
|
+
: [safety],
|
|
730
|
+
)
|
|
282
731
|
return yield* find(validNewAlias, startPath)
|
|
283
732
|
}),
|
|
284
733
|
|
|
@@ -291,50 +740,228 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
291
740
|
const fs = yield* FileSystemService
|
|
292
741
|
const repository = yield* find(alias, startPath)
|
|
293
742
|
if (remote === undefined) return repository
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
743
|
+
const portable = yield* validateRemote(remote)
|
|
744
|
+
const state = yield* configState(startPath)
|
|
745
|
+
const config = withDeclarations(state.config, {
|
|
746
|
+
...(state.config.repositories ?? {}),
|
|
747
|
+
[repository.alias]: { remote: portable },
|
|
748
|
+
})
|
|
749
|
+
const steps: TransactionStep[] = []
|
|
750
|
+
if (
|
|
751
|
+
repository.kind !== null &&
|
|
752
|
+
repository.kind !== "symlink" &&
|
|
753
|
+
!repository.states.includes("invalid")
|
|
754
|
+
) {
|
|
755
|
+
const previous = repository.remote
|
|
756
|
+
const update = (value: string | null) =>
|
|
757
|
+
Effect.runPromise(
|
|
758
|
+
fs.runCommand(
|
|
759
|
+
value === null
|
|
760
|
+
? [
|
|
761
|
+
"git",
|
|
762
|
+
"-C",
|
|
763
|
+
repository.path,
|
|
764
|
+
"remote",
|
|
765
|
+
"remove",
|
|
766
|
+
"origin",
|
|
767
|
+
]
|
|
768
|
+
: [
|
|
769
|
+
"git",
|
|
770
|
+
"-C",
|
|
771
|
+
repository.path,
|
|
772
|
+
"remote",
|
|
773
|
+
previous === null ? "add" : "set-url",
|
|
774
|
+
"origin",
|
|
775
|
+
value,
|
|
776
|
+
],
|
|
777
|
+
{ captureOutput: true },
|
|
778
|
+
),
|
|
779
|
+
).then((result) => {
|
|
780
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim())
|
|
781
|
+
})
|
|
782
|
+
steps.push({
|
|
783
|
+
label: `update origin for repos/${repository.alias}`,
|
|
784
|
+
preflight: async () => {
|
|
785
|
+
const stats = await lstat(repository.path)
|
|
786
|
+
if (stats.isSymbolicLink()) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`Repository alias '${repository.alias}' changed to a linked checkout; retry the remote update`,
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
const current = await Effect.runPromise(
|
|
792
|
+
fs.runCommand(
|
|
793
|
+
[
|
|
794
|
+
"git",
|
|
795
|
+
"-C",
|
|
796
|
+
repository.path,
|
|
797
|
+
"remote",
|
|
798
|
+
"get-url",
|
|
799
|
+
"origin",
|
|
800
|
+
],
|
|
801
|
+
{ captureOutput: true },
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
const currentRemote =
|
|
805
|
+
current.exitCode === 0 ? current.stdout.trim() : null
|
|
806
|
+
if (currentRemote !== previous) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`Origin for repository '${repository.alias}' changed; retry the remote update`,
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
apply: () => update(portable),
|
|
813
|
+
rollback: () => update(previous),
|
|
814
|
+
manualRecovery: `Restore origin for ${repository.path} to ${previous ?? "no remote"}`,
|
|
315
815
|
})
|
|
316
816
|
}
|
|
817
|
+
yield* runTransaction(state, config, steps)
|
|
317
818
|
return yield* find(alias, startPath)
|
|
318
819
|
}),
|
|
319
820
|
|
|
320
821
|
verify: (alias: string, startPath: string = process.cwd()) =>
|
|
321
822
|
Effect.gen(function* () {
|
|
322
|
-
const fs = yield* FileSystemService
|
|
323
823
|
const repository = yield* find(alias, startPath)
|
|
324
824
|
const issues: string[] = []
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
825
|
+
if (repository.states.includes("missing"))
|
|
826
|
+
issues.push("Local materialization is missing")
|
|
827
|
+
if (repository.states.includes("invalid"))
|
|
828
|
+
issues.push("Path is not a Git repository")
|
|
829
|
+
if (!repository.declaredRemote)
|
|
830
|
+
issues.push("Portable remote is not declared")
|
|
831
|
+
if (repository.states.includes("remote-drifted"))
|
|
832
|
+
issues.push("Origin remote differs from the portable declaration")
|
|
332
833
|
return {
|
|
333
834
|
...repository,
|
|
334
835
|
valid: issues.length === 0,
|
|
335
836
|
issues,
|
|
336
837
|
} satisfies RepositoryVerification
|
|
337
838
|
}),
|
|
839
|
+
|
|
840
|
+
setup: (
|
|
841
|
+
options: { readonly cwd?: string; readonly apply?: boolean } = {},
|
|
842
|
+
) =>
|
|
843
|
+
Effect.gen(function* () {
|
|
844
|
+
const service = yield* RepositoryService
|
|
845
|
+
const fs = yield* FileSystemService
|
|
846
|
+
const state = yield* configState(options.cwd ?? process.cwd())
|
|
847
|
+
const repositories = yield* service.list(state.root)
|
|
848
|
+
const planned: Omit<RepositorySetupAction, "status">[] = []
|
|
849
|
+
const unresolved: RepositorySetupIssue[] = []
|
|
850
|
+
|
|
851
|
+
for (const repository of repositories) {
|
|
852
|
+
if (repository.states.includes("invalid")) {
|
|
853
|
+
unresolved.push({
|
|
854
|
+
alias: repository.alias,
|
|
855
|
+
state: "invalid",
|
|
856
|
+
message: `Local path for '${repository.alias}' is not a valid Git repository`,
|
|
857
|
+
action: `Repair the path or run 'agency repo remove ${repository.alias}' before setup`,
|
|
858
|
+
})
|
|
859
|
+
continue
|
|
860
|
+
}
|
|
861
|
+
if (repository.states.includes("remote-drifted")) {
|
|
862
|
+
unresolved.push({
|
|
863
|
+
alias: repository.alias,
|
|
864
|
+
state: "remote-drifted",
|
|
865
|
+
message: `Origin for '${repository.alias}' differs from its portable declaration`,
|
|
866
|
+
action: `Choose the intended remote explicitly with 'agency repo remote ${repository.alias} <remote>'`,
|
|
867
|
+
})
|
|
868
|
+
continue
|
|
869
|
+
}
|
|
870
|
+
if (
|
|
871
|
+
repository.states.includes("missing") &&
|
|
872
|
+
repository.declaredRemote
|
|
873
|
+
) {
|
|
874
|
+
planned.push({
|
|
875
|
+
kind: "materialize",
|
|
876
|
+
alias: repository.alias,
|
|
877
|
+
remote: repository.declaredRemote,
|
|
878
|
+
})
|
|
879
|
+
continue
|
|
880
|
+
}
|
|
881
|
+
if (!repository.states.includes("declared")) {
|
|
882
|
+
const decoded = repository.remote
|
|
883
|
+
? Schema.decodeUnknownEither(RepositoryRemote)(
|
|
884
|
+
repository.remote,
|
|
885
|
+
)
|
|
886
|
+
: null
|
|
887
|
+
if (decoded && Either.isRight(decoded)) {
|
|
888
|
+
planned.push({
|
|
889
|
+
kind: "adopt",
|
|
890
|
+
alias: repository.alias,
|
|
891
|
+
remote: decoded.right,
|
|
892
|
+
})
|
|
893
|
+
} else {
|
|
894
|
+
unresolved.push({
|
|
895
|
+
alias: repository.alias,
|
|
896
|
+
state: "undeclared",
|
|
897
|
+
message: `Local repository '${repository.alias}' has no portable remote declaration`,
|
|
898
|
+
action: `Set a portable origin, then rerun 'agency repo setup --apply'`,
|
|
899
|
+
})
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (options.apply === true && planned.length > 0) {
|
|
905
|
+
const staging: { alias: string; from: string; to: string }[] = []
|
|
906
|
+
for (const action of planned.filter(
|
|
907
|
+
(action) => action.kind === "materialize",
|
|
908
|
+
)) {
|
|
909
|
+
const from = join(
|
|
910
|
+
state.root,
|
|
911
|
+
"repos",
|
|
912
|
+
`.agency-setup-${action.alias}-${process.pid}-${Date.now()}`,
|
|
913
|
+
)
|
|
914
|
+
yield* fs.createDirectory(join(state.root, "repos"))
|
|
915
|
+
const cloned = yield* fs.runCommand(
|
|
916
|
+
["git", "clone", "--bare", "--", action.remote, from],
|
|
917
|
+
{ captureOutput: true },
|
|
918
|
+
)
|
|
919
|
+
if (cloned.exitCode !== 0) {
|
|
920
|
+
for (const item of staging)
|
|
921
|
+
yield* fs.deleteDirectory(item.from).pipe(Effect.ignore)
|
|
922
|
+
return yield* new RepositoryError({
|
|
923
|
+
message: `Failed to materialize repository '${action.alias}': ${cloned.stderr.trim()}`,
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
staging.push({
|
|
927
|
+
alias: action.alias,
|
|
928
|
+
from,
|
|
929
|
+
to: join(state.root, "repos", action.alias),
|
|
930
|
+
})
|
|
931
|
+
}
|
|
932
|
+
const declarations = { ...(state.config.repositories ?? {}) }
|
|
933
|
+
for (const action of planned) {
|
|
934
|
+
declarations[action.alias] = { remote: action.remote }
|
|
935
|
+
}
|
|
936
|
+
yield* runTransaction(
|
|
937
|
+
state,
|
|
938
|
+
withDeclarations(state.config, declarations),
|
|
939
|
+
staging.map((item) =>
|
|
940
|
+
directoryMoveStep(state.root, item.from, item.to),
|
|
941
|
+
),
|
|
942
|
+
).pipe(
|
|
943
|
+
Effect.ensuring(
|
|
944
|
+
Effect.forEach(staging, (item) =>
|
|
945
|
+
fs.deleteDirectory(item.from).pipe(Effect.ignore),
|
|
946
|
+
).pipe(Effect.asVoid),
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
root: state.root,
|
|
953
|
+
mode: options.apply === true ? "apply" : "dry-run",
|
|
954
|
+
actions: planned.map((action) => ({
|
|
955
|
+
...action,
|
|
956
|
+
status: options.apply === true ? "applied" : "planned",
|
|
957
|
+
})),
|
|
958
|
+
unresolved,
|
|
959
|
+
repositories:
|
|
960
|
+
options.apply === true
|
|
961
|
+
? yield* service.list(state.root)
|
|
962
|
+
: repositories,
|
|
963
|
+
} satisfies RepositorySetupResult
|
|
964
|
+
}),
|
|
338
965
|
}),
|
|
339
966
|
},
|
|
340
967
|
) {}
|