mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Durable workspace import must preserve archived task files and Git history. > - A saved nested checkout can be a linked worktree whose branch belongs to its old absolute path. > - Archived import reconstructs that checkout in a new scratch directory. > - Ordinary linked recovery rejects the old branch at that new path. It can also write into a cache used by other import workers. > - This pull request reconstructs archived nested checkouts as private standalone repositories. > - Each import preserves saved files and extra history without changing shared worktree registrations or branches. ## Linked Issues or Issue Description **What happened?** Archived workspace import fails when a saved nested linked checkout has a branch owned by the old path. The new scratch path fails the linked-worktree ownership check. The existing cache lock is process-local, so separate import processes also share mutable Git state without a common lock. **Expected behavior** Archived import must create an independent portable copy of the saved checkout. It must retain the saved commit, branch name, staged edits, working files, ignored recovery files, settings, and extra saved commits. It must keep the source cache unchanged and enforce source authorization. **Steps to reproduce** 1. Create a managed nested linked checkout with an unpushed commit and staged and unstaged edits. 2. Save an encrypted restore point, including ignored files and an extra history tip. 3. Restore its saved contents under a different absolute workspace path. 4. Before this repair, the new path cannot adopt the branch owned by the old path. **Paperclip version or commit** The real Git regression fails before the repair at `fc1f5f3052`. The final branch includes the merged interrupted-upload and nested-source authorization fix from #19396. **Deployment mode** Archived remote workspace migration with real Git repositories, disposable PostgreSQL, and encrypted object storage. Local integration tests use the filesystem storage provider. Builds on merged #19402 and #19396. Related #19374 concerns interrupted clone staging; it does not implement private archived reconstruction. No duplicate open repair was found. ## What Changed - Enable private repository reconstruction only for archived migration. Ordinary linked recovery keeps its path ownership guard. - Add an object-only source to the managed checkout owner. A verified cache supplies only the saved commit and authenticated bundle prerequisites through scoped fetches. The private copy includes no unrelated task refs or objects, hard links, object alternates, or current cache files. - Fetch missing authenticated bundle prerequisites before importing a saved bundle in both private reconstruction and ordinary linked recovery. A pruned cache can use the approved remote; unavailable history still blocks restore. - Keep every authenticated restore point's extra commit under a private ref in root and nested private copies so portable archive capture and garbage collection retain it. Ordinary recovery keeps its existing history and expiration rules. - Share the verified, bounded ignore-file copy between root and nested reconstruction. Sources remain unchanged. - Retain source authorization before and after reconstruction and the final source-row locks from #19396. - Test concurrent private copies, source rejection, foreign task refs and objects, ordinary linked prerequisite recovery, a real migration, and cold restore after removing all original repositories. Test hard links, oversized excludes, unsafe info paths, and replacement during a descriptor read. ## Verification - Final affected run: 292 tests pass across ten suites. These cover real Git repositories, encrypted restore points, disposable PostgreSQL migration, portable archives, the restore-point ledger, source rejection, and concurrent copies. - Full repository typecheck and build pass after the scoped-object repair. Final server typecheck and full build also pass after restricting preserved refs to archive mode. - RED then GREEN: a linked checkout at a different path; a pruned bundle prerequisite still available from the approved remote; foreign task refs and objects entering the archive; root extra history disappearing after cold restore; and archive-only refs blocking ordinary standalone expiration. - Two concurrent private copies retain the saved head, branch, staged and unstaged edits, ignored recovery bytes, settings, and modes. Source refs, index, worktree registrations, and working files remain unchanged. - Delete the original checkout, cache, remote, and private copies after portable capture. Cold restore retains the saved files and extra history after garbage collection. No alternates or unrelated task objects remain. - Ordinary linked recovery retains its path ownership check and recovers pruned prerequisites. New and existing ordinary standalone destinations have no archive-only refs and remain eligible for expiration after normal object cleanup. - Unsafe exclude tests reject hard links, oversized files, nonregular paths, info-directory substitution, and replacement during the descriptor read. They preserve the target and replacement source bytes. - Preserve the failed attempts: an initial migration fixture changed its home after constructing the source path. Later expiration fixture attempts used a filtered current-head tip, omitted the local-source callback, or used cleanup that generated Git server-info files or left packed unreachable objects. Correct those setup assumptions before the actual regressions fail. Final affected suites pass. - Two earlier local full-suite attempts were interrupted before editing valid review findings. Neither is reported as a full-suite PASS. Final-head full CI passes; review is 5/5 with no open findings. ## Risks Archived imports use additional private disk space for independent Git objects. Missing, changed, or unsafe sources still block import. The repair does not weaken the default linked-worktree ownership check, change stored formats, or update live provider state. The shared checkout addition is optional and has no effect for existing callers that do not supply an object source. ## Model Used OpenAI GPT-6 in Codex, with code editing, tool use, and test execution. The exact runtime model identifier and context-window size are not exposed in this session. ## 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>
321 lines
21 KiB
TypeScript
321 lines
21 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { constants } from "node:fs";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { PROJECT_REPOSITORIES_DIR } from "@paperclipai/adapter-utils/git-workspace-sync";
|
|
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
|
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
|
|
import type { GitRemoteAuthProvider } from "./git-credentials.js";
|
|
import { localRepositoryPath } from "./local-repository-path.js";
|
|
import { managedProjectCheckoutName } from "./managed-project-checkout-name.js";
|
|
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
|
|
import { ensureSavedGitCommit, gitCacheCommand, GitCacheRestorationFailure } from "./git-workspace-restoration.js";
|
|
import { applyGitRestorePointToWorktree, ensureGitRestorePointCommit, loadGitRestorePoint, type GitRestorePointRef } from "./git-restore-point-storage.js";
|
|
import { copyVerifiedGitExcludes } from "./git-repository-excludes.js";
|
|
import { importRestorePoint } from "./git-restore-point.js";
|
|
import { isNestedWorktreeBranchName, isVerifiedNestedRepositoryBase, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, recordedNestedRepositoryBase, restoreNestedRepositoryWorktree, withNestedRepositoryBaseLock } from "./nested-repository-worktrees.js";
|
|
import { getStorageService } from "../storage/index.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
|
|
export const CLAUDE_SETTINGS_PATH = ".claude/settings.local.json";
|
|
const SETTINGS_LIMIT = 64 * 1024;
|
|
const CHECKOUT_LIMIT = 50;
|
|
type Identity = { companyId: string; workspaceId: string };
|
|
type Settings = { material: StoredSecretVersionMaterial } | null;
|
|
export type ManagedCheckoutSource = { id: string; repoUrl: string; repoRef: string | null; cwd: string | null };
|
|
export type ManagedCheckoutContents = {
|
|
name: string; source: ManagedCheckoutSource; commit: string; branchName: string | null;
|
|
worktree?: { base: string; admin: string };
|
|
restorePoint?: GitRestorePointRef;
|
|
};
|
|
export type GitWorkspaceContents = {
|
|
schema: "paperclip.git-workspace-contents.v1";
|
|
settings: Settings;
|
|
checkouts: Array<ManagedCheckoutContents & { settings: Settings }>;
|
|
};
|
|
|
|
async function ensureCheckoutPrerequisites(cwd: string, checkout: ManagedCheckoutContents,
|
|
manifest: { prerequisites: readonly string[] }, resolveGitAuth: GitRemoteAuthProvider) {
|
|
const commits = new Set(manifest.prerequisites);
|
|
if (checkout.restorePoint?.headOnRemote) commits.add(checkout.commit);
|
|
for (const commit of commits) await ensureSavedGitCommit(cwd, checkout.source.repoUrl, commit, resolveGitAuth);
|
|
}
|
|
|
|
async function entry(target: string) {
|
|
return fs.lstat(target).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async function checkedDirectory(cwd: string, relative: string, create = false) {
|
|
let current = await fs.realpath(cwd);
|
|
for (const segment of relative.split("/")) {
|
|
current = path.join(current, segment);
|
|
if (create) await fs.mkdir(current, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "EEXIST") throw error;
|
|
});
|
|
const stat = await entry(current);
|
|
if (!stat) return null;
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new GitCacheRestorationFailure();
|
|
}
|
|
return current;
|
|
}
|
|
|
|
async function readSettings(cwd: string) {
|
|
if (!await checkedDirectory(cwd, ".claude")) return null;
|
|
const target = path.join(cwd, CLAUDE_SETTINGS_PATH);
|
|
const initial = await entry(target);
|
|
if (!initial) return null;
|
|
if (!initial.isFile() || initial.isSymbolicLink() || initial.nlink !== 1 || initial.size > SETTINGS_LIMIT) throw new Error("unsupported_local_settings");
|
|
if (await gitCacheCommand(cwd, ["ls-files", "--error-unmatch", "--", CLAUDE_SETTINGS_PATH]).then(() => true, () => false)) throw new Error("tracked_local_settings");
|
|
const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile() || stat.nlink !== 1 || stat.size > SETTINGS_LIMIT) throw new Error("unsupported_local_settings");
|
|
const buffer = Buffer.alloc(SETTINGS_LIMIT + 1);
|
|
let length = 0;
|
|
while (length < buffer.length) {
|
|
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
if (bytesRead === 0) break;
|
|
length += bytesRead;
|
|
}
|
|
if (length > SETTINGS_LIMIT) throw new Error("unsupported_local_settings");
|
|
return { mode: stat.mode & 0o777, bytes: buffer.subarray(0, length).toString("base64") };
|
|
} finally { await handle.close(); }
|
|
}
|
|
|
|
async function saveSettings(cwd: string, identity: Identity, relativePath: string): Promise<Settings> {
|
|
const value = await readSettings(cwd);
|
|
if (!value) return null;
|
|
const { material } = await localEncryptedProvider.createVersion({ value: JSON.stringify({ ...identity, relativePath, ...value }) });
|
|
return { material };
|
|
}
|
|
|
|
async function savedSettings(settings: Settings, identity: Identity, relativePath: string) {
|
|
if (settings === null) return null;
|
|
const value = JSON.parse(await localEncryptedProvider.resolveVersion({ material: settings.material, externalRef: null }));
|
|
if (value.companyId !== identity.companyId || value.workspaceId !== identity.workspaceId || value.relativePath !== relativePath
|
|
|| !Number.isInteger(value.mode) || value.mode < 0 || value.mode > 0o777
|
|
|| typeof value.bytes !== "string" || value.bytes.length > Math.ceil(SETTINGS_LIMIT / 3) * 4
|
|
|| Buffer.from(value.bytes, "base64").toString("base64") !== value.bytes) throw new GitCacheRestorationFailure();
|
|
return { mode: value.mode as number, bytes: value.bytes as string };
|
|
}
|
|
|
|
export function validateGitWorkspaceContents(contents: GitWorkspaceContents | undefined) {
|
|
if (contents === undefined) return;
|
|
const settingsValid = (settings: Settings) => settings === null || !!settings && typeof settings.material === "object"
|
|
&& settings.material !== null && JSON.stringify(settings.material).length <= SETTINGS_LIMIT * 2;
|
|
if (!contents || contents.schema !== "paperclip.git-workspace-contents.v1" || !settingsValid(contents.settings)
|
|
|| !Array.isArray(contents.checkouts) || contents.checkouts.length > CHECKOUT_LIMIT) throw new GitCacheRestorationFailure();
|
|
const names = new Set<string>();
|
|
for (const checkout of contents.checkouts) {
|
|
if (!checkout || !checkout.source || typeof checkout.source.id !== "string" || typeof checkout.source.repoUrl !== "string"
|
|
|| !(checkout.source.repoRef === null || typeof checkout.source.repoRef === "string")
|
|
|| !(checkout.source.cwd === null || typeof checkout.source.cwd === "string")
|
|
|| typeof checkout.name !== "string" || !/^[A-Za-z0-9_-]+-[a-f0-9]{12}$/.test(checkout.name)
|
|
|| checkout.name !== managedProjectCheckoutName(checkout.source) || names.has(checkout.name)
|
|
|| !/^[a-f0-9]{40}$/.test(checkout.commit)
|
|
|| !(checkout.branchName === null || typeof checkout.branchName === "string" && checkout.branchName.length > 0
|
|
&& checkout.branchName.length <= 1024 && !checkout.branchName.startsWith("-") && !checkout.branchName.includes("\0"))
|
|
|| !settingsValid(checkout.settings)
|
|
|| (checkout.worktree !== undefined && (!checkout.worktree || typeof checkout.worktree.base !== "string"
|
|
|| !path.isAbsolute(checkout.worktree.base) || checkout.worktree.base.includes("\0") || checkout.worktree.base.length > 4096
|
|
|| typeof checkout.worktree.admin !== "string" || !/^[A-Za-z0-9._-]{1,255}$/.test(checkout.worktree.admin)
|
|
|| [".", ".."].includes(checkout.worktree.admin) || Object.keys(checkout.worktree).length !== 2
|
|
|| (checkout.branchName !== null && !isNestedWorktreeBranchName(checkout.branchName))))) throw new GitCacheRestorationFailure();
|
|
names.add(checkout.name);
|
|
}
|
|
}
|
|
|
|
export function selectRestoredProjectRepositories<T extends { id: string; repoUrl: string | null; repoRef: string | null; cwd?: string | null }>(sources: T[], contents?: GitWorkspaceContents): T[] {
|
|
validateGitWorkspaceContents(contents);
|
|
for (const checkout of contents?.checkouts ?? []) {
|
|
if (!sources.some(source => source.id === checkout.source.id && source.repoUrl === checkout.source.repoUrl)) throw new GitCacheRestorationFailure();
|
|
}
|
|
return sources.map(source => {
|
|
const saved = contents?.checkouts.find(checkout => checkout.source.id === source.id)?.source;
|
|
return saved ? { ...source, repoRef: saved.repoRef, cwd: saved.cwd } : source;
|
|
});
|
|
}
|
|
|
|
export async function readManagedCheckoutContents(checkout: string, name: string, source: ManagedCheckoutSource, signal?: AbortSignal): Promise<ManagedCheckoutContents> {
|
|
const commit = (await gitCacheCommand(checkout, ["rev-parse", "HEAD"], signal)).trim();
|
|
const branchName = (await gitCacheCommand(checkout, ["symbolic-ref", "--quiet", "--short", "HEAD"], signal).catch(() => "")).trim() || null;
|
|
const linked = await readLinkedNestedCheckout(checkout);
|
|
return linked ? { name, source, commit, branchName, worktree: { base: linked.base, admin: linked.admin } } : { name, source, commit, branchName };
|
|
}
|
|
|
|
export async function captureGitWorkspaceContents(cwd: string, identity: Identity, checkouts: ManagedCheckoutContents[]): Promise<GitWorkspaceContents> {
|
|
if (checkouts.length > CHECKOUT_LIMIT) throw new Error("managed_checkout_budget");
|
|
const contents: GitWorkspaceContents = { schema: "paperclip.git-workspace-contents.v1",
|
|
settings: await saveSettings(cwd, identity, CLAUDE_SETTINGS_PATH), checkouts: [] };
|
|
for (const checkout of checkouts.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const relative = `${PROJECT_REPOSITORIES_DIR}/${checkout.name}`;
|
|
if (!await checkedDirectory(cwd, relative)) throw new GitCacheRestorationFailure();
|
|
contents.checkouts.push({ ...checkout, settings: await saveSettings(path.join(cwd, relative), identity, `${relative}/${CLAUDE_SETTINGS_PATH}`) });
|
|
}
|
|
validateGitWorkspaceContents(contents);
|
|
return contents;
|
|
}
|
|
|
|
export async function assertGitWorkspaceContentsUnchanged(cwd: string, identity: Identity, contents: GitWorkspaceContents | undefined, checkouts: ManagedCheckoutContents[]) {
|
|
validateGitWorkspaceContents(contents);
|
|
const recorded = contents?.checkouts.map(({ settings: _settings, restorePoint: _restorePoint, ...checkout }) => checkout) ?? [];
|
|
const actual = checkouts.map(({ restorePoint: _restorePoint, ...checkout }) => checkout).sort((a, b) => a.name.localeCompare(b.name));
|
|
if (!isDeepStrictEqual(recorded, actual)) throw new Error("managed_checkouts_changed");
|
|
const assertSettings = async (root: string, saved: Settings, relative: string) => {
|
|
if (!isDeepStrictEqual(await readSettings(root), await savedSettings(saved, identity, relative))) throw new Error("local_settings_changed");
|
|
};
|
|
await assertSettings(cwd, contents?.settings ?? null, CLAUDE_SETTINGS_PATH);
|
|
for (const checkout of contents?.checkouts ?? []) {
|
|
const relative = `${PROJECT_REPOSITORIES_DIR}/${checkout.name}`;
|
|
if (!await checkedDirectory(cwd, relative)) throw new Error("managed_checkouts_changed");
|
|
await assertSettings(path.join(cwd, relative), checkout.settings, `${relative}/${CLAUDE_SETTINGS_PATH}`);
|
|
}
|
|
}
|
|
|
|
async function restoreSettings(cwd: string, settings: Settings, identity: Identity, relative: string) {
|
|
const saved = await savedSettings(settings, identity, relative);
|
|
const directory = await checkedDirectory(cwd, ".claude", saved !== null);
|
|
if (!directory) return;
|
|
const target = path.join(directory, "settings.local.json");
|
|
const temporary = path.join(directory, `.settings-restore-${createHash("sha256").update(JSON.stringify(settings)).digest("hex")}`);
|
|
const pending = await entry(temporary);
|
|
if (pending) {
|
|
if (!saved || !pending.isFile() || pending.isSymbolicLink() || pending.size > SETTINGS_LIMIT) throw new GitCacheRestorationFailure();
|
|
const current = await entry(target);
|
|
const expected = Buffer.from(saved.bytes, "base64");
|
|
const bytes = await fs.readFile(temporary);
|
|
const unpublished = pending.nlink === 1 && !current && bytes.length <= expected.length
|
|
&& bytes.equals(expected.subarray(0, bytes.length)) && [0o600, saved.mode].includes(pending.mode & 0o777);
|
|
const published = pending.nlink === 2 && current?.ino === pending.ino && current.dev === pending.dev
|
|
&& bytes.equals(expected) && (pending.mode & 0o777) === saved.mode;
|
|
if (!unpublished && !published) throw new GitCacheRestorationFailure();
|
|
await fs.unlink(temporary);
|
|
}
|
|
const current = await readSettings(cwd);
|
|
if (current) {
|
|
if (!isDeepStrictEqual(current, saved)) throw new GitCacheRestorationFailure();
|
|
return;
|
|
}
|
|
if (!saved) return;
|
|
try {
|
|
await fs.writeFile(temporary, Buffer.from(saved.bytes, "base64"), { flag: "wx", mode: 0o600 });
|
|
await fs.chmod(temporary, saved.mode);
|
|
await fs.link(temporary, target);
|
|
} finally { await fs.rm(temporary, { force: true }); }
|
|
}
|
|
|
|
export async function restoreGitWorkspaceContents(input: Identity & { cwd: string; contents?: GitWorkspaceContents; projectId?: string | null;
|
|
prepareLocalSource?: (sourceCwd: string) => Promise<() => Promise<void>>;
|
|
storage?: StorageService;
|
|
isolateRepositories?: boolean;
|
|
resolveGitAuth: GitRemoteAuthProvider; authorizeSource: (source: ManagedCheckoutSource) => Promise<void> }) {
|
|
if (!input.contents) return;
|
|
validateGitWorkspaceContents(input.contents);
|
|
const workspace = { companyId: input.companyId, id: input.workspaceId };
|
|
try {
|
|
for (const checkout of input.contents.checkouts) {
|
|
await input.authorizeSource(checkout.source);
|
|
const localSource = localRepositoryPath(checkout.source.repoUrl);
|
|
if (localSource && !input.prepareLocalSource) throw new GitCacheRestorationFailure();
|
|
const recheck = localSource ? await input.prepareLocalSource!(localSource) : undefined;
|
|
const root = await checkedDirectory(input.cwd, PROJECT_REPOSITORIES_DIR, true);
|
|
const destination = path.join(root!, checkout.name);
|
|
const relative = `${PROJECT_REPOSITORIES_DIR}/${checkout.name}/${CLAUDE_SETTINGS_PATH}`;
|
|
const existing = await entry(destination);
|
|
const candidates = checkout.worktree && input.projectId
|
|
? nestedRepositoryBaseCandidates({ companyId: input.companyId, projectId: input.projectId, repoUrl: checkout.source.repoUrl }) : null;
|
|
if (checkout.worktree && !candidates) throw new GitCacheRestorationFailure();
|
|
if (existing && checkout.worktree && !input.isolateRepositories) {
|
|
const linked = existing.isDirectory() && !existing.isSymbolicLink() ? await readLinkedNestedCheckout(destination, candidates!) : null;
|
|
if (!linked || linked.checkout !== destination || !await isVerifiedNestedRepositoryBase(linked.base, candidates!, checkout.source.repoUrl)) {
|
|
throw new GitCacheRestorationFailure();
|
|
}
|
|
} else if (existing) {
|
|
if (!existing.isDirectory() || existing.isSymbolicLink() || !(await entry(path.join(destination, ".git")))?.isDirectory()
|
|
|| (await fs.realpath(path.join(destination, ".git"))) !== path.join(destination, ".git")) throw new GitCacheRestorationFailure();
|
|
}
|
|
if (existing) {
|
|
if ((await gitCacheCommand(destination, ["rev-parse", "--show-toplevel"])).trim() !== destination
|
|
|| (await gitCacheCommand(destination, ["config", "--local", "--null", "--get-all", "remote.origin.url"])) !== `${checkout.source.repoUrl}\0`) throw new GitCacheRestorationFailure();
|
|
const actual = await readManagedCheckoutContents(destination, checkout.name, checkout.source);
|
|
if (actual.commit !== checkout.commit || actual.branchName !== checkout.branchName) throw new GitCacheRestorationFailure();
|
|
if (checkout.restorePoint) await applyGitRestorePointToWorktree({ storage: input.storage ?? getStorageService(), workspace,
|
|
ref: checkout.restorePoint, repositoryCwd: destination, worktreePath: destination,
|
|
preserveImportedHistory: input.isolateRepositories });
|
|
await restoreSettings(destination, checkout.settings, input, relative);
|
|
continue;
|
|
}
|
|
if (checkout.worktree && !input.isolateRepositories) {
|
|
const recordedBase = await recordedNestedRepositoryBase(checkout.worktree.base, candidates!);
|
|
if (!recordedBase) throw new GitCacheRestorationFailure();
|
|
if (!await isVerifiedNestedRepositoryBase(recordedBase, candidates!, checkout.source.repoUrl)) {
|
|
if (await entry(recordedBase)) throw new GitCacheRestorationFailure();
|
|
await materializeManagedProjectWorkspace(recordedBase, { repoUrl: checkout.source.repoUrl, resolveGitAuth: input.resolveGitAuth });
|
|
if (!await isVerifiedNestedRepositoryBase(recordedBase, candidates!, checkout.source.repoUrl)) throw new GitCacheRestorationFailure();
|
|
}
|
|
const base = await fs.realpath(recordedBase);
|
|
await withNestedRepositoryBaseLock(base, async () => {
|
|
let preserved: string[] = [];
|
|
if (checkout.restorePoint) {
|
|
const { payload, manifest } = await loadGitRestorePoint({ storage: input.storage ?? getStorageService(), workspace, ref: checkout.restorePoint });
|
|
await ensureCheckoutPrerequisites(base, checkout, manifest, input.resolveGitAuth);
|
|
await importRestorePoint({ repositoryCwd: base, payload });
|
|
preserved = manifest.preserved;
|
|
}
|
|
await ensureSavedGitCommit(base, checkout.source.repoUrl, checkout.commit, input.resolveGitAuth);
|
|
if (checkout.branchName) await gitCacheCommand(base, ["check-ref-format", "--branch", checkout.branchName]);
|
|
await restoreNestedRepositoryWorktree({ base, cwd: destination, commit: checkout.commit, branchName: checkout.branchName,
|
|
repoRef: checkout.source.repoRef, preserved,
|
|
afterAdd: async cwd => {
|
|
if (checkout.restorePoint) await applyGitRestorePointToWorktree({ storage: input.storage ?? getStorageService(), workspace,
|
|
ref: checkout.restorePoint, repositoryCwd: cwd, worktreePath: cwd });
|
|
await restoreSettings(cwd, checkout.settings, input, relative);
|
|
await input.authorizeSource(checkout.source);
|
|
await recheck?.();
|
|
} });
|
|
});
|
|
continue;
|
|
}
|
|
let gitObjectSource: string | undefined;
|
|
if (checkout.worktree) {
|
|
const recorded = await recordedNestedRepositoryBase(checkout.worktree.base, candidates!);
|
|
if (!recorded) throw new GitCacheRestorationFailure();
|
|
if (await entry(recorded)) {
|
|
if (!await isVerifiedNestedRepositoryBase(recorded, candidates!, checkout.source.repoUrl)) throw new GitCacheRestorationFailure();
|
|
gitObjectSource = await fs.realpath(recorded);
|
|
}
|
|
}
|
|
const savedRestorePoint = checkout.restorePoint && input.isolateRepositories
|
|
? await loadGitRestorePoint({ storage: input.storage ?? getStorageService(), workspace, ref: checkout.restorePoint }) : null;
|
|
const result = await materializeManagedProjectWorkspace(destination, { repoUrl: checkout.source.repoUrl,
|
|
gitObjectSource: gitObjectSource ? { cwd: gitObjectSource, commits: [checkout.commit, ...(savedRestorePoint?.manifest.prerequisites ?? [])] } : undefined,
|
|
resolveGitAuth: input.resolveGitAuth, beforePublish: async temporary => {
|
|
if (gitObjectSource && checkout.restorePoint) await copyVerifiedGitExcludes(gitObjectSource, temporary);
|
|
if (savedRestorePoint) {
|
|
const { payload, manifest } = savedRestorePoint;
|
|
await ensureCheckoutPrerequisites(temporary, checkout, manifest, input.resolveGitAuth);
|
|
await importRestorePoint({ repositoryCwd: temporary, payload });
|
|
} else if (checkout.restorePoint) await ensureGitRestorePointCommit({ storage: input.storage ?? getStorageService(), workspace,
|
|
ref: checkout.restorePoint, repositoryCwd: temporary });
|
|
await ensureSavedGitCommit(temporary, checkout.source.repoUrl, checkout.commit, input.resolveGitAuth);
|
|
if (checkout.branchName) await gitCacheCommand(temporary, ["check-ref-format", "--branch", checkout.branchName]);
|
|
await gitCacheCommand(temporary, checkout.branchName
|
|
? ["checkout", "-B", checkout.branchName, checkout.commit] : ["checkout", "--detach", checkout.commit]);
|
|
if (checkout.restorePoint) await applyGitRestorePointToWorktree({ storage: input.storage ?? getStorageService(), workspace,
|
|
ref: checkout.restorePoint, repositoryCwd: temporary, worktreePath: temporary, preserveImportedHistory: input.isolateRepositories });
|
|
await gitCacheCommand(temporary, ["reflog", "expire", "--expire=now", "--all"]);
|
|
await restoreSettings(temporary, checkout.settings, input, relative);
|
|
await input.authorizeSource(checkout.source);
|
|
await recheck?.();
|
|
if (gitObjectSource && !await isVerifiedNestedRepositoryBase(gitObjectSource, candidates!, checkout.source.repoUrl)) throw new GitCacheRestorationFailure();
|
|
} });
|
|
if (result.warning) throw new GitCacheRestorationFailure();
|
|
}
|
|
await restoreSettings(input.cwd, input.contents.settings, input, CLAUDE_SETTINGS_PATH);
|
|
} catch { throw new GitCacheRestorationFailure(); }
|
|
}
|