@nimbus-sh/worker 0.10.0 → 0.11.0

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 (260) hide show
  1. package/NOTICE.md +5 -0
  2. package/README.md +10 -0
  3. package/dist/_shared/session-router.d.ts +7 -0
  4. package/dist/_shared/session-router.d.ts.map +1 -1
  5. package/dist/_shared/session-router.js +7 -0
  6. package/dist/bindings/body.d.ts +6 -0
  7. package/dist/bindings/body.d.ts.map +1 -0
  8. package/dist/bindings/body.js +37 -0
  9. package/dist/bindings/kv.d.ts +0 -2
  10. package/dist/bindings/kv.d.ts.map +1 -1
  11. package/dist/bindings/kv.js +3 -42
  12. package/dist/bindings/r2.d.ts +0 -2
  13. package/dist/bindings/r2.d.ts.map +1 -1
  14. package/dist/bindings/r2.js +4 -44
  15. package/dist/esbuild-cli-artifact.generated.d.ts +18 -0
  16. package/dist/esbuild-cli-artifact.generated.d.ts.map +1 -0
  17. package/dist/esbuild-cli-artifact.generated.js +17 -0
  18. package/dist/facets/cirrus-real.d.ts.map +1 -1
  19. package/dist/facets/cirrus-real.js +9 -4
  20. package/dist/facets/compose.d.ts +11 -7
  21. package/dist/facets/compose.d.ts.map +1 -1
  22. package/dist/facets/compose.js +8 -38
  23. package/dist/facets/data-plan.d.ts +79 -0
  24. package/dist/facets/data-plan.d.ts.map +1 -0
  25. package/dist/facets/data-plan.js +477 -0
  26. package/dist/facets/esbuild-transform.d.ts +36 -10
  27. package/dist/facets/esbuild-transform.d.ts.map +1 -1
  28. package/dist/facets/esbuild-transform.js +216 -20
  29. package/dist/facets/exec-telemetry.d.ts +7 -3
  30. package/dist/facets/exec-telemetry.d.ts.map +1 -1
  31. package/dist/facets/manager.d.ts +179 -78
  32. package/dist/facets/manager.d.ts.map +1 -1
  33. package/dist/facets/manager.js +1221 -649
  34. package/dist/facets/opencode-staging.d.ts +0 -2
  35. package/dist/facets/opencode-staging.d.ts.map +1 -1
  36. package/dist/facets/opencode-staging.js +5 -11
  37. package/dist/facets/process.d.ts +4 -0
  38. package/dist/facets/process.d.ts.map +1 -1
  39. package/dist/facets/process.js +3 -0
  40. package/dist/facets/read-profile.d.ts +149 -0
  41. package/dist/facets/read-profile.d.ts.map +1 -0
  42. package/dist/facets/read-profile.js +483 -0
  43. package/dist/facets/resident-identity.d.ts +0 -1
  44. package/dist/facets/resident-identity.d.ts.map +1 -1
  45. package/dist/facets/resident-identity.js +0 -3
  46. package/dist/facets/wasm-swap-registry.d.ts +34 -25
  47. package/dist/facets/wasm-swap-registry.d.ts.map +1 -1
  48. package/dist/facets/wasm-swap-registry.js +62 -56
  49. package/dist/facets/ws-terminal.d.ts +4 -4
  50. package/dist/facets/ws-terminal.d.ts.map +1 -1
  51. package/dist/facets/ws-terminal.js +4 -5
  52. package/dist/git/commands.d.ts +16 -9
  53. package/dist/git/commands.d.ts.map +1 -1
  54. package/dist/git/commands.js +1818 -188
  55. package/dist/git/network-facet.d.ts +10 -2
  56. package/dist/git/network-facet.d.ts.map +1 -1
  57. package/dist/git/network-facet.js +62 -16
  58. package/dist/git/unified-diff.d.ts +74 -0
  59. package/dist/git/unified-diff.d.ts.map +1 -0
  60. package/dist/git/unified-diff.js +676 -0
  61. package/dist/git-bundle.generated.d.ts +7 -11
  62. package/dist/git-bundle.generated.d.ts.map +1 -1
  63. package/dist/git-bundle.generated.js +7 -11
  64. package/dist/hosted/commands.d.ts.map +1 -1
  65. package/dist/hosted/commands.js +62 -243
  66. package/dist/hosted/runtime.d.ts +19 -13
  67. package/dist/hosted/runtime.d.ts.map +1 -1
  68. package/dist/hosted/runtime.js +71 -14
  69. package/dist/hosted/services.d.ts +6 -7
  70. package/dist/hosted/services.d.ts.map +1 -1
  71. package/dist/hosted/services.js +10 -14
  72. package/dist/hosted/session.d.ts +165 -0
  73. package/dist/hosted/session.d.ts.map +1 -0
  74. package/dist/hosted/session.js +132 -0
  75. package/dist/loaders/generated-workers.d.ts +5 -2
  76. package/dist/loaders/generated-workers.d.ts.map +1 -1
  77. package/dist/loaders/generated-workers.js +6 -3
  78. package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -1
  79. package/dist/loaders/npm-resolve-preamble.js +0 -27
  80. package/dist/node-shims-artifact.generated.d.ts +13 -4
  81. package/dist/node-shims-artifact.generated.d.ts.map +1 -1
  82. package/dist/node-shims-artifact.generated.js +16 -7
  83. package/dist/npm/bin-links.d.ts +3 -1
  84. package/dist/npm/bin-links.d.ts.map +1 -1
  85. package/dist/npm/bin-links.js +32 -8
  86. package/dist/npm/cache.d.ts +5 -0
  87. package/dist/npm/cache.d.ts.map +1 -1
  88. package/dist/npm/cache.js +13 -1
  89. package/dist/npm/install-batch-facet.d.ts +2 -2
  90. package/dist/npm/install-batch-facet.js +3 -3
  91. package/dist/npm/installer.d.ts +17 -1
  92. package/dist/npm/installer.d.ts.map +1 -1
  93. package/dist/npm/installer.js +159 -20
  94. package/dist/npm/package-lock.d.ts +32 -0
  95. package/dist/npm/package-lock.d.ts.map +1 -0
  96. package/dist/npm/package-lock.js +65 -0
  97. package/dist/npm/pre-bundle-facet.d.ts +1 -30
  98. package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
  99. package/dist/npm/pre-bundle-facet.js +3 -5
  100. package/dist/npm/r2-cache.d.ts +2 -0
  101. package/dist/npm/r2-cache.d.ts.map +1 -1
  102. package/dist/npm/r2-cache.js +2 -0
  103. package/dist/npm/resolve-one-facet.d.ts.map +1 -1
  104. package/dist/npm/resolve-one-facet.js +14 -3
  105. package/dist/npm/resolver.d.ts +0 -7
  106. package/dist/npm/resolver.d.ts.map +1 -1
  107. package/dist/npm/resolver.js +0 -22
  108. package/dist/opencode-artifact.generated.js +2 -2
  109. package/dist/replica/routing.d.ts +2 -38
  110. package/dist/replica/routing.d.ts.map +1 -1
  111. package/dist/replica/routing.js +3 -55
  112. package/dist/router/index.d.ts.map +1 -1
  113. package/dist/router/index.js +15 -2
  114. package/dist/router/public-directory.d.ts +18 -0
  115. package/dist/router/public-directory.d.ts.map +1 -1
  116. package/dist/router/public-directory.js +1 -1
  117. package/dist/router/remote-api.d.ts +3 -1
  118. package/dist/router/remote-api.d.ts.map +1 -1
  119. package/dist/router/remote-api.js +42 -79
  120. package/dist/runtime/bash-repl.js +2 -2
  121. package/dist/runtime/bun-repl.d.ts.map +1 -1
  122. package/dist/runtime/bun-repl.js +2 -13
  123. package/dist/runtime/esbuild-wasm-bytes.d.ts +17 -5
  124. package/dist/runtime/esbuild-wasm-bytes.d.ts.map +1 -1
  125. package/dist/runtime/esbuild-wasm-bytes.js +43 -94
  126. package/dist/runtime/git-bundle-artifact.d.ts +17 -0
  127. package/dist/runtime/git-bundle-artifact.d.ts.map +1 -0
  128. package/dist/runtime/git-bundle-artifact.js +25 -0
  129. package/dist/runtime/node-repl.d.ts.map +1 -1
  130. package/dist/runtime/node-repl.js +2 -13
  131. package/dist/runtime/node-shims-artifact.d.ts +22 -23
  132. package/dist/runtime/node-shims-artifact.d.ts.map +1 -1
  133. package/dist/runtime/node-shims-artifact.js +48 -78
  134. package/dist/runtime/node-shims.d.ts.map +1 -1
  135. package/dist/runtime/node-shims.js +1567 -864
  136. package/dist/runtime/opencode-artifact.d.ts.map +1 -1
  137. package/dist/runtime/opencode-artifact.js +17 -53
  138. package/dist/runtime/opencode-facet-runner.d.ts +3 -6
  139. package/dist/runtime/opencode-facet-runner.d.ts.map +1 -1
  140. package/dist/runtime/opencode-facet-runner.js +45 -10
  141. package/dist/runtime/opentui-wasm-bytes.d.ts.map +1 -1
  142. package/dist/runtime/opentui-wasm-bytes.js +16 -49
  143. package/dist/runtime/package-manager.d.ts +0 -2
  144. package/dist/runtime/package-manager.d.ts.map +1 -1
  145. package/dist/runtime/package-manager.js +0 -3
  146. package/dist/runtime/python-repl.d.ts.map +1 -1
  147. package/dist/runtime/python-repl.js +2 -2
  148. package/dist/runtime/repl-session.d.ts +10 -0
  149. package/dist/runtime/repl-session.d.ts.map +1 -1
  150. package/dist/runtime/repl-session.js +7 -0
  151. package/dist/runtime/ruby-repl.d.ts.map +1 -1
  152. package/dist/runtime/ruby-repl.js +2 -2
  153. package/dist/runtime/ruby-resident.d.ts.map +1 -1
  154. package/dist/runtime/ruby-resident.js +36 -35
  155. package/dist/runtime/runtime-catalog.d.ts +10 -3
  156. package/dist/runtime/runtime-catalog.d.ts.map +1 -1
  157. package/dist/runtime/runtime-catalog.js +125 -15
  158. package/dist/runtime/sqlite-wasm-bytes.d.ts +4 -4
  159. package/dist/runtime/sqlite-wasm-bytes.d.ts.map +1 -1
  160. package/dist/runtime/sqlite-wasm-bytes.js +21 -57
  161. package/dist/runtime/staged-source.d.ts +67 -0
  162. package/dist/runtime/staged-source.d.ts.map +1 -0
  163. package/dist/runtime/staged-source.js +113 -0
  164. package/dist/runtime/wasi/preamble.d.ts.map +1 -1
  165. package/dist/runtime/wasi/preamble.js +0 -24
  166. package/dist/runtime-catalog.generated.js +1 -1
  167. package/dist/session/agent.d.ts +14 -2
  168. package/dist/session/agent.d.ts.map +1 -1
  169. package/dist/session/agent.js +57 -19
  170. package/dist/session/ai.d.ts +0 -11
  171. package/dist/session/ai.d.ts.map +1 -1
  172. package/dist/session/ai.js +0 -4
  173. package/dist/session/fs-watch.d.ts +0 -11
  174. package/dist/session/fs-watch.d.ts.map +1 -1
  175. package/dist/session/fs-watch.js +0 -21
  176. package/dist/session/hibernation.d.ts +34 -11
  177. package/dist/session/hibernation.d.ts.map +1 -1
  178. package/dist/session/hibernation.js +46 -29
  179. package/dist/session/init-phases.d.ts +1 -2
  180. package/dist/session/init-phases.d.ts.map +1 -1
  181. package/dist/session/init-phases.js +1 -2
  182. package/dist/session/init.d.ts.map +1 -1
  183. package/dist/session/init.js +7 -20
  184. package/dist/session/keys.d.ts +0 -3
  185. package/dist/session/keys.d.ts.map +1 -1
  186. package/dist/session/keys.js +0 -3
  187. package/dist/session/legacy-reset.d.ts +11 -0
  188. package/dist/session/legacy-reset.d.ts.map +1 -0
  189. package/dist/session/legacy-reset.js +16 -0
  190. package/dist/session/nimbus-session.d.ts +41 -15
  191. package/dist/session/nimbus-session.d.ts.map +1 -1
  192. package/dist/session/nimbus-session.js +64 -70
  193. package/dist/session/npm-install-port.d.ts.map +1 -1
  194. package/dist/session/npm-install-port.js +1 -0
  195. package/dist/session/programmatic.d.ts +13 -18
  196. package/dist/session/programmatic.d.ts.map +1 -1
  197. package/dist/session/programmatic.js +53 -39
  198. package/dist/session/replica-routes.d.ts +2 -3
  199. package/dist/session/replica-routes.d.ts.map +1 -1
  200. package/dist/session/replica-routes.js +3 -10
  201. package/dist/session/routes.d.ts.map +1 -1
  202. package/dist/session/routes.js +7 -10
  203. package/dist/session/rpc.d.ts +104 -20
  204. package/dist/session/rpc.d.ts.map +1 -1
  205. package/dist/session/rpc.js +184 -37
  206. package/dist/session/start-real-vite.js +2 -2
  207. package/dist/session/state-store.d.ts +1 -17
  208. package/dist/session/state-store.d.ts.map +1 -1
  209. package/dist/session/state-store.js +3 -53
  210. package/dist/session/supervisor-op.d.ts +7 -0
  211. package/dist/session/supervisor-op.d.ts.map +1 -1
  212. package/dist/session/supervisor-op.js +6 -1
  213. package/dist/session/supervisor-rpc.d.ts +104 -15
  214. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  215. package/dist/session/supervisor-rpc.js +188 -71
  216. package/dist/session/vite-command.d.ts.map +1 -1
  217. package/dist/session/vite-command.js +17 -14
  218. package/dist/session/ws.d.ts.map +1 -1
  219. package/dist/session/ws.js +20 -8
  220. package/dist/shell/npm-bin-entrypoints.d.ts.map +1 -1
  221. package/dist/shell/npm-bin-entrypoints.js +30 -5
  222. package/dist/vfs/facet-resident-limits.d.ts +33 -0
  223. package/dist/vfs/facet-resident-limits.d.ts.map +1 -0
  224. package/dist/vfs/facet-resident-limits.js +33 -0
  225. package/dist/vfs/facet-resident-store.d.ts +2 -10
  226. package/dist/vfs/facet-resident-store.d.ts.map +1 -1
  227. package/dist/vfs/facet-resident-store.js +1594 -219
  228. package/dist/workspace-host.d.ts +2 -0
  229. package/dist/workspace-host.d.ts.map +1 -1
  230. package/dist/workspace-host.js +2 -0
  231. package/dist/wrangler/nimbus-wrangler.d.ts +0 -2
  232. package/dist/wrangler/nimbus-wrangler.d.ts.map +1 -1
  233. package/dist/wrangler/nimbus-wrangler.js +5 -22
  234. package/package.json +8 -7
  235. package/public/_assets/opencode/1.16.2/index-attach.js +2 -2
  236. package/public/_assets/runtime/esbuild-cli-c4fda380d52befc1.js +6892 -0
  237. package/public/_assets/runtime/git-de3e60c9f3ff006a.js +103 -0
  238. package/public/_assets/runtime/{node-shims-7b4dd7a76d95d21e.js → node-shims-06565b121555c194.js} +2089 -861
  239. package/public/_assets/runtime/resident-store-6ffb1939c53d982f.js +2264 -0
  240. package/public/_assets/runtime/vfs-write-ledger-e501e8fc793bc2c1.js +889 -0
  241. package/scripts/build-opencode-attach-entry.mjs +51 -1
  242. package/scripts/bundle-facet-workers.mjs +114 -3
  243. package/scripts/bundle-git.mjs +88 -27
  244. package/scripts/bundle-node-shims.mjs +72 -46
  245. package/scripts/bundle-runtime.mjs +18 -421
  246. package/scripts/cf-git-patch.mjs +137 -0
  247. package/scripts/patch-install-deps.mjs +49 -96
  248. package/scripts/runtime-specs.mjs +435 -0
  249. package/vendor/git-http.generated.d.ts +82 -0
  250. package/vendor/git-upstream.generated.d.ts +4378 -0
  251. package/vendor/git.LICENSE +7 -0
  252. package/vendor/git.generated.d.mts +21 -0
  253. package/vendor/git.generated.mjs +103 -0
  254. package/dist/replica/suspension.d.ts +0 -36
  255. package/dist/replica/suspension.d.ts.map +0 -1
  256. package/dist/replica/suspension.js +0 -49
  257. package/dist/runtime/static-server.d.ts +0 -3
  258. package/dist/runtime/static-server.d.ts.map +0 -1
  259. package/dist/runtime/static-server.js +0 -133
  260. package/scripts/clean-dist.mjs +0 -7
