diff --git a/setup b/setup index 275236cd3..4dc0ee912 100755 --- a/setup +++ b/setup @@ -33,9 +33,10 @@ esac # ─── Symlink-or-copy helper ─────────────────────────────────── # On macOS/Linux: create a symlink (existing behavior). # 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 -# explicit `cp -R` / `cp -f` so the user gets a real copy and the staleness -# is reportable (re-run ./setup after pull). Auto-detects file vs dir. +# creates a frozen file copy that doesn't refresh after `git pull`. For +# directories, try a Windows junction first so installs reflect source updates +# 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. # A raw ln call here will be caught by test/setup-windows-fallback.test.ts @@ -54,6 +55,15 @@ _link_or_copy() { return 0 fi 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" else cp -f "$src" "$dst" @@ -66,7 +76,7 @@ _link_or_copy() { _WINDOWS_COPY_NOTE_PRINTED=0 _print_windows_copy_note_once() { 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 fi } diff --git a/test/setup-windows-fallback.test.ts b/test/setup-windows-fallback.test.ts index cc04fbbd7..f39928f55 100644 --- a/test/setup-windows-fallback.test.ts +++ b/test/setup-windows-fallback.test.ts @@ -44,11 +44,18 @@ describe('setup: _link_or_copy invariant (D7)', () => { expect(offending).toEqual([]); }); - test('Windows-copy note message exists in setup', () => { - expect(SETUP_SRC).toContain('Windows install uses file copies'); + test('Windows junction/fallback note message exists in setup', () => { + expect(SETUP_SRC).toContain('Windows install uses directory junctions when available'); 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', () => { const fnStart = SETUP_SRC.indexOf('link_claude_skill_dirs() {'); 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( isWindows: '0' | '1', srcKind: 'file' | 'dir', + opts: { fakeJunction?: boolean } = {}, ): { ok: boolean; targetIsSymlink: boolean; targetExists: boolean; stderr: string } { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-helper-')); try { const src = path.join(tmp, 'source'); const dst = path.join(tmp, 'dest'); + const bindir = path.join(tmp, 'bin'); if (srcKind === 'file') { fs.writeFileSync(src, 'hello\n'); } else { fs.mkdirSync(src); 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(); // 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 result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 5000, + env, }); const lst = fs.lstatSync(dst, { throwIfNoEntry: false }); return { @@ -119,7 +140,14 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b 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'); expect(r.ok).toBe(true); expect(r.targetExists).toBe(true);