mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
fix(autoplan): linked worktrees resolve from a project directory below the repo root (#3296) (#3383)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0ea3dc8986
commit
c279ce045d
6 files changed
+90
-8
No files matched your search
@@ -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/<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.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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user