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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-24 20:10:53 +00:00
1 parent 00100f1598
commit 7aea67a793
3 files changed
+87 -5

No files matched your search

+13
View File
@@ -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/<name>` under that subfolder. The publication guard only looked for `<project dir>/.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.
+13 -4
View File
@@ -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: <root>/.git/worktrees/<name>/gitdir names
* <worktree>/.git, and that file names the same admin entry back.
* <worktree>/.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);
+61 -1
View File
@@ -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([]);
});
});