@nimbus-sh/worker 0.9.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 (286) 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/esbuild-wasm-bundle.generated.d.ts +24 -24
  19. package/dist/esbuild-wasm-bundle.generated.d.ts.map +1 -1
  20. package/dist/esbuild-wasm-bundle.generated.js +24 -24
  21. package/dist/facets/cirrus-real.d.ts.map +1 -1
  22. package/dist/facets/cirrus-real.js +11 -6
  23. package/dist/facets/compose.d.ts +11 -7
  24. package/dist/facets/compose.d.ts.map +1 -1
  25. package/dist/facets/compose.js +8 -31
  26. package/dist/facets/data-plan.d.ts +79 -0
  27. package/dist/facets/data-plan.d.ts.map +1 -0
  28. package/dist/facets/data-plan.js +477 -0
  29. package/dist/facets/esbuild-bundle-pool.d.ts +1 -1
  30. package/dist/facets/esbuild-bundle-pool.js +3 -3
  31. package/dist/facets/esbuild-transform.d.ts +38 -8
  32. package/dist/facets/esbuild-transform.d.ts.map +1 -1
  33. package/dist/facets/esbuild-transform.js +219 -19
  34. package/dist/facets/exec-telemetry.d.ts +7 -3
  35. package/dist/facets/exec-telemetry.d.ts.map +1 -1
  36. package/dist/facets/manager.d.ts +198 -69
  37. package/dist/facets/manager.d.ts.map +1 -1
  38. package/dist/facets/manager.js +1276 -652
  39. package/dist/facets/opencode-staging.d.ts +0 -2
  40. package/dist/facets/opencode-staging.d.ts.map +1 -1
  41. package/dist/facets/opencode-staging.js +5 -11
  42. package/dist/facets/process.d.ts +4 -0
  43. package/dist/facets/process.d.ts.map +1 -1
  44. package/dist/facets/process.js +3 -0
  45. package/dist/facets/read-profile.d.ts +149 -0
  46. package/dist/facets/read-profile.d.ts.map +1 -0
  47. package/dist/facets/read-profile.js +483 -0
  48. package/dist/facets/real-vite-hmr.d.ts +2 -0
  49. package/dist/facets/real-vite-hmr.d.ts.map +1 -1
  50. package/dist/facets/real-vite-hmr.js +3 -2
  51. package/dist/facets/resident-identity.d.ts +0 -1
  52. package/dist/facets/resident-identity.d.ts.map +1 -1
  53. package/dist/facets/resident-identity.js +0 -3
  54. package/dist/facets/wasm-swap-registry.d.ts +34 -25
  55. package/dist/facets/wasm-swap-registry.d.ts.map +1 -1
  56. package/dist/facets/wasm-swap-registry.js +62 -56
  57. package/dist/facets/ws-terminal.d.ts +4 -4
  58. package/dist/facets/ws-terminal.d.ts.map +1 -1
  59. package/dist/facets/ws-terminal.js +4 -5
  60. package/dist/git/commands.d.ts +33 -10
  61. package/dist/git/commands.d.ts.map +1 -1
  62. package/dist/git/commands.js +1891 -197
  63. package/dist/git/network-facet.d.ts +12 -2
  64. package/dist/git/network-facet.d.ts.map +1 -1
  65. package/dist/git/network-facet.js +69 -18
  66. package/dist/git/unified-diff.d.ts +74 -0
  67. package/dist/git/unified-diff.d.ts.map +1 -0
  68. package/dist/git/unified-diff.js +676 -0
  69. package/dist/git-bundle.generated.d.ts +7 -11
  70. package/dist/git-bundle.generated.d.ts.map +1 -1
  71. package/dist/git-bundle.generated.js +7 -11
  72. package/dist/hosted/commands.d.ts.map +1 -1
  73. package/dist/hosted/commands.js +64 -245
  74. package/dist/hosted/runtime.d.ts +22 -15
  75. package/dist/hosted/runtime.d.ts.map +1 -1
  76. package/dist/hosted/runtime.js +72 -14
  77. package/dist/hosted/services.d.ts +8 -1
  78. package/dist/hosted/services.d.ts.map +1 -1
  79. package/dist/hosted/services.js +12 -13
  80. package/dist/hosted/session.d.ts +165 -0
  81. package/dist/hosted/session.d.ts.map +1 -0
  82. package/dist/hosted/session.js +132 -0
  83. package/dist/loaders/generated-workers.d.ts +6 -3
  84. package/dist/loaders/generated-workers.d.ts.map +1 -1
  85. package/dist/loaders/generated-workers.js +8 -5
  86. package/dist/loaders/npm-resolve-preamble.d.ts.map +1 -1
  87. package/dist/loaders/npm-resolve-preamble.js +0 -27
  88. package/dist/loaders/pre-bundle-preamble.d.ts +6 -3
  89. package/dist/loaders/pre-bundle-preamble.d.ts.map +1 -1
  90. package/dist/loaders/pre-bundle-preamble.js +13 -8
  91. package/dist/node-shims-artifact.generated.d.ts +13 -4
  92. package/dist/node-shims-artifact.generated.d.ts.map +1 -1
  93. package/dist/node-shims-artifact.generated.js +16 -7
  94. package/dist/npm/bin-links.d.ts +3 -1
  95. package/dist/npm/bin-links.d.ts.map +1 -1
  96. package/dist/npm/bin-links.js +32 -8
  97. package/dist/npm/cache.d.ts +11 -1
  98. package/dist/npm/cache.d.ts.map +1 -1
  99. package/dist/npm/cache.js +21 -7
  100. package/dist/npm/install-batch-facet.d.ts +4 -2
  101. package/dist/npm/install-batch-facet.d.ts.map +1 -1
  102. package/dist/npm/install-batch-facet.js +32 -18
  103. package/dist/npm/installer.d.ts +23 -3
  104. package/dist/npm/installer.d.ts.map +1 -1
  105. package/dist/npm/installer.js +330 -131
  106. package/dist/npm/npx-install.d.ts +3 -1
  107. package/dist/npm/npx-install.d.ts.map +1 -1
  108. package/dist/npm/npx-install.js +4 -1
  109. package/dist/npm/package-lock.d.ts +32 -0
  110. package/dist/npm/package-lock.d.ts.map +1 -0
  111. package/dist/npm/package-lock.js +65 -0
  112. package/dist/npm/placement.d.ts +16 -0
  113. package/dist/npm/placement.d.ts.map +1 -0
  114. package/dist/npm/placement.js +33 -0
  115. package/dist/npm/pre-bundle-facet.d.ts +1 -30
  116. package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
  117. package/dist/npm/pre-bundle-facet.js +3 -5
  118. package/dist/npm/r2-cache.d.ts +12 -6
  119. package/dist/npm/r2-cache.d.ts.map +1 -1
  120. package/dist/npm/r2-cache.js +34 -16
  121. package/dist/npm/resolve-one-facet.d.ts +3 -0
  122. package/dist/npm/resolve-one-facet.d.ts.map +1 -1
  123. package/dist/npm/resolve-one-facet.js +15 -3
  124. package/dist/npm/resolver.d.ts +11 -13
  125. package/dist/npm/resolver.d.ts.map +1 -1
  126. package/dist/npm/resolver.js +12 -25
  127. package/dist/opencode-artifact.generated.js +2 -2
  128. package/dist/replica/routing.d.ts +2 -38
  129. package/dist/replica/routing.d.ts.map +1 -1
  130. package/dist/replica/routing.js +3 -55
  131. package/dist/router/index.d.ts.map +1 -1
  132. package/dist/router/index.js +15 -2
  133. package/dist/router/public-directory.d.ts +18 -0
  134. package/dist/router/public-directory.d.ts.map +1 -1
  135. package/dist/router/public-directory.js +1 -1
  136. package/dist/router/remote-api.d.ts +3 -1
  137. package/dist/router/remote-api.d.ts.map +1 -1
  138. package/dist/router/remote-api.js +42 -79
  139. package/dist/runtime/bash-repl.js +2 -2
  140. package/dist/runtime/bun-repl.d.ts.map +1 -1
  141. package/dist/runtime/bun-repl.js +2 -13
  142. package/dist/runtime/cpython-resident.d.ts.map +1 -1
  143. package/dist/runtime/cpython-resident.js +2 -10
  144. package/dist/runtime/esbuild-wasm-bytes.d.ts +37 -18
  145. package/dist/runtime/esbuild-wasm-bytes.d.ts.map +1 -1
  146. package/dist/runtime/esbuild-wasm-bytes.js +66 -92
  147. package/dist/runtime/facet-loader-host.d.ts.map +1 -1
  148. package/dist/runtime/facet-loader-host.js +0 -7
  149. package/dist/runtime/git-bundle-artifact.d.ts +17 -0
  150. package/dist/runtime/git-bundle-artifact.d.ts.map +1 -0
  151. package/dist/runtime/git-bundle-artifact.js +25 -0
  152. package/dist/runtime/node-repl.d.ts.map +1 -1
  153. package/dist/runtime/node-repl.js +2 -13
  154. package/dist/runtime/node-shims-artifact.d.ts +22 -23
  155. package/dist/runtime/node-shims-artifact.d.ts.map +1 -1
  156. package/dist/runtime/node-shims-artifact.js +48 -78
  157. package/dist/runtime/node-shims.d.ts.map +1 -1
  158. package/dist/runtime/node-shims.js +2358 -996
  159. package/dist/runtime/opencode-artifact.d.ts.map +1 -1
  160. package/dist/runtime/opencode-artifact.js +17 -53
  161. package/dist/runtime/opencode-facet-runner.d.ts +3 -6
  162. package/dist/runtime/opencode-facet-runner.d.ts.map +1 -1
  163. package/dist/runtime/opencode-facet-runner.js +45 -10
  164. package/dist/runtime/opentui-wasm-bytes.d.ts.map +1 -1
  165. package/dist/runtime/opentui-wasm-bytes.js +16 -49
  166. package/dist/runtime/package-manager.d.ts +0 -2
  167. package/dist/runtime/package-manager.d.ts.map +1 -1
  168. package/dist/runtime/package-manager.js +0 -3
  169. package/dist/runtime/python-repl.d.ts.map +1 -1
  170. package/dist/runtime/python-repl.js +4 -35
  171. package/dist/runtime/repl-session.d.ts +10 -0
  172. package/dist/runtime/repl-session.d.ts.map +1 -1
  173. package/dist/runtime/repl-session.js +7 -0
  174. package/dist/runtime/ruby-repl.d.ts.map +1 -1
  175. package/dist/runtime/ruby-repl.js +2 -2
  176. package/dist/runtime/ruby-resident.d.ts.map +1 -1
  177. package/dist/runtime/ruby-resident.js +41 -51
  178. package/dist/runtime/runtime-catalog.d.ts +10 -3
  179. package/dist/runtime/runtime-catalog.d.ts.map +1 -1
  180. package/dist/runtime/runtime-catalog.js +125 -15
  181. package/dist/runtime/sqlite-wasm-bytes.d.ts +4 -4
  182. package/dist/runtime/sqlite-wasm-bytes.d.ts.map +1 -1
  183. package/dist/runtime/sqlite-wasm-bytes.js +21 -57
  184. package/dist/runtime/staged-source.d.ts +67 -0
  185. package/dist/runtime/staged-source.d.ts.map +1 -0
  186. package/dist/runtime/staged-source.js +113 -0
  187. package/dist/runtime/wasi/preamble.d.ts +0 -22
  188. package/dist/runtime/wasi/preamble.d.ts.map +1 -1
  189. package/dist/runtime/wasi/preamble.js +98 -1425
  190. package/dist/runtime-catalog.generated.js +1 -1
  191. package/dist/session/agent.d.ts +14 -2
  192. package/dist/session/agent.d.ts.map +1 -1
  193. package/dist/session/agent.js +57 -19
  194. package/dist/session/ai.d.ts +0 -11
  195. package/dist/session/ai.d.ts.map +1 -1
  196. package/dist/session/ai.js +0 -4
  197. package/dist/session/fs-watch.d.ts +0 -11
  198. package/dist/session/fs-watch.d.ts.map +1 -1
  199. package/dist/session/fs-watch.js +0 -21
  200. package/dist/session/hibernation.d.ts +68 -2
  201. package/dist/session/hibernation.d.ts.map +1 -1
  202. package/dist/session/hibernation.js +95 -1
  203. package/dist/session/init-phases.d.ts +1 -2
  204. package/dist/session/init-phases.d.ts.map +1 -1
  205. package/dist/session/init-phases.js +1 -2
  206. package/dist/session/init.d.ts.map +1 -1
  207. package/dist/session/init.js +7 -20
  208. package/dist/session/keys.d.ts +0 -3
  209. package/dist/session/keys.d.ts.map +1 -1
  210. package/dist/session/keys.js +0 -3
  211. package/dist/session/legacy-reset.d.ts +11 -0
  212. package/dist/session/legacy-reset.d.ts.map +1 -0
  213. package/dist/session/legacy-reset.js +16 -0
  214. package/dist/session/nimbus-session.d.ts +50 -16
  215. package/dist/session/nimbus-session.d.ts.map +1 -1
  216. package/dist/session/nimbus-session.js +77 -71
  217. package/dist/session/npm-install-port.d.ts.map +1 -1
  218. package/dist/session/npm-install-port.js +2 -0
  219. package/dist/session/programmatic.d.ts +13 -18
  220. package/dist/session/programmatic.d.ts.map +1 -1
  221. package/dist/session/programmatic.js +53 -39
  222. package/dist/session/replica-routes.d.ts +2 -3
  223. package/dist/session/replica-routes.d.ts.map +1 -1
  224. package/dist/session/replica-routes.js +3 -10
  225. package/dist/session/routes.d.ts.map +1 -1
  226. package/dist/session/routes.js +11 -12
  227. package/dist/session/rpc.d.ts +108 -21
  228. package/dist/session/rpc.d.ts.map +1 -1
  229. package/dist/session/rpc.js +194 -39
  230. package/dist/session/start-real-vite.js +2 -2
  231. package/dist/session/state-store.d.ts +1 -17
  232. package/dist/session/state-store.d.ts.map +1 -1
  233. package/dist/session/state-store.js +3 -53
  234. package/dist/session/supervisor-op.d.ts +7 -0
  235. package/dist/session/supervisor-op.d.ts.map +1 -1
  236. package/dist/session/supervisor-op.js +6 -1
  237. package/dist/session/supervisor-rpc.d.ts +116 -19
  238. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  239. package/dist/session/supervisor-rpc.js +194 -84
  240. package/dist/session/vite-command.d.ts.map +1 -1
  241. package/dist/session/vite-command.js +17 -14
  242. package/dist/session/ws.d.ts.map +1 -1
  243. package/dist/session/ws.js +20 -8
  244. package/dist/shell/npm-bin-entrypoints.d.ts.map +1 -1
  245. package/dist/shell/npm-bin-entrypoints.js +30 -5
  246. package/dist/vfs/facet-resident-limits.d.ts +33 -0
  247. package/dist/vfs/facet-resident-limits.d.ts.map +1 -0
  248. package/dist/vfs/facet-resident-limits.js +33 -0
  249. package/dist/vfs/facet-resident-store.d.ts +2 -10
  250. package/dist/vfs/facet-resident-store.d.ts.map +1 -1
  251. package/dist/vfs/facet-resident-store.js +1594 -219
  252. package/dist/workspace-host.d.ts +3 -1
  253. package/dist/workspace-host.d.ts.map +1 -1
  254. package/dist/workspace-host.js +2 -0
  255. package/dist/wrangler/nimbus-wrangler.d.ts +0 -2
  256. package/dist/wrangler/nimbus-wrangler.d.ts.map +1 -1
  257. package/dist/wrangler/nimbus-wrangler.js +8 -22
  258. package/package.json +34 -7
  259. package/public/_assets/esbuild-0.24.2.js +2380 -0
  260. package/public/_assets/opencode/1.16.2/index-attach.js +2 -2
  261. package/public/_assets/runtime/esbuild-cli-c4fda380d52befc1.js +6892 -0
  262. package/public/_assets/runtime/git-de3e60c9f3ff006a.js +103 -0
  263. package/public/_assets/runtime/{node-shims-acaa037e6d26ec0d.js → node-shims-06565b121555c194.js} +2879 -992
  264. package/public/_assets/runtime/resident-store-6ffb1939c53d982f.js +2264 -0
  265. package/public/_assets/runtime/vfs-write-ledger-e501e8fc793bc2c1.js +889 -0
  266. package/scripts/build-opencode-attach-entry.mjs +51 -1
  267. package/scripts/bundle-esbuild-wasm.mjs +55 -39
  268. package/scripts/bundle-facet-workers.mjs +159 -10
  269. package/scripts/bundle-git.mjs +88 -27
  270. package/scripts/bundle-node-shims.mjs +72 -46
  271. package/scripts/bundle-runtime.mjs +18 -421
  272. package/scripts/cf-git-patch.mjs +137 -0
  273. package/scripts/patch-install-deps.mjs +49 -96
  274. package/scripts/runtime-specs.mjs +435 -0
  275. package/vendor/git-http.generated.d.ts +82 -0
  276. package/vendor/git-upstream.generated.d.ts +4378 -0
  277. package/vendor/git.LICENSE +7 -0
  278. package/vendor/git.generated.d.mts +21 -0
  279. package/vendor/git.generated.mjs +103 -0
  280. package/dist/replica/suspension.d.ts +0 -36
  281. package/dist/replica/suspension.d.ts.map +0 -1
  282. package/dist/replica/suspension.js +0 -49
  283. package/dist/runtime/static-server.d.ts +0 -3
  284. package/dist/runtime/static-server.d.ts.map +0 -1
  285. package/dist/runtime/static-server.js +0 -133
  286. 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,20 +48,89 @@ 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);
