@cerbur/clutch-dsh-worktree 0.1.10 → 0.1.13

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 (298) hide show
  1. package/README.md +278 -498
  2. package/README.zh.md +231 -432
  3. package/assets/screenshots/screenshots-dashboard.png +0 -0
  4. package/assets/screenshots/screenshots-en.png +0 -0
  5. package/assets/screenshots/screenshots-git-reviewer.png +0 -0
  6. package/assets/screenshots/screenshots-import.png +0 -0
  7. package/assets/screenshots/screenshots-zh.png +0 -0
  8. package/lib/client/WorktreeSurface.d.ts +1 -1
  9. package/lib/client/WorktreeSurface.d.ts.map +1 -1
  10. package/lib/client/WorktreeSurface.js +268 -10
  11. package/lib/client/WorktreeSurface.js.map +1 -1
  12. package/lib/client/context/WorktreeHeroContext.d.ts +4 -3
  13. package/lib/client/context/WorktreeHeroContext.d.ts.map +1 -1
  14. package/lib/client/context/WorktreeHeroContext.js +29 -11
  15. package/lib/client/context/WorktreeHeroContext.js.map +1 -1
  16. package/lib/client/dashboard/DashboardHeaderAction.d.ts +20 -0
  17. package/lib/client/dashboard/DashboardHeaderAction.d.ts.map +1 -0
  18. package/lib/client/dashboard/DashboardHeaderAction.js +19 -0
  19. package/lib/client/dashboard/DashboardHeaderAction.js.map +1 -0
  20. package/lib/client/dashboard/OpenInAppButton.d.ts +9 -0
  21. package/lib/client/dashboard/OpenInAppButton.d.ts.map +1 -0
  22. package/lib/client/dashboard/OpenInAppButton.js +123 -0
  23. package/lib/client/dashboard/OpenInAppButton.js.map +1 -0
  24. package/lib/client/dashboard/WorktreeDashboard.d.ts +28 -0
  25. package/lib/client/dashboard/WorktreeDashboard.d.ts.map +1 -0
  26. package/lib/client/dashboard/WorktreeDashboard.js +362 -0
  27. package/lib/client/dashboard/WorktreeDashboard.js.map +1 -0
  28. package/lib/client/dashboard/WorktreeInstructions.d.ts +8 -0
  29. package/lib/client/dashboard/WorktreeInstructions.d.ts.map +1 -0
  30. package/lib/client/dashboard/WorktreeInstructions.js +53 -0
  31. package/lib/client/dashboard/WorktreeInstructions.js.map +1 -0
  32. package/lib/client/dashboard/dashboard-icon.d.ts +7 -0
  33. package/lib/client/dashboard/dashboard-icon.d.ts.map +1 -0
  34. package/lib/client/dashboard/dashboard-icon.js +6 -0
  35. package/lib/client/dashboard/dashboard-icon.js.map +1 -0
  36. package/lib/client/dashboard/dashboard-navigation.d.ts +35 -0
  37. package/lib/client/dashboard/dashboard-navigation.d.ts.map +1 -0
  38. package/lib/client/dashboard/dashboard-navigation.js +48 -0
  39. package/lib/client/dashboard/dashboard-navigation.js.map +1 -0
  40. package/lib/client/dashboard/dashboard-overlay.d.ts +16 -0
  41. package/lib/client/dashboard/dashboard-overlay.d.ts.map +1 -0
  42. package/lib/client/dashboard/dashboard-overlay.js +185 -0
  43. package/lib/client/dashboard/dashboard-overlay.js.map +1 -0
  44. package/lib/client/dashboard/dashboard-selection.d.ts +33 -0
  45. package/lib/client/dashboard/dashboard-selection.d.ts.map +1 -0
  46. package/lib/client/dashboard/dashboard-selection.js +31 -0
  47. package/lib/client/dashboard/dashboard-selection.js.map +1 -0
  48. package/lib/client/dashboard/dashboard-sessions.d.ts +6 -0
  49. package/lib/client/dashboard/dashboard-sessions.d.ts.map +1 -0
  50. package/lib/client/dashboard/dashboard-sessions.js +21 -0
  51. package/lib/client/dashboard/dashboard-sessions.js.map +1 -0
  52. package/lib/client/dashboard/git/GitChangedFiles.d.ts +11 -0
  53. package/lib/client/dashboard/git/GitChangedFiles.d.ts.map +1 -0
  54. package/lib/client/dashboard/git/GitChangedFiles.js +101 -0
  55. package/lib/client/dashboard/git/GitChangedFiles.js.map +1 -0
  56. package/lib/client/dashboard/git/GitCommitList.d.ts +15 -0
  57. package/lib/client/dashboard/git/GitCommitList.d.ts.map +1 -0
  58. package/lib/client/dashboard/git/GitCommitList.js +55 -0
  59. package/lib/client/dashboard/git/GitCommitList.js.map +1 -0
  60. package/lib/client/dashboard/git/GitDiffView.d.ts +10 -0
  61. package/lib/client/dashboard/git/GitDiffView.d.ts.map +1 -0
  62. package/lib/client/dashboard/git/GitDiffView.js +65 -0
  63. package/lib/client/dashboard/git/GitDiffView.js.map +1 -0
  64. package/lib/client/dashboard/git/GitFileTypeIcon.d.ts +8 -0
  65. package/lib/client/dashboard/git/GitFileTypeIcon.d.ts.map +1 -0
  66. package/lib/client/dashboard/git/GitFileTypeIcon.js +24 -0
  67. package/lib/client/dashboard/git/GitFileTypeIcon.js.map +1 -0
  68. package/lib/client/dashboard/git/GitLineStats.d.ts +10 -0
  69. package/lib/client/dashboard/git/GitLineStats.d.ts.map +1 -0
  70. package/lib/client/dashboard/git/GitLineStats.js +9 -0
  71. package/lib/client/dashboard/git/GitLineStats.js.map +1 -0
  72. package/lib/client/dashboard/git/WorktreeGitOverview.d.ts +31 -0
  73. package/lib/client/dashboard/git/WorktreeGitOverview.d.ts.map +1 -0
  74. package/lib/client/dashboard/git/WorktreeGitOverview.js +89 -0
  75. package/lib/client/dashboard/git/WorktreeGitOverview.js.map +1 -0
  76. package/lib/client/dashboard/git/WorktreeGitPanel.d.ts +16 -0
  77. package/lib/client/dashboard/git/WorktreeGitPanel.d.ts.map +1 -0
  78. package/lib/client/dashboard/git/WorktreeGitPanel.js +126 -0
  79. package/lib/client/dashboard/git/WorktreeGitPanel.js.map +1 -0
  80. package/lib/client/dashboard/git/file-address.d.ts +2 -0
  81. package/lib/client/dashboard/git/file-address.d.ts.map +1 -0
  82. package/lib/client/dashboard/git/file-address.js +16 -0
  83. package/lib/client/dashboard/git/file-address.js.map +1 -0
  84. package/lib/client/dashboard/git/git-column-split.d.ts +34 -0
  85. package/lib/client/dashboard/git/git-column-split.d.ts.map +1 -0
  86. package/lib/client/dashboard/git/git-column-split.js +41 -0
  87. package/lib/client/dashboard/git/git-column-split.js.map +1 -0
  88. package/lib/client/dashboard/git/git-diff-parser.d.ts +24 -0
  89. package/lib/client/dashboard/git/git-diff-parser.d.ts.map +1 -0
  90. package/lib/client/dashboard/git/git-diff-parser.js +56 -0
  91. package/lib/client/dashboard/git/git-diff-parser.js.map +1 -0
  92. package/lib/client/dashboard/git/git-facts.d.ts +18 -0
  93. package/lib/client/dashboard/git/git-facts.d.ts.map +1 -0
  94. package/lib/client/dashboard/git/git-facts.js +31 -0
  95. package/lib/client/dashboard/git/git-facts.js.map +1 -0
  96. package/lib/client/dashboard/git/git-file-tree.d.ts +16 -0
  97. package/lib/client/dashboard/git/git-file-tree.d.ts.map +1 -0
  98. package/lib/client/dashboard/git/git-file-tree.js +52 -0
  99. package/lib/client/dashboard/git/git-file-tree.js.map +1 -0
  100. package/lib/client/dashboard/git/git-state-cache.d.ts +49 -0
  101. package/lib/client/dashboard/git/git-state-cache.d.ts.map +1 -0
  102. package/lib/client/dashboard/git/git-state-cache.js +80 -0
  103. package/lib/client/dashboard/git/git-state-cache.js.map +1 -0
  104. package/lib/client/dashboard/git/useGitPaneSplit.d.ts +33 -0
  105. package/lib/client/dashboard/git/useGitPaneSplit.d.ts.map +1 -0
  106. package/lib/client/dashboard/git/useGitPaneSplit.js +119 -0
  107. package/lib/client/dashboard/git/useGitPaneSplit.js.map +1 -0
  108. package/lib/client/dashboard/git/useWorktreeGitState.d.ts +60 -0
  109. package/lib/client/dashboard/git/useWorktreeGitState.d.ts.map +1 -0
  110. package/lib/client/dashboard/git/useWorktreeGitState.js +654 -0
  111. package/lib/client/dashboard/git/useWorktreeGitState.js.map +1 -0
  112. package/lib/client/dashboard/open-in-app-controller.d.ts +30 -0
  113. package/lib/client/dashboard/open-in-app-controller.d.ts.map +1 -0
  114. package/lib/client/dashboard/open-in-app-controller.js +94 -0
  115. package/lib/client/dashboard/open-in-app-controller.js.map +1 -0
  116. package/lib/client/dashboard/vscode-url.d.ts +3 -0
  117. package/lib/client/dashboard/vscode-url.d.ts.map +1 -0
  118. package/lib/client/dashboard/vscode-url.js +9 -0
  119. package/lib/client/dashboard/vscode-url.js.map +1 -0
  120. package/lib/client/dashboard/worktree-acquisition-facts.d.ts +16 -0
  121. package/lib/client/dashboard/worktree-acquisition-facts.d.ts.map +1 -0
  122. package/lib/client/dashboard/worktree-acquisition-facts.js +14 -0
  123. package/lib/client/dashboard/worktree-acquisition-facts.js.map +1 -0
  124. package/lib/client/dsh-slot-contract.d.ts +5 -0
  125. package/lib/client/dsh-slot-contract.d.ts.map +1 -1
  126. package/lib/client/entry.d.ts.map +1 -1
  127. package/lib/client/entry.js +93 -0
  128. package/lib/client/entry.js.map +1 -1
  129. package/lib/client/locales.d.ts +296 -0
  130. package/lib/client/locales.d.ts.map +1 -1
  131. package/lib/client/locales.js +296 -0
  132. package/lib/client/locales.js.map +1 -1
  133. package/lib/client/session/session-labels.d.ts +9 -0
  134. package/lib/client/session/session-labels.d.ts.map +1 -0
  135. package/lib/client/session/session-labels.js +44 -0
  136. package/lib/client/session/session-labels.js.map +1 -0
  137. package/lib/client/session/session-view.d.ts +10 -0
  138. package/lib/client/session/session-view.d.ts.map +1 -1
  139. package/lib/client/session/session-view.js +57 -0
  140. package/lib/client/session/session-view.js.map +1 -1
  141. package/lib/client/session/worktree-session-order.d.ts +2 -5
  142. package/lib/client/session/worktree-session-order.d.ts.map +1 -1
  143. package/lib/client/session/worktree-session-order.js +35 -10
  144. package/lib/client/session/worktree-session-order.js.map +1 -1
  145. package/lib/client/session/worktree-session-position.d.ts +2 -1
  146. package/lib/client/session/worktree-session-position.d.ts.map +1 -1
  147. package/lib/client/session/worktree-session-position.js +10 -3
  148. package/lib/client/session/worktree-session-position.js.map +1 -1
  149. package/lib/client/surface/actions/useDragActions.d.ts +33 -2
  150. package/lib/client/surface/actions/useDragActions.d.ts.map +1 -1
  151. package/lib/client/surface/actions/useDragActions.js +66 -18
  152. package/lib/client/surface/actions/useDragActions.js.map +1 -1
  153. package/lib/client/surface/components/ActiveWorktree.d.ts +1 -1
  154. package/lib/client/surface/components/ActiveWorktree.d.ts.map +1 -1
  155. package/lib/client/surface/components/ActiveWorktree.js +4 -3
  156. package/lib/client/surface/components/ActiveWorktree.js.map +1 -1
  157. package/lib/client/surface/components/ArchivedWorktree.d.ts +1 -1
  158. package/lib/client/surface/components/ArchivedWorktree.d.ts.map +1 -1
  159. package/lib/client/surface/components/ArchivedWorktree.js +4 -3
  160. package/lib/client/surface/components/ArchivedWorktree.js.map +1 -1
  161. package/lib/client/surface/components/ArchivedWorktrees.d.ts +1 -1
  162. package/lib/client/surface/components/ArchivedWorktrees.d.ts.map +1 -1
  163. package/lib/client/surface/components/ArchivedWorktrees.js +2 -2
  164. package/lib/client/surface/components/ArchivedWorktrees.js.map +1 -1
  165. package/lib/client/surface/components/SurfaceContent.d.ts +2 -2
  166. package/lib/client/surface/components/SurfaceContent.d.ts.map +1 -1
  167. package/lib/client/surface/components/SurfaceContent.js +1 -1
  168. package/lib/client/surface/components/SurfaceContent.js.map +1 -1
  169. package/lib/client/surface/components/SurfaceHeader.d.ts +1 -1
  170. package/lib/client/surface/components/SurfaceHeader.d.ts.map +1 -1
  171. package/lib/client/surface/components/SurfaceHeader.js +12 -3
  172. package/lib/client/surface/components/SurfaceHeader.js.map +1 -1
  173. package/lib/client/surface/components/WorkspaceTree.d.ts +1 -1
  174. package/lib/client/surface/components/WorkspaceTree.d.ts.map +1 -1
  175. package/lib/client/surface/components/WorkspaceTree.js +9 -5
  176. package/lib/client/surface/components/WorkspaceTree.js.map +1 -1
  177. package/lib/client/surface/components/rows.d.ts +2 -2
  178. package/lib/client/surface/components/rows.d.ts.map +1 -1
  179. package/lib/client/surface/components/rows.js +111 -8
  180. package/lib/client/surface/components/rows.js.map +1 -1
  181. package/lib/client/surface/selectors.d.ts +10 -0
  182. package/lib/client/surface/selectors.d.ts.map +1 -1
  183. package/lib/client/surface/selectors.js +14 -0
  184. package/lib/client/surface/selectors.js.map +1 -1
  185. package/lib/client/surface/state/useSessionExpansion.d.ts +2 -1
  186. package/lib/client/surface/state/useSessionExpansion.d.ts.map +1 -1
  187. package/lib/client/surface/state/useSessionExpansion.js +26 -13
  188. package/lib/client/surface/state/useSessionExpansion.js.map +1 -1
  189. package/lib/client/surface/state/useSessionOrdering.d.ts +1 -1
  190. package/lib/client/surface/state/useSessionOrdering.d.ts.map +1 -1
  191. package/lib/client/surface/state/useSessionOrdering.js +23 -8
  192. package/lib/client/surface/state/useSessionOrdering.js.map +1 -1
  193. package/lib/client/surface/types.d.ts +23 -3
  194. package/lib/client/surface/types.d.ts.map +1 -1
  195. package/lib/client/view/WorktreeModeAction.d.ts.map +1 -1
  196. package/lib/client/view/WorktreeModeAction.js +8 -0
  197. package/lib/client/view/WorktreeModeAction.js.map +1 -1
  198. package/lib/client/view/view-mode-dispatch.d.ts +7 -0
  199. package/lib/client/view/view-mode-dispatch.d.ts.map +1 -0
  200. package/lib/client/view/view-mode-dispatch.js +11 -0
  201. package/lib/client/view/view-mode-dispatch.js.map +1 -0
  202. package/lib/client/view/worktree-expand-state.d.ts +1 -1
  203. package/lib/client/view/worktree-expand-state.d.ts.map +1 -1
  204. package/lib/client/view/worktree-expand-state.js +8 -1
  205. package/lib/client/view/worktree-expand-state.js.map +1 -1
  206. package/lib/client/worktree-connection.d.ts +5 -0
  207. package/lib/client/worktree-connection.d.ts.map +1 -1
  208. package/lib/client/worktree-connection.js +14 -0
  209. package/lib/client/worktree-connection.js.map +1 -1
  210. package/lib/client.js +4865 -158
  211. package/lib/client.js.map +1 -1
  212. package/lib/contract/index.contract.js +10 -0
  213. package/lib/contract/index.contract.js.map +1 -1
  214. package/lib/contract/index.d.ts +163 -1
  215. package/lib/contract/index.d.ts.map +1 -1
  216. package/lib/contract/index.js +19 -0
  217. package/lib/contract/index.js.map +1 -1
  218. package/lib/host/dsh-read-adapter.d.ts +3 -1
  219. package/lib/host/dsh-read-adapter.d.ts.map +1 -1
  220. package/lib/host/dsh-read-adapter.js +5 -2
  221. package/lib/host/dsh-read-adapter.js.map +1 -1
  222. package/lib/host/remote.d.ts.map +1 -1
  223. package/lib/host/remote.js +5 -0
  224. package/lib/host/remote.js.map +1 -1
  225. package/lib/host/service.d.ts +20 -1
  226. package/lib/host/service.d.ts.map +1 -1
  227. package/lib/host/service.js +32 -0
  228. package/lib/host/service.js.map +1 -1
  229. package/lib/host/worktree-instructions.d.ts +41 -0
  230. package/lib/host/worktree-instructions.d.ts.map +1 -0
  231. package/lib/host/worktree-instructions.js +65 -0
  232. package/lib/host/worktree-instructions.js.map +1 -0
  233. package/lib/index.d.ts +4 -4
  234. package/lib/index.d.ts.map +1 -1
  235. package/lib/index.js +3 -3
  236. package/lib/index.js.map +1 -1
  237. package/lib/manage/manager-git-history.d.ts +17 -0
  238. package/lib/manage/manager-git-history.d.ts.map +1 -0
  239. package/lib/manage/manager-git-history.js +717 -0
  240. package/lib/manage/manager-git-history.js.map +1 -0
  241. package/lib/manage/manager-worktrees.d.ts.map +1 -1
  242. package/lib/manage/manager-worktrees.js +27 -0
  243. package/lib/manage/manager-worktrees.js.map +1 -1
  244. package/lib/manage/manager.d.ts +21 -1
  245. package/lib/manage/manager.d.ts.map +1 -1
  246. package/lib/manage/manager.js +60 -0
  247. package/lib/manage/manager.js.map +1 -1
  248. package/lib/manage/types.d.ts +1 -0
  249. package/lib/manage/types.d.ts.map +1 -1
  250. package/lib/provider/git/adapter.d.ts +54 -1
  251. package/lib/provider/git/adapter.d.ts.map +1 -1
  252. package/lib/provider/git/adapter.js +820 -1
  253. package/lib/provider/git/adapter.js.map +1 -1
  254. package/lib/provider/index.d.ts +2 -2
  255. package/lib/provider/index.d.ts.map +1 -1
  256. package/lib/provider/index.js +1 -1
  257. package/lib/provider/index.js.map +1 -1
  258. package/lib/provider/mutation-token.d.ts +1 -1
  259. package/lib/provider/mutation-token.d.ts.map +1 -1
  260. package/lib/provider/mutation-token.js +1 -0
  261. package/lib/provider/mutation-token.js.map +1 -1
  262. package/lib/provider/sidecar/repository.d.ts +1 -1
  263. package/lib/provider/sidecar/repository.d.ts.map +1 -1
  264. package/lib/provider/sidecar/repository.js +7 -2
  265. package/lib/provider/sidecar/repository.js.map +1 -1
  266. package/lib/provider/sidecar/sidecar-schema.d.ts +16 -3
  267. package/lib/provider/sidecar/sidecar-schema.d.ts.map +1 -1
  268. package/lib/provider/sidecar/sidecar-schema.js +117 -28
  269. package/lib/provider/sidecar/sidecar-schema.js.map +1 -1
  270. package/lib/provider/transaction/operations/create.d.ts.map +1 -1
  271. package/lib/provider/transaction/operations/create.js +21 -6
  272. package/lib/provider/transaction/operations/create.js.map +1 -1
  273. package/lib/provider/transaction/operations/import.d.ts.map +1 -1
  274. package/lib/provider/transaction/operations/import.js +1 -0
  275. package/lib/provider/transaction/operations/import.js.map +1 -1
  276. package/lib/provider/transaction/recovery/failure-recovery.d.ts.map +1 -1
  277. package/lib/provider/transaction/recovery/failure-recovery.js +11 -4
  278. package/lib/provider/transaction/recovery/failure-recovery.js.map +1 -1
  279. package/lib/provider/transaction/recovery/recover.js +11 -11
  280. package/lib/provider/transaction/recovery/recover.js.map +1 -1
  281. package/lib/provider/transaction/support/inspection.d.ts +0 -2
  282. package/lib/provider/transaction/support/inspection.d.ts.map +1 -1
  283. package/lib/provider/transaction/support/inspection.js +0 -4
  284. package/lib/provider/transaction/support/inspection.js.map +1 -1
  285. package/lib/provider/transaction/support/journal.d.ts.map +1 -1
  286. package/lib/provider/transaction/support/journal.js +4 -0
  287. package/lib/provider/transaction/support/journal.js.map +1 -1
  288. package/lib/provider/transaction/types.d.ts +1 -0
  289. package/lib/provider/transaction/types.d.ts.map +1 -1
  290. package/lib/provider/types.d.ts +53 -5
  291. package/lib/provider/types.d.ts.map +1 -1
  292. package/lib/provider/types.js +1 -1
  293. package/lib/provider/types.js.map +1 -1
  294. package/lib/typert.host.js +349 -33
  295. package/lib/typert.remote-client.d.ts +11 -1
  296. package/lib/typert.remote-client.d.ts.map +1 -1
  297. package/lib/typert.remote-client.js +349 -33
  298. package/package.json +23 -20
