@zq-silk/yui 0.5.1 → 0.5.3
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.
|
@@ -229,8 +229,8 @@ const taskChildren = [
|
|
|
229
229
|
{
|
|
230
230
|
name: "rebuild",
|
|
231
231
|
summary: "Rebuild a legacy Task workspace under its canonical identity.",
|
|
232
|
-
usage: "yui task rebuild <task>",
|
|
233
|
-
options: []
|
|
232
|
+
usage: "yui task rebuild <task> [--latest]",
|
|
233
|
+
options: ["--latest"]
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
name: "history",
|
|
@@ -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
|
|
209
|
-
* advertised remote SHAs
|
|
210
|
-
* checkout is never touched
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
}
|
|
@@ -20,11 +20,17 @@ export async function runTaskWorkspaceCommand(args, store, preparer, options = {
|
|
|
20
20
|
+ "yui task history list|archive [task], yui task replace <task>.");
|
|
21
21
|
}
|
|
22
22
|
async function rebuildTaskCommand(args, preparer) {
|
|
23
|
-
const usage = "Task rebuild usage: yui task rebuild <task
|
|
23
|
+
const usage = "Task rebuild usage: yui task rebuild <task> [--latest].";
|
|
24
24
|
const taskId = args[0];
|
|
25
|
-
|
|
25
|
+
const options = new Set(args.slice(1));
|
|
26
|
+
if (taskId === undefined
|
|
27
|
+
|| options.size !== args.length - 1
|
|
28
|
+
|| [...options].some((option) => option !== "--latest")) {
|
|
26
29
|
throw usageError(usage);
|
|
27
|
-
|
|
30
|
+
}
|
|
31
|
+
const result = await preparer.rebuildTaskWorkspace(taskId, {
|
|
32
|
+
latestRemote: options.has("--latest")
|
|
33
|
+
});
|
|
28
34
|
const archived = result.archived.length === 0
|
|
29
35
|
? "no legacy refs"
|
|
30
36
|
: `${result.archived.length} legacy ref(s) archived`;
|
|
@@ -35,7 +41,8 @@ async function rebuildTaskCommand(args, preparer) {
|
|
|
35
41
|
data: {
|
|
36
42
|
taskId: result.task.id,
|
|
37
43
|
archived: result.archived,
|
|
38
|
-
resumed: result.resumed
|
|
44
|
+
resumed: result.resumed,
|
|
45
|
+
latestRemote: options.has("--latest")
|
|
39
46
|
}
|
|
40
47
|
};
|
|
41
48
|
}
|
|
@@ -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(
|
|
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)
|
|
@@ -1485,7 +1485,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
1485
1485
|
* A Task that already carries an identity takes the resume path: only the
|
|
1486
1486
|
* pending legacy archive and old-worktree removal run.
|
|
1487
1487
|
*/
|
|
1488
|
-
async rebuildTaskWorkspace(taskId) {
|
|
1488
|
+
async rebuildTaskWorkspace(taskId, options = {}) {
|
|
1489
1489
|
const task = requireTask(this.store, taskId);
|
|
1490
1490
|
if (!["draft", "active"].includes(task.status)) {
|
|
1491
1491
|
throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
|
|
@@ -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.#
|
|
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
|
|
@@ -1518,9 +1518,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
1518
1518
|
const pins = new Map();
|
|
1519
1519
|
for (const binding of task.projectBindings) {
|
|
1520
1520
|
const project = requireProject(this.store, binding.projectId);
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1521
|
+
// `--latest` explicitly re-resolves every remote-backed Project. Without
|
|
1522
|
+
// it, only a still-symbolic creation default is refreshed; an explicit
|
|
1523
|
+
// or previously pinned commit retains the established rebuild behavior.
|
|
1524
|
+
const useRemoteDefault = (options.latestRemote === true
|
|
1525
|
+
|| (defaultProjects.has(project.id) && !looksLikeCommit(binding.baseRef)))
|
|
1526
|
+
&& project.remoteUrl !== undefined;
|
|
1524
1527
|
if (useRemoteDefault) {
|
|
1525
1528
|
const resolver = this.git.resolveRemoteBaseline;
|
|
1526
1529
|
if (typeof resolver !== "function") {
|
|
@@ -1529,9 +1532,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
1529
1532
|
const remote = await resolver.call(this.git, {
|
|
1530
1533
|
repositoryPath: project.path,
|
|
1531
1534
|
remoteUrl: project.remoteUrl,
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
+
developmentRef: options.latestRemote === true
|
|
1536
|
+
? project.developmentBranch
|
|
1537
|
+
: binding.baseRef
|
|
1535
1538
|
});
|
|
1536
1539
|
pins.set(project.id, remote.commit);
|
|
1537
1540
|
}
|
|
@@ -1792,9 +1795,14 @@ export class FileTaskWorkspacePreparer {
|
|
|
1792
1795
|
async #removeLegacyWorktrees(task, projectIds) {
|
|
1793
1796
|
for (const projectId of projectIds) {
|
|
1794
1797
|
const project = requireProject(this.store, projectId);
|
|
1795
|
-
const
|
|
1798
|
+
const container = this.#projectContainer(project.name);
|
|
1799
|
+
const branch = worktreeIdentity(task.id, MAIN_WORKTREE).branch;
|
|
1800
|
+
const removal = await this.git.removeRecordedWorktree({
|
|
1796
1801
|
repositoryPath: project.path,
|
|
1797
|
-
container
|
|
1802
|
+
container,
|
|
1803
|
+
path: join(container, task.id, MAIN_WORKTREE),
|
|
1804
|
+
branch,
|
|
1805
|
+
retainedRef: taskArchiveRef(this.store.getHomeIdentity().homeId, `refs/heads/${branch}`),
|
|
1798
1806
|
taskSegment: task.id,
|
|
1799
1807
|
roleName: MAIN_WORKTREE
|
|
1800
1808
|
});
|
|
@@ -1803,6 +1811,24 @@ export class FileTaskWorkspacePreparer {
|
|
|
1803
1811
|
}
|
|
1804
1812
|
}
|
|
1805
1813
|
}
|
|
1814
|
+
async #inspectLegacyTaskEntries(task, entries) {
|
|
1815
|
+
let found = false;
|
|
1816
|
+
for (const entry of entries) {
|
|
1817
|
+
const project = requireProject(this.store, entry.projectId);
|
|
1818
|
+
const state = await this.git.inspectRecordedWorktree({
|
|
1819
|
+
repositoryPath: project.path,
|
|
1820
|
+
container: this.#projectContainer(project.name),
|
|
1821
|
+
path: entry.path,
|
|
1822
|
+
branch: entry.branch,
|
|
1823
|
+
taskSegment: task.id,
|
|
1824
|
+
roleName: MAIN_WORKTREE
|
|
1825
|
+
});
|
|
1826
|
+
if (state === "dirty")
|
|
1827
|
+
return state;
|
|
1828
|
+
found ||= state === "clean";
|
|
1829
|
+
}
|
|
1830
|
+
return found ? "clean" : "missing";
|
|
1831
|
+
}
|
|
1806
1832
|
#recordWorkspaceRemoval(task, workspace, fallback, workItem) {
|
|
1807
1833
|
this.store.transaction((tx) => {
|
|
1808
1834
|
if (workspace.owner.type === "task") {
|