diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index b6f4bd84f72..9c35a2ccadd 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1939,6 +1939,12 @@ project with the same repository URL; publication locks and rechecks those sourc rows. Imports from mutable local folders still recapture and compare the current files; a saved upload does not permit later local edits to be discarded. +When the verified Git source still exists, reconstruction also copies its local +`info/exclude` rules into the private repository before applying a saved restore +point. This keeps saved ignored files, including recovery receipts and bundles, +in their original classification. The source ignore file stays unchanged. +Symlinked, oversized, or changing source ignore files block reconstruction. + Shared physical folders require Cloud's offline enrollment maintenance process. A task-level hold cannot fence another workspace record pointing at the same folder. Maintenance verifies that legacy deployments have stopped and that no diff --git a/server/src/services/remote-workspace-migration-git.ts b/server/src/services/remote-workspace-migration-git.ts index 39c0423d459..242e38c9e53 100644 --- a/server/src/services/remote-workspace-migration-git.ts +++ b/server/src/services/remote-workspace-migration-git.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { constants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -180,6 +181,38 @@ export async function lockArchivedGitMigrationSources(db: Pick, wo } } +async function copyArchivedGitExcludes(source: string, cwd: string) { + const common = await fs.realpath(path.resolve(source, await git(source, ["rev-parse", "--path-format=absolute", "--git-common-dir"]))); + const info = path.join(common, "info"), file = path.join(info, "exclude"); + const directory = await fs.lstat(info).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!directory) return; + if (!directory.isDirectory() || directory.isSymbolicLink()) throw unavailable(); + const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + .catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw unavailable(); }); + if (!handle) return; + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size > 1024n * 1024n) throw unavailable(); + const bytes = Buffer.alloc(Number(before.size) + 1); let length = 0; + while (length < bytes.length) { + const read = await handle.read(bytes, length, bytes.length - length, length); + if (!read.bytesRead) break; + length += read.bytesRead; + } + const after = await fs.lstat(file, { bigint: true }); + if (!after.isFile() || after.isSymbolicLink() || BigInt(length) !== before.size + || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs + || before.mode !== after.mode || before.ctimeNs !== after.ctimeNs || before.nlink !== after.nlink + || await fs.realpath(info) !== info) throw unavailable(); + const target = path.join(cwd, ".git", "info", "exclude"); + await fs.writeFile(target, bytes.subarray(0, length)); + await fs.chmod(target, Number(before.mode & 0o777n)); + } finally { await handle.close(); } +} + export async function materializeArchivedGitMigrationSource(input: { db: Db; storage: StorageService; workspace: Workspace; source: ArchivedGitMigrationSource; scratch: string; }) { @@ -199,6 +232,7 @@ export async function materializeArchivedGitMigrationSource(input: { : await verifiedReceiptObjectSource(workspace, source.sourcePath); if (source.retainedBranch && (await git(verified, ["rev-parse", "--verify", `refs/heads/${source.retainedBranch}^{commit}`])) !== source.commit) throw unavailable(); await fs.mkdir(cwd, { mode: 0o700 }); await git(cwd, ["init", "--quiet"]); + if (source.recipe?.restorePoint) await copyArchivedGitExcludes(verified, cwd); // Fetch objects, never the current working tree of a shared project root. const presentCommit = await git(verified, ["cat-file", "-e", `${source.commit}^{commit}`]).then(() => true, () => false); if (presentCommit) { diff --git a/server/src/services/remote-workspace-migration.test.ts b/server/src/services/remote-workspace-migration.test.ts index 12bf564070f..a78367dd358 100644 --- a/server/src/services/remote-workspace-migration.test.ts +++ b/server/src/services/remote-workspace-migration.test.ts @@ -660,12 +660,20 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { const address = server.address(); if (!address || typeof address === "string") throw new Error("test server not bound"); return { bare, repoUrl: `http://127.0.0.1:${address.port}/repository.git` }; } - it.each(["present", "pruned", "retry", "missing-base", "missing-base-retry", "missing-base-uploaded-retry", "missing-base-changed-retry", "nested", "nested-retry", "nested-source-changed-retry", "unapproved-nested", "missing", "expired", "corrupt"])("preserves archived Git edits or refuses an unavailable restore point (%s)", async state => { + it.each(["present", "pruned", "retry", "source-excludes", "source-excludes-symlink", "missing-base", "missing-base-retry", "missing-base-uploaded-retry", "missing-base-changed-retry", "nested", "nested-retry", "nested-source-changed-retry", "unapproved-nested", "missing", "expired", "corrupt"])("preserves archived Git edits or refuses an unavailable restore point (%s)", async state => { const remote = state.startsWith("missing-base") ? await serveRepository() : null; const f = await gitArchiveFixture(remote?.repoUrl); if (remote) { await git(remote.bare, "fetch", f.base, "main:refs/heads/main"); await git(remote.bare, "update-server-info"); } await fs.writeFile(path.join(f.cwd, "saved.txt"), "staged edit\n"); await git(f.cwd, "add", "saved.txt"); await fs.writeFile(path.join(f.cwd, "saved.txt"), "unstaged edit\n"); + if (state.startsWith("source-excludes")) { + await fs.appendFile(path.join(f.base, ".git", "info", "exclude"), "\n/.paperclip/recovered-runs/\n"); + const recovered = path.join(f.cwd, ".paperclip", "recovered-runs", "saved"); + await fs.mkdir(recovered, { recursive: true }); + await fs.writeFile(path.join(recovered, "repair-receipt.json"), '{"preserved":true}\n'); + await fs.writeFile(path.join(recovered, "source-delta.bundle"), Buffer.from([0, 255, 17, 128])); + } + const sourceExcludes = state.startsWith("source-excludes") ? await fs.readFile(path.join(f.base, ".git", "info", "exclude")) : null; const captured = (await captureRestorePoint({ cwd: f.cwd, upstreamCommits: [f.upstream] }))!; const id = randomUUID(), generation = 1, sealed = await sealRestorePoint(captured.payload, { companyId: f.companyId, workspaceId: f.workspaceId, restorePointId: id, generation }); @@ -697,6 +705,15 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { gitBranchOwnershipVersion: 1, verifiedAt: new Date().toISOString(), restorePoint, contents }, } }).where(eq(executionWorkspaces.id, f.workspaceId)); await git(f.base, "worktree", "remove", "--force", f.cwd); await git(f.base, "branch", "-D", f.branchName); + if (state === "source-excludes-symlink") { + const outside = path.join(root, "private-ignore.txt"); await fs.writeFile(outside, "/outside-private-pattern/\n"); + await fs.rm(path.join(f.base, ".git", "info", "exclude")); + await fs.symlink(outside, path.join(f.base, ".git", "info", "exclude")); + await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_archived_git_source_unavailable"); + expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); + expect(await fs.readFile(outside, "utf8")).toBe("/outside-private-pattern/\n"); + return; + } if (state.startsWith("missing-base")) await fs.rm(f.base, { recursive: true }); if (state === "unapproved-nested") { await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow(); @@ -784,6 +801,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { expect(operations[0]).toMatchObject({ id: interrupted.id, status: "succeeded" }); } const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!; + if (sourceExcludes) expect(await fs.readFile(path.join(f.base, ".git", "info", "exclude"))).toEqual(sourceExcludes); await fs.rm(f.base, { recursive: true, force: true }); await fs.rm(path.join(root, "nested-source"), { recursive: true, force: true }); await storage.deleteObject(f.companyId, objectKey); const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }); const archivePath = path.join(root, "retained-git.pcws"), targetDir = path.join(root, "restored-retained-git"); @@ -797,6 +815,13 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { } expect(await git(targetDir, "show", ":saved.txt")).toBe("staged edit"); expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("unstaged edit\n"); + if (state === "source-excludes") { + const recovered = path.join(targetDir, ".paperclip", "recovered-runs", "saved"); + expect(await fs.readFile(path.join(recovered, "repair-receipt.json"), "utf8")).toBe('{"preserved":true}\n'); + expect(await fs.readFile(path.join(recovered, "source-delta.bundle"))).toEqual(Buffer.from([0, 255, 17, 128])); + expect(await git(targetDir, "check-ignore", ".paperclip/recovered-runs/saved/repair-receipt.json")) + .toBe(".paperclip/recovered-runs/saved/repair-receipt.json"); + } }); async function retainDirectory(f: Awaited>) { const captured = await captureTaskDirectorySnapshot(f.cwd), id = randomUUID(), generation = 1;