mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When an agent runs on a different host (sandbox or SSH), the adapter transport copies the local git execution workspace to that host and syncs changes back after the run > - The transport materializes the remote copy with `git init` plus a depth-1 or bundle fetch, so the copy has no `origin` remote and its head reads as a parentless snapshot commit > - An agent asked to publish its branch (push it, open a pull request) sees "no remote, root snapshot" and must hand the publish step back to a human operator, even when the branch base is a commit the upstream remote already holds > - This pull request carries the workspace's `origin` URL (credential-scrubbed) onto the transported copy as metadata > - The benefit is that branches produced in transported workspaces stay publishable by any actor with credentials, while the transport itself still never fetches or pushes ## Linked Issues or Issue Description No public issue exists. Description follows the enhancement template: **What existing behavior does this improve?** The workspace transport in `@paperclipai/adapter-utils` already copies a git workspace to the execution host and back. This change improves the fidelity of that copy: the transported repo keeps the workspace's `origin` remote instead of losing it. **Subsystem affected** Adapter utilities — the sandbox transport (`withShallowGitWorkspaceClone` in `packages/adapter-utils/src/git-workspace-sync.ts`) and the SSH transport (`importGitWorkspaceToSsh` in `packages/adapter-utils/src/ssh.ts`). **Current behavior** The transported copy is built with `git init` plus a depth-1 (sandbox) or bundle (SSH) fetch. It has no remotes. `git remote -v` is empty and the head commit reads as a root snapshot with no visible ancestry. Agents and operators inside the execution host cannot fetch real ancestry or push a branch, even when the branch base is a commit the upstream remote already holds. **Proposed behavior** The transport reads the source workspace's `origin` URL, scrubs credentials from it, and configures it on the transported copy. The sandbox path adds the remote to the fresh clone. The SSH path sets or adds the remote in the remote setup script, which also covers reused workspace directories. A workspace with no `origin` transports exactly as before. **Reason and benefit** A branch committed in a transported workspace becomes publishable in place: the shallow boundary commit already exists on the remote, so a push pack closes without full local ancestry (a new test locks in this property). Fetching real ancestry also becomes possible for whoever holds credentials. Without this, agents must describe their change in a handoff document and a human must reconstruct the branch by hand. **Breaking changes** None. The URL copy is best-effort and metadata-only. The transport never fetches from or pushes to the remote. The no-remote-git contract holds: sync-back through the local cwd stays the only cross-run persistence path, and `packages/adapters/AUTHORING.md` gains a paragraph that makes the carried-remote nuance explicit. ## What Changed - `packages/adapter-utils/src/git-workspace-sync.ts`: new `sanitizeGitRemoteUrl` (strips http(s) userinfo, where tokens can be embedded; scp-like/ssh forms and filesystem paths pass through) and `readSanitizedOriginRemoteUrl`; `withShallowGitWorkspaceClone` configures the scrubbed `origin` on the fresh clone, best-effort. - `packages/adapter-utils/src/ssh.ts`: `importGitWorkspaceToSsh` sets or adds the scrubbed `origin` in the remote setup script, non-fatal under `set -e`. - `packages/adapter-utils/src/git-workspace-sync.test.ts`: four new integration cases (remote copied, credentials scrubbed, no-origin unchanged, push from the shallow clone to an origin that holds the base commit) plus `sanitizeGitRemoteUrl` unit tests. - `packages/adapters/AUTHORING.md`: documents that a transported copy may carry a credential-scrubbed `origin` as metadata, and why this does not weaken the no-remote-git contract. ## Verification - `npx vitest run packages/adapter-utils/src/git-workspace-sync.test.ts` — 12/12 pass (4 new integration cases + sanitizer unit tests). - `npx vitest run packages/adapter-utils/src/sandbox-managed-runtime.test.ts` — 24/24 pass. - `npx vitest run packages/adapter-utils/src/ssh-fixture.test.ts` — 16/16 pass, including the `no-remote-git contract` case (a workspace without `origin` still round-trips with no remote introduced at any point). - `node scripts/check-no-git-push.mjs` — passes; this change adds no push or fetch to adapter/runtime code. - `pnpm typecheck` in `packages/adapter-utils` — clean. ## Risks - Low risk. The change is additive metadata on the transported copy only; failure to record the remote never fails the transport. - Credential exposure is the real hazard and is handled: http(s) userinfo is stripped before the URL leaves the host. Non-http forms (scp-like, `ssh://`) carry no secret in the URL and pass through. - A reused SSH workspace whose project `origin` changed now gets the current URL via `set-url` instead of keeping a stale one. ## Model Used Claude Fable 5 (`claude-fable-5`), Anthropic — extended thinking, agentic tool use via Claude Code CLI. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
export interface GitCommandResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface GitWorkspaceSnapshot {
|
|
headCommit: string;
|
|
branchName: string | null;
|
|
overlayPaths: string[];
|
|
deletedPaths: string[];
|
|
ignoredPaths: string[];
|
|
}
|
|
|
|
export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const;
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
}
|
|
|
|
export async function runLocalGit(
|
|
localDir: string,
|
|
args: string[],
|
|
options: {
|
|
timeout?: number;
|
|
maxBuffer?: number;
|
|
} = {},
|
|
): Promise<GitCommandResult> {
|
|
return await new Promise<GitCommandResult>((resolve, reject) => {
|
|
execFile(
|
|
"git",
|
|
["-C", localDir, ...args],
|
|
{
|
|
timeout: options.timeout ?? 15_000,
|
|
maxBuffer: options.maxBuffer ?? 1024 * 128,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" }));
|
|
return;
|
|
}
|
|
resolve({
|
|
stdout: stdout ?? "",
|
|
stderr: stderr ?? "",
|
|
});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function readGitWorkspaceSnapshot(localDir: string): Promise<GitWorkspaceSnapshot | null> {
|
|
try {
|
|
const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
if (insideWorkTree.stdout.trim() !== "true") {
|
|
return null;
|
|
}
|
|
|
|
const [headCommitResult, branchResult, overlayDiffResult, untrackedResult, deletedResult, ignoredResult] = await Promise.all([
|
|
runLocalGit(localDir, ["rev-parse", "HEAD"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}),
|
|
runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}),
|
|
runLocalGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 1024 * 1024,
|
|
}),
|
|
runLocalGit(localDir, ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 1024 * 1024,
|
|
}),
|
|
runLocalGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 256 * 1024,
|
|
}),
|
|
runLocalGit(localDir, ["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 1024 * 1024,
|
|
}),
|
|
]);
|
|
|
|
const branchName = branchResult.stdout.trim();
|
|
const splitNul = (value: string) => value.split("\0").map((entry) => entry.trim()).filter(Boolean);
|
|
return {
|
|
headCommit: headCommitResult.stdout.trim(),
|
|
branchName: branchName && branchName !== "HEAD" ? branchName : null,
|
|
overlayPaths: [...new Set([...splitNul(overlayDiffResult.stdout), ...splitNul(untrackedResult.stdout)])]
|
|
.sort((left, right) => left.localeCompare(right)),
|
|
deletedPaths: [...new Set(splitNul(deletedResult.stdout))]
|
|
.sort((left, right) => left.localeCompare(right)),
|
|
ignoredPaths: splitNul(ignoredResult.stdout)
|
|
.filter((entry) => entry.startsWith("!! "))
|
|
.map((entry) => entry.slice(3).replace(/\/+$/, ""))
|
|
.filter(Boolean)
|
|
.sort((left, right) => left.localeCompare(right)),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// scp-like ssh remote (`user@host:path`). The syntax has no password slot, so
|
|
// it cannot embed a secret. Conservative shape: exactly one `@`, no colon in
|
|
// the user segment (a colon there could smuggle credential-looking material),
|
|
// no scheme separator (a `://` form parses as a URL and never reaches this).
|
|
const SCP_LIKE_REMOTE_PATTERN = /^[^@:/\s]+@[^@:/\s]+:\S+$/;
|
|
|
|
/**
|
|
* Reduce a git remote URL to a credential-free form before it is copied into a
|
|
* transported workspace, or null when the URL must not be carried at all.
|
|
* Allowlist, fail closed: only shapes whose credential surface is fully known
|
|
* are kept — http(s) with userinfo/query/fragment stripped (tokens ride in any
|
|
* of those), ssh/git schemes with password/query/fragment stripped, and
|
|
* scp-like `user@host:path` (no password slot exists in that syntax). Every
|
|
* other form — filesystem paths, unknown schemes, unparseable strings — is
|
|
* dropped rather than risk persisting an embedded secret in the execution
|
|
* host's git config.
|
|
*/
|
|
export function sanitizeGitRemoteUrl(url: string): string | null {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
parsed.search = "";
|
|
parsed.hash = "";
|
|
return parsed.toString();
|
|
}
|
|
if (parsed.protocol === "ssh:" || parsed.protocol === "git:" || parsed.protocol === "git+ssh:") {
|
|
// The username (conventionally `git`) is addressing, not a secret; a
|
|
// password or query string can be, so those are stripped.
|
|
parsed.password = "";
|
|
parsed.search = "";
|
|
parsed.hash = "";
|
|
return parsed.toString();
|
|
}
|
|
return null;
|
|
} catch {
|
|
return SCP_LIKE_REMOTE_PATTERN.test(trimmed) ? trimmed : null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The workspace's `origin` remote URL with credentials scrubbed, or null when
|
|
* the workspace has no `origin` remote (or is not a git repository).
|
|
*/
|
|
export async function readSanitizedOriginRemoteUrl(localDir: string): Promise<string | null> {
|
|
try {
|
|
const result = await runLocalGit(localDir, ["remote", "get-url", "origin"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
return sanitizeGitRemoteUrl(result.stdout.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function withShallowGitWorkspaceClone<T>(
|
|
input: {
|
|
localDir: string;
|
|
snapshot: GitWorkspaceSnapshot;
|
|
},
|
|
fn: (cloneDir: string) => Promise<T>,
|
|
): Promise<T> {
|
|
const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-"));
|
|
const tempRef = `refs/paperclip/git-sync/import/${randomUUID()}`;
|
|
try {
|
|
const originUrl = await readSanitizedOriginRemoteUrl(input.localDir);
|
|
await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
await runLocalGit(cloneDir, ["init"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 64 * 1024,
|
|
});
|
|
if (originUrl) {
|
|
// The clone is what lands in the sandbox. Without `origin`, the branch
|
|
// there reads as an unpublishable root snapshot even though its head is a
|
|
// commit the upstream remote already holds — so fetch (to reconnect
|
|
// ancestry) and push (to publish the branch; the shallow boundary commit
|
|
// is already on the remote, so the pack closes) are both mechanically
|
|
// possible once the remote is carried over. Best-effort: a failure to
|
|
// record the remote must not fail the transport.
|
|
await runLocalGit(cloneDir, ["remote", "add", "origin", originUrl], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}).catch(() => undefined);
|
|
}
|
|
await runLocalGit(cloneDir, ["fetch", "--depth=1", input.localDir, tempRef], {
|
|
timeout: 60_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
await runLocalGit(
|
|
cloneDir,
|
|
input.snapshot.branchName
|
|
? ["checkout", "--force", "-B", input.snapshot.branchName, "FETCH_HEAD"]
|
|
: ["checkout", "--force", "--detach", "FETCH_HEAD"],
|
|
{
|
|
timeout: 60_000,
|
|
maxBuffer: 1024 * 1024,
|
|
},
|
|
);
|
|
await runLocalGit(cloneDir, ["reset", "--hard", input.snapshot.headCommit], {
|
|
timeout: 60_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return await fn(cloneDir);
|
|
} finally {
|
|
await runLocalGit(input.localDir, ["update-ref", "-d", tempRef], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}).catch(() => undefined);
|
|
await fs.rm(cloneDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export function createImportedGitRef(scope = "remote"): string {
|
|
return `refs/paperclip/git-sync/imported/${scope}/${randomUUID()}`;
|
|
}
|
|
|
|
export function createRemoteGitExportRef(scope = "remote"): string {
|
|
return `refs/paperclip/git-sync/export/${scope}/${randomUUID()}`;
|
|
}
|
|
|
|
export async function deleteLocalGitRef(input: {
|
|
localDir: string;
|
|
ref: string;
|
|
}): Promise<void> {
|
|
await runLocalGit(input.localDir, ["update-ref", "-d", input.ref], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
export async function fetchGitBundleIntoLocalRef(input: {
|
|
localDir: string;
|
|
bundlePath: string;
|
|
exportRef: string;
|
|
importedRef: string;
|
|
baseSha: string;
|
|
}): Promise<string> {
|
|
const bundleSize = (await fs.stat(input.bundlePath).catch(() => null))?.size ?? 0;
|
|
if (bundleSize === 0) {
|
|
return input.baseSha;
|
|
}
|
|
|
|
await runLocalGit(input.localDir, ["fetch", "--force", input.bundlePath, `${input.exportRef}:${input.importedRef}`], {
|
|
timeout: 60_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
const importedHead = await runLocalGit(input.localDir, ["rev-parse", input.importedRef], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
return importedHead.stdout.trim();
|
|
}
|
|
|
|
/** Substrings git emits when a bundle names a prerequisite the importer lacks. */
|
|
const GIT_MISSING_PREREQUISITE_MARKERS = [
|
|
"did not send all necessary objects",
|
|
"lacks these prerequisite commits",
|
|
"revision walk setup failed",
|
|
];
|
|
|
|
/**
|
|
* True when a bundle import failed because the host repository does not hold a
|
|
* commit the (delta) bundle assumes as a prerequisite. Such a failure is
|
|
* recoverable by re-exporting a full, self-contained bundle from the still-live
|
|
* sandbox rather than discarding the run.
|
|
*/
|
|
export function isMissingGitPrerequisiteError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return GIT_MISSING_PREREQUISITE_MARKERS.some((marker) => message.includes(marker));
|
|
}
|
|
|
|
export function buildRemoteGitDeltaBundleScript(input: {
|
|
remoteDir: string;
|
|
baseSha: string;
|
|
exportRef: string;
|
|
bundlePath: string;
|
|
statusPath?: string;
|
|
catBundle?: boolean;
|
|
cleanupBundle?: boolean;
|
|
/**
|
|
* Skip the delta boundary entirely and always emit a full, self-contained
|
|
* bundle (no prerequisites). Used as the recovery path when a delta import
|
|
* failed because the host lacked the bundle's prerequisite.
|
|
*/
|
|
forceFullBundle?: boolean;
|
|
}): string {
|
|
const remoteDir = shellQuote(input.remoteDir);
|
|
const bundlePath = shellQuote(input.bundlePath);
|
|
const exportRef = shellQuote(input.exportRef);
|
|
const baseSha = shellQuote(input.baseSha);
|
|
const statusPath = input.statusPath ? shellQuote(input.statusPath) : null;
|
|
const cleanupParts = [
|
|
`rm -f ${bundlePath}`,
|
|
...(statusPath ? [`rm -f ${statusPath}`] : []),
|
|
`git -C ${remoteDir} update-ref -d ${exportRef} >/dev/null 2>&1 || true`,
|
|
];
|
|
return [
|
|
"set -e",
|
|
input.cleanupBundle ? `cleanup() { ${cleanupParts.join("; ")}; }` : "",
|
|
input.cleanupBundle ? "trap cleanup EXIT" : "",
|
|
`mkdir -p ${shellQuote(path.posix.dirname(input.bundlePath))}`,
|
|
`rm -f ${bundlePath}`,
|
|
// Choose the bundle boundary. A thin bundle `HEAD --not <baseSha>` records
|
|
// baseSha as a prerequisite the importer (host) must already hold. That
|
|
// assumption breaks in two real cases, and then `git fetch` on the host
|
|
// hard-fails with "did not send all necessary objects" and the run's work
|
|
// is lost:
|
|
// 1. The sandbox HEAD has diverged from baseSha (e.g. a local-only branch
|
|
// that forked from an older commit) — the host may still hold baseSha,
|
|
// but a repo whose history is inconsistent cannot satisfy the walk.
|
|
// 2. The host workspace no longer holds baseSha at import time (a shared
|
|
// workspace that was reset/re-realized between export and import).
|
|
// Bundle relative to the merge-base of baseSha and HEAD instead: that
|
|
// merge-base is an ancestor of baseSha, so any host that holds baseSha (or
|
|
// an ancestor of it) can satisfy the prerequisite, while the bundle stays a
|
|
// delta. When baseSha is absent from the sandbox — or no merge-base exists,
|
|
// or the caller forces it after a delta import failed on a missing
|
|
// prerequisite — fall back to a full, self-contained bundle with no
|
|
// prerequisites.
|
|
...(input.forceFullBundle
|
|
? [`bundle_base=""`]
|
|
: [
|
|
`if git -C ${remoteDir} cat-file -e ${baseSha}^{commit} 2>/dev/null; then`,
|
|
` bundle_base=$(git -C ${remoteDir} merge-base ${baseSha} HEAD 2>/dev/null || true)`,
|
|
"else",
|
|
` bundle_base=""`,
|
|
"fi",
|
|
]),
|
|
`if [ -n "$bundle_base" ]; then`,
|
|
` commit_count=$(git -C ${remoteDir} rev-list --count HEAD --not "$bundle_base")`,
|
|
"else",
|
|
` commit_count=$(git -C ${remoteDir} rev-list --count HEAD)`,
|
|
"fi",
|
|
'if [ "$commit_count" -gt 0 ]; then',
|
|
` git -C ${remoteDir} update-ref ${exportRef} HEAD`,
|
|
` if [ -n "$bundle_base" ]; then`,
|
|
` git -C ${remoteDir} bundle create ${bundlePath} ${exportRef} --not "$bundle_base" >/dev/null`,
|
|
" else",
|
|
` git -C ${remoteDir} bundle create ${bundlePath} ${exportRef} >/dev/null`,
|
|
" fi",
|
|
"else",
|
|
` : > ${bundlePath}`,
|
|
"fi",
|
|
statusPath
|
|
? [
|
|
`if [ -z "$(git -C ${remoteDir} status --porcelain=v1 --untracked-files=normal)" ]; then`,
|
|
` printf clean > ${statusPath}`,
|
|
"else",
|
|
` printf dirty > ${statusPath}`,
|
|
"fi",
|
|
].join("\n")
|
|
: "",
|
|
input.catBundle ? `cat ${bundlePath}` : "",
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
export async function integrateImportedGitHead(input: {
|
|
localDir: string;
|
|
importedHead: string;
|
|
}): Promise<void> {
|
|
const isConcurrentRefUpdateError = (error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return message.includes("cannot lock ref") && message.includes("expected");
|
|
};
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const snapshot = await readGitWorkspaceSnapshot(input.localDir);
|
|
if (!snapshot) return;
|
|
|
|
const currentHead = snapshot.headCommit;
|
|
if (!currentHead || currentHead === input.importedHead) return;
|
|
|
|
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
|
|
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}).catch(() => null);
|
|
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
|
|
|
|
if (mergeBaseHead === input.importedHead) {
|
|
return;
|
|
}
|
|
|
|
if (mergeBaseHead === currentHead) {
|
|
try {
|
|
await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let mergedTree;
|
|
try {
|
|
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
|
|
timeout: 60_000,
|
|
maxBuffer: 256 * 1024,
|
|
});
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
`Failed to merge concurrent remote git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`,
|
|
);
|
|
}
|
|
const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
if (!mergedTreeId) {
|
|
throw new Error("Failed to compute a merged git tree for workspace restore.");
|
|
}
|
|
|
|
const mergeCommit = await runLocalGit(
|
|
input.localDir,
|
|
[
|
|
"commit-tree",
|
|
mergedTreeId,
|
|
"-p",
|
|
currentHead,
|
|
"-p",
|
|
input.importedHead,
|
|
"-m",
|
|
`Paperclip remote git sync merge ${input.importedHead.slice(0, 12)}`,
|
|
],
|
|
{
|
|
timeout: 60_000,
|
|
maxBuffer: 64 * 1024,
|
|
},
|
|
);
|
|
try {
|
|
await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw new Error(`Failed to integrate concurrent remote git history for ${input.importedHead.slice(0, 12)} after multiple retries.`);
|
|
}
|
|
|
|
export async function resetLocalGitIndexToHead(input: {
|
|
localDir: string;
|
|
checkWorkingTreeClean?: boolean;
|
|
}): Promise<void> {
|
|
try {
|
|
await runLocalGit(input.localDir, ["reset", "--quiet", "HEAD", "--", "."], {
|
|
timeout: 60_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
} catch (error) {
|
|
const detail = error && typeof error === "object"
|
|
? [
|
|
(error as { message?: unknown }).message,
|
|
(error as { stderr?: unknown }).stderr,
|
|
(error as { stdout?: unknown }).stdout,
|
|
].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n")
|
|
: String(error);
|
|
throw new Error(`Failed to reset local git index to HEAD after workspace restore: ${detail}`);
|
|
}
|
|
|
|
const stagedDiff = await runLocalGit(input.localDir, ["diff", "--cached", "--name-status", "HEAD", "--"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
if (stagedDiff.stdout.trim().length > 0) {
|
|
throw new Error(
|
|
`Workspace restore left staged git index changes after reset:\n${stagedDiff.stdout.trim()}`,
|
|
);
|
|
}
|
|
|
|
if (!input.checkWorkingTreeClean) return;
|
|
|
|
const workingTreeDiff = await runLocalGit(input.localDir, ["diff", "--name-status", "HEAD", "--"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
if (workingTreeDiff.stdout.trim().length > 0) {
|
|
console.warn(
|
|
"[paperclip] Workspace restore preserved local working tree changes after clean sandbox restore.",
|
|
);
|
|
}
|
|
}
|