From c43599c6a0b5b6ea7e5e895412cbd9d1d62a9bf6 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 30 Apr 2026 19:40:16 +0200 Subject: [PATCH 1/5] fix(handoff): improve baseline handling in GitHandoffTracker --- .../src/renderer/components/HeaderRow.tsx | 3 ++- packages/git/src/handoff.ts | 24 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index 19fd6cb34..bad22ee33 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -28,7 +28,8 @@ function LocalHandoffButton({ taskId, task }: { taskId: string; task: Task }) { const workspace = useWorkspace(taskId); const repoPath = workspace?.folderPath ?? null; const authStatus = useAuthStateValue((s) => s.status); - const cloudHandoffEnabled = useFeatureFlag(CLOUD_HANDOFF_FLAG); + const cloudHandoffEnabled = + useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV; const { initiateHandoffToCloud } = useSessionCallbacks({ taskId, task, diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 5626dca40..1bd78b68a 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -93,7 +93,7 @@ export class GitHandoffTracker { } async captureForHandoff( - _localGitState?: HandoffLocalGitState, + localGitState?: HandoffLocalGitState, ): Promise { const captureSaga = new CaptureCheckpointSaga(this.logger); const result = await captureSaga.run({ baseDir: this.repositoryPath }); @@ -107,10 +107,12 @@ export class GitHandoffTracker { const git = createGitClient(this.repositoryPath); const tempDir = await this.createTempDir(checkpoint.checkpointId); const checkpointRef = `${CHECKPOINT_REF_PREFIX}${checkpoint.checkpointId}`; + const packBaseline = localGitState?.upstreamHead ?? null; const packRefs = [ checkpoint.head, checkpoint.indexTree, checkpoint.worktreeTree, + packBaseline ? `^${packBaseline}` : null, ].filter((ref): ref is string => !!ref); const headRef = checkpoint.head ? `${HANDOFF_HEAD_REF_PREFIX}${checkpoint.checkpointId}` @@ -161,6 +163,7 @@ export class GitHandoffTracker { const git = createGitClient(this.repositoryPath); if (headPackPath) { + await this.ensureBaselineForApply(git, checkpoint, localGitState); await this.unpackPackFile(headPackPath); } @@ -287,6 +290,25 @@ export class GitHandoffTracker { ); } + private async ensureBaselineForApply( + git: GitClient, + checkpoint: GitHandoffCheckpoint, + localGitState: HandoffLocalGitState | undefined, + ): Promise { + const tracking = this.getPreferredTracking(localGitState, checkpoint); + if (!tracking.upstreamRemote || !tracking.upstreamMergeRef) return; + + await this.ensureRemoteForTracking(git, tracking).catch(() => {}); + await git + .raw(["fetch", tracking.upstreamRemote, tracking.upstreamMergeRef]) + .catch((err) => { + this.logger?.warn( + "Handoff baseline fetch failed; continuing with locally available history", + { err: String(err) }, + ); + }); + } + private async ensureRemoteForTracking( git: GitClient, tracking: GitTrackingMetadata, From 43bcef69598efe9defe585523fe0550342d9f006 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Fri, 1 May 2026 11:10:30 +0200 Subject: [PATCH 2/5] feat(handoff): enforce worktree size cap and fetch upstream before checkpoint Generated-By: PostHog Code Task-Id: da45c20c-df1d-4761-a47b-e0c658828493 --- packages/git/src/handoff.ts | 7 ++ packages/git/src/sagas/checkpoint.test.ts | 71 ++++++++++++++++++ packages/git/src/sagas/checkpoint.ts | 90 +++++++++++++++++++++++ 3 files changed, 168 insertions(+) diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 1bd78b68a..04b39a8d8 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -553,6 +553,13 @@ export async function readHandoffLocalGitState( const head = await readCurrentHead(git); const branch = await getCurrentBranchName(git); const tracking = await getTrackingMetadata(git, branch); + + if (tracking.upstreamRemote && tracking.upstreamMergeRef) { + await git + .raw(["fetch", tracking.upstreamRemote, tracking.upstreamMergeRef]) + .catch(() => {}); + } + const upstreamHead = tracking.upstreamRemote && tracking.upstreamMergeRef ? await resolveUpstreamHead( diff --git a/packages/git/src/sagas/checkpoint.test.ts b/packages/git/src/sagas/checkpoint.test.ts index 069934ec7..e05dfcf5c 100644 --- a/packages/git/src/sagas/checkpoint.test.ts +++ b/packages/git/src/sagas/checkpoint.test.ts @@ -400,6 +400,77 @@ describe("checkpoint sagas", () => { }); }); + it("drops untracked files larger than the worktree size cap", async () => { + await withRepo(async (repoPath) => { + const git = createGitClient(repoPath); + const largePath = path.join(repoPath, "large.bin"); + await writeFile(largePath, Buffer.alloc(1024 * 1024 + 1, 7)); + await writeFile(path.join(repoPath, "small.txt"), "tiny\n"); + + await captureCheckpoint(repoPath, "large-untracked"); + + await rm(largePath); + await rm(path.join(repoPath, "small.txt")); + + await revertCheckpoint(repoPath, "large-untracked"); + + await expect(readFile(largePath)).rejects.toBeTruthy(); + const small = await readFile(path.join(repoPath, "small.txt"), "utf8"); + expect(small).toBe("tiny\n"); + + const status = await git.raw(["status", "--porcelain"]); + expect(status).not.toContain("large.bin"); + }); + }); + + it("preserves tracked files larger than the cap when content is unchanged", async () => { + await withRepo(async (repoPath) => { + const git = createGitClient(repoPath); + const largePath = path.join(repoPath, "tracked-large.bin"); + const data = Buffer.alloc(1024 * 1024 + 1, 3); + await writeFile(largePath, data); + await git.add(["tracked-large.bin"]); + await git.commit("add large tracked"); + + await writeFile(path.join(repoPath, "a.txt"), "edited\n"); + + await captureCheckpoint(repoPath, "large-tracked-unchanged"); + + await writeFile(path.join(repoPath, "a.txt"), "after\n"); + await rm(largePath); + + await revertCheckpoint(repoPath, "large-tracked-unchanged"); + + const a = await readFile(path.join(repoPath, "a.txt"), "utf8"); + expect(a).toBe("edited\n"); + const restored = await readFile(largePath); + expect(Buffer.compare(restored, data)).toBe(0); + }); + }); + + it("rolls tracked large files back to HEAD when modified locally", async () => { + await withRepo(async (repoPath) => { + const git = createGitClient(repoPath); + const largePath = path.join(repoPath, "tracked-large.bin"); + const original = Buffer.alloc(1024 * 1024 + 1, 1); + await writeFile(largePath, original); + await git.add(["tracked-large.bin"]); + await git.commit("add large tracked"); + + const modified = Buffer.alloc(1024 * 1024 + 1, 9); + await writeFile(largePath, modified); + + await captureCheckpoint(repoPath, "large-tracked-modified"); + + await writeFile(largePath, Buffer.alloc(1024 * 1024 + 1, 5)); + + await revertCheckpoint(repoPath, "large-tracked-modified"); + + const restored = await readFile(largePath); + expect(Buffer.compare(restored, original)).toBe(0); + }); + }); + it("does not leak temp index into normal git operations", async () => { await withRepo(async (repoPath) => { const git = createGitClient(repoPath); diff --git a/packages/git/src/sagas/checkpoint.ts b/packages/git/src/sagas/checkpoint.ts index b95660c7b..d689a614f 100644 --- a/packages/git/src/sagas/checkpoint.ts +++ b/packages/git/src/sagas/checkpoint.ts @@ -440,6 +440,8 @@ export async function getGitBusyState(git: GitClient): Promise { return { busy: false }; } +const MAX_WORKTREE_FILE_BYTES = 1024 * 1024; + async function createWorktreeTree( git: GitClient, baseDir: string, @@ -459,6 +461,7 @@ async function createWorktreeTree( } await tempGit.raw(["add", "-A", "--", "."]); + await reconcileLargeBlobs(tempGit, head, MAX_WORKTREE_FILE_BYTES); const treeHash = await tempGit.raw(["write-tree"]); return treeHash.trim(); } finally { @@ -466,6 +469,93 @@ async function createWorktreeTree( } } +async function reconcileLargeBlobs( + tempGit: GitClient, + head: string | null, + maxBytes: number, +): Promise { + const intermediateTree = (await tempGit.raw(["write-tree"])).trim(); + const largePaths = await listLargeBlobPaths( + tempGit, + intermediateTree, + maxBytes, + ); + if (largePaths.length === 0) return; + + const headEntries = head + ? await readHeadBlobEntries(tempGit, head, largePaths) + : new Map(); + + for (const filePath of largePaths) { + const headEntry = headEntries.get(filePath); + if (headEntry) { + await tempGit.raw([ + "update-index", + "--cacheinfo", + `${headEntry.mode},${headEntry.hash},${filePath}`, + ]); + } else { + await tempGit + .raw(["update-index", "--force-remove", filePath]) + .catch(() => {}); + } + } +} + +async function listLargeBlobPaths( + tempGit: GitClient, + tree: string, + maxBytes: number, +): Promise { + const output = await tempGit.raw(["ls-tree", "-r", "-l", tree]); + const result: string[] = []; + for (const line of output.split("\n")) { + if (!line) continue; + const tabIndex = line.indexOf("\t"); + if (tabIndex < 0) continue; + const meta = line.slice(0, tabIndex); + const filePath = line.slice(tabIndex + 1); + const parts = meta.split(/\s+/); + if (parts.length < 4) continue; + const [, type, , sizeStr] = parts; + if (type !== "blob") continue; + if (sizeStr === "-") continue; + const size = Number.parseInt(sizeStr, 10); + if (Number.isFinite(size) && size > maxBytes) { + result.push(filePath); + } + } + return result; +} + +async function readHeadBlobEntries( + tempGit: GitClient, + head: string, + paths: string[], +): Promise> { + const result = new Map(); + const CHUNK_SIZE = 100; + for (let i = 0; i < paths.length; i += CHUNK_SIZE) { + const chunk = paths.slice(i, i + CHUNK_SIZE); + const output = await tempGit + .raw(["ls-tree", "-r", head, "--", ...chunk]) + .catch(() => ""); + for (const line of output.split("\n")) { + if (!line) continue; + const tabIndex = line.indexOf("\t"); + if (tabIndex < 0) continue; + const meta = line.slice(0, tabIndex); + const filePath = line.slice(tabIndex + 1); + const parts = meta.split(/\s+/); + if (parts.length < 3) continue; + const [mode, type, hash] = parts; + if (type !== "blob") continue; + result.set(filePath, { mode, hash }); + } + } + return result; +} + async function createMetaTree( git: GitClient, baseDir: string, From d25c57a4b084bd767350429d5b278016209e3471 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Fri, 1 May 2026 12:00:53 +0200 Subject: [PATCH 3/5] feat(folders): add remoteUrl override when adding folders Generated-By: PostHog Code Task-Id: da45c20c-df1d-4761-a47b-e0c658828493 --- .../code/src/main/services/folders/schemas.ts | 1 + .../src/main/services/folders/service.test.ts | 45 +++++++++++++++++++ .../code/src/main/services/folders/service.ts | 37 +++++++++------ apps/code/src/main/trpc/routers/folders.ts | 4 +- .../sessions/service/localHandoffService.ts | 15 ++++--- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/apps/code/src/main/services/folders/schemas.ts b/apps/code/src/main/services/folders/schemas.ts index 6febbd158..06e2c1a16 100644 --- a/apps/code/src/main/services/folders/schemas.ts +++ b/apps/code/src/main/services/folders/schemas.ts @@ -17,6 +17,7 @@ export const getFoldersOutput = z.array(registeredFolderWithExistsSchema); export const addFolderInput = z.object({ folderPath: z.string().min(2, "Folder path must be a valid directory path"), + remoteUrl: z.string().min(1).optional(), }); export const addFolderOutput = registeredFolderWithExistsSchema; diff --git a/apps/code/src/main/services/folders/service.test.ts b/apps/code/src/main/services/folders/service.test.ts index 127072e8f..a212471df 100644 --- a/apps/code/src/main/services/folders/service.test.ts +++ b/apps/code/src/main/services/folders/service.test.ts @@ -445,6 +445,51 @@ describe("FoldersService", () => { expect(result.name).toBe("project"); }); + it("tags a new folder with the supplied remoteUrl override", async () => { + vi.mocked(isGitRepository).mockResolvedValue(true); + mockRepositoryRepo.findByPath.mockReturnValue(null); + mockRepositoryRepo.create.mockReturnValue({ + id: "folder-new", + path: "/home/user/fork", + remoteUrl: "PostHog/posthog", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + + await service.addFolder("/home/user/fork", { + remoteUrl: "https://github.com/PostHog/posthog", + }); + + expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ + path: "/home/user/fork", + remoteUrl: "PostHog/posthog", + }); + }); + + it("backfills remoteUrl on an existing folder when override is supplied", async () => { + vi.mocked(isGitRepository).mockResolvedValue(true); + const existing = { + id: "folder-existing", + path: "/home/user/project", + remoteUrl: null, + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + mockRepositoryRepo.findByPath.mockReturnValue(existing); + mockRepositoryRepo.findById.mockReturnValue(existing); + + await service.addFolder("/home/user/project", { + remoteUrl: "https://github.com/PostHog/posthog", + }); + + expect(mockRepositoryRepo.updateRemoteUrl).toHaveBeenCalledWith( + "folder-existing", + "PostHog/posthog", + ); + }); + it("throws error when user cancels git init", async () => { vi.mocked(isGitRepository).mockResolvedValue(false); mockDialog.confirm.mockResolvedValue(1); diff --git a/apps/code/src/main/services/folders/service.ts b/apps/code/src/main/services/folders/service.ts index eea3c857a..eb4342111 100644 --- a/apps/code/src/main/services/folders/service.ts +++ b/apps/code/src/main/services/folders/service.ts @@ -114,6 +114,7 @@ export class FoldersService { async addFolder( folderPath: string, + options: { remoteUrl?: string } = {}, ): Promise { const folderName = path.basename(folderPath); if (!folderPath || !folderName) { @@ -152,6 +153,7 @@ export class FoldersService { } } + const repoKey = await this.resolveRepoKey(folderPath, options.remoteUrl); const existingRepo = this.repositoryRepo.findByPath(folderPath); let repo: Repository; @@ -163,23 +165,17 @@ export class FoldersService { } repo = updated; - if (!repo.remoteUrl) { - const remoteUrl = await getRemoteUrl(folderPath); - const repoKey = remoteUrl ? extractRepoKey(remoteUrl) : null; - if (repoKey) { - this.repositoryRepo.updateRemoteUrl(repo.id, repoKey); - const refreshed = this.repositoryRepo.findById(repo.id); - if (!refreshed) { - throw new Error( - `Repository ${repo.id} not found after remote URL update`, - ); - } - repo = refreshed; + if (repoKey && repo.remoteUrl !== repoKey) { + this.repositoryRepo.updateRemoteUrl(repo.id, repoKey); + const refreshed = this.repositoryRepo.findById(repo.id); + if (!refreshed) { + throw new Error( + `Repository ${repo.id} not found after remote URL update`, + ); } + repo = refreshed; } } else { - const remoteUrl = await getRemoteUrl(folderPath); - const repoKey = remoteUrl ? extractRepoKey(remoteUrl) : null; repo = this.repositoryRepo.create({ path: folderPath, remoteUrl: repoKey ?? undefined, @@ -248,6 +244,19 @@ export class FoldersService { await manager.cleanupOrphanedWorktrees(associatedWorktreePaths); } + private async resolveRepoKey( + folderPath: string, + overrideRemoteUrl: string | undefined, + ): Promise { + if (overrideRemoteUrl) { + const overrideKey = extractRepoKey(overrideRemoteUrl); + if (overrideKey) return overrideKey; + return normalizeRepoKey(overrideRemoteUrl); + } + const localRemoteUrl = await getRemoteUrl(folderPath); + return localRemoteUrl ? extractRepoKey(localRemoteUrl) : null; + } + getRepositoryByRemoteUrl( remoteUrl: string, ): { id: string; path: string } | null { diff --git a/apps/code/src/main/trpc/routers/folders.ts b/apps/code/src/main/trpc/routers/folders.ts index 7b30d871e..d6d011ecc 100644 --- a/apps/code/src/main/trpc/routers/folders.ts +++ b/apps/code/src/main/trpc/routers/folders.ts @@ -24,7 +24,9 @@ export const foldersRouter = router({ .input(addFolderInput) .output(addFolderOutput) .mutation(({ input }) => { - return getService().addFolder(input.folderPath); + return getService().addFolder(input.folderPath, { + remoteUrl: input.remoteUrl, + }); }), removeFolder: publicProcedure diff --git a/apps/code/src/renderer/features/sessions/service/localHandoffService.ts b/apps/code/src/renderer/features/sessions/service/localHandoffService.ts index d74072370..e7307bbed 100644 --- a/apps/code/src/renderer/features/sessions/service/localHandoffService.ts +++ b/apps/code/src/renderer/features/sessions/service/localHandoffService.ts @@ -17,15 +17,16 @@ async function resolveRepoPathFromRemote( return repo?.path ?? null; } -async function resolveRepoPathFromPicker(): Promise { +async function resolveRepoPathFromPicker( + remoteUrl: string | null | undefined, +): Promise { const selectedPath = await trpcClient.os.selectDirectory.query(); if (!selectedPath) return null; - const folders = await trpcClient.folders.getFolders.query(); - const folder = folders.find((f) => f.path === selectedPath); - if (!folder) { - await trpcClient.folders.addFolder.mutate({ folderPath: selectedPath }); - } + await trpcClient.folders.addFolder.mutate({ + folderPath: selectedPath, + remoteUrl: remoteUrl ?? undefined, + }); return selectedPath; } @@ -66,7 +67,7 @@ export class LocalHandoffService { try { const targetPath = (await resolveRepoPathFromRemote(task.repository)) ?? - (await resolveRepoPathFromPicker()); + (await resolveRepoPathFromPicker(task.repository)); if (!targetPath) return; From 70aa31148bac5e4855e137fca4703c1a2bc54275 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Fri, 1 May 2026 13:48:08 +0200 Subject: [PATCH 4/5] feat(handoff): reconcile index to exclude large staged files Generated-By: PostHog Code Task-Id: da45c20c-df1d-4761-a47b-e0c658828493 --- packages/git/src/handoff.test.ts | 25 ++++ packages/git/src/handoff.ts | 190 +++++++++++++++++++++++++++---- 2 files changed, 190 insertions(+), 25 deletions(-) diff --git a/packages/git/src/handoff.test.ts b/packages/git/src/handoff.test.ts index e07904916..8db2a144c 100644 --- a/packages/git/src/handoff.test.ts +++ b/packages/git/src/handoff.test.ts @@ -180,6 +180,31 @@ describe("GitHandoffTracker", () => { }); }, 15000); + it("keeps shipped index consistent with worktreeTree for staged large files", async () => { + await withRepos(async (repos) => { + const largePath = path.join(repos.cloudRepo, "tracked.txt"); + const modified = Buffer.alloc(1024 * 1024 + 1, 9); + await writeFile(largePath, modified); + await repos.cloudGit.add(["tracked.txt"]); + + const capture = await captureAndApply(repos); + + try { + const restored = await readFile( + path.join(repos.localRepo, "tracked.txt"), + "utf-8", + ); + expect(restored).toBe("base\n"); + + const status = await repos.localGit.raw(["status", "--porcelain"]); + expect(status).not.toMatch(/^M[ M] tracked\.txt/m); + expect(status).not.toMatch(/^MM tracked\.txt/m); + } finally { + await cleanupCapture(capture); + } + }); + }, 20000); + it("removes tracked files absent from the checkpoint worktree", async () => { await withRepos(async (repos) => { await rm(path.join(repos.cloudRepo, "tracked.txt")); diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 04b39a8d8..4e70d3c92 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -8,6 +8,7 @@ import { CaptureCheckpointSaga, deleteCheckpoint } from "./sagas/checkpoint"; const HANDOFF_HEAD_REF_PREFIX = "refs/posthog-code-handoff/head/"; const CHECKPOINT_REF_PREFIX = "refs/posthog-code-checkpoint/"; +const MAX_HANDOFF_FILE_BYTES = 1024 * 1024; export interface HandoffLocalGitState { head: string | null; @@ -107,22 +108,31 @@ export class GitHandoffTracker { const git = createGitClient(this.repositoryPath); const tempDir = await this.createTempDir(checkpoint.checkpointId); const checkpointRef = `${CHECKPOINT_REF_PREFIX}${checkpoint.checkpointId}`; - const packBaseline = localGitState?.upstreamHead ?? null; - const packRefs = [ - checkpoint.head, - checkpoint.indexTree, - checkpoint.worktreeTree, - packBaseline ? `^${packBaseline}` : null, - ].filter((ref): ref is string => !!ref); - const headRef = checkpoint.head - ? `${HANDOFF_HEAD_REF_PREFIX}${checkpoint.checkpointId}` - : undefined; - const packPrefix = path.join(tempDir, checkpoint.checkpointId); try { + const reconciledIndex = await this.reconcileHandoffIndex( + git, + checkpoint.head, + checkpoint.indexTree, + tempDir, + checkpoint.checkpointId, + ); + + const packBaseline = localGitState?.upstreamHead ?? null; + const packRefs = [ + checkpoint.head, + reconciledIndex.indexTree, + checkpoint.worktreeTree, + packBaseline ? `^${packBaseline}` : null, + ].filter((ref): ref is string => !!ref); + const headRef = checkpoint.head + ? `${HANDOFF_HEAD_REF_PREFIX}${checkpoint.checkpointId}` + : undefined; + const packPrefix = path.join(tempDir, checkpoint.checkpointId); + const [headPack, indexFile, tracking] = await Promise.all([ this.captureObjectPack(packPrefix, packRefs), - this.copyIndexFile(git, checkpoint.checkpointId, tempDir), + this.statFileArtifact(reconciledIndex.indexFilePath), getTrackingMetadata(git, checkpoint.branch), ]); @@ -134,7 +144,7 @@ export class GitHandoffTracker { headRef, head: checkpoint.head, branch: checkpoint.branch, - indexTree: checkpoint.indexTree, + indexTree: reconciledIndex.indexTree, worktreeTree: checkpoint.worktreeTree, timestamp: checkpoint.timestamp, upstreamRemote: tracking.upstreamRemote, @@ -226,18 +236,113 @@ export class GitHandoffTracker { return { path: packPath, rawBytes }; } - private async copyIndexFile( + private async reconcileHandoffIndex( git: GitClient, - checkpointId: string, + head: string | null, + indexTree: string, tempDir: string, + checkpointId: string, + ): Promise<{ indexTree: string; indexFilePath: string }> { + const realIndexPath = await this.getGitPath(git, "index"); + const tempIndexPath = path.join(tempDir, `${checkpointId}.index`); + await copyFile(realIndexPath, tempIndexPath); + + const largePaths = await this.listLargeBlobsInTree( + indexTree, + MAX_HANDOFF_FILE_BYTES, + ); + if (largePaths.length === 0) { + return { indexTree, indexFilePath: tempIndexPath }; + } + + const headBlobs = head + ? await this.readHeadBlobsForPaths(head, largePaths) + : new Map(); + + const env = { ...process.env, GIT_INDEX_FILE: tempIndexPath }; + for (const filePath of largePaths) { + const headBlob = headBlobs.get(filePath); + if (headBlob) { + await this.runGitWithEnv(env, [ + "update-index", + "--cacheinfo", + `${headBlob.mode},${headBlob.hash},${filePath}`, + ]); + } else { + await this.runGitWithEnv(env, [ + "update-index", + "--force-remove", + filePath, + ]).catch(() => {}); + } + } + + const reconciledTree = ( + await this.runGitWithEnv(env, ["write-tree"]) + ).trim(); + return { indexTree: reconciledTree, indexFilePath: tempIndexPath }; + } + + private async listLargeBlobsInTree( + tree: string, + maxBytes: number, + ): Promise { + const { stdout } = await this.runGitProcess( + ["ls-tree", "-r", "-l", tree], + "", + ); + const result: string[] = []; + for (const line of stdout.split("\n")) { + if (!line) continue; + const tabIndex = line.indexOf("\t"); + if (tabIndex < 0) continue; + const meta = line.slice(0, tabIndex); + const filePath = line.slice(tabIndex + 1); + const parts = meta.split(/\s+/); + if (parts.length < 4) continue; + const [, type, , sizeStr] = parts; + if (type !== "blob") continue; + if (sizeStr === "-") continue; + const size = Number.parseInt(sizeStr, 10); + if (Number.isFinite(size) && size > maxBytes) { + result.push(filePath); + } + } + return result; + } + + private async readHeadBlobsForPaths( + head: string, + paths: string[], + ): Promise> { + const result = new Map(); + const CHUNK_SIZE = 100; + for (let i = 0; i < paths.length; i += CHUNK_SIZE) { + const chunk = paths.slice(i, i + CHUNK_SIZE); + const { stdout } = await this.runGitProcess( + ["ls-tree", "-r", head, "--", ...chunk], + "", + ).catch(() => ({ stdout: "", stderr: "" })); + for (const line of stdout.split("\n")) { + if (!line) continue; + const tabIndex = line.indexOf("\t"); + if (tabIndex < 0) continue; + const meta = line.slice(0, tabIndex); + const filePath = line.slice(tabIndex + 1); + const parts = meta.split(/\s+/); + if (parts.length < 3) continue; + const [mode, type, hash] = parts; + if (type !== "blob") continue; + result.set(filePath, { mode, hash }); + } + } + return result; + } + + private async statFileArtifact( + filePath: string, ): Promise { - const indexPath = await this.getGitPath(git, "index"); - const copiedIndexPath = path.join(tempDir, `${checkpointId}.index`); - await copyFile(indexPath, copiedIndexPath); - return { - path: copiedIndexPath, - rawBytes: await this.getFileSize(copiedIndexPath), - }; + return { path: filePath, rawBytes: await this.getFileSize(filePath) }; } private async restoreIndexFile( @@ -302,9 +407,13 @@ export class GitHandoffTracker { await git .raw(["fetch", tracking.upstreamRemote, tracking.upstreamMergeRef]) .catch((err) => { - this.logger?.warn( - "Handoff baseline fetch failed; continuing with locally available history", - { err: String(err) }, + this.logger?.error( + "Handoff baseline fetch failed; if the pack excludes commits the receiver does not already have, the subsequent unpack/read-tree will fail with an object-missing error", + { + err: String(err), + remote: tracking.upstreamRemote, + ref: tracking.upstreamMergeRef, + }, ); }); } @@ -507,6 +616,37 @@ export class GitHandoffTracker { }); } + private async runGitWithEnv( + env: NodeJS.ProcessEnv, + args: string[], + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd: this.repositoryPath, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(stdout); + return; + } + reject( + new Error(stderr || `git ${args.join(" ")} failed with code ${code}`), + ); + }); + }); + } + private runGitProcess( args: string[], input: string | Buffer, From 9bb6e8ac6b85fb0980ad642afa43798997579b3e Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 5 May 2026 13:56:13 +0200 Subject: [PATCH 5/5] fix(handoff): suppress EPIPE on git stdin write Generated-By: PostHog Code Task-Id: ef5b8a8f-92da-473e-bd2a-e71dd9ffe0d0 --- packages/git/src/handoff.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 4e70d3c92..43c21194c 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -677,6 +677,7 @@ export class GitHandoffTracker { ); }); + child.stdin.on("error", () => {}); child.stdin.end(input); }); }