mirror of https://github.com/garrytan/gstack.git
Use Windows junctions for setup links
This commit is contained in:
parent
1626d4857b
commit
3040f7df36
18
setup
18
setup
|
|
@ -33,9 +33,10 @@ esac
|
||||||
# ─── Symlink-or-copy helper ───────────────────────────────────
|
# ─── Symlink-or-copy helper ───────────────────────────────────
|
||||||
# On macOS/Linux: create a symlink (existing behavior).
|
# On macOS/Linux: create a symlink (existing behavior).
|
||||||
# On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently
|
# On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently
|
||||||
# creates a frozen file copy that doesn't refresh after `git pull`. We use
|
# creates a frozen file copy that doesn't refresh after `git pull`. For
|
||||||
# explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness
|
# directories, try a Windows junction first so installs reflect source updates
|
||||||
# is reportable (re-run ./setup after pull). Auto-detects file vs dir.
|
# without duplicating large trees; fall back to explicit copies when junctions
|
||||||
|
# are unavailable. Auto-detects file vs dir.
|
||||||
#
|
#
|
||||||
# INVARIANT: every symlink in this script MUST route through this helper.
|
# INVARIANT: every symlink in this script MUST route through this helper.
|
||||||
# A raw ln call here will be caught by test/setup-windows-fallback.test.ts
|
# A raw ln call here will be caught by test/setup-windows-fallback.test.ts
|
||||||
|
|
@ -54,6 +55,15 @@ _link_or_copy() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ -d "$src" ]; then
|
if [ -d "$src" ]; then
|
||||||
|
if command -v cmd.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2>&1; then
|
||||||
|
local _src_win _dst_win
|
||||||
|
_src_win="$(cygpath -w "$src" 2>/dev/null || true)"
|
||||||
|
_dst_win="$(cygpath -w "$dst" 2>/dev/null || true)"
|
||||||
|
if [ -n "$_src_win" ] && [ -n "$_dst_win" ] \
|
||||||
|
&& cmd.exe /c mklink /J "$_dst_win" "$_src_win" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
cp -R "$src" "$dst"
|
cp -R "$src" "$dst"
|
||||||
else
|
else
|
||||||
cp -f "$src" "$dst"
|
cp -f "$src" "$dst"
|
||||||
|
|
@ -66,7 +76,7 @@ _link_or_copy() {
|
||||||
_WINDOWS_COPY_NOTE_PRINTED=0
|
_WINDOWS_COPY_NOTE_PRINTED=0
|
||||||
_print_windows_copy_note_once() {
|
_print_windows_copy_note_once() {
|
||||||
if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
|
if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
|
||||||
echo " note: Windows install uses file copies (no Developer Mode required). Re-run ./setup after every 'git pull' to refresh skill files."
|
echo " note: Windows install uses directory junctions when available, with file copies as fallback."
|
||||||
_WINDOWS_COPY_NOTE_PRINTED=1
|
_WINDOWS_COPY_NOTE_PRINTED=1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,18 @@ describe('setup: _link_or_copy invariant (D7)', () => {
|
||||||
expect(offending).toEqual([]);
|
expect(offending).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Windows-copy note message exists in setup', () => {
|
test('Windows junction/fallback note message exists in setup', () => {
|
||||||
expect(SETUP_SRC).toContain('Windows install uses file copies');
|
expect(SETUP_SRC).toContain('Windows install uses directory junctions when available');
|
||||||
expect(SETUP_SRC).toContain('_print_windows_copy_note_once');
|
expect(SETUP_SRC).toContain('_print_windows_copy_note_once');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Windows directory branch tries mklink junctions before copying', () => {
|
||||||
|
const helper = extractHelper();
|
||||||
|
expect(helper).toContain('cmd.exe /c mklink /J');
|
||||||
|
expect(helper).toContain('cygpath -w "$src"');
|
||||||
|
expect(helper.indexOf('cmd.exe /c mklink /J')).toBeLessThan(helper.indexOf('cp -R "$src" "$dst"'));
|
||||||
|
});
|
||||||
|
|
||||||
test('link_claude_skill_dirs calls the Windows note printer', () => {
|
test('link_claude_skill_dirs calls the Windows note printer', () => {
|
||||||
const fnStart = SETUP_SRC.indexOf('link_claude_skill_dirs() {');
|
const fnStart = SETUP_SRC.indexOf('link_claude_skill_dirs() {');
|
||||||
const fnEnd = SETUP_SRC.indexOf('\n}\n', fnStart);
|
const fnEnd = SETUP_SRC.indexOf('\n}\n', fnStart);
|
||||||
|
|
@ -69,23 +76,37 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b
|
||||||
function runHelper(
|
function runHelper(
|
||||||
isWindows: '0' | '1',
|
isWindows: '0' | '1',
|
||||||
srcKind: 'file' | 'dir',
|
srcKind: 'file' | 'dir',
|
||||||
|
opts: { fakeJunction?: boolean } = {},
|
||||||
): { ok: boolean; targetIsSymlink: boolean; targetExists: boolean; stderr: string } {
|
): { ok: boolean; targetIsSymlink: boolean; targetExists: boolean; stderr: string } {
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-helper-'));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-helper-'));
|
||||||
try {
|
try {
|
||||||
const src = path.join(tmp, 'source');
|
const src = path.join(tmp, 'source');
|
||||||
const dst = path.join(tmp, 'dest');
|
const dst = path.join(tmp, 'dest');
|
||||||
|
const bindir = path.join(tmp, 'bin');
|
||||||
if (srcKind === 'file') {
|
if (srcKind === 'file') {
|
||||||
fs.writeFileSync(src, 'hello\n');
|
fs.writeFileSync(src, 'hello\n');
|
||||||
} else {
|
} else {
|
||||||
fs.mkdirSync(src);
|
fs.mkdirSync(src);
|
||||||
fs.writeFileSync(path.join(src, 'inner.txt'), 'hello\n');
|
fs.writeFileSync(path.join(src, 'inner.txt'), 'hello\n');
|
||||||
}
|
}
|
||||||
|
let env = process.env;
|
||||||
|
if (opts.fakeJunction) {
|
||||||
|
fs.mkdirSync(bindir);
|
||||||
|
const cygpath = `#!/bin/sh\nif [ "$1" = "-w" ]; then echo "$2"; exit 0; fi\nexit 1\n`;
|
||||||
|
const cmd = `#!/bin/sh\nif [ "$1" = "/c" ] && [ "$2" = "mklink" ] && [ "$3" = "/J" ]; then\n ln -s "$5" "$4"\n exit 0\nfi\nexit 1\n`;
|
||||||
|
fs.writeFileSync(path.join(bindir, 'cygpath'), cygpath);
|
||||||
|
fs.writeFileSync(path.join(bindir, 'cmd.exe'), cmd);
|
||||||
|
fs.chmodSync(path.join(bindir, 'cygpath'), 0o755);
|
||||||
|
fs.chmodSync(path.join(bindir, 'cmd.exe'), 0o755);
|
||||||
|
env = { ...process.env, PATH: `${bindir}:${process.env.PATH || ''}` };
|
||||||
|
}
|
||||||
const helper = extractHelper();
|
const helper = extractHelper();
|
||||||
// IS_WINDOWS must exist as a shell-readable var before sourcing.
|
// IS_WINDOWS must exist as a shell-readable var before sourcing.
|
||||||
const script = `IS_WINDOWS=${isWindows}\n${helper}\n_link_or_copy "${src}" "${dst}"\n`;
|
const script = `IS_WINDOWS=${isWindows}\n${helper}\n_link_or_copy "${src}" "${dst}"\n`;
|
||||||
const result = spawnSync('bash', ['-c', script], {
|
const result = spawnSync('bash', ['-c', script], {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
env,
|
||||||
});
|
});
|
||||||
const lst = fs.lstatSync(dst, { throwIfNoEntry: false });
|
const lst = fs.lstatSync(dst, { throwIfNoEntry: false });
|
||||||
return {
|
return {
|
||||||
|
|
@ -119,7 +140,14 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b
|
||||||
expect(r.targetIsSymlink).toBe(false);
|
expect(r.targetIsSymlink).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('IS_WINDOWS=1 + dir → real directory copy', () => {
|
test('IS_WINDOWS=1 + dir → junction when cmd/cygpath succeed', () => {
|
||||||
|
const r = runHelper('1', 'dir', { fakeJunction: true });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.targetExists).toBe(true);
|
||||||
|
expect(r.targetIsSymlink).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IS_WINDOWS=1 + dir → real directory copy when junction is unavailable', () => {
|
||||||
const r = runHelper('1', 'dir');
|
const r = runHelper('1', 'dir');
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
expect(r.targetExists).toBe(true);
|
expect(r.targetExists).toBe(true);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue