11import { execFile } from "node:child_process" ;
22import { randomUUID } from "node:crypto" ;
3- import { access , mkdtemp , rm } from "node:fs/promises" ;
3+ import { access , mkdtemp , readFile , rm } from "node:fs/promises" ;
44import { tmpdir } from "node:os" ;
55import { dirname , join , resolve } from "node:path" ;
66import { promisify } from "node:util" ;
@@ -31,6 +31,13 @@ export async function createWorktree(id: number): Promise<string> {
3131 cwd : repoRoot ,
3232 } ) ;
3333
34+ // Lock the worktree to prevent git gc --auto from pruning it while agents run.
35+ // Without this, concurrent agents' git commits can trigger gc which prunes
36+ // other worktrees' metadata from .git/worktrees/.
37+ await exec ( "git" , [ "worktree" , "lock" , "--reason" , "thinktank agent in use" , dir ] , {
38+ cwd : repoRoot ,
39+ } ) ;
40+
3441 // Symlink node_modules from the main repo so tests and tools work in worktrees.
3542 // Git worktrees don't include gitignored directories like node_modules.
3643 const mainNodeModules = join ( repoRoot , "node_modules" ) ;
@@ -49,6 +56,9 @@ export async function createWorktree(id: number): Promise<string> {
4956export async function removeWorktree ( worktreePath : string ) : Promise < void > {
5057 const repoRoot = await getMainRepoRoot ( ) ;
5158
59+ // Unlock the worktree before removal (it was locked during creation)
60+ await exec ( "git" , [ "worktree" , "unlock" , worktreePath ] , { cwd : repoRoot } ) . catch ( ( ) => { } ) ;
61+
5262 // Remove node_modules symlink/junction BEFORE removing worktree.
5363 // On Windows, rm -rf follows junctions and deletes the target.
5464 try {
@@ -77,9 +87,14 @@ export async function removeWorktree(worktreePath: string): Promise<void> {
7787export async function getDiff ( worktreePath : string ) : Promise < string > {
7888 const absPath = resolve ( worktreePath ) ;
7989 try {
80- // Verify worktree is still a git repo before running git commands
90+ // Verify worktree .git file AND its metadata directory still exist.
91+ // git gc --auto can prune .git/worktrees/NAME/ even if the .git pointer file remains.
8192 await access ( join ( absPath , ".git" ) ) ;
82- await exec ( "git" , [ "rev-parse" , "--git-dir" ] , { cwd : absPath } ) ;
93+ const gitContent = await readFile ( join ( absPath , ".git" ) , "utf-8" ) ;
94+ const gitdirMatch = gitContent . match ( / g i t d i r : \s * ( .+ ) / ) ;
95+ if ( gitdirMatch ?. [ 1 ] ) {
96+ await access ( gitdirMatch [ 1 ] . trim ( ) ) ;
97+ }
8398
8499 await exec ( "git" , [ "add" , "-A" ] , { cwd : absPath } ) ;
85100 await exec ( "git" , [ "reset" , "HEAD" , "--" , "node_modules" ] , { cwd : absPath } ) . catch ( ( ) => { } ) ;
@@ -99,6 +114,11 @@ export async function getDiffStats(
99114 const absPath = resolve ( worktreePath ) ;
100115 try {
101116 await access ( join ( absPath , ".git" ) ) ;
117+ const gitContent = await readFile ( join ( absPath , ".git" ) , "utf-8" ) ;
118+ const gitdirMatch = gitContent . match ( / g i t d i r : \s * ( .+ ) / ) ;
119+ if ( gitdirMatch ?. [ 1 ] ) {
120+ await access ( gitdirMatch [ 1 ] . trim ( ) ) ;
121+ }
102122 await exec ( "git" , [ "add" , "-A" ] , { cwd : absPath } ) ;
103123 await exec ( "git" , [ "reset" , "HEAD" , "--" , "node_modules" ] , { cwd : absPath } ) . catch ( ( ) => { } ) ;
104124 const { stdout } = await exec ( "git" , [ "diff" , "--cached" , "--stat" , "HEAD" ] , {
0 commit comments