@markjaquith/agency 2.73.0 → 2.73.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.73.0",
3
+ "version": "2.73.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -229,7 +229,9 @@ describe("strict CLI parsing", () => {
229
229
  test("parses addressable resource maintenance commands", () => {
230
230
  for (const args of [
231
231
  ["doctor", "--json"],
232
+ ["repo", "materialize", "--help"],
232
233
  ["repo", "show", "agency", "--json"],
234
+ ["repo", "materialize", "agency", "--json"],
233
235
  ["repo", "fetch", "agency"],
234
236
  ["repo", "remove", "agency"],
235
237
  ["repo", "unlink", "agency"],
@@ -270,6 +272,7 @@ describe("strict CLI parsing", () => {
270
272
  [["integration", "sync", "extra"], "agency integration sync"],
271
273
  [["repo", "add", "a", "b", "extra"], "agency repo add"],
272
274
  [["repo", "link", "a", "b", "extra"], "agency repo link"],
275
+ [["repo", "materialize", "a", "extra"], "agency repo materialize"],
273
276
  [["repo", "list", "extra"], "agency repo list"],
274
277
  [
275
278
  [
package/src/cli-parser.ts CHANGED
@@ -259,7 +259,7 @@ const commands = {
259
259
  },
260
260
  repo: {
261
261
  usage:
262
- "agency repo <setup|add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
262
+ "agency repo <setup|add|link|materialize|list|show|fetch|remove|unlink|rename|remote|verify>",
263
263
  options: {
264
264
  ...outputOptions,
265
265
  "dry-run": { type: "boolean" },
@@ -285,6 +285,12 @@ const commands = {
285
285
  maxArgs: 2,
286
286
  options: ["json"],
287
287
  },
288
+ materialize: {
289
+ usage: "agency repo materialize <alias> [--json]",
290
+ minArgs: 1,
291
+ maxArgs: 1,
292
+ options: ["json"],
293
+ },
288
294
  list: {
289
295
  usage: "agency repo list [--json]",
290
296
  minArgs: 0,
package/src/cli.test.ts CHANGED
@@ -934,6 +934,48 @@ status: open
934
934
  })
935
935
  })
936
936
 
937
+ test("materializes a linked repository through the CLI", async () => {
938
+ const parent = await createTempDir()
939
+ tempDirs.push(parent)
940
+ const root = join(parent, "workbase")
941
+ const source = join(parent, "source")
942
+ await runGit(["init", "--initial-branch=main", source])
943
+ await Bun.write(join(source, "README.md"), "materialize\n")
944
+ for (const args of [
945
+ ["config", "user.email", "test@example.com"],
946
+ ["config", "user.name", "Test"],
947
+ ["add", "README.md"],
948
+ ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
949
+ [
950
+ "remote",
951
+ "add",
952
+ "origin",
953
+ "https://example.com/agency-tests/source.git",
954
+ ],
955
+ ]) {
956
+ await runGit(["-C", source, ...args])
957
+ }
958
+
959
+ parseJson(await runCli(["init", root, "--json"], parent))
960
+ const configPath = join(root, "agency.json")
961
+ await Bun.write(
962
+ configPath,
963
+ JSON.stringify({ ...(await Bun.file(configPath).json()), vcs: "git" }),
964
+ )
965
+ parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
966
+ const materialized = parseJson(
967
+ await runCli(["repo", "materialize", "agency", "--json"], root),
968
+ )
969
+
970
+ expect(materialized).toMatchObject({
971
+ alias: "agency",
972
+ kind: "bare",
973
+ target: null,
974
+ states: ["declared", "materialized"],
975
+ })
976
+ expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
977
+ })
978
+
937
979
  test("prepares a workspace and reports a non-mutating dry-run", async () => {
938
980
  const parent = await createTempDir()
939
981
  tempDirs.push(parent)
@@ -212,6 +212,13 @@ status: working
212
212
  "main",
213
213
  ])
214
214
  await Bun.write(join(checkout, "dirty.txt"), "preserved\n")
215
+ await runGit([
216
+ "-C",
217
+ target,
218
+ "update-ref",
219
+ "refs/agency/reviews/active",
220
+ "main",
221
+ ])
215
222
 
216
223
  const result = await runTestEffect(
217
224
  RepositoryService.pipe(
@@ -238,6 +245,96 @@ status: working
238
245
  expect(await Bun.file(join(root, "tasks/active/TASK.md")).text()).toContain(
239
246
  "repo: linked",
240
247
  )
248
+ expect(
249
+ await gitOutput([
250
+ "--git-dir",
251
+ join(root, "repos/linked"),
252
+ "rev-parse",
253
+ "refs/agency/reviews/active",
254
+ ]),
255
+ ).toBe(await gitOutput(["-C", target, "rev-parse", "main"]))
256
+ })
257
+
258
+ test("preserves detached registered worktree commits from a shallow linked repository", async () => {
259
+ const upstream = join(root, "upstream")
260
+ const target = join(root, "shallow-linked-repository")
261
+ const checkout = join(root, "tasks/detached/code/linked")
262
+ await runGit(["init", "--bare", "--initial-branch=main", upstream])
263
+ const seed = join(root, "seed")
264
+ await runGit(["clone", upstream, seed])
265
+ await runGit(["-C", seed, "config", "user.email", "test@example.com"])
266
+ await runGit(["-C", seed, "config", "user.name", "Test"])
267
+ await Bun.write(join(seed, "README.md"), "shallow\n")
268
+ await runGit(["-C", seed, "add", "README.md"])
269
+ await runGit(["-C", seed, "commit", "-m", "initial"])
270
+ await runGit(["-C", seed, "push", "origin", "main"])
271
+ await runGit(["clone", "--depth=1", `file://${upstream}`, target])
272
+ await runGit(["-C", target, "config", "user.email", "test@example.com"])
273
+ await runGit(["-C", target, "config", "user.name", "Test"])
274
+ await runGit([
275
+ "-C",
276
+ target,
277
+ "remote",
278
+ "set-url",
279
+ "origin",
280
+ portableRemote("shallow-materialized"),
281
+ ])
282
+ await mkdir(dirname(checkout), { recursive: true })
283
+ await runGit([
284
+ "-C",
285
+ target,
286
+ "worktree",
287
+ "add",
288
+ "--detach",
289
+ checkout,
290
+ "main",
291
+ ])
292
+ await Bun.write(join(checkout, "detached.txt"), "preserved\n")
293
+ await runGit(["-C", checkout, "add", "detached.txt"])
294
+ await runGit(["-C", checkout, "commit", "-m", "detached work"])
295
+ const detachedCommit = await gitOutput([
296
+ "-C",
297
+ checkout,
298
+ "rev-parse",
299
+ "HEAD",
300
+ ])
301
+ await runTestEffect(
302
+ RepositoryService.pipe(
303
+ Effect.flatMap((service) => service.link("linked", target, root)),
304
+ ),
305
+ )
306
+
307
+ await runTestEffect(
308
+ RepositoryService.pipe(
309
+ Effect.flatMap((service) => service.materialize("linked", root)),
310
+ ),
311
+ )
312
+
313
+ expect(await gitOutput(["-C", checkout, "rev-parse", "HEAD"])).toBe(
314
+ detachedCommit,
315
+ )
316
+ expect(await Bun.file(join(checkout, "detached.txt")).text()).toBe(
317
+ "preserved\n",
318
+ )
319
+ expect(
320
+ await gitOutput(["-C", checkout, "rev-parse", "--git-common-dir"]),
321
+ ).toBe(await realpath(join(root, "repos/linked")))
322
+ await runGit([
323
+ "--git-dir",
324
+ join(root, "repos/linked"),
325
+ "cat-file",
326
+ "-e",
327
+ `${detachedCommit}^{commit}`,
328
+ ])
329
+ expect(
330
+ await gitOutput([
331
+ "--git-dir",
332
+ join(root, "repos/linked"),
333
+ "for-each-ref",
334
+ "--format=%(refname)",
335
+ "refs/agency/materialize/",
336
+ ]),
337
+ ).toBe("")
241
338
  })
242
339
 
243
340
  test("materializes an alias linked through an existing worktree", async () => {
@@ -19,6 +19,7 @@ import {
19
19
  import { documentRevision } from "../workbase/document-revision"
20
20
  import {
21
21
  VersionControlService,
22
+ type RegisteredWorkspace,
22
23
  type VersionControlBackend,
23
24
  } from "./VersionControlService"
24
25
 
@@ -558,9 +559,9 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
558
559
 
559
560
  const source = yield* fs.realPath(repository.path)
560
561
  const registered = yield* backend.listWorkspaces(repository.path)
561
- const registeredPaths = registered
562
- .map((workspace) => workspace.path)
563
- .sort()
562
+ const registeredState = registered
563
+ .map(({ path, commit, branch }) => ({ path, commit, branch }))
564
+ .sort((left, right) => left.path.localeCompare(right.path))
564
565
  const worktrees: (typeof registered)[number][] = []
565
566
  for (const workspace of registered) {
566
567
  if (!(yield* fs.isDirectory(workspace.path))) {
@@ -634,6 +635,77 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
634
635
  ),
635
636
  ),
636
637
  )
638
+ const reviewRefs = yield* fs.runCommand(
639
+ [
640
+ "git",
641
+ "-C",
642
+ source,
643
+ "for-each-ref",
644
+ "--format=%(refname)",
645
+ "refs/agency/reviews/",
646
+ ],
647
+ { captureOutput: true },
648
+ )
649
+ if (reviewRefs.exitCode !== 0) {
650
+ yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
651
+ return yield* new RepositoryError({
652
+ message: `Failed to enumerate Agency review refs while materializing repository '${alias}'`,
653
+ })
654
+ }
655
+ const preservedReviewRefs = reviewRefs.stdout
656
+ .split("\n")
657
+ .map((ref) => ref.trim())
658
+ .filter(Boolean)
659
+ const worktreeCommits = [
660
+ ...new Set(
661
+ worktrees
662
+ .map((workspace) => workspace.commit)
663
+ .filter((commit): commit is string => commit !== null),
664
+ ),
665
+ ]
666
+ const temporaryRefs = worktreeCommits.map(
667
+ (_, index) =>
668
+ `refs/agency/materialize/${suffix}/${String(index).padStart(4, "0")}`,
669
+ )
670
+ const preservationRefspecs = [
671
+ ...preservedReviewRefs.map((ref) => `+${ref}:${ref}`),
672
+ ...worktreeCommits.map(
673
+ (commit, index) => `+${commit}:${temporaryRefs[index]}`,
674
+ ),
675
+ ]
676
+ if (preservationRefspecs.length > 0) {
677
+ const preserved = yield* fs.runCommand(
678
+ [
679
+ "git",
680
+ "--git-dir",
681
+ staging,
682
+ "fetch",
683
+ "--no-tags",
684
+ "--no-write-fetch-head",
685
+ source,
686
+ ...preservationRefspecs,
687
+ ],
688
+ { captureOutput: true },
689
+ )
690
+ if (preserved.exitCode !== 0) {
691
+ yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
692
+ return yield* new RepositoryError({
693
+ message: `Failed to preserve Agency refs and registered worktree commits while materializing repository '${alias}': ${preserved.stderr.trim()}`,
694
+ })
695
+ }
696
+ for (const ref of temporaryRefs) {
697
+ const removed = yield* fs.runCommand(
698
+ ["git", "--git-dir", staging, "update-ref", "-d", ref],
699
+ { captureOutput: true },
700
+ )
701
+ if (removed.exitCode !== 0) {
702
+ yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
703
+ return yield* new RepositoryError({
704
+ message: `Failed to remove temporary materialization ref '${ref}' for repository '${alias}': ${removed.stderr.trim()}`,
705
+ })
706
+ }
707
+ }
708
+ }
637
709
  yield* backend
638
710
  .setRemoteUrl(staging, "origin", repository.declaredRemote)
639
711
  .pipe(
@@ -728,16 +800,16 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
728
800
  .pipe(
729
801
  Effect.provideService(FileSystemService, fs),
730
802
  ) as unknown as Effect.Effect<
731
- readonly { readonly path: string }[],
803
+ readonly RegisteredWorkspace[],
732
804
  unknown,
733
805
  never
734
806
  >,
735
807
  )
736
- const currentPaths = current
737
- .map((workspace) => workspace.path)
738
- .sort()
808
+ const currentState = current
809
+ .map(({ path, commit, branch }) => ({ path, commit, branch }))
810
+ .sort((left, right) => left.path.localeCompare(right.path))
739
811
  if (
740
- JSON.stringify(currentPaths) !== JSON.stringify(registeredPaths)
812
+ JSON.stringify(currentState) !== JSON.stringify(registeredState)
741
813
  )
742
814
  throw new Error(
743
815
  `Git worktree registrations changed during materialization`,