mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - At run finalize, the host imports the sandbox git history and reconciles it with the local worktree in `integrateImportedGitHead` > - Transported workspaces are depth-1 shallow clones, so the boundary commit reads as parentless inside the sandbox > - A `git commit --amend` there rewrites the boundary commit into a root commit, and the re-imported history no longer connects to the host history > - `git merge-tree` has no common base to merge against, so the sync throws "Failed to merge concurrent remote git histories" and the run fails with its work stranded in the sandbox > - This pull request grafts the imported tree onto the current head as a single commit instead of failing > - The benefit is that a history rewrite inside the sandbox can no longer lose a run's work ## Linked Issues or Issue Description No existing issue found. I searched issues and PRs for "unrelated histories", "Failed to merge concurrent", and "shallow". Depends on #11637 (merged; the graft commit reuses its identity constant). This PR is now rebased onto `master`. **What happened?** An agent run amended a commit inside its sandbox workspace to address review feedback. The sandbox clone is depth-1 shallow, so git treated the boundary commit as parentless and the amend produced a root commit. At finalize, the host-side sync failed with `Failed to merge concurrent remote git histories for <sha>` and the run was marked failed. A follow-up run had to repair the branch by hand: fetch the true parent from origin and rebuild the commit with `git commit-tree`. **Expected behavior** The sync must never strand completed work. When the imported history shares no ancestor with the local one, the imported tree should still land on the current head, with the imported message preserved and the graft recorded. **Steps to reproduce** 1. Start a run whose workspace transport uses the shallow clone path (`withShallowGitWorkspaceClone`, depth 1). 2. Inside the sandbox workspace, run `git commit --amend` on the boundary commit. The result is a parentless root commit. 3. Finish the run. The host-side `integrateImportedGitHead` finds no merge base, `merge-tree` fails, and the run fails. ## What Changed - `git-workspace-sync.ts`: new exported `createUnrelatedHistoryGraftCommit` helper. It reads the imported head's tree and message, and creates one commit on top of the current head with the deterministic sync identity and a trailer that records the graft and both shas. - `integrateImportedGitHead` (both the remote-git-sync version and the SSH copy in `ssh.ts`): when `merge-base` reports no common ancestor, graft instead of throwing. The ref update keeps the same compare-and-swap and concurrent-retry semantics as the merge path. - The graft is gated on `git merge-base` exiting with status 1 — the no-ancestor signal. Operational failures (timeout, missing object, repository error) keep the loud merge failure instead of rewriting the tip. - New regression tests: one builds the exact shallow-amend shape (a root commit rebuilt from the base tree) and asserts the graft lands on the current head with the imported tree, subject, and graft trailer; one integrates a well-formed sha the repository does not hold and asserts the integration still throws with the branch tip unchanged. ## Verification - `pnpm vitest run packages/adapter-utils/src/git-workspace-sync.test.ts` — 19/19 pass (includes the new graft test and the merge-base failure-discrimination test). - `pnpm --filter @paperclipai/adapter-utils typecheck` — clean. - Full `pnpm vitest run packages/adapter-utils`: every file passes except `local-process-sandbox.test.ts`, which fails identically on an untouched `master` checkout on macOS (bubblewrap-dependent, pre-existing, unrelated). ## Risks - Behavioral shift: unrelated imported histories previously failed the integration; now they land as a squash-graft. In this degenerate case there is no base to merge against, so the imported tree is taken wholesale and concurrent local-only tree changes are superseded at the tip. The local commits keep their place in the graft's ancestry, and the trailer records both shas, so nothing is unrecoverable. The old behavior lost the imported work instead, which is the worse failure for an autonomous run. - The graft reuses the imported head's commit message, so branch history still reads naturally after a sandbox rewrite. ## Model Used - Claude Fable 5 (`claude-fable-5`), extended thinking, via Claude Code CLI (tool use for code exploration, test runs, and verification). ## 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 (no doc surface describes this internal sync path) - [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
582 lines
26 KiB
TypeScript
582 lines
26 KiB
TypeScript
import { execFile as execFileCallback } from "node:child_process";
|
|
import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildRemoteGitDeltaBundleScript,
|
|
createImportedGitRef,
|
|
createRemoteGitExportRef,
|
|
deleteLocalGitRef,
|
|
fetchGitBundleIntoLocalRef,
|
|
integrateImportedGitHead,
|
|
isMissingGitPrerequisiteError,
|
|
readGitWorkspaceSnapshot,
|
|
runLocalGit,
|
|
sanitizeGitRemoteUrl,
|
|
setExpensiveWorkspaceGitExecutor,
|
|
withShallowGitWorkspaceClone,
|
|
} from "./git-workspace-sync.js";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
async function git(cwd: string, args: string[]): Promise<string> {
|
|
return (await runLocalGit(cwd, args)).stdout.trim();
|
|
}
|
|
|
|
describe("git workspace sync", () => {
|
|
const cleanupDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
setExpensiveWorkspaceGitExecutor(null);
|
|
while (cleanupDirs.length > 0) {
|
|
const dir = cleanupDirs.pop();
|
|
if (!dir) continue;
|
|
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("delegates every host-side full-tree enumeration to the registered scheduler", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-scheduler-hook-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
await writeFile(path.join(repo, "untracked.txt"), "untracked\n", "utf8");
|
|
const operations: string[] = [];
|
|
setExpensiveWorkspaceGitExecutor(async (input) => {
|
|
operations.push(input.operation);
|
|
return await runLocalGit(input.localDir, [...input.args], {
|
|
timeout: input.timeout,
|
|
maxBuffer: input.maxBuffer,
|
|
});
|
|
});
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
|
|
expect(snapshot?.overlayPaths).toContain("untracked.txt");
|
|
expect(operations.sort()).toEqual([
|
|
"adapter_sync.deleted_files",
|
|
"adapter_sync.ignored_files",
|
|
"adapter_sync.overlay_diff",
|
|
"adapter_sync.untracked_files",
|
|
]);
|
|
});
|
|
|
|
async function createRepo(rootDir: string): Promise<string> {
|
|
const repo = path.join(rootDir, "repo");
|
|
await mkdir(repo, { recursive: true });
|
|
await git(repo, ["init"]);
|
|
await git(repo, ["checkout", "-b", "main"]);
|
|
await git(repo, ["config", "user.name", "Paperclip Test"]);
|
|
await git(repo, ["config", "user.email", "test@paperclip.dev"]);
|
|
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
|
|
await git(repo, ["add", "tracked.txt"]);
|
|
await git(repo, ["commit", "-m", "base"]);
|
|
return repo;
|
|
}
|
|
|
|
it("creates a shallow standalone clone from the local HEAD snapshot", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-sync-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
await rm(path.join(repo, "tracked.txt"));
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
expect(snapshot).toMatchObject({
|
|
headCommit: baseHead,
|
|
branchName: "main",
|
|
deletedPaths: ["tracked.txt"],
|
|
});
|
|
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (cloneDir) => {
|
|
expect((await lstat(path.join(cloneDir, ".git"))).isDirectory()).toBe(true);
|
|
await expect(readFile(path.join(cloneDir, ".git", "shallow"), "utf8")).resolves.toContain(baseHead);
|
|
expect(await git(cloneDir, ["rev-list", "--count", "HEAD"])).toBe("1");
|
|
expect(await git(cloneDir, ["branch", "--show-current"])).toBe("main");
|
|
await expect(readFile(path.join(cloneDir, "tracked.txt"), "utf8")).resolves.toBe("base\n");
|
|
});
|
|
});
|
|
|
|
it("copies the workspace origin remote into the shallow clone", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-origin-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
await git(repo, ["remote", "add", "origin", "https://github.com/example/repo.git"]);
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (cloneDir) => {
|
|
expect(await git(cloneDir, ["remote", "get-url", "origin"])).toBe("https://github.com/example/repo.git");
|
|
});
|
|
});
|
|
|
|
it("scrubs credentials from the origin remote before copying it", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-origin-scrub-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
await git(repo, ["remote", "add", "origin", "https://x-access-token:sekret@github.com/example/repo.git"]);
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (cloneDir) => {
|
|
expect(await git(cloneDir, ["remote", "get-url", "origin"])).toBe("https://github.com/example/repo.git");
|
|
});
|
|
});
|
|
|
|
it("leaves the shallow clone remote-less when the workspace has no origin", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-origin-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (cloneDir) => {
|
|
await expect(git(cloneDir, ["remote", "get-url", "origin"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it("drops a filesystem-path origin instead of copying it into the shallow clone", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-path-origin-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
await git(repo, ["remote", "add", "origin", path.join(rootDir, "elsewhere.git")]);
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (cloneDir) => {
|
|
await expect(git(cloneDir, ["remote", "get-url", "origin"])).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it("pushes new commits from the shallow clone to an origin that holds the base commit", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-shallow-push-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
const upstream = path.join(rootDir, "upstream.git");
|
|
await mkdir(upstream, { recursive: true });
|
|
await git(upstream, ["init", "--bare"]);
|
|
await git(repo, ["remote", "add", "origin", upstream]);
|
|
await git(repo, ["push", "origin", "main"]);
|
|
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (cloneDir) => {
|
|
await git(cloneDir, ["config", "user.name", "Paperclip Sandbox"]);
|
|
await git(cloneDir, ["config", "user.email", "sandbox@paperclip.dev"]);
|
|
await writeFile(path.join(cloneDir, "change.txt"), "sandbox change\n", "utf8");
|
|
await git(cloneDir, ["add", "change.txt"]);
|
|
await git(cloneDir, ["commit", "-m", "sandbox change"]);
|
|
const cloneHead = await git(cloneDir, ["rev-parse", "HEAD"]);
|
|
|
|
// A filesystem-path origin is dropped by the allowlist, so configure the
|
|
// remote explicitly — the property under test is the push itself: the
|
|
// clone is shallow (single grafted commit), but the boundary commit
|
|
// already exists on the origin, so the push pack closes without full
|
|
// ancestry. That is what makes transported branches publishable.
|
|
await git(cloneDir, ["remote", "add", "origin", upstream]);
|
|
await git(cloneDir, ["push", "origin", "HEAD:refs/heads/sandbox-change"]);
|
|
|
|
expect(await git(upstream, ["rev-parse", "refs/heads/sandbox-change"])).toBe(cloneHead);
|
|
expect(await git(upstream, ["merge-base", "refs/heads/main", "refs/heads/sandbox-change"])).toBe(baseHead);
|
|
});
|
|
});
|
|
|
|
it("builds thin git delta bundles relative to the imported base", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-delta-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = await createRepo(rootDir);
|
|
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
const snapshot = await readGitWorkspaceSnapshot(repo);
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: repo,
|
|
snapshot: snapshot!,
|
|
}, async (remoteDir) => {
|
|
const emptyBundle = path.join(rootDir, "empty.bundle");
|
|
await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
|
|
remoteDir,
|
|
baseSha: baseHead,
|
|
exportRef: createRemoteGitExportRef("test"),
|
|
bundlePath: emptyBundle,
|
|
})]);
|
|
expect((await stat(emptyBundle)).size).toBe(0);
|
|
|
|
await git(remoteDir, ["config", "user.name", "Paperclip Remote"]);
|
|
await git(remoteDir, ["config", "user.email", "remote@paperclip.dev"]);
|
|
await writeFile(path.join(remoteDir, "tracked.txt"), "remote\n", "utf8");
|
|
await git(remoteDir, ["commit", "-am", "remote update"]);
|
|
const remoteHead = await git(remoteDir, ["rev-parse", "HEAD"]);
|
|
|
|
const deltaBundle = path.join(rootDir, "delta.bundle");
|
|
const importedRef = createImportedGitRef("test");
|
|
const exportRef = createRemoteGitExportRef("test");
|
|
try {
|
|
await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
|
|
remoteDir,
|
|
baseSha: baseHead,
|
|
exportRef,
|
|
bundlePath: deltaBundle,
|
|
})]);
|
|
expect((await stat(deltaBundle)).size).toBeGreaterThan(0);
|
|
|
|
const importedHead = await fetchGitBundleIntoLocalRef({
|
|
localDir: repo,
|
|
bundlePath: deltaBundle,
|
|
exportRef,
|
|
importedRef,
|
|
baseSha: baseHead,
|
|
});
|
|
expect(importedHead).toBe(remoteHead);
|
|
expect(await git(repo, ["rev-list", "--count", importedRef, "--not", baseHead])).toBe("1");
|
|
} finally {
|
|
await deleteLocalGitRef({ localDir: repo, ref: importedRef });
|
|
}
|
|
});
|
|
});
|
|
|
|
it("imports a diverged sandbox HEAD even when the host no longer holds baseSha", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-diverge-"));
|
|
cleanupDirs.push(rootDir);
|
|
// Host holds only the shared ancestor B (the eventual merge-base), not the
|
|
// recorded base H — the state a shared workspace lands in when it is reset
|
|
// between export and import.
|
|
const host = await createRepo(rootDir);
|
|
const mergeBase = await git(host, ["rev-parse", "HEAD"]);
|
|
|
|
// Sandbox holds B, an advanced commit H (the recorded baseSha), and a
|
|
// local-only commit S that forked from B and diverges from H.
|
|
const sandbox = path.join(rootDir, "sandbox");
|
|
await git(rootDir, ["clone", host, sandbox]);
|
|
await git(sandbox, ["config", "user.name", "Paperclip Remote"]);
|
|
await git(sandbox, ["config", "user.email", "remote@paperclip.dev"]);
|
|
await writeFile(path.join(sandbox, "advance.txt"), "advance\n", "utf8");
|
|
await git(sandbox, ["add", "-A"]);
|
|
await git(sandbox, ["commit", "-m", "advance"]);
|
|
const baseSha = await git(sandbox, ["rev-parse", "HEAD"]);
|
|
await git(sandbox, ["reset", "--hard", mergeBase]);
|
|
await writeFile(path.join(sandbox, "local.txt"), "local\n", "utf8");
|
|
await git(sandbox, ["add", "-A"]);
|
|
await git(sandbox, ["commit", "-m", "local-only"]);
|
|
const sandboxHead = await git(sandbox, ["rev-parse", "HEAD"]);
|
|
|
|
// The host genuinely lacks baseSha; the old thin bundle would name it as an
|
|
// unsatisfiable prerequisite.
|
|
await expect(git(host, ["cat-file", "-e", `${baseSha}^{commit}`])).rejects.toThrow();
|
|
|
|
const bundle = path.join(rootDir, "diverge.bundle");
|
|
const exportRef = createRemoteGitExportRef("test");
|
|
const importedRef = createImportedGitRef("test");
|
|
try {
|
|
await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
|
|
remoteDir: sandbox,
|
|
baseSha,
|
|
exportRef,
|
|
bundlePath: bundle,
|
|
})]);
|
|
expect((await stat(bundle)).size).toBeGreaterThan(0);
|
|
|
|
const importedHead = await fetchGitBundleIntoLocalRef({
|
|
localDir: host,
|
|
bundlePath: bundle,
|
|
exportRef,
|
|
importedRef,
|
|
baseSha,
|
|
});
|
|
expect(importedHead).toBe(sandboxHead);
|
|
// The host received the local-only commit and its parent (the merge-base).
|
|
expect(await git(host, ["cat-file", "-e", `${sandboxHead}^{commit}`])).toBe("");
|
|
} finally {
|
|
await deleteLocalGitRef({ localDir: host, ref: importedRef });
|
|
}
|
|
});
|
|
|
|
it("re-exports a full bundle that imports when the host holds neither baseSha nor the merge-base", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-ancestor-"));
|
|
cleanupDirs.push(rootDir);
|
|
// Host was reset to a strict ancestor of the eventual merge-base: it holds
|
|
// only the very first commit, not baseSha and not the fork point.
|
|
const host = await createRepo(rootDir);
|
|
const ancestor = await git(host, ["rev-parse", "HEAD"]);
|
|
|
|
const sandbox = path.join(rootDir, "sandbox");
|
|
await git(rootDir, ["clone", host, sandbox]);
|
|
await git(sandbox, ["config", "user.name", "Paperclip Remote"]);
|
|
await git(sandbox, ["config", "user.email", "remote@paperclip.dev"]);
|
|
// Advance the merge-base past the host, then baseSha past that, then a
|
|
// divergent local commit — so merge-base(baseSha, HEAD) is itself a commit
|
|
// the host does not hold.
|
|
await writeFile(path.join(sandbox, "fork.txt"), "fork\n", "utf8");
|
|
await git(sandbox, ["add", "-A"]);
|
|
await git(sandbox, ["commit", "-m", "fork point"]);
|
|
const forkPoint = await git(sandbox, ["rev-parse", "HEAD"]);
|
|
await writeFile(path.join(sandbox, "advance.txt"), "advance\n", "utf8");
|
|
await git(sandbox, ["add", "-A"]);
|
|
await git(sandbox, ["commit", "-m", "advance"]);
|
|
const baseSha = await git(sandbox, ["rev-parse", "HEAD"]);
|
|
await git(sandbox, ["reset", "--hard", forkPoint]);
|
|
await writeFile(path.join(sandbox, "local.txt"), "local\n", "utf8");
|
|
await git(sandbox, ["add", "-A"]);
|
|
await git(sandbox, ["commit", "-m", "local-only"]);
|
|
const sandboxHead = await git(sandbox, ["rev-parse", "HEAD"]);
|
|
|
|
// Host holds only the initial commit; it lacks both baseSha and the fork point.
|
|
expect(await git(host, ["rev-parse", "HEAD"])).toBe(ancestor);
|
|
await expect(git(host, ["cat-file", "-e", `${forkPoint}^{commit}`])).rejects.toThrow();
|
|
|
|
const exportRef = createRemoteGitExportRef("test");
|
|
const importedRef = createImportedGitRef("test");
|
|
|
|
// The delta bundle (relative to the merge-base = fork point) names a
|
|
// prerequisite the host lacks, so its import fails and is detected.
|
|
const deltaBundle = path.join(rootDir, "delta.bundle");
|
|
await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
|
|
remoteDir: sandbox,
|
|
baseSha,
|
|
exportRef,
|
|
bundlePath: deltaBundle,
|
|
})]);
|
|
let deltaError: unknown;
|
|
try {
|
|
await fetchGitBundleIntoLocalRef({ localDir: host, bundlePath: deltaBundle, exportRef, importedRef, baseSha });
|
|
} catch (error) {
|
|
deltaError = error;
|
|
}
|
|
expect(deltaError).toBeDefined();
|
|
expect(isMissingGitPrerequisiteError(deltaError)).toBe(true);
|
|
|
|
// The forced full bundle is self-contained and imports into the same host.
|
|
const fullBundle = path.join(rootDir, "full.bundle");
|
|
try {
|
|
await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
|
|
remoteDir: sandbox,
|
|
baseSha,
|
|
exportRef,
|
|
bundlePath: fullBundle,
|
|
forceFullBundle: true,
|
|
})]);
|
|
const importedHead = await fetchGitBundleIntoLocalRef({
|
|
localDir: host,
|
|
bundlePath: fullBundle,
|
|
exportRef,
|
|
importedRef,
|
|
baseSha,
|
|
});
|
|
expect(importedHead).toBe(sandboxHead);
|
|
} finally {
|
|
await deleteLocalGitRef({ localDir: host, ref: importedRef });
|
|
}
|
|
});
|
|
|
|
it("falls back to a full self-contained bundle when the sandbox lacks baseSha", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-full-"));
|
|
cleanupDirs.push(rootDir);
|
|
const sandbox = await createRepo(rootDir);
|
|
await writeFile(path.join(sandbox, "more.txt"), "more\n", "utf8");
|
|
await git(sandbox, ["add", "-A"]);
|
|
await git(sandbox, ["commit", "-m", "more"]);
|
|
const sandboxHead = await git(sandbox, ["rev-parse", "HEAD"]);
|
|
|
|
// A fresh, unrelated host that shares no history with the sandbox.
|
|
const host = path.join(rootDir, "fresh-host");
|
|
await mkdir(host, { recursive: true });
|
|
await git(host, ["init"]);
|
|
|
|
const bundle = path.join(rootDir, "full.bundle");
|
|
const exportRef = createRemoteGitExportRef("test");
|
|
const importedRef = createImportedGitRef("test");
|
|
try {
|
|
await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({
|
|
remoteDir: sandbox,
|
|
// A base the sandbox does not have forces the full-bundle fallback.
|
|
baseSha: "0000000000000000000000000000000000000000",
|
|
exportRef,
|
|
bundlePath: bundle,
|
|
})]);
|
|
expect((await stat(bundle)).size).toBeGreaterThan(0);
|
|
|
|
const importedHead = await fetchGitBundleIntoLocalRef({
|
|
localDir: host,
|
|
bundlePath: bundle,
|
|
exportRef,
|
|
importedRef,
|
|
baseSha: "0000000000000000000000000000000000000000",
|
|
});
|
|
expect(importedHead).toBe(sandboxHead);
|
|
} finally {
|
|
await deleteLocalGitRef({ localDir: host, ref: importedRef });
|
|
}
|
|
});
|
|
|
|
it("creates the concurrent-history merge commit with a deterministic identity", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-merge-identity-"));
|
|
cleanupDirs.push(rootDir);
|
|
// No repo-local user.name/user.email on purpose: execution hosts are
|
|
// containers without git config, where commit-tree cannot auto-detect an
|
|
// identity. Setup commits pass their identity inline so only the merge
|
|
// commit under test depends on the sync-supplied identity.
|
|
const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
|
|
const repo = path.join(rootDir, "repo");
|
|
await mkdir(repo, { recursive: true });
|
|
await git(repo, ["init"]);
|
|
await git(repo, ["checkout", "-b", "main"]);
|
|
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
|
|
await git(repo, ["add", "tracked.txt"]);
|
|
await git(repo, [...setupIdentity, "commit", "-m", "base"]);
|
|
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
|
|
await writeFile(path.join(repo, "local.txt"), "local\n", "utf8");
|
|
await git(repo, ["add", "local.txt"]);
|
|
await git(repo, [...setupIdentity, "commit", "-m", "local advance"]);
|
|
const currentHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
|
|
await git(repo, ["checkout", "-b", "imported", baseHead]);
|
|
await writeFile(path.join(repo, "imported.txt"), "imported\n", "utf8");
|
|
await git(repo, ["add", "imported.txt"]);
|
|
await git(repo, [...setupIdentity, "commit", "-m", "sandbox change"]);
|
|
const importedHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
await git(repo, ["checkout", "main"]);
|
|
|
|
// Ambient identity env vars would override the `-c` flags and make the
|
|
// assertion machine-dependent, so clear them for the call under test.
|
|
const identityEnvKeys = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"];
|
|
const savedEnv = new Map(identityEnvKeys.map((key) => [key, process.env[key]]));
|
|
for (const key of identityEnvKeys) delete process.env[key];
|
|
try {
|
|
await integrateImportedGitHead({ localDir: repo, importedHead });
|
|
} finally {
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" ");
|
|
expect(parents.slice(1)).toEqual([currentHead, importedHead]);
|
|
expect(await git(repo, ["log", "-1", "--format=%an|%ae|%cn|%ce"]))
|
|
.toBe("Paperclip|noreply@paperclip.ing|Paperclip|noreply@paperclip.ing");
|
|
expect(await git(repo, ["log", "-1", "--format=%s"]))
|
|
.toBe(`Paperclip remote git sync merge ${importedHead.slice(0, 12)}`);
|
|
const mergedTree = await git(repo, ["ls-tree", "--name-only", "HEAD"]);
|
|
expect(mergedTree).toContain("local.txt");
|
|
expect(mergedTree).toContain("imported.txt");
|
|
});
|
|
|
|
it("grafts an imported head onto the current head when histories share no ancestor", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-graft-"));
|
|
cleanupDirs.push(rootDir);
|
|
const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
|
|
const repo = path.join(rootDir, "repo");
|
|
await mkdir(repo, { recursive: true });
|
|
await git(repo, ["init"]);
|
|
await git(repo, ["checkout", "-b", "main"]);
|
|
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
|
|
await git(repo, ["add", "tracked.txt"]);
|
|
await git(repo, [...setupIdentity, "commit", "-m", "base"]);
|
|
const baseHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
|
|
await writeFile(path.join(repo, "local.txt"), "local\n", "utf8");
|
|
await git(repo, ["add", "local.txt"]);
|
|
await git(repo, [...setupIdentity, "commit", "-m", "local advance"]);
|
|
const currentHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
|
|
// The shape a depth-1 shallow clone produces after `git commit --amend`:
|
|
// a parentless root commit that shares no ancestor with the host history.
|
|
const importedTree = await git(repo, ["rev-parse", `${baseHead}^{tree}`]);
|
|
const importedHead = await git(repo, [...setupIdentity, "commit-tree", importedTree, "-m", "sandbox rewrite"]);
|
|
|
|
await integrateImportedGitHead({ localDir: repo, importedHead });
|
|
|
|
const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" ");
|
|
expect(parents.slice(1)).toEqual([currentHead]);
|
|
// The imported tree is taken wholesale: no base exists to merge against.
|
|
expect(await git(repo, ["rev-parse", "HEAD^{tree}"])).toBe(importedTree);
|
|
expect(await git(repo, ["log", "-1", "--format=%s"])).toBe("sandbox rewrite");
|
|
const body = await git(repo, ["log", "-1", "--format=%B"]);
|
|
expect(body).toContain(`Paperclip remote git sync graft ${importedHead.slice(0, 12)}`);
|
|
expect(body).toContain("shares no ancestor");
|
|
});
|
|
|
|
it("does not graft when merge-base fails for a reason other than missing ancestry", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-graft-"));
|
|
cleanupDirs.push(rootDir);
|
|
const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"];
|
|
const repo = path.join(rootDir, "repo");
|
|
await mkdir(repo, { recursive: true });
|
|
await git(repo, ["init"]);
|
|
await git(repo, ["checkout", "-b", "main"]);
|
|
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
|
|
await git(repo, ["add", "tracked.txt"]);
|
|
await git(repo, [...setupIdentity, "commit", "-m", "base"]);
|
|
const currentHead = await git(repo, ["rev-parse", "HEAD"]);
|
|
|
|
// A well-formed sha the repository does not hold: merge-base fails with an
|
|
// object error (exit 128), not the no-ancestor signal (exit 1). The graft
|
|
// must not fire, and the integration keeps its loud failure.
|
|
const missingHead = "0123456789abcdef0123456789abcdef01234567";
|
|
await expect(integrateImportedGitHead({ localDir: repo, importedHead: missingHead }))
|
|
.rejects.toThrow(/Failed to merge concurrent remote git histories/);
|
|
expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead);
|
|
});
|
|
});
|
|
|
|
describe("sanitizeGitRemoteUrl", () => {
|
|
it("strips userinfo, query, and fragment from http(s) URLs", () => {
|
|
expect(sanitizeGitRemoteUrl("https://x-access-token:sekret@github.com/example/repo.git"))
|
|
.toBe("https://github.com/example/repo.git");
|
|
expect(sanitizeGitRemoteUrl("https://sekret-token@github.com/example/repo.git"))
|
|
.toBe("https://github.com/example/repo.git");
|
|
expect(sanitizeGitRemoteUrl("http://user:pass@git.internal/example/repo.git"))
|
|
.toBe("http://git.internal/example/repo.git");
|
|
expect(sanitizeGitRemoteUrl("https://github.com/example/repo.git?private_token=sekret#fragment"))
|
|
.toBe("https://github.com/example/repo.git");
|
|
});
|
|
|
|
it("strips password and query from ssh-scheme URLs but keeps the username", () => {
|
|
expect(sanitizeGitRemoteUrl("ssh://git@github.com/example/repo.git"))
|
|
.toBe("ssh://git@github.com/example/repo.git");
|
|
expect(sanitizeGitRemoteUrl("ssh://git:sekret@github.com/example/repo.git"))
|
|
.toBe("ssh://git@github.com/example/repo.git");
|
|
expect(sanitizeGitRemoteUrl("git+ssh://git@github.com/example/repo.git?key=sekret"))
|
|
.toBe("git+ssh://git@github.com/example/repo.git");
|
|
});
|
|
|
|
it("keeps credential-free scp-like remotes unchanged", () => {
|
|
expect(sanitizeGitRemoteUrl("git@github.com:example/repo.git"))
|
|
.toBe("git@github.com:example/repo.git");
|
|
expect(sanitizeGitRemoteUrl("https://github.com/example/repo.git"))
|
|
.toBe("https://github.com/example/repo.git");
|
|
});
|
|
|
|
it("drops every shape whose credential surface is unknown", () => {
|
|
// Filesystem paths are useless on the execution host and could leak
|
|
// host-layout details; unknown schemes and malformed userinfo could carry
|
|
// embedded secrets the sanitizer cannot recognize. All fail closed.
|
|
expect(sanitizeGitRemoteUrl("/tmp/local/upstream.git")).toBeNull();
|
|
expect(sanitizeGitRemoteUrl("ftp://user:pass@host/repo.git")).toBeNull();
|
|
expect(sanitizeGitRemoteUrl("user:pass@host:path/repo.git")).toBeNull();
|
|
expect(sanitizeGitRemoteUrl("host.example:path/repo.git")).toBeNull();
|
|
});
|
|
|
|
it("returns null for empty input", () => {
|
|
expect(sanitizeGitRemoteUrl("")).toBeNull();
|
|
expect(sanitizeGitRemoteUrl(" ")).toBeNull();
|
|
});
|
|
});
|