@tickernelz/paperclip-pro-adapter-utils 2026.1006.0 → 2026.1006.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts +4 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.js +29 -0
- package/dist/acpx-engine/ephemeral-session-environment.js.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts +2 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js +177 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +2 -6
- package/dist/acpx-engine/execute.d.ts.map +1 -1
- package/dist/acpx-engine/execute.js +125 -62
- package/dist/acpx-engine/execute.js.map +1 -1
- package/dist/acpx-engine/execute.test.js +299 -3
- package/dist/acpx-engine/execute.test.js.map +1 -1
- package/dist/acpx-engine/session-codec.d.ts.map +1 -1
- package/dist/acpx-engine/session-codec.js +1 -0
- package/dist/acpx-engine/session-codec.js.map +1 -1
- package/dist/acpx-engine/settlement-characterization.test.js +105 -0
- package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
- package/dist/acpx-engine/spawn-smoke.test.js +16 -7
- package/dist/acpx-engine/spawn-smoke.test.js.map +1 -1
- package/dist/acpx-engine/startup-cancellation.d.ts +1 -0
- package/dist/acpx-engine/startup-cancellation.d.ts.map +1 -1
- package/dist/acpx-engine/startup-cancellation.js +7 -2
- package/dist/acpx-engine/startup-cancellation.js.map +1 -1
- package/dist/acpx-engine/terminal-session-failure.d.ts +18 -0
- package/dist/acpx-engine/terminal-session-failure.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.js +106 -0
- package/dist/acpx-engine/terminal-session-failure.js.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts +2 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.js +139 -0
- package/dist/acpx-engine/terminal-session-failure.test.js.map +1 -0
- package/dist/acpx-engine/turn-characterization.test.js +4 -3
- package/dist/acpx-engine/turn-characterization.test.js.map +1 -1
- package/dist/command-managed-runtime.d.ts +2 -0
- package/dist/command-managed-runtime.d.ts.map +1 -1
- package/dist/command-managed-runtime.js +2 -0
- package/dist/command-managed-runtime.js.map +1 -1
- package/dist/command-redaction.d.ts +2 -0
- package/dist/command-redaction.d.ts.map +1 -1
- package/dist/command-redaction.js +20 -10
- package/dist/command-redaction.js.map +1 -1
- package/dist/command-redaction.test.js +20 -1
- package/dist/command-redaction.test.js.map +1 -1
- package/dist/directory-merge-lock.test.d.ts +2 -0
- package/dist/directory-merge-lock.test.d.ts.map +1 -0
- package/dist/directory-merge-lock.test.js +166 -0
- package/dist/directory-merge-lock.test.js.map +1 -0
- package/dist/execution-phase.d.ts +10 -0
- package/dist/execution-phase.d.ts.map +1 -0
- package/dist/execution-phase.js +27 -0
- package/dist/execution-phase.js.map +1 -0
- package/dist/execution-phase.test.d.ts +2 -0
- package/dist/execution-phase.test.d.ts.map +1 -0
- package/dist/execution-phase.test.js +26 -0
- package/dist/execution-phase.test.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +49 -7
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target-stdin-race.test.js +245 -2
- package/dist/execution-target-stdin-race.test.js.map +1 -1
- package/dist/execution-target.d.ts +6 -0
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +74 -22
- package/dist/execution-target.js.map +1 -1
- package/dist/git-workspace-sync.d.ts +15 -5
- package/dist/git-workspace-sync.d.ts.map +1 -1
- package/dist/git-workspace-sync.js +267 -124
- package/dist/git-workspace-sync.js.map +1 -1
- package/dist/git-workspace-sync.test.js +296 -20
- package/dist/git-workspace-sync.test.js.map +1 -1
- package/dist/github-launcher.d.ts.map +1 -1
- package/dist/github-launcher.js +26 -11
- package/dist/github-launcher.js.map +1 -1
- package/dist/github-launcher.test.js +34 -1
- package/dist/github-launcher.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paperclip-runner-permissions.d.ts +28 -1
- package/dist/paperclip-runner-permissions.d.ts.map +1 -1
- package/dist/paperclip-runner-permissions.js +9 -1
- package/dist/paperclip-runner-permissions.js.map +1 -1
- package/dist/prompt-sections.test.d.ts +2 -0
- package/dist/prompt-sections.test.d.ts.map +1 -0
- package/dist/prompt-sections.test.js +53 -0
- package/dist/prompt-sections.test.js.map +1 -0
- package/dist/provider-stopped-boundary.d.ts +7 -0
- package/dist/provider-stopped-boundary.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.js +22 -0
- package/dist/provider-stopped-boundary.js.map +1 -0
- package/dist/provider-stopped-boundary.test.d.ts +2 -0
- package/dist/provider-stopped-boundary.test.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.test.js +76 -0
- package/dist/provider-stopped-boundary.test.js.map +1 -0
- package/dist/remote-managed-runtime.d.ts +2 -0
- package/dist/remote-managed-runtime.d.ts.map +1 -1
- package/dist/remote-managed-runtime.js +3 -1
- package/dist/remote-managed-runtime.js.map +1 -1
- package/dist/remote-managed-runtime.test.js +25 -0
- package/dist/remote-managed-runtime.test.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts +2 -0
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +34 -10
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +106 -3
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/sandbox-managed-runtime.d.ts +3 -0
- package/dist/sandbox-managed-runtime.d.ts.map +1 -1
- package/dist/sandbox-managed-runtime.js +749 -718
- package/dist/sandbox-managed-runtime.js.map +1 -1
- package/dist/sandbox-managed-runtime.test.js +171 -2
- package/dist/sandbox-managed-runtime.test.js.map +1 -1
- package/dist/server-utils.d.ts +43 -1
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +103 -31
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +235 -45
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +6 -0
- package/dist/session-compaction.js.map +1 -1
- package/dist/ssh-fixture.test.js +22 -0
- package/dist/ssh-fixture.test.js.map +1 -1
- package/dist/ssh.d.ts +2 -0
- package/dist/ssh.d.ts.map +1 -1
- package/dist/ssh.js +2 -2
- package/dist/ssh.js.map +1 -1
- package/dist/test-fixtures/prompt-context.d.ts +54 -0
- package/dist/test-fixtures/prompt-context.d.ts.map +1 -0
- package/dist/test-fixtures/prompt-context.js +55 -0
- package/dist/test-fixtures/prompt-context.js.map +1 -0
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace-git-stream.d.ts +19 -0
- package/dist/workspace-git-stream.d.ts.map +1 -0
- package/dist/workspace-git-stream.js +99 -0
- package/dist/workspace-git-stream.js.map +1 -0
- package/dist/workspace-git-stream.test.d.ts +2 -0
- package/dist/workspace-git-stream.test.d.ts.map +1 -0
- package/dist/workspace-git-stream.test.js +71 -0
- package/dist/workspace-git-stream.test.js.map +1 -0
- package/dist/workspace-manifest.d.ts +61 -0
- package/dist/workspace-manifest.d.ts.map +1 -0
- package/dist/workspace-manifest.js +257 -0
- package/dist/workspace-manifest.js.map +1 -0
- package/dist/workspace-manifest.test.d.ts +2 -0
- package/dist/workspace-manifest.test.d.ts.map +1 -0
- package/dist/workspace-manifest.test.js +160 -0
- package/dist/workspace-manifest.test.js.map +1 -0
- package/dist/workspace-restore-diagnostics.d.ts +34 -0
- package/dist/workspace-restore-diagnostics.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.js +238 -0
- package/dist/workspace-restore-diagnostics.js.map +1 -0
- package/dist/workspace-restore-diagnostics.test.d.ts +2 -0
- package/dist/workspace-restore-diagnostics.test.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.test.js +258 -0
- package/dist/workspace-restore-diagnostics.test.js.map +1 -0
- package/dist/workspace-restore-merge.d.ts +47 -6
- package/dist/workspace-restore-merge.d.ts.map +1 -1
- package/dist/workspace-restore-merge.js +382 -114
- package/dist/workspace-restore-merge.js.map +1 -1
- package/dist/workspace-restore-merge.test.js +253 -4
- package/dist/workspace-restore-merge.test.js.map +1 -1
- package/dist/workspace-restore-result.d.ts +6 -0
- package/dist/workspace-restore-result.d.ts.map +1 -0
- package/dist/workspace-restore-result.js +72 -0
- package/dist/workspace-restore-result.js.map +1 -0
- package/dist/workspace-restore-result.test.d.ts +2 -0
- package/dist/workspace-restore-result.test.d.ts.map +1 -0
- package/dist/workspace-restore-result.test.js +74 -0
- package/dist/workspace-restore-result.test.js.map +1 -0
- package/dist/workspace-restore-teardown.d.ts.map +1 -1
- package/dist/workspace-restore-teardown.js +5 -3
- package/dist/workspace-restore-teardown.js.map +1 -1
- package/dist/workspace-restore-teardown.test.js +24 -0
- package/dist/workspace-restore-teardown.test.js.map +1 -1
- package/node_modules/acpx/dist/live-checkpoint-ClPCSdrW.js +4 -3
- package/package.json +2 -2
|
@@ -3,6 +3,9 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { createWorkspaceManifest, workspacePaths, WorkspaceNulParser } from "./workspace-manifest.js";
|
|
7
|
+
import { runWorkspaceGitProcess } from "./workspace-git-stream.js";
|
|
8
|
+
import { preserveWorkspaceRestoreErrorDiagnostic, withWorkspaceRestoreGitCommand } from "./workspace-restore-diagnostics.js";
|
|
6
9
|
export const PROJECT_REPOSITORIES_DIR = ".paperclip-repositories";
|
|
7
10
|
let expensiveWorkspaceGitExecutor = null;
|
|
8
11
|
/**
|
|
@@ -20,7 +23,7 @@ export const WORKSPACE_GIT_SCAN_SATURATED_CODE = "workspace_git_scan_saturated";
|
|
|
20
23
|
/**
|
|
21
24
|
* Lets a host process apply its process-wide admission policy to the adapter
|
|
22
25
|
* package's full-tree Git walks. Standalone adapter-utils consumers retain the
|
|
23
|
-
*
|
|
26
|
+
* streaming process fallback for filename scans.
|
|
24
27
|
*/
|
|
25
28
|
export function setExpensiveWorkspaceGitExecutor(executor) {
|
|
26
29
|
expensiveWorkspaceGitExecutor = executor;
|
|
@@ -72,33 +75,36 @@ async function runExpensiveWorkspaceGit(localDir, args, operation, options) {
|
|
|
72
75
|
timeout: options.timeout,
|
|
73
76
|
maxBuffer: options.maxBuffer,
|
|
74
77
|
env: options.env,
|
|
78
|
+
onStdout: options.onStdout,
|
|
79
|
+
signal: options.signal,
|
|
75
80
|
});
|
|
76
81
|
}
|
|
82
|
+
if (options.onStdout)
|
|
83
|
+
return runWorkspaceGitProcess({
|
|
84
|
+
cwd: localDir, args, timeoutMs: options.timeout, maxStdoutBytes: options.maxBuffer,
|
|
85
|
+
maxStderrBytes: options.maxBuffer, onStdout: options.onStdout, signal: options.signal, env: options.env,
|
|
86
|
+
});
|
|
77
87
|
return await runLocalGit(localDir, args, options);
|
|
78
88
|
}
|
|
79
|
-
|
|
89
|
+
const ownedSnapshots = new WeakMap();
|
|
90
|
+
export async function disposeGitWorkspaceSnapshot(snapshot) {
|
|
91
|
+
if (!snapshot)
|
|
92
|
+
return;
|
|
93
|
+
const ownedDirectory = ownedSnapshots.get(snapshot);
|
|
94
|
+
if (!ownedDirectory)
|
|
95
|
+
return;
|
|
96
|
+
ownedSnapshots.delete(snapshot);
|
|
97
|
+
for (const repository of snapshot.repositories ?? [])
|
|
98
|
+
await disposeGitWorkspaceSnapshot(repository.snapshot);
|
|
99
|
+
await fs.rm(ownedDirectory, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
/** Snapshot deadlines include disk backpressure. Operators can allow up to 24h. */
|
|
102
|
+
export function workspaceSnapshotTimeoutMs() {
|
|
103
|
+
const configured = Number(process.env.PAPERCLIP_WORKSPACE_GIT_SNAPSHOT_TIMEOUT_MS);
|
|
104
|
+
return Number.isFinite(configured) && configured >= 1000 ? Math.min(configured, 86_400_000) : 30 * 60_000;
|
|
105
|
+
}
|
|
106
|
+
export async function readGitWorkspaceSnapshot(localDir, includeRepositories = true, options = {}) {
|
|
80
107
|
const repositories = [];
|
|
81
|
-
if (includeRepositories) {
|
|
82
|
-
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
|
|
83
|
-
const rootStat = await fs.lstat(root).catch((error) => {
|
|
84
|
-
if (error.code === "ENOENT")
|
|
85
|
-
return null;
|
|
86
|
-
throw error;
|
|
87
|
-
});
|
|
88
|
-
if (rootStat) {
|
|
89
|
-
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
90
|
-
throw new Error("Invalid project repositories directory");
|
|
91
|
-
for (const entry of (await fs.readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
92
|
-
if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name))
|
|
93
|
-
throw new Error("Invalid project repository directory");
|
|
94
|
-
const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
|
|
95
|
-
const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false);
|
|
96
|
-
if (!snapshot)
|
|
97
|
-
throw new Error(`Project repository is not a Git checkout: ${relative}`);
|
|
98
|
-
repositories.push({ path: relative, snapshot });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
108
|
// Only repository discovery may report an ordinary directory. A failed
|
|
103
109
|
// snapshot of a confirmed repository must never fall back to directory sync.
|
|
104
110
|
let insideWorkTree;
|
|
@@ -129,64 +135,106 @@ export async function readGitWorkspaceSnapshot(localDir, includeRepositories = t
|
|
|
129
135
|
]);
|
|
130
136
|
if (workspacePath !== repositoryPath)
|
|
131
137
|
return null;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
138
|
+
const writer = await createWorkspaceManifest();
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const abort = () => controller.abort();
|
|
141
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
142
|
+
if (options.signal?.aborted)
|
|
143
|
+
abort();
|
|
144
|
+
try {
|
|
145
|
+
if (includeRepositories) {
|
|
146
|
+
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
|
|
147
|
+
const rootStat = await fs.lstat(root).catch((error) => {
|
|
148
|
+
if (error.code === "ENOENT")
|
|
149
|
+
return null;
|
|
150
|
+
throw error;
|
|
151
|
+
});
|
|
152
|
+
if (rootStat) {
|
|
153
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
154
|
+
throw new Error("Invalid project repositories directory");
|
|
155
|
+
for await (const entry of await fs.opendir(root)) {
|
|
156
|
+
if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name))
|
|
157
|
+
throw new Error("Invalid project repository directory");
|
|
158
|
+
const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
|
|
159
|
+
const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false, options);
|
|
160
|
+
if (!snapshot)
|
|
161
|
+
throw new Error(`Project repository is not a Git checkout: ${relative}`);
|
|
162
|
+
repositories.push({ path: relative, snapshot });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const scan = async (args, operation, category) => {
|
|
167
|
+
const parser = new WorkspaceNulParser((record) => {
|
|
168
|
+
const relative = category === "ignored" ? record.replace(/\/+$/, "") : record;
|
|
169
|
+
if (category === "ignored" && repositories.length && relative === PROJECT_REPOSITORIES_DIR)
|
|
170
|
+
return;
|
|
171
|
+
// Managed children have their own exact file selections. Never stage a
|
|
172
|
+
// parent Git directory record that could admit files created later.
|
|
173
|
+
if (repositories.some((repo) => relative === repo.path || relative.startsWith(`${repo.path}/`)))
|
|
174
|
+
return;
|
|
175
|
+
writer.add(category, relative);
|
|
176
|
+
});
|
|
177
|
+
const result = await runExpensiveWorkspaceGit(localDir, args, operation, {
|
|
178
|
+
timeout: workspaceSnapshotTimeoutMs(), maxBuffer: 64 * 1024,
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
onStdout: (chunk) => writer.batch(() => parser.write(chunk)),
|
|
181
|
+
});
|
|
182
|
+
if (result.stdout)
|
|
183
|
+
throw new Error("Workspace Git executor did not stream stdout");
|
|
184
|
+
try {
|
|
185
|
+
parser.finish();
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
throw Object.assign(error, { code: "workspace_git_scan_failed" });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
// Cancellation is followed by an all-settled barrier BEFORE storage cleanup.
|
|
192
|
+
const tasks = [
|
|
193
|
+
runLocalGit(localDir, ["rev-parse", "HEAD"], { timeout: 10_000, maxBuffer: 16 * 1024 }),
|
|
194
|
+
runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: 10_000, maxBuffer: 16 * 1024 }),
|
|
195
|
+
scan(["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], "adapter_sync.overlay_diff", "overlay"),
|
|
196
|
+
scan(["ls-files", "--others", "--exclude-standard", "-z"], "adapter_sync.untracked_files", "overlay"),
|
|
197
|
+
scan(["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], "adapter_sync.deleted_files", "deleted"),
|
|
198
|
+
scan(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], "adapter_sync.ignored_files", "ignored"),
|
|
199
|
+
];
|
|
200
|
+
let firstError;
|
|
201
|
+
const settled = await Promise.allSettled(tasks.map((task) => task.catch((error) => {
|
|
202
|
+
if (firstError === undefined)
|
|
203
|
+
firstError = error;
|
|
204
|
+
controller.abort();
|
|
205
|
+
throw error;
|
|
206
|
+
})));
|
|
207
|
+
if (firstError !== undefined)
|
|
208
|
+
throw firstError;
|
|
209
|
+
if (controller.signal.aborted)
|
|
210
|
+
throw Object.assign(new Error("Workspace snapshot cancelled"), { code: "workspace_git_scan_cancelled" });
|
|
211
|
+
for (const repo of repositories) {
|
|
212
|
+
for (const [category, paths] of [["overlay", repo.snapshot.overlayPaths], ["deleted", repo.snapshot.deletedPaths], ["ignored", repo.snapshot.ignoredPaths]]) {
|
|
213
|
+
for (const relative of workspacePaths(paths))
|
|
214
|
+
writer.add(category, `${repo.path}/${relative}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const head = settled[0].value.stdout.trim();
|
|
218
|
+
const branch = settled[1].value.stdout.trim();
|
|
219
|
+
const snapshot = {
|
|
220
|
+
headCommit: head, branchName: branch && branch !== "HEAD" ? branch : null,
|
|
221
|
+
overlayPaths: writer.paths("overlay"), deletedPaths: writer.paths("deleted"), ignoredPaths: writer.paths("ignored"),
|
|
222
|
+
...(repositories.length ? { repositories } : {}),
|
|
223
|
+
};
|
|
224
|
+
writer.close();
|
|
225
|
+
ownedSnapshots.set(snapshot, path.dirname(writer.filePath));
|
|
226
|
+
return snapshot;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
writer.close(false);
|
|
230
|
+
for (const repo of repositories)
|
|
231
|
+
await disposeGitWorkspaceSnapshot(repo.snapshot);
|
|
232
|
+
await fs.rm(path.dirname(writer.filePath), { recursive: true, force: true });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
options.signal?.removeEventListener("abort", abort);
|
|
237
|
+
}
|
|
190
238
|
}
|
|
191
239
|
/**
|
|
192
240
|
* Build the environment for a hardened, read-only Git invocation against a
|
|
@@ -433,6 +481,21 @@ export async function readSanitizedOriginRemoteUrl(localDir) {
|
|
|
433
481
|
return null;
|
|
434
482
|
}
|
|
435
483
|
}
|
|
484
|
+
async function copyCloneTree(source, target) {
|
|
485
|
+
await fs.mkdir(target, { recursive: true });
|
|
486
|
+
for await (const entry of await fs.opendir(source)) {
|
|
487
|
+
const from = path.join(source, entry.name);
|
|
488
|
+
const to = path.join(target, entry.name);
|
|
489
|
+
if (entry.isDirectory())
|
|
490
|
+
await copyCloneTree(from, to);
|
|
491
|
+
else if (entry.isSymbolicLink())
|
|
492
|
+
await fs.symlink(await fs.readlink(from), to);
|
|
493
|
+
else {
|
|
494
|
+
await fs.copyFile(from, to);
|
|
495
|
+
await fs.chmod(to, (await fs.stat(from)).mode);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
436
499
|
export async function withShallowGitWorkspaceClone(input, fn) {
|
|
437
500
|
const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-"));
|
|
438
501
|
const tempRef = `refs/paperclip/git-sync/import/${randomUUID()}`;
|
|
@@ -481,10 +544,7 @@ export async function withShallowGitWorkspaceClone(input, fn) {
|
|
|
481
544
|
// Preserve repository-relative links. fs.cp otherwise rewrites them to
|
|
482
545
|
// absolute paths into nestedClone, which is deleted after this callback
|
|
483
546
|
// and is outside the workspace when the sandbox restores its files.
|
|
484
|
-
await
|
|
485
|
-
recursive: true,
|
|
486
|
-
verbatimSymlinks: true,
|
|
487
|
-
});
|
|
547
|
+
await copyCloneTree(nestedClone, path.join(cloneDir, repository.path));
|
|
488
548
|
});
|
|
489
549
|
}
|
|
490
550
|
if (input.snapshot.repositories?.length) {
|
|
@@ -586,6 +646,12 @@ export function buildRemoteGitDeltaBundleScript(input) {
|
|
|
586
646
|
` bundle_base=""`,
|
|
587
647
|
"fi",
|
|
588
648
|
]),
|
|
649
|
+
// An empty bundle means "still at baseSha" to the importer. A reset to an
|
|
650
|
+
// older ancestor has no delta commits either, but must carry its new tip.
|
|
651
|
+
// Use the full-bundle path so Git advertises that tip instead of losing it.
|
|
652
|
+
`if [ -n "$bundle_base" ] && [ "$bundle_base" != ${baseSha} ] && [ "$bundle_base" = "$(git -C ${remoteDir} rev-parse HEAD)" ]; then`,
|
|
653
|
+
` bundle_base=""`,
|
|
654
|
+
"fi",
|
|
589
655
|
`if [ -n "$bundle_base" ]; then`,
|
|
590
656
|
` commit_count=$(git -C ${remoteDir} rev-list --count HEAD --not "$bundle_base")`,
|
|
591
657
|
"else",
|
|
@@ -603,16 +669,22 @@ export function buildRemoteGitDeltaBundleScript(input) {
|
|
|
603
669
|
"fi",
|
|
604
670
|
statusPath
|
|
605
671
|
? [
|
|
606
|
-
`
|
|
672
|
+
`git -C ${remoteDir} status --porcelain=v1 --untracked-files=normal -z > ${shellQuote(`${input.statusPath}.git-status`)}`,
|
|
673
|
+
`if [ ! -s ${shellQuote(`${input.statusPath}.git-status`)} ]; then`,
|
|
607
674
|
` printf clean > ${statusPath}`,
|
|
608
675
|
"else",
|
|
609
676
|
` printf dirty > ${statusPath}`,
|
|
610
677
|
"fi",
|
|
678
|
+
`rm -f -- ${shellQuote(`${input.statusPath}.git-status`)}`,
|
|
611
679
|
].join("\n")
|
|
612
680
|
: "",
|
|
613
681
|
input.catBundle ? `cat ${bundlePath}` : "",
|
|
614
682
|
].filter(Boolean).join("\n");
|
|
615
683
|
}
|
|
684
|
+
// Labels are fixed at the integration call sites, never derived from Git arguments.
|
|
685
|
+
function runIntegrationGit(command, ...args) {
|
|
686
|
+
return withWorkspaceRestoreGitCommand(command, () => runLocalGit(...args));
|
|
687
|
+
}
|
|
616
688
|
/**
|
|
617
689
|
* Preserve imported work whose history does not connect to the local one.
|
|
618
690
|
*
|
|
@@ -629,11 +701,11 @@ export function buildRemoteGitDeltaBundleScript(input) {
|
|
|
629
701
|
* branch ref to the returned commit.
|
|
630
702
|
*/
|
|
631
703
|
export async function createUnrelatedHistoryGraftCommit(input) {
|
|
632
|
-
const importedTree = (await
|
|
704
|
+
const importedTree = (await runIntegrationGit("rev_parse", input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], {
|
|
633
705
|
timeout: 10_000,
|
|
634
706
|
maxBuffer: 16 * 1024,
|
|
635
707
|
})).stdout.trim();
|
|
636
|
-
const importedMessage = (await
|
|
708
|
+
const importedMessage = (await runIntegrationGit("log", input.localDir, ["log", "-1", "--format=%B", input.importedHead], {
|
|
637
709
|
timeout: 10_000,
|
|
638
710
|
maxBuffer: 256 * 1024,
|
|
639
711
|
})).stdout;
|
|
@@ -642,31 +714,96 @@ export async function createUnrelatedHistoryGraftCommit(input) {
|
|
|
642
714
|
"",
|
|
643
715
|
`(${input.syncLabel} graft ${input.importedHead.slice(0, 12)}: imported history shares no ancestor with ${input.currentHead.slice(0, 12)})`,
|
|
644
716
|
].join("\n");
|
|
645
|
-
const graftCommit = await
|
|
717
|
+
const graftCommit = await runIntegrationGit("commit_tree", input.localDir, [...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message], {
|
|
646
718
|
timeout: 60_000,
|
|
647
719
|
maxBuffer: 64 * 1024,
|
|
648
720
|
});
|
|
649
721
|
return graftCommit.stdout.trim();
|
|
650
722
|
}
|
|
723
|
+
async function updateLocalGitHead(input) {
|
|
724
|
+
// Prepare the ref transaction before checking the symbolic HEAD identity.
|
|
725
|
+
// Git holds HEAD.lock (and the branch lock when attached) until commit/abort,
|
|
726
|
+
// so a checkout cannot redirect the write after this check. --no-deref also
|
|
727
|
+
// prevents a detached write from following a newly attached branch.
|
|
728
|
+
await withWorkspaceRestoreGitCommand("update_ref", () => new Promise((resolve, reject) => {
|
|
729
|
+
let identityError;
|
|
730
|
+
let prepared = false;
|
|
731
|
+
let output = "";
|
|
732
|
+
const child = execFile("git", ["-C", input.localDir, "update-ref", "--stdin"], {
|
|
733
|
+
timeout: 15_000,
|
|
734
|
+
maxBuffer: 64 * 1024,
|
|
735
|
+
}, (error, stdout, stderr) => {
|
|
736
|
+
if (identityError)
|
|
737
|
+
reject(identityError);
|
|
738
|
+
else if (error)
|
|
739
|
+
reject(Object.assign(error, { stdout, stderr }));
|
|
740
|
+
else if (!stdout.includes("commit: ok\n"))
|
|
741
|
+
reject(new Error("Git HEAD transaction did not commit."));
|
|
742
|
+
else
|
|
743
|
+
resolve();
|
|
744
|
+
});
|
|
745
|
+
// Early Git errors close stdin; the process callback reports the error.
|
|
746
|
+
child.stdin.on("error", () => { });
|
|
747
|
+
child.stdout.on("data", (chunk) => {
|
|
748
|
+
output += chunk.toString();
|
|
749
|
+
if (prepared || !output.includes("prepare: ok\n"))
|
|
750
|
+
return;
|
|
751
|
+
prepared = true;
|
|
752
|
+
void (async () => {
|
|
753
|
+
try {
|
|
754
|
+
const branchName = (await runIntegrationGit("symbolic_ref", input.localDir, ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
755
|
+
timeout: 10_000,
|
|
756
|
+
}).catch((error) => {
|
|
757
|
+
if (error.code === 1)
|
|
758
|
+
return { stdout: "" };
|
|
759
|
+
throw error;
|
|
760
|
+
})).stdout.trim() || null;
|
|
761
|
+
if (branchName !== input.branchName) {
|
|
762
|
+
throw new Error("Workspace branch changed while remote work was running.");
|
|
763
|
+
}
|
|
764
|
+
child.stdin.end("commit\n");
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
identityError = error;
|
|
768
|
+
child.stdin.end("abort\n");
|
|
769
|
+
}
|
|
770
|
+
})();
|
|
771
|
+
});
|
|
772
|
+
child.stdin.write([
|
|
773
|
+
"start",
|
|
774
|
+
...(input.branchName === null ? ["option no-deref"] : []),
|
|
775
|
+
`update HEAD ${input.newHead} ${input.oldHead}`,
|
|
776
|
+
"prepare",
|
|
777
|
+
"",
|
|
778
|
+
].join("\n"));
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
651
781
|
export async function integrateImportedGitHead(input) {
|
|
652
782
|
const isConcurrentRefUpdateError = (error) => {
|
|
653
783
|
const message = error instanceof Error ? error.message : String(error);
|
|
654
784
|
return message.includes("cannot lock ref") && message.includes("expected");
|
|
655
785
|
};
|
|
656
786
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
657
|
-
const snapshot =
|
|
658
|
-
|
|
659
|
-
|
|
787
|
+
const snapshot = {
|
|
788
|
+
headCommit: (await runIntegrationGit("rev_parse", input.localDir, ["rev-parse", "HEAD"])).stdout.trim(),
|
|
789
|
+
branchName: (await runIntegrationGit("symbolic_ref", input.localDir, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch((error) => {
|
|
790
|
+
if (error.code === 1)
|
|
791
|
+
return { stdout: "" };
|
|
792
|
+
throw error;
|
|
793
|
+
})).stdout.trim() || null,
|
|
794
|
+
};
|
|
795
|
+
if (input.baseline && snapshot.branchName !== input.baseline.branchName) {
|
|
796
|
+
throw new Error("Workspace branch changed while remote work was running.");
|
|
797
|
+
}
|
|
660
798
|
const currentHead = snapshot.headCommit;
|
|
661
799
|
if (!currentHead || currentHead === input.importedHead)
|
|
662
800
|
return;
|
|
663
|
-
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
|
|
664
801
|
// `git merge-base` exits 1 when the commits share no ancestor — the only
|
|
665
802
|
// outcome that authorizes the graft fallback below. Every other failure
|
|
666
803
|
// (timeout, missing object, repository error) must keep failing the
|
|
667
804
|
// integration instead of silently rewriting the tip.
|
|
668
805
|
let noCommonAncestor = false;
|
|
669
|
-
const mergeBase = await
|
|
806
|
+
const mergeBase = await runIntegrationGit("merge_base", input.localDir, ["merge-base", currentHead, input.importedHead], {
|
|
670
807
|
timeout: 10_000,
|
|
671
808
|
maxBuffer: 16 * 1024,
|
|
672
809
|
}).catch((error) => {
|
|
@@ -674,14 +811,15 @@ export async function integrateImportedGitHead(input) {
|
|
|
674
811
|
return null;
|
|
675
812
|
});
|
|
676
813
|
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
if
|
|
814
|
+
// A rebase/amend rewrites the sandbox tip without a concurrent host edit.
|
|
815
|
+
// Adopt that history when the host still matches the run's starting tip;
|
|
816
|
+
// merging the old and rewritten commits can reintroduce resolved conflicts.
|
|
817
|
+
// Keep the expected-old-value check: if the host advances during this write,
|
|
818
|
+
// retry against its new tip and use the normal concurrent-history path.
|
|
819
|
+
if (mergeBaseHead === currentHead || (mergeBaseHead && currentHead === input.baseline?.headCommit)) {
|
|
681
820
|
try {
|
|
682
|
-
await
|
|
683
|
-
|
|
684
|
-
maxBuffer: 16 * 1024,
|
|
821
|
+
await updateLocalGitHead({
|
|
822
|
+
localDir: input.localDir, newHead: input.importedHead, oldHead: currentHead, branchName: snapshot.branchName,
|
|
685
823
|
});
|
|
686
824
|
return;
|
|
687
825
|
}
|
|
@@ -691,7 +829,13 @@ export async function integrateImportedGitHead(input) {
|
|
|
691
829
|
throw error;
|
|
692
830
|
}
|
|
693
831
|
}
|
|
832
|
+
if (mergeBaseHead === input.importedHead) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
694
835
|
if (noCommonAncestor) {
|
|
836
|
+
if (input.baseline && currentHead !== input.baseline.headCommit) {
|
|
837
|
+
throw new Error("Cannot restore unrelated remote history after the host advanced.");
|
|
838
|
+
}
|
|
695
839
|
// No common ancestor — merging is impossible and failing here would
|
|
696
840
|
// discard the imported work. Graft it onto the current head instead;
|
|
697
841
|
// see createUnrelatedHistoryGraftCommit.
|
|
@@ -702,9 +846,8 @@ export async function integrateImportedGitHead(input) {
|
|
|
702
846
|
syncLabel: "Paperclip remote git sync",
|
|
703
847
|
});
|
|
704
848
|
try {
|
|
705
|
-
await
|
|
706
|
-
|
|
707
|
-
maxBuffer: 16 * 1024,
|
|
849
|
+
await updateLocalGitHead({
|
|
850
|
+
localDir: input.localDir, newHead: graftCommit, oldHead: currentHead, branchName: snapshot.branchName,
|
|
708
851
|
});
|
|
709
852
|
return;
|
|
710
853
|
}
|
|
@@ -716,20 +859,20 @@ export async function integrateImportedGitHead(input) {
|
|
|
716
859
|
}
|
|
717
860
|
let mergedTree;
|
|
718
861
|
try {
|
|
719
|
-
mergedTree = await
|
|
862
|
+
mergedTree = await runIntegrationGit("merge_tree", input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
|
|
720
863
|
timeout: 60_000,
|
|
721
864
|
maxBuffer: 256 * 1024,
|
|
722
865
|
});
|
|
723
866
|
}
|
|
724
867
|
catch (error) {
|
|
725
868
|
const reason = error instanceof Error ? error.message : String(error);
|
|
726
|
-
throw new Error(`Failed to merge concurrent remote git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`);
|
|
869
|
+
throw preserveWorkspaceRestoreErrorDiagnostic(new Error(`Failed to merge concurrent remote git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`), error);
|
|
727
870
|
}
|
|
728
871
|
const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
729
872
|
if (!mergedTreeId) {
|
|
730
873
|
throw new Error("Failed to compute a merged git tree for workspace restore.");
|
|
731
874
|
}
|
|
732
|
-
const mergeCommit = await
|
|
875
|
+
const mergeCommit = await runIntegrationGit("commit_tree", input.localDir, [
|
|
733
876
|
...GIT_SYNC_COMMIT_IDENTITY_ARGS,
|
|
734
877
|
"commit-tree",
|
|
735
878
|
mergedTreeId,
|
|
@@ -744,9 +887,8 @@ export async function integrateImportedGitHead(input) {
|
|
|
744
887
|
maxBuffer: 64 * 1024,
|
|
745
888
|
});
|
|
746
889
|
try {
|
|
747
|
-
await
|
|
748
|
-
|
|
749
|
-
maxBuffer: 16 * 1024,
|
|
890
|
+
await updateLocalGitHead({
|
|
891
|
+
localDir: input.localDir, newHead: mergeCommit.stdout.trim(), oldHead: currentHead, branchName: snapshot.branchName,
|
|
750
892
|
});
|
|
751
893
|
return;
|
|
752
894
|
}
|
|
@@ -773,22 +915,23 @@ export async function resetLocalGitIndexToHead(input) {
|
|
|
773
915
|
error.stdout,
|
|
774
916
|
].filter((value) => typeof value === "string" && value.trim().length > 0).join("\n")
|
|
775
917
|
: String(error);
|
|
776
|
-
throw new Error(`Failed to reset local git index to HEAD after workspace restore: ${detail}`);
|
|
918
|
+
throw preserveWorkspaceRestoreErrorDiagnostic(new Error(`Failed to reset local git index to HEAD after workspace restore: ${detail}`), error);
|
|
777
919
|
}
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
920
|
+
const hasDiff = async (args) => {
|
|
921
|
+
try {
|
|
922
|
+
await runLocalGit(input.localDir, args, { timeout: workspaceSnapshotTimeoutMs(), maxBuffer: 64 * 1024 });
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
if (error.code === 1)
|
|
927
|
+
return true;
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
if (await hasDiff(["diff", "--quiet", "--cached", "HEAD", "--"])) {
|
|
932
|
+
throw new Error("Workspace restore left staged git index changes after reset");
|
|
784
933
|
}
|
|
785
|
-
if (
|
|
786
|
-
return;
|
|
787
|
-
const workingTreeDiff = await runLocalGit(input.localDir, ["diff", "--name-status", "HEAD", "--"], {
|
|
788
|
-
timeout: 10_000,
|
|
789
|
-
maxBuffer: 1024 * 1024,
|
|
790
|
-
});
|
|
791
|
-
if (workingTreeDiff.stdout.trim().length > 0) {
|
|
934
|
+
if (input.checkWorkingTreeClean && await hasDiff(["diff", "--quiet", "HEAD", "--"])) {
|
|
792
935
|
console.warn("[paperclip] Workspace restore preserved local working tree changes after clean sandbox restore.");
|
|
793
936
|
}
|
|
794
937
|
}
|