@@ -1,9 +1,9 @@
1
1
  /**
2
- * git-commands.ts — Nimbus v2.0 Git integration via isomorphic-git.
2
+ * git/commands.ts — Nimbus v2.0 Git integration via isomorphic-git.
3
3
  *
4
4
  * Provides a full `git` command with subcommands:
5
- * init, clone, status, add, commit, log, branch, checkout,
6
- * diff, remote, fetch, pull, push, merge, reset, tag, stash
5
+ * init, clone, status, add, commit, log, branch, checkout, diff,
6
+ * ls-files, rev-parse, remote, fetch, pull, push, merge, reset, tag
7
7
  *
8
8
  * Uses a VFS→isomorphic-git FS adapter that maps all operations
9
9
  * to the SqliteVFS.
@@ -11,18 +11,20 @@
11
11
  import { requireVfsCred } from '@nimbus-sh/core/runtime/os-contracts.js';
12
12
  import { execGitNetwork } from './network-facet.js';
13
13
  import { normalizeVfsPath } from '@nimbus-sh/core/vfs/path.js';
14
- import { dec } from '@nimbus-sh/core/_shared/bytes.js';
14
+ import { dec, enc } from '@nimbus-sh/core/_shared/bytes.js';
15
+ import { DEFAULT_CONTEXT, DEFAULT_RENAME_SCORE, absentSpec, bytesFromBinary, detectRenames, formatNameOnly, formatNameStatus, formatPatch, formatStat, parseRenameScore, pathLine, statFile, } from './unified-diff.js';
15
16
  // ── Lazy-loaded isomorphic-git (avoid ~1MB load on every cold start) ────
16
17
  // NOTE: local git ops (init, status, add, commit, log, branch, checkout,
17
- // diff, remote, merge, reset, tag, config) run here in the supervisor DO.
18
+ // diff, ls-files, rev-parse, remote, merge, reset, tag, config) run here in the supervisor DO.
18
19
  // Network ops (clone, fetch, pull) are delegated to the git-network-facet
19
20
  // because the supervisor's CPU budget cannot handle packfile processing
20
21
  // for real-world repos (>100 files).
21
22
  let _git = null;
22
23
  async function getGit() {
23
24
  if (!_git) {
24
- // @ts-ignore — CF-compatible fork (github:AshishKumar4/cf-git)
25
- _git = await import('isomorphic-git');
25
+ // Same patched build artifact used by the network facet. A fresh npm
26
+ // consumer does not run Nimbus' repository dependency-patching hook.
27
+ _git = (await import('../../vendor/git.generated.mjs')).git;
26
28
  }
27
29
  return _git;
28
30
  }
@@ -46,21 +48,90 @@ function wantsUtf8(options) {
46
48
  * Creates an isomorphic-git compatible `fs` object from SqliteVFS.
47
49
  * isomorphic-git requires: readFile, writeFile, unlink, readdir,
48
50
  * mkdir, rmdir, stat, lstat (all as promises).
51
+ *
52
+ * With a `worktree`, the adapter writes that worktree the way git's checkout
53
+ * does (entry.c create_directories, has_symlink_leading_path): below its top,
54
+ * `.git` aside, every leading component is lstat'd, and one that is not a real
55
+ * directory (a link, dangling or not, or a file) is replaced by one rather than
56
+ * followed, and a file replaces a link at its own path instead of writing
57
+ * through it. Components above the top are followed, as git follows them.
58
+ * Commands that only read the worktree or write `.git` pass no worktree.
49
59
  */
