diff --git a/AGENTS.md b/AGENTS.md index 2a3a0bed0..0250a4b74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,19 @@ When editing the framework monorepo (this repo, not a scaffolded app): **`packag See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, reference codebases, and per-feature update checklists. +### Repo health: worktree-safe git config (core.bare / hooksPath) + +This repo uses git worktrees (the review subagents spawn throwaway ones under `.claude/worktrees/`). Git's worktree machinery can leave `core.bare=true` in the shared `.git/config`, which is lethal to the main checkout: every git operation that needs a work tree then fails with `fatal: this operation must be run in a work tree`. The shared value is harmless only while the main worktree carries a per-worktree override (`extensions.worktreeConfig=true` plus a `.git/config.worktree` pinning `core.bare=false`). + +`scripts/git-worktree-safe.mjs` establishes that override and pins an absolute `core.hooksPath` to `.hooks` on the main worktree, where both survive a shared-config reset (which is what otherwise silently disables the framework `.hooks/pre-commit`). It runs from the root `prepare` script, so every `npm install` self-heals. Two manual entry points: + +- `npm run fix:git` heals the config on demand (run it if a git command reports the work-tree error). +- `npm run check:git` asserts the invariant (`core.bare` resolves false, the framework hook is active) and exits non-zero otherwise. The regression test is `test/repo-health/git-worktree-safe.test.mjs`. + +Because the pin lives in the main worktree's `config.worktree`, `git worktree add` copies it into each linked worktree, so a commit made inside a throwaway review worktree also runs the framework `.hooks/pre-commit`. That is harmless (the hook only blocks main and auto-generates a changelog on a version bump), and review subagents are read-only so they do not commit; the inheritance is noted here only so the behavior is not surprising. + +The fix only repairs the LOCAL checkout. Commits and branches are always safe on GitHub regardless. + ### Changelog: per-package, per-version, auto-generated webjs ships per-package per-version changelogs under `changelog//.md`. The model: **a version bump is the trigger**. When any commit on `main` changes the `version` field in `packages//package.json`, the scripts/backfill-changelog.js generator emits a new `changelog//.md` summarising every conventional-commit (`feat:` / `fix:` / `breaking:` / `perf:`) that landed in that package since the prior bump. The website renders the union of all packages' files at `/changelog`. diff --git a/package.json b/package.json index 5ae551f0f..5cced9516 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "node": ">=24.0.0" }, "scripts": { - "prepare": "git config core.hooksPath .hooks 2>/dev/null || true", + "prepare": "node scripts/git-worktree-safe.mjs", + "fix:git": "node scripts/git-worktree-safe.mjs", + "check:git": "node scripts/git-worktree-safe.mjs --check", "dev": "node scripts/dev-all.js", "test": "node scripts/run-node-tests.js", "test:browser": "wtr", diff --git a/scripts/git-worktree-safe.mjs b/scripts/git-worktree-safe.mjs new file mode 100644 index 000000000..42c8369ec --- /dev/null +++ b/scripts/git-worktree-safe.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// Keeps the main checkout safe from the recurring core.bare=true corruption +// (issue #166) and the related core.hooksPath reset. +// +// Background. This repo uses git worktrees (the review subagents spawn +// throwaway worktrees under .claude/worktrees/). With git's worktree +// machinery, the shared .git/config can end up carrying core.bare=true. +// The shared value is harmless ONLY while the main worktree has a +// per-worktree override (extensions.worktreeConfig=true plus a +// .git/config.worktree pinning core.bare=false). If that override is +// missing, the main checkout reads the shared core.bare=true and every +// git operation that needs a work tree fails with +// "fatal: this operation must be run in a work tree". +// +// Separately, the old `prepare` wrote a RELATIVE core.hooksPath (.hooks) +// to the SHARED config. A relative hooksPath is resolved at read time and +// the shared surface is the same one worktree events reset, which is how +// the framework hook (.hooks/pre-commit) silently stops firing and the +// absolute default .git/hooks takes over. +// +// This script pins both values on the MAIN worktree (where they survive a +// shared-config reset) and is idempotent, so `prepare` can run it on every +// `npm install` as a self-heal. Nothing here is ever lost on GitHub; this +// only repairs the LOCAL checkout. +// +// Usage: +// node scripts/git-worktree-safe.mjs ensure/heal (default) +// node scripts/git-worktree-safe.mjs --check assert the invariant; exit 1 if violated + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join, resolve, isAbsolute } from 'node:path'; + +const CHECK = process.argv.includes('--check'); + +function git(args, { allowFail = false } = {}) { + try { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (err) { + if (allowFail) return null; + throw err; + } +} + +function isInsideWorkTree() { + return git(['rev-parse', '--is-inside-work-tree'], { allowFail: true }) === 'true'; +} + +// Resolve the work-tree root. When the repo is in the corrupted bare state +// `--show-toplevel` fails, so fall back to the common dir's parent. +function topLevel() { + const top = git(['rev-parse', '--show-toplevel'], { allowFail: true }); + if (top) return top; + const commonDir = git(['rev-parse', '--git-common-dir'], { allowFail: true }); + if (commonDir) return resolve(commonDir, '..'); + return process.cwd(); +} + +function ensure() { + // 1. Un-break the shared value first so the rest of the commands (which + // need a work tree) succeed even if we started corrupted. + git(['config', '--local', 'core.bare', 'false'], { allowFail: true }); + + // 2. Enable per-worktree config and pin the override on the MAIN worktree. + // The override wins even if a later worktree event flips the shared + // core.bare back to true. extensions.* are only contractually honored at + // core.repositoryformatversion >= 1, and git's own `git worktree` bumps + // it when enabling worktreeConfig; match that so a stricter git cannot + // silently ignore the override the fix depends on. + git(['config', 'core.repositoryformatversion', '1'], { allowFail: true }); + git(['config', 'extensions.worktreeConfig', 'true'], { allowFail: true }); + git(['config', '--worktree', 'core.bare', 'false'], { allowFail: true }); + + // 3. Pin core.hooksPath to an ABSOLUTE path on the main worktree so it is + // cwd-independent and survives a shared-config reset. Only when the + // tracked .hooks dir exists (the framework repo); a generic repo using + // this script keeps its own hooks. + const top = topLevel(); + const hooksDir = join(top, '.hooks'); + let hooksMsg = 'core.hooksPath: left as-is (.hooks not present)'; + if (existsSync(hooksDir)) { + git(['config', '--worktree', 'core.hooksPath', hooksDir], { allowFail: true }); + hooksMsg = `core.hooksPath: ${hooksDir}`; + } + + const resolvedBare = git(['config', 'core.bare'], { allowFail: true }); + console.log('[git-worktree-safe] ensured worktree-safe config:'); + console.log(` extensions.worktreeConfig: ${git(['config', 'extensions.worktreeConfig'], { allowFail: true })}`); + console.log(` core.bare (resolved): ${resolvedBare}`); + console.log(` ${hooksMsg}`); +} + +function check() { + const problems = []; + + if (!isInsideWorkTree()) { + problems.push('repo is not a usable work tree (core.bare resolves true); run: npm run fix:git'); + } else { + const resolvedBare = git(['config', 'core.bare'], { allowFail: true }); + if (resolvedBare !== 'false') { + problems.push(`core.bare resolves to "${resolvedBare}" (expected "false")`); + } + } + + const wtConfig = git(['config', 'extensions.worktreeConfig'], { allowFail: true }); + if (wtConfig !== 'true') { + problems.push(`extensions.worktreeConfig is "${wtConfig}" (expected "true"); without it a shared core.bare flip is fatal`); + } + + // extensions.worktreeConfig is only contractually honored at rfv >= 1, so a + // drop back to 0 is the same threat as worktreeConfig being off: assert it + // here, not just in ensure, or the guard does not defend the property. + const rfv = parseInt(git(['config', 'core.repositoryformatversion'], { allowFail: true }) || '0', 10); + if (!(rfv >= 1)) { + problems.push(`core.repositoryformatversion is "${rfv}" (expected >= 1); below it a stricter git may ignore the worktree override`); + } + + const top = topLevel(); + const hooksDir = join(top, '.hooks'); + if (existsSync(hooksDir)) { + const resolvedHooks = git(['config', 'core.hooksPath'], { allowFail: true }); + // Compare WITHOUT resolving a relative value against the cwd. The old + // drift was a relative `.hooks` on the shared config, which a cwd-relative + // resolve() would pass from the repo root but fail from a subdirectory. + // ensure pins an absolute path, so a healthy repo is an exact match and a + // relative value fails the same way regardless of where the check runs. + const hooksOk = resolvedHooks && isAbsolute(resolvedHooks) + && resolve(resolvedHooks) === resolve(hooksDir); + if (!hooksOk) { + problems.push(`core.hooksPath is "${resolvedHooks}" (expected the absolute "${hooksDir}"); the framework hook is not reliably active`); + } + } + + if (problems.length) { + console.error('[git-worktree-safe] repo health check FAILED:'); + for (const p of problems) console.error(` - ${p}`); + console.error('Fix with: npm run fix:git'); + process.exit(1); + } + console.log('[git-worktree-safe] repo health check passed (core.bare=false, hooks active).'); +} + +try { + if (CHECK) check(); + else ensure(); +} catch (err) { + // Ensure mode must never fail `npm install`. Surface the reason and exit 0. + if (!CHECK) { + console.warn(`[git-worktree-safe] could not fully ensure config: ${String(err.message).split('\n')[0]}`); + process.exit(0); + } + throw err; +} diff --git a/test/repo-health/git-worktree-safe.test.mjs b/test/repo-health/git-worktree-safe.test.mjs new file mode 100644 index 000000000..f0cd190cd --- /dev/null +++ b/test/repo-health/git-worktree-safe.test.mjs @@ -0,0 +1,133 @@ +// Regression guard for issue #166 (recurring core.bare=true corruption). +// +// Proves scripts/git-worktree-safe.mjs heals a repo that is in the broken +// bare state, that --check catches the breakage, and that the per-worktree +// override survives a later shared-config core.bare flip (the exact event +// that used to re-break the main checkout). + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT = join( + dirname(fileURLToPath(import.meta.url)), + '..', '..', 'scripts', 'git-worktree-safe.mjs', +); + +let repo; + +function git(args, opts = {}) { + return execFileSync('git', args, { cwd: repo, encoding: 'utf8', ...opts }).trim(); +} +// Run the heal/check script in the throwaway repo. Returns {code, out}. +function runScript(args = []) { + try { + const out = execFileSync(process.execPath, [SCRIPT, ...args], { + cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, out }; + } catch (err) { + return { code: err.status ?? 1, out: `${err.stdout ?? ''}${err.stderr ?? ''}` }; + } +} + +before(() => { + repo = mkdtempSync(join(tmpdir(), 'webjs-corebare-')); + git(['init', '-q']); + git(['config', 'user.email', 't@t.t']); + git(['config', 'user.name', 't']); + writeFileSync(join(repo, 'a.txt'), 'hi\n'); + git(['add', 'a.txt']); + git(['commit', '-qm', 'init']); + // A tracked .hooks dir so the script exercises the hooksPath branch. + mkdirSync(join(repo, '.hooks')); + writeFileSync(join(repo, '.hooks', 'pre-commit'), '#!/bin/bash\nexit 0\n'); +}); + +after(() => { + if (repo) rmSync(repo, { recursive: true, force: true }); +}); + +describe('git-worktree-safe (#166)', () => { + test('--check fails when the shared config is in the broken bare state', () => { + // Simulate the corruption: shared core.bare=true, no per-worktree override. + git(['config', '--local', 'core.bare', 'true']); + assert.notEqual( + git(['rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'pipe', 'ignore'] }, ), + 'true', + 'precondition: the repo should read as bare/broken', + ); + const { code } = runScript(['--check']); + assert.equal(code, 1, '--check must exit non-zero on the broken state'); + }); + + test('ensure heals the broken state', () => { + const { code } = runScript(); + assert.equal(code, 0, 'ensure must succeed'); + assert.equal(git(['rev-parse', '--is-inside-work-tree']), 'true', + 'main checkout is a usable work tree again'); + assert.equal(git(['config', 'core.bare']), 'false', + 'core.bare resolves to false'); + assert.equal(git(['config', 'extensions.worktreeConfig']), 'true', + 'per-worktree config is enabled'); + assert.equal(git(['config', 'core.repositoryformatversion']), '1', + 'repositoryformatversion bumped to 1 so the extension is contractually honored'); + const hooks = git(['config', 'core.hooksPath']); + assert.equal(hooks, join(repo, '.hooks'), + 'core.hooksPath pinned to the absolute .hooks dir'); + }); + + test('--check passes after healing', () => { + assert.equal(runScript(['--check']).code, 0); + }); + + test('the per-worktree override survives a later SHARED core.bare flip', () => { + // This is the event that used to re-break the checkout every time. + git(['config', '--local', 'core.bare', 'true']); + assert.equal(git(['config', 'core.bare']), 'false', + 'main worktree still reads false despite the shared flip'); + assert.equal(git(['rev-parse', '--is-inside-work-tree']), 'true'); + assert.equal(runScript(['--check']).code, 0, + '--check stays green because the override holds'); + }); + + test('ensure is idempotent', () => { + assert.equal(runScript().code, 0); + assert.equal(runScript().code, 0); + assert.equal(runScript(['--check']).code, 0); + }); + + test('--check rejects a relative hooksPath drift, cwd-independently', () => { + // Drift hooksPath back to a relative value (the old-prepare bug), while + // core.bare and worktreeConfig stay healthy. A cwd-relative comparison + // would have passed this from the repo root; it must fail everywhere. + git(['config', '--worktree', 'core.hooksPath', '.hooks']); + assert.equal(runScript(['--check']).code, 1, 'fails from the repo root'); + const sub = join(repo, 'pkg', 'nested'); + mkdirSync(sub, { recursive: true }); + let fromSub; + try { + execFileSync(process.execPath, [SCRIPT, '--check'], { cwd: sub, stdio: ['ignore', 'pipe', 'pipe'] }); + fromSub = 0; + } catch (err) { fromSub = err.status ?? 1; } + assert.equal(fromSub, 1, 'fails from a subdirectory too'); + // Healing restores the absolute pin and the check goes green. + assert.equal(runScript().code, 0); + assert.equal(runScript(['--check']).code, 0); + }); + + test('--check rejects an rfv drop below 1 (worktree override no longer guaranteed)', () => { + // worktreeConfig + the override stay in place; only rfv drops. Current git + // still honors the override, but the check must flag it because a stricter + // git would not, which is exactly what ensure's rfv bump defends against. + git(['config', 'core.repositoryformatversion', '0']); + assert.equal(runScript(['--check']).code, 1, 'rfv < 1 must fail the check'); + assert.equal(runScript().code, 0); + assert.equal(git(['config', 'core.repositoryformatversion']), '1', 'heal restores rfv=1'); + assert.equal(runScript(['--check']).code, 0); + }); +});