@zq-silk/yui 0.5.1 → 0.5.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.
@@ -205,11 +205,11 @@ async function cloneProject(args, store, options) {
205
205
  }
206
206
  /**
207
207
  * Migrate an external Project binding to a Home-managed repository. The
208
- * remote is cloned and both configured branches are verified against the
209
- * advertised remote SHAs before the catalog record switches; the old
210
- * checkout is never touched and stays usable until the switch commits. A
211
- * failed migration leaves no partial state: the unfinished managed clone is
212
- * removed so the command can be retried cleanly.
208
+ * remote is cloned, both configured branches are verified against the
209
+ * advertised remote SHAs, and local Yui refs are imported before the catalog
210
+ * record switches. The old checkout is never touched. A failed migration
211
+ * leaves no partial state: the unfinished managed clone is removed so the
212
+ * command can be retried cleanly.
213
213
  */
214
214
  async function migrateProject(args, store, options) {
215
215
  const usage = "Project migrate usage: yui project migrate <project> [--preflight].";
@@ -264,6 +264,15 @@ async function migrateProject(args, store, options) {
264
264
  localCommit: (await git.inspect(verifyRoot, project.developmentBranch)).baseCommit
265
265
  }])
266
266
  ]);
267
+ const copyRefs = git.copyRefs;
268
+ if (typeof copyRefs !== "function") {
269
+ throw new Error(`Git workspace cannot preserve local Yui refs for Project: ${project.id}.`);
270
+ }
271
+ await copyRefs.call(git, {
272
+ sourceRepositoryPath: project.path,
273
+ destinationRepositoryPath: verifyRoot,
274
+ patterns: ["refs/heads/yui/", "refs/yui/archive/"]
275
+ });
267
276
  if (parsed.preflight) {
268
277
  return { project, path: destination, preflight: true };
269
278
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
3
  import { lstat, mkdir, realpath, rm } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
@@ -322,6 +322,73 @@ export class NodeGitWorkspace {
322
322
  return output.length === 0 ? [] : output.split("\n").map((line) => line.trim())
323
323
  .filter((line) => line.length > 0);
324
324
  }
325
+ async copyRefs(input) {
326
+ const source = await this.inspect(input.sourceRepositoryPath);
327
+ const destination = await this.inspect(input.destinationRepositoryPath);
328
+ if (source.gitDirectory === destination.gitDirectory)
329
+ return [];
330
+ const refs = [...new Set((await Promise.all(input.patterns.map((pattern) => this.listRefs(source.root, pattern)))).flat())].sort();
331
+ const snapshots = [];
332
+ for (const ref of refs) {
333
+ const name = safeRef(ref);
334
+ const commit = await resolveRefCommit(source.root, name);
335
+ if (await this.refExists(destination.root, name)) {
336
+ const existing = await resolveRefCommit(destination.root, name);
337
+ if (existing !== commit) {
338
+ throw new Error(`Destination ref already exists at a different commit: ${name}.`);
339
+ }
340
+ }
341
+ snapshots.push({ ref: name, commit });
342
+ }
343
+ const importRoot = `refs/yui/migration-import/${randomBytes(16).toString("hex")}`;
344
+ const temporary = [];
345
+ try {
346
+ for (const [index, snapshot] of snapshots.entries()) {
347
+ const importedRef = `${importRoot}/${index}`;
348
+ await git([
349
+ "-C", destination.root,
350
+ "fetch", "--no-tags", "--no-write-fetch-head", "--",
351
+ source.root, `${snapshot.ref}:${importedRef}`
352
+ ]);
353
+ const imported = await resolveRefCommit(destination.root, importedRef);
354
+ if (imported !== snapshot.commit) {
355
+ throw new Error(`Imported ref did not preserve its exact commit: ${snapshot.ref}.`);
356
+ }
357
+ temporary.push({ ref: importedRef, commit: imported });
358
+ }
359
+ // Freeze the source snapshots through publication. A moving local ref is
360
+ // never silently copied under an earlier/later identity.
361
+ for (const snapshot of snapshots) {
362
+ if (await resolveRefCommit(source.root, snapshot.ref) !== snapshot.commit) {
363
+ throw new Error(`Source ref changed while it was being copied: ${snapshot.ref}.`);
364
+ }
365
+ }
366
+ for (const snapshot of snapshots) {
367
+ if (await this.refExists(destination.root, snapshot.ref)) {
368
+ if (await resolveRefCommit(destination.root, snapshot.ref) !== snapshot.commit) {
369
+ throw new Error(`Destination ref changed while it was being copied: ${snapshot.ref}.`);
370
+ }
371
+ continue;
372
+ }
373
+ await git([
374
+ "-C", destination.root,
375
+ "update-ref", "--no-deref", snapshot.ref, snapshot.commit,
376
+ "0".repeat(snapshot.commit.length)
377
+ ]);
378
+ }
379
+ return snapshots.map(({ ref }) => ref);
380
+ }
381
+ finally {
382
+ for (const entry of temporary) {
383
+ if (await this.refExists(destination.root, entry.ref)) {
384
+ await git([
385
+ "-C", destination.root,
386
+ "update-ref", "-d", "--no-deref", entry.ref, entry.commit
387
+ ]);
388
+ }
389
+ }
390
+ }
391
+ }
325
392
  async archiveRef(input) {
326
393
  const root = (await this.inspect(input.repositoryPath)).root;
327
394
  const source = safeRef(input.sourceRef);
@@ -342,7 +409,7 @@ export class NodeGitWorkspace {
342
409
  else {
343
410
  // Create the archive ref only if it does not exist yet (old value zero).
344
411
  await git([
345
- "-C", root, "update-ref", "--no-deref", target, commit, "0".repeat(40)
412
+ "-C", root, "update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
346
413
  ]);
347
414
  }
348
415
  // Delete the source only if it still exists and still points at the
@@ -510,6 +577,37 @@ export class NodeGitWorkspace {
510
577
  }
511
578
  return "removed";
512
579
  }
580
+ async inspectRecordedWorktree(input) {
581
+ const inspected = await inspectExactRecordedWorktree(input);
582
+ return inspected?.state ?? "missing";
583
+ }
584
+ async removeRecordedWorktree(input) {
585
+ const inspected = await inspectExactRecordedWorktree(input);
586
+ if (inspected === undefined)
587
+ return "missing";
588
+ if (inspected.state === "dirty")
589
+ return "dirty";
590
+ // Revalidate immediately before asking the worktree's own Git common-dir
591
+ // to remove it. This never relies on the Project catalog's former path.
592
+ const current = await inspectExactRecordedWorktree(input);
593
+ if (current === undefined)
594
+ return "missing";
595
+ if (current.state === "dirty")
596
+ return "dirty";
597
+ await retainCommitRef(current.destinationRoot, input.retainedRef, current.head);
598
+ await git(["-C", current.path, "worktree", "remove", "--", current.path]);
599
+ if (await pathKind(current.path) !== undefined) {
600
+ throw new Error(`Recorded managed worktree remained after removal: ${current.path}.`);
601
+ }
602
+ if (current.gitDirectory !== current.destinationGitDirectory) {
603
+ await git([
604
+ `--git-dir=${current.gitDirectory}`,
605
+ "update-ref", "-d", "--no-deref", `refs/heads/${safeRef(input.branch)}`,
606
+ current.head
607
+ ]);
608
+ }
609
+ return "removed";
610
+ }
513
611
  async inspectWorktree(input) {
514
612
  const container = resolve(input.container);
515
613
  const path = managedPath(container, worktreeIdentity(input.taskSegment, input.roleName).directory);
@@ -625,6 +723,75 @@ async function assertOwnedWorktree(project, container, path) {
625
723
  throw new Error("Managed worktree belongs to another project.");
626
724
  }
627
725
  }
726
+ async function inspectExactRecordedWorktree(input) {
727
+ const container = resolve(input.container);
728
+ const identity = worktreeIdentity(input.taskSegment, input.roleName);
729
+ const expectedPath = managedPath(container, identity.directory);
730
+ if (resolve(input.path) !== expectedPath || input.branch !== identity.branch) {
731
+ throw new Error("Recorded managed worktree identity is invalid.");
732
+ }
733
+ const kind = await pathKind(expectedPath);
734
+ if (kind === undefined)
735
+ return undefined;
736
+ if (kind === "symlink") {
737
+ throw new Error("Recorded managed worktree path must not be a symbolic link.");
738
+ }
739
+ const canonicalContainerPath = await canonicalContainer(container, false);
740
+ const path = await canonicalDirectory(expectedPath, "Recorded managed worktree");
741
+ assertContained(canonicalContainerPath, path);
742
+ if (path !== expectedPath) {
743
+ throw new Error("Recorded managed worktree resolves through a symbolic link.");
744
+ }
745
+ const root = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--show-toplevel"]), "Recorded managed worktree root");
746
+ if (root !== path) {
747
+ throw new Error("Recorded managed worktree root does not match its deterministic path.");
748
+ }
749
+ const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
750
+ if (branch !== input.branch) {
751
+ throw new Error(`Recorded managed worktree is on an unexpected branch: ${branch}.`);
752
+ }
753
+ const gitDirectory = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]), "Recorded managed Git common directory");
754
+ const destination = await new NodeGitWorkspace().inspect(input.repositoryPath);
755
+ const head = await resolveRefCommit(path, "HEAD");
756
+ const retained = await resolveRefCommit(destination.root, `refs/heads/${input.branch}`);
757
+ if (retained !== head) {
758
+ throw new Error(`Recorded managed worktree is not retained by the current Project: ${input.branch}.`);
759
+ }
760
+ const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
761
+ return {
762
+ path,
763
+ gitDirectory,
764
+ destinationGitDirectory: destination.gitDirectory,
765
+ destinationRoot: destination.root,
766
+ head,
767
+ state: status.length === 0 ? "clean" : "dirty"
768
+ };
769
+ }
770
+ async function resolveRefCommit(repositoryPath, ref) {
771
+ const commit = (await gitLine([
772
+ "-C", repositoryPath,
773
+ "rev-parse", "--verify", "--end-of-options", `${safeRef(ref)}^{commit}`
774
+ ])).toLowerCase();
775
+ if (!isCommit(commit))
776
+ throw new Error("Git returned an invalid ref commit.");
777
+ return commit;
778
+ }
779
+ async function retainCommitRef(repositoryPath, ref, commit) {
780
+ const target = safeRef(ref);
781
+ if (await gitSucceeds([
782
+ "-C", repositoryPath,
783
+ "rev-parse", "--verify", "--quiet", "--end-of-options", `${target}^{commit}`
784
+ ])) {
785
+ if (await resolveRefCommit(repositoryPath, target) !== commit) {
786
+ throw new Error(`Retained ref already exists at a different commit: ${target}.`);
787
+ }
788
+ return;
789
+ }
790
+ await git([
791
+ "-C", repositoryPath,
792
+ "update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
793
+ ]);
794
+ }
628
795
  async function canonicalContainer(path, create) {
629
796
  const lexical = resolve(path);
630
797
  if (create)
@@ -1508,7 +1508,7 @@ export class FileTaskWorkspacePreparer {
1508
1508
  throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
1509
1509
  }
1510
1510
  if (existing !== null
1511
- && await this.#inspectEntries(task.id, MAIN_WORKTREE, existing.entries) === "dirty") {
1511
+ && await this.#inspectLegacyTaskEntries(task, existing.entries) === "dirty") {
1512
1512
  throw new Error(`Task workspace is dirty and blocks the rebuild: ${task.id}.`);
1513
1513
  }
1514
1514
  // Resolve verified remote SHAs before any Git side effect, mirroring the
@@ -1792,9 +1792,14 @@ export class FileTaskWorkspacePreparer {
1792
1792
  async #removeLegacyWorktrees(task, projectIds) {
1793
1793
  for (const projectId of projectIds) {
1794
1794
  const project = requireProject(this.store, projectId);
1795
- const removal = await this.git.removeWorktree({
1795
+ const container = this.#projectContainer(project.name);
1796
+ const branch = worktreeIdentity(task.id, MAIN_WORKTREE).branch;
1797
+ const removal = await this.git.removeRecordedWorktree({
1796
1798
  repositoryPath: project.path,
1797
- container: this.#projectContainer(project.name),
1799
+ container,
1800
+ path: join(container, task.id, MAIN_WORKTREE),
1801
+ branch,
1802
+ retainedRef: taskArchiveRef(this.store.getHomeIdentity().homeId, `refs/heads/${branch}`),
1798
1803
  taskSegment: task.id,
1799
1804
  roleName: MAIN_WORKTREE
1800
1805
  });
@@ -1803,6 +1808,24 @@ export class FileTaskWorkspacePreparer {
1803
1808
  }
1804
1809
  }
1805
1810
  }
1811
+ async #inspectLegacyTaskEntries(task, entries) {
1812
+ let found = false;
1813
+ for (const entry of entries) {
1814
+ const project = requireProject(this.store, entry.projectId);
1815
+ const state = await this.git.inspectRecordedWorktree({
1816
+ repositoryPath: project.path,
1817
+ container: this.#projectContainer(project.name),
1818
+ path: entry.path,
1819
+ branch: entry.branch,
1820
+ taskSegment: task.id,
1821
+ roleName: MAIN_WORKTREE
1822
+ });
1823
+ if (state === "dirty")
1824
+ return state;
1825
+ found ||= state === "clean";
1826
+ }
1827
+ return found ? "clean" : "missing";
1828
+ }
1806
1829
  #recordWorkspaceRemoval(task, workspace, fallback, workItem) {
1807
1830
  this.store.transaction((tx) => {
1808
1831
  if (workspace.owner.type === "task") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,