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`, + ); + } +});