99
+ }
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
+ }
62
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
+ };
63
134
  }
64
135
  return {
65
136
  promises: {
@@ -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,79 +180,82 @@ 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
  }
176
220
  function getDir(ctx) {
177
221
  return '/' + (ctx.cwd || '/home/user').replace(/^\/+/, '');
178
222
  }
223
+ /**
224
+ * The options git accepts BEFORE the subcommand. `-C <path>` runs the
225
+ * command as if started from <path>; repeated, each is relative to the
226
+ * previous (`git -C a -C b` runs in `a/b`). `--no-pager` and `-P` are
227
+ * accepted and mean nothing here, there is no pager. Any other leading
228
+ * option is refused: swallowing it would run the next word as a subcommand.
229
+ */
230
+ export function parseGitGlobals(args, cwd) {
231
+ let dir = cwd;
232
+ let i = 0;
233
+ for (; i < args.length; i++) {
234
+ const arg = args[i];
235
+ if (arg === '-C') {
236
+ const path = args[++i];
237
+ if (path === undefined)
238
+ throw new Error("option '-C' requires a value");
239
+ dir = path.startsWith('/') ? path : dir + '/' + path;
240
+ dir = '/' + dir.split('/').filter((seg) => seg && seg !== '.').join('/');
241
+ }
242
+ else if (arg.startsWith('-C') && arg.length > 2) {
243
+ const path = arg.slice(2);
244
+ dir = path.startsWith('/') ? path : dir + '/' + path;
245
+ dir = '/' + dir.split('/').filter((seg) => seg && seg !== '.').join('/');
246
+ }
247
+ else if (arg === '--no-pager' || arg === '-P') {
248
+ // no pager to disable
249
+ }
250
+ else if (arg.startsWith('-') && arg !== '--version' && arg !== '-v' && arg !== '--help' && arg !== '-h') {
251
+ throw new Error(`unknown option '${arg}'\nusage: git [-C <path>] [--no-pager] <command> [<args>]`);
252
+ }
253
+ else {
254
+ break;
255
+ }
256
+ }
257
+ return { sub: args[i], subArgs: args.slice(i + 1), dir };
258
+ }
179
259
  function getFlag(args, flag) {
180
260
  const idx = args.indexOf(flag);
181
261
  if (idx >= 0)
@@ -183,7 +263,7 @@ function getFlag(args, flag) {
183
263
  const prefix = `${flag}=`;
184
264
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) || undefined;
185
265
  }
186
- export const CLONE_USAGE = 'usage: git clone [--depth <n>] [--no-shallow] [--branch <name> | -b <name>] [--bg] <url> [dir]';
266
+ export const CLONE_USAGE = 'usage: git clone [-q | --quiet] [--depth <n>] [--no-shallow] [--branch <name> | -b <name>] [--bg] <url> [dir]';
187
267
  /**
188
268
  * Every flag is either handled or refused loudly. Silently skipping unknown
189
269
  * flags corrupted positionals for value-taking ones (`--branch dev URL`
@@ -195,6 +275,7 @@ export function parseCloneArgs(args) {
195
275
  let branch;
196
276
  let noShallow = false;
197
277
  let isBg = false;
278
+ let quiet = false;
198
279
  const positionals = [];
199
280
  for (let i = 0; i < args.length; i++) {
200
281
  const arg = args[i];
@@ -217,6 +298,10 @@ export function parseCloneArgs(args) {
217
298
  noShallow = true;
218
299
  else if (arg === '--bg' || arg === '&')
219
300
  isBg = true;
301
+ else if (arg === '-q' || arg === '--quiet')
302
+ quiet = true;
303
+ // Progress is already the default; there is no more of it to ask for.
304
+ else if (arg === '-v' || arg === '--verbose') { /* accepted */ }
220
305
  else if (name === '--filter') {
221
306
  throw new Error("clone does not support '--filter': the bundled isomorphic-git has no " +
222
307
  'partial-clone support, so a filter would silently download every object. ' +
@@ -236,13 +321,1579 @@ export function parseCloneArgs(args) {
236
321
  noShallow,
237
322
  isBg,
238
323
  branch,
324
+ quiet,
239
325
  };
240
326
  }
241
- function getAuthor(ctx) {
242
- return {
243
- name: ctx.env.GIT_AUTHOR_NAME || ctx.env.USER || 'user',
244
- 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;
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;
245
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;
246
1897
  }
247
1898
  // ── Git subcommand implementations ──────────────────────────────────────
248
1899
  /**
@@ -254,10 +1905,18 @@ function getAuthor(ctx) {
254
1905
  export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
255
1906
  const credentialedVfs = vfs.as(requireVfsCred(ctx.cred, 'git'));
256
1907
  const fs = createGitFs(credentialedVfs);
257
- const args = ctx.args;
258
- const sub = args[0];
259
- const subArgs = args.slice(1);
260
- const dir = getDir(ctx);
1908
+ let globals;
1909
+ try {
1910
+ globals = parseGitGlobals(ctx.args, getDir(ctx));
1911
+ }
1912
+ catch (e) {
1913
+ ctx.stderr.write(`git: ${e?.message}\n`);
1914
+ return 129;
1915
+ }
1916
+ const { sub, subArgs, dir } = globals;
1917
+ // Every subcommand below reads `dir` and the clone's `getDir(ctx)`; `-C`
1918
+ // moves both, exactly as `git -C <path>` runs the command from <path>.
1919
+ ctx = { ...ctx, cwd: dir };
261
1920
  if (sub === '--version' || sub === '-v') {
262
1921
  ctx.stdout.write('git version 2.44.0 (isomorphic-git/cf-git)\n');
263
1922
  return 0;
@@ -266,8 +1925,8 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
266
1925
  ctx.stdout.write('usage: git <command> [<args>]\n\n');
267
1926
  ctx.stdout.write('Commands:\n');
268
1927
  ctx.stdout.write(' init, clone, status, add, commit, log, branch,\n');
269
- ctx.stdout.write(' checkout, diff, remote, fetch, pull, push, merge,\n');
270
- 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');
271
1930
  return 0;
272
1931
  }
273
1932
  // Lazy-load isomorphic-git only when actually needed.
@@ -295,11 +1954,14 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
295
1954
  credentialedVfs.mkdir(stripped, { recursive: true });
296
1955
  }
297
1956
  await git.init({ fs, dir: initDir });
298
- 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
+ }
299
1960
  return 0;
300
1961
  }
301
1962
  case 'clone': {
302
- const { url, dest: destArg, depth, isBg, branch } = parseCloneArgs(subArgs);
1963
+ const { url, dest: destArg, depth, isBg, branch, quiet } = parseCloneArgs(subArgs);
1964
+ const progress = quiet ? { write() { } } : ctx.stdout;
303
1965
  if (!url) {
304
1966
  ctx.stderr.write(CLONE_USAGE + '\n');
305
1967
  return 1;
@@ -322,7 +1984,7 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
322
1984
  ctx.stderr.write('[git] clone requires DO ctx + env (internal configuration error)\n');
323
1985
  return 1;
324
1986
  }
325
- ctx.stdout.write(`Cloning into '${dest}'...${depth ? ' (shallow, depth=' + depth + ')' : ''}\n`);
1987
+ progress.write(`Cloning into '${dest}'...${depth ? ' (shallow, depth=' + depth + ')' : ''}\n`);
326
1988
  // A clone's closed-world filesystem view is correct only while no
327
1989
  // other session surface can mutate its destination subtree. Acquire
328
1990
  // the lease before the facet performs its lstat/readdir emptiness
@@ -342,6 +2004,7 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
342
2004
  url,
343
2005
  ref: branch,
344
2006
  depth,
2007
+ quiet,
345
2008
  exclusiveDestination: true,
346
2009
  exclusiveMutationRoot: mutationLease.root,
347
2010
  mutationOwner: mutationLease.owner,
@@ -357,7 +2020,7 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
357
2020
  },
358
2021
  });
359
2022
  if (result.success) {
360
- ctx.stdout.write(`\n[git] clone complete (${result.filesWritten} files, ` +
2023
+ progress.write(`\n[git] clone complete (${result.filesWritten} files, ` +
361
2024
  `${(result.bytesWritten / 1024).toFixed(1)}KB in ${(result.elapsed / 1000).toFixed(1)}s)\n`);
362
2025
  }
363
2026
  else {
@@ -372,7 +2035,7 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
372
2035
  if (isBg) {
373
2036
  const task = doClone();
374
2037
  doCtx.waitUntil(task);
375
- ctx.stdout.write('[git] clone running in background...\n');
2038
+ progress.write('[git] clone running in background...\n');
376
2039
  return 0;
377
2040
  }
378
2041
  else {
@@ -405,41 +2068,47 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
405
2068
  ctx.stdout.write('nothing to commit, working tree clean\n');
406
2069
  return 0;
407
2070
  }
408
- case 'add': {
409
- const paths = subArgs.filter(a => !a.startsWith('-'));
410
- if (paths.length === 0 || paths.includes('.')) {
411
- // Add all
412
- const matrix = await git.statusMatrix({ fs, dir });
413
- for (const [filepath, head, workdir, stage] of matrix) {
414
- if (head !== workdir || workdir !== stage) {
415
- if (workdir === 0)
416
- await git.remove({ fs, dir, filepath });
417
- else
418
- await git.add({ fs, dir, filepath });
419
- }
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;
420
2081
  }
2082
+ catch { /* unset */ }
421
2083
  }
