@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.
Files changed (181) hide show
  1. package/README.md +13 -0
  2. package/dist/acpx-engine/ephemeral-session-environment.d.ts +4 -0
  3. package/dist/acpx-engine/ephemeral-session-environment.d.ts.map +1 -0
  4. package/dist/acpx-engine/ephemeral-session-environment.js +29 -0
  5. package/dist/acpx-engine/ephemeral-session-environment.js.map +1 -0
  6. package/dist/acpx-engine/ephemeral-session-environment.test.d.ts +2 -0
  7. package/dist/acpx-engine/ephemeral-session-environment.test.d.ts.map +1 -0
  8. package/dist/acpx-engine/ephemeral-session-environment.test.js +177 -0
  9. package/dist/acpx-engine/ephemeral-session-environment.test.js.map +1 -0
  10. package/dist/acpx-engine/execute.d.ts +2 -6
  11. package/dist/acpx-engine/execute.d.ts.map +1 -1
  12. package/dist/acpx-engine/execute.js +125 -62
  13. package/dist/acpx-engine/execute.js.map +1 -1
  14. package/dist/acpx-engine/execute.test.js +299 -3
  15. package/dist/acpx-engine/execute.test.js.map +1 -1
  16. package/dist/acpx-engine/session-codec.d.ts.map +1 -1
  17. package/dist/acpx-engine/session-codec.js +1 -0
  18. package/dist/acpx-engine/session-codec.js.map +1 -1
  19. package/dist/acpx-engine/settlement-characterization.test.js +105 -0
  20. package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
  21. package/dist/acpx-engine/spawn-smoke.test.js +16 -7
  22. package/dist/acpx-engine/spawn-smoke.test.js.map +1 -1
  23. package/dist/acpx-engine/startup-cancellation.d.ts +1 -0
  24. package/dist/acpx-engine/startup-cancellation.d.ts.map +1 -1
  25. package/dist/acpx-engine/startup-cancellation.js +7 -2
  26. package/dist/acpx-engine/startup-cancellation.js.map +1 -1
  27. package/dist/acpx-engine/terminal-session-failure.d.ts +18 -0
  28. package/dist/acpx-engine/terminal-session-failure.d.ts.map +1 -0
  29. package/dist/acpx-engine/terminal-session-failure.js +106 -0
  30. package/dist/acpx-engine/terminal-session-failure.js.map +1 -0
  31. package/dist/acpx-engine/terminal-session-failure.test.d.ts +2 -0
  32. package/dist/acpx-engine/terminal-session-failure.test.d.ts.map +1 -0
  33. package/dist/acpx-engine/terminal-session-failure.test.js +139 -0
  34. package/dist/acpx-engine/terminal-session-failure.test.js.map +1 -0
  35. package/dist/acpx-engine/turn-characterization.test.js +4 -3
  36. package/dist/acpx-engine/turn-characterization.test.js.map +1 -1
  37. package/dist/command-managed-runtime.d.ts +2 -0
  38. package/dist/command-managed-runtime.d.ts.map +1 -1
  39. package/dist/command-managed-runtime.js +2 -0
  40. package/dist/command-managed-runtime.js.map +1 -1
  41. package/dist/command-redaction.d.ts +2 -0
  42. package/dist/command-redaction.d.ts.map +1 -1
  43. package/dist/command-redaction.js +20 -10
  44. package/dist/command-redaction.js.map +1 -1
  45. package/dist/command-redaction.test.js +20 -1
  46. package/dist/command-redaction.test.js.map +1 -1
  47. package/dist/directory-merge-lock.test.d.ts +2 -0
  48. package/dist/directory-merge-lock.test.d.ts.map +1 -0
  49. package/dist/directory-merge-lock.test.js +166 -0
  50. package/dist/directory-merge-lock.test.js.map +1 -0
  51. package/dist/execution-phase.d.ts +10 -0
  52. package/dist/execution-phase.d.ts.map +1 -0
  53. package/dist/execution-phase.js +27 -0
  54. package/dist/execution-phase.js.map +1 -0
  55. package/dist/execution-phase.test.d.ts +2 -0
  56. package/dist/execution-phase.test.d.ts.map +1 -0
  57. package/dist/execution-phase.test.js +26 -0
  58. package/dist/execution-phase.test.js.map +1 -0
  59. package/dist/execution-target-sandbox.test.js +49 -7
  60. package/dist/execution-target-sandbox.test.js.map +1 -1
  61. package/dist/execution-target-stdin-race.test.js +245 -2
  62. package/dist/execution-target-stdin-race.test.js.map +1 -1
  63. package/dist/execution-target.d.ts +6 -0
  64. package/dist/execution-target.d.ts.map +1 -1
  65. package/dist/execution-target.js +74 -22
  66. package/dist/execution-target.js.map +1 -1
  67. package/dist/git-workspace-sync.d.ts +15 -5
  68. package/dist/git-workspace-sync.d.ts.map +1 -1
  69. package/dist/git-workspace-sync.js +267 -124
  70. package/dist/git-workspace-sync.js.map +1 -1
  71. package/dist/git-workspace-sync.test.js +296 -20
  72. package/dist/git-workspace-sync.test.js.map +1 -1
  73. package/dist/github-launcher.d.ts.map +1 -1
  74. package/dist/github-launcher.js +26 -11
  75. package/dist/github-launcher.js.map +1 -1
  76. package/dist/github-launcher.test.js +34 -1
  77. package/dist/github-launcher.test.js.map +1 -1
  78. package/dist/index.d.ts +1 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/paperclip-runner-permissions.d.ts +28 -1
  83. package/dist/paperclip-runner-permissions.d.ts.map +1 -1
  84. package/dist/paperclip-runner-permissions.js +9 -1
  85. package/dist/paperclip-runner-permissions.js.map +1 -1
  86. package/dist/prompt-sections.test.d.ts +2 -0
  87. package/dist/prompt-sections.test.d.ts.map +1 -0
  88. package/dist/prompt-sections.test.js +53 -0
  89. package/dist/prompt-sections.test.js.map +1 -0
  90. package/dist/provider-stopped-boundary.d.ts +7 -0
  91. package/dist/provider-stopped-boundary.d.ts.map +1 -0
  92. package/dist/provider-stopped-boundary.js +22 -0
  93. package/dist/provider-stopped-boundary.js.map +1 -0
  94. package/dist/provider-stopped-boundary.test.d.ts +2 -0
  95. package/dist/provider-stopped-boundary.test.d.ts.map +1 -0
  96. package/dist/provider-stopped-boundary.test.js +76 -0
  97. package/dist/provider-stopped-boundary.test.js.map +1 -0
  98. package/dist/remote-managed-runtime.d.ts +2 -0
  99. package/dist/remote-managed-runtime.d.ts.map +1 -1
  100. package/dist/remote-managed-runtime.js +3 -1
  101. package/dist/remote-managed-runtime.js.map +1 -1
  102. package/dist/remote-managed-runtime.test.js +25 -0
  103. package/dist/remote-managed-runtime.test.js.map +1 -1
  104. package/dist/sandbox-callback-bridge.d.ts +2 -0
  105. package/dist/sandbox-callback-bridge.d.ts.map +1 -1
  106. package/dist/sandbox-callback-bridge.js +34 -10
  107. package/dist/sandbox-callback-bridge.js.map +1 -1
  108. package/dist/sandbox-callback-bridge.test.js +106 -3
  109. package/dist/sandbox-callback-bridge.test.js.map +1 -1
  110. package/dist/sandbox-managed-runtime.d.ts +3 -0
  111. package/dist/sandbox-managed-runtime.d.ts.map +1 -1
  112. package/dist/sandbox-managed-runtime.js +749 -718
  113. package/dist/sandbox-managed-runtime.js.map +1 -1
  114. package/dist/sandbox-managed-runtime.test.js +171 -2
  115. package/dist/sandbox-managed-runtime.test.js.map +1 -1
  116. package/dist/server-utils.d.ts +43 -1
  117. package/dist/server-utils.d.ts.map +1 -1
  118. package/dist/server-utils.js +103 -31
  119. package/dist/server-utils.js.map +1 -1
  120. package/dist/server-utils.test.js +235 -45
  121. package/dist/server-utils.test.js.map +1 -1
  122. package/dist/session-compaction.d.ts.map +1 -1
  123. package/dist/session-compaction.js +6 -0
  124. package/dist/session-compaction.js.map +1 -1
  125. package/dist/ssh-fixture.test.js +22 -0
  126. package/dist/ssh-fixture.test.js.map +1 -1
  127. package/dist/ssh.d.ts +2 -0
  128. package/dist/ssh.d.ts.map +1 -1
  129. package/dist/ssh.js +2 -2
  130. package/dist/ssh.js.map +1 -1
  131. package/dist/test-fixtures/prompt-context.d.ts +54 -0
  132. package/dist/test-fixtures/prompt-context.d.ts.map +1 -0
  133. package/dist/test-fixtures/prompt-context.js +55 -0
  134. package/dist/test-fixtures/prompt-context.js.map +1 -0
  135. package/dist/types.d.ts +12 -2
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/workspace-git-stream.d.ts +19 -0
  138. package/dist/workspace-git-stream.d.ts.map +1 -0
  139. package/dist/workspace-git-stream.js +99 -0
  140. package/dist/workspace-git-stream.js.map +1 -0
  141. package/dist/workspace-git-stream.test.d.ts +2 -0
  142. package/dist/workspace-git-stream.test.d.ts.map +1 -0
  143. package/dist/workspace-git-stream.test.js +71 -0
  144. package/dist/workspace-git-stream.test.js.map +1 -0
  145. package/dist/workspace-manifest.d.ts +61 -0
  146. package/dist/workspace-manifest.d.ts.map +1 -0
  147. package/dist/workspace-manifest.js +257 -0
  148. package/dist/workspace-manifest.js.map +1 -0
  149. package/dist/workspace-manifest.test.d.ts +2 -0
  150. package/dist/workspace-manifest.test.d.ts.map +1 -0
  151. package/dist/workspace-manifest.test.js +160 -0
  152. package/dist/workspace-manifest.test.js.map +1 -0
  153. package/dist/workspace-restore-diagnostics.d.ts +34 -0
  154. package/dist/workspace-restore-diagnostics.d.ts.map +1 -0
  155. package/dist/workspace-restore-diagnostics.js +238 -0
  156. package/dist/workspace-restore-diagnostics.js.map +1 -0
  157. package/dist/workspace-restore-diagnostics.test.d.ts +2 -0
  158. package/dist/workspace-restore-diagnostics.test.d.ts.map +1 -0
  159. package/dist/workspace-restore-diagnostics.test.js +258 -0
  160. package/dist/workspace-restore-diagnostics.test.js.map +1 -0
  161. package/dist/workspace-restore-merge.d.ts +47 -6
  162. package/dist/workspace-restore-merge.d.ts.map +1 -1
  163. package/dist/workspace-restore-merge.js +382 -114
  164. package/dist/workspace-restore-merge.js.map +1 -1
  165. package/dist/workspace-restore-merge.test.js +253 -4
  166. package/dist/workspace-restore-merge.test.js.map +1 -1
  167. package/dist/workspace-restore-result.d.ts +6 -0
  168. package/dist/workspace-restore-result.d.ts.map +1 -0
  169. package/dist/workspace-restore-result.js +72 -0
  170. package/dist/workspace-restore-result.js.map +1 -0
  171. package/dist/workspace-restore-result.test.d.ts +2 -0
  172. package/dist/workspace-restore-result.test.d.ts.map +1 -0
  173. package/dist/workspace-restore-result.test.js +74 -0
  174. package/dist/workspace-restore-result.test.js.map +1 -0
  175. package/dist/workspace-restore-teardown.d.ts.map +1 -1
  176. package/dist/workspace-restore-teardown.js +5 -3
  177. package/dist/workspace-restore-teardown.js.map +1 -1
  178. package/dist/workspace-restore-teardown.test.js +24 -0
  179. package/dist/workspace-restore-teardown.test.js.map +1 -1
  180. package/node_modules/acpx/dist/live-checkpoint-ClPCSdrW.js +4 -3
  181. 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