@@ -1,7 +1,293 @@
1
- import { realpath } from 'node:fs/promises';
1
+ import { lstat, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { GitCommandError, runGit, } from './subprocess.js';
4
5
  import { WorktreeProviderError, providerError } from '../types.js';
6
+ import { WORKTREE_GIT_COMMIT_PATTERN, WORKTREE_GIT_WORKING_TREE } from '../../contract/index.js';
7
+ /** Shared with Contract/Manage/Client so commit-SHA validation cannot drift. */
8
+ const COMMIT_PATTERN = WORKTREE_GIT_COMMIT_PATTERN;
9
+ const HISTORY_REQUEST_LIMIT = 201;
10
+ const HISTORY_VISIBLE_LIMIT = 200;
11
+ /**
12
+ * Line statistics for untracked files cost one `git diff --no-index` process per
13
+ * file. A large untracked tree therefore reports unknown counts beyond this
14
+ * bound instead of spawning an unbounded number of Git processes.
15
+ */
16
+ const MAX_UNTRACKED_STAT_FILES = 50;
17
+ /** Keeps the bounded per-file statistics fan-out from exhausting process limits. */
18
+ const STAT_READ_CONCURRENCY = 8;
19
+ function parseCommitHash(stdout, operation, workspaceRoot) {
20
+ const commit = stdout.trim();
21
+ if (!COMMIT_PATTERN.test(commit)) {
22
+ throw providerError('GIT_OPERATION_FAILED', `Git ${operation} returned an invalid commit`, {
23
+ workspaceRoot,
24
+ operation,
25
+ });
26
+ }
27
+ return commit;
28
+ }
29
+ function assertCommitArgument(commit, operation, workspaceRoot) {
30
+ if (!COMMIT_PATTERN.test(commit)) {
31
+ throw providerError('GIT_OPERATION_FAILED', `Invalid commit supplied to Git ${operation}`, {
32
+ workspaceRoot,
33
+ operation,
34
+ });
35
+ }
36
+ }
37
+ function parseCommitRecord(record, workspaceRoot) {
38
+ const fields = record.split('\0');
39
+ if (fields.length !== 6) {
40
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned malformed commit history data', {
41
+ workspaceRoot,
42
+ operation: 'list commits',
43
+ });
44
+ }
45
+ const [sha, parents, subject, authorName, authorEmail, authoredAt] = fields;
46
+ if (!sha ||
47
+ !COMMIT_PATTERN.test(sha) ||
48
+ (parents !== '' && parents.split(/\s+/u).some((parent) => !COMMIT_PATTERN.test(parent))) ||
49
+ !authorName ||
50
+ !authoredAt) {
51
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned invalid commit history data', {
52
+ workspaceRoot,
53
+ operation: 'list commits',
54
+ });
55
+ }
56
+ return {
57
+ sha,
58
+ parents: parents === '' ? [] : parents.split(/\s+/u),
59
+ subject,
60
+ authorName,
61
+ ...(authorEmail ? { authorEmail } : {}),
62
+ authoredAt,
63
+ };
64
+ }
65
+ function parseChangedFiles(stdout, workspaceRoot) {
66
+ const fields = stdout.split('\0');
67
+ const files = [];
68
+ let index = 0;
69
+ while (index < fields.length) {
70
+ const statusField = (fields[index++] ?? '').replace(/^[\r\n]+/u, '');
71
+ if (statusField === '')
72
+ continue;
73
+ const statusCode = statusField[0]?.toUpperCase();
74
+ const status = statusCode === 'A'
75
+ ? 'added'
76
+ : statusCode === 'M'
77
+ ? 'modified'
78
+ : statusCode === 'D'
79
+ ? 'deleted'
80
+ : statusCode === 'R'
81
+ ? 'renamed'
82
+ : statusCode === 'C'
83
+ ? 'copied'
84
+ : statusCode === 'T'
85
+ ? 'type-changed'
86
+ : undefined;
87
+ if (status === undefined) {
88
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned an unsupported changed-file status', {
89
+ workspaceRoot,
90
+ operation: 'list commit files',
91
+ status: statusField,
92
+ });
93
+ }
94
+ const firstPath = fields[index++];
95
+ if (!firstPath) {
96
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned a changed file without a path', {
97
+ workspaceRoot,
98
+ operation: 'list commit files',
99
+ });
100
+ }
101
+ if (status === 'renamed' || status === 'copied') {
102
+ const newPath = fields[index++];
103
+ if (!newPath) {
104
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned a rename/copy without two paths', {
105
+ workspaceRoot,
106
+ operation: 'list commit files',
107
+ });
108
+ }
109
+ files.push({ path: newPath, oldPath: firstPath, status });
110
+ }
111
+ else {
112
+ files.push({ path: firstPath, status });
113
+ }
114
+ }
115
+ return files;
116
+ }
117
+ function parseUntrackedFiles(stdout) {
118
+ return stdout
119
+ .split('\0')
120
+ .filter((filePath) => filePath.length > 0)
121
+ .map((filePath) => ({ path: filePath, status: 'added' }));
122
+ }
123
+ function parseDiffCount(value, workspaceRoot, operation) {
124
+ if (value === '-')
125
+ return undefined;
126
+ const count = Number(value);
127
+ if (!Number.isSafeInteger(count) || count < 0) {
128
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned an invalid line count', {
129
+ workspaceRoot,
130
+ operation,
131
+ value,
132
+ });
133
+ }
134
+ return count;
135
+ }
136
+ /** Parse Git's NUL-safe --numstat projection, including rename/copy pairs. */
137
+ function parseNumstat(stdout, workspaceRoot, operation) {
138
+ const fields = stdout.split('\0');
139
+ const stats = [];
140
+ let index = 0;
141
+ while (index < fields.length) {
142
+ const record = fields[index++];
143
+ if (record === undefined || record.length === 0)
144
+ continue;
145
+ const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/u.exec(record);
146
+ if (match === null) {
147
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned malformed line-count data', {
148
+ workspaceRoot,
149
+ operation,
150
+ record,
151
+ });
152
+ }
153
+ const additions = parseDiffCount(match[1], workspaceRoot, operation);
154
+ const deletions = parseDiffCount(match[2], workspaceRoot, operation);
155
+ const pathName = match[3];
156
+ if (pathName.length > 0) {
157
+ stats.push({
158
+ path: pathName,
159
+ ...(additions === undefined ? {} : { additions }),
160
+ ...(deletions === undefined ? {} : { deletions }),
161
+ ...(additions === undefined || deletions === undefined ? { unknown: true } : {}),
162
+ });
163
+ continue;
164
+ }
165
+ const oldPath = fields[index++];
166
+ const newPath = fields[index++];
167
+ if (!oldPath || !newPath) {
168
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned a rename/copy without two numstat paths', {
169
+ workspaceRoot,
170
+ operation,
171
+ });
172
+ }
173
+ stats.push({
174
+ path: newPath,
175
+ oldPath,
176
+ ...(additions === undefined ? {} : { additions }),
177
+ ...(deletions === undefined ? {} : { deletions }),
178
+ ...(additions === undefined || deletions === undefined ? { unknown: true } : {}),
179
+ });
180
+ }
181
+ return stats;
182
+ }
183
+ function mergeDiffStat(current, next) {
184
+ if (current === undefined)
185
+ return next;
186
+ if (current.unknown === true || next.unknown === true) {
187
+ return {
188
+ path: current.path,
189
+ ...(current.oldPath === undefined ? {} : { oldPath: current.oldPath }),
190
+ unknown: true,
191
+ };
192
+ }
193
+ const additions = current.additions === undefined || next.additions === undefined
194
+ ? current.additions ?? next.additions
195
+ : current.additions + next.additions;
196
+ const deletions = current.deletions === undefined || next.deletions === undefined
197
+ ? current.deletions ?? next.deletions
198
+ : current.deletions + next.deletions;
199
+ return {
200
+ path: current.path,
201
+ ...(current.oldPath === undefined ? {} : { oldPath: current.oldPath }),
202
+ ...(additions === undefined ? {} : { additions }),
203
+ ...(deletions === undefined ? {} : { deletions }),
204
+ };
205
+ }
206
+ function decorateChangedFiles(files, stats) {
207
+ const byPath = new Map();
208
+ for (const stat of stats) {
209
+ byPath.set(stat.path, mergeDiffStat(byPath.get(stat.path), stat));
210
+ if (stat.oldPath !== undefined)
211
+ byPath.set(stat.oldPath, mergeDiffStat(byPath.get(stat.oldPath), stat));
212
+ }
213
+ return files.map((file) => {
214
+ const stat = byPath.get(file.path) ?? (file.oldPath === undefined ? undefined : byPath.get(file.oldPath));
215
+ if (stat === undefined || stat.unknown === true || (stat.additions === undefined && stat.deletions === undefined))
216
+ return file;
217
+ return {
218
+ ...file,
219
+ ...(stat.additions === undefined ? {} : { additions: stat.additions }),
220
+ ...(stat.deletions === undefined ? {} : { deletions: stat.deletions }),
221
+ };
222
+ });
223
+ }
224
+ function omitRestoredStats(stats, restoredPaths) {
225
+ return stats.flatMap((stat) => {
226
+ if (restoredPaths.has(stat.path))
227
+ return [];
228
+ if (stat.oldPath !== undefined && restoredPaths.has(stat.oldPath))
229
+ return [];
230
+ return [stat];
231
+ });
232
+ }
233
+ function isBinaryDiff(stdout) {
234
+ return /^Binary files .* differ$/mu.test(stdout) || /^GIT binary patch$/mu.test(stdout);
235
+ }
236
+ /**
237
+ * True when decoded stdout cannot represent the blob bytes exactly. Binary blobs
238
+ * (NUL bytes) or lossy UTF-8 decoding make a temporary text baseline unreliable.
239
+ */
240
+ function isLossyBlob(stdout) {
241
+ return stdout.includes('\u0000') || stdout.includes('\ufffd');
242
+ }
243
+ /** Reject missing or empty paths before they reach a Git invocation. */
244
+ function requireFilePath(filePath, operation, worktreeRoot) {
245
+ if (typeof filePath !== 'string' || filePath.length === 0) {
246
+ throw providerError('GIT_OPERATION_FAILED', 'A changed file path is required', {
247
+ workspaceRoot: worktreeRoot,
248
+ operation,
249
+ });
250
+ }
251
+ }
252
+ /** Run a bounded number of Git reads in parallel without an unbounded fan-out. */
253
+ async function mapInChunks(values, concurrency, map) {
254
+ const results = [];
255
+ for (let index = 0; index < values.length; index += concurrency) {
256
+ results.push(...await Promise.all(values.slice(index, index + concurrency).map(map)));
257
+ }
258
+ return results;
259
+ }
260
+ /**
261
+ * Normalize one file-diff invocation: binary patches are emptied, exceeded output
262
+ * bounds become an explicit truncated state, and Git failures keep the existing
263
+ * provider error plumbing.
264
+ */
265
+ async function shapeFileDiff(run, identity) {
266
+ try {
267
+ const result = await run();
268
+ const binary = isBinaryDiff(result.stdout);
269
+ return {
270
+ commit: identity.commit,
271
+ path: identity.path,
272
+ patch: binary ? '' : result.stdout,
273
+ binary,
274
+ };
275
+ }
276
+ catch (error) {
277
+ if (error instanceof GitCommandError && error.outputTruncated) {
278
+ return {
279
+ commit: identity.commit,
280
+ path: identity.path,
281
+ patch: '',
282
+ binary: false,
283
+ truncated: true,
284
+ };
285
+ }
286
+ if (error instanceof WorktreeProviderError)
287
+ throw error;
288
+ throw operationError(identity.operation, identity.worktreeRoot, identity.path, identity.detail, error);
289
+ }
290
+ }
5
291
  const DEFAULT_TIMEOUT_MS = 30_000;