422
- else {
423
- for (const filepath of paths) {
424
- await git.add({ fs, dir, filepath });
425
- }
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;
426
2089
  }
427
- return 0;
428
- }
429
- case 'commit': {
430
- const msgIdx = subArgs.indexOf('-m');
431
- const message = msgIdx >= 0 ? subArgs[msgIdx + 1] : 'commit';
432
- if (!message) {
433
- 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');
434
2094
  return 1;
435
2095
  }
436
- const sha = await git.commit({
437
- fs, dir, message,
438
- author: getAuthor(ctx),
439
- });
440
- 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`);
441
2106
  return 0;
442
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);
443
2112
  case 'log': {
444
2113
  const maxCount = parseInt(getFlag(subArgs, '-n') || getFlag(subArgs, '--max-count') || '10');
445
2114
  const oneline = subArgs.includes('--oneline');
@@ -458,6 +2127,18 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
458
2127
  return 0;
459
2128
  }
460
2129
  case 'branch': {
2130
+ if (subArgs[0] === '--show-current') {
2131
+ // Empty output on a detached HEAD, like git.
2132
+ const current = await git.currentBranch({ fs, dir });
2133
+ if (current)
2134
+ ctx.stdout.write(`${current}\n`);
2135
+ return 0;
2136
+ }
2137
+ const unknown = subArgs.find((a) => a.startsWith('-') && !['-a', '--list', '-d', '-D'].includes(a));
2138
+ if (unknown) {
2139
+ ctx.stderr.write(`error: unknown option '${unknown}'\nusage: git branch [-a | --list | --show-current | -d <name> | -D <name> | <name>]\n`);
2140
+ return 129;
2141
+ }
461
2142
  if (subArgs.length === 0 || subArgs[0] === '-a' || subArgs[0] === '--list') {
462
2143
  const branches = await git.listBranches({ fs, dir });
463
2144
  const current = await git.currentBranch({ fs, dir });
@@ -488,47 +2169,38 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
488
2169
  return 0;
489
2170
  }
490
2171
  case 'checkout': {
491
- 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;
492
2184
  if (!ref) {
493
- ctx.stderr.write('error: specify a branch\n');
494
- return 1;
2185
+ ctx.stderr.write("error: switch `b' requires a value\n");
2186
+ return 129;
495
2187
  }