- * existing timeout/buffer-bounded fallback.
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
- export async function readGitWorkspaceSnapshot(localDir, includeRepositories = true) {
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 [headCommitResult, branchResult, overlayDiffResult, untrackedResult, deletedResult, ignoredResult] = await Promise.all([
133
- runLocalGit(localDir, ["rev-parse", "HEAD"], {
134
- timeout: 10_000,
135
- maxBuffer: 16 * 1024,
136
- }),
137
- runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], {
138
- timeout: 10_000,
139
- maxBuffer: 16 * 1024,
140
- }),
141
- runExpensiveWorkspaceGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=ACMRTUXB", "HEAD", "--"], "adapter_sync.overlay_diff", {
142
- timeout: 10_000,
143
- maxBuffer: 1024 * 1024,
144
- }),
145
- // A generated output tree can exceed 1 MiB of filenames with only a few
146
- // thousand files. Keep the explicit file snapshot (and a finite bound):
147
- // collapsing directories would let later files enter the staging copy.
148
- runExpensiveWorkspaceGit(localDir, ["ls-files", "--others", "--exclude-standard", "-z"], "adapter_sync.untracked_files", {
149
- timeout: 10_000,
150
- maxBuffer: 32 * 1024 * 1024,
151
- }),
152
- runExpensiveWorkspaceGit(localDir, ["diff", "--name-only", "-z", "--diff-filter=D", "HEAD", "--"], "adapter_sync.deleted_files", {
153
- timeout: 10_000,
154
- maxBuffer: 256 * 1024,
155
- }),
156
- // Collapse ignored directories instead of walking their contents, and
157
- // avoid producing unrelated tracked/untracked status records.
158
- runExpensiveWorkspaceGit(localDir, ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], "adapter_sync.ignored_files", {
159
- timeout: 10_000,
160
- maxBuffer: 1024 * 1024,
161
- }),
162
- ]);
163
- const branchName = branchResult.stdout.trim();
164
- // `-z` already delimits each record with a NUL byte, so a leading or
165
- // trailing space in a record is part of the path itself, not padding to
166
- // remove — trimming it would resolve to a path that does not exist. A
167
- // length check finds the one genuinely empty record `-z` appends after
168
- // the last NUL, without eating a real path's own leading or trailing
169
- // whitespace. This applies to all four NUL-delimited outputs below (the
170
- // overlay diff, the untracked list, the deleted list, and the ignored
171
- // list); `branchName` and `headCommit` come from non-`-z` commands and
172
- // keep their own `.trim()` above and below, which is safe.
173
- const splitNul = (value) => value.split("\0").filter((entry) => entry.length > 0);
174
- return {
175
- headCommit: headCommitResult.stdout.trim(),
176
- branchName: branchName && branchName !== "HEAD" ? branchName : null,
177
- overlayPaths: [...new Set([...splitNul(overlayDiffResult.stdout), ...splitNul(untrackedResult.stdout),
178
- ...repositories.flatMap((repo) => repo.snapshot.overlayPaths.map((entry) => `${repo.path}/${entry}`))])]
179
- .sort((left, right) => left.localeCompare(right)),
180
- deletedPaths: [...new Set([...splitNul(deletedResult.stdout),
181
- ...repositories.flatMap((repo) => repo.snapshot.deletedPaths.map((entry) => `${repo.path}/${entry}`))])]
182
- .sort((left, right) => left.localeCompare(right)),
183
- ignoredPaths: [...splitNul(ignoredResult.stdout)
184
- .map((entry) => entry.replace(/\/+$/, ""))
185
- .filter((entry) => Boolean(entry) && !(repositories.length > 0 && entry === PROJECT_REPOSITORIES_DIR)),
186
- ...repositories.flatMap((repo) => repo.snapshot.ignoredPaths.map((entry) => `${repo.path}/${entry}`))]
187
- .sort((left, right) => left.localeCompare(right)),
188
- ...(repositories.length > 0 ? { repositories } : {}),
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 fs.cp(nestedClone, path.join(cloneDir, repository.path), {
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
- `if [ -z "$(git -C ${remoteDir} status --porcelain=v1 --untracked-files=normal)" ]; then`,
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 runLocalGit(input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], {
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 runLocalGit(input.localDir, ["log", "-1", "--format=%B", input.importedHead], {
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 runLocalGit(input.localDir, [...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message], {
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 = await readGitWorkspaceSnapshot(input.localDir);
658
- if (!snapshot)
659
- return;
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 runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
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
- if (mergeBaseHead === input.importedHead) {
678
- return;
679
- }
680
- if (mergeBaseHead === currentHead) {
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 runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], {
683
- timeout: 10_000,
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 runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], {
706
- timeout: 10_000,
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 runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
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 runLocalGit(input.localDir, [
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 runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], {
748
- timeout: 10_000,
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 stagedDiff = await runLocalGit(input.localDir, ["diff", "--cached", "--name-status", "HEAD", "--"], {
779
- timeout: 10_000,
780
- maxBuffer: 1024 * 1024,
781
- });
782
- if (stagedDiff.stdout.trim().length > 0) {
783
- throw new Error(`Workspace restore left staged git index changes after reset:\n${stagedDiff.stdout.trim()}`);
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 (!input.checkWorkingTreeClean)
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
  }