mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
fix(workspaces): tolerate interrupted managed clone staging beside nested repositories (#19374)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A task workspace can hold managed nested repositories in `.paperclip-repositories/<name>`. Sandbox and SSH runs read each nested repository before they stage the workspace. > - The server stages a new nested clone in `.paperclip-repositories/<checkout>.clone-XXXXXX` and renames it when the clone is complete. If the process stops during the clone, this staging directory stays. > - Workspace preparation keeps `.clone-` entries on purpose. But the snapshot reader and the SSH listing reject every directory name that has a dot. One leftover staging directory makes every later sandbox or SSH run on that workspace fail with `Invalid project repository directory`. > - This pull request makes both readers skip only server-owned `<name>.clone-<suffix>` directories. Sandbox and SSH staging also exclude these directories from the upload and from the restore. > - The benefit is that an interrupted clone no longer blocks all later runs on the workspace. Other unexpected entries still fail closed. ## Linked Issues or Issue Description Refs #17382 **What happened?** A hosted staging run failed before the agent started. The error was `Error: Invalid project repository directory` from `readManagedWorkspaceRepositories`, through `readGitWorkspaceSnapshot` in `prepareSandboxManagedRuntime`. The server had restarted about five minutes before the run. The run was an automatic retry. **Expected behavior** A leftover clone staging directory from an interrupted clone does not stop later runs. The sandbox or SSH host does not receive the partial clone. The restore does not delete or change the host staging directory, because a concurrent preparation can own it. An unexpected directory that is not a staging directory still fails closed. **Steps to reproduce** 1. Create a Git workspace with a managed nested repository at `.paperclip-repositories/backend`. 2. Create the directory `.paperclip-repositories/backend.clone-Ab12Cd` with a `.git` directory and a file in it. This is the state after an interrupted clone. 3. Call `readGitWorkspaceSnapshot` on the workspace, or start a sandbox or SSH run on it. 4. Before this change, the call throws `Invalid project repository directory`. **Additional context** `materializeManagedProjectWorkspace` creates the staging directory with `fs.mkdtemp(\`${cwd}.clone-\`)`. Its cleanup does not run when the process stops. `prepareProjectRepositoryWorkspaces` already skips entries that contain `.clone-` when it retires stale entries. #17382 moved the common nested path to shared worktrees, but the fallback path still stages in place. #18534 protects nested `.git` directories during copy-back. It does not cover staging directories. ## What Changed - `git-workspace-sync.ts`: Add `PROJECT_REPOSITORY_STAGING_PATTERN` (`^[a-zA-Z0-9_-]+\.clone-[a-zA-Z0-9_-]+$`). `readManagedWorkspaceRepositories` skips a directory that matches it. Add `listProjectRepositoryStagingPaths`, which lists these directories. - `ssh.ts`: `listLocalProjectRepositories` skips the same directories. `prepareWorkspaceForSshExecution` excludes the staging paths from the upload when no completed repository exists, and returns them as `excludedPaths`. - `remote-managed-runtime.ts`: Add `excludedPaths` to the SSH workspace baseline excludes. The restore does not download them, and the merge does not change them on the host. - `sandbox-managed-runtime.ts`: Add the listed staging paths to the workspace archive excludes and to the restore excludes. - New test file `managed-clone-staging.test.ts`: - The snapshot and the SSH listing skip the staging directory and keep the nested repository. - An unexpected entry such as `backend.backup` still fails in both readers. - A sandbox round trip does not ship the staging directory, restores the nested repository change, and keeps the host staging directory. - An SSH round trip with only a staging directory does not ship it, restores a tracked-file change, and keeps the host staging directory and its `.git/HEAD`. ## Verification - `cd packages/adapter-utils && PATH=$PATH:/usr/sbin npx vitest run src/managed-clone-staging.test.ts`: 4 pass, with a real sshd. - Red: with the source changes reverted, the staging tests fail. Before the change, the reader fails with the exact production message `Invalid project repository directory`. Before the SSH fix, the SSH round trip failed with `expected 'present' to be 'absent'`. Without the restore exclude, the sandbox restore deletes the host staging directory. - `npx vitest run src/ssh-fixture.test.ts src/remote-managed-runtime.test.ts src/ssh-command-managed.test.ts src/managed-clone-staging.test.ts src/git-workspace-sync.test.ts src/sandbox-managed-runtime.test.ts`: 218 pass. `ssh-fixture.test.ts` ran all 25 tests with a real sshd. 3 tests in `git-workspace-sync.test.ts` fail (two "rejects a checkout during integration" cases and "does not inherit a poisoned GIT_CONFIG_GLOBAL"). They fail in the same way on unmodified master in the test sandbox, so they are environment failures. - `npx tsc --noEmit -p packages/adapter-utils`: passes. ## Risks - Low risk. The skip matches only the exact staging name shape that the server creates. Every other unexpected name still fails closed. - The excludes apply only to directories that exist when staging starts. A staging directory that appears during the run is not in the baseline, so the restore does not touch it. - A user directory with a name like `name.clone-x` in `.paperclip-repositories` is now ignored and not rejected. This directory belongs to the server, so this is the expected behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, test runs, GitHub connector). ## 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 - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
65c5d8d600
commit
e82210386f
5 files changed
+224
-7
No files matched your search
@@ -24,6 +24,7 @@ export interface GitWorkspaceSnapshot {
|
||||
}
|
||||
|
||||
export const PROJECT_REPOSITORIES_DIR = ".paperclip-repositories";
|
||||
export const PROJECT_REPOSITORY_STAGING_PATTERN = /^[a-zA-Z0-9_-]+\.clone-[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export interface ExpensiveWorkspaceGitInput {
|
||||
localDir: string;
|
||||
@@ -175,6 +176,15 @@ export function workspaceSnapshotTimeoutMs(): number {
|
||||
return Number.isFinite(configured) && configured >= 1000 ? Math.min(configured, 86_400_000) : 30 * 60_000;
|
||||
}
|
||||
|
||||
export async function listProjectRepositoryStagingPaths(localDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(path.join(localDir, PROJECT_REPOSITORIES_DIR), { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
|
||||
throw error;
|
||||
});
|
||||
return entries.filter((entry) => entry.isDirectory() && PROJECT_REPOSITORY_STAGING_PATTERN.test(entry.name))
|
||||
.map((entry) => `${PROJECT_REPOSITORIES_DIR}/${entry.name}`).sort();
|
||||
}
|
||||
|
||||
export async function readManagedWorkspaceRepositories(localDir: string, options: { signal?: AbortSignal } = {}): Promise<NonNullable<GitWorkspaceSnapshot["repositories"]>> {
|
||||
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
|
||||
try {
|
||||
@@ -187,6 +197,7 @@ export async function readManagedWorkspaceRepositories(localDir: string, options
|
||||
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() && PROJECT_REPOSITORY_STAGING_PATTERN.test(entry.name)) continue;
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readdir, 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 {
|
||||
disposeGitWorkspaceSnapshot,
|
||||
listProjectRepositoryStagingPaths,
|
||||
readGitWorkspaceSnapshot,
|
||||
type GitWorkspaceSnapshot,
|
||||
} from "./git-workspace-sync.js";
|
||||
import {
|
||||
prepareSandboxManagedRuntime,
|
||||
type SandboxManagedRuntimeClient,
|
||||
type SandboxSyncOperation,
|
||||
type SandboxSyncResult,
|
||||
} from "./sandbox-managed-runtime.js";
|
||||
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
|
||||
import {
|
||||
buildSshEnvLabFixtureConfig,
|
||||
getSshEnvLabSupport,
|
||||
listLocalProjectRepositories,
|
||||
runSshCommand,
|
||||
startSshEnvLabFixture,
|
||||
stopSshEnvLabFixture,
|
||||
} from "./ssh.js";
|
||||
import { workspacePaths } from "./workspace-manifest.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const STAGING_PATH = ".paperclip-repositories/backend.clone-Ab12Cd";
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFile("git", ["-C", cwd, ...args], { maxBuffer: 32 * 1024 * 1024 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function createRepo(cwd: string, contents: string): Promise<void> {
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await git(cwd, ["init", "-b", "main"]);
|
||||
await git(cwd, ["config", "user.name", "Paperclip Test"]);
|
||||
await git(cwd, ["config", "user.email", "test@paperclip.dev"]);
|
||||
await writeFile(path.join(cwd, "README.md"), contents);
|
||||
await git(cwd, ["add", "."]);
|
||||
await git(cwd, ["commit", "-m", contents]);
|
||||
}
|
||||
|
||||
function makeFilesystemClient(): SandboxManagedRuntimeClient {
|
||||
const client: SandboxManagedRuntimeClient = {
|
||||
makeDir: async (remotePath) => {
|
||||
await mkdir(remotePath, { recursive: true });
|
||||
},
|
||||
writeFile: async (remotePath, bytes) => {
|
||||
await mkdir(path.dirname(remotePath), { recursive: true });
|
||||
await writeFile(remotePath, Buffer.from(bytes));
|
||||
},
|
||||
readFile: async (remotePath) => await readFile(remotePath),
|
||||
listFiles: async (remotePath) => {
|
||||
const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
|
||||
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
|
||||
},
|
||||
remove: async (remotePath) => {
|
||||
await rm(remotePath, { recursive: true, force: true });
|
||||
},
|
||||
run: async (command) => {
|
||||
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
|
||||
},
|
||||
};
|
||||
client.syncIn = async (operations: SandboxSyncOperation[]): Promise<SandboxSyncResult> => {
|
||||
const resultOperations: SandboxSyncResult["operations"] = [];
|
||||
for (const operation of operations) {
|
||||
let filesTransferred = 0;
|
||||
let bytesTransferred = 0;
|
||||
for (const mapping of operation.files) {
|
||||
const bytes = await readFile(mapping.sourcePath);
|
||||
await mkdir(path.posix.dirname(mapping.targetPath), { recursive: true });
|
||||
await writeFile(mapping.targetPath, bytes);
|
||||
filesTransferred += 1;
|
||||
bytesTransferred += bytes.byteLength;
|
||||
}
|
||||
for (const command of operation.postUploadCommands ?? []) {
|
||||
await execFile("sh", ["-c", command.command], { maxBuffer: 32 * 1024 * 1024 });
|
||||
}
|
||||
resultOperations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
|
||||
}
|
||||
return { operations: resultOperations };
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("interrupted managed clone staging", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
const snapshots: GitWorkspaceSnapshot[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const snapshot of snapshots.splice(0)) await disposeGitWorkspaceSnapshot(snapshot);
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
async function createWorkspace(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
cleanupDirs.push(root);
|
||||
const local = path.join(root, "local");
|
||||
await createRepo(local, "frontend");
|
||||
await createRepo(path.join(local, ".paperclip-repositories/backend"), "backend");
|
||||
await writeFile(path.join(local, ".git/info/exclude"), ".paperclip-repositories/\n");
|
||||
await mkdir(path.join(local, STAGING_PATH, ".git"), { recursive: true });
|
||||
await writeFile(path.join(local, STAGING_PATH, "partial.txt"), "interrupted clone");
|
||||
return local;
|
||||
}
|
||||
|
||||
it("skips the staging directory when reading nested repositories", async () => {
|
||||
const local = await createWorkspace("paperclip-clone-staging-snapshot-");
|
||||
|
||||
const snapshot = await readGitWorkspaceSnapshot(local);
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
|
||||
expect(snapshot?.repositories?.map((entry) => entry.path)).toEqual([".paperclip-repositories/backend"]);
|
||||
const paths = [
|
||||
...workspacePaths(snapshot!.overlayPaths),
|
||||
...workspacePaths(snapshot!.deletedPaths),
|
||||
...workspacePaths(snapshot!.ignoredPaths),
|
||||
];
|
||||
expect(paths.some((entry) => entry.includes(".clone-"))).toBe(false);
|
||||
expect(await listProjectRepositoryStagingPaths(local)).toEqual([STAGING_PATH]);
|
||||
expect(await listLocalProjectRepositories(local)).toEqual([".paperclip-repositories/backend"]);
|
||||
});
|
||||
|
||||
it("still rejects an unexpected entry in the nested repositories directory", async () => {
|
||||
const local = await createWorkspace("paperclip-clone-staging-invalid-");
|
||||
await mkdir(path.join(local, ".paperclip-repositories/backend.backup"));
|
||||
|
||||
await expect(readGitWorkspaceSnapshot(local)).rejects.toThrow("Invalid project repository directory");
|
||||
await expect(listLocalProjectRepositories(local)).rejects.toThrow("Invalid project repository directory");
|
||||
});
|
||||
|
||||
it("neither ships nor deletes the staging directory during a sandbox round trip", async () => {
|
||||
const local = await createWorkspace("paperclip-clone-staging-sandbox-");
|
||||
const remote = path.join(path.dirname(local), "remote");
|
||||
const backend = ".paperclip-repositories/backend";
|
||||
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: { transport: "sandbox", provider: "test", sandboxId: "clone-staging", remoteCwd: remote, timeoutMs: 30_000, apiKey: null },
|
||||
client: makeFilesystemClient(),
|
||||
adapterKey: "test",
|
||||
workspaceLocalDir: local,
|
||||
});
|
||||
|
||||
expect(await readFile(path.join(remote, backend, "README.md"), "utf8")).toBe("backend");
|
||||
await expect(stat(path.join(remote, STAGING_PATH))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await writeFile(path.join(remote, backend, "README.md"), "updated backend");
|
||||
await prepared.restoreWorkspace();
|
||||
expect(await readFile(path.join(local, backend, "README.md"), "utf8")).toBe("updated backend");
|
||||
expect(await readFile(path.join(local, STAGING_PATH, "partial.txt"), "utf8")).toBe("interrupted clone");
|
||||
}, 30_000);
|
||||
|
||||
it("neither ships nor deletes the staging directory during an SSH round trip without completed repositories", async () => {
|
||||
const support = await getSshEnvLabSupport();
|
||||
if (!support.supported) return;
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-clone-staging-ssh-"));
|
||||
cleanupDirs.push(root);
|
||||
const local = path.join(root, "local");
|
||||
await createRepo(local, "frontend");
|
||||
await writeFile(path.join(local, ".git/info/exclude"), ".paperclip-repositories/\n");
|
||||
await mkdir(path.join(local, STAGING_PATH, ".git"), { recursive: true });
|
||||
await writeFile(path.join(local, STAGING_PATH, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
await writeFile(path.join(local, STAGING_PATH, "partial.txt"), "interrupted clone");
|
||||
|
||||
const fixtureRoot = path.join(root, "fixture");
|
||||
await mkdir(fixtureRoot, { recursive: true });
|
||||
const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") });
|
||||
try {
|
||||
const config = await buildSshEnvLabFixtureConfig(fixture);
|
||||
const prepared = await prepareRemoteManagedRuntime({
|
||||
spec: { ...config, remoteCwd: fixture.workspaceDir },
|
||||
runId: "clone-staging",
|
||||
adapterKey: "test",
|
||||
workspaceLocalDir: local,
|
||||
});
|
||||
const remoteStaging = path.posix.join(prepared.workspaceRemoteDir, STAGING_PATH);
|
||||
const listing = await runSshCommand(config, `if [ -e ${JSON.stringify(remoteStaging)} ]; then echo present; else echo absent; fi`);
|
||||
expect(listing.stdout.trim()).toBe("absent");
|
||||
await runSshCommand(config, `printf "updated frontend" > ${JSON.stringify(path.posix.join(prepared.workspaceRemoteDir, "README.md"))}`);
|
||||
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
expect(await readFile(path.join(local, "README.md"), "utf8")).toBe("updated frontend");
|
||||
expect(await readFile(path.join(local, STAGING_PATH, "partial.txt"), "utf8")).toBe("interrupted clone");
|
||||
expect(await readFile(path.join(local, STAGING_PATH, ".git", "HEAD"), "utf8")).toBe("ref: refs/heads/main\n");
|
||||
} finally {
|
||||
await stopSshEnvLabFixture(fixture);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -152,9 +152,9 @@ export async function prepareRemoteManagedRuntime(input: {
|
||||
? [
|
||||
...GIT_ARCHIVE_EXCLUDES,
|
||||
".paperclip-runtime",
|
||||
...(projectRepositories.length > 0 ? [PROJECT_REPOSITORIES_DIR] : []),
|
||||
...(projectRepositories.length > 0 ? [PROJECT_REPOSITORIES_DIR] : preparedWorkspace.excludedPaths ?? []),
|
||||
]
|
||||
: [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
|
||||
: [".paperclip-runtime", ...(preparedWorkspace.excludedPaths ?? []), ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
|
||||
})
|
||||
: null;
|
||||
const repositoryBaselines: Array<{ path: string; baselineSnapshot: DirectorySnapshot }> = [];
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
integrateImportedGitHead,
|
||||
readGitWorkspaceSnapshot,
|
||||
readManagedWorkspaceRepositories,
|
||||
listProjectRepositoryStagingPaths,
|
||||
copyCloneTree,
|
||||
disposeGitWorkspaceSnapshot,
|
||||
ReferencedSourceIgnoreScanLimitExceededError,
|
||||
@@ -1390,6 +1391,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
return [`${relative}/.git`, ...repositoryGitPaths(repo.snapshot.repositories ?? [], relative)];
|
||||
});
|
||||
const repositoryMetadataExcludes = repositoryGitPaths(repositories);
|
||||
const repositoryStagingExcludes = syncWorkspace ? await listProjectRepositoryStagingPaths(input.workspaceLocalDir) : [];
|
||||
let managedIgnored: Awaited<ReturnType<typeof createWorkspaceManifest>> | null = null;
|
||||
let ignoredPaths = input.workspaceFileMode === "all" ? undefined : gitSnapshot?.ignoredPaths;
|
||||
if (!gitSnapshot && repositories.length) {
|
||||
@@ -1426,6 +1428,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
const workspaceArchiveExclude = mergeExcludes(
|
||||
[".paperclip-runtime"],
|
||||
repositoryMetadataExcludes,
|
||||
repositoryStagingExcludes,
|
||||
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
|
||||
input.workspaceFileMode === "all" && !gitSnapshot ? [] : [...GIT_ARCHIVE_EXCLUDES],
|
||||
input.workspaceExclude,
|
||||
@@ -1433,6 +1436,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
);
|
||||
const restoreExclude = mergeExcludes(
|
||||
repositoryMetadataExcludes,
|
||||
repositoryStagingExcludes,
|
||||
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
|
||||
input.workspaceFileMode === "all" && !gitSnapshot ? [] : [...GIT_ARCHIVE_EXCLUDES],
|
||||
[".paperclip-runtime"],
|
||||
|
||||
@@ -9,7 +9,9 @@ import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
||||
import {
|
||||
createUnrelatedHistoryGraftCommit,
|
||||
GIT_SYNC_COMMIT_IDENTITY_ARGS,
|
||||
listProjectRepositoryStagingPaths,
|
||||
PROJECT_REPOSITORIES_DIR,
|
||||
PROJECT_REPOSITORY_STAGING_PATTERN,
|
||||
readSanitizedOriginRemoteUrl,
|
||||
} from "./git-workspace-sync.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
@@ -1100,6 +1102,7 @@ export async function listLocalProjectRepositories(localDir: string): Promise<st
|
||||
}
|
||||
const repositories: string[] = [];
|
||||
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && PROJECT_REPOSITORY_STAGING_PATTERN.test(entry.name)) continue;
|
||||
if (!entry.isDirectory() || !PROJECT_REPOSITORY_NAME_PATTERN.test(entry.name)) {
|
||||
throw new Error("Invalid project repository directory");
|
||||
}
|
||||
@@ -1644,9 +1647,11 @@ export async function prepareWorkspaceForSshExecution(input: {
|
||||
onProgress?: RuntimeProgressSink;
|
||||
workspaceFileMode?: "all";
|
||||
workspaceExclude?: string[];
|
||||
}): Promise<{ gitBacked: boolean; repositories?: string[] }> {
|
||||
}): Promise<{ gitBacked: boolean; repositories?: string[]; excludedPaths?: string[] }> {
|
||||
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
|
||||
const gitSnapshot = input.workspaceFileMode === "all" ? null : await readLocalGitWorkspaceSnapshot(input.localDir);
|
||||
const excludedPaths = await listProjectRepositoryStagingPaths(input.localDir);
|
||||
const excludedResult = excludedPaths.length > 0 ? { excludedPaths } : {};
|
||||
|
||||
if (gitSnapshot) {
|
||||
const repositories = await listLocalProjectRepositories(input.localDir);
|
||||
@@ -1655,7 +1660,7 @@ export async function prepareWorkspaceForSshExecution(input: {
|
||||
localDir: input.localDir,
|
||||
remoteDir,
|
||||
snapshot: gitSnapshot,
|
||||
exclude: repositories.length > 0 ? [PROJECT_REPOSITORIES_DIR] : [],
|
||||
exclude: repositories.length > 0 ? [PROJECT_REPOSITORIES_DIR] : excludedPaths,
|
||||
onProgress: input.onProgress,
|
||||
progressLabel: "workspace",
|
||||
});
|
||||
@@ -1675,7 +1680,7 @@ export async function prepareWorkspaceForSshExecution(input: {
|
||||
progressLabel: relative,
|
||||
});
|
||||
}
|
||||
return { gitBacked: true, ...(repositories.length > 0 ? { repositories } : {}) };
|
||||
return { gitBacked: true, ...(repositories.length > 0 ? { repositories } : {}), ...excludedResult };
|
||||
}
|
||||
|
||||
await clearRemoteDirectory({
|
||||
@@ -1687,11 +1692,11 @@ export async function prepareWorkspaceForSshExecution(input: {
|
||||
spec: input.spec,
|
||||
localDir: input.localDir,
|
||||
remoteDir,
|
||||
exclude: [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
|
||||
exclude: [".paperclip-runtime", ...excludedPaths, ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
|
||||
onProgress: input.onProgress,
|
||||
progressLabel: "workspace",
|
||||
});
|
||||
return { gitBacked: false };
|
||||
return { gitBacked: false, ...excludedResult };
|
||||
}
|
||||
|
||||
export async function restoreWorkspaceFromSshExecution(input: {
|
||||
|
||||
Reference in new issue
Block a user