11import { execFile } from "node:child_process" ;
2- import { randomUUID } from "node:crypto" ;
3- import { access , mkdtemp , readFile , rm } from "node:fs/promises" ;
2+ import { mkdtemp , rm } from "node:fs/promises" ;
43import { tmpdir } from "node:os" ;
54import { dirname , join , resolve } from "node:path" ;
65import { promisify } from "node:util" ;
@@ -25,27 +24,25 @@ async function getMainRepoRoot(): Promise<string> {
2524export async function createWorktree ( id : number ) : Promise < string > {
2625 const repoRoot = await getMainRepoRoot ( ) ;
2726 const dir = await mkdtemp ( join ( tmpdir ( ) , `thinktank-agent-${ id } -` ) ) ;
28- const branchName = `thinktank/agent-${ id } -${ randomUUID ( ) . slice ( 0 , 8 ) } ` ;
2927
30- await exec ( "git" , [ "worktree" , "add" , "-b" , branchName , dir ] , {
31- cwd : repoRoot ,
32- } ) ;
33-
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-
41- // Symlink node_modules from the main repo so tests and tools work in worktrees.
42- // Git worktrees don't include gitignored directories like node_modules.
28+ // Use git clone instead of git worktree to create fully independent copies.
29+ // Worktrees share .git metadata with the main repo, allowing agents to discover
30+ // and interfere with the main repo (via .git pointer file, git -C commands, or
31+ // git worktree add --force). Clones are completely isolated — no shared state,
32+ // no metadata to corrupt, no path to the main repo.
33+ // Local clone uses hardlinks for objects (near-zero extra disk, ~0.1s).
34+ // Each clone has a fully independent .git directory — no shared metadata,
35+ // no alternates file pointing to parent, no worktree registration to corrupt.
36+ // Agents with Bash access cannot interfere with other clones or the main repo.
37+ await exec ( "git" , [ "clone" , repoRoot , dir ] ) ;
38+
39+ // Symlink node_modules from the main repo so tests and tools work in clones.
4340 const mainNodeModules = join ( repoRoot , "node_modules" ) ;
44- const worktreeNodeModules = join ( dir , "node_modules" ) ;
41+ const cloneNodeModules = join ( dir , "node_modules" ) ;
4542 try {
4643 const { lstat, symlink } = await import ( "node:fs/promises" ) ;
4744 await lstat ( mainNodeModules ) ;
48- await symlink ( mainNodeModules , worktreeNodeModules , "junction" ) ;
45+ await symlink ( mainNodeModules , cloneNodeModules , "junction" ) ;
4946 } catch {
5047 // No node_modules in main repo or symlink failed — not critical
5148 }
@@ -54,48 +51,26 @@ export async function createWorktree(id: number): Promise<string> {
5451}
5552
5653export async function removeWorktree ( worktreePath : string ) : Promise < void > {
57- const repoRoot = await getMainRepoRoot ( ) ;
58-
59- // Unlock the worktree before removal (it was locked during creation)
60- await exec ( "git" , [ "worktree" , "unlock" , worktreePath ] , { cwd : repoRoot } ) . catch ( ( ) => { } ) ;
61-
62- // Remove node_modules symlink/junction BEFORE removing worktree.
54+ // Remove node_modules symlink/junction BEFORE removing clone directory.
6355 // On Windows, rm -rf follows junctions and deletes the target.
6456 try {
6557 const nmPath = join ( worktreePath , "node_modules" ) ;
6658 const { lstat, unlink } = await import ( "node:fs/promises" ) ;
6759 const stat = await lstat ( nmPath ) ;
6860 if ( stat . isSymbolicLink ( ) || stat . isDirectory ( ) ) {
69- // unlink removes the junction/symlink without following it
7061 await unlink ( nmPath ) . catch ( ( ) => { } ) ;
7162 }
7263 } catch {
7364 // No symlink to remove
7465 }
7566
76- try {
77- await exec ( "git" , [ "worktree" , "remove" , worktreePath , "--force" ] , {
78- cwd : repoRoot ,
79- } ) ;
80- } catch {
81- // Fallback: remove directory manually and prune
82- await rm ( worktreePath , { recursive : true , force : true } ) ;
83- await exec ( "git" , [ "worktree" , "prune" ] , { cwd : repoRoot } ) ;
84- }
67+ // Since we use clones (not worktrees), just delete the directory.
68+ await rm ( worktreePath , { recursive : true , force : true } ) ;
8569}
8670
8771export async function getDiff ( worktreePath : string ) : Promise < string > {
8872 const absPath = resolve ( worktreePath ) ;
8973 try {
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.
92- await access ( join ( absPath , ".git" ) ) ;
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- }
98-
9974 await exec ( "git" , [ "add" , "-A" ] , { cwd : absPath } ) ;
10075 await exec ( "git" , [ "reset" , "HEAD" , "--" , "node_modules" ] , { cwd : absPath } ) . catch ( ( ) => { } ) ;
10176 const { stdout } = await exec ( "git" , [ "diff" , "--cached" , "HEAD" ] , { cwd : absPath } ) ;
@@ -113,12 +88,6 @@ export async function getDiffStats(
11388) : Promise < { filesChanged : string [ ] ; linesAdded : number ; linesRemoved : number } > {
11489 const absPath = resolve ( worktreePath ) ;
11590 try {
116- 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- }
12291 await exec ( "git" , [ "add" , "-A" ] , { cwd : absPath } ) ;
12392 await exec ( "git" , [ "reset" , "HEAD" , "--" , "node_modules" ] , { cwd : absPath } ) . catch ( ( ) => { } ) ;
12493 const { stdout } = await exec ( "git" , [ "diff" , "--cached" , "--stat" , "HEAD" ] , {
@@ -167,16 +136,7 @@ export async function estimateRepoSize(): Promise<number> {
167136}
168137
169138export async function cleanupBranches ( ) : Promise < void > {
170- const repoRoot = await getMainRepoRoot ( ) ;
171- const { stdout } = await exec ( "git" , [ "branch" , "--list" , "thinktank/*" ] , {
172- cwd : repoRoot ,
173- } ) ;
174- for ( const branch of stdout . split ( "\n" ) . filter ( Boolean ) ) {
175- const name = branch . trim ( ) ;
176- try {
177- await exec ( "git" , [ "branch" , "-D" , name ] , { cwd : repoRoot } ) ;
178- } catch {
179- // ignore
180- }
181- }
139+ // With clone-based isolation (instead of worktrees), there are no
140+ // thinktank/* branches in the main repo. This function remains for
141+ // backward compatibility but is now a no-op.
182142}
0 commit comments