mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Separate task organization from filesystem ownership. Add channel defaults, workspace and repository tools, safe admission transitions, and sparse native workspace persistence. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1104 lines
47 KiB
TypeScript
1104 lines
47 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";
|
|
import { createWorkspaceManifest, workspacePaths, WorkspaceNulParser, type WorkspacePaths } from "./workspace-manifest.js";
|
|
import { runWorkspaceGitProcess } from "./workspace-git-stream.js";
|
|
import { preserveWorkspaceRestoreErrorDiagnostic, withWorkspaceRestoreGitCommand, type WorkspaceRestoreGitCommand } from "./workspace-restore-diagnostics.js";
|
|
|
|
export interface GitCommandResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface GitWorkspaceSnapshot {
|
|
headCommit: string;
|
|
branchName: string | null;
|
|
/** Selected remote captured with the Git generation; omitted on legacy descriptors. */
|
|
originUrl?: string | null;
|
|
overlayPaths: WorkspacePaths;
|
|
deletedPaths: WorkspacePaths;
|
|
ignoredPaths: WorkspacePaths;
|
|
/** Managed, editable repositories inside the task workspace. */
|
|
repositories?: Array<{ path: string; snapshot: GitWorkspaceSnapshot }>;
|
|
}
|
|
|
|
export const PROJECT_REPOSITORIES_DIR = ".paperclip-repositories";
|
|
|
|
export interface ExpensiveWorkspaceGitInput {
|
|
localDir: string;
|
|
args: readonly string[];
|
|
operation: string;
|
|
timeout: number;
|
|
maxBuffer: number;
|
|
onStdout?: (chunk: Buffer) => Promise<void> | void;
|
|
signal?: AbortSignal;
|
|
/**
|
|
* Optional environment override for the invocation. Absent for the anchor
|
|
* workspace's own full-tree walks (they inherit the process environment, a
|
|
* directory this process already controls). A referenced-project scan sets
|
|
* this to its hardened environment (see {@link buildHardenedGitEnv}), so a
|
|
* host executor that honors it still runs the read hardened even though it
|
|
* dispatches through the same seam as the anchor's reads.
|
|
*/
|
|
env?: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
export type ExpensiveWorkspaceGitExecutor = (
|
|
input: ExpensiveWorkspaceGitInput,
|
|
) => Promise<GitCommandResult>;
|
|
|
|
let expensiveWorkspaceGitExecutor: ExpensiveWorkspaceGitExecutor | null = null;
|
|
|
|
/**
|
|
* The workspace Git scan scheduler's typed code for a saturated queue
|
|
* (`server/src/services/workspace-git-operation-scheduler.ts`,
|
|
* `WORKSPACE_GIT_SCAN_ERROR_CODES.saturated`). Declared again here because
|
|
* `adapter-utils` cannot import from `server` (the reverse direction is
|
|
* allowed, not this one); `server` carries a test that asserts the two
|
|
* literals stay equal. `resolveReferencedSourceIgnore` in
|
|
* `sandbox-managed-runtime.ts` reads this code off a caught error's `code`
|
|
* property, never off its message text, to retry only a saturated queue and
|
|
* fail closed on every other Git scan error.
|
|
*/
|
|
export const WORKSPACE_GIT_SCAN_SATURATED_CODE = "workspace_git_scan_saturated";
|
|
|
|
/**
|
|
* Lets a host process apply its process-wide admission policy to the adapter
|
|
* package's full-tree Git walks. Standalone adapter-utils consumers retain the
|
|
* streaming process fallback for filename scans.
|
|
*/
|
|
export function setExpensiveWorkspaceGitExecutor(executor: ExpensiveWorkspaceGitExecutor | null): void {
|
|
expensiveWorkspaceGitExecutor = executor;
|
|
}
|
|
|
|
export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const;
|
|
|
|
/**
|
|
* Identity flags for commits the sync machinery itself creates (the merge
|
|
* commits that reconcile concurrent histories). Execution hosts are often
|
|
* containers with no git config and no resolvable hostname, so git cannot
|
|
* auto-detect an identity there and `commit-tree` hard-fails with "Author
|
|
* identity unknown" — which fails the whole run at finalize. Passing the
|
|
* identity per invocation keeps every deployment working without host
|
|
* configuration; `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables
|
|
* still take precedence over `-c` when an operator sets them.
|
|
*/
|
|
export const GIT_SYNC_COMMIT_IDENTITY_ARGS = [
|
|
"-c",
|
|
"user.name=Paperclip",
|
|
"-c",
|
|
"user.email=noreply@paperclip.ing",
|
|
] as const;
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
}
|
|
|
|
export async function runLocalGit(
|
|
localDir: string,
|
|
args: string[],
|
|
options: {
|
|
timeout?: number;
|
|
maxBuffer?: number;
|
|
env?: NodeJS.ProcessEnv;
|
|
} = {},
|
|
): Promise<GitCommandResult> {
|
|
return await new Promise<GitCommandResult>((resolve, reject) => {
|
|
execFile(
|
|
"git",
|
|
["-C", localDir, ...args],
|
|
{
|
|
timeout: options.timeout ?? 15_000,
|
|
maxBuffer: options.maxBuffer ?? 1024 * 128,
|
|
env: options.env ?? process.env,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" }));
|
|
return;
|
|
}
|
|
resolve({
|
|
stdout: stdout ?? "",
|
|
stderr: stderr ?? "",
|
|
});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async function runExpensiveWorkspaceGit(
|
|
localDir: string,
|
|
args: string[],
|
|
operation: string,
|
|
options: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv; onStdout?: ExpensiveWorkspaceGitInput["onStdout"]; signal?: AbortSignal },
|
|
): Promise<GitCommandResult> {
|
|
if (expensiveWorkspaceGitExecutor) {
|
|
return await expensiveWorkspaceGitExecutor({
|
|
localDir,
|
|
args,
|
|
operation,
|
|
timeout: options.timeout,
|
|
maxBuffer: options.maxBuffer,
|
|
env: options.env,
|
|
onStdout: options.onStdout,
|
|
signal: options.signal,
|
|
});
|
|
}
|
|
if (options.onStdout) return runWorkspaceGitProcess({
|
|
cwd: localDir, args, timeoutMs: options.timeout, maxStdoutBytes: options.maxBuffer,
|
|
maxStderrBytes: options.maxBuffer, onStdout: options.onStdout, signal: options.signal, env: options.env,
|
|
});
|
|
return await runLocalGit(localDir, args, options);
|
|
}
|
|
|
|
const ownedSnapshots = new WeakMap<GitWorkspaceSnapshot, string>();
|
|
|
|
export async function disposeGitWorkspaceSnapshot(snapshot: GitWorkspaceSnapshot | null): Promise<void> {
|
|
if (!snapshot) return;
|
|
const ownedDirectory = ownedSnapshots.get(snapshot);
|
|
if (!ownedDirectory) return;
|
|
ownedSnapshots.delete(snapshot);
|
|
for (const repository of snapshot.repositories ?? []) await disposeGitWorkspaceSnapshot(repository.snapshot);
|
|
await fs.rm(ownedDirectory, { recursive: true, force: true });
|
|
}
|
|
|
|
/** Snapshot deadlines include disk backpressure. Operators can allow up to 24h. */
|
|
export function workspaceSnapshotTimeoutMs(): number {
|
|
const configured = Number(process.env.PAPERCLIP_WORKSPACE_GIT_SNAPSHOT_TIMEOUT_MS);
|
|
return Number.isFinite(configured) && configured >= 1000 ? Math.min(configured, 86_400_000) : 30 * 60_000;
|
|
}
|
|
|
|
export async function readManagedWorkspaceRepositories(localDir: string, options: { signal?: AbortSignal } = {}): Promise<NonNullable<GitWorkspaceSnapshot["repositories"]>> {
|
|
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
|
|
try {
|
|
|
|
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
|
|
const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
if (rootStat) {
|
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid project repositories directory");
|
|
for await (const entry of await fs.opendir(root)) {
|
|
if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error("Invalid project repository directory");
|
|
const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
|
|
const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false, options);
|
|
if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`);
|
|
repositories.push({ path: relative, snapshot });
|
|
}
|
|
}
|
|
|
|
return repositories;
|
|
} catch (error) {
|
|
for (const repo of repositories) await disposeGitWorkspaceSnapshot(repo.snapshot);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function readGitWorkspaceSnapshot(localDir: string, includeRepositories = true, options: { signal?: AbortSignal } = {}): Promise<GitWorkspaceSnapshot | null> {
|
|
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
|
|
// Only repository discovery may report an ordinary directory. A failed
|
|
// snapshot of a confirmed repository must never fall back to directory sync.
|
|
let insideWorkTree: GitCommandResult;
|
|
try {
|
|
insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
} catch (error) {
|
|
if (repositories.length === 0 && isNotAGitRepositoryError(error)) return null;
|
|
throw error;
|
|
}
|
|
if (insideWorkTree.stdout.trim() !== "true") {
|
|
return null;
|
|
}
|
|
|
|
const toplevelResult = await runLocalGit(localDir, ["rev-parse", "--show-toplevel"], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
});
|
|
// Git discovers a parent repository from a nested project directory, but
|
|
// that directory is not a fetch source. Keep the selected workspace
|
|
// boundary: subfolders use directory sync instead of importing the parent.
|
|
const [workspacePath, repositoryPath] = await Promise.all([
|
|
fs.realpath(localDir),
|
|
fs.realpath(toplevelResult.stdout.trim()),
|
|
]);
|
|
if (workspacePath !== repositoryPath) return null;
|
|
|
|
const writer = await createWorkspaceManifest();
|
|
const controller = new AbortController();
|
|
const abort = () => controller.abort();
|
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
if (options.signal?.aborted) abort();
|
|
try {
|
|
if (includeRepositories) repositories.push(...await readManagedWorkspaceRepositories(localDir, options));
|
|
|
|
const scan = async (args: string[], operation: string, category: string) => {
|
|
const parser = new WorkspaceNulParser((record) => {
|
|
const relative = category === "ignored" ? record.replace(/\/+$/, "") : record;
|
|
if (category === "ignored" && repositories.length && relative === PROJECT_REPOSITORIES_DIR) return;
|
|
// Managed children have their own exact file selections. Never stage a
|
|
// parent Git directory record that could admit files created later.
|
|
if (repositories.some((repo) => relative === repo.path || relative.startsWith(`${repo.path}/`))) return;
|
|
writer.add(category, relative);
|
|
});
|
|
const result = await runExpensiveWorkspaceGit(localDir, args, operation, {
|
|
timeout: workspaceSnapshotTimeoutMs(), maxBuffer: 64 * 1024,
|
|
signal: controller.signal,
|
|
onStdout: (chunk) => writer.batch(() => parser.write(chunk)),
|
|
});
|
|
if (result.stdout) throw new Error("Workspace Git executor did not stream stdout");
|
|
try { parser.finish(); }
|
|
catch (error) { throw Object.assign(error as Error, { code: "workspace_git_scan_failed" }); }
|
|
};
|
|
// Cancellation is followed by an all-settled barrier BEFORE storage cleanup.
|
|
const tasks = [
|
|
runLocalGit(localDir, ["rev-parse", "HEAD"], { timeout: 10_000, maxBuffer: 16 * 1024 }),
|
|
runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: 10_000, maxBuffer: 16 * 1024 }),
|
|
scan(["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], "adapter_sync.overlay_diff", "overlay"),
|
|
scan(["ls-files", "--others", "--exclude-standard", "-z"], "adapter_sync.untracked_files", "overlay"),
|
|
scan(["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], "adapter_sync.deleted_files", "deleted"),
|
|
scan(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], "adapter_sync.ignored_files", "ignored"),
|
|
];
|
|
let firstError: unknown;
|
|
const settled = await Promise.allSettled(tasks.map((task) => task.catch((error) => {
|
|
if (firstError === undefined) firstError = error;
|
|
controller.abort();
|
|
throw error;
|
|
})));
|
|
if (firstError !== undefined) throw firstError;
|
|
if (controller.signal.aborted) throw Object.assign(new Error("Workspace snapshot cancelled"), { code: "workspace_git_scan_cancelled" });
|
|
for (const repo of repositories) {
|
|
for (const [category, paths] of [["overlay", repo.snapshot.overlayPaths], ["deleted", repo.snapshot.deletedPaths], ["ignored", repo.snapshot.ignoredPaths]] as const) {
|
|
for (const relative of workspacePaths(paths)) writer.add(category, `${repo.path}/${relative}`);
|
|
}
|
|
}
|
|
const head = (settled[0] as PromiseFulfilledResult<GitCommandResult>).value.stdout.trim();
|
|
const branch = (settled[1] as PromiseFulfilledResult<GitCommandResult>).value.stdout.trim();
|
|
const snapshot: GitWorkspaceSnapshot = {
|
|
headCommit: head, branchName: branch && branch !== "HEAD" ? branch : null,
|
|
originUrl: await readSanitizedOriginRemoteUrl(localDir),
|
|
overlayPaths: writer.paths("overlay"), deletedPaths: writer.paths("deleted"), ignoredPaths: writer.paths("ignored"),
|
|
...(repositories.length ? { repositories } : {}),
|
|
};
|
|
writer.close();
|
|
ownedSnapshots.set(snapshot, path.dirname(writer.filePath));
|
|
return snapshot;
|
|
} catch (error) {
|
|
writer.close(false);
|
|
for (const repo of repositories) await disposeGitWorkspaceSnapshot(repo.snapshot);
|
|
await fs.rm(path.dirname(writer.filePath), { recursive: true, force: true });
|
|
throw error;
|
|
} finally { options.signal?.removeEventListener("abort", abort); }
|
|
}
|
|
|
|
/** The `git ls-files --others --ignored` output for one directory, read by {@link readReferencedSourceGitIgnoredPaths}. */
|
|
export interface ReferencedSourceGitIgnoreScan {
|
|
/** The absolute repository top level `git rev-parse --show-toplevel` reports. */
|
|
toplevel: string;
|
|
/** Ignored paths, relative to `toplevel`, trailing slashes stripped, sorted. */
|
|
ignoredPaths: string[];
|
|
}
|
|
|
|
/**
|
|
* Build the environment for a hardened, read-only Git invocation against a
|
|
* directory this process does not control (a referenced project, not the
|
|
* anchor workspace). Two protections apply:
|
|
*
|
|
* - Drop every inherited `GIT_*` variable, so an already-set override in this
|
|
* process's own environment cannot change how the read-only command runs.
|
|
* - Point the global config file at `/dev/null` (in addition to the
|
|
* command-line `GIT_CONFIG_NOSYSTEM=1` the caller sets), so neither this
|
|
* host's global nor system Git configuration can add a setting the
|
|
* read-only command was not built to expect.
|
|
*
|
|
* This does not defend against the directory's OWN repository-local
|
|
* configuration; the command-line `-c core.fsmonitor=false` override in
|
|
* {@link runHardenedReadOnlyGit} does that instead, because command-line
|
|
* config always wins over repository-local config.
|
|
*/
|
|
function buildHardenedGitEnv(): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (key.startsWith("GIT_") || value === undefined) continue;
|
|
env[key] = value;
|
|
}
|
|
env.GIT_CONFIG_NOSYSTEM = "1";
|
|
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Run a read-only Git command against a directory this process does not
|
|
* control, hardened against a hostile repository-local configuration, and
|
|
* dispatched through {@link runExpensiveWorkspaceGit} — the SAME process-wide
|
|
* admission seam the anchor workspace's expensive full-tree reads use. A host
|
|
* process that registers a bounded scheduler there (see
|
|
* `setExpensiveWorkspaceGitExecutor`) governs referenced-project scans too, so
|
|
* a run with many referenced projects cannot spawn one unbounded Git process
|
|
* per project; each request queues behind the same concurrency limit.
|
|
*
|
|
* Every call still carries `--no-optional-locks` (never blocks on, or is
|
|
* blocked by, a concurrent Git process in the directory) and
|
|
* `-c core.fsmonitor=false` (neutralizes a repository-local `core.fsmonitor`
|
|
* setting that would otherwise run an arbitrary configured program on this
|
|
* read). See {@link buildHardenedGitEnv} for the paired environment hardening,
|
|
* carried through the executor's optional `env` field so hardening survives
|
|
* the hop through a host-registered scheduler.
|
|
*/
|
|
async function runHardenedReadOnlyGit(
|
|
localDir: string,
|
|
args: string[],
|
|
operation: string,
|
|
options: { timeout: number; maxBuffer: number },
|
|
): Promise<GitCommandResult> {
|
|
return await runExpensiveWorkspaceGit(
|
|
localDir,
|
|
["-c", "core.fsmonitor=false", "--no-optional-locks", ...args],
|
|
operation,
|
|
{ timeout: options.timeout, maxBuffer: options.maxBuffer, env: buildHardenedGitEnv() },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* True when a failed `git` invocation failed specifically because `localDir`
|
|
* is not inside a Git work tree — Git's own "not a git repository" fatal
|
|
* error. Distinguishes the expected non-Git case from a real failure (a
|
|
* timeout, a permissions error, a corrupt repository), which must still
|
|
* surface as a failure and never look like "no Git tree here".
|
|
*/
|
|
function isNotAGitRepositoryError(error: unknown): boolean {
|
|
// The host scheduler keeps bounded subprocess diagnostics under details.
|
|
// Only a completed Git exit may establish that no repository exists.
|
|
if (error && typeof error === "object" && "code" in error &&
|
|
typeof error.code === "string" && error.code.startsWith("workspace_git_scan_")) {
|
|
const details = "details" in error && error.details && typeof error.details === "object"
|
|
? error.details as Record<string, unknown> : {};
|
|
return error.code === "workspace_git_scan_failed" && details.exitCode === 128 && details.signal === null &&
|
|
typeof details.stderr === "string" && /not a git repository/i.test(details.stderr);
|
|
}
|
|
const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr: unknown }).stderr) : "";
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return /not a git repository/i.test(stderr) || /not a git repository/i.test(message);
|
|
}
|
|
|
|
/** Bound on the number of parsed ignored entries `readReferencedSourceGitIgnoredPaths` accepts before it fails closed. */
|
|
export const REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT = 10_000;
|
|
|
|
/** Bound on the summed UTF-8 byte length of the resolved ignored-path strings `readReferencedSourceGitIgnoredPaths` accepts before it fails closed. */
|
|
export const REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
|
|
/**
|
|
* Bound on the raw `git ls-files --others --ignored` output
|
|
* `readReferencedSourceGitIgnoredPaths` lets Node buffer, kept proportionate
|
|
* to {@link REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES} instead of the far
|
|
* larger allowance the anchor workspace's general-purpose full-tree reads
|
|
* use. The command reports only ignored entries (see the invocation below),
|
|
* so this raw allowance is not exposed to an unrelated tracked-change or
|
|
* ordinary-untracked record count — a repository with a huge diff or a huge
|
|
* untracked set never grows this command's output. The parser below still
|
|
* enforces the real entry-count and byte bounds while it reads each record,
|
|
* so this value only needs headroom for the NUL delimiter and the trailing
|
|
* slash on every entry, not room for an oversized ignored-path list to land
|
|
* in memory in the first place.
|
|
*/
|
|
const REFERENCED_SOURCE_IGNORE_MAX_RAW_BUFFER = REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES * 2;
|
|
|
|
/**
|
|
* Thrown by {@link readReferencedSourceGitIgnoredPaths} when the parsed
|
|
* ignored-path list breaches {@link REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT}
|
|
* or {@link REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES}, so the caller can
|
|
* classify the failure as a bound breach instead of a plain Git read error.
|
|
* The message never leaves this package: `resolveReferencedSourceIgnore`
|
|
* replaces it with a fixed category before the failure reaches any consumer.
|
|
*/
|
|
export class ReferencedSourceIgnoreScanLimitExceededError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "ReferencedSourceIgnoreScanLimitExceededError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the Git-ignored paths of a referenced-project host directory, for the
|
|
* staging path to exclude them (see `resolveReferencedSourceIgnore` in
|
|
* `sandbox-managed-runtime.ts`). Every command runs through
|
|
* {@link runHardenedReadOnlyGit}, because the directory is a host checkout the
|
|
* staging code does not control, unlike the anchor workspace.
|
|
*
|
|
* Returns `null` when `localDir` is not a Git work tree — the caller keeps
|
|
* today's fixed excludes for that case. Throws on any other Git error, a
|
|
* timeout, malformed output, or a bound breach (see
|
|
* {@link ReferencedSourceIgnoreScanLimitExceededError}), so the caller can
|
|
* fail closed and skip staging that one project instead of shipping it
|
|
* unfiltered.
|
|
*/
|
|
export async function readReferencedSourceGitIgnoredPaths(
|
|
localDir: string,
|
|
): Promise<ReferencedSourceGitIgnoreScan | null> {
|
|
let toplevel: string;
|
|
try {
|
|
const toplevelResult = await runHardenedReadOnlyGit(
|
|
localDir,
|
|
["rev-parse", "--show-toplevel"],
|
|
"referenced_source.toplevel",
|
|
{ timeout: 15_000, maxBuffer: 64 * 1024 },
|
|
);
|
|
toplevel = toplevelResult.stdout.trim();
|
|
} catch (error) {
|
|
if (isNotAGitRepositoryError(error)) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
if (!toplevel) {
|
|
throw new Error(`git rev-parse --show-toplevel returned an empty path for ${localDir}`);
|
|
}
|
|
|
|
// `ls-files --others --ignored --exclude-standard` reports only ignored
|
|
// entries — unlike `git status --ignored`, it never also reports a tracked
|
|
// change or an ordinary untracked file. A repository with a huge diff or a
|
|
// huge untracked set (unrelated to what is ignored) cannot inflate this
|
|
// command's raw output, so the raw buffer bound below only ever has to
|
|
// cover the declared ignored-set limits, not an unbounded amount of
|
|
// unrelated status noise ahead of them.
|
|
// `--directory` collapses an entirely ignored directory into one entry with
|
|
// a trailing slash, matching `git status --ignored`'s traditional mode.
|
|
// `--full-name` reports paths relative to the repository toplevel, so this
|
|
// still matches the toplevel-relative shape `resolveReferencedSourceIgnore`
|
|
// re-relativizes against, regardless of `localDir`'s position under it.
|
|
const ignoredResult = await runHardenedReadOnlyGit(
|
|
localDir,
|
|
["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--full-name", "-z"],
|
|
"referenced_source.ignored_files",
|
|
{ timeout: 60_000, maxBuffer: REFERENCED_SOURCE_IGNORE_MAX_RAW_BUFFER },
|
|
);
|
|
|
|
// Read one NUL-delimited record at a time and enforce both bounds while the
|
|
// ignored-entry list accumulates, instead of splitting and mapping the
|
|
// whole response into a list first and only then checking its size. A
|
|
// pathologically large ignore set (a huge repository, or one crafted to
|
|
// hold many ignored entries) must fail closed the moment it breaches a
|
|
// bound, without this scan first retaining and transforming the full
|
|
// oversized response.
|
|
//
|
|
// Do not trim each entry: `-z` already delimits entries with a NUL byte, so
|
|
// a leading or trailing space in an entry is part of the path itself, not
|
|
// padding to remove. A length check finds the one genuinely empty record
|
|
// `-z` appends after the last NUL, without eating a real path's own
|
|
// leading or trailing whitespace.
|
|
const rawIgnored = ignoredResult.stdout;
|
|
const parsedIgnoredEntries: string[] = [];
|
|
let totalIgnoredBytes = 0;
|
|
let recordStart = 0;
|
|
while (recordStart < rawIgnored.length) {
|
|
const nulIndex = rawIgnored.indexOf("\0", recordStart);
|
|
const recordEnd = nulIndex === -1 ? rawIgnored.length : nulIndex;
|
|
const record = rawIgnored.slice(recordStart, recordEnd);
|
|
recordStart = nulIndex === -1 ? rawIgnored.length : nulIndex + 1;
|
|
|
|
const entry = record.replace(/\/+$/, "");
|
|
if (entry.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (parsedIgnoredEntries.length + 1 > REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT) {
|
|
throw new ReferencedSourceIgnoreScanLimitExceededError(
|
|
`referenced project ignore scan found more than ${REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT} ignored entries`,
|
|
);
|
|
}
|
|
totalIgnoredBytes += Buffer.byteLength(entry, "utf8");
|
|
if (totalIgnoredBytes > REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES) {
|
|
throw new ReferencedSourceIgnoreScanLimitExceededError(
|
|
`referenced project ignore scan exceeded ${REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES} UTF-8 bytes of ignored paths`,
|
|
);
|
|
}
|
|
parsedIgnoredEntries.push(entry);
|
|
}
|
|
|
|
// The list is bounded by both checks above, so sorting and re-relativizing
|
|
// it here never costs more than the accepted bounds allow.
|
|
const ignoredPaths = parsedIgnoredEntries.sort((left, right) => left.localeCompare(right));
|
|
|
|
return { toplevel, ignoredPaths };
|
|
}
|
|
|
|
// 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 copyCloneTree(source: string, target: string): Promise<void> {
|
|
await fs.mkdir(target, { recursive: true });
|
|
for await (const entry of await fs.opendir(source)) {
|
|
const from = path.join(source, entry.name);
|
|
const to = path.join(target, entry.name);
|
|
if (entry.isDirectory()) await copyCloneTree(from, to);
|
|
else if (entry.isSymbolicLink()) await fs.symlink(await fs.readlink(from), to);
|
|
else { await fs.copyFile(from, to); await fs.chmod(to, (await fs.stat(from)).mode); }
|
|
}
|
|
}
|
|
|
|
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 = input.snapshot.originUrl !== undefined ? input.snapshot.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,
|
|
});
|
|
for (const repository of input.snapshot.repositories ?? []) {
|
|
await withShallowGitWorkspaceClone({
|
|
localDir: path.join(input.localDir, repository.path),
|
|
snapshot: repository.snapshot,
|
|
}, async (nestedClone) => {
|
|
// Preserve repository-relative links. fs.cp otherwise rewrites them to
|
|
// absolute paths into nestedClone, which is deleted after this callback
|
|
// and is outside the workspace when the sandbox restores its files.
|
|
await copyCloneTree(nestedClone, path.join(cloneDir, repository.path));
|
|
});
|
|
}
|
|
if (input.snapshot.repositories?.length) {
|
|
await fs.appendFile(path.join(cloneDir, ".git/info/exclude"), `\n/${PROJECT_REPOSITORIES_DIR}/\n`);
|
|
}
|
|
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",
|
|
]),
|
|
// An empty bundle means "still at baseSha" to the importer. A reset to an
|
|
// older ancestor has no delta commits either, but must carry its new tip.
|
|
// Use the full-bundle path so Git advertises that tip instead of losing it.
|
|
`if [ -n "$bundle_base" ] && [ "$bundle_base" != ${baseSha} ] && [ "$bundle_base" = "$(git -C ${remoteDir} rev-parse HEAD)" ]; then`,
|
|
` 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
|
|
? [
|
|
`git -C ${remoteDir} status --porcelain=v1 --untracked-files=normal -z > ${shellQuote(`${input.statusPath}.git-status`)}`,
|
|
`if [ ! -s ${shellQuote(`${input.statusPath}.git-status`)} ]; then`,
|
|
` printf clean > ${statusPath}`,
|
|
"else",
|
|
` printf dirty > ${statusPath}`,
|
|
"fi",
|
|
`rm -f -- ${shellQuote(`${input.statusPath}.git-status`)}`,
|
|
].join("\n")
|
|
: "",
|
|
input.catBundle ? `cat ${bundlePath}` : "",
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
// Labels are fixed at the integration call sites, never derived from Git arguments.
|
|
function runIntegrationGit(
|
|
command: WorkspaceRestoreGitCommand,
|
|
...args: Parameters<typeof runLocalGit>
|
|
): Promise<GitCommandResult> {
|
|
return withWorkspaceRestoreGitCommand(command, () => runLocalGit(...args));
|
|
}
|
|
|
|
/**
|
|
* Preserve imported work whose history does not connect to the local one.
|
|
*
|
|
* The dominant real-world cause is a history rewrite inside a transported
|
|
* workspace: transported clones are depth-1 shallow, so the boundary commit
|
|
* reads as parentless there and `git commit --amend` rewrites it into a root
|
|
* commit that shares no ancestor with the host history. A tree merge is
|
|
* impossible without a common ancestor, and failing the integration would
|
|
* discard the run's work. Instead, squash-graft the imported tree onto the
|
|
* current head as a single commit that reuses the imported head's message,
|
|
* with a trailer recording the graft. Concurrent local-only commits keep
|
|
* their place in history as the graft's ancestry; the imported tree is taken
|
|
* wholesale because no base exists to merge against. The caller advances the
|
|
* branch ref to the returned commit.
|
|
*/
|
|
export async function createUnrelatedHistoryGraftCommit(input: {
|
|
localDir: string;
|
|
currentHead: string;
|
|
importedHead: string;
|
|
syncLabel: string;
|
|
}): Promise<string> {
|
|
const importedTree = (await runIntegrationGit("rev_parse", input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
})).stdout.trim();
|
|
const importedMessage = (await runIntegrationGit("log", input.localDir, ["log", "-1", "--format=%B", input.importedHead], {
|
|
timeout: 10_000,
|
|
maxBuffer: 256 * 1024,
|
|
})).stdout;
|
|
const message = [
|
|
importedMessage.trim(),
|
|
"",
|
|
`(${input.syncLabel} graft ${input.importedHead.slice(0, 12)}: imported history shares no ancestor with ${input.currentHead.slice(0, 12)})`,
|
|
].join("\n");
|
|
const graftCommit = await runIntegrationGit(
|
|
"commit_tree", input.localDir,
|
|
[...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message],
|
|
{
|
|
timeout: 60_000,
|
|
maxBuffer: 64 * 1024,
|
|
},
|
|
);
|
|
return graftCommit.stdout.trim();
|
|
}
|
|
|
|
async function updateLocalGitHead(input: {
|
|
localDir: string;
|
|
newHead: string;
|
|
oldHead: string;
|
|
branchName: string | null;
|
|
}): Promise<void> {
|
|
// Prepare the ref transaction before checking the symbolic HEAD identity.
|
|
// Git holds HEAD.lock (and the branch lock when attached) until commit/abort,
|
|
// so a checkout cannot redirect the write after this check. --no-deref also
|
|
// prevents a detached write from following a newly attached branch.
|
|
await withWorkspaceRestoreGitCommand("update_ref", () => new Promise<void>((resolve, reject) => {
|
|
let identityError: unknown;
|
|
let prepared = false;
|
|
let output = "";
|
|
const child = execFile("git", ["-C", input.localDir, "update-ref", "--stdin"], {
|
|
timeout: 15_000,
|
|
maxBuffer: 64 * 1024,
|
|
}, (error, stdout, stderr) => {
|
|
if (identityError) reject(identityError);
|
|
else if (error) reject(Object.assign(error, { stdout, stderr }));
|
|
else if (!stdout.includes("commit: ok\n")) reject(new Error("Git HEAD transaction did not commit."));
|
|
else resolve();
|
|
});
|
|
// Early Git errors close stdin; the process callback reports the error.
|
|
child.stdin!.on("error", () => {});
|
|
child.stdout!.on("data", (chunk: string | Buffer) => {
|
|
output += chunk.toString();
|
|
if (prepared || !output.includes("prepare: ok\n")) return;
|
|
prepared = true;
|
|
void (async () => {
|
|
try {
|
|
const branchName = (await runIntegrationGit("symbolic_ref", input.localDir, ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
timeout: 10_000,
|
|
}).catch((error) => {
|
|
if (error.code === 1) return { stdout: "" };
|
|
throw error;
|
|
})).stdout.trim() || null;
|
|
if (branchName !== input.branchName) {
|
|
throw new Error("Workspace branch changed while remote work was running.");
|
|
}
|
|
child.stdin!.end("commit\n");
|
|
} catch (error) {
|
|
identityError = error;
|
|
child.stdin!.end("abort\n");
|
|
}
|
|
})();
|
|
});
|
|
child.stdin!.write([
|
|
"start",
|
|
...(input.branchName === null ? ["option no-deref"] : []),
|
|
`update HEAD ${input.newHead} ${input.oldHead}`,
|
|
"prepare",
|
|
"",
|
|
].join("\n"));
|
|
}));
|
|
}
|
|
|
|
export async function integrateImportedGitHead(input: {
|
|
localDir: string;
|
|
importedHead: string;
|
|
/** The host Git identity captured before staging this run. */
|
|
baseline?: Pick<GitWorkspaceSnapshot, "headCommit" | "branchName">;
|
|
}): 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 = {
|
|
headCommit: (await runIntegrationGit("rev_parse", input.localDir, ["rev-parse", "HEAD"])).stdout.trim(),
|
|
branchName: (await runIntegrationGit("symbolic_ref", input.localDir, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch((error) => {
|
|
if (error.code === 1) return { stdout: "" };
|
|
throw error;
|
|
})).stdout.trim() || null,
|
|
};
|
|
|
|
if (input.baseline && snapshot.branchName !== input.baseline.branchName) {
|
|
throw new Error("Workspace branch changed while remote work was running.");
|
|
}
|
|
const currentHead = snapshot.headCommit;
|
|
if (!currentHead || currentHead === input.importedHead) return;
|
|
|
|
// `git merge-base` exits 1 when the commits share no ancestor — the only
|
|
// outcome that authorizes the graft fallback below. Every other failure
|
|
// (timeout, missing object, repository error) must keep failing the
|
|
// integration instead of silently rewriting the tip.
|
|
let noCommonAncestor = false;
|
|
const mergeBase = await runIntegrationGit("merge_base", input.localDir, ["merge-base", currentHead, input.importedHead], {
|
|
timeout: 10_000,
|
|
maxBuffer: 16 * 1024,
|
|
}).catch((error: unknown) => {
|
|
noCommonAncestor = (error as { code?: unknown } | null)?.code === 1;
|
|
return null;
|
|
});
|
|
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
|
|
|
|
// A rebase/amend rewrites the sandbox tip without a concurrent host edit.
|
|
// Adopt that history when the host still matches the run's starting tip;
|
|
// merging the old and rewritten commits can reintroduce resolved conflicts.
|
|
// Keep the expected-old-value check: if the host advances during this write,
|
|
// retry against its new tip and use the normal concurrent-history path.
|
|
if (mergeBaseHead === currentHead || (mergeBaseHead && currentHead === input.baseline?.headCommit)) {
|
|
try {
|
|
await updateLocalGitHead({
|
|
localDir: input.localDir, newHead: input.importedHead, oldHead: currentHead, branchName: snapshot.branchName,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (mergeBaseHead === input.importedHead) {
|
|
return;
|
|
}
|
|
|
|
if (noCommonAncestor) {
|
|
if (input.baseline && currentHead !== input.baseline.headCommit) {
|
|
throw new Error("Cannot restore unrelated remote history after the host advanced.");
|
|
}
|
|
// No common ancestor — merging is impossible and failing here would
|
|
// discard the imported work. Graft it onto the current head instead;
|
|
// see createUnrelatedHistoryGraftCommit.
|
|
const graftCommit = await createUnrelatedHistoryGraftCommit({
|
|
localDir: input.localDir,
|
|
currentHead,
|
|
importedHead: input.importedHead,
|
|
syncLabel: "Paperclip remote git sync",
|
|
});
|
|
try {
|
|
await updateLocalGitHead({
|
|
localDir: input.localDir, newHead: graftCommit, oldHead: currentHead, branchName: snapshot.branchName,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let mergedTree;
|
|
try {
|
|
mergedTree = await runIntegrationGit("merge_tree", 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 preserveWorkspaceRestoreErrorDiagnostic(new Error(
|
|
`Failed to merge concurrent remote git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`,
|
|
), error);
|
|
}
|
|
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 runIntegrationGit(
|
|
"commit_tree", input.localDir,
|
|
[
|
|
...GIT_SYNC_COMMIT_IDENTITY_ARGS,
|
|
"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 updateLocalGitHead({
|
|
localDir: input.localDir, newHead: mergeCommit.stdout.trim(), oldHead: currentHead, branchName: snapshot.branchName,
|
|
});
|
|
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 preserveWorkspaceRestoreErrorDiagnostic(
|
|
new Error(`Failed to reset local git index to HEAD after workspace restore: ${detail}`), error,
|
|
);
|
|
}
|
|
|
|
const hasDiff = async (args: string[]) => {
|
|
try { await runLocalGit(input.localDir, args, { timeout: workspaceSnapshotTimeoutMs(), maxBuffer: 64 * 1024 }); return false; }
|
|
catch (error) { if ((error as { code?: unknown }).code === 1) return true; throw error; }
|
|
};
|
|
if (await hasDiff(["diff", "--quiet", "--cached", "HEAD", "--"])) {
|
|
throw new Error("Workspace restore left staged git index changes after reset");
|
|
}
|
|
if (input.checkWorkingTreeClean && await hasDiff(["diff", "--quiet", "HEAD", "--"])) {
|
|
console.warn("[paperclip] Workspace restore preserved local working tree changes after clean sandbox restore.");
|
|
}
|
|
}
|