diff --git a/packages/agent/src/handoff-checkpoint.test.ts b/packages/agent/src/handoff-checkpoint.test.ts index 7c56a5e82a..9ad3f8cedf 100644 --- a/packages/agent/src/handoff-checkpoint.test.ts +++ b/packages/agent/src/handoff-checkpoint.test.ts @@ -1,5 +1,8 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { HandoffCheckpointTracker } from "./handoff-checkpoint"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + decodeHandoffArtifact, + HandoffCheckpointTracker, +} from "./handoff-checkpoint"; import { cloneTestRepo, createTestRepo, @@ -8,7 +11,7 @@ import { import type { HandoffLocalGitState } from "./types"; interface BundleStore { - artifacts: Record; + artifacts: Record; storagePath: string; manifest: Array<{ storage_path: string }>; } @@ -20,19 +23,38 @@ interface HandoffRepos { localGitState: HandoffLocalGitState; } -function createMockApi(store: BundleStore) { - return { +interface MockApiOptions { + /** Store inline uploads as base64 text, like the backend did before content_encoding was sent. */ + legacyBase64AtRest?: boolean; + /** Expose the prepare/finalize direct-upload endpoints. */ + directUploads?: boolean; + /** Make inline uploads fail, like an API rejecting the request. */ + failInlineUploads?: boolean; + /** Make finalize omit the uploaded artifact from its response. */ + unconfirmedFinalize?: boolean; +} + +function createMockApi(store: BundleStore, options?: MockApiOptions) { + let nextId = 0; + const api: Record = { uploadTaskArtifacts: async ( _taskId: string, _runId: string, artifacts: Array<{ name: string; content: string; + content_encoding?: string; }>, ) => { - const uploaded = artifacts.map((artifact, index) => { - const storagePath = `${store.storagePath}-${store.manifest.length + index}-${artifact.name}`; - store.artifacts[storagePath] = artifact.content; + if (options?.failInlineUploads) { + throw new Error("Failed request: [413] Payload Too Large"); + } + const uploaded = artifacts.map((artifact) => { + const storagePath = `${store.storagePath}-${nextId++}-${artifact.name}`; + store.artifacts[storagePath] = + !options?.legacyBase64AtRest && artifact.content_encoding === "base64" + ? Buffer.from(artifact.content, "base64") + : Buffer.from(artifact.content, "utf-8"); return { storage_path: storagePath }; }); for (const entry of uploaded) { @@ -45,15 +67,67 @@ function createMockApi(store: BundleStore) { _runId: string, artifactPath: string, ) => { - const contentBase64 = store.artifacts[artifactPath]; - if (!contentBase64) return null; - const buffer = Buffer.from(contentBase64, "utf-8"); - return buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, + const content = store.artifacts[artifactPath]; + if (!content) return null; + return content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength, ); }, }; + + if (options?.directUploads) { + api.prepareTaskArtifactUploads = async ( + _taskId: string, + _runId: string, + artifacts: Array<{ name: string; type: string; size: number }>, + ) => + artifacts.map((artifact) => { + const storagePath = `${store.storagePath}-${nextId++}-${artifact.name}`; + return { + id: `prepared-${storagePath}`, + name: artifact.name, + type: artifact.type, + size: artifact.size, + storage_path: storagePath, + expires_in: 300, + presigned_post: { + url: "https://object-storage.test/upload", + fields: { key: storagePath }, + }, + }; + }); + api.finalizeTaskArtifactUploads = async ( + _taskId: string, + _runId: string, + artifacts: Array<{ name: string; type: string; storage_path: string }>, + ) => { + if (options?.unconfirmedFinalize) { + return []; + } + const finalized = artifacts.map((artifact) => ({ + name: artifact.name, + type: artifact.type, + storage_path: artifact.storage_path, + })); + for (const entry of finalized) { + store.manifest.push(entry); + } + return finalized; + }; + } + + return api; +} + +function stubPresignedUploadFetch(store: BundleStore): void { + vi.stubGlobal("fetch", async (_url: string, init?: { body?: unknown }) => { + const form = init?.body as FormData; + const key = form.get("key") as string; + const file = form.get("file") as Blob; + store.artifacts[key] = Buffer.from(await file.arrayBuffer()); + return new Response(null, { status: 204 }); + }); } function createBundleStore(): BundleStore { @@ -130,6 +204,7 @@ describe("HandoffCheckpointTracker", () => { const cleanups: Array<() => Promise> = []; afterEach(async () => { + vi.unstubAllGlobals(); await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); @@ -166,4 +241,129 @@ describe("HandoffCheckpointTracker", () => { expect(status).toContain("?? untracked.txt"); expect(localRepo.exists(".posthog/tmp")).toBe(false); }); + + it("round-trips a cloud capture without local git state via direct-to-storage uploads", async () => { + const originRepo = await createTestRepo("handoff-origin"); + cleanups.push(originRepo.cleanup); + await seedCloudRepo(originRepo); + + const sandboxRepo = await cloneTestRepo(originRepo.path, "handoff-sandbox"); + cleanups.push(sandboxRepo.cleanup); + const resumeRepo = await cloneTestRepo(originRepo.path, "handoff-resume"); + cleanups.push(resumeRepo.cleanup); + + await makeCloudChanges(sandboxRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { directUploads: true }); + stubPresignedUploadFetch(store); + + const captureTracker = createTracker(sandboxRepo.path, apiClient); + const checkpoint = await captureTracker.captureForHandoff(); + + expect(checkpoint).not.toBeNull(); + if (!checkpoint) return; + expect(checkpoint.artifactPath).toBeDefined(); + expect(checkpoint.indexArtifactPath).toBeDefined(); + + // Direct uploads store raw bytes, not base64 text. + const pack = store.artifacts[checkpoint.artifactPath as string]; + expect(pack.subarray(0, 4).toString("utf-8")).toBe("PACK"); + + const applyTracker = createTracker(resumeRepo.path, apiClient); + await applyTracker.applyFromHandoff(checkpoint); + + expect(await resumeRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head); + expect(await resumeRepo.readFile("committed.txt")).toBe("cloud commit\n"); + expect(await resumeRepo.readFile("tracked.txt")).toBe("staged change\n"); + expect(await resumeRepo.readFile("unstaged.txt")).toBe("unstaged change\n"); + expect(await resumeRepo.readFile("untracked.txt")).toBe("untracked\n"); + + const status = await resumeRepo.git(["status", "--porcelain"]); + expect(status).toContain("M tracked.txt"); + expect(status).toContain(" M unstaged.txt"); + expect(status).toContain("?? untracked.txt"); + }); + + it("applies checkpoints whose artifacts are stored as legacy base64 text", async () => { + const { cloudRepo, localRepo, localGitState } = + await prepareHandoffRepos(cleanups); + await makeCloudChanges(cloudRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { legacyBase64AtRest: true }); + const captureTracker = createTracker(cloudRepo.path, apiClient); + + const checkpoint = await captureTracker.captureForHandoff(localGitState); + expect(checkpoint).not.toBeNull(); + if (!checkpoint) return; + + // Sanity-check the fixture: artifacts at rest are base64 text, not raw bytes. + const pack = store.artifacts[checkpoint.artifactPath as string]; + expect(pack.subarray(0, 4).toString("utf-8")).not.toBe("PACK"); + + const applyTracker = createTracker(localRepo.path, apiClient); + await applyTracker.applyFromHandoff(checkpoint); + + expect(await localRepo.git(["rev-parse", "HEAD"])).toBe(checkpoint.head); + expect(await localRepo.readFile("committed.txt")).toBe("cloud commit\n"); + expect(await localRepo.readFile("tracked.txt")).toBe("staged change\n"); + }); + + it("returns null instead of a checkpoint when artifact uploads fail", async () => { + const { cloudRepo, localGitState } = await prepareHandoffRepos(cleanups); + await makeCloudChanges(cloudRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { failInlineUploads: true }); + const captureTracker = createTracker(cloudRepo.path, apiClient); + + const checkpoint = await captureTracker.captureForHandoff(localGitState); + + expect(checkpoint).toBeNull(); + }); + + it("falls back to inline upload when finalize does not confirm the artifact", async () => { + const { cloudRepo, localRepo, localGitState } = + await prepareHandoffRepos(cleanups); + await makeCloudChanges(cloudRepo); + + const store = createBundleStore(); + const apiClient = createMockApi(store, { + directUploads: true, + unconfirmedFinalize: true, + }); + stubPresignedUploadFetch(store); + const captureTracker = createTracker(cloudRepo.path, apiClient); + + const checkpoint = await captureTracker.captureForHandoff(localGitState); + + expect(checkpoint).not.toBeNull(); + if (!checkpoint) return; + // The unconfirmed direct upload must not be referenced; the checkpoint + // points at the inline upload, which stores decoded bytes. + const pack = store.artifacts[checkpoint.artifactPath as string]; + expect(pack.subarray(0, 4).toString("utf-8")).toBe("PACK"); + + const applyTracker = createTracker(localRepo.path, apiClient); + await applyTracker.applyFromHandoff(checkpoint); + expect(await localRepo.readFile("committed.txt")).toBe("cloud commit\n"); + }); + + it("decodes raw and legacy base64 artifact buffers", () => { + const rawPack = Buffer.concat([ + Buffer.from("PACK"), + Buffer.from([0, 0, 0, 2, 255, 1, 2, 3]), + ]); + expect(decodeHandoffArtifact(rawPack)).toEqual(rawPack); + + const rawIndex = Buffer.concat([ + Buffer.from("DIRC"), + Buffer.from([0, 0, 0, 2, 255, 4, 5, 6]), + ]); + expect(decodeHandoffArtifact(rawIndex)).toEqual(rawIndex); + + const legacyBase64 = Buffer.from(rawPack.toString("base64"), "utf-8"); + expect(decodeHandoffArtifact(legacyBase64)).toEqual(rawPack); + }); }); diff --git a/packages/agent/src/handoff-checkpoint.ts b/packages/agent/src/handoff-checkpoint.ts index e9ac6d9e10..3813841f24 100644 --- a/packages/agent/src/handoff-checkpoint.ts +++ b/packages/agent/src/handoff-checkpoint.ts @@ -6,10 +6,39 @@ import { type GitHandoffCheckpoint, GitHandoffTracker, } from "@posthog/git/handoff"; -import type { PostHogAPIClient } from "./posthog-api"; +import type { + PostHogAPIClient, + PreparedTaskArtifactUpload, +} from "./posthog-api"; import type { GitCheckpoint, HandoffLocalGitState } from "./types"; import { Logger } from "./utils/logger"; +/** Server-side cap on a single task-run artifact; larger files are skipped, not failed. */ +const MAX_ARTIFACT_UPLOAD_BYTES = 30 * 1024 * 1024; +/** Inline uploads travel base64-encoded inside a JSON API body, so they must stay well under API request size limits. */ +const MAX_INLINE_UPLOAD_BYTES = 10 * 1024 * 1024; + +const PACK_MAGIC = Buffer.from("PACK"); +const INDEX_MAGIC = Buffer.from("DIRC"); + +/** + * Handoff artifacts used to be stored as base64 text (inline uploads without + * content_encoding); direct-to-storage uploads store raw bytes. Detect raw + * git payloads by their magic bytes and fall back to the legacy base64 + * decode otherwise. + */ +export function decodeHandoffArtifact(buffer: Buffer): Buffer { + const head = buffer.subarray(0, 4); + if (head.equals(PACK_MAGIC) || head.equals(INDEX_MAGIC)) { + return buffer; + } + const text = buffer.toString("utf-8"); + if (/^[A-Za-z0-9+/]+={0,2}$/.test(text)) { + return Buffer.from(text, "base64"); + } + return buffer; +} + export interface HandoffCheckpointTrackerConfig { repositoryPath: string; taskId: string; @@ -93,6 +122,25 @@ export class HandoffCheckpointTracker { }, ]); + // A checkpoint that references artifacts which never made it to storage + // would make resume apply an incomplete git state; drop it instead. + const packUploadMissing = + !!capture.headPack && !uploads.pack?.storagePath; + const indexUploadMissing = !uploads.index?.storagePath; + if (packUploadMissing || indexUploadMissing) { + this.logger.warn( + "Discarding handoff checkpoint: required artifact uploads did not complete", + { + checkpointId: capture.checkpoint.checkpointId, + packUploadMissing, + indexUploadMissing, + packBytes: capture.headPack?.rawBytes ?? 0, + indexBytes: capture.indexFile.rawBytes, + }, + ); + return null; + } + this.logCaptureMetrics(capture.checkpoint, uploads); return { @@ -198,25 +246,166 @@ export class HandoffCheckpointTracker { } const content = await readFile(filePath); - const base64Content = content.toString("base64"); - const artifacts = await this.apiClient.uploadTaskArtifacts( + if (content.byteLength > MAX_ARTIFACT_UPLOAD_BYTES) { + this.logger.warn( + "Skipping handoff artifact upload: file exceeds the artifact size limit", + { + name, + rawBytes: content.byteLength, + maxBytes: MAX_ARTIFACT_UPLOAD_BYTES, + }, + ); + return { rawBytes: content.byteLength, wireBytes: 0 }; + } + + try { + const storagePath = await this.uploadArtifactDirect( + content, + name, + contentType, + ); + if (storagePath) { + return { + storagePath, + rawBytes: content.byteLength, + wireBytes: content.byteLength, + }; + } + } catch (error) { + this.logger.warn( + "Direct artifact upload failed; falling back to inline upload", + { name, error: error instanceof Error ? error.message : String(error) }, + ); + } + + return this.uploadArtifactInline(content, name, contentType); + } + + private async uploadArtifactDirect( + content: Buffer, + name: string, + contentType: string, + ): Promise { + if (!this.apiClient) { + return undefined; + } + + const [prepared] = await this.apiClient.prepareTaskArtifactUploads( this.taskId, this.runId, [ { name, type: "artifact", - content: base64Content, + size: content.byteLength, content_type: contentType, }, ], ); + if (!prepared) { + return undefined; + } - return { - storagePath: artifacts.at(-1)?.storage_path, - rawBytes: content.byteLength, - wireBytes: Buffer.byteLength(base64Content, "utf-8"), - }; + await this.postToPresignedUrl(prepared, content, contentType); + + const [finalized] = await this.apiClient.finalizeTaskArtifactUploads( + this.taskId, + this.runId, + [ + { + id: prepared.id, + name: prepared.name, + type: "artifact", + storage_path: prepared.storage_path, + content_type: contentType, + }, + ], + ); + // An unconfirmed finalize means the artifact was never attached to the + // run manifest; referencing it would break the download on resume. + if (!finalized?.storage_path) { + throw new Error( + `Artifact finalize did not confirm ${name} at ${prepared.storage_path}`, + ); + } + return finalized.storage_path; + } + + private async postToPresignedUrl( + prepared: PreparedTaskArtifactUpload, + content: Buffer, + contentType: string, + ): Promise { + const form = new FormData(); + for (const [key, value] of Object.entries(prepared.presigned_post.fields)) { + form.append(key, value); + } + form.append( + "file", + new Blob([new Uint8Array(content)], { type: contentType }), + prepared.name, + ); + + const response = await fetch(prepared.presigned_post.url, { + method: "POST", + body: form, + }); + if (!response.ok) { + throw new Error( + `Presigned artifact upload failed: [${response.status}] ${response.statusText}`, + ); + } + } + + private async uploadArtifactInline( + content: Buffer, + name: string, + contentType: string, + ): Promise { + if (!this.apiClient) { + return { rawBytes: content.byteLength, wireBytes: 0 }; + } + + if (content.byteLength > MAX_INLINE_UPLOAD_BYTES) { + this.logger.warn( + "Skipping inline handoff artifact upload: file exceeds the inline upload limit", + { + name, + rawBytes: content.byteLength, + maxBytes: MAX_INLINE_UPLOAD_BYTES, + }, + ); + return { rawBytes: content.byteLength, wireBytes: 0 }; + } + + const base64Content = content.toString("base64"); + try { + const artifacts = await this.apiClient.uploadTaskArtifacts( + this.taskId, + this.runId, + [ + { + name, + type: "artifact", + content: base64Content, + content_encoding: "base64", + content_type: contentType, + }, + ], + ); + return { + storagePath: artifacts.at(-1)?.storage_path, + rawBytes: content.byteLength, + wireBytes: Buffer.byteLength(base64Content, "utf-8"), + }; + } catch (error) { + this.logger.warn("Inline handoff artifact upload failed", { + name, + rawBytes: content.byteLength, + error: error instanceof Error ? error.message : String(error), + }); + return { rawBytes: content.byteLength, wireBytes: 0 }; + } } private async uploadArtifacts(specs: UploadArtifactSpec[]): Promise { @@ -257,8 +446,7 @@ export class HandoffCheckpointTracker { if (!arrayBuffer) { throw new Error(`Failed to download ${label} from ${artifactPath}`); } - const base64Content = Buffer.from(arrayBuffer).toString("utf-8"); - const binaryContent = Buffer.from(base64Content, "base64"); + const binaryContent = decodeHandoffArtifact(Buffer.from(arrayBuffer)); await writeFile(filePath, binaryContent); return { filePath, diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index fdeb360188..5ad0d1c1e5 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -25,6 +25,34 @@ export interface TaskArtifactUploadPayload { name: string; type: ArtifactType; content: string; + /** Encoding of `content`. With "base64" the backend stores the decoded bytes. */ + content_encoding?: "utf-8" | "base64"; + content_type?: string; +} + +export interface TaskArtifactPrepareUploadPayload { + name: string; + type: ArtifactType; + size: number; + content_type?: string; +} + +export interface PreparedTaskArtifactUpload { + id: string; + name: string; + type: ArtifactType; + size: number; + content_type?: string; + storage_path: string; + expires_in: number; + presigned_post: { url: string; fields: Record }; +} + +export interface TaskArtifactFinalizeUploadPayload { + id: string; + name: string; + type: ArtifactType; + storage_path: string; content_type?: string; } @@ -253,6 +281,63 @@ export class PostHogAPIClient { return manifest.slice(-artifacts.length); } + /** + * Reserve S3 keys and presigned POST forms so artifact bytes can be + * uploaded directly to object storage instead of traveling base64-encoded + * through the API (which enforces much smaller request body limits). + */ + async prepareTaskArtifactUploads( + taskId: string, + runId: string, + artifacts: TaskArtifactPrepareUploadPayload[], + ): Promise { + if (!artifacts.length) { + return []; + } + + const teamId = this.getTeamId(); + const response = await this.apiRequest<{ + artifacts: PreparedTaskArtifactUpload[]; + }>( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/prepare_upload/`, + { + method: "POST", + body: JSON.stringify({ artifacts }), + }, + ); + return response.artifacts ?? []; + } + + /** Attach directly-uploaded artifacts (see prepareTaskArtifactUploads) to the run manifest. */ + async finalizeTaskArtifactUploads( + taskId: string, + runId: string, + artifacts: TaskArtifactFinalizeUploadPayload[], + ): Promise { + if (!artifacts.length) { + return []; + } + + const teamId = this.getTeamId(); + const response = await this.apiRequest<{ artifacts: TaskRunArtifact[] }>( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/finalize_upload/`, + { + method: "POST", + body: JSON.stringify({ artifacts }), + }, + ); + + // The backend returns the full run artifact manifest; pick out the + // entries for this request (retried finalizes can land mid-manifest). + const manifest = response.artifacts ?? []; + const byStoragePath = new Map( + manifest.map((artifact) => [artifact.storage_path, artifact]), + ); + return artifacts + .map((artifact) => byStoragePath.get(artifact.storage_path)) + .filter((artifact): artifact is TaskRunArtifact => !!artifact); + } + /** Signal reports the given task is associated with (via report task associations). */ async getSignalReportIdsForTask(taskId: string): Promise { const teamId = this.getTeamId(); diff --git a/packages/git/src/handoff.test.ts b/packages/git/src/handoff.test.ts index 8db2a144cf..20f377b7e7 100644 --- a/packages/git/src/handoff.test.ts +++ b/packages/git/src/handoff.test.ts @@ -422,4 +422,67 @@ describe("GitHandoffTracker", () => { } }); }, 15000); + + it.each([ + ["the branch's upstream tracking ref", false], + ["the remote default branch when the branch has no upstream", true], + ])( + "packs against %s when no local git state is provided", + async (_label, useBranchWithoutUpstream) => { + const originRepo = await setupRepo(); + const sandboxRepo = await cloneRepo(originRepo); + try { + const sandboxGit = createGitClient(sandboxRepo); + const branch = ( + await sandboxGit.revparse(["--abbrev-ref", "HEAD"]) + ).trim(); + const baseCommit = ( + await sandboxGit.revparse([`origin/${branch}`]) + ).trim(); + const baseBlob = ( + await sandboxGit.revparse([`origin/${branch}:tracked.txt`]) + ).trim(); + + if (useBranchWithoutUpstream) { + await sandboxGit.checkout(["-b", "session-branch"]); + } + + await writeFile( + path.join(sandboxRepo, "committed.txt"), + "session commit\n", + ); + await sandboxGit.add(["committed.txt"]); + await sandboxGit.commit("Session commit"); + const sessionCommit = (await sandboxGit.revparse(["HEAD"])).trim(); + + const tracker = new GitHandoffTracker({ repositoryPath: sandboxRepo }); + const capture = await tracker.captureForHandoff(); + + try { + expect(capture.headPack).toBeDefined(); + const packPath = capture.headPack?.path as string; + await execFileAsync("git", ["index-pack", packPath], { + cwd: sandboxRepo, + }); + const idxPath = packPath.replace(/\.pack$/, ".idx"); + const { stdout } = await execFileAsync( + "git", + ["verify-pack", "-v", idxPath], + { cwd: sandboxRepo }, + ); + await rm(idxPath, { force: true }); + + expect(stdout).toContain(sessionCommit); + expect(stdout).not.toContain(baseCommit); + expect(stdout).not.toContain(baseBlob); + } finally { + await cleanupCapture(capture); + } + } finally { + await rm(sandboxRepo, { recursive: true, force: true }); + await rm(originRepo, { recursive: true, force: true }); + } + }, + 15000, + ); }); diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 86d8a74ce2..81f2a2c538 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -104,22 +104,24 @@ export class GitHandoffTracker { checkpoint.checkpointId, ); - const packBaseline = localGitState?.upstreamHead ?? null; + const tracking = await getTrackingMetadata(git, checkpoint.branch); + const baselineRefs = localGitState?.upstreamHead + ? [localGitState.upstreamHead] + : await this.resolveDefaultPackBaseline(git, tracking); const packRefs = [ checkpoint.head, reconciledIndex.indexTree, checkpoint.worktreeTree, - packBaseline ? `^${packBaseline}` : null, + ...baselineRefs.map((ref) => `^${ref}`), ].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([ + const [headPack, indexFile] = await Promise.all([ this.captureObjectPack(packPrefix, packRefs), this.statFileArtifact(reconciledIndex.indexFilePath), - getTrackingMetadata(git, checkpoint.branch), ]); return { @@ -208,6 +210,50 @@ export class GitHandoffTracker { }; } + /** + * Without local handoff state the pack baseline must stay limited to + * objects the apply side is able to restore: the branch's upstream + * tracking ref (which ensureBaselineForApply fetches before unpacking), + * or failing that the remote's default branch, which any fresh clone of + * the repository already contains. Excluding broader refs (e.g. every + * remote-tracking ref) risks producing a pack that omits objects the + * resume repository cannot fetch. Without any baseline, a capture with no + * local handoff state packs the entire repo snapshot, which for large + * repos exceeds artifact upload limits. + */ + private async resolveDefaultPackBaseline( + git: GitClient, + tracking: GitTrackingMetadata, + ): Promise { + if (tracking.upstreamRemote && tracking.upstreamMergeRef) { + const branchName = tracking.upstreamMergeRef.replace( + /^refs\/heads\//, + "", + ); + const upstreamHead = await this.revparseOrNull( + git, + `refs/remotes/${tracking.upstreamRemote}/${branchName}`, + ); + if (upstreamHead) { + return [upstreamHead]; + } + } + + const defaultHead = await this.revparseOrNull( + git, + "refs/remotes/origin/HEAD", + ); + return defaultHead ? [defaultHead] : []; + } + + private async revparseOrNull( + git: GitClient, + ref: string, + ): Promise { + const value = await git.revparse([ref]).catch(() => null); + return value ? value.trim() : null; + } + private async captureObjectPack( packPrefix: string, refs: string[],