Skip to content

Commit d8454e9

Browse files
committed
Keep git worktree runs isolated from dirty repos
1 parent a151360 commit d8454e9

2 files changed

Lines changed: 59 additions & 27 deletions

File tree

packages/local-runner/src/index.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ test("workspace manager reopens an existing git branch without resetting it", as
356356
await manager.cleanup(reopened.workspacePath);
357357
});
358358

359-
test("workspace manager overlays uncommitted working tree files into git workspaces", async () => {
359+
test("workspace manager keeps git workspaces clean when the source repo is dirty", async () => {
360360
const repo = await createRealGitRepo();
361361
await writeFile(join(repo, "vitest.config.ts"), "export default {};\n");
362362
const manager = new FileSystemWorkspaceManager();
@@ -368,10 +368,34 @@ test("workspace manager overlays uncommitted working tree files into git workspa
368368
baseRef: "main",
369369
});
370370

371-
assert.equal(await readFile(join(workspacePath, "vitest.config.ts"), "utf-8"), "export default {};\n");
371+
assert.equal(await fileExists(join(workspacePath, "vitest.config.ts")), false);
372372
await manager.cleanup(workspacePath);
373373
});
374374

375+
test("local runner emits a warning when it ignores dirty source changes for git worktrees", async () => {
376+
const repo = await createRealGitRepo();
377+
await writeFile(join(repo, "vitest.config.ts"), "export default {};\n");
378+
const runner = new LocalRunner({
379+
adapters: [new OrderedFakeAdapter()],
380+
});
381+
const request = createRequest(repo, "task-dirty-git-worktree");
382+
request.workspace.isolation = "git-worktree";
383+
request.workspace.baseRef = "main";
384+
385+
const { runId } = await runner.startTask(request);
386+
await runner.awaitResult(runId);
387+
const metadata = await runner.inspect(runId);
388+
const events = await readEventLog(metadata.eventLogPath);
389+
390+
assert.equal(
391+
events.some((event) =>
392+
event.type === "log" &&
393+
event.message.includes("ignored local uncommitted source-repo changes"),
394+
),
395+
true,
396+
);
397+
});
398+
375399
test("local runner records artifacts and result metadata", async () => {
376400
const repo = await createRepo();
377401
const runner = new LocalRunner({

packages/local-runner/src/index.ts

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -105,30 +105,25 @@ async function copyRepoContents(sourceRepoPath: string, workspacePath: string):
105105
}
106106
}
107107

108-
async function overlayGitWorkingTreeChanges(sourceRepoPath: string, workspacePath: string): Promise<void> {
109-
const rawStatus = await execFileStdout("git", ["status", "--porcelain"], sourceRepoPath);
110-
const entries = rawStatus
111-
.split("\n")
112-
.map((line) => line.trimEnd())
113-
.filter(Boolean);
114-
115-
for (const entry of entries) {
116-
const status = entry.slice(0, 2);
117-
const rawPath = entry.slice(3);
118-
const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").at(-1)! : rawPath;
119-
if (!path || path.startsWith(".git") || path.startsWith(".devagent-runner") || path.startsWith("node_modules")) {
120-
continue;
121-
}
122-
123-
const sourcePath = join(sourceRepoPath, path);
124-
const workspacePathTarget = join(workspacePath, path);
125-
if (status.includes("D")) {
126-
await rm(workspacePathTarget, { recursive: true, force: true });
127-
continue;
128-
}
129-
130-
await mkdir(dirname(workspacePathTarget), { recursive: true });
131-
await cp(sourcePath, workspacePathTarget, { recursive: true });
108+
async function hasDirtyWorkingTree(sourceRepoPath: string): Promise<boolean> {
109+
try {
110+
const rawStatus = await execFileStdout("git", ["status", "--porcelain"], sourceRepoPath);
111+
return rawStatus
112+
.split("\n")
113+
.map((line) => line.trimEnd())
114+
.some((line) => {
115+
if (!line) return false;
116+
const rawPath = line.slice(3);
117+
const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").at(-1)! : rawPath;
118+
return Boolean(
119+
path &&
120+
!path.startsWith(".git") &&
121+
!path.startsWith(".devagent-runner") &&
122+
!path.startsWith("node_modules"),
123+
);
124+
});
125+
} catch {
126+
return false;
132127
}
133128
}
134129

@@ -248,7 +243,6 @@ export class FileSystemWorkspaceManager implements WorkspaceManager {
248243
? ["worktree", "add", workspacePath, spec.workBranch]
249244
: ["worktree", "add", "-B", spec.workBranch, workspacePath, spec.baseRef ?? "HEAD"];
250245
await execFileAsync("git", worktreeArgs, spec.sourceRepoPath);
251-
await overlayGitWorkingTreeChanges(spec.sourceRepoPath, workspacePath);
252246
await linkSharedDependencies(spec.sourceRepoPath, workspacePath);
253247
return { workspacePath };
254248
} catch {
@@ -353,6 +347,20 @@ export class LocalRunner implements RunnerClient {
353347
}
354348
};
355349

350+
if (
351+
request.workspace.isolation === "git-worktree" &&
352+
await hasDirtyWorkingTree(request.workspace.sourceRepoPath)
353+
) {
354+
onEvent({
355+
protocolVersion: PROTOCOL_VERSION,
356+
type: "log",
357+
at: new Date().toISOString(),
358+
taskId: request.taskId,
359+
stream: "stdout",
360+
message: "Runner ignored local uncommitted source-repo changes and used a clean isolated workspace.",
361+
});
362+
}
363+
356364
const handle = await adapter.launch(request, workspacePath, artifactDir, onEvent);
357365
const metadata: RunMetadata = {
358366
runId: handle.id,

0 commit comments

Comments
 (0)