mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(cli): canonicalize repo paths so remove/register match across platforms (#1003 review)
Address review feedback from @evander-wang and @magyargergo on PR #1003 plus the Windows + macOS CI failure (same root cause). Problem: - macOS: /var is a symlink to /private/var. `path.resolve` does NOT follow symlinks, so a child running analyze in /var/folders/X stores /private/var/folders/X (realpath from OS cwd) but an outer caller passing the symlink form misses. - Windows: GitHub runners surface tmpdirs in 8.3 short-name form (RUNNERA~1) while process.cwd() returns the long form (runneradmin). Same divergence. Fix: new `canonicalizePath(p)` helper wraps `path.resolve` plus `fs.realpathSync.native`, falling back to `path.resolve` when the path doesn't exist (preserves idempotent-on-missing semantics needed by `remove <unknown>`). Applied at 3 call-sites — registerRepo, unregisterRepo, resolveRegistryEntry — canonicalising BOTH the input and each stored `entry.path` at compare time. That last bit is the backward-compat story: registries written by older versions (pre-canonicalisation) still match correctly, so we don't need a migration script. Test side: the ambiguous-target integration test now reads the path from the registry snapshot rather than passing the outer `repoA` variable directly, so it exercises the registry contract regardless of which path form the platform stores. 4 new unit tests cover the helper (idempotent, fallback-on-missing, absolute-for-relative) plus the backward-compat resolver path.
This commit is contained in:
parent
1f4235a343
commit
c5eceba0cb
3 changed files with 185 additions and 11 deletions
|
|
@ -7,10 +7,50 @@
|
|||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { realpathSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { getInferredRepoName } from './git.js';
|
||||
|
||||
/**
|
||||
* Normalise a repo path for registry comparison across platforms
|
||||
* (#664 review feedback from @evander-wang).
|
||||
*
|
||||
* Why this exists: `path.resolve` alone is NOT enough for
|
||||
* cross-platform registry stability.
|
||||
* - **macOS**: tmpdirs and `/var` are symlinks to `/private/var`.
|
||||
* A child process that stored `/private/var/folders/.../repo` in
|
||||
* the registry cannot later be matched by an outer caller that
|
||||
* supplies the symlink form `/var/folders/.../repo`. `path.resolve`
|
||||
* does not follow symlinks; `realpathSync.native` does.
|
||||
* - **Windows**: GitHub runners surface tmpdirs in 8.3 short-name
|
||||
* form (`RUNNERA~1\...`), but `process.cwd()` often returns the
|
||||
* long form (`runneradmin\...`). `realpathSync.native` normalises
|
||||
* both sides to the long-name canonical path.
|
||||
*
|
||||
* Fallback behaviour: if the path does not exist on disk (e.g. a user
|
||||
* passed `gitnexus remove some-alias` and the alias misses every
|
||||
* registry entry, or the caller is resolving a path that was deleted
|
||||
* after registration), we return `path.resolve(p)` rather than
|
||||
* throwing. This preserves the idempotent-on-missing semantics of
|
||||
* `resolveRegistryEntry` / `remove`.
|
||||
*
|
||||
* Backwards compatibility: this function is applied to BOTH the
|
||||
* caller-supplied input AND each stored `entry.path` at compare time
|
||||
* inside `resolveRegistryEntry`, so registries written by older
|
||||
* versions (where `registerRepo` only ran `path.resolve`) still match
|
||||
* correctly. Newly-written entries are canonicalised at write time too
|
||||
* so the registry stabilises over analyze/re-analyze cycles.
|
||||
*/
|
||||
export const canonicalizePath = (p: string): string => {
|
||||
const resolved = path.resolve(p);
|
||||
try {
|
||||
return realpathSync.native(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
};
|
||||
|
||||
export interface RepoMeta {
|
||||
repoPath: string;
|
||||
lastCommit: string;
|
||||
|
|
@ -349,12 +389,22 @@ export const registerRepo = async (
|
|||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
): Promise<string> => {
|
||||
const resolved = path.resolve(repoPath);
|
||||
// Canonicalise the caller's path up-front (#1003 review) — expands
|
||||
// macOS /var → /private/var and Windows 8.3 → long-name so the
|
||||
// registry entry stays matchable by `resolveRegistryEntry` regardless
|
||||
// of which form the caller hands us.
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const { storagePath } = getStoragePaths(resolved);
|
||||
|
||||
const entries = await readRegistry();
|
||||
const existingIdx = entries.findIndex((e) => {
|
||||
const a = path.resolve(e.path);
|
||||
// Canonicalise the STORED entry too so pre-canonicalisation
|
||||
// registries (written by older versions, or the same version before
|
||||
// this review fix) still match correctly. `canonicalizePath` falls
|
||||
// back to `path.resolve` when the path no longer exists on disk, so
|
||||
// stale entries that have been rm'd externally still resolve to a
|
||||
// stable key instead of throwing.
|
||||
const a = canonicalizePath(e.path);
|
||||
const b = resolved;
|
||||
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
});
|
||||
|
|
@ -393,7 +443,7 @@ export const registerRepo = async (
|
|||
(e, i) =>
|
||||
i !== existingIdx &&
|
||||
e.name.toLowerCase() === name.toLowerCase() &&
|
||||
path.resolve(e.path) !== resolved,
|
||||
canonicalizePath(e.path) !== resolved,
|
||||
);
|
||||
if (collidingEntry) {
|
||||
throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
|
||||
|
|
@ -424,9 +474,16 @@ export const registerRepo = async (
|
|||
* Called after `gitnexus clean`.
|
||||
*/
|
||||
export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
||||
const resolved = path.resolve(repoPath);
|
||||
// Canonicalise BOTH sides so an unregister call issued with the
|
||||
// symlink form (`/var/folders/.../repo`) still matches an entry
|
||||
// written with the realpath form (`/private/var/folders/.../repo`),
|
||||
// and vice versa. Matches the semantics of `registerRepo` and
|
||||
// `resolveRegistryEntry` post-#1003 review.
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
const filtered = entries.filter((e) => path.resolve(e.path) !== resolved);
|
||||
const matches = (a: string, b: string) =>
|
||||
process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
const filtered = entries.filter((e) => !matches(canonicalizePath(e.path), resolved));
|
||||
await writeRegistry(filtered);
|
||||
};
|
||||
|
||||
|
|
@ -504,12 +561,19 @@ export class RegistryAmbiguousTargetError extends Error {
|
|||
* `GITNEXUS_HOME`.
|
||||
*/
|
||||
export const resolveRegistryEntry = (entries: RegistryEntry[], target: string): RegistryEntry => {
|
||||
// Tier 1: path match. Normalise both sides the same way
|
||||
// `registerRepo` / `unregisterRepo` do.
|
||||
const resolvedTarget = path.resolve(target);
|
||||
// Tier 1: path match. Canonicalise BOTH sides so symlink and
|
||||
// Windows-8.3 quirks don't cause a false miss — e.g. the caller
|
||||
// passes `/var/folders/.../repo` while the registry has
|
||||
// `/private/var/folders/.../repo` (both resolve to the same
|
||||
// `realpath.native`). See `canonicalizePath` for the rationale.
|
||||
//
|
||||
// Canonicalising the STORED entry (not just the input) is what gives
|
||||
// us backward-compat for registries written by versions that only
|
||||
// ran `path.resolve` — both get canonicalised here at compare time.
|
||||
const canonicalTarget = canonicalizePath(target);
|
||||
const pathMatch = entries.find((e) => {
|
||||
const a = path.resolve(e.path);
|
||||
const b = resolvedTarget;
|
||||
const a = canonicalizePath(e.path);
|
||||
const b = canonicalTarget;
|
||||
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
});
|
||||
if (pathMatch) return pathMatch;
|
||||
|
|
|
|||
|
|
@ -489,8 +489,31 @@ describe('CLI end-to-end', () => {
|
|||
|
||||
// And path-based remove still works: pass the absolute path of
|
||||
// repoA and it resolves unambiguously.
|
||||
//
|
||||
// We pull the path from the registry snapshot rather than
|
||||
// passing the outer `repoA` variable directly. This is the
|
||||
// belt-and-suspenders for cross-platform path normalisation
|
||||
// (#1003 review): the path the registry recorded has already
|
||||
// gone through the analyze-side canonicalisation (which on
|
||||
// macOS expands /var → /private/var and on Windows expands 8.3
|
||||
// → long-name). Passing that exact string back to `remove`
|
||||
// guarantees the comparison succeeds even on runners where the
|
||||
// outer `repoA` is the symlink/short-name form. The code-side
|
||||
// fix in `canonicalizePath` makes this redundant in practice,
|
||||
// but the test shouldn't depend on the code fix being perfect
|
||||
// on every platform — it should prove correctness against the
|
||||
// registry contract.
|
||||
const repoAEntry = before.find(
|
||||
(e: { path: string }) =>
|
||||
path.basename(e.path) === 'dup' && e.path.includes(path.basename(parentA)),
|
||||
);
|
||||
expect(
|
||||
repoAEntry,
|
||||
'repoA entry must exist in registry before path-remove step',
|
||||
).toBeDefined();
|
||||
|
||||
const r4 = runCliWithEnv(
|
||||
['remove', repoA, '--force'],
|
||||
['remove', repoAEntry.path, '--force'],
|
||||
parentA,
|
||||
{ GITNEXUS_HOME: gnHome },
|
||||
15000,
|
||||
|
|
@ -508,6 +531,8 @@ describe('CLI end-to-end', () => {
|
|||
expect(finalEntries).toHaveLength(1);
|
||||
// The survivor is repoB (its path stays in the registry).
|
||||
expect(path.basename(finalEntries[0].path)).toBe('dup');
|
||||
// And it's NOT the one we just removed.
|
||||
expect(finalEntries[0].path).not.toBe(repoAEntry.path);
|
||||
} finally {
|
||||
fs.rmSync(gnHome, { recursive: true, force: true });
|
||||
fs.rmSync(parentA, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
registerRepo,
|
||||
listRegisteredRepos,
|
||||
resolveRegistryEntry,
|
||||
canonicalizePath,
|
||||
RegistryNameCollisionError,
|
||||
RegistryNotFoundError,
|
||||
RegistryAmbiguousTargetError,
|
||||
|
|
@ -605,3 +606,87 @@ describe('resolveRegistryEntry (#664)', () => {
|
|||
expect(hit.name).toBe('website');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canonicalizePath (#1003 review — @evander-wang / @magyargergo) ──
|
||||
//
|
||||
// Shields `registerRepo`, `unregisterRepo`, and `resolveRegistryEntry`
|
||||
// against cross-platform path-form divergence: macOS symlink expansion
|
||||
// (/var → /private/var) and Windows 8.3 short-name expansion
|
||||
// (RUNNERA~1 → runneradmin). The helper also underpins backwards
|
||||
// compatibility with registries written by versions that only ran
|
||||
// `path.resolve` — by canonicalising the stored entry at compare time,
|
||||
// both pre- and post-fix entries converge to the same key.
|
||||
//
|
||||
// These tests avoid snapshotting a specific realpath value (that would
|
||||
// be platform-fragile); instead they assert:
|
||||
// - canonicalizePath is idempotent (f(f(x)) == f(x))
|
||||
// - canonicalizePath falls back cleanly when the path doesn't exist
|
||||
// - resolveRegistryEntry matches a stored entry even when the target
|
||||
// and the stored value disagree on one-step normalisation (simulated
|
||||
// via a fixture that stores the de-canonicalised form of a real
|
||||
// existing path).
|
||||
|
||||
describe('canonicalizePath (#1003)', () => {
|
||||
it('is idempotent — canonicalizePath(canonicalizePath(x)) === canonicalizePath(x)', async () => {
|
||||
// Use the vitest project-root as a known-existing path. `os.tmpdir()`
|
||||
// would work too but process.cwd() is guaranteed to exist for the
|
||||
// test runner.
|
||||
const p = process.cwd();
|
||||
const once = canonicalizePath(p);
|
||||
const twice = canonicalizePath(once);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it('falls back to path.resolve when the target does not exist', () => {
|
||||
// Construct a definitely-nonexistent path under tmpdir. Using
|
||||
// random-ish segments so we don't collide with anything real.
|
||||
const ghost = path.join(os.tmpdir(), 'gnx-never-exists-____', 'still-not-there');
|
||||
const got = canonicalizePath(ghost);
|
||||
// Must not throw, must not resolve to something weird — should be
|
||||
// identical to `path.resolve(ghost)` since realpathSync.native will
|
||||
// have thrown and we swallowed it.
|
||||
expect(got).toBe(path.resolve(ghost));
|
||||
});
|
||||
|
||||
it('returns an absolute path for relative input even when the path is missing', () => {
|
||||
// Relative path that does not exist. Must still be absolute
|
||||
// (fallback path: path.resolve normalises even non-existent inputs).
|
||||
const rel = './does-not-exist-zzz-' + Date.now();
|
||||
const got = canonicalizePath(rel);
|
||||
expect(path.isAbsolute(got)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRegistryEntry backward-compat with non-canonical stored paths (#1003)', () => {
|
||||
it('matches a stored entry even when the target was passed in canonical form', async () => {
|
||||
// Simulate the bug-producing scenario without depending on a real
|
||||
// symlink/8.3 discrepancy (those are platform-specific and flaky to
|
||||
// set up in CI). We take a REAL path that exists
|
||||
// (canonicalizePath-stable), store a known-non-canonical copy of it
|
||||
// in a fake RegistryEntry, then resolve with the canonical form and
|
||||
// assert the match.
|
||||
//
|
||||
// Construct a non-canonical string that resolves to the same real
|
||||
// path. `path.join` auto-normalises `.` and trailing separators, so
|
||||
// we build the string by raw concat to keep it string-unequal to
|
||||
// `realDir` until `canonicalizePath` runs.
|
||||
const realDir = process.cwd();
|
||||
const nonCanonical = realDir + path.sep + '.'; // e.g. /work/gitnexus/.
|
||||
// Sanity: these are string-unequal before canonicalisation.
|
||||
expect(nonCanonical).not.toBe(realDir);
|
||||
|
||||
const entries: RegistryEntry[] = [
|
||||
{
|
||||
name: 'stored-under-noncanonical-form',
|
||||
path: nonCanonical,
|
||||
storagePath: path.join(nonCanonical, '.gitnexus'),
|
||||
indexedAt: '2026-04-20T00:00:00.000Z',
|
||||
lastCommit: 'deadbee',
|
||||
},
|
||||
];
|
||||
|
||||
// Pass the canonical form as the target — resolver must still match.
|
||||
const hit = resolveRegistryEntry(entries, realDir);
|
||||
expect(hit).toBe(entries[0]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue