diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdb8f066..10a868365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.210.0] - 2026-10-25 + +**/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.209.0] - 2026-10-25 **The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green. diff --git a/VERSION b/VERSION index 32b3c2d81..e41ac1723 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.209.0 +1.91.210.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index bfdfaa808..6035c125d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.209.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.210.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, 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/package.json b/package.json index da6af4fc8..77850610a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.209", + "version": "1.91.210", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", 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([]); + }); +}); +