@pygmalionjs/pygmalion 0.2.25 → 0.2.26

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.
Files changed (2) hide show
  1. package/node/dev-mirror.mjs +102 -3
  2. package/package.json +1 -1
@@ -47,6 +47,74 @@ export function devMirrorRefSlug(ref) {
47
47
  return createHash('sha256').update(raw).digest('hex').slice(0, 12);
48
48
  }
49
49
 
50
+ /**
51
+ * Identity of the project a mirror checkout belongs to.
52
+ *
53
+ * Two worktrees of one repository configure the same mirror path, and each
54
+ * generates its own inventory into it. Sharing one checkout made each server see
55
+ * the other's generated files as unexpected changes and refuse to sync, so a
56
+ * checkout is claimed by the project that syncs it.
57
+ */
58
+ export function devMirrorOwnerToken(repoRoot, appDirectory = '') {
59
+ const identity = `${path.resolve(repoRoot)}\u0000${appDirectory}`;
60
+ return createHash('sha256').update(identity).digest('hex').slice(0, 10);
61
+ }
62
+
63
+ // The claim lives beside the repository's other Pygmalion state, not inside the
64
+ // checkout: a file in the worktree would itself be an unexpected change.
65
+ async function mirrorClaimFile(repoRoot, mirrorRoot) {
66
+ const directory = path.join(await pygmalionStateDirectory(repoRoot), 'mirror-claims');
67
+ const key = createHash('sha256').update(path.resolve(mirrorRoot)).digest('hex').slice(0, 16);
68
+ return path.join(directory, `${key}.json`);
69
+ }
70
+
71
+ function isPlainMirrorStamp(value) {
72
+ return (
73
+ value != null &&
74
+ typeof value === 'object' &&
75
+ !Array.isArray(value) &&
76
+ typeof value.owner === 'string' &&
77
+ value.owner.length > 0
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Whether this project may sync the checkout at `mirrorRoot`.
83
+ *
84
+ * An unclaimed or self-claimed checkout is ours. One claimed by another project is
85
+ * not, and the caller moves to its own path rather than fighting over this one —
86
+ * which is what two worktrees of one repository were doing every time they
87
+ * regenerated their own inventory into the same directory.
88
+ */
89
+ export async function devMirrorClaim(repoRoot, mirrorRoot, owner) {
90
+ let stamp = null;
91
+ try {
92
+ const raw = await fsp.readFile(await mirrorClaimFile(repoRoot, mirrorRoot), 'utf8');
93
+ const parsed = JSON.parse(raw);
94
+ stamp = isPlainMirrorStamp(parsed) ? parsed : null;
95
+ } catch {
96
+ stamp = null;
97
+ }
98
+ if (!stamp) return { owned: true, previousOwner: null };
99
+ return { owned: stamp.owner === owner, previousOwner: stamp.owner };
100
+ }
101
+
102
+ export async function claimDevMirror(repoRoot, mirrorRoot, owner) {
103
+ const file = await mirrorClaimFile(repoRoot, mirrorRoot);
104
+ await fsp.mkdir(path.dirname(file), { recursive: true });
105
+ await fsp
106
+ .writeFile(
107
+ file,
108
+ `${JSON.stringify({
109
+ owner,
110
+ repoRoot: path.resolve(repoRoot),
111
+ mirrorRoot: path.resolve(mirrorRoot),
112
+ claimedAt: new Date().toISOString(),
113
+ })}\n`,
114
+ )
115
+ .catch(() => undefined);
116
+ }
117
+
50
118
  /**
51
119
  * One checkout per ref. Two editor servers pointed at different refs used to
52
120
  * share a single mirror directory and take turns running `git switch` in it, so
@@ -59,16 +127,17 @@ export function resolveDevMirrorWorktreePaths({
59
127
  mirrorAppRoot,
60
128
  appDirectory = '',
61
129
  ref = null,
130
+ owner = null,
62
131
  }) {
63
132
  const base = path.resolve(mirrorRoot);
64
- const slug = devMirrorRefSlug(ref);
65
- if (!slug) {
133
+ const parts = [devMirrorRefSlug(ref), owner ? String(owner) : ''].filter(Boolean);
134
+ if (!parts.length) {
66
135
  return {
67
136
  mirrorRoot: base,
68
137
  mirrorAppRoot: path.resolve(mirrorAppRoot ?? path.join(base, appDirectory)),
69
138
  };
70
139
  }
71
- const scoped = `${base}-${slug}`;
140
+ const scoped = `${base}-${parts.join('-')}`;
72
141
  return {
73
142
  mirrorRoot: scoped,
74
143
  mirrorAppRoot: path.resolve(path.join(scoped, appDirectory)),
@@ -582,6 +651,9 @@ export function pygmalionDevMirrorPlugin(options) {
582
651
  );
583
652
  // The ref is repointable at runtime and each ref owns its own checkout, so the
584
653
  // active paths are recomputed per sync instead of frozen at plugin creation.
654
+ const ownerToken = devMirrorOwnerToken(repoRoot, appDirectory);
655
+ // Set once the base checkout turns out to belong to another project.
656
+ let mirrorOwnerScope = null;
585
657
  let mirrorRoot = mirrorBaseRoot;
586
658
  let mirrorAppRoot = mirrorAppOverride ?? path.join(mirrorBaseRoot, appDirectory);
587
659
  let viteConfig = viteConfigOverride ?? path.join(mirrorAppRoot, 'vite.config.ts');
@@ -598,6 +670,7 @@ export function pygmalionDevMirrorPlugin(options) {
598
670
  mirrorAppRoot: mirrorAppOverride,
599
671
  appDirectory,
600
672
  ref: activeRef,
673
+ owner: mirrorOwnerScope,
601
674
  });
602
675
  mirrorRoot = resolved.mirrorRoot;
603
676
  mirrorAppRoot = resolved.mirrorAppRoot;
@@ -729,6 +802,24 @@ export function pygmalionDevMirrorPlugin(options) {
729
802
  const lockHash = await fileHash(lockFile);
730
803
  const stamp = await fsp.readFile(stampFile, 'utf8').catch(() => '');
731
804
  if ((await exists(nodeModules)) && stamp.trim() === lockHash) return false;
805
+ // An owner-scoped checkout is a second copy of the same source. Borrowing the
806
+ // base checkout's modules when the lock file agrees keeps that isolation from
807
+ // costing a full install per project.
808
+ if (!(await exists(nodeModules)) && mirrorOwnerScope) {
809
+ const baseModules = path.join(
810
+ mirrorAppOverride ?? path.join(mirrorBaseRoot, appDirectory),
811
+ dependencies.modulesDirectory ?? 'node_modules',
812
+ );
813
+ const baseLock = path.join(
814
+ mirrorAppOverride ?? path.join(mirrorBaseRoot, appDirectory),
815
+ dependencies.lockFile ?? 'package-lock.json',
816
+ );
817
+ const baseLockHash = await fileHash(baseLock).catch(() => null);
818
+ if ((await exists(baseModules)) && baseLockHash === lockHash) {
819
+ await fsp.symlink(baseModules, nodeModules, 'dir').catch(() => undefined);
820
+ if (await exists(nodeModules)) return false;
821
+ }
822
+ }
732
823
  await run(
733
824
  dependencies.installCommand ?? 'npm',
734
825
  dependencies.installArgs ?? ['install', '--no-audit', '--no-fund'],
@@ -840,6 +931,13 @@ export function pygmalionDevMirrorPlugin(options) {
840
931
  if (syncPromise) return syncPromise;
841
932
  applyMirrorPathsForRef(ref);
842
933
  syncPromise = (async () => {
934
+ // A checkout another project claimed is not ours to regenerate into. Moving
935
+ // to our own path is what ends the tug of war between two worktrees.
936
+ const claim = await devMirrorClaim(repoRoot, mirrorRoot, ownerToken);
937
+ if (!claim.owned && mirrorOwnerScope !== ownerToken) {
938
+ mirrorOwnerScope = ownerToken;
939
+ applyMirrorPathsForRef(ref);
940
+ }
843
941
  status = { ...status, state: 'syncing', error: null, warning: null };
844
942
  let warning = null;
845
943
  try {
@@ -859,6 +957,7 @@ export function pygmalionDevMirrorPlugin(options) {
859
957
  });
860
958
  warning = mirrorState.warning;
861
959
  const { previous, commit, shortCommit } = mirrorState;
960
+ await claimDevMirror(repoRoot, mirrorRoot, ownerToken);
862
961
  const dependenciesChanged = await syncDependencies();
863
962
  await generateInventory(commit);
864
963
  await startPreview(dependenciesChanged);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {