From 00d362d3bdb849a1d07357986d895b0a226736b9 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 16:34:19 +0530 Subject: [PATCH] fix: drop the committed core dist symlink and close the ignore gap A `packages/core/dist` symlink reached main. It held an absolute path into one machine's home directory, so it dangles in every other clone and shadows the directory the core build writes to. It got in because `.gitignore` listed `dist/` with a trailing slash, which matches a directory only. Git does not treat a symlink as a directory, so the pattern never applied and a `git add -A` swept it up. The entry is now slash-free for dist, build, and out, which matches the name either way. A fresh worktree has no built dist (#954), so borrowing one from the primary checkout with a symlink stays the right local workaround. It just must never be committed, and the next borrowed path will not necessarily be called dist, so the regression test asserts the general shape: no tracked symlink may resolve outside the repo root. In-repo symlinks are untouched, since several are load-bearing. --- .agents/skills/omarchy | 1 - .gitignore | 14 ++- packages/core/dist | 1 - .../no-committed-symlinks.test.mjs | 86 +++++++++++++++++++ 4 files changed, 97 insertions(+), 5 deletions(-) delete mode 120000 .agents/skills/omarchy delete mode 120000 packages/core/dist create mode 100644 test/repo-health/no-committed-symlinks.test.mjs diff --git a/.agents/skills/omarchy b/.agents/skills/omarchy deleted file mode 120000 index d6e1435c4..000000000 --- a/.agents/skills/omarchy +++ /dev/null @@ -1 +0,0 @@ -/home/vivek/.local/share/omarchy/default/omarchy-skill \ No newline at end of file diff --git a/.gitignore b/.gitignore index 72437ab73..02b8cb25d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,12 @@ node_modules /*.jpeg # build / caches -dist/ -build/ -out/ +# No trailing slash on purpose: `dist/` matches a DIRECTORY only, so a symlink +# named `dist` (the shape used to borrow a built dist into a fresh worktree) +# walked straight past it and got committed once. Match the name either way. +dist +build +out .cache/ # `.webjs/vendor/` is the EXCEPTION: holds the committed importmap # manifest + optional downloaded bundles for `webjs vendor pin`. See @@ -86,3 +89,8 @@ packages/mcp/resources/ # Generated at prepack from the canonical .agents/skills/webjs/ (see # scripts/sync-scaffold-skill.mjs). Never commit this bundle. packages/cli/templates/.agents/skills/webjs/ + +# A machine-local symlink into ~/.local/share (an omarchy skill). Kept on disk +# so the local setup keeps resolving it, untracked so it does not dangle in +# every other clone. See test/repo-health/no-committed-symlinks.test.mjs. +.agents/skills/omarchy diff --git a/packages/core/dist b/packages/core/dist deleted file mode 120000 index 0ee3aee03..000000000 --- a/packages/core/dist +++ /dev/null @@ -1 +0,0 @@ -/home/vivek/Documents/Projects/frameworks/webjs/packages/core/dist \ No newline at end of file diff --git a/test/repo-health/no-committed-symlinks.test.mjs b/test/repo-health/no-committed-symlinks.test.mjs new file mode 100644 index 000000000..7cf16eb9f --- /dev/null +++ b/test/repo-health/no-committed-symlinks.test.mjs @@ -0,0 +1,86 @@ +/** + * No tracked file may be a SYMLINK that escapes the repo. + * + * A fresh git worktree has no `node_modules` and no built `packages/core/dist` + * (#954), so the standard remedy is to borrow them from the primary checkout + * with a symlink. That symlink is machine-local by construction: it holds an + * ABSOLUTE path into one developer's home directory. Committing one puts that + * path in every clone, and it dangles everywhere except the machine that made + * it. + * + * This has happened. A `packages/core/dist` symlink was committed and reached + * `main`, because `.gitignore` listed `dist/` WITH a trailing slash, which + * matches a directory only. Git does not treat a symlink as a directory, so the + * pattern never applied and a `git add -A` swept it in. The `.gitignore` entry + * is now `dist` (no slash), and this test is the backstop for the general + * shape, since the next borrowed path will not necessarily be called `dist`. + * + * In-repo symlinks are fine and several are load-bearing (the vendored nvim + * intellisense copy, the ui registry), so the rule is about ESCAPING the repo, + * not about symlinks as such: a target is rejected when it is absolute, or when + * resolving it relative to the link's own directory lands outside the repo root. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { dirname, isAbsolute, resolve, relative, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +test('no tracked symlink points outside the repo', () => { + // Mode 120000 is git's symlink mode. `ls-files -s` prints it per tracked path. + const out = execFileSync('git', ['ls-files', '-s'], { cwd: repoRoot, encoding: 'utf8' }); + const links = out + .split('\n') + .filter((l) => l.startsWith('120000 ')) + .map((l) => l.split('\t').slice(1).join('\t')) + .filter(Boolean); + + const escaping = []; + for (const path of links) { + // The blob content of a symlink IS its target string. + const target = execFileSync('git', ['cat-file', '-p', `HEAD:${path}`], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + + if (isAbsolute(target)) { + escaping.push(`${path} -> ${target} (absolute, so machine-local)`); + continue; + } + const resolved = resolve(join(repoRoot, dirname(path)), target); + const rel = relative(repoRoot, resolved); + if (rel.startsWith('..')) escaping.push(`${path} -> ${target} (resolves outside the repo)`); + } + + assert.deepEqual( + escaping, + [], + 'a tracked symlink escapes the repo, so it dangles in every clone but the one that made it:\n ' + + escaping.join('\n '), + ); +}); + +test('the build-output ignore patterns match a symlink, not just a directory', () => { + // The specific gap that let the dist symlink through. `dist/` matches a + // directory only; the entry must be slash-free so a symlink of that name is + // caught too. + const ignore = execFileSync('git', ['show', 'HEAD:.gitignore'], { + cwd: repoRoot, + encoding: 'utf8', + }); + const lines = ignore.split('\n').map((l) => l.trim()); + for (const name of ['dist', 'build', 'out']) { + assert.ok( + lines.includes(name), + `.gitignore must list a slash-free \`${name}\` so a symlink of that name is ignored too, ` + + `not just a \`${name}/\` directory`, + ); + assert.ok( + !lines.includes(`${name}/`), + `.gitignore still has \`${name}/\`, which matches a directory only and is what let a ` + + `committed \`packages/core/dist\` symlink reach main`, + ); + } +});