Files
PaperClipAI/packages/adapter-utils/src/git-workspace-sync.test.ts
T
DottaandPaperclip 6809314a3f fix: recover sandbox workspace setup and retries (#13353)
## Thinking Path

> - Paperclip manages AI agents and their tasks.
> - Sandbox tasks need a workspace and a provider session before work
can start.
> - A selected Git subfolder is a valid workspace, but it is not a
repository fetch source.
> - A resumed sandbox must keep one resource identity when the provider
fills in its region.
> - Workspace reuse does not prove that a provider session has started.
> - A recorded continuation must not leave an old failure blocking Retry
for a newer attempt.
> - This pull request fixes those setup and recovery boundaries while
preserving ownership checks.

## Linked Issues or Issue Description

**What happened?**

A Daytona task failed before provider startup when its project folder
was inside a parent Git repository. After the folder was repaired, Retry
resumed the same sandbox and verified its workspace sentinel, but
workspace preparation reported that the lease was no longer active. A
later attempt could fail because the resumed workspace had no provider
checkpoint. The old run also kept a recovery projection after the server
recorded its explicit successor, hiding Retry for the new failure.

**Expected behavior**

Sync the selected folder without importing parent files or history. Keep
the existing sandbox identity stable. Allow a new provider session only
with proof that its exact session has never started. Let the current
failed attempt retain Retry once the old recovery has a recorded
successor.

**Steps to reproduce**

1. Select a subfolder of a Git repository as a project workspace and
start a Daytona task.
2. Leave the target region unset. Release its reusable sandbox after
setup fails, then resume and realize the workspace.
3. Retry a provider setup that failed before any session directory or
checkpoint was created.
4. Record an explicit successor for a native failure, fail that
successor during setup, and inspect Retry in the task thread.

**Paperclip version or commit**

Reproduced against `8d1f0c20a` with new failing regressions before each
production fix.

**Deployment mode**

Local controller with a Daytona environment. Automated tests use
deterministic provider fixtures and real filesystem operations.

Related: #13338, #13163, #13264, #13349. The open work-folder stack in
#13264 includes a broader fresh-session authority change. This patch
addresses the independently reproduced startup failure with existing
durable bootstrap proof and atomic directory creation. It does not
include the work-folder migration or credential changes from that stack.

## What Changed

- Classify only the selected Git repository root as a fetch source. Sync
subfolders as directories and preserve their enclosing Git ignore rules
on upload and restore. Recognize the shared scheduler’s completed
non-repository result so ordinary folders still sync; timeout,
cancellation, and output-limit failures remain closed.
- Remove the placement target from Daytona account cache identity. Keep
API endpoint, credential digest, company, environment, driver, and
sandbox ID boundaries.
- Preserve closed-lease admission until a sentinel-verified resume
reopens that same resource.
- Show the already-recorded explicit successor of a resolved recovery.
Keep the old failure evidence and unresolved holds. No historical status
writes occur.
- Permit a resumed workspace to create a new session directory only with
matching durable identity, zero connections and events, untouched
bootstrap commands, no backup, and an absent remote session. Claim the
directory atomically. Existing, partial, or ambiguous state still blocks
startup.

## Verification

- Final head `c43c7403f`: [CI completed
successfully](https://github.com/paperclipai/paperclip/actions/runs/34733846820/attempts/2),
with 32 successful checks and 2 conditional skips. This includes every
server, UI, package, serialized-route, browser, native-runner,
typecheck, and build gate. Greptile reviewed the same head at [5/5 with
no unresolved
findings](https://github.com/paperclipai/paperclip/pull/13353#issuecomment-5650270168).
- New regressions failed before each of the four production fixes.
Git/archive/restore suites: 148 passed. The scheduler-wrapped non-Git
regression also failed before its fix; 135 affected Git/sync/Codex tests
then passed.
- Daytona plugin: 230 passed, 6 opt-in live tests skipped. Native
executor, projection, and TaskChatThread: 494 passed, including
existing/partial state, wrong identity, prior connections or turns,
backups, unavailable proof, and unresolved recovery controls.
- Local full-repository typecheck, build, and token gates passed on the
final head. Local CLI: 485 passed. Complete single-worker package rerun:
3,224 passed, 19 skipped.
- Local verification is an aggregate with recorded retries, not one
pristine green invocation: the general-server run began on `e721a920a`
and finished with 12,002 passed, 3 failed, 70 skipped. Its real Codex
scheduler failure is fixed above; the socket and workspace-runtime
timeout failures passed unchanged in focused reruns. Both Inbox failures
passed unchanged in the full 27-test Inbox file; database/shared-package
failures passed in the single-worker package rerun. The supplemental
local serialized-route rerun remains in progress; all five corresponding
final-head CI lanes passed.
- The first final-head CI attempt hit a Daytona fixture-readiness race
and a signoff-browser heartbeat receipt timeout. One supported unchanged
failed-job rerun passed both and the aggregate gates. Live combined
user-journey verification is tracked in the related follow-up; this PR's
provider tests use deterministic fixtures and real filesystem checks.

## Risks

- A selected subfolder uses directory sync and does not carry parent Git
history. Its ignored files stay local.
- The target region remains a creation setting and part of workspace
reuse policy; it does not split the account identity of an existing
sandbox.
- Incomplete or conflicting provider state still fails closed. This
change does not erase a session, infer completed work, bypass a user
decision, or replay uncertain actions.
- A later remote setup failure can leave a claimed partial session
directory. It remains blocked rather than being overwritten.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The exact hosted model ID and context-window size are not
exposed in this task.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-12 22:19:42 -05:00

916 lines
43 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,
ReferencedSourceIgnoreScanLimitExceededError,
readReferencedSourceGitIgnoredPaths,
REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT,
REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES,
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",
]);
});
it("keeps every filename byte for a padded name in each of the four anchor lanes", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-anchor-whitespace-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// Deleted lane: commit the file first (in isolation, before anything else
// is staged), then remove it from the work tree.
const deletedName = " deleted padded ";
await writeFile(path.join(repo, deletedName), "deleted\n", "utf8");
await git(repo, ["add", deletedName]);
await git(repo, ["commit", "-qm", "add deleted padded"]);
await rm(path.join(repo, deletedName));
// Overlay lane, staged-new half: `git diff --diff-filter=ACMRTUXB HEAD`
// reports a staged-but-uncommitted file as added.
const overlayName = " overlay padded ";
await writeFile(path.join(repo, overlayName), "overlay\n", "utf8");
await git(repo, ["add", overlayName]);
// Overlay lane, untracked half: `ls-files --others --exclude-standard`.
const untrackedName = " untracked padded ";
await writeFile(path.join(repo, untrackedName), "untracked\n", "utf8");
// Ignored lane: a double-wildcard pattern avoids the separate rule that
// Git trims an unescaped trailing space in a .gitignore PATTERN itself;
// the padding under test lives in the matched FILE name.
const ignoredName = " ignored padded ";
await writeFile(path.join(repo, ".gitignore"), "*ignored*padded*\n", "utf8");
await writeFile(path.join(repo, ignoredName), "ignored\n", "utf8");
const snapshot = await readGitWorkspaceSnapshot(repo);
expect(snapshot?.overlayPaths).toContain(overlayName);
expect(snapshot?.overlayPaths).toContain(untrackedName);
expect(snapshot?.deletedPaths).toContain(deletedName);
expect(snapshot?.ignoredPaths).toContain(ignoredName);
});
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("does not classify a selected repository subfolder as a cloneable repository root", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-selected-folder-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
const selectedDir = path.join(repo, "project");
await mkdir(selectedDir);
await writeFile(path.join(selectedDir, "draft.md"), "selected work\n");
expect(await git(selectedDir, ["rev-parse", "--is-inside-work-tree"])).toBe("true");
expect(await readGitWorkspaceSnapshot(selectedDir)).toBeNull();
expect((await readGitWorkspaceSnapshot(repo))?.headCommit).toBe(await git(repo, ["rev-parse", "HEAD"]));
});
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("readReferencedSourceGitIgnoredPaths", () => {
it("returns null for a directory that is not a Git work tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-nogit-"));
cleanupDirs.push(rootDir);
const plainDir = path.join(rootDir, "plain");
await mkdir(plainDir, { recursive: true });
await writeFile(path.join(plainDir, "file.txt"), "body\n", "utf8");
await expect(readReferencedSourceGitIgnoredPaths(plainDir)).resolves.toBeNull();
});
it.each([
{ code: "workspace_git_scan_failed", exitCode: 128, signal: null, nonGit: true },
{ code: "workspace_git_scan_timeout", exitCode: 128, signal: null, nonGit: false },
{ code: "workspace_git_scan_cancelled", exitCode: 128, signal: null, nonGit: false },
{ code: "workspace_git_scan_output_limit", exitCode: 128, signal: null, nonGit: false },
{ code: "workspace_git_scan_failed", exitCode: null, signal: "SIGTERM", nonGit: false },
])("classifies scheduled non-repository failures without swallowing $code/$signal", async ({ code, exitCode, signal, nonGit }) => {
const error = Object.assign(new Error("Workspace Git scan failed"), {
code,
details: { exitCode, signal, stderr: "fatal: not a git repository (or any of the parent directories): .git" },
});
setExpensiveWorkspaceGitExecutor(async () => { throw error; });
try {
const result = readReferencedSourceGitIgnoredPaths("/plain-workspace");
if (nonGit) await expect(result).resolves.toBeNull();
else await expect(result).rejects.toBe(error);
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
});
it("reads the repository top level and the ignored paths of a Git work tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-git-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
await writeFile(path.join(repo, ".gitignore"), "secret.env\nbuild/\n", "utf8");
await writeFile(path.join(repo, "secret.env"), "TOKEN=abc\n", "utf8");
await mkdir(path.join(repo, "build"), { recursive: true });
await writeFile(path.join(repo, "build", "out.js"), "artifact\n", "utf8");
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.toplevel).toBe(await git(repo, ["rev-parse", "--show-toplevel"]));
expect(scan?.ignoredPaths).toEqual(["build", "secret.env"]);
});
it("preserves trailing whitespace in an ignored path entry", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-trailing-ws-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// A wildcard pattern avoids the separate rule that git trims an
// unescaped trailing space in a .gitignore pattern itself; the trailing
// space under test lives in the matched FILE name, not the pattern.
const paddedName = "secret.env ";
await writeFile(path.join(repo, ".gitignore"), "secret.env*\n", "utf8");
await writeFile(path.join(repo, paddedName), "TOKEN=abc\n", "utf8");
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.ignoredPaths).toEqual([paddedName]);
});
it("fails closed when the parsed ignored-entry count exceeds the bound", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-count-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// Synthesize the `git ls-files --others --ignored -z` output directly,
// rather than creating ten thousand real files, by intercepting the
// scan at the executor seam. The parser must reject this before it
// sorts or re-relativizes the list.
const overLimitCount = REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT + 1;
const syntheticIgnored = `${Array.from({ length: overLimitCount }, (_, index) => `entry-${index}`).join("\0")}\0`;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toBeInstanceOf(
ReferencedSourceIgnoreScanLimitExceededError,
);
});
it("fails closed when the summed UTF-8 byte size of ignored paths exceeds the bound", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-bytes-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// One entry alone exceeds the byte bound, well under the entry-count bound.
const hugeEntry = "a".repeat(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES + 1);
const syntheticIgnored = `${hugeEntry}\0`;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toBeInstanceOf(
ReferencedSourceIgnoreScanLimitExceededError,
);
});
it("fails closed on the byte bound while it is still accumulating, before it would ever reach a later entry-count breach", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-order-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
// Three entries alone cross the byte bound. Many more small entries
// follow, so the FULL response also carries more than the entry-count
// bound. A parser that fully builds the list before checking either
// bound (post-parse) would report the entry-count breach, because it
// checks that bound first against the whole materialized list. A
// parser that checks both bounds while the list accumulates rejects on
// the byte bound instead, the moment the third entry crosses it, well
// before the count bound is ever reached.
const oversizedEntry = "a".repeat(Math.ceil(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES / 2) + 1);
const bigEntries = Array.from({ length: 3 }, (_, index) => `${oversizedEntry}-${index}`);
const trailingEntries = Array.from(
{ length: REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT + 10 },
(_, index) => `trailing-${index}`,
);
const syntheticIgnored = `${[...bigEntries, ...trailingEntries].join("\0")}\0`;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
return { stdout: syntheticIgnored, stderr: "" };
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toThrow(/UTF-8 bytes/);
});
it("bounds the raw command-output allowance to the ignore-scan limits, not the general-purpose full-tree ceiling", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-raw-buffer-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
let observedMaxBuffer: number | undefined;
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.ignored_files") {
observedMaxBuffer = input.maxBuffer;
}
return await runLocalGit(input.localDir, [...input.args], {
timeout: input.timeout,
maxBuffer: input.maxBuffer,
env: input.env,
});
});
await readReferencedSourceGitIgnoredPaths(repo);
// Enough headroom for a scan within bounds to complete, but a small
// multiple of the byte bound — not the far larger allowance the
// anchor workspace's general-purpose full-tree reads use.
expect(observedMaxBuffer).toBeGreaterThan(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES);
expect(observedMaxBuffer).toBeLessThan(16 * 1024 * 1024);
});
it("does not fail closed on a huge amount of unrelated tracked-change and untracked noise, when the ignored set itself stays in bounds", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-mixed-status-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
await writeFile(path.join(repo, ".gitignore"), "secret.env\n", "utf8");
await writeFile(path.join(repo, "secret.env"), "TOKEN=abc\n", "utf8");
// Many long-named, untracked, NOT-ignored files at the repository root.
// `git status` reports one record per file (root-level files are never
// collapsed the way an entirely untracked directory is), so this alone
// makes the raw `git status --ignored` response exceed the raw buffer
// bound this scan used to apply to the WHOLE response, well before the
// parser ever got to discard these non-ignored records. The ignored set
// above stays a single small entry throughout.
const noiseNameLength = 220;
const noiseFileCount = 30_000;
const noiseNames = Array.from(
{ length: noiseFileCount },
(_, index) => `${"n".repeat(noiseNameLength - 6)}${String(index).padStart(6, "0")}`,
);
const writeConcurrency = 200;
for (let start = 0; start < noiseNames.length; start += writeConcurrency) {
const batch = noiseNames.slice(start, start + writeConcurrency);
await Promise.all(batch.map((name) => writeFile(path.join(repo, name), "", "utf8")));
}
// Confirm this test actually reproduces the reported defect precondition:
// the raw `git status --ignored` response for this repository state is
// larger than the 4 MiB raw buffer bound the scan used to apply to the
// whole response, not just to the declared ignored-set limits. A large
// explicit maxBuffer is required here only to observe that raw size;
// the scan under test never issues this command.
const rawStatusResult = await runLocalGit(
repo,
["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"],
{ maxBuffer: 16 * 1024 * 1024 },
);
expect(Buffer.byteLength(rawStatusResult.stdout, "utf8")).toBeGreaterThan(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES * 2);
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.ignoredPaths).toEqual(["secret.env"]);
});
it("routes both scan commands through the registered scheduler instead of spawning git directly", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-scheduler-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
await writeFile(path.join(repo, ".gitignore"), "build/\n", "utf8");
await mkdir(path.join(repo, "build"), { recursive: true });
await writeFile(path.join(repo, "build", "out.js"), "artifact\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,
env: input.env,
});
});
const scan = await readReferencedSourceGitIgnoredPaths(repo);
expect(scan?.ignoredPaths).toEqual(["build"]);
// Both the toplevel probe and the ignored-paths read go through the SAME
// process-wide admission seam the anchor workspace's expensive reads
// use. A host process that bounds concurrent scans there also bounds
// referenced-project scans, so a run with many referenced projects
// cannot spawn one unbounded Git process per project.
expect(operations.sort()).toEqual(["referenced_source.ignored_files", "referenced_source.toplevel"]);
});
it("carries the hardened arguments and does not inherit a poisoned GIT_CONFIG_GLOBAL", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-hardened-env-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
const badGlobalConfig = path.join(rootDir, "bad-global-gitconfig");
await writeFile(badGlobalConfig, "this is not valid git config syntax [[[\n", "utf8");
const priorGlobal = process.env.GIT_CONFIG_GLOBAL;
process.env.GIT_CONFIG_GLOBAL = badGlobalConfig;
try {
// A plain invocation inherits the poisoned global config and fails to parse it.
await expect(execFile("git", ["-C", repo, "status", "--porcelain"])).rejects.toThrow();
// The hardened helper does not inherit GIT_CONFIG_GLOBAL from this process's
// environment, so it succeeds regardless.
await expect(readReferencedSourceGitIgnoredPaths(repo)).resolves.toMatchObject({ ignoredPaths: [] });
} finally {
if (priorGlobal === undefined) delete process.env.GIT_CONFIG_GLOBAL;
else process.env.GIT_CONFIG_GLOBAL = priorGlobal;
}
});
it("neutralizes a repository-local core.fsmonitor hook", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-fsmonitor-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
const markerPath = path.join(rootDir, "pwned.txt");
// A malicious repository-local config: a non-boolean `core.fsmonitor` value
// is a hook COMMAND Git runs on every status-like read. `--no-optional-locks`
// alone does not stop this; only the command-line `-c core.fsmonitor=false`
// override does, because command-line config wins over repository config.
await git(repo, ["config", "core.fsmonitor", `sh -c 'touch ${markerPath}; printf 1'`]);
await readReferencedSourceGitIgnoredPaths(repo);
await expect(stat(markerPath)).rejects.toThrow();
});
});
});
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();
});
});