496
- if (subArgs.includes('-b')) {
2188
+ const worktreeFs = createGitFs(credentialedVfs, dir);
2189
+ const create = options.includes('-b');
2190
+ if (create)
497
2191
  await git.branch({ fs, dir, ref });
498
- await git.checkout({ fs, dir, ref });
499
- ctx.stdout.write(`Switched to a new branch '${ref}'\n`);
500
- }
501
- else {
502
- await git.checkout({ fs, dir, ref });
503
- ctx.stdout.write(`Switched to branch '${ref}'\n`);
2192
+ try {
2193
+ await git.checkout({ fs: worktreeFs, dir, ref });
504
2194
  }
505
- return 0;
506
- }
507
- case 'diff': {
508
- // Simple diff: show unstaged changes
509
- const matrix = await git.statusMatrix({ fs, dir });
510
- for (const [filepath, head, workdir, stage] of matrix) {
511
- if (workdir !== head || workdir !== stage) {
512
- ctx.stdout.write(`\x1b[1mdiff --git a/${filepath} b/${filepath}\x1b[0m\n`);
513
- try {
514
- const raw = await fs.promises.readFile(dir + '/' + filepath);
515
- const content = typeof raw === 'string' ? raw : dec.decode(raw);
516
- const lines = content.split('\n');
517
- for (let i = 0; i < Math.min(lines.length, 50); i++) {
518
- if (head === 0)
519
- ctx.stdout.write(`\x1b[32m+${lines[i]}\x1b[0m\n`);
520
- else
521
- ctx.stdout.write(` ${lines[i]}\n`);
522
- }
523
- if (lines.length > 50)
524
- ctx.stdout.write(`... (${lines.length - 50} more lines)\n`);
525
- }
526
- catch { }
527
- ctx.stdout.write('\n');
528
- }
2195
+ catch (e) {
2196
+ return await refusal(ctx, e);
529
2197
  }
2198
+ if (!quiet)
2199
+ ctx.stdout.write(create ? `Switched to a new branch '${ref}'\n` : `Switched to branch '${ref}'\n`);
530
2200
  return 0;
531
2201
  }
2202
+ case 'diff':
2203
+ return await diffCommand(ctx, git, fs, credentialedVfs, subArgs);
532
2204
  case 'remote': {
533
2205
  if (subArgs[0] === 'add' && subArgs[1] && subArgs[2]) {
534
2206
  await git.addRemote({ fs, dir, remote: subArgs[1], url: subArgs[2] });
@@ -547,24 +2219,28 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
547
2219
  return 0;
548
2220
  }
549
2221
  case 'fetch': {
550
- const remote = subArgs[0] || 'origin';
2222
+ const { quiet, rest } = takeQuiet(subArgs);
2223
+ const remote = rest[0] || 'origin';
551
2224
  if (!doCtx || !doEnv) {
552
2225
  ctx.stderr.write('[git] fetch requires DO ctx + env (internal configuration error)\n');
553
2226
  return 1;
554
2227
  }
555
- ctx.stdout.write(`Fetching from ${remote}...\n`);
2228
+ if (!quiet)
2229
+ ctx.stdout.write(`Fetching from ${remote}...\n`);
556
2230
  const result = await execGitNetwork(doCtx, doEnv, {
557
2231
  op: 'fetch',
558
2232
  pid: ctx.pid,
559
2233
  dir,
560
2234
  remote,
2235
+ quiet,
561
2236
  auth: {
562
2237
  username: ctx.env.GIT_USERNAME || '',
563
2238
  password: ctx.env.GIT_PASSWORD || ctx.env.GIT_TOKEN || '',
564
2239
  },
565
2240
  });
566
2241
  if (result.success) {
567
- 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`);
568
2244
  return 0;
569
2245
  }
570
2246
  else {
@@ -573,27 +2249,36 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
573
2249
  }
574
2250
  }
575
2251
  case 'pull': {
576
- const remote = subArgs[0] || 'origin';
577
- 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';
578
2255
  if (!doCtx || !doEnv) {
579
2256
  ctx.stderr.write('[git] pull requires DO ctx + env (internal configuration error)\n');
580
2257
  return 1;
581
2258
  }
582
- 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`);
583
2266
  const result = await execGitNetwork(doCtx, doEnv, {
584
2267
  op: 'pull',
585
2268
  pid: ctx.pid,
586
2269
  dir,
587
2270
  remote,
588
2271
  ref: branch,
589
- author: getAuthor(ctx),
2272
+ quiet,
2273
+ ...pullIdents,
590
2274
  auth: {
591
2275
  username: ctx.env.GIT_USERNAME || '',
592
2276
  password: ctx.env.GIT_PASSWORD || ctx.env.GIT_TOKEN || '',
593
2277
  },
594
2278
  });
595
2279
  if (result.success) {
596
- 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`);
597
2282
  return 0;
598
2283
  }
599
2284
  else {
@@ -602,26 +2287,30 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
602
2287
  }
603
2288
  }
604
2289
  case 'push': {
605
- const remote = subArgs[0] || 'origin';
606
- 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';
607
2293
  if (!doCtx || !doEnv) {
608
2294
  ctx.stderr.write('[git] push requires DO ctx + env (internal configuration error)\n');
609
2295
  return 1;
610
2296
  }
611
- ctx.stdout.write(`Pushing to ${remote}/${branch}...\n`);
2297
+ if (!quiet)
2298
+ ctx.stdout.write(`Pushing to ${remote}/${branch}...\n`);
612
2299
  const result = await execGitNetwork(doCtx, doEnv, {
613
2300
  op: 'push',
614
2301
  pid: ctx.pid,
615
2302
  dir,
616
2303
  remote,
617
2304
  ref: branch,
2305
+ quiet,
618
2306
  auth: {
619
2307
  username: ctx.env.GIT_USERNAME || '',
620
2308
  password: ctx.env.GIT_PASSWORD || ctx.env.GIT_TOKEN || '',
621
2309
  },
622
2310
  });
623
2311
  if (result.success) {
624
- 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`);
625
2314
  return 0;
626
2315
  }
627
2316
  else {
@@ -630,16 +2319,35 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
630
2319
  }
631
2320
  }
632
2321
  case 'merge': {
633
- const theirs = subArgs[0];
2322
+ const theirs = subArgs.find(a => !a.startsWith('-'));
634
2323
  if (!theirs) {
635
2324
  ctx.stderr.write('usage: git merge <branch>\n');
636
2325
  return 1;
637
2326
  }
638
- await git.merge({
639
- fs, dir, theirs,
640
- 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,
641
2339
  });
642
- 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`);
643
2351
  return 0;
644
2352
  }
645
2353
  case 'reset': {
@@ -647,13 +2355,16 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
647
2355
  const soft = subArgs.includes('--soft');
648
2356
  const ref = subArgs.find(a => !a.startsWith('-')) || 'HEAD';
649
2357
  const oid = await git.resolveRef({ fs, dir, ref });
650
- // Move the current branch to the target OID
651
- const branch = await git.currentBranch({ fs, dir });
652
- if (branch) {
653
- 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 });
654
2362
  }
655
- if (!soft) {
656
- // 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)
657
2368
  const matrix = await git.statusMatrix({ fs, dir });
658
2369
  for (const [filepath] of matrix) {
659
2370
  try {
@@ -662,31 +2373,11 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
662
2373
  catch { }
663
2374
  }
664
2375
  }
665
- if (hard) {
666
- // Reset working tree to match the target
667
- await git.checkout({ fs, dir, ref: oid, force: true });
668
- }
669
2376
  ctx.stdout.write(`HEAD is now at ${oid.slice(0, 7)}\n`);
670
2377
  return 0;
671
2378
  }
672
- case 'tag': {
673
- if (subArgs.length === 0) {
674
- const tags = await git.listTags({ fs, dir });
675
- for (const t of tags)
676
- ctx.stdout.write(t + '\n');
677
- }
678
- else if (subArgs.includes('-d')) {
679
- const name = subArgs.find(a => !a.startsWith('-'));
680
- if (name)
681
- await git.deleteTag({ fs, dir, ref: name });
682
- }
683
- else {
684
- const name = subArgs[0];
685
- await git.tag({ fs, dir, ref: name });
686
- ctx.stdout.write(`Created tag ${name}\n`);
687
- }
688
- return 0;
689
- }
2379
+ case 'tag':
2380
+ return await tagCommand(ctx, git, fs, credentialedVfs, subArgs);
690
2381
  case 'config': {
691
2382
  const key = subArgs.find(a => !a.startsWith('-'));
692
2383
  const value = subArgs[subArgs.indexOf(key || '') + 1];
@@ -717,6 +2408,9 @@ export async function runGitCommand(ctx, vfs, doCtx, doEnv) {
717
2408
  }
718
2409
  }
719
2410
  catch (e) {
2411
+ // The reader went away (`git diff | head`): git dies of SIGPIPE, silently.
2412
+ if (e?.code === 'EPIPE')
2413
+ return 141;
720
2414
  ctx.stderr.write(`fatal: ${e?.message || e}\n`);
721
2415
  return 128;
722
2416
  }