From 7aea67a793011563865d6dee514f254e1cdc7129 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 24 Oct 2026 20:10:53 +0000 Subject: [PATCH] fix(autoplan): linked worktrees resolve from a project directory below the repo root (#3296) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 ++++++ autoplan/bin/phase-publication-hook.ts | 17 +++++-- test/autoplan-publication-hook.test.ts | 62 +++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9652e71fd..83108c5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] + +**/autoplan works when Claude Code's project directory is a subfolder of the repository (#3296).** When the git root sits above the project (for example the root is `$HOME` and the session starts in `$HOME/projects/app`), a worktree session's journal lives in `.claude/worktrees/` under that subfolder. The publication guard only looked for `/.git/worktrees`, found nothing, and denied every phase Read with `foreign_cwd`, so the reporter had to restart the session from the repository root. + +### Fixed +- **`autoplan/bin/phase-publication-hook.ts`**: `linkedWorktrees` walks up from the project directory to the first `.git` and reads that repository's worktree registry. A `.git` file (the project is itself a linked worktree or a submodule) stops the walk with no owners, as before. Git's two-way link is still required: the registry entry must name the worktree's `.git` file and that file must name the same entry back. No `git` process is started inside the hook. + +### Tests +- `test/autoplan-publication-hook.test.ts` builds the reporter's layout with real `git worktree add`: a subfolder project directory (and a deeper one) owns the worktree under it, and the root still does. It fails on main. Controls that still get no owners: a nested independent repository (its own `.git` stops the walk), a project directory inside a linked worktree, a registry entry whose worktree names another repository, and a directory with no `.git` above it. + +### Gate integrity +- One accepted-input change to review: from a project directory below the repository root, the guard now accepts the journal of a linked worktree of that same repository, which it already accepted when the project directory was the root. The seven existing `foreign_cwd` refusals in `test/autoplan-publication-guard.test.ts` (no link, one-sided links, another repository, a submodule gitdir, a project that is itself a worktree, a bare repository) still deny. No threshold, budget, timeout, trial count or case kind changed. + ## [1.91.186.0] - 2026-10-24 **Approving an autoplan run no longer fails one time in ten because its run id looks like a card number.** Today's daily flaky-fix pass found `test/capy-captain-flow.test.ts` failing once and passing on the serial retry in 7 of the 57 Free Tests runs from 2026-10-21 to 2026-10-23, one of them a push to main (39878113535). Every failure had the same assertion: `gstack-approvals import` refused the approval row with "field ask: pii.cc". The default autoplan run id is `run-YYYYMMDDHHMMSS`, the import writes it into the row's ask and source, and the credit-card rule saw those 14 digits as a card number whenever they passed the Luhn check, which a timestamp does about one time in ten. A real user's approval import failed the same way. diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index 41b95ec83..d8cf0c214 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -627,12 +627,21 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ * A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its * journal is rooted in the linked worktree. Only git's own two-way link makes * a directory that worktree: /.git/worktrees//gitdir names - * /.git, and that file names the same admin entry back. + * /.git, and that file names the same admin entry back. A project + * directory below the root (#3296) uses the first .git found walking up; a + * .git file there means a linked worktree or submodule, which has no registry. */ export function linkedWorktrees(projectDir: string): string[] { - let entries: fs.Dirent[]; - const admin = path.join(projectDir, '.git', 'worktrees'); - try { entries = fs.readdirSync(admin, { withFileTypes: true }); } catch { return []; } + let entries: fs.Dirent[], admin: string | undefined; + try { + for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) { + const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false }); + if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; } + if (path.dirname(dir) === dir) break; + } + if (!admin) return []; + entries = fs.readdirSync(admin, { withFileTypes: true }); + } catch { return []; } return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => { try { const link = path.join(admin, entry.name); diff --git a/test/autoplan-publication-hook.test.ts b/test/autoplan-publication-hook.test.ts index de4eebfc9..9635e38fa 100644 --- a/test/autoplan-publication-hook.test.ts +++ b/test/autoplan-publication-hook.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts'; +import { linkedWorktrees, runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts'; import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read'; const ROOT = path.join(import.meta.dir, '..'); @@ -192,3 +192,63 @@ describe('Autoplan hook journal-root verdicts', () => { for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail); }); }); + +describe('linkedWorktrees from a project directory below the repository root (#3296)', () => { + const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0' }; + const git = (cwd: string, ...args: string[]) => { + const child = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env: gitEnv }); + expect(child.status).toBe(0); + }; + function repo(): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(dir); + git(dir, 'init', '-q', '-b', 'main'); + git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed'); + return dir; + } + const real = (dir: string) => fs.realpathSync(dir); + + test.skipIf(process.platform === 'win32')('the reporter layout: a subfolder project directory owns its worktree under that subfolder', () => { + const root = repo(), sub = path.join(root, 'sub'); fs.mkdirSync(path.join(sub, '.claude', 'worktrees'), { recursive: true }); + const worktree = path.join(sub, '.claude', 'worktrees', 'w'); + git(root, 'worktree', 'add', '-q', worktree); + expect(linkedWorktrees(sub)).toEqual([real(worktree)]); + expect(linkedWorktrees(path.join(sub, 'deeper', 'still'))).toEqual([real(worktree)]); + expect(linkedWorktrees(root)).toEqual([real(worktree)]); + }); + + test.skipIf(process.platform === 'win32')('a nested independent repository stops the walk at its own .git', () => { + const outer = repo(), inner = path.join(outer, 'vendor', 'lib'); + fs.mkdirSync(inner, { recursive: true }); + git(inner, 'init', '-q', '-b', 'main'); + git(inner, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed'); + git(outer, 'worktree', 'add', '-q', path.join(outer, 'outer-wt')); + expect(linkedWorktrees(inner)).toEqual([]); + expect(linkedWorktrees(path.join(inner, 'src'))).toEqual([]); + }); + + test.skipIf(process.platform === 'win32')('a project directory inside a linked worktree has no registry of its own', () => { + const root = repo(), worktree = path.join(root, 'wt'); + git(root, 'worktree', 'add', '-q', worktree); + fs.mkdirSync(path.join(worktree, 'pkg')); + expect(linkedWorktrees(worktree)).toEqual([]); + expect(linkedWorktrees(path.join(worktree, 'pkg'))).toEqual([]); + }); + + test('a registry entry whose worktree names another repository is still refused from a subfolder', () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(root); + const other = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-other-'))); dirs.push(other); + const worktree = path.join(root, 'sub', 'wt'); fs.mkdirSync(worktree, { recursive: true }); + const entry = path.join(root, '.git', 'worktrees', 'wt'); fs.mkdirSync(entry, { recursive: true }); + const foreign = path.join(other, '.git', 'worktrees', 'wt'); fs.mkdirSync(foreign, { recursive: true }); + fs.writeFileSync(path.join(entry, 'gitdir'), path.join(worktree, '.git') + '\n'); + fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${foreign}\n`); + expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([]); + fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${entry}\n`); + expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([worktree]); + }); + + test('a directory with no .git anywhere above it has no worktrees', () => { + expect(linkedWorktrees(path.parse(tmpdir()).root)).toEqual([]); + }); +}); +