Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit a72d05c

Browse files
authored
fix(git): keep handoff packs on the repository filesystem (#3426)
1 parent 0cbc64f commit a72d05c

4 files changed

Lines changed: 84 additions & 19 deletions

File tree

packages/agent/src/handoff-checkpoint.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { readdir } from "node:fs/promises";
2+
import path from "node:path";
13
import { afterEach, describe, expect, it, vi } from "vitest";
24
import {
35
decodeHandoffArtifact,
@@ -222,6 +224,18 @@ describe("HandoffCheckpointTracker", () => {
222224
expect(checkpoint).not.toBeNull();
223225
if (!checkpoint) return;
224226
expect(Object.keys(store.artifacts).length).toBeGreaterThan(0);
227+
const gitCommonDirRaw = await cloudRepo.git([
228+
"rev-parse",
229+
"--git-common-dir",
230+
]);
231+
const gitCommonDir = path.isAbsolute(gitCommonDirRaw)
232+
? gitCommonDirRaw
233+
: path.resolve(cloudRepo.path, gitCommonDirRaw);
234+
expect(
235+
(await readdir(gitCommonDir)).filter((entry) =>
236+
entry.startsWith("posthog-code-handoff-"),
237+
),
238+
).toEqual([]);
225239

226240
const applyTracker = createTracker(localRepo.path, apiClient);
227241
await applyTracker.applyFromHandoff(checkpoint);

packages/agent/src/handoff-checkpoint.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
22
import { tmpdir } from "node:os";
3-
import { dirname, join } from "node:path";
3+
import { join } from "node:path";
44
import {
55
type GitHandoffBranchDivergence,
66
type GitHandoffCheckpoint,
@@ -149,12 +149,10 @@ export class HandoffCheckpointTracker {
149149
indexArtifactPath: uploads.index?.storagePath,
150150
};
151151
} finally {
152-
const tempDir = capture.headPack?.path
153-
? dirname(capture.headPack.path)
154-
: dirname(capture.indexFile.path);
155-
await this.removeIfPresent(capture.headPack?.path);
156-
await this.removeIfPresent(capture.indexFile.path);
157-
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
152+
await rm(capture.artifactDirectory, {
153+
recursive: true,
154+
force: true,
155+
}).catch(() => {});
158156
}
159157
}
160158

packages/git/src/handoff.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,9 @@ async function makeCloudChanges(
103103
}
104104

105105
async function cleanupCapture(capture: GitHandoffCaptureResult): Promise<void> {
106-
if (capture.headPack?.path) {
107-
await rm(capture.headPack.path, { force: true }).catch(() => {});
108-
}
109-
await rm(capture.indexFile.path, { force: true }).catch(() => {});
106+
await rm(capture.artifactDirectory, { recursive: true, force: true }).catch(
107+
() => {},
108+
);
110109
}
111110

112111
async function captureAndApply(
@@ -145,6 +144,41 @@ async function captureAndApply(
145144
}
146145

147146
describe("GitHandoffTracker", () => {
147+
it("stores capture artifacts beside the git object store", async () => {
148+
await withRepos(async (repos) => {
149+
await makeCloudChanges(repos.cloudRepo, repos.cloudGit);
150+
151+
const captureTracker = new GitHandoffTracker({
152+
repositoryPath: repos.cloudRepo,
153+
});
154+
const capture = await captureTracker.captureForHandoff(
155+
repos.localGitState,
156+
);
157+
const gitCommonDir = (
158+
await repos.cloudGit.raw([
159+
"rev-parse",
160+
"--path-format=absolute",
161+
"--git-common-dir",
162+
])
163+
).trim();
164+
165+
try {
166+
expect(path.dirname(capture.artifactDirectory)).toBe(gitCommonDir);
167+
expect(path.dirname(path.dirname(capture.indexFile.path))).toBe(
168+
gitCommonDir,
169+
);
170+
if (!capture.headPack) {
171+
throw new Error("Expected handoff capture to include a pack file");
172+
}
173+
expect(path.dirname(path.dirname(capture.headPack.path))).toBe(
174+
gitCommonDir,
175+
);
176+
} finally {
177+
await cleanupCapture(capture);
178+
}
179+
});
180+
}, 15000);
181+
148182
it("captures and reapplies head, worktree, and index state from local files", async () => {
149183
await withRepos(async (repos) => {
150184
await makeCloudChanges(repos.cloudRepo, repos.cloudGit);

packages/git/src/handoff.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { spawn } from "node:child_process";
22
import { copyFile, mkdtemp, readFile, rm, stat } from "node:fs/promises";
3-
import { tmpdir } from "node:os";
43
import path from "node:path";
54
import type {
65
GitHandoffCheckpoint,
@@ -26,6 +25,7 @@ export interface GitHandoffArtifactFile {
2625

2726
export interface GitHandoffCaptureResult {
2827
checkpoint: GitHandoffCheckpoint;
28+
artifactDirectory: string;
2929
headPack?: GitHandoffArtifactFile;
3030
indexFile: GitHandoffArtifactFile;
3131
totalBytes: number;
@@ -92,7 +92,7 @@ export class GitHandoffTracker {
9292

9393
const checkpoint = result.data;
9494
const git = createGitClient(this.repositoryPath);
95-
const tempDir = await this.createTempDir(checkpoint.checkpointId);
95+
const tempDir = await this.createTempDir(git, checkpoint.checkpointId);
9696
const checkpointRef = `${CHECKPOINT_REF_PREFIX}${checkpoint.checkpointId}`;
9797

9898
try {
@@ -139,10 +139,14 @@ export class GitHandoffTracker {
139139
upstreamMergeRef: tracking.upstreamMergeRef,
140140
remoteUrl: tracking.remoteUrl,
141141
},
142+
artifactDirectory: tempDir,
142143
headPack,
143144
indexFile,
144145
totalBytes: (headPack?.rawBytes ?? 0) + indexFile.rawBytes,
145146
};
147+
} catch (error) {
148+
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
149+
throw error;
146150
} finally {
147151
await deleteCheckpoint(git, checkpoint.checkpointId).catch(() => {});
148152
}
@@ -590,8 +594,27 @@ export class GitHandoffTracker {
590594
return exitCode === 0;
591595
}
592596

593-
private async createTempDir(checkpointId: string): Promise<string> {
594-
return mkdtemp(joinTempPrefix(checkpointId));
597+
private async createTempDir(
598+
git: GitClient,
599+
checkpointId: string,
600+
): Promise<string> {
601+
// Git stages packs in the object store, so the destination must share its filesystem.
602+
const gitCommonDir = await this.resolveGitCommonDir(git);
603+
return mkdtemp(
604+
path.join(gitCommonDir, `posthog-code-handoff-${checkpointId}-`),
605+
);
606+
}
607+
608+
private async resolveGitCommonDir(git: GitClient): Promise<string> {
609+
const raw = await git.raw([
610+
"rev-parse",
611+
"--path-format=absolute",
612+
"--git-common-dir",
613+
]);
614+
const resolved = raw.trim() || ".git";
615+
return path.isAbsolute(resolved)
616+
? resolved
617+
: path.resolve(this.repositoryPath, resolved);
595618
}
596619

597620
private async getGitPath(git: GitClient, gitPath: string): Promise<string> {
@@ -715,10 +738,6 @@ export class GitHandoffTracker {
715738
}
716739
}
717740

718-
function joinTempPrefix(checkpointId: string): string {
719-
return path.join(tmpdir(), `posthog-code-handoff-${checkpointId}-`);
720-
}
721-
722741
export async function readHandoffLocalGitState(
723742
repositoryPath: string,
724743
): Promise<HandoffLocalGitState> {

0 commit comments

Comments
 (0)