-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit.ts
More file actions
182 lines (162 loc) · 6.57 KB
/
Copy pathgit.ts
File metadata and controls
182 lines (162 loc) · 6.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
const exec = promisify(execFile);
export async function getRepoRoot(): Promise<string> {
const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"]);
return stdout.trim();
}
/**
* Returns the root of the main repository, even when called from inside a worktree.
* This is necessary because git does not support nested worktrees.
*/
async function getMainRepoRoot(): Promise<string> {
const { stdout } = await exec("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
// --git-common-dir returns /path/to/main-repo/.git for both worktrees and the main repo
return dirname(stdout.trim());
}
export async function createWorktree(id: number): Promise<string> {
const repoRoot = await getMainRepoRoot();
const dir = await mkdtemp(join(tmpdir(), `thinktank-agent-${id}-`));
const branchName = `thinktank/agent-${id}-${randomUUID().slice(0, 8)}`;
await exec("git", ["worktree", "add", "-b", branchName, dir], {
cwd: repoRoot,
});
// Lock the worktree to prevent git gc --auto from pruning it while agents run.
// Without this, concurrent agents' git commits can trigger gc which prunes
// other worktrees' metadata from .git/worktrees/.
await exec("git", ["worktree", "lock", "--reason", "thinktank agent in use", dir], {
cwd: repoRoot,
});
// Symlink node_modules from the main repo so tests and tools work in worktrees.
// Git worktrees don't include gitignored directories like node_modules.
const mainNodeModules = join(repoRoot, "node_modules");
const worktreeNodeModules = join(dir, "node_modules");
try {
const { lstat, symlink } = await import("node:fs/promises");
await lstat(mainNodeModules);
await symlink(mainNodeModules, worktreeNodeModules, "junction");
} catch {
// No node_modules in main repo or symlink failed — not critical
}
return dir;
}
export async function removeWorktree(worktreePath: string): Promise<void> {
const repoRoot = await getMainRepoRoot();
// Unlock the worktree before removal (it was locked during creation)
await exec("git", ["worktree", "unlock", worktreePath], { cwd: repoRoot }).catch(() => {});
// Remove node_modules symlink/junction BEFORE removing worktree.
// On Windows, rm -rf follows junctions and deletes the target.
try {
const nmPath = join(worktreePath, "node_modules");
const { lstat, unlink } = await import("node:fs/promises");
const stat = await lstat(nmPath);
if (stat.isSymbolicLink() || stat.isDirectory()) {
// unlink removes the junction/symlink without following it
await unlink(nmPath).catch(() => {});
}
} catch {
// No symlink to remove
}
try {
await exec("git", ["worktree", "remove", worktreePath, "--force"], {
cwd: repoRoot,
});
} catch {
// Fallback: remove directory manually and prune
await rm(worktreePath, { recursive: true, force: true });
await exec("git", ["worktree", "prune"], { cwd: repoRoot });
}
}
export async function getDiff(worktreePath: string): Promise<string> {
const absPath = resolve(worktreePath);
try {
// Verify worktree .git file AND its metadata directory still exist.
// git gc --auto can prune .git/worktrees/NAME/ even if the .git pointer file remains.
await access(join(absPath, ".git"));
const gitContent = await readFile(join(absPath, ".git"), "utf-8");
const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/);
if (gitdirMatch?.[1]) {
await access(gitdirMatch[1].trim());
}
await exec("git", ["add", "-A"], { cwd: absPath });
await exec("git", ["reset", "HEAD", "--", "node_modules"], { cwd: absPath }).catch(() => {});
const { stdout } = await exec("git", ["diff", "--cached", "HEAD"], { cwd: absPath });
return stdout;
} catch (err) {
console.warn(
`[thinktank] getDiff failed for ${absPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return "";
}
}
export async function getDiffStats(
worktreePath: string,
): Promise<{ filesChanged: string[]; linesAdded: number; linesRemoved: number }> {
const absPath = resolve(worktreePath);
try {
await access(join(absPath, ".git"));
const gitContent = await readFile(join(absPath, ".git"), "utf-8");
const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/);
if (gitdirMatch?.[1]) {
await access(gitdirMatch[1].trim());
}
await exec("git", ["add", "-A"], { cwd: absPath });
await exec("git", ["reset", "HEAD", "--", "node_modules"], { cwd: absPath }).catch(() => {});
const { stdout } = await exec("git", ["diff", "--cached", "--stat", "HEAD"], {
cwd: absPath,
});
const filesChanged: string[] = [];
let linesAdded = 0;
let linesRemoved = 0;
for (const line of stdout.split("\n")) {
const fileMatch = line.match(/^\s*(.+?)\s+\|\s+\d+/);
if (fileMatch?.[1]) {
filesChanged.push(fileMatch[1].trim());
}
const statsMatch = line.match(/(\d+) insertions?\(\+\)/);
const removeMatch = line.match(/(\d+) deletions?\(-\)/);
if (statsMatch?.[1]) linesAdded = parseInt(statsMatch[1], 10);
if (removeMatch?.[1]) linesRemoved = parseInt(removeMatch[1], 10);
}
return { filesChanged, linesAdded, linesRemoved };
} catch (err) {
console.warn(
`[thinktank] getDiffStats failed for ${absPath}: ${err instanceof Error ? err.message : String(err)}`,
);
return { filesChanged: [], linesAdded: 0, linesRemoved: 0 };
}
}
/**
* Estimate the size of the git repo in bytes using `git count-objects -v`.
* Returns the sum of loose object size and pack size (a rough lower bound
* for the size of a checked-out worktree).
*/
export async function estimateRepoSize(): Promise<number> {
const { stdout } = await exec("git", ["count-objects", "-v"]);
let sizeKB = 0;
for (const line of stdout.split("\n")) {
const match = line.match(/^(size|size-pack):\s+(\d+)/);
if (match?.[2]) {
sizeKB += parseInt(match[2], 10);
}
}
return sizeKB * 1024;
}
export async function cleanupBranches(): Promise<void> {
const repoRoot = await getMainRepoRoot();
const { stdout } = await exec("git", ["branch", "--list", "thinktank/*"], {
cwd: repoRoot,
});
for (const branch of stdout.split("\n").filter(Boolean)) {
const name = branch.trim();
try {
await exec("git", ["branch", "-D", name], { cwd: repoRoot });
} catch {
// ignore
}
}
}