50
- function createGitFs(vfs) {
51
- // Path normalization is shared with esbuild-service via ./vfs-path.ts.
60
+ function createGitFs(vfs, worktree = null) {
61
+ // Path normalization is shared with esbuild-service via @nimbus-sh/core/vfs/path.js.
52
62
  // isomorphic-git constructs paths like `dir + '/' + filepath` which can
53
63
  // produce `/home/user/project/.` or paths with `..` segments — those are
54
64
  // collapsed before VFS lookup. The bounded `..` pop won't escape root.
55
65
  const normalizePath = normalizeVfsPath;
56
- function ensureParent(p) {
57
- const parts = normalizePath(p).split('/');
58
- for (let i = 1; i < parts.length; i++) {
66
+ const top = worktree === null ? null : normalizePath(worktree);
67
+ const below = top ? `${top}/` : '';
68
+ const gitdir = `${below}.git`;
69
+ function lstatOrNull(p) {
70
+ try {
71
+ return vfs.lstat(p);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /** A path git's checkout owns: inside the worktree, not its top, not in its .git. */
78
+ function checkedOut(p) {
79
+ return top !== null && p.startsWith(below) && p !== gitdir && !p.startsWith(`${gitdir}/`);
80
+ }
81
+ /** `p`'s directories, down to `p` itself when `self`; see createGitFs for the worktree's rule. */
82
+ function ensureDirectories(p, self) {
83
+ const parts = p.split('/');
84
+ for (let i = 1; i <= (self ? parts.length : parts.length - 1); i++) {
59
85
  const dir = parts.slice(0, i).join('/');
60
- if (dir && !vfs.exists(dir))
61
- vfs.mkdir(dir, { recursive: true });
86
+ if (!dir)
87
+ continue;
88
+ if (!checkedOut(dir)) {
89
+ if (!vfs.exists(dir))
90
+ vfs.mkdir(dir, { recursive: true });
91
+ continue;
92
+ }
93
+ const st = lstatOrNull(dir);
94
+ if (st?.type === 'directory')
95
+ continue;
96
+ if (st)
97
+ vfs.unlink(dir);
98
+ vfs.mkdir(dir);
62
99
  }
63
100
  }
101
+ // The inode as Node's fs.Stats: git's stat cache compares ctime, ino, uid and gid too.
102
+ function statsOf(filepath, follow) {
103
+ const p = normalizePath(filepath);
104
+ let st;
105
+ if (!p) {
106
+ const now = Date.now();
107
+ st = { dev: 0, ino: 0, nlink: 1, type: 'directory', size: 0, atime: now, ctime: now, mtime: now, mode: 0o755, uid: 0, gid: 0 };
108
+ }
109
+ else {
110
+ try {
111
+ st = follow ? vfs.stat(p) : vfs.lstat(p);
112
+ }
113
+ catch {
114
+ const err = new Error(`ENOENT: no such file or directory, ${follow ? 'stat' : 'lstat'} '${filepath}'`);
115
+ err.code = 'ENOENT';
116
+ err.errno = -2;
117
+ throw err;
118
+ }
119
+ }
120
+ const isDir = st.type === 'directory';
121
+ const isLink = st.type === 'symlink';
122
+ return {
123
+ isFile: () => st.type === 'file',
124
+ isDirectory: () => isDir,
125
+ isSymbolicLink: () => isLink,
126
+ size: st.size,
127
+ mode: (isLink ? 0o120000 : isDir ? 0o040000 : 0o100000) | (st.mode & 0o7777),
128
+ mtimeMs: st.mtime, mtime: new Date(st.mtime),
129
+ ctimeMs: st.ctime, ctime: new Date(st.ctime),
130
+ atimeMs: st.atime, atime: new Date(st.atime),
131
+ uid: st.uid, gid: st.gid, dev: st.dev, ino: st.ino, nlink: st.nlink,
132
+ type: isDir ? 'dir' : isLink ? 'symlink' : 'file',
133
+ };
134
+ }
64
135
  return {
65
136
  promises: {
66
137
  async readFile(filepath, opts) {
@@ -81,7 +152,13 @@ function createGitFs(vfs) {
81
152
  },
82
153
  async writeFile(filepath, data, opts) {
83
154
  const p = normalizePath(filepath);
84
- ensureParent(p);
155
+ ensureDirectories(p, false);
156
+ // A file replaces a link or a directory at its own path (entry.c checkout_entry, remove_subtree).
157
+ const existing = checkedOut(p) ? lstatOrNull(p)?.type : undefined;
158
+ if (existing === 'symlink')
159
+ vfs.unlink(p);
160
+ else if (existing === 'directory')
161
+ vfs.removeRecursive(p);
85
162
  if (typeof data === 'string') {
86
163
  vfs.writeFile(p, data);
87
164
  }
@@ -91,7 +168,7 @@ function createGitFs(vfs) {
91
168
  },
92
169
  async unlink(filepath) {
93
170
  const p = normalizePath(filepath);
94
- if (vfs.exists(p))
171
+ if (lstatOrNull(p))
95
172
  vfs.unlink(p);
96
173
  },
97
174
  async readdir(filepath) {
@@ -103,73 +180,40 @@ function createGitFs(vfs) {
103
180
  return vfs.readdir(p).map(e => e.name);
104
181
  },
105
182
  async mkdir(filepath, opts) {
106
- const p = normalizePath(filepath);
107
- if (!vfs.exists(p))
108
- vfs.mkdir(p, { recursive: true });
183
+ ensureDirectories(normalizePath(filepath), true);
109
184
  },
110
185
  async rmdir(filepath) {
111
186
  const p = normalizePath(filepath);
112
- if (vfs.exists(p))
113
- vfs.rmdir(p);
187
+ const st = lstatOrNull(p);
188
+ if (!st)
189
+ return;
190
+ // rmdir(2) of a link is ENOTDIR: it never removes the directory the link names.
191
+ if (st.type !== 'directory') {
192
+ throw Object.assign(new Error(`ENOTDIR: not a directory, rmdir '${filepath}'`), { code: 'ENOTDIR', errno: -20 });
193
+ }
194
+ vfs.rmdir(p);
114
195
  },
115
196
  async stat(filepath) {
116
- const p = normalizePath(filepath);
117
- // Synthetic directory stat — used for root, '.', and known directories
118
- function dirStat() {
119
- const now = Date.now();
120
- const d = new Date(now);
121
- return {
122
- isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false,
123
- size: 0, mode: 0o755, type: 'dir',
124
- mtimeMs: now, mtime: d, ctimeMs: now, ctime: d, atimeMs: now, atime: d,
125
- uid: 1000, gid: 1000, dev: 0, ino: 0, nlink: 1,
126
- };
127
- }
128
- // Empty path (from '.', '/', etc.) = root directory
129
- if (!p)
130
- return dirStat();
131
- // Check if path is a known directory (even without VFS stat entry)
132
- if (vfs.exists(p) && vfs.isDirectory(p))
133
- return dirStat();
134
- let st;
135
- try {
136
- st = vfs.stat(p);
137
- }
138
- catch {
139
- const err = new Error(`ENOENT: no such file or directory, stat '${filepath}'`);
140
- err.code = 'ENOENT';
141
- err.errno = -2;
142
- throw err;
143
- }
144
- // isomorphic-git calls .valueOf() on mtime/ctime/atime — all must be Date objects
145
- const mtimeMs = st.mtime || Date.now();
146
- const mtime = new Date(mtimeMs);
147
- return {
148
- isFile: () => st.type === 'file',
149
- isDirectory: () => st.type === 'directory',
150
- isSymbolicLink: () => false,
151
- size: st.size,
152
- mode: st.mode || 0o644,
153
- mtimeMs,
154
- mtime,
155
- ctimeMs: mtimeMs,
156
- ctime: mtime,
157
- atimeMs: mtimeMs,
158
- atime: mtime,
159
- uid: 1000,
160
- gid: 1000,
161
- dev: 0,
162
- ino: 0,
163
- nlink: 1,
164
- type: st.type === 'directory' ? 'dir' : 'file',
165
- };
197
+ return statsOf(filepath, true);
166
198
  },
167
199
  async lstat(filepath) {
168
- return this.stat(filepath);
200
+ return statsOf(filepath, false);
169
201
  },
170
202
  async chmod() { },
171
- async symlink() { },
172
- async readlink(filepath) { return filepath; },
203
+ async symlink(target, filepath) {
204
+ const p = normalizePath(filepath);
205
+ ensureDirectories(p, false);
206
+ // Checkout retargets a link in place, as the clone facet's adapter does.
207
+ const st = lstatOrNull(p);
208
+ if (st?.type === 'directory' && checkedOut(p))
209
+ vfs.removeRecursive(p);
210
+ else if (st && st.type !== 'directory')
211
+ vfs.unlink(p);
212
+ vfs.symlink(target, p);
213
+ },
214
+ async readlink(filepath) {
215
+ return vfs.readlink(normalizePath(filepath));
216
+ },
173
217
  },
174
218
  };
175
219
  }
@@ -280,11 +324,1576 @@ export function parseCloneArgs(args) {
280
324
  quiet,
281
325
  };
282
326
  }
283
- function getAuthor(ctx) {
284
- return {
285
- name: ctx.env.GIT_AUTHOR_NAME || ctx.env.USER || 'user',
286
- email: ctx.env.GIT_AUTHOR_EMAIL || 'user@nimbus.dev',
327
+ /** fetch, pull and push: `-q`/`--quiet` wherever it appears; the other words keep their order. */
328
+ function takeQuiet(args) {
329
+ const rest = args.filter((arg) => arg !== '-q' && arg !== '--quiet');
330
+ return { quiet: rest.length !== args.length, rest };
331
+ }
332
+ /** commit's -m (repeatable), -q and -a, bundled as git allows (`-qm msg`, `-mmsg`); other options stay ignored. */
333
+ function parseCommitArgs(args) {
334
+ const messages = [];
335
+ let quiet = false;
336
+ let all = false;
337
+ let cleanup = null;
338
+ let allowEmptyMessage = false;
339
+ for (let i = 0; i < args.length; i++) {
340
+ const arg = args[i];
341
+ if (arg === '--')
342
+ break;
343
+ if (arg === '--quiet')
344
+ quiet = true;
345
+ else if (arg === '--all')
346
+ all = true;
347
+ else if (arg === '--allow-empty-message')
348
+ allowEmptyMessage = true;
349
+ else if (arg === '--cleanup')
350
+ cleanup = args[++i] ?? '';
351
+ else if (arg.startsWith('--cleanup='))
352
+ cleanup = arg.slice('--cleanup='.length);
353
+ else if (arg === '--message')
354
+ messages.push(args[++i] ?? '');
355
+ else if (arg.startsWith('--message='))
356
+ messages.push(arg.slice('--message='.length));
357
+ else if (/^-[^-]/.test(arg)) {
358
+ for (let j = 1; j < arg.length; j++) {
359
+ if (arg[j] === 'q')
360
+ quiet = true;
361
+ else if (arg[j] === 'a')
362
+ all = true;
363
+ else if (arg[j] === 'm') {
364
+ messages.push(j + 1 < arg.length ? arg.slice(j + 1) : args[++i] ?? '');
365
+ break;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ return { messages, quiet, all, cleanup, allowEmptyMessage };
371
+ }
372
+ /**
373
+ * The message `-m` options make (builtin/commit.c opt_parse_m): each value
374
+ * ends in a newline, and one after another is joined by a blank line; an
375
+ * empty value adds nothing.
376
+ */
377
+ function joinMessageOptions(values) {
378
+ let buf = '';
379
+ for (const value of values) {
380
+ if (buf.length)
381
+ buf += '\n';
382
+ buf += value;
383
+ if (buf.length && !buf.endsWith('\n'))
384
+ buf += '\n';
385
+ }
386
+ return buf;
387
+ }
388
+ /**
389
+ * The cleanup mode a commit message gets (builtin/commit.c): --cleanup, else
390
+ * commit.cleanup. With no editor, as for -m, `default` and `scissors` are
391
+ * `whitespace`. Null for a mode git does not know.
392
+ */
393
+ function commitCleanupMode(value) {
394
+ if (value === undefined || value === 'default' || value === 'scissors' || value === 'whitespace')
395
+ return 'whitespace';
396
+ if (value === 'strip' || value === 'verbatim')
397
+ return value;
398
+ return null;
399
+ }
400
+ function isNotFound(error) {
401
+ return error instanceof Error && 'code' in error && error.code === 'NotFoundError';
402
+ }
403
+ // ── Output ───────────────────────────────────────────────────────────────
404
+ /** Emit a binary string: its bytes verbatim where the sink keeps bytes, else as UTF-8 text. */
405
+ async function writeBinary(stream, bin) {
406
+ if (!bin)
407
+ return;
408
+ const bytes = bytesFromBinary(bin);
409
+ if (stream.writeBytes)
410
+ await stream.writeBytes(bytes);
411
+ else
412
+ await stream.write(dec.decode(bytes));
413
+ }
414
+ // ── Staging ──────────────────────────────────────────────────────────────
415
+ /**
416
+ * `commit -a`'s staging of tracked changes: the status refresh, the removals
417
+ * and the additions under one index write, as git's, and a path at a time
418
+ * (1,000 concurrent deflates reset the isolate).
419
+ */
420
+ async function stageTracked(git, fs, dir) {
421
+ const cache = {};
422
+ const added = [];
423
+ const removed = [];
424
+ for (const [filepath, head, workdir, stage] of await git.statusMatrix({ fs, dir, cache, deferRefresh: true })) {
425
+ if (stage === 0)
426
+ continue;
427
+ if (head === workdir && workdir === stage)
428
+ continue;
429
+ if (workdir === 0)
430
+ removed.push(filepath);
431
+ else
432
+ added.push(filepath);
433
+ }
434
+ await git.stage({ fs, dir, cache, add: added, remove: removed, parallel: false });
435
+ }
436
+ // ── Repository discovery ─────────────────────────────────────────────────
437
+ const NOT_A_REPOSITORY = 'fatal: not a git repository (or any of the parent directories): .git\n';
438
+ const NOT_A_WORK_TREE = 'fatal: this operation must be run in a work tree\n';
439
+ /** setup_git_directory's walk: from the cwd up, a `.git` inside each level, else the level itself as a git directory. */
440
+ function discoverRepo(vfs, cwd) {
441
+ const isGitDir = (key) => {
442
+ const sub = (name) => (key ? `${key}/${name}` : name);
443
+ return vfs.isFile(sub('HEAD')) && vfs.isDirectory(sub('objects')) && vfs.isDirectory(sub('refs'));
444
+ };
445
+ const segments = normalizeVfsPath(cwd).split('/').filter(Boolean);
446
+ for (let depth = segments.length; depth >= 0; depth--) {
447
+ const dir = segments.slice(0, depth).join('/');
448
+ const prefix = segments.slice(depth).join('/');
449
+ const dotGit = dir ? `${dir}/.git` : '.git';
450
+ if (isGitDir(dotGit))
451
+ return { gitdir: `/${dotGit}`, worktree: `/${dir}`, prefix };
452
+ if (isGitDir(dir))
453
+ return { gitdir: `/${dir}`, worktree: null, prefix };
454
+ }
455
+ return null;
456
+ }
457
+ function ambiguousArgument(arg) {
458
+ return `fatal: ambiguous argument '${arg}': unknown revision or path not in the working tree.\n`
459
+ + "Use '--' to separate paths from revisions, like this:\n"
460
+ + "'git <command> [<revision>...] -- [<file>...]'\n";
461
+ }
462
+ /** A revision as rev-parse reads one: a ref, or a full or uniquely abbreviated object name. */
463
+ async function resolveRevision(git, fs, gitdir, rev, cache) {
464
+ const name = rev === '@' ? 'HEAD' : rev;
465
+ if (/[~^:{}\\]|\.\.|^@/.test(name))
466
+ throw new Error(`unsupported revision syntax '${rev}'`);
467
+ try {
468
+ return await git.resolveRef({ fs, gitdir, ref: name });
469
+ }
470
+ catch (e) {
471
+ if (!isNotFound(e))
472
+ throw e;
473
+ }
474
+ if (!/^[0-9a-f]{4,39}$/.test(name))
475
+ return null;
476
+ try {
477
+ return await git.expandOid({ fs, gitdir, oid: name, cache });
478
+ }
479
+ catch (e) {
480
+ if (!isNotFound(e))
481
+ throw e;
482
+ return null;
483
+ }
484
+ }
485
+ /** --abbrev-ref: the short name of the ref a revision spells, null when it spells none. */
486
+ async function abbreviatedRef(git, fs, gitdir, rev) {
487
+ if (rev === 'HEAD' || rev === '@')
488
+ return (await git.currentBranch({ fs, gitdir })) ?? 'HEAD';
489
+ try {
490
+ const full = await git.expandRef({ fs, gitdir, ref: rev });
491
+ return full.replace(/^refs\/remotes\/(.+)\/HEAD$/, '$1').replace(/^refs\/(?:heads|tags|remotes)\//, '');
492
+ }
493
+ catch (e) {
494
+ if (!isNotFound(e))
495
+ throw e;
496
+ return null;
497
+ }
498
+ }
499
+ async function revParse(ctx, git, fs, vfs, args) {
500
+ const repo = discoverRepo(vfs, ctx.cwd);
501
+ if (!repo) {
502
+ await ctx.stderr.write(NOT_A_REPOSITORY);
503
+ return 128;
504
+ }
505
+ const cache = {};
506
+ let verify = false;
507
+ let quiet = false;
508
+ let abbrevRef = false;
509
+ let out = '';
510
+ const verified = [];
511
+ const show = async (rev, oid) => {
512
+ if (!abbrevRef)
513
+ return `${oid}\n`;
514
+ const name = await abbreviatedRef(git, fs, repo.gitdir, rev);
515
+ return name === null ? '' : `${name}\n`;
516
+ };
517
+ const fail = async (message, code) => {
518
+ if (out)
519
+ await ctx.stdout.write(out);
520
+ if (message)
521
+ await ctx.stderr.write(message);
522
+ return code;
287
523
  };
524
+ const noSingleRevision = () => (quiet ? fail('', 1) : fail('fatal: Needed a single revision\n', 128));
525
+ for (const arg of args) {
526
+ switch (arg) {
527
+ case '--verify':
528
+ verify = true;
529
+ continue;
530
+ case '-q':
531
+ case '--quiet':
532
+ quiet = true;
533
+ continue;
534
+ case '--abbrev-ref':
535
+ abbrevRef = true;
536
+ continue;
537
+ case '--show-toplevel':
538
+ if (!repo.worktree)
539
+ return fail(NOT_A_WORK_TREE, 128);
540
+ out += `${repo.worktree}\n`;
541
+ continue;
542
+ case '--git-dir':
543
+ // rev-parse names the git directory relative to the cwd only from the top.
544
+ out += `${repo.prefix ? repo.gitdir : repo.worktree ? '.git' : '.'}\n`;
545
+ continue;
546
+ case '--is-inside-work-tree':
547
+ out += `${repo.worktree ? 'true' : 'false'}\n`;
548
+ continue;
549
+ }
550
+ if (arg.startsWith('-'))
551
+ return fail(`fatal: rev-parse: unsupported option '${arg}'\n`, 129);
552
+ const oid = await resolveRevision(git, fs, repo.gitdir, arg, cache);
553
+ if (oid === null) {
554
+ if (verify)
555
+ return noSingleRevision();
556
+ // A non-revision is echoed as a path, which must then exist.
557
+ out += `${arg}\n`;
558
+ if (vfs.exists(normalizeVfsPath(arg.startsWith('/') ? arg : `${ctx.cwd}/${arg}`)))
559
+ continue;
560
+ return fail(ambiguousArgument(arg), 128);
561
+ }
562
+ if (verify)
563
+ verified.push({ rev: arg, oid });
564
+ else
565
+ out += await show(arg, oid);
566
+ }
567
+ if (verify) {
568
+ if (verified.length !== 1)
569
+ return noSingleRevision();
570
+ out += await show(verified[0].rev, verified[0].oid);
571
+ }
572
+ await ctx.stdout.write(out);
573
+ return 0;
574
+ }
575
+ // ── Worktree inspection (ls-files, diff) ─────────────────────────────────
576
+ /** Whether git trusts the worktree's exec bit here: core.filemode, true unless set false. */
577
+ async function trustsFileMode(git, fs, dir) {
578
+ return (await git.getConfig({ fs, dir, path: 'core.filemode' })) !== false;
579
+ }
580
+ /** ce_mode_from_stat: without a trusted exec bit a file keeps its index mode, or 100644 when new. */
581
+ function worktreeMode(mode, indexMode, filemode) {
582
+ if (filemode || mode === 0o120000)
583
+ return mode;
584
+ return indexMode !== undefined && indexMode !== 0o120000 ? indexMode : 0o100644;
585
+ }
586
+ /** git's index and tree order: the UTF-8 bytes, which is code point order rather than UTF-16's. */
587
+ function comparePaths(a, b) {
588
+ for (let i = 0; i < a.length && i < b.length; i++) {
589
+ let x = a.charCodeAt(i);
590
+ let y = b.charCodeAt(i);
591
+ if (x === y)
592
+ continue;
593
+ // Surrogates (astral code points) sort above the rest of the BMP.
594
+ if (x >= 0xd800)
595
+ x = x >= 0xe000 ? x - 0x800 : x + 0x2000;
596
+ if (y >= 0xd800)
597
+ y = y >= 0xe000 ? y - 0x800 : y + 0x2000;
598
+ return x - y;
599
+ }
600
+ return a.length - b.length;
601
+ }
602
+ /** A repo-relative path as seen from `prefix`, climbing with '../' where it must. */
603
+ function relativeTo(path, prefix) {
604
+ if (!prefix)
605
+ return path;
606
+ if (path.startsWith(`${prefix}/`))
607
+ return path.slice(prefix.length + 1);
608
+ const from = prefix.split('/');
609
+ const to = path.split('/');
610
+ let shared = 0;
611
+ while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared])
612
+ shared++;
613
+ return '../'.repeat(from.length - shared) + to.slice(shared).join('/');
614
+ }
615
+ /** Literal pathspecs, relative to the cwd, as repo-relative paths; '' is the whole tree. */
616
+ function repoPaths(args, cwd, worktree) {
617
+ const root = normalizeVfsPath(worktree);
618
+ return args.map((arg) => {
619
+ if (/[*?[]/.test(arg))
620
+ throw new Error(`pathspec '${arg}': globs are not supported, name the paths`);
621
+ const key = normalizeVfsPath(arg.startsWith('/') ? arg : `${cwd}/${arg}`);
622
+ if (!root || key === root)
623
+ return root ? '' : key;
624
+ if (key.startsWith(`${root}/`))
625
+ return key.slice(root.length + 1);
626
+ throw new Error(`${arg}: '${arg}' is outside repository at '${worktree}'`);
627
+ });
628
+ }
629
+ /** cf-git's walk one entry at a time, not every sibling at once; `visit` answers whether to descend. */
630
+ async function walkScoped(git, fs, dir, cache, trees, specs, visit) {
631
+ await git.walk({
632
+ fs, dir, cache, trees,
633
+ map: async (path, entries) => {
634
+ if (path === '.')
635
+ return true;
636
+ const inScope = specs.length === 0
637
+ || specs.some((spec) => spec === '' || path === spec || path.startsWith(`${spec}/`));
638
+ // Outside the pathspecs a directory is entered only on the way to one.
639
+ if (!inScope)
640
+ return specs.some((spec) => spec.startsWith(`${path}/`)) ? true : null;
641
+ return (await visit(path, entries)) ? true : null;
642
+ },
643
+ reduce: async () => undefined,
644
+ iterate: async (walk, children) => {
645
+ for (const child of children)
646
+ await walk(child);
647
+ return [];
648
+ },
649
+ });
650
+ }
651
+ const ADD_USAGE = 'usage: git add [-n | --dry-run] [-v | --verbose] [-f | --force] [-A | --all | --no-all] '
652
+ + '[-u | --update] [--] <pathspec>...\n';
653
+ /** git add's options this git does not do: they are git's, so they are refused as unsupported, not unknown. */
654
+ const ADD_UNSUPPORTED = new Set(['i', 'p', 'e', 'N', 'U', '--interactive', '--patch', '--edit', '--intent-to-add',
655
+ '--unified', '--inter-hunk-context', '--renormalize', '--refresh', '--ignore-errors', '--ignore-missing', '--sparse',
656
+ '--chmod', '--pathspec-from-file', '--pathspec-file-nul']);
657
+ /**
658
+ * `git add`: what git 2.x stages for its pathspecs. A pathspec takes the
659
+ * changes, deletions and new files below it (as -A does); -u only tracked
660
+ * paths, --no-all no deletions, -A or -u with no pathspec the whole tree.
661
+ * -n prints what would be staged (`add 'p'`, `remove 'p'`, tracked paths
662
+ * first, then new ones) and stages nothing; -v prints it and stages. A
663
+ * pathspec that matches nothing fails before anything is staged; one that
664
+ * names an ignored path is reported (exit 1) unless -f, the rest still added.
665
+ */
666
+ async function addCommand(ctx, git, fs, vfs, args) {
667
+ let dryRun = false;
668
+ let verbose = false;
669
+ let force = false;
670
+ let all = null;
671
+ let update = false;
672
+ let dashdash = false;
673
+ const pathArgs = [];
674
+ for (const arg of args) {
675
+ if (dashdash || arg === '-' || !arg.startsWith('-')) {
676
+ pathArgs.push(arg);
677
+ continue;
678
+ }
679
+ if (arg === '--') {
680
+ dashdash = true;
681
+ continue;
682
+ }
683
+ const flags = arg.startsWith('--') ? [arg.split('=')[0]] : [...arg.slice(1)];
684
+ for (const flag of flags) {
685
+ switch (flag) {
686
+ case 'n':
687
+ case '--dry-run':
688
+ dryRun = true;
689
+ break;
690
+ case 'v':
691
+ case '--verbose':
692
+ verbose = true;
693
+ break;
694
+ case 'f':
695
+ case '--force':
696
+ force = true;
697
+ break;
698
+ case 'A':
699
+ case '--all':
700
+ case '--no-ignore-removal':
701
+ all = true;
702
+ break;
703
+ case '--no-all':
704
+ case '--ignore-removal':
705
+ all = false;
706
+ break;
707
+ case 'u':
708
+ case '--update':
709
+ update = true;
710
+ break;
711
+ default:
712
+ if (ADD_UNSUPPORTED.has(flag)) {
713
+ await ctx.stderr.write(`fatal: git add ${flag.length === 1 ? `-${flag}` : flag} is not supported here\n`);
714
+ return 128;
715
+ }
716
+ await ctx.stderr.write(flag.length === 1
717
+ ? `error: unknown switch \`${flag}'\n${ADD_USAGE}`
718
+ : `error: unknown option \`${flag.slice(2)}'\n${ADD_USAGE}`);
719
+ return 129;
720
+ }
721
+ }
722
+ }
723
+ const repo = discoverRepo(vfs, ctx.cwd);
724
+ if (!repo) {
725
+ await ctx.stderr.write(NOT_A_REPOSITORY);
726
+ return 128;
727
+ }
728
+ const root = repo.worktree;
729
+ if (!root) {
730
+ await ctx.stderr.write(NOT_A_WORK_TREE);
731
+ return 128;
732
+ }
733
+ if (pathArgs.length === 0 && all !== true && !update) {
734
+ await ctx.stderr.write("Nothing specified, nothing added.\nhint: Maybe you wanted to say 'git add .'?\n"
735
+ + 'hint: Disable this message with "git config set advice.addEmptyPathspec false"\n');
736
+ return 0;
737
+ }
738
+ const specs = pathArgs.length ? repoPaths(pathArgs, ctx.cwd, root) : [''];
739
+ const inSpec = (path, spec) => spec === '' || path === spec || path.startsWith(`${spec}/`);
740
+ const cache = {};
741
+ // Its stat refreshes ride in the one index write that stages (git add writes once).
742
+ const matrix = await git.statusMatrix({ fs, dir: root, cache, deferRefresh: !dryRun });
743
+ // -f takes the ignored files below each pathspec as well.
744
+ const forced = force
745
+ ? await git.statusMatrix({ fs, dir: root, cache, ignored: true, filepaths: specs.map((spec) => spec || '.') })
746
+ : [];
747
+ const rows = new Map();
748
+ for (const [path, head, workdir, stage] of [...matrix, ...forced]) {
749
+ // -u updates what the index holds and nothing else: a path it does not
750
+ // hold (never added, or removed with rm --cached) is not git's to update.
751
+ if (update && stage === 0)
752
+ continue;
753
+ if (specs.some((spec) => inSpec(path, spec)))
754
+ rows.set(path, [head, workdir, stage]);
755
+ }
756
+ const ignored = [];
757
+ for (const [i, spec] of specs.entries()) {
758
+ if (spec === '' || [...rows.keys()].some((path) => inSpec(path, spec)))
759
+ continue;
760
+ if (update) {
761
+ // "Known to git" is the index; git names the first pathspec it holds nothing under.
762
+ await ctx.stderr.write(`error: pathspec '${pathArgs[i]}' did not match any file(s) known to git\n`);
763
+ return 128;
764
+ }
765
+ let st = null;
766
+ try {
767
+ st = vfs.lstat(normalizeVfsPath(`${root}/${spec}`));
768
+ }
769
+ catch { /* absent */ }
770
+ if (!st) {
771
+ await ctx.stderr.write(`fatal: pathspec '${pathArgs[i]}' did not match any files\n`);
772
+ return 128;
773
+ }
774
+ // Named but not listed: ignored (reported at the path the ignore rule names), or an empty directory.
775
+ const parts = spec.split('/');
776
+ for (let depth = 1; depth <= parts.length; depth++) {
777
+ const prefix = parts.slice(0, depth).join('/');
778
+ if (await git.isIgnored({ fs, dir: root, filepath: prefix })) {
779
+ if (!ignored.includes(prefix))
780
+ ignored.push(prefix);
781
+ break;
782
+ }
783
+ }
784
+ }
785
+ const tracked = [];
786
+ const untracked = [];
787
+ for (const [path, [head, workdir, stage]] of [...rows].sort(([a], [b]) => comparePaths(a, b))) {
788
+ if (head === 0 && stage === 0) {
789
+ if (workdir !== 0 && !update)
790
+ untracked.push(path);
791
+ continue;
792
+ }
793
+ if (workdir === 0) {
794
+ if (stage !== 0 && all !== false)
795
+ tracked.push([path, 'remove']);
796
+ }
797
+ else if (stage !== workdir) {
798
+ tracked.push([path, 'add']);
799
+ }
800
+ }
801
+ if (ignored.length) {
802
+ await ctx.stderr.write(`The following paths are ignored by one of your .gitignore files:\n${ignored.map((p) => `${p}\n`).join('')}`
803
+ + 'hint: Use -f if you really want to add them.\n'
804
+ + 'hint: Disable this message with "git config set advice.addIgnoredFile false"\n');
805
+ }
806
+ let out = '';
807
+ for (const [path, action] of tracked)
808
+ out += `${action} '${path}'\n`;
809
+ for (const path of untracked)
810
+ out += `add '${path}'\n`;
811
+ if (dryRun || verbose)
812
+ await writeBinary(ctx.stdout, out);
813
+ if (!dryRun) {
814
+ const removed = tracked.filter(([, action]) => action === 'remove').map(([path]) => path);
815
+ const added = [...tracked.filter(([, action]) => action === 'add').map(([path]) => path), ...untracked];
816
+ await git.stage({ fs, dir: root, cache, add: added, remove: removed, parallel: false, force: true });
817
+ }
818
+ return ignored.length ? 1 : 0;
819
+ }
820
+ const TAG_USAGE = 'usage: git tag [-a] [-f] [-m <msg> | -F <file>] <tagname> [<commit>]\n'
821
+ + ' or: git tag -d <tagname>...\n'
822
+ + ' or: git tag [-n[<num>]] [-l] [<pattern>...]\n';
823
+ /** git's `whitespace` cleanup of a message given with -m or -F: no trailing blanks, no runs or edges of empty lines. */
824
+ /** A `±hhmm` timezone for an offset in minutes east of UTC. */
825
+ function gitTimezone(minutesEast) {
826
+ const sign = minutesEast < 0 ? '-' : '+';
827
+ const abs = Math.abs(minutesEast);
828
+ return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}${String(abs % 60).padStart(2, '0')}`;
829
+ }
830
+ // git's date.c tables, in its order: a name matches from its third letter on.
831
+ const GIT_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
832
+ const GIT_WEEKDAYS = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
833
+ /** [name, hours east, daylight]: git adds the daylight hour ("This is bogus, but we like summer"). */
834
+ const GIT_ZONES = [
835
+ ['IDLW', -12, 0], ['NT', -11, 0], ['CAT', -10, 0], ['HST', -10, 0], ['HDT', -10, 1], ['YST', -9, 0], ['YDT', -9, 1],
836
+ ['PST', -8, 0], ['PDT', -8, 1], ['MST', -7, 0], ['MDT', -7, 1], ['CST', -6, 0], ['CDT', -6, 1], ['EST', -5, 0],
837
+ ['EDT', -5, 1], ['AST', -3, 0], ['ADT', -3, 1], ['WAT', -1, 0], ['GMT', 0, 0], ['UTC', 0, 0], ['Z', 0, 0],
838
+ ['WET', 0, 0], ['BST', 0, 1], ['CET', 1, 0], ['MET', 1, 0], ['MEWT', 1, 0], ['MEST', 1, 1], ['CEST', 1, 1],
839
+ ['MESZ', 1, 1], ['FWT', 1, 0], ['FST', 1, 1], ['EET', 2, 0], ['EEST', 2, 1], ['WAST', 7, 0], ['WADT', 7, 1],
840
+ ['CCT', 8, 0], ['JST', 9, 0], ['EAST', 10, 0], ['EADT', 10, 1], ['GST', 10, 0], ['NZT', 12, 0], ['NZST', 12, 0],
841
+ ['NZDT', 12, 1], ['IDLE', 12, 0],
842
+ ];
843
+ const isDigit = (c) => c !== undefined && c >= '0' && c <= '9';
844
+ const isAlpha = (c) => c !== undefined && /[A-Za-z]/.test(c);
845
+ const isAlnum = (c) => isDigit(c) || isAlpha(c);
846
+ /**
847
+ * A GIT_COMMITTER_DATE as git's date.c parse_date_basic() reads it, ported
848
+ * routine for routine (match_object_header_date, match_alpha, match_digit,
849
+ * match_multi_number, set_date, match_tz, tm_to_time_t), so every form git
850
+ * accepts is accepted and read the same way: `@<secs> ±hhmm` (git's own raw
851
+ * form), a bare epoch of 9 or more digits, ISO 8601, RFC 2822 (`date -R`),
852
+ * git's default `Thu Jan 2 03:04:05 2020 -0800`, dotted and slashed dates,
853
+ * zone names, am/pm. Without a zone, the local one at that time, as mktime()
854
+ * gives it. Null where git says "invalid date format": no time of day, a
855
+ * year outside 1970-2099, a date it cannot place.
856
+ */
857
+ function parseGitDate(text, nowSeconds = Math.floor(Date.now() / 1000)) {
858
+ // match_object_header_date: "@<digits> ±hhmm", exactly.
859
+ const header = /^@(\d+) ([+-])(\d{4})(?:\n|$)/.exec(text);
860
+ if (header) {
861
+ const hhmm = Number(header[3]);
862
+ const minutes = (header[2] === '-' ? -1 : 1) * (Math.floor(hhmm / 100) * 60 + (hhmm % 100));
863
+ return { seconds: Number(header[1]), zone: gitTimezone(minutes) };
864
+ }
865
+ const tm = { year: -1, mon: -1, mday: -1, hour: -1, min: -1, sec: -1, wday: -1 };
866
+ let offset = null;
867
+ let gmt = false;
868
+ // tm_to_time_t: 1970-2099 only, and every field of the time of day given.
869
+ const toTime = (t) => {
870
+ const days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
871
+ const year = t.year - 70;
872
+ let day = t.mday;
873
+ if (year < 0 || year > 129 || t.mon < 0 || t.mon > 11)
874
+ return null;
875
+ if (t.mon < 2 || (year + 2) % 4)
876
+ day--;
877
+ if (t.hour < 0 || t.min < 0 || t.sec < 0)
878
+ return null;
879
+ return (year * 365 + Math.floor((year + 1) / 4) + days[t.mon] + day) * 86_400 + t.hour * 3600 + t.min * 60 + t.sec;
880
+ };
881
+ const noDate = () => tm.year < 0 && tm.mon < 0 && tm.mday < 0 && tm.hour < 0 && tm.min < 0 && tm.sec < 0;
882
+ const matchString = (at, word) => {
883
+ let i = 0;
884
+ for (; at + i < text.length; i++) {
885
+ const c = text[at + i];
886
+ if (c === word[i] || c.toUpperCase() === (word[i] ?? '').toUpperCase())
887
+ continue;
888
+ if (!isAlnum(c))
889
+ break;
890
+ return 0;
891
+ }
892
+ return i;
893
+ };
894
+ // set_date: month and day in range, the year placed as git places it, and
895
+ // (for a guessed order) no more than ten days into the future.
896
+ const setDate = (year, month, day, refuseFuture) => {
897
+ if (!(month > 0 && month < 13 && day > 0 && day < 32))
898
+ return false;
899
+ const r = { ...tm, mon: month - 1, mday: day };
900
+ if (year === -1) {
901
+ if (!refuseFuture)
902
+ return false;
903
+ r.year = new Date(nowSeconds * 1000).getUTCFullYear() - 1900;
904
+ }
905
+ else if (year >= 1970 && year < 2100)
906
+ r.year = year - 1900;
907
+ else if (year > 70 && year < 100)
908
+ r.year = year;
909
+ else if (year < 38)
910
+ r.year = year + 100;
911
+ else
912
+ return false;
913
+ if (refuseFuture) {
914
+ const specified = toTime({ ...r, hour: 0, min: 0, sec: 0 });
915
+ if (specified !== null && nowSeconds + 10 * 86_400 < specified)
916
+ return false;
917
+ }
918
+ tm.mon = r.mon;
919
+ tm.mday = r.mday;
920
+ if (year !== -1 || !refuseFuture)
921
+ tm.year = r.year;
922
+ return true;
923
+ };
924
+ const readNumber = (at) => {
925
+ let end = at;
926
+ while (isDigit(text[end]))
927
+ end++;
928
+ return [Number(text.slice(at, end)), end];
929
+ };
930
+ const matchMulti = (num, sep, at, end) => {
931
+ let num3 = -1;
932
+ let [num2, next] = readNumber(end + 1);
933
+ if (text[next] === sep && isDigit(text[next + 1]))
934
+ [num3, next] = readNumber(next + 1);
935
+ if (sep === ':') {
936
+ if (num3 < 0)
937
+ num3 = 0;
938
+ if (num < 25 && num2 >= 0 && num2 < 60 && num3 >= 0 && num3 <= 60) {
939
+ tm.hour = num;
940
+ tm.min = num2;
941
+ tm.sec = num3;
942
+ return next - at;
943
+ }
944
+ return 0;
945
+ }
946
+ if (num > 70 && (setDate(num, num2, num3, false) || setDate(num, num3, num2, false)))
947
+ return next - at;
948
+ if (sep !== '.' && setDate(num3, num, num2, true))
949
+ return next - at;
950
+ if (setDate(num3, num2, num, true))
951
+ return next - at;
952
+ if (sep === '.' && setDate(num3, num, num2, true))
953
+ return next - at;
954
+ return 0;
955
+ };
956
+ const matchDigit = (at) => {
957
+ const [num, end] = readNumber(at);
958
+ // Seconds since 1970: a number of 9 digits or more, before any date.
959
+ if (num >= 100_000_000 && noDate()) {
960
+ const d = new Date(num * 1000);
961
+ Object.assign(tm, { year: d.getUTCFullYear() - 1900, mon: d.getUTCMonth(), mday: d.getUTCDate(), hour: d.getUTCHours(), min: d.getUTCMinutes(), sec: d.getUTCSeconds() });
962
+ gmt = true;
963
+ return end - at;
964
+ }
965
+ if ((text[end] === ':' || text[end] === '.' || text[end] === '/' || text[end] === '-') && isDigit(text[end + 1])) {
966
+ const matched = matchMulti(num, text[end], at, end);
967
+ if (matched)
968
+ return matched;
969
+ }
970
+ const n = end - at;
971
+ if (n === 8 || n === 6) {
972
+ const [a, b, c] = [Math.floor(num / 10000), Math.floor((num % 10000) / 100), num % 100];
973
+ if (n === 8)
974
+ setDate(a, b, c, false);
975
+ else if (a < 25 && b < 60 && c <= 60) {
976
+ tm.hour = a;
977
+ tm.min = b;
978
+ tm.sec = c;
979
+ if (text[end] === '.' && isDigit(text[end + 1]))
980
+ return readNumber(end + 1)[1] - at;
981
+ }
982
+ return n;
983
+ }
984
+ if (n === 4) {
985
+ if (num <= 1400 && offset === null)
986
+ offset = Math.floor(num / 100) * 60 + (num % 100);
987
+ else if (num > 1900 && num < 2100)
988
+ tm.year = num - 1900;
989
+ return n;
990
+ }
991
+ if (n > 2)
992
+ return n;
993
+ if (num > 0 && num < 32 && tm.mday < 0) {
994
+ tm.mday = num;
995
+ return n;
996
+ }
997
+ if (n === 2 && tm.year < 0) {
998
+ if (num < 10 && tm.mday >= 0) {
999
+ tm.year = num + 100;
1000
+ return n;
1001
+ }
1002
+ if (num >= 70) {
1003
+ tm.year = num;
1004
+ return n;
1005
+ }
1006
+ }
1007
+ if (num > 0 && num < 13 && tm.mon < 0)
1008
+ tm.mon = num - 1;
1009
+ return n;
1010
+ };
1011
+ const matchAlpha = (at) => {
1012
+ for (let i = 0; i < 12; i++) {
1013
+ const m = matchString(at, GIT_MONTHS[i]);
1014
+ if (m >= 3) {
1015
+ tm.mon = i;
1016
+ return m;
1017
+ }
1018
+ }
1019
+ for (let i = 0; i < 7; i++) {
1020
+ const m = matchString(at, GIT_WEEKDAYS[i]);
1021
+ if (m >= 3) {
1022
+ tm.wday = i;
1023
+ return m;
1024
+ }
1025
+ }
1026
+ for (const [name, hours, dst] of GIT_ZONES) {
1027
+ const m = matchString(at, name);
1028
+ if (m >= 3 || m === name.length) {
1029
+ if (offset === null)
1030
+ offset = 60 * (hours + dst);
1031
+ return m;
1032
+ }
1033
+ }
1034
+ if (matchString(at, 'PM') === 2) {
1035
+ tm.hour = (tm.hour % 12) + 12;
1036
+ return 2;
1037
+ }
1038
+ if (matchString(at, 'AM') === 2) {
1039
+ tm.hour = tm.hour % 12;
1040
+ return 2;
1041
+ }
1042
+ // ISO 8601's 'T' before a time.
1043
+ if (text[at] === 'T' && isDigit(text[at + 1]) && tm.hour === -1) {
1044
+ tm.min = 0;
1045
+ tm.sec = 0;
1046
+ return 1;
1047
+ }
1048
+ let skip = 0;
1049
+ while (isAlpha(text[at + skip]))
1050
+ skip++;
1051
+ return skip;
1052
+ };
1053
+ const matchTz = (at) => {
1054
+ let [hour, end] = readNumber(at + 1);
1055
+ const n = end - (at + 1);
1056
+ let min = 0;
1057
+ if (n === 4) {
1058
+ min = hour % 100;
1059
+ hour = Math.floor(hour / 100);
1060
+ }
1061
+ else if (n !== 2)
1062
+ min = 99;
1063
+ else if (text[end] === ':') {
1064
+ [min, end] = readNumber(end + 1);
1065
+ if (end - (at + 1) !== 5)
1066
+ min = 99;
1067
+ }
1068
+ if (min < 60 && hour < 24)
1069
+ offset = (text[at] === '-' ? -1 : 1) * (hour * 60 + min);
1070
+ return end - at;
1071
+ };
1072
+ for (let at = 0; at < text.length && text[at] !== '\n';) {
1073
+ const c = text[at];
1074
+ let matched = 0;
1075
+ if (isAlpha(c))
1076
+ matched = matchAlpha(at);
1077
+ else if (isDigit(c))
1078
+ matched = matchDigit(at);
1079
+ else if ((c === '-' || c === '+') && isDigit(text[at + 1]))
1080
+ matched = matchTz(at);
1081
+ at += matched || 1;
1082
+ }
1083
+ let seconds = toTime(tm);
1084
+ if (seconds === null)
1085
+ return null;
1086
+ let minutesEast;
1087
+ if (offset === null) {
1088
+ // No zone given: the local one at that time, as mktime() reads the same fields.
1089
+ const local = new Date(tm.year + 1900, tm.mon, tm.mday, tm.hour, tm.min, tm.sec).getTime() / 1000;
1090
+ minutesEast = Math.round((seconds - local) / 60);
1091
+ }
1092
+ else
1093
+ minutesEast = offset;
1094
+ if (!gmt)
1095
+ seconds -= minutesEast * 60;
1096
+ return { seconds, zone: gitTimezone(minutesEast) };
1097
+ }
1098
+ /**
1099
+ * The author or committer identity git stamps an object with (ident.c):
1100
+ * GIT_<ROLE>_NAME and GIT_<ROLE>_EMAIL, else user.name and user.email from
1101
+ * the config, then the login name; and GIT_<ROLE>_DATE read by
1102
+ * parseGitDate, or now. A date git cannot read is its error.
1103
+ */
1104
+ async function gitIdent(ctx, git, fs, dir, role) {
1105
+ const config = async (key) => {
1106
+ try {
1107
+ const value = await git.getConfig({ fs, dir, path: key });
1108
+ return typeof value === 'string' ? value : undefined;
1109
+ }
1110
+ catch {
1111
+ return undefined;
1112
+ }
1113
+ };
1114
+ const name = ctx.env[`GIT_${role}_NAME`] || await config('user.name') || ctx.env.USER || 'user';
1115
+ const email = ctx.env[`GIT_${role}_EMAIL`] || await config('user.email') || 'user@nimbus.dev';
1116
+ const raw = ctx.env[`GIT_${role}_DATE`];
1117
+ let seconds;
1118
+ let minutesEast;
1119
+ if (raw) {
1120
+ const parsed = parseGitDate(raw);
1121
+ if (!parsed)
1122
+ return { error: `fatal: invalid date format: ${raw}\n` };
1123
+ seconds = parsed.seconds;
1124
+ const zone = /^([+-])(\d{2})(\d{2})$/.exec(parsed.zone);
1125
+ minutesEast = (zone[1] === '-' ? -1 : 1) * (Number(zone[2]) * 60 + Number(zone[3]));
1126
+ }
1127
+ else {
1128
+ seconds = Math.floor(Date.now() / 1000);
1129
+ minutesEast = -new Date(seconds * 1000).getTimezoneOffset();
1130
+ }
1131
+ // Not -0: cf-git writes a negative zero offset as -0000, which is git's "zone unknown".
1132
+ return { name, email, timestamp: seconds, timezoneOffset: minutesEast === 0 ? 0 : -minutesEast };
1133
+ }
1134
+ /** An identity as a signature line: `Name <email> <seconds> ±hhmm`. */
1135
+ function identLine(ident) {
1136
+ return `${ident.name} <${ident.email}> ${ident.timestamp} ${gitTimezone(-ident.timezoneOffset)}`;
1137
+ }
1138
+ /** Both identities a commit takes, or git's error for a date it cannot read. */
1139
+ async function commitIdents(ctx, git, fs, dir) {
1140
+ const author = await gitIdent(ctx, git, fs, dir, 'AUTHOR');
1141
+ if ('error' in author)
1142
+ return author;
1143
+ const committer = await gitIdent(ctx, git, fs, dir, 'COMMITTER');
1144
+ if ('error' in committer)
1145
+ return committer;
1146
+ return { author, committer };
1147
+ }
1148
+ /**
1149
+ * git's stripspace (strbuf_stripspace): trailing whitespace, runs of blank
1150
+ * lines and blank lines at either end go, and with `stripComments` (the
1151
+ * `strip` cleanup, the default for a tag message) lines starting with the
1152
+ * comment character too. A non-empty result ends with a newline.
1153
+ */
1154
+ function cleanupMessage(text, stripComments = true) {
1155
+ const lines = text.split('\n').filter((line) => !stripComments || !line.startsWith('#')).map((line) => line.replace(/\s+$/, ''));
1156
+ const out = [];
1157
+ for (const line of lines) {
1158
+ if (line === '' && (out.length === 0 || out[out.length - 1] === ''))
1159
+ continue;
1160
+ out.push(line);
1161
+ }
1162
+ while (out.length > 0 && out[out.length - 1] === '')
1163
+ out.pop();
1164
+ return out.length ? `${out.join('\n')}\n` : '';
1165
+ }
1166
+ /**
1167
+ * Commit the index with exactly `message`, git's cleanup already applied:
1168
+ * one object, written byte for byte (the tracked cf-git patch's rawMessage;
1169
+ * cf-git's own rendering would normalize the newlines of a verbatim message
1170
+ * and refuse an empty one), and the branch, or a detached HEAD, moved by
1171
+ * cf-git.
1172
+ */
1173
+ async function writeCommit(git, fs, dir, message, idents) {
1174
+ return git.commit({ fs, dir, message, ...idents, rawMessage: true });
1175
+ }
1176
+ /** A tag pattern (fnmatch: `*`, `?`, `[...]`) as a whole-name regular expression. */
1177
+ function tagPattern(pattern) {
1178
+ let source = '';
1179
+ for (const ch of pattern) {
1180
+ source += ch === '*' ? '.*' : ch === '?' ? '.' : ch === '[' || ch === ']' ? ch : ch.replace(/[.*+?^${}()|\\/]/g, '\\$&');
1181
+ }
1182
+ return new RegExp(`^${source}$`);
1183
+ }
1184
+ /**
1185
+ * `git tag`: list (-l, patterns, -n<num> with each tag's message or its
1186
+ * commit's subject), create (lightweight, or annotated with -a, -m or -F,
1187
+ * a tag object as git writes it), replace (-f) and delete (-d), with git's
1188
+ * messages and exit codes.
1189
+ */
1190
+ async function tagCommand(ctx, git, fs, vfs, args) {
1191
+ let annotate = false;
1192
+ let force = false;
1193
+ let del = false;
1194
+ let list = false;
1195
+ let lines = null;
1196
+ const messages = [];
1197
+ let messageFile = null;
1198
+ const operands = [];
1199
+ for (let i = 0; i < args.length; i++) {
1200
+ const arg = args[i];
1201
+ if (arg === '--') {
1202
+ operands.push(...args.slice(i + 1));
1203
+ break;
1204
+ }
1205
+ if (arg === '-' || !arg.startsWith('-')) {
1206
+ operands.push(arg);
1207
+ continue;
1208
+ }
1209
+ if (arg === '--annotate') {
1210
+ annotate = true;
1211
+ continue;
1212
+ }
1213
+ if (arg === '--force') {
1214
+ force = true;
1215
+ continue;
1216
+ }
1217
+ if (arg === '--delete') {
1218
+ del = true;
1219
+ continue;
1220
+ }
1221
+ if (arg === '--list') {
1222
+ list = true;
1223
+ continue;
1224
+ }
1225
+ if (arg.startsWith('--message=')) {
1226
+ messages.push(arg.slice('--message='.length));
1227
+ continue;
1228
+ }
1229
+ if (arg === '--message') {
1230
+ messages.push(args[++i] ?? '');
1231
+ continue;
1232
+ }
1233
+ if (arg.startsWith('--file=')) {
1234
+ messageFile = arg.slice('--file='.length);
1235
+ continue;
1236
+ }
1237
+ if (arg === '--file') {
1238
+ messageFile = args[++i] ?? '';
1239
+ continue;
1240
+ }
1241
+ if (arg.startsWith('--')) {
1242
+ await ctx.stderr.write(`error: unknown option \`${arg.slice(2)}'\n${TAG_USAGE}`);
1243
+ return 129;
1244
+ }
1245
+ for (let j = 1; j < arg.length; j++) {
1246
+ const flag = arg[j];
1247
+ if (flag === 'a')
1248
+ annotate = true;
1249
+ else if (flag === 'f')
1250
+ force = true;
1251
+ else if (flag === 'd')
1252
+ del = true;
1253
+ else if (flag === 'l')
1254
+ list = true;
1255
+ else if (flag === 'n') {
1256
+ const digits = /^\d*/.exec(arg.slice(j + 1))?.[0] ?? '';
1257
+ lines = digits ? Number(digits) : 1;
1258
+ j += digits.length;
1259
+ }
1260
+ else if (flag === 'm' || flag === 'F') {
1261
+ const value = arg.slice(j + 1) || args[++i];
1262
+ if (value === undefined) {
1263
+ await ctx.stderr.write(`error: switch \`${flag}' requires a value\n${TAG_USAGE}`);
1264
+ return 129;
1265
+ }
1266
+ if (flag === 'm')
1267
+ messages.push(value);
1268
+ else
1269
+ messageFile = value;
1270
+ break;
1271
+ }
1272
+ else {
1273
+ await ctx.stderr.write(`error: unknown switch \`${flag}'\n${TAG_USAGE}`);
1274
+ return 129;
1275
+ }
1276
+ }
1277
+ }
1278
+ const repo = discoverRepo(vfs, ctx.cwd);
1279
+ if (!repo) {
1280
+ await ctx.stderr.write(NOT_A_REPOSITORY);
1281
+ return 128;
1282
+ }
1283
+ const { gitdir } = repo;
1284
+ const dir = repo.worktree ?? ctx.cwd;
1285
+ const cache = {};
1286
+ const abbrev = (oid) => oid.slice(0, 7);
1287
+ if (del) {
1288
+ let code = 0;
1289
+ for (const name of operands) {
1290
+ let oid;
1291
+ try {
1292
+ oid = await git.resolveRef({ fs, gitdir, ref: `refs/tags/${name}` });
1293
+ }
1294
+ catch {
1295
+ await ctx.stderr.write(`error: tag '${name}' not found.\n`);
1296
+ code = 1;
1297
+ continue;
1298
+ }
1299
+ await git.deleteTag({ fs, dir, ref: name });
1300
+ await ctx.stdout.write(`Deleted tag '${name}' (was ${abbrev(oid)})\n`);
1301
+ }
1302
+ return code;
1303
+ }
1304
+ const creating = !list && lines === null && operands.length > 0;
1305
+ if (!creating) {
1306
+ const patterns = operands.map(tagPattern);
1307
+ const names = (await git.listTags({ fs, dir }))
1308
+ .filter((name) => patterns.length === 0 || patterns.some((pattern) => pattern.test(name)))
1309
+ .sort(comparePaths);
1310
+ let out = '';
1311
+ for (const name of names) {
1312
+ if (lines === null) {
1313
+ out += `${name}\n`;
1314
+ continue;
1315
+ }
1316
+ // The tag's own message, or for a lightweight tag its commit's (a tree or blob has none).
1317
+ let text = '';
1318
+ try {
1319
+ const oid = await git.resolveRef({ fs, gitdir, ref: `refs/tags/${name}` });
1320
+ // The raw object, split at its first blank line as git splits it: cf-git's
1321
+ // parsed form normalizes newlines first and misreads an empty message.
1322
+ const { type, object } = await git.readObject({ fs, dir, oid, cache, format: 'content' });
1323
+ if (type === 'tag' || type === 'commit') {
1324
+ const raw = dec.decode(object);
1325
+ const blank = raw.indexOf('\n\n');
1326
+ text = blank < 0 ? '' : raw.slice(blank + 2).split('\n-----BEGIN PGP SIGNATURE-----')[0];
1327
+ }
1328
+ }
1329
+ catch { /* a broken ref lists bare */ }
1330
+ // The first <num> lines as they are, blank ones too, continuation lines indented.
1331
+ const shown = text.replace(/\n+$/, '').split('\n').slice(0, lines).map((line, i) => (i === 0 ? line : ` ${line}`));
1332
+ out += shown.length && lines > 0 ? `${name.padEnd(15)} ${shown.join('\n')}\n` : `${name}\n`;
1333
+ }
1334
+ await writeBinary(ctx.stdout, out);
1335
+ return 0;
1336
+ }
1337
+ const [name, target = 'HEAD', ...extra] = operands;
1338
+ if (extra.length > 0) {
1339
+ await ctx.stderr.write(`error: too many arguments\n${TAG_USAGE}`);
1340
+ return 129;
1341
+ }
1342
+ if (messageFile !== null) {
1343
+ try {
1344
+ messages.push(dec.decode(vfs.readFile(normalizeVfsPath(messageFile.startsWith('/') ? messageFile : `${ctx.cwd}/${messageFile}`))));
1345
+ }
1346
+ catch {
1347
+ await ctx.stderr.write(`fatal: could not open or read '${messageFile}': No such file or directory\n`);
1348
+ return 128;
1349
+ }
1350
+ }
1351
+ if (annotate && messages.length === 0) {
1352
+ // git would open an editor, and this git has none: what git says with no editor to run.
1353
+ await ctx.stderr.write('error: Terminal is dumb, but EDITOR unset\nPlease supply the message using either -m or -F option.\n');
1354
+ return 1;
1355
+ }
1356
+ const object = await resolveRevision(git, fs, gitdir, target, cache);
1357
+ if (!object) {
1358
+ await ctx.stderr.write(`fatal: Failed to resolve '${target}' as a valid ref.\n`);
1359
+ return 128;
1360
+ }
1361
+ let previous = null;
1362
+ try {
1363
+ previous = await git.resolveRef({ fs, gitdir, ref: `refs/tags/${name}` });
1364
+ }
1365
+ catch { /* new */ }
1366
+ if (previous !== null && !force) {
1367
+ await ctx.stderr.write(`fatal: tag '${name}' already exists\n`);
1368
+ return 128;
1369
+ }
1370
+ let value;
1371
+ if (messages.length > 0) {
1372
+ const ident = await gitIdent(ctx, git, fs, dir, 'COMMITTER');
1373
+ if ('error' in ident) {
1374
+ await ctx.stderr.write(ident.error);
1375
+ return 128;
1376
+ }
1377
+ const tagger = identLine(ident);
1378
+ // The object as git writes it, byte for byte: the headers, a blank line,
1379
+ // then the cleaned message (nothing at all when it is empty). cf-git's
1380
+ // annotatedTag appends a newline after the message, which an empty one
1381
+ // must not have, and its parser then misreads the listing.
1382
+ const { type } = await git.readObject({ fs, dir, oid: object, cache: {}, format: 'parsed' });
1383
+ const body = `object ${object}\ntype ${type}\ntag ${name}\ntagger ${tagger}\n\n${cleanupMessage(messages.join('\n\n'))}`;
1384
+ value = await git.writeObject({ fs, dir, type: 'tag', object: new TextEncoder().encode(body), format: 'content' });
1385
+ await git.writeRef({ fs, dir, ref: `refs/tags/${name}`, value, force: true });
1386
+ }
1387
+ else {
1388
+ await git.tag({ fs, dir, ref: name, object, force: true });
1389
+ value = object;
1390
+ }
1391
+ if (previous !== null && previous !== value)
1392
+ await ctx.stdout.write(`Updated tag '${name}' (was ${abbrev(previous)})\n`);
1393
+ return 0;
1394
+ }
1395
+ const LS_FILES_USAGE = 'usage: git ls-files [-c | --cached] [-o | --others] [-m | --modified] [-d | --deleted] '
1396
+ + '[--exclude-standard] [-z] [--] [<path>...]\n';
1397
+ async function lsFiles(ctx, git, fs, vfs, args) {
1398
+ let cached = false;
1399
+ let others = false;
1400
+ let modified = false;
1401
+ let deleted = false;
1402
+ let excludeStandard = false;
1403
+ let z = false;
1404
+ let dashdash = false;
1405
+ const pathArgs = [];
1406
+ for (const arg of args) {
1407
+ if (dashdash || arg === '-' || !arg.startsWith('-')) {
1408
+ pathArgs.push(arg);
1409
+ continue;
1410
+ }
1411
+ if (arg === '--') {
1412
+ dashdash = true;
1413
+ continue;
1414
+ }
1415
+ for (const flag of arg.startsWith('--') ? [arg] : [...arg.slice(1)].map((c) => `-${c}`)) {
1416
+ switch (flag) {
1417
+ case '-c':
1418
+ case '--cached':
1419
+ cached = true;
1420
+ break;
1421
+ case '-o':
1422
+ case '--others':
1423
+ others = true;
1424
+ break;
1425
+ case '-m':
1426
+ case '--modified':
1427
+ modified = true;
1428
+ break;
1429
+ case '-d':
1430
+ case '--deleted':
1431
+ deleted = true;
1432
+ break;
1433
+ case '-z':
1434
+ z = true;
1435
+ break;
1436
+ case '--exclude-standard':
1437
+ excludeStandard = true;
1438
+ break;
1439
+ default:
1440
+ await ctx.stderr.write(`error: unknown option '${flag.replace(/^-+/, '')}'\n${LS_FILES_USAGE}`);
1441
+ return 129;
1442
+ }
1443
+ }
1444
+ }
1445
+ // With no selection ls-files shows the index.
1446
+ if (!others && !modified && !deleted)
1447
+ cached = true;
1448
+ const repo = discoverRepo(vfs, ctx.cwd);
1449
+ if (!repo) {
1450
+ await ctx.stderr.write(NOT_A_REPOSITORY);
1451
+ return 128;
1452
+ }
1453
+ const root = repo.worktree;
1454
+ if (!root) {
1455
+ await ctx.stderr.write(NOT_A_WORK_TREE);
1456
+ return 128;
1457
+ }
1458
+ // Without pathspecs ls-files covers the cwd's subtree.
1459
+ const specs = pathArgs.length ? repoPaths(pathArgs, ctx.cwd, root) : [repo.prefix];
1460
+ const readWorktree = others || modified || deleted;
1461
+ const untracked = [];
1462
+ const tracked = [];
1463
+ const trees = readWorktree ? [git.STAGE(), git.WORKDIR()] : [git.STAGE()];
1464
+ const filemode = modified && await trustsFileMode(git, fs, root);
1465
+ await walkScoped(git, fs, root, {}, trees, specs, async (path, [stage, work]) => {
1466
+ const workType = work ? await work.type() : undefined;
1467
+ if (stage) {
1468
+ const stageType = await stage.type();
1469
+ if (stageType === 'tree') {
1470
+ // A file or link where the index has a directory is untracked; the directory's files are deleted.
1471
+ if (others && workType && workType !== 'tree'
1472
+ && !(excludeStandard && await git.isIgnored({ fs, dir: root, filepath: path })))
1473
+ untracked.push(path);
1474
+ return true;
1475
+ }
1476
+ const missing = readWorktree && !workType;
1477
+ let changed = missing;
1478
+ if (modified && !missing && stageType === 'blob') {
1479
+ const stageMode = await stage.mode();
1480
+ changed = !work || workType !== 'blob' || await work.oid() !== await stage.oid()
1481
+ || worktreeMode(await work.mode(), stageMode, filemode) !== stageMode;
1482
+ }
1483
+ tracked.push({ path, deleted: missing, modified: changed });
1484
+ // A file replaced by a directory leaves that directory untracked.
1485
+ return others && stageType === 'blob' && workType === 'tree';
1486
+ }
1487
+ if (!others || !workType)
1488
+ return false;
1489
+ const isDir = workType === 'tree';
1490
+ if (excludeStandard && await git.isIgnored({ fs, dir: root, filepath: isDir ? `${path}/` : path }))
1491
+ return false;
1492
+ if (!isDir) {
1493
+ untracked.push(path);
1494
+ return false;
1495
+ }
1496
+ // A nested repository is listed as the directory, never entered.
1497
+ if (!vfs.exists(normalizeVfsPath(`${root}/${path}/.git`)))
1498
+ return true;
1499
+ untracked.push(`${path}/`);
1500
+ return false;
1501
+ });
1502
+ let out = '';
1503
+ for (const path of untracked.sort(comparePaths))
1504
+ out += pathLine(relativeTo(path, repo.prefix), z);
1505
+ for (const entry of tracked.sort((a, b) => comparePaths(a.path, b.path))) {
1506
+ const line = pathLine(relativeTo(entry.path, repo.prefix), z);
1507
+ if (cached)
1508
+ out += line;
1509
+ if (deleted && entry.deleted)
1510
+ out += line;
1511
+ if (modified && entry.modified)
1512
+ out += line;
1513
+ }
1514
+ await writeBinary(ctx.stdout, out);
1515
+ return 0;
1516
+ }
1517
+ /**
1518
+ * The index entries that restoring `restored` replaces (add_index_entry_with_check):
1519
+ * a file at one of a restored path's leading directories, or anything below a
1520
+ * restored path.
1521
+ */
1522
+ export function replacedIndexEntries(index, restored) {
1523
+ // Every leading directory of a restored path, once: an entry there is a file in its way.
1524
+ const leading = new Set();
1525
+ for (const path of restored) {
1526
+ for (let at = path.indexOf('/'); at >= 0; at = path.indexOf('/', at + 1))
1527
+ leading.add(path.slice(0, at));
1528
+ }
1529
+ return index.filter((path) => {
1530
+ if (restored.has(path))
1531
+ return false;
1532
+ if (leading.has(path))
1533
+ return true;
1534
+ for (let at = path.indexOf('/'); at >= 0; at = path.indexOf('/', at + 1)) {
1535
+ if (restored.has(path.slice(0, at)))
1536
+ return true;
1537
+ }
1538
+ return false;
1539
+ });
1540
+ }
1541
+ /**
1542
+ * A checkout cf-git refused, as git reports one: its message on stderr, exit 1. A merge
1543
+ * that is not a fast-forward (`strategy`) refuses as its strategy does, with that line
1544
+ * after it and exit 2. Anything else propagates.
1545
+ */
1546
+ async function refusal(ctx, error, strategy) {
1547
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'CheckoutConflictError')
1548
+ throw error;
1549
+ await ctx.stderr.write(`${error.message}\n${strategy ? `Merge with strategy ${strategy} failed.\n` : ''}`);
1550
+ return strategy ? 2 : 1;
1551
+ }
1552
+ /**
1553
+ * `git checkout [<tree-ish>] -- <pathspec>...`: every tracked file the
1554
+ * pathspecs name, from the index, or from <tree-ish> into the index as well
1555
+ * (overlay mode: a path the tree lacks stays). A pathspec naming nothing fails
1556
+ * the command before any file is written, as in git. The files are written as
1557
+ * git's checkout writes them: see createGitFs's worktree rule.
1558
+ */
1559
+ async function checkoutPaths(ctx, git, vfs, source, pathArgs) {
1560
+ const repo = discoverRepo(vfs, ctx.cwd);
1561
+ if (!repo) {
1562
+ await ctx.stderr.write(NOT_A_REPOSITORY);
1563
+ return 128;
1564
+ }
1565
+ const root = repo.worktree;
1566
+ if (!root) {
1567
+ await ctx.stderr.write(NOT_A_WORK_TREE);
1568
+ return 128;
1569
+ }
1570
+ const specs = repoPaths(pathArgs, ctx.cwd, root);
1571
+ // A pathspec ending in '/' names a directory: it matches what is below it, never a file or link there.
1572
+ const dirOnly = pathArgs.map((arg, i) => arg.endsWith('/') && specs[i] !== '');
1573
+ const fs = createGitFs(vfs, root);
1574
+ const cache = {};
1575
+ let tree = git.STAGE();
1576
+ if (source !== null) {
1577
+ const oid = await resolveRevision(git, fs, repo.gitdir, source, cache);
1578
+ if (!oid) {
1579
+ await ctx.stderr.write(`fatal: invalid reference: ${source}\n`);
1580
+ return 128;
1581
+ }
1582
+ tree = git.TREE({ ref: oid });
1583
+ }
1584
+ const files = [];
1585
+ const matched = new Set();
1586
+ await walkScoped(git, fs, root, cache, [tree], specs, async (path, [entry]) => {
1587
+ const type = entry ? await entry.type() : undefined;
1588
+ if (type === 'tree')
1589
+ return true;
1590
+ // A gitlink is never checked out.
1591
+ if (!entry || type !== 'blob')
1592
+ return false;
1593
+ const matching = specs.flatMap((spec, i) => spec === '' || path.startsWith(`${spec}/`) || (path === spec && !dirOnly[i]) ? [i] : []);
1594
+ if (matching.length === 0)
1595
+ return false;
1596
+ for (const i of matching)
1597
+ matched.add(i);
1598
+ files.push({ path, oid: await entry.oid(), mode: await entry.mode() });
1599
+ return false;
1600
+ });
1601
+ let unmatched = '';
1602
+ pathArgs.forEach((arg, i) => {
1603
+ if (!matched.has(i))
1604
+ unmatched += `error: pathspec '${arg}' did not match any file(s) known to git\n`;
1605
+ });
1606
+ if (unmatched) {
1607
+ await ctx.stderr.write(unmatched);
1608
+ return 1;
1609
+ }
1610
+ for (const { path, oid, mode } of files) {
1611
+ const file = `${root}/${path}`;
1612
+ const { blob } = await git.readBlob({ fs, dir: root, oid, cache });
1613
+ if (mode === 0o120000) {
1614
+ await fs.promises.symlink(dec.decode(blob), file);
1615
+ }
1616
+ else {
1617
+ await fs.promises.writeFile(file, blob);
1618
+ vfs.chmod(normalizeVfsPath(file), mode === 0o100755 ? 0o755 : 0o644);
1619
+ }
1620
+ }
1621
+ // The index takes each file's fresh stat data (and, from a tree, its blob). An entry a
1622
+ // restored path replaces goes first, as add_index_entry_with_check replaces it: a file at
1623
+ // one of its leading directories, or anything below it.
1624
+ const restored = new Set(files.map(({ path }) => path));
1625
+ const replaced = replacedIndexEntries(await git.listFiles({ fs, dir: root, cache }), restored);
1626
+ if (replaced.length)
1627
+ await git.remove({ fs, dir: root, filepath: replaced, cache });
1628
+ if (files.length)
1629
+ await git.add({ fs, dir: root, filepath: [...restored], parallel: false, force: true, cache });
1630
+ return 0;
1631
+ }
1632
+ /** diff-files, diff-index and diff-index --cached, as file pairs in path order. */
1633
+ async function changedPairs(git, fs, dir, cache, base, specs) {
1634
+ const pairs = [];
1635
+ const filemode = await trustsFileMode(git, fs, dir);
1636
+ // A worktree side is read against its index entry's mode.
1637
+ const blob = async (path, entry, index) => {
1638
+ if (!entry || (await entry.type()) !== 'blob')
1639
+ return null;
1640
+ const mode = await entry.mode();
1641
+ return index
1642
+ ? { path, oid: await entry.oid(), mode: worktreeMode(mode, await index.mode(), filemode), worktree: true }
1643
+ : { path, oid: await entry.oid(), mode, worktree: false };
1644
+ };
1645
+ const record = (one, two) => {
1646
+ if (!one && !two)
1647
+ return;
1648
+ if (one && two && one.oid === two.oid && one.mode === two.mode)
1649
+ return;
1650
+ pairs.push({ one, two });
1651
+ };
1652
+ const trees = base.kind === 'index'
1653
+ ? [git.STAGE(), git.WORKDIR()]
1654
+ : base.cached ? [git.TREE({ ref: base.ref }), git.STAGE()] : [git.TREE({ ref: base.ref }), git.STAGE(), git.WORKDIR()];
1655
+ await walkScoped(git, fs, dir, cache, trees, specs, async (path, entries) => {
1656
+ if (base.kind === 'index') {
1657
+ const [stage, work] = entries;
1658
+ const stageType = stage ? await stage.type() : undefined;
1659
+ if (stageType !== 'blob')
1660
+ return stageType === 'tree';
1661
+ // A missing file, or a directory where the file was, is a deletion.
1662
+ record(await blob(path, stage), await blob(path, work, stage));
1663
+ return false;
1664
+ }
1665
+ const [head, stage, work] = entries;
1666
+ const headType = head ? await head.type() : undefined;
1667
+ const stageType = stage ? await stage.type() : undefined;
1668
+ // diff-index: a path the index lacks is deleted whatever the worktree holds.
1669
+ const two = stageType !== 'blob' ? null : base.cached ? await blob(path, stage) : await blob(path, work, stage);
1670
+ record(await blob(path, head), two);
1671
+ return headType === 'tree' || stageType === 'tree';
1672
+ });
1673
+ const pathOf = (pair) => (pair.one ?? pair.two).path;
1674
+ return pairs.sort((a, b) => comparePaths(pathOf(a), pathOf(b)));
1675
+ }
1676
+ /** Print pairs one at a time, each loaded only while it is rendered. */
1677
+ async function writeDiff(ctx, pairs, output) {
1678
+ const stats = [];
1679
+ let out = '';
1680
+ for (const load of pairs) {
1681
+ const pair = await load();
1682
+ if (output.format === 'stat')
1683
+ stats.push(statFile(pair));
1684
+ else if (output.format === 'name-only')
1685
+ out += formatNameOnly(pair, output.z);
1686
+ else if (output.format === 'name-status')
1687
+ out += formatNameStatus(pair, output.z);
1688
+ else
1689
+ out += formatPatch(pair, output.context);
1690
+ if (out.length >= 1 << 16) {
1691
+ await writeBinary(ctx.stdout, out);
1692
+ out = '';
1693
+ }
1694
+ }
1695
+ if (output.format === 'stat')
1696
+ out += formatStat(stats, output.columns);
1697
+ await writeBinary(ctx.stdout, out);
1698
+ }
1699
+ /** `git diff --no-index`: two paths, either of them /dev/null; exits 1 when they differ. */
1700
+ async function diffNoIndex(ctx, git, vfs, paths, output) {
1701
+ if (paths.length !== 2) {
1702
+ await ctx.stderr.write('usage: git diff --no-index [<options>] <path> <path>\n');
1703
+ return 129;
1704
+ }
1705
+ const key = (path) => normalizeVfsPath(path.startsWith('/') ? path : `${ctx.cwd}/${path}`);
1706
+ const isDir = paths.map((path) => path !== '/dev/null' && vfs.isDirectory(key(path)));
1707
+ if (isDir[0] && isDir[1]) {
1708
+ await ctx.stderr.write('error: --no-index between two directories is not supported\n');
1709
+ return 129;
1710
+ }
1711
+ // fixup_paths: a directory against a file means that file's namesake inside it.
1712
+ if (isDir[0] !== isDir[1]) {
1713
+ const dirSide = isDir[0] ? 0 : 1;
1714
+ const file = paths[1 - dirSide];
1715
+ paths[dirSide] = `${paths[dirSide].replace(/\/+$/, '')}/${file.slice(file.lastIndexOf('/') + 1)}`;
1716
+ }
1717
+ const specs = [];
1718
+ for (const path of paths) {
1719
+ if (path === '/dev/null') {
1720
+ specs.push(absentSpec(path));
1721
+ continue;
1722
+ }
1723
+ let st;
1724
+ try {
1725
+ st = vfs.lstat(key(path));
1726
+ }
1727
+ catch {
1728
+ await ctx.stderr.write(`error: Could not access '${path}'\n`);
1729
+ return 1;
1730
+ }
1731
+ const link = st.type === 'symlink';
1732
+ const data = link ? enc.encode(vfs.readlink(key(path))) : vfs.readFile(key(path));
1733
+ const { oid } = await git.hashBlob({ object: data });
1734
+ // canon_mode: the owner's execute bit alone decides 100755.
1735
+ const mode = link ? 0o120000 : st.mode & 0o100 ? 0o100755 : 0o100644;
1736
+ specs.push({ path, valid: true, oid, mode, data });
1737
+ }
1738
+ const [one, two] = specs;
1739
+ if (!one.valid && !two.valid)
1740
+ return 0;
1741
+ if (one.valid && two.valid && one.oid === two.oid && one.mode === two.mode)
1742
+ return 0;
1743
+ await writeDiff(ctx, [async () => ({ one, two })], output);
1744
+ return 1;
1745
+ }
1746
+ const DIFF_USAGE = 'usage: git diff [--cached] [<commit>] [--] [<path>...]\n'
1747
+ + ' or: git diff --no-index [--] <path> <path>\n'
1748
+ + 'options: --stat | --name-only | --name-status, -z, -U<n>, -M[<n>] | --no-renames\n';
1749
+ async function diffCommand(ctx, git, fs, vfs, args) {
1750
+ let cached = false;
1751
+ let noIndex = false;
1752
+ let dashdash = false;
1753
+ // Renames are on by default, as with git's diff.renames; null turns them off.
1754
+ let minimumScore = DEFAULT_RENAME_SCORE;
1755
+ const output = {
1756
+ format: 'patch',
1757
+ z: false,
1758
+ context: DEFAULT_CONTEXT,
1759
+ columns: parseInt(ctx.env.COLUMNS ?? '', 10) > 0 ? parseInt(ctx.env.COLUMNS, 10) : 80,
1760
+ };
1761
+ const positionals = [];
1762
+ const pathArgs = [];
1763
+ for (const arg of args) {
1764
+ if (dashdash) {
1765
+ pathArgs.push(arg);
1766
+ continue;
1767
+ }
1768
+ if (arg === '--') {
1769
+ dashdash = true;
1770
+ continue;
1771
+ }
1772
+ if (arg === '-' || !arg.startsWith('-')) {
1773
+ positionals.push(arg);
1774
+ continue;
1775
+ }
1776
+ const context = /^(?:-U|--unified=)(\d+)$/.exec(arg);
1777
+ if (context) {
1778
+ output.context = Number(context[1]);
1779
+ continue;
1780
+ }
1781
+ const findRenames = /^(?:-M|--find-renames(?:=|$))(.*)$/.exec(arg);
1782
+ if (findRenames) {
1783
+ const score = parseRenameScore(findRenames[1]);
1784
+ if (score === null) {
1785
+ await ctx.stderr.write(`error: invalid argument to find-renames\n${DIFF_USAGE}`);
1786
+ return 129;
1787
+ }
1788
+ minimumScore = score || DEFAULT_RENAME_SCORE;
1789
+ continue;
1790
+ }
1791
+ switch (arg) {
1792
+ case '--cached':
1793
+ case '--staged':
1794
+ cached = true;
1795
+ continue;
1796
+ case '--no-index':
1797
+ noIndex = true;
1798
+ continue;
1799
+ case '-z':
1800
+ output.z = true;
1801
+ continue;
1802
+ case '--no-renames':
1803
+ minimumScore = null;
1804
+ continue;
1805
+ // A patch is the default, and nothing here colors or runs external tools.
1806
+ case '-p':
1807
+ case '-u':
1808
+ case '--patch':
1809
+ case '--no-ext-diff':
1810
+ case '--no-color': continue;
1811
+ case '--stat':
1812
+ case '--name-only':
1813
+ case '--name-status': {
1814
+ const format = arg.slice(2);
1815
+ if (output.format !== 'patch' && output.format !== format) {
1816
+ await ctx.stderr.write(`fatal: options '--${output.format}' and '${arg}' cannot be used together\n`);
1817
+ return 128;
1818
+ }
1819
+ output.format = format;
1820
+ continue;
1821
+ }
1822
+ }
1823
+ await ctx.stderr.write(`error: unknown option '${arg.replace(/^-+/, '')}'\n${DIFF_USAGE}`);
1824
+ return 129;
1825
+ }
1826
+ if (noIndex) {
1827
+ if (cached) {
1828
+ await ctx.stderr.write("fatal: options '--cached' and '--no-index' cannot be used together\n");
1829
+ return 128;
1830
+ }
1831
+ return diffNoIndex(ctx, git, vfs, [...positionals, ...pathArgs], output);
1832
+ }
1833
+ const repo = discoverRepo(vfs, ctx.cwd);
1834
+ if (!repo) {
1835
+ await ctx.stderr.write(NOT_A_REPOSITORY);
1836
+ return 128;
1837
+ }
1838
+ const root = repo.worktree;
1839
+ if (!root) {
1840
+ await ctx.stderr.write(NOT_A_WORK_TREE);
1841
+ return 128;
1842
+ }
1843
+ const cache = {};
1844
+ const revs = [];
1845
+ for (const arg of positionals) {
1846
+ // Before `--` a word is a revision until one is not; then it and the rest must be paths.
1847
+ const oid = pathArgs.length && !dashdash ? null : await resolveRevision(git, fs, repo.gitdir, arg, cache);
1848
+ if (oid !== null) {
1849
+ revs.push(oid);
1850
+ continue;
1851
+ }
1852
+ if (dashdash) {
1853
+ await ctx.stderr.write(`fatal: bad revision '${arg}'\n`);
1854
+ return 128;
1855
+ }
1856
+ if (!vfs.exists(normalizeVfsPath(arg.startsWith('/') ? arg : `${ctx.cwd}/${arg}`))) {
1857
+ await ctx.stderr.write(ambiguousArgument(arg));
1858
+ return 128;
1859
+ }
1860
+ pathArgs.push(arg);
1861
+ }
1862
+ if (revs.length > 1) {
1863
+ await ctx.stderr.write('fatal: diff between two commits is not supported; compare one commit with the worktree or the index\n');
1864
+ return 128;
1865
+ }
1866
+ const base = cached
1867
+ ? { kind: 'tree', ref: revs[0] ?? 'HEAD', cached: true }
1868
+ : revs.length ? { kind: 'tree', ref: revs[0], cached: false } : { kind: 'index' };
1869
+ const pending = await changedPairs(git, fs, root, cache, base, repoPaths(pathArgs, ctx.cwd, root));
1870
+ const read = async (side) => {
1871
+ if (!side.worktree)
1872
+ return (await git.readBlob({ fs, dir: root, oid: side.oid, cache })).blob;
1873
+ const key = normalizeVfsPath(`${root}/${side.path}`);
1874
+ return side.mode === 0o120000 ? enc.encode(vfs.readlink(key)) : vfs.readFile(key);
1875
+ };
1876
+ const { queue, neededRenameLimit } = minimumScore === null
1877
+ ? { queue: pending, neededRenameLimit: 0 }
1878
+ : await detectRenames(pending, read, { minimumScore });
1879
+ const withData = output.format === 'patch' || output.format === 'stat';
1880
+ const spec = async (side) => ({
1881
+ path: side.path,
1882
+ valid: true,
1883
+ oid: side.oid,
1884
+ mode: side.mode,
1885
+ data: withData ? await read(side) : new Uint8Array(0),
1886
+ });
1887
+ await writeDiff(ctx, queue.map(({ one, two, renameScore }) => async () => ({
1888
+ one: one ? await spec(one) : absentSpec(two.path),
1889
+ two: two ? await spec(two) : absentSpec(one.path),
1890
+ renameScore,
1891
+ })), output);
1892
+ if (neededRenameLimit) {
1893
+ await ctx.stderr.write('warning: exhaustive rename detection was skipped due to too many files.\n'
1894
+ + `warning: you may want to set your diff.renameLimit variable to at least ${neededRenameLimit} and retry the command.\n`);
1895
+ }
1896
+ return 0;
288
1897
  }
289
1898
  // ── Git subcommand implementations ──────────────────────────────────────
290
1899
  /**
@@ -316,8 +1925,8 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
316
1925
  ctx.stdout.write('usage: git <command> [<args>]\n\n');
317
1926
  ctx.stdout.write('Commands:\n');
318
1927
  ctx.stdout.write(' init, clone, status, add, commit, log, branch,\n');
319
- ctx.stdout.write(' checkout, diff, remote, fetch, pull, push, merge,\n');
320
- ctx.stdout.write(' reset, tag, config, --version\n');
1928
+ ctx.stdout.write(' checkout, diff, ls-files, rev-parse, remote,\n');
1929
+ ctx.stdout.write(' fetch, pull, push, merge, reset, tag, config, --version\n');
321
1930
  return 0;
322
1931
  }
323
1932
  // Lazy-load isomorphic-git only when actually needed.
@@ -345,7 +1954,9 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
345
1954
  credentialedVfs.mkdir(stripped, { recursive: true });
346
1955
  }
347
1956
  await git.init({ fs, dir: initDir });
348
- ctx.stdout.write(`Initialized empty Git repository in ${initDir}/.git/\n`);
1957
+ if (!subArgs.includes('-q') && !subArgs.includes('--quiet')) {
1958
+ ctx.stdout.write(`Initialized empty Git repository in ${initDir}/.git/\n`);
1959
+ }
349
1960
  return 0;
350
1961
  }
351
1962
  case 'clone': {
@@ -457,41 +2068,47 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
457
2068
  ctx.stdout.write('nothing to commit, working tree clean\n');
458
2069
  return 0;
459
2070
  }
460
- case 'add': {
461
- const paths = subArgs.filter(a => !a.startsWith('-'));
462
- if (paths.length === 0 || paths.includes('.')) {
463
- // Add all
464
- const matrix = await git.statusMatrix({ fs, dir });
465
- for (const [filepath, head, workdir, stage] of matrix) {
466
- if (head !== workdir || workdir !== stage) {
467
- if (workdir === 0)
468
- await git.remove({ fs, dir, filepath });
469
- else
470
- await git.add({ fs, dir, filepath });
471
- }
2071
+ case 'add':
2072
+ return await addCommand(ctx, git, fs, credentialedVfs, subArgs);
2073
+ case 'commit': {
2074
+ const { messages, quiet, all, cleanup, allowEmptyMessage } = parseCommitArgs(subArgs);
2075
+ let configured;
2076
+ if (cleanup === null) {
2077
+ try {
2078
+ const value = await git.getConfig({ fs, dir, path: 'commit.cleanup' });
2079
+ if (typeof value === 'string')
2080
+ configured = value;
472
2081
  }
2082
+ catch { /* unset */ }
473
2083
  }
474
- else {
475
- for (const filepath of paths) {
476
- await git.add({ fs, dir, filepath });
477
- }
2084
+ const requested = cleanup ?? configured;
2085
+ const mode = commitCleanupMode(requested);
2086
+ if (!mode) {
2087
+ await ctx.stderr.write(`fatal: Invalid cleanup mode ${requested}\n`);
2088
+ return 128;
478
2089
  }
479
- return 0;
480
- }
481
- case 'commit': {
482
- const msgIdx = subArgs.indexOf('-m');
483
- const message = msgIdx >= 0 ? subArgs[msgIdx + 1] : 'commit';
484
- if (!message) {
485
- ctx.stderr.write('error: empty commit message\n');
2090
+ const raw = messages.length ? joinMessageOptions(messages) : 'commit\n';
2091
+ const message = mode === 'verbatim' ? raw : cleanupMessage(raw, mode === 'strip');
2092
+ if (!message && !allowEmptyMessage) {
2093
+ await ctx.stderr.write('Aborting commit due to empty commit message.\n');
486
2094
  return 1;
487
2095
  }
488
- const sha = await git.commit({
489
- fs, dir, message,
490
- author: getAuthor(ctx),
491
- });
492
- ctx.stdout.write(`[${sha.slice(0, 7)}] ${message}\n`);
2096
+ if (all)
2097
+ await stageTracked(git, fs, dir);
2098
+ const idents = await commitIdents(ctx, git, fs, dir);
2099
+ if ('error' in idents) {
2100
+ await ctx.stderr.write(idents.error);
2101
+ return 128;
2102
+ }
2103
+ const sha = await writeCommit(git, fs, dir, message, idents);
2104
+ if (!quiet)
2105
+ ctx.stdout.write(`[${sha.slice(0, 7)}] ${message.split('\n')[0]}\n`);
493
2106
  return 0;
494
2107
  }
2108
+ case 'rev-parse':
2109
+ return await revParse(ctx, git, fs, credentialedVfs, subArgs);
2110
+ case 'ls-files':
2111
+ return await lsFiles(ctx, git, fs, credentialedVfs, subArgs);
495
2112
  case 'log': {
496
2113
  const maxCount = parseInt(getFlag(subArgs, '-n') || getFlag(subArgs, '--max-count') || '10');
497
2114
  const oneline = subArgs.includes('--oneline');
@@ -552,47 +2169,38 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
552
2169
  return 0;
553
2170
  }
554
2171
  case 'checkout': {
555
- const ref = subArgs.find(a => !a.startsWith('-'));
2172
+ // `--` with paths after it restores those paths; a bare `--` only ends the options.
2173
+ const dashdash = subArgs.indexOf('--');
2174
+ const options = dashdash >= 0 ? subArgs.slice(0, dashdash) : subArgs;
2175
+ if (dashdash >= 0 && dashdash < subArgs.length - 1) {
2176
+ const source = options.find(a => !a.startsWith('-'));
2177
+ return await checkoutPaths(ctx, git, credentialedVfs, source ?? null, subArgs.slice(dashdash + 1));
2178
+ }
2179
+ const quiet = options.includes('-q') || options.includes('--quiet');
2180
+ const ref = options.find(a => !a.startsWith('-'));
2181
+ // `git checkout` and `git checkout HEAD` switch to where HEAD already is: nothing changes.
2182
+ if ((!ref || ref === 'HEAD') && !options.includes('-b'))
2183
+ return 0;
556
2184
  if (!ref) {
557
- ctx.stderr.write('error: specify a branch\n');
558
- return 1;
2185
+ ctx.stderr.write("error: switch `b' requires a value\n");
2186
+ return 129;
559
2187
  }
560
- if (subArgs.includes('-b')) {
2188
+ const worktreeFs = createGitFs(credentialedVfs, dir);
2189
+ const create = options.includes('-b');
2190
+ if (create)
561
2191
  await git.branch({ fs, dir, ref });
562
- await git.checkout({ fs, dir, ref });
563
- ctx.stdout.write(`Switched to a new branch '${ref}'\n`);
2192
+ try {
2193
+ await git.checkout({ fs: worktreeFs, dir, ref });
564
2194
  }
565
- else {
566
- await git.checkout({ fs, dir, ref });
567
- ctx.stdout.write(`Switched to branch '${ref}'\n`);
568
- }
569
- return 0;
570
- }
571
- case 'diff': {
572
- // Simple diff: show unstaged changes
573
- const matrix = await git.statusMatrix({ fs, dir });
574
- for (const [filepath, head, workdir, stage] of matrix) {
575
- if (workdir !== head || workdir !== stage) {
576
- ctx.stdout.write(`\x1b[1mdiff --git a/${filepath} b/${filepath}\x1b[0m\n`);
577
- try {
578
- const raw = await fs.promises.readFile(dir + '/' + filepath);
579
- const content = typeof raw === 'string' ? raw : dec.decode(raw);
580
- const lines = content.split('\n');
581
- for (let i = 0; i < Math.min(lines.length, 50); i++) {
582
- if (head === 0)
583
- ctx.stdout.write(`\x1b[32m+${lines[i]}\x1b[0m\n`);
584
- else
585
- ctx.stdout.write(` ${lines[i]}\n`);
586
- }
587
- if (lines.length > 50)
588
- ctx.stdout.write(`... (${lines.length - 50} more lines)\n`);
589
- }
590
- catch { }
591
- ctx.stdout.write('\n');
592
- }
2195
+ catch (e) {
2196
+ return await refusal(ctx, e);
593
2197
  }
2198
+ if (!quiet)
2199
+ ctx.stdout.write(create ? `Switched to a new branch '${ref}'\n` : `Switched to branch '${ref}'\n`);
594
2200
  return 0;
595
2201
  }
2202
+ case 'diff':
2203
+ return await diffCommand(ctx, git, fs, credentialedVfs, subArgs);
596
2204
  case 'remote': {
597
2205
  if (subArgs[0] === 'add' && subArgs[1] && subArgs[2]) {
598
2206
  await git.addRemote({ fs, dir, remote: subArgs[1], url: subArgs[2] });
@@ -611,24 +2219,28 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
611
2219
  return 0;
612
2220
  }
613
2221
  case 'fetch': {
614
- const remote = subArgs[0] || 'origin';
2222
+ const { quiet, rest } = takeQuiet(subArgs);
2223
+ const remote = rest[0] || 'origin';
615
2224
  if (!doCtx || !doEnv) {
616
2225
  ctx.stderr.write('[git] fetch requires DO ctx + env (internal configuration error)\n');
617
2226
  return 1;
618
2227
  }
619
- ctx.stdout.write(`Fetching from ${remote}...\n`);
2228
+ if (!quiet)
2229
+ ctx.stdout.write(`Fetching from ${remote}...\n`);
620
2230
  const result = await execGitNetwork(doCtx, doEnv, {
621
2231
  op: 'fetch',
622
2232
  pid: ctx.pid,
623
2233
  dir,
624
2234
  remote,
2235
+ quiet,
625
2236
  auth: {
626
2237
  username: ctx.env.GIT_USERNAME || '',
627
2238
  password: ctx.env.GIT_PASSWORD || ctx.env.GIT_TOKEN || '',
628
2239
  },
629
2240
  });
630
2241
  if (result.success) {
631
- ctx.stdout.write(`\n[git] fetch complete (${result.filesWritten} files in ${(result.elapsed / 1000).toFixed(1)}s)\n`);
2242
+ if (!quiet)
2243
+ ctx.stdout.write(`\n[git] fetch complete (${result.filesWritten} files in ${(result.elapsed / 1000).toFixed(1)}s)\n`);
632
2244
  return 0;
633
2245
  }
634
2246
  else {
@@ -637,27 +2249,36 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
637
2249
  }
638
2250
  }
639
2251
  case 'pull': {
640
- const remote = subArgs[0] || 'origin';
641
- const branch = subArgs[1] || await git.currentBranch({ fs, dir }) || 'main';
2252
+ const { quiet, rest } = takeQuiet(subArgs);
2253
+ const remote = rest[0] || 'origin';
2254
+ const branch = rest[1] || await git.currentBranch({ fs, dir }) || 'main';
642
2255
  if (!doCtx || !doEnv) {
643
2256
  ctx.stderr.write('[git] pull requires DO ctx + env (internal configuration error)\n');
644
2257
  return 1;
645
2258
  }
646
- ctx.stdout.write(`Pulling from ${remote}/${branch}...\n`);
2259
+ const pullIdents = await commitIdents(ctx, git, fs, dir);
2260
+ if ('error' in pullIdents) {
2261
+ await ctx.stderr.write(pullIdents.error);
2262
+ return 128;
2263
+ }
2264
+ if (!quiet)
2265
+ ctx.stdout.write(`Pulling from ${remote}/${branch}...\n`);
647
2266
  const result = await execGitNetwork(doCtx, doEnv, {
648
2267
  op: 'pull',
649
2268
  pid: ctx.pid,
650
2269
  dir,
651
2270
  remote,
652
2271
  ref: branch,
653
- author: getAuthor(ctx),
2272
+ quiet,
2273
+ ...pullIdents,
654
2274
  auth: {
655
2275
  username: ctx.env.GIT_USERNAME || '',
656
2276
  password: ctx.env.GIT_PASSWORD || ctx.env.GIT_TOKEN || '',
657
2277
  },
658
2278
  });
659
2279
  if (result.success) {
660
- ctx.stdout.write(`\n[git] pull complete (${result.filesWritten} files in ${(result.elapsed / 1000).toFixed(1)}s)\n`);
2280
+ if (!quiet)
2281
+ ctx.stdout.write(`\n[git] pull complete (${result.filesWritten} files in ${(result.elapsed / 1000).toFixed(1)}s)\n`);
661
2282
  return 0;
662
2283
  }
663
2284
  else {
@@ -666,26 +2287,30 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
666
2287
  }
667
2288
  }
668
2289
  case 'push': {
669
- const remote = subArgs[0] || 'origin';
670
- const branch = subArgs[1] || await git.currentBranch({ fs, dir }) || 'main';
2290
+ const { quiet, rest } = takeQuiet(subArgs);
2291
+ const remote = rest[0] || 'origin';
2292
+ const branch = rest[1] || await git.currentBranch({ fs, dir }) || 'main';
671
2293
  if (!doCtx || !doEnv) {
672
2294
  ctx.stderr.write('[git] push requires DO ctx + env (internal configuration error)\n');
673
2295
  return 1;
674
2296
  }
675
- ctx.stdout.write(`Pushing to ${remote}/${branch}...\n`);
2297
+ if (!quiet)
2298
+ ctx.stdout.write(`Pushing to ${remote}/${branch}...\n`);
676
2299
  const result = await execGitNetwork(doCtx, doEnv, {
677
2300
  op: 'push',
678
2301
  pid: ctx.pid,
679
2302
  dir,
680
2303
  remote,
681
2304
  ref: branch,
2305
+ quiet,
682
2306
  auth: {
683
2307
  username: ctx.env.GIT_USERNAME || '',
684
2308
  password: ctx.env.GIT_PASSWORD || ctx.env.GIT_TOKEN || '',
685
2309
  },
686
2310
  });
687
2311
  if (result.success) {
688
- ctx.stdout.write(`\n[git] push complete (${(result.elapsed / 1000).toFixed(1)}s)\n`);
2312
+ if (!quiet)
2313
+ ctx.stdout.write(`\n[git] push complete (${(result.elapsed / 1000).toFixed(1)}s)\n`);
689
2314
  return 0;
690
2315
  }
691
2316
  else {
@@ -694,16 +2319,35 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
694
2319
  }
695
2320
  }
696
2321
  case 'merge': {
697
- const theirs = subArgs[0];
2322
+ const theirs = subArgs.find(a => !a.startsWith('-'));
698
2323
  if (!theirs) {
699
2324
  ctx.stderr.write('usage: git merge <branch>\n');
700
2325
  return 1;
701
2326
  }
702
- await git.merge({
703
- fs, dir, theirs,
704
- author: getAuthor(ctx),
2327
+ // Into the current branch, or HEAD when detached. The branch moves only once the worktree
2328
+ // and index have: a checkout git refuses ("would be overwritten by merge") changes nothing.
2329
+ const ours = await git.currentBranch({ fs, dir, fullname: true }) ?? 'HEAD';
2330
+ const mergeIdents = await commitIdents(ctx, git, fs, dir);
2331
+ if ('error' in mergeIdents) {
2332
+ await ctx.stderr.write(mergeIdents.error);
2333
+ return 128;
2334
+ }
2335
+ const merged = await git.merge({
2336
+ fs, dir, ours, theirs,
2337
+ ...mergeIdents,
2338
+ noUpdateBranch: true,
705
2339
  });
706
- ctx.stdout.write(`Merged ${theirs}\n`);
2340
+ if (!merged.alreadyMerged) {
2341
+ try {
2342
+ await git.checkout({ fs: createGitFs(credentialedVfs, dir), dir, ref: merged.oid, noUpdateHead: true, conflictOperation: 'merge' });
2343
+ }
2344
+ catch (e) {
2345
+ return await refusal(ctx, e, merged.fastForward ? undefined : 'ort');
2346
+ }
2347
+ await git.writeRef({ fs, dir, ref: ours, value: merged.oid, force: true });
2348
+ }
2349
+ if (!subArgs.includes('-q') && !subArgs.includes('--quiet'))
2350
+ ctx.stdout.write(`Merged ${theirs}\n`);
707
2351
  return 0;
708
2352
  }
709
2353
  case 'reset': {
@@ -711,13 +2355,16 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
711
2355
  const soft = subArgs.includes('--soft');
712
2356
  const ref = subArgs.find(a => !a.startsWith('-')) || 'HEAD';
713
2357
  const oid = await git.resolveRef({ fs, dir, ref });
714
- // Move the current branch to the target OID
715
- const branch = await git.currentBranch({ fs, dir });
716
- if (branch) {
717
- await git.writeRef({ fs, dir, ref: `refs/heads/${branch}`, value: oid, force: true });
2358
+ if (hard) {
2359
+ // The index and worktree become the target's, as a forced checkout from the old index
2360
+ // makes them (type changes included), before the branch moves: a failed write leaves it.
2361
+ await git.checkout({ fs: createGitFs(credentialedVfs, dir), dir, ref: oid, force: true, noUpdateHead: true });
718
2362
  }
719
- if (!soft) {
720
- // Reset index (--mixed behavior, also applies to --hard)
2363
+ // Move the current branch, or a detached HEAD, to the target OID
2364
+ const branch = await git.currentBranch({ fs, dir });
2365
+ await git.writeRef({ fs, dir, ref: branch ? `refs/heads/${branch}` : 'HEAD', value: oid, force: true });
2366
+ if (!hard && !soft) {
2367
+ // Reset index (--mixed)
721
2368
  const matrix = await git.statusMatrix({ fs, dir });
722
2369
  for (const [filepath] of matrix) {
723
2370
  try {
@@ -726,31 +2373,11 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
726
2373
  catch { }
727
2374
  }
728
2375
  }
729
- if (hard) {
730
- // Reset working tree to match the target
731
- await git.checkout({ fs, dir, ref: oid, force: true });
732
- }
733
2376
  ctx.stdout.write(`HEAD is now at ${oid.slice(0, 7)}\n`);
734
2377
  return 0;
735
2378
  }
736
- case 'tag': {
737
- if (subArgs.length === 0) {
738
- const tags = await git.listTags({ fs, dir });
739
- for (const t of tags)
740
- ctx.stdout.write(t + '\n');
741
- }
742
- else if (subArgs.includes('-d')) {
743
- const name = subArgs.find(a => !a.startsWith('-'));
744
- if (name)
745
- await git.deleteTag({ fs, dir, ref: name });
746
- }
747
- else {
748
- const name = subArgs[0];
749
- await git.tag({ fs, dir, ref: name });
750
- ctx.stdout.write(`Created tag ${name}\n`);
751
- }
752
- return 0;
753
- }
2379
+ case 'tag':
2380
+ return await tagCommand(ctx, git, fs, credentialedVfs, subArgs);
754
2381
  case 'config': {
755
2382
  const key = subArgs.find(a => !a.startsWith('-'));
756
2383
  const value = subArgs[subArgs.indexOf(key || '') + 1];
@@ -781,6 +2408,9 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
781
2408
  }
782
2409
  }
783
2410
  catch (e) {
2411
+ // The reader went away (`git diff | head`): git dies of SIGPIPE, silently.
2412
+ if (e?.code === 'EPIPE')
2413
+ return 141;
784
2414
  ctx.stderr.write(`fatal: ${e?.message || e}\n`);
785
2415
  return 128;
786
2416
  }