6
292
  const DEFAULT_GRACE_MS = 1_000;
7
293
  const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000;
@@ -292,6 +578,539 @@ export class LocalGitAdapter {
292
578
  throw operationError('resolve repository identity', workspaceRoot, undefined, undefined, error);
293
579
  }
294
580
  }
581
+ /** Resolve a ref to one canonical commit object for a bounded Manage operation. */
582
+ async resolveCommit(workspaceRoot, ref, options = {}) {
583
+ try {
584
+ const result = await this.run(['rev-parse', '--verify', `${ref}^{commit}`], workspaceRoot, options);
585
+ return parseCommitHash(result.stdout, 'resolve commit', workspaceRoot);
586
+ }
587
+ catch (error) {
588
+ if (error instanceof WorktreeProviderError)
589
+ throw error;
590
+ throw operationError('resolve commit', workspaceRoot, undefined, ref, error);
591
+ }
592
+ }
593
+ /** Resolve a common ancestor for two branch heads. */
594
+ async findMergeBase(workspaceRoot, left, right, options = {}) {
595
+ try {
596
+ const result = await this.run(['merge-base', left, right], workspaceRoot, options);
597
+ return parseCommitHash(result.stdout, 'find merge base', workspaceRoot);
598
+ }
599
+ catch (error) {
600
+ if (error instanceof GitCommandError &&
601
+ error.exitCode === 1 &&
602
+ !error.timedOut &&
603
+ !error.aborted &&
604
+ !error.outputTruncated) {
605
+ return undefined;
606
+ }
607
+ if (error instanceof WorktreeProviderError)
608
+ throw error;
609
+ throw operationError('find merge base', workspaceRoot, undefined, left, error);
610
+ }
611
+ }
612
+ /** Count commits that exist only on either branch head. */
613
+ async getCommitDivergence(worktreeRoot, baselineCommit, headCommit, options = {}) {
614
+ assertCommitArgument(baselineCommit, 'count commit divergence', worktreeRoot);
615
+ assertCommitArgument(headCommit, 'count commit divergence', worktreeRoot);
616
+ try {
617
+ const result = await this.run(['rev-list', '--left-right', '--count', baselineCommit + '...' + headCommit], worktreeRoot, options);
618
+ const fields = result.stdout.trim().split(/\s+/u);
619
+ if (fields.length !== 2 || fields.some((field) => !/^\d+$/u.test(field))) {
620
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned malformed ahead-behind counts', {
621
+ workspaceRoot: worktreeRoot,
622
+ operation: 'count commit divergence',
623
+ });
624
+ }
625
+ const behind = parseDiffCount(fields[0], worktreeRoot, 'count commit divergence');
626
+ const ahead = parseDiffCount(fields[1], worktreeRoot, 'count commit divergence');
627
+ if (behind === undefined || ahead === undefined) {
628
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned malformed ahead-behind counts', {
629
+ workspaceRoot: worktreeRoot,
630
+ operation: 'count commit divergence',
631
+ });
632
+ }
633
+ return { behind, ahead };
634
+ }
635
+ catch (error) {
636
+ if (error instanceof WorktreeProviderError)
637
+ throw error;
638
+ throw operationError('count commit divergence', worktreeRoot, undefined, headCommit, error);
639
+ }
640
+ }
641
+ /** Read at most 201 Worktree commits after a resolved base branch tip. */
642
+ async listCommits(worktreeRoot, baseCommit, options = {}) {
643
+ assertCommitArgument(baseCommit, 'list commits', worktreeRoot);
644
+ const headCommit = await this.resolveCommit(worktreeRoot, 'HEAD', options);
645
+ try {
646
+ const result = await this.run([
647
+ 'log',
648
+ '--no-color',
649
+ '--topo-order',
650
+ `--max-count=${HISTORY_REQUEST_LIMIT}`,
651
+ '--format=%H%x00%P%x00%s%x00%an%x00%ae%x00%aI%x1e',
652
+ `${baseCommit}..HEAD`,
653
+ ], worktreeRoot, options);
654
+ const commits = result.stdout
655
+ .split('\x1e')
656
+ .map((record) => record.replace(/^[\r\n]+|[\r\n]+$/gu, ''))
657
+ .filter((record) => record.length > 0)
658
+ .map((record) => parseCommitRecord(record, worktreeRoot));
659
+ return {
660
+ headCommit,
661
+ commits: commits.slice(0, HISTORY_VISIBLE_LIMIT),
662
+ truncated: commits.length > HISTORY_VISIBLE_LIMIT,
663
+ };
664
+ }
665
+ catch (error) {
666
+ if (error instanceof WorktreeProviderError)
667
+ throw error;
668
+ throw operationError('list commits', worktreeRoot, undefined, baseCommit, error);
669
+ }
670
+ }
671
+ async isTrackedWorkingTreeFile(worktreeRoot, filePath, options) {
672
+ try {
673
+ await this.run(['ls-files', '--error-unmatch', '--', filePath], worktreeRoot, options);
674
+ return true;
675
+ }
676
+ catch (error) {
677
+ if (error instanceof GitCommandError &&
678
+ error.exitCode === 1 &&
679
+ !error.timedOut &&
680
+ !error.aborted &&
681
+ !error.outputTruncated) {
682
+ return false;
683
+ }
684
+ if (error instanceof WorktreeProviderError)
685
+ throw error;
686
+ throw operationError('check working tree file', worktreeRoot, filePath, undefined, error);
687
+ }
688
+ }
689
+ async isPresentInCommit(worktreeRoot, commit, filePath, options) {
690
+ if (commit !== 'HEAD')
691
+ assertCommitArgument(commit, 'check committed working-tree file', worktreeRoot);
692
+ try {
693
+ const result = await this.run(['--literal-pathspecs', 'ls-tree', '-r', '-z', '--name-only', commit, '--', filePath], worktreeRoot, options);
694
+ return result.stdout.split('\0').some((candidate) => candidate === filePath);
695
+ }
696
+ catch (error) {
697
+ if (error instanceof WorktreeProviderError)
698
+ throw error;
699
+ throw operationError('check committed working-tree file', worktreeRoot, filePath, commit, error);
700
+ }
701
+ }
702
+ async isPresentOnDisk(worktreeRoot, filePath) {
703
+ try {
704
+ await lstat(path.resolve(worktreeRoot, filePath));
705
+ return true;
706
+ }
707
+ catch (error) {
708
+ if (error.code === 'ENOENT')
709
+ return false;
710
+ throw operationError('check working tree path', worktreeRoot, filePath, undefined, error);
711
+ }
712
+ }
713
+ async readRestoredWorkingTreeFileDiff(worktreeRoot, baseCommit, filePath, options) {
714
+ const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'clutch-dsh-git-live-diff-'));
715
+ const baselinePath = path.join(temporaryDirectory, 'baseline');
716
+ try {
717
+ const baseline = await this.run(['--literal-pathspecs', 'cat-file', 'blob', `${baseCommit}:${filePath}`], worktreeRoot, options);
718
+ // A re-encoded binary baseline would produce a false comparison result, so
719
+ // the caller falls back to the untracked projection instead.
720
+ if (isLossyBlob(baseline.stdout))
721
+ return undefined;
722
+ await writeFile(baselinePath, baseline.stdout);
723
+ const result = await this.runDiffAllowingChanges([
724
+ '--literal-pathspecs',
725
+ 'diff',
726
+ '--no-index',
727
+ '--no-color',
728
+ '--no-ext-diff',
729
+ '--no-textconv',
730
+ '--',
731
+ baselinePath,
732
+ filePath,
733
+ ], worktreeRoot, options);
734
+ return {
735
+ ...result,
736
+ stdout: result.stdout.replaceAll(baselinePath, filePath),
737
+ };
738
+ }
739
+ finally {
740
+ await rm(temporaryDirectory, { recursive: true, force: true });
741
+ }
742
+ }
743
+ async readRestoredWorkingTreeFileStats(worktreeRoot, baseCommit, filePath, options) {
744
+ const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'clutch-dsh-git-live-numstat-'));
745
+ const baselinePath = path.join(temporaryDirectory, 'baseline');
746
+ try {
747
+ const baseline = await this.run(['--literal-pathspecs', 'cat-file', 'blob', baseCommit + ':' + filePath], worktreeRoot, options);
748
+ // Binary line counts cannot be derived from a re-encoded baseline; report
749
+ // them as explicitly unknown rather than as an approximate number.
750
+ if (isLossyBlob(baseline.stdout))
751
+ return [{ path: filePath, unknown: true }];
752
+ await writeFile(baselinePath, baseline.stdout);
753
+ const result = await this.runDiffAllowingChanges([
754
+ '--literal-pathspecs',
755
+ 'diff',
756
+ '--no-index',
757
+ '--numstat',
758
+ '-z',
759
+ '--no-color',
760
+ '--no-ext-diff',
761
+ '--no-textconv',
762
+ '--',
763
+ baselinePath,
764
+ filePath,
765
+ ], worktreeRoot, options);
766
+ const stats = parseNumstat(result.stdout, worktreeRoot, 'list working-tree diff files');
767
+ return stats.length === 0
768
+ ? [{ path: filePath, additions: 0, deletions: 0 }]
769
+ : stats.map((stat) => ({
770
+ ...stat,
771
+ path: filePath,
772
+ oldPath: undefined,
773
+ }));
774
+ }
775
+ finally {
776
+ await rm(temporaryDirectory, { recursive: true, force: true });
777
+ }
778
+ }
779
+ async normalizeWorkingTreeDiffFiles(worktreeRoot, baseCommit, tracked, untracked, options) {
780
+ const untrackedPaths = new Set(untracked.map((file) => file.path));
781
+ const restoredPaths = new Set();
782
+ const addedPaths = new Set();
783
+ const replacements = new Map();
784
+ for (const [index, file] of tracked.entries()) {
785
+ const restoredPath = file.status === 'deleted'
786
+ ? file.path
787
+ : (file.status === 'renamed' || file.status === 'copied')
788
+ ? file.oldPath
789
+ : undefined;
790
+ if (restoredPath === undefined || !untrackedPaths.has(restoredPath) || restoredPaths.has(restoredPath))
791
+ continue;
792
+ const result = await this.readRestoredWorkingTreeFileDiff(worktreeRoot, baseCommit, restoredPath, options);
793
+ restoredPaths.add(restoredPath);
794
+ // An unrepresentable binary baseline counts as changed so the live
795
+ // projection never hides a real difference.
796
+ const differs = result === undefined || isBinaryDiff(result.stdout) || result.stdout.length > 0;
797
+ if (file.status === 'deleted') {
798
+ replacements.set(index, differs ? [{ path: file.path, status: 'modified' }] : []);
799
+ }
800
+ else if (differs) {
801
+ replacements.set(index, [
802
+ { path: restoredPath, status: 'modified' },
803
+ { path: file.path, status: 'added' },
804
+ ]);
805
+ addedPaths.add(file.path);
806
+ }
807
+ else {
808
+ replacements.set(index, [{ ...file, status: 'copied' }]);
809
+ }
810
+ }
811
+ return {
812
+ files: [
813
+ ...tracked.flatMap((file, index) => replacements.get(index) ?? [file]),
814
+ ...untracked.filter((file) => !restoredPaths.has(file.path)),
815
+ ],
816
+ restoredPaths: [...restoredPaths],
817
+ addedPaths: [...addedPaths],
818
+ };
819
+ }
820
+ async readNumstat(args, worktreeRoot, operation, options) {
821
+ const result = await this.run(args, worktreeRoot, options);
822
+ return parseNumstat(result.stdout, worktreeRoot, operation);
823
+ }
824
+ async listUntrackedFileStats(worktreeRoot, files, operation, options) {
825
+ const bounded = files.slice(0, MAX_UNTRACKED_STAT_FILES);
826
+ const stats = await mapInChunks(bounded, STAT_READ_CONCURRENCY, async (file) => {
827
+ const result = await this.runDiffAllowingChanges([
828
+ '--literal-pathspecs',
829
+ 'diff',
830
+ '--no-index',
831
+ '--numstat',
832
+ '-z',
833
+ '--no-color',
834
+ '--no-ext-diff',
835
+ '--no-textconv',
836
+ '--',
837
+ '/dev/null',
838
+ file.path,
839
+ ], worktreeRoot, options);
840
+ return parseNumstat(result.stdout, worktreeRoot, operation).map((stat) => ({
841
+ ...stat,
842
+ path: file.path,
843
+ oldPath: undefined,
844
+ }));
845
+ });
846
+ return stats.flat();
847
+ }
848
+ /**
849
+ * Bound one changed-file projection. Output past the adapter's byte limit is
850
+ * reported as an explicit truncated read rather than an unbounded payload or a
851
+ * generic Git failure, and path authorization over a truncated read fails
852
+ * closed because the authorized set is empty.
853
+ */
854
+ async boundedChangedFileRead(operation, worktreeRoot, detail, read) {
855
+ try {
856
+ return { files: await read(), truncated: false };
857
+ }
858
+ catch (error) {
859
+ if (error instanceof GitCommandError && error.outputTruncated) {
860
+ return { files: [], truncated: true };
861
+ }
862
+ if (error instanceof WorktreeProviderError)
863
+ throw error;
864
+ throw operationError(operation, worktreeRoot, undefined, detail, error);
865
+ }
866
+ }
867
+ async runDiffAllowingChanges(args, worktreeRoot, options) {
868
+ try {
869
+ return await this.run(args, worktreeRoot, options);
870
+ }
871
+ catch (error) {
872
+ // `git diff --no-index` exits with 1 when it finds a difference.
873
+ if (error instanceof GitCommandError &&
874
+ error.exitCode === 1 &&
875
+ !error.timedOut &&
876
+ !error.aborted &&
877
+ !error.outputTruncated) {
878
+ return { stdout: error.stdout, stderr: error.stderr };
879
+ }
880
+ throw error;
881
+ }
882
+ }
883
+ /** Read the tracked and untracked changed-path projection for one revision. */
884
+ async readChangedFiles(worktreeRoot, revision, options) {
885
+ const [tracked, untracked] = await Promise.all([
886
+ this.run(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--name-status', '-z', '-M', '-C', revision, '--'], worktreeRoot, options),
887
+ this.run(['ls-files', '--others', '--exclude-standard', '-z', '--'], worktreeRoot, options),
888
+ ]);
889
+ return {
890
+ tracked: parseChangedFiles(tracked.stdout, worktreeRoot),
891
+ untracked: parseUntrackedFiles(untracked.stdout),
892
+ };
893
+ }
894
+ /**
895
+ * Read the live working-tree changed paths without line statistics. Path
896
+ * authorization only needs the projection, so callers that must confirm one
897
+ * path avoid the per-untracked-file statistics probe.
898
+ */
899
+ async listWorkingTreeChangedPaths(worktreeRoot, options = {}) {
900
+ return this.boundedChangedFileRead('list working tree changed paths', worktreeRoot, 'HEAD', async () => {
901
+ const { tracked, untracked } = await this.readChangedFiles(worktreeRoot, 'HEAD', options);
902
+ return [...tracked, ...untracked];
903
+ });
904
+ }
905
+ /** Read tracked and untracked changes relative to the current HEAD. */
906
+ async listWorkingTreeFiles(worktreeRoot, options = {}) {
907
+ return this.boundedChangedFileRead('list working tree files', worktreeRoot, 'HEAD', async () => {
908
+ const { tracked, untracked } = await this.readChangedFiles(worktreeRoot, 'HEAD', options);
909
+ const [trackedStats, untrackedStats] = await Promise.all([
910
+ this.readNumstat(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--numstat', '-z', '-M', '-C', 'HEAD', '--'], worktreeRoot, 'list working tree files', options),
911
+ this.listUntrackedFileStats(worktreeRoot, untracked, 'list working tree files', options),
912
+ ]);
913
+ return decorateChangedFiles([...tracked, ...untracked], [...trackedStats, ...untrackedStats]);
914
+ });
915
+ }
916
+ /** Read one tracked or untracked working-tree file diff against HEAD. */
917
+ async readWorkingTreeFileDiff(worktreeRoot, filePath, options = {}) {
918
+ const operation = 'read working tree file diff';
919
+ requireFilePath(filePath, operation, worktreeRoot);
920
+ const tracked = await this.isTrackedWorkingTreeFile(worktreeRoot, filePath, options);
921
+ const presentInBase = !tracked && await this.isPresentInCommit(worktreeRoot, 'HEAD', filePath, options);
922
+ const restoredUntracked = presentInBase && await this.isPresentOnDisk(worktreeRoot, filePath);
923
+ const compareAsTracked = tracked || (presentInBase && !restoredUntracked);
924
+ const trackedArgs = [
925
+ '--literal-pathspecs',
926
+ 'diff',
927
+ '--no-color',
928
+ '--no-ext-diff',
929
+ '--no-textconv',
930
+ '-M',
931
+ 'HEAD',
932
+ '--',
933
+ filePath,
934
+ ];
935
+ const untrackedArgs = [
936
+ '--literal-pathspecs',
937
+ 'diff',
938
+ '--no-index',
939
+ '--no-color',
940
+ '--no-ext-diff',
941
+ '--no-textconv',
942
+ '--',
943
+ '/dev/null',
944
+ filePath,
945
+ ];
946
+ return shapeFileDiff(async () => {
947
+ if (!restoredUntracked) {
948
+ return compareAsTracked
949
+ ? this.run(trackedArgs, worktreeRoot, options)
950
+ : this.runDiffAllowingChanges(untrackedArgs, worktreeRoot, options);
951
+ }
952
+ // A binary baseline blob cannot round-trip through decoded stdout: fall
953
+ // back to the untracked projection instead of claiming equality.
954
+ const restored = await this.readRestoredWorkingTreeFileDiff(worktreeRoot, 'HEAD', filePath, options);
955
+ return restored ?? this.runDiffAllowingChanges(untrackedArgs, worktreeRoot, options);
956
+ }, { commit: WORKTREE_GIT_WORKING_TREE, path: filePath, operation, worktreeRoot, detail: 'HEAD' });
957
+ }
958
+ async resolveCommitParents(worktreeRoot, commit, options) {
959
+ assertCommitArgument(commit, 'read commit parents', worktreeRoot);
960
+ try {
961
+ const result = await this.run(['rev-list', '--parents', '-n', '1', commit], worktreeRoot, options);
962
+ const fields = result.stdout.trim().split(/\s+/u).filter((field) => field.length > 0);
963
+ if (fields.length === 0 || !COMMIT_PATTERN.test(fields[0] ?? '') ||
964
+ fields.slice(1).some((parent) => !COMMIT_PATTERN.test(parent))) {
965
+ throw providerError('GIT_OPERATION_FAILED', 'Git returned malformed commit parent data', {
966
+ workspaceRoot: worktreeRoot,
967
+ operation: 'read commit parents',
968
+ });
969
+ }
970
+ return fields.slice(1);
971
+ }
972
+ catch (error) {
973
+ if (error instanceof WorktreeProviderError)
974
+ throw error;
975
+ throw operationError('read commit parents', worktreeRoot, undefined, commit, error);
976
+ }
977
+ }
978
+ /** Read one net baseline-to-HEAD tree diff using NUL-safe output. */
979
+ async listDiffFiles(worktreeRoot, baseCommit, targetCommit, options = {}) {
980
+ assertCommitArgument(baseCommit, 'list diff files', worktreeRoot);
981
+ assertCommitArgument(targetCommit, 'list diff files', worktreeRoot);
982
+ return this.boundedChangedFileRead('list diff files', worktreeRoot, `${baseCommit}..${targetCommit}`, async () => {
983
+ const [result, stats] = await Promise.all([
984
+ this.run(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--name-status', '-z', '-M', '-C', baseCommit, targetCommit, '--'], worktreeRoot, options),
985
+ this.readNumstat(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--numstat', '-z', '-M', '-C', baseCommit, targetCommit, '--'], worktreeRoot, 'list diff files', options),
986
+ ]);
987
+ return decorateChangedFiles(parseChangedFiles(result.stdout, worktreeRoot), stats);
988
+ });
989
+ }
990
+ /** Read net tracked and untracked changes from an arbitrary committed base to the live tree. */
991
+ async listWorkingTreeDiffFiles(worktreeRoot, baseCommit, options = {}) {
992
+ assertCommitArgument(baseCommit, 'list working-tree diff files', worktreeRoot);
993
+ return this.boundedChangedFileRead('list working-tree diff files', worktreeRoot, baseCommit, async () => {
994
+ const { tracked: trackedFiles, untracked: untrackedFiles } = await this.readChangedFiles(worktreeRoot, baseCommit, options);
995
+ const [trackedStats, untrackedStats] = await Promise.all([
996
+ this.readNumstat(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--numstat', '-z', '-M', '-C', baseCommit, '--'], worktreeRoot, 'list working-tree diff files', options),
997
+ this.listUntrackedFileStats(worktreeRoot, untrackedFiles, 'list working-tree diff files', options),
998
+ ]);
999
+ const normalized = await this.normalizeWorkingTreeDiffFiles(worktreeRoot, baseCommit, trackedFiles, untrackedFiles, options);
1000
+ const restoredPaths = new Set(normalized.restoredPaths);
1001
+ const restoredStats = await Promise.all(normalized.restoredPaths.map((filePath) => this.readRestoredWorkingTreeFileStats(worktreeRoot, baseCommit, filePath, options)));
1002
+ const restoredAddedStats = await this.listUntrackedFileStats(worktreeRoot, normalized.addedPaths.map((filePath) => ({ path: filePath, status: 'added' })), 'list working-tree diff files', options);
1003
+ const baseStats = omitRestoredStats([...trackedStats, ...untrackedStats], restoredPaths);
1004
+ return decorateChangedFiles(normalized.files, [...baseStats, ...restoredStats.flat(), ...restoredAddedStats]);
1005
+ });
1006
+ }
1007
+ /** Read first-parent (or root) changed-file metadata using NUL-safe output. */
1008
+ async listCommitFiles(worktreeRoot, commit, options = {}) {
1009
+ const parents = await this.resolveCommitParents(worktreeRoot, commit, options);
1010
+ const args = parents.length > 0
1011
+ ? ['--literal-pathspecs', 'diff-tree', '--no-commit-id', '--name-status', '-z', '-r', '-M', '-C', parents[0], commit]
1012
+ : ['--literal-pathspecs', 'diff-tree', '--root', '--no-commit-id', '--name-status', '-z', '-r', '-M', '-C', commit];
1013
+ const numstatArgs = parents.length > 0
1014
+ ? ['--literal-pathspecs', 'diff-tree', '--no-commit-id', '--numstat', '-z', '-r', '-M', '-C', parents[0], commit]
1015
+ : ['--literal-pathspecs', 'diff-tree', '--root', '--no-commit-id', '--numstat', '-z', '-r', '-M', '-C', commit];
1016
+ return this.boundedChangedFileRead('list commit files', worktreeRoot, commit, async () => {
1017
+ const [result, stats] = await Promise.all([
1018
+ this.run(args, worktreeRoot, options),
1019
+ this.readNumstat(numstatArgs, worktreeRoot, 'list commit files', options),
1020
+ ]);
1021
+ return decorateChangedFiles(parseChangedFiles(result.stdout, worktreeRoot), stats);
1022
+ });
1023
+ }
1024
+ /** Read one live working-tree file from an arbitrary committed base. */
1025
+ async readWorkingTreeDiffFileDiff(worktreeRoot, baseCommit, filePath, options = {}) {
1026
+ const operation = 'read working-tree diff file';
1027
+ requireFilePath(filePath, operation, worktreeRoot);
1028
+ assertCommitArgument(baseCommit, operation, worktreeRoot);
1029
+ const tracked = await this.isTrackedWorkingTreeFile(worktreeRoot, filePath, options);
1030
+ const presentInBase = !tracked && await this.isPresentInCommit(worktreeRoot, baseCommit, filePath, options);
1031
+ const restoredUntracked = presentInBase && await this.isPresentOnDisk(worktreeRoot, filePath);
1032
+ const compareAsTracked = tracked || (presentInBase && !restoredUntracked);
1033
+ const trackedArgs = [
1034
+ '--literal-pathspecs',
1035
+ 'diff',
1036
+ '--no-color',
1037
+ '--no-ext-diff',
1038
+ '--no-textconv',
1039
+ '-M',
1040
+ '-C',
1041
+ baseCommit,
1042
+ '--',
1043
+ filePath,
1044
+ ];
1045
+ const untrackedArgs = [
1046
+ '--literal-pathspecs',
1047
+ 'diff',
1048
+ '--no-index',
1049
+ '--no-color',
1050
+ '--no-ext-diff',
1051
+ '--no-textconv',
1052
+ '--',
1053
+ '/dev/null',
1054
+ filePath,
1055
+ ];
1056
+ return shapeFileDiff(async () => {
1057
+ if (!restoredUntracked) {
1058
+ return compareAsTracked
1059
+ ? this.run(trackedArgs, worktreeRoot, options)
1060
+ : this.runDiffAllowingChanges(untrackedArgs, worktreeRoot, options);
1061
+ }
1062
+ const restored = await this.readRestoredWorkingTreeFileDiff(worktreeRoot, baseCommit, filePath, options);
1063
+ return restored ?? this.runDiffAllowingChanges(untrackedArgs, worktreeRoot, options);
1064
+ }, { commit: WORKTREE_GIT_WORKING_TREE, path: filePath, operation, worktreeRoot, detail: baseCommit });
1065
+ }
1066
+ /** Read one file from a net baseline-to-HEAD tree diff. */
1067
+ async readDiffFileDiff(worktreeRoot, baseCommit, targetCommit, filePath, options = {}) {
1068
+ const operation = 'read diff file diff';
1069
+ requireFilePath(filePath, operation, worktreeRoot);
1070
+ assertCommitArgument(baseCommit, operation, worktreeRoot);
1071
+ assertCommitArgument(targetCommit, operation, worktreeRoot);
1072
+ return shapeFileDiff(() => this.run(['--literal-pathspecs', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '-M', '-C', baseCommit, targetCommit, '--', filePath], worktreeRoot, options), {
1073
+ commit: targetCommit,
1074
+ path: filePath,
1075
+ operation,
1076
+ worktreeRoot,
1077
+ detail: baseCommit + '..' + targetCommit,
1078
+ });
1079
+ }
1080
+ /** Read one authorized file patch with external diff and textconv disabled. */
1081
+ async readCommitFileDiff(worktreeRoot, commit, filePath, options = {}) {
1082
+ const operation = 'read commit file diff';
1083
+ requireFilePath(filePath, operation, worktreeRoot);
1084
+ const parents = await this.resolveCommitParents(worktreeRoot, commit, options);
1085
+ const args = parents.length > 0
1086
+ ? [
1087
+ '--literal-pathspecs',
1088
+ 'diff',
1089
+ '--no-color',
1090
+ '--no-ext-diff',
1091
+ '--no-textconv',
1092
+ '-M',
1093
+ parents[0],
1094
+ commit,
1095
+ '--',
1096
+ filePath,
1097
+ ]
1098
+ : [
1099
+ '--literal-pathspecs',
1100
+ 'diff-tree',
1101
+ '--root',
1102
+ '--no-commit-id',
1103
+ '--no-color',
1104
+ '--no-ext-diff',
1105
+ '--no-textconv',
1106
+ '-p',
1107
+ '-M',
1108
+ commit,
1109
+ '--',
1110
+ filePath,
1111
+ ];
1112
+ return shapeFileDiff(() => this.run(args, worktreeRoot, options), { commit, path: filePath, operation, worktreeRoot, detail: commit });
1113
+ }
295
1114
  /**
296
1115
  * 仅列出 `refs/heads/` 下的本地分支;NUL 分隔避免依赖面向人的展示格式或空白切分。
297
1116
  * Lists local branches under `refs/heads/` only; NUL delimiting avoids