@nyte-ai/host 0.0.0-stage → 0.0.18

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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/THIRD-PARTY-NOTICES.md +800 -0
  4. package/dist/chunks/canvas-preview-0cmbkp2j.js +65 -0
  5. package/dist/chunks/canvas-preview-0nq75h9k.js +7 -0
  6. package/dist/chunks/canvas-preview-0nrnbt76.js +497 -0
  7. package/dist/chunks/canvas-preview-1sq7z9jq.js +17 -0
  8. package/dist/chunks/canvas-preview-1ta3avah.js +56 -0
  9. package/dist/chunks/canvas-preview-2zdyh9ny.js +26 -0
  10. package/dist/chunks/canvas-preview-31w7xm8z.js +34 -0
  11. package/dist/chunks/canvas-preview-4b6sayhg.js +295 -0
  12. package/dist/chunks/canvas-preview-4e0pxa32.js +63 -0
  13. package/dist/chunks/canvas-preview-518dh8by.js +169 -0
  14. package/dist/chunks/canvas-preview-5hxat7hh.js +239 -0
  15. package/dist/chunks/canvas-preview-5mex725c.js +14 -0
  16. package/dist/chunks/canvas-preview-5z63am63.js +295 -0
  17. package/dist/chunks/canvas-preview-65xqjw7f.js +20 -0
  18. package/dist/chunks/canvas-preview-69fkk9yn.js +95 -0
  19. package/dist/chunks/canvas-preview-75db60xy.js +457 -0
  20. package/dist/chunks/canvas-preview-76fwvd6e.js +272 -0
  21. package/dist/chunks/canvas-preview-8xgt67ka.js +598 -0
  22. package/dist/chunks/canvas-preview-98aj5p34.js +176 -0
  23. package/dist/chunks/canvas-preview-9aqczacs.js +73 -0
  24. package/dist/chunks/canvas-preview-a3yw0j70.js +72 -0
  25. package/dist/chunks/canvas-preview-azg56359.js +68 -0
  26. package/dist/chunks/canvas-preview-d9wwhbtz.js +240 -0
  27. package/dist/chunks/canvas-preview-ehf3vp31.js +411 -0
  28. package/dist/chunks/canvas-preview-evh92ywd.js +369 -0
  29. package/dist/chunks/canvas-preview-ewgspmsd.js +81 -0
  30. package/dist/chunks/canvas-preview-g3qj3y3k.js +156 -0
  31. package/dist/chunks/canvas-preview-h8hshnff.js +63 -0
  32. package/dist/chunks/canvas-preview-j1tnf51w.js +36 -0
  33. package/dist/chunks/canvas-preview-kdqgrstc.js +194 -0
  34. package/dist/chunks/canvas-preview-kn7kvbfj.js +12 -0
  35. package/dist/chunks/canvas-preview-m02v4yy6.js +386 -0
  36. package/dist/chunks/canvas-preview-mgcx2b2p.js +271 -0
  37. package/dist/chunks/canvas-preview-rp4x6y1h.js +144 -0
  38. package/dist/chunks/canvas-preview-rp6fz0w5.js +130 -0
  39. package/dist/chunks/canvas-preview-s94ebwf5.js +112 -0
  40. package/dist/chunks/canvas-preview-senpjwep.js +518 -0
  41. package/dist/chunks/canvas-preview-skzwvw4d.js +23 -0
  42. package/dist/chunks/canvas-preview-srgfpdmf.js +60 -0
  43. package/dist/chunks/canvas-preview-tjhabynd.js +252 -0
  44. package/dist/chunks/canvas-preview-vaatfznm.js +189 -0
  45. package/dist/chunks/canvas-preview-vax9ne4e.js +341 -0
  46. package/dist/chunks/canvas-preview-wg80rbc2.js +46 -0
  47. package/dist/chunks/canvas-preview-xn10d4f8.js +608 -0
  48. package/dist/chunks/canvas-preview-yjz0f7n4.js +50 -0
  49. package/dist/chunks/canvas-preview-ymgz0937.js +587 -0
  50. package/dist/chunks/canvas-preview-yrfz2ak6.js +134 -0
  51. package/dist/chunks/canvas-preview-zaaj99wd.js +244 -0
  52. package/dist/chunks/canvas-preview-zn2d0k9d.js +133 -0
  53. package/dist/src/canvas-preview.d.ts +29 -0
  54. package/dist/src/canvas-preview.js +267 -0
  55. package/dist/src/credentials.d.ts +44 -0
  56. package/dist/src/credentials.js +10 -0
  57. package/dist/src/deploy/cloudflare/worker.d.ts +50 -0
  58. package/dist/src/deploy/cloudflare/worker.js +196 -0
  59. package/dist/src/deploy/vercel/nitro.config.d.ts +2 -0
  60. package/dist/src/deploy/vercel/nitro.config.js +30 -0
  61. package/dist/src/deploy/vercel/server/routes/[...].d.ts +2 -0
  62. package/dist/src/deploy/vercel/server/routes/[...].js +18 -0
  63. package/dist/src/deploy/vercel/server/utils/host.d.ts +13 -0
  64. package/dist/src/deploy/vercel/server/utils/host.js +7 -0
  65. package/dist/src/deploy/vercel/server/utils/repair.d.ts +1 -0
  66. package/dist/src/deploy/vercel/server/utils/repair.js +6 -0
  67. package/dist/src/deploy/vercel/server/utils/step.d.ts +10 -0
  68. package/dist/src/deploy/vercel/server/utils/step.js +6 -0
  69. package/dist/src/deploy/vercel/server/workflows/drive.d.ts +14 -0
  70. package/dist/src/deploy/vercel/server/workflows/drive.js +13 -0
  71. package/dist/src/durable-object.d.ts +14 -0
  72. package/dist/src/durable-object.js +231 -0
  73. package/dist/src/hash.d.ts +1 -0
  74. package/dist/src/hash.js +6 -0
  75. package/dist/src/identity-key.d.ts +11 -0
  76. package/dist/src/identity-key.js +9 -0
  77. package/dist/src/identity.d.ts +12 -0
  78. package/dist/src/identity.js +6 -0
  79. package/dist/src/index.d.ts +21 -0
  80. package/dist/src/index.js +120 -0
  81. package/dist/src/key-file.d.ts +1 -0
  82. package/dist/src/key-file.js +7 -0
  83. package/dist/src/listen-fetch.d.ts +8 -0
  84. package/dist/src/listen-fetch.js +9 -0
  85. package/dist/src/listen.d.ts +4 -0
  86. package/dist/src/listen.js +10 -0
  87. package/dist/src/local/command.d.ts +15 -0
  88. package/dist/src/local/command.js +8 -0
  89. package/dist/src/local/feed.d.ts +7 -0
  90. package/dist/src/local/feed.js +6 -0
  91. package/dist/src/local/files.d.ts +18 -0
  92. package/dist/src/local/files.js +13 -0
  93. package/dist/src/local/git-watch.d.ts +15 -0
  94. package/dist/src/local/git-watch.js +7 -0
  95. package/dist/src/local/git.d.ts +5 -0
  96. package/dist/src/local/git.js +13 -0
  97. package/dist/src/local/github.d.ts +30 -0
  98. package/dist/src/local/github.js +9 -0
  99. package/dist/src/local/identity.d.ts +3 -0
  100. package/dist/src/local/identity.js +7 -0
  101. package/dist/src/local/jobs.d.ts +7 -0
  102. package/dist/src/local/jobs.js +6 -0
  103. package/dist/src/local/legacy-trust.d.ts +10 -0
  104. package/dist/src/local/legacy-trust.js +6 -0
  105. package/dist/src/local/patch.d.ts +16 -0
  106. package/dist/src/local/patch.js +17 -0
  107. package/dist/src/local/provider.d.ts +11 -0
  108. package/dist/src/local/provider.js +20 -0
  109. package/dist/src/local/snapshots.d.ts +7 -0
  110. package/dist/src/local/snapshots.js +11 -0
  111. package/dist/src/local/text.d.ts +1 -0
  112. package/dist/src/local/text.js +6 -0
  113. package/dist/src/local/tree.d.ts +50 -0
  114. package/dist/src/local/tree.js +16 -0
  115. package/dist/src/local/workspace-migration.d.ts +2 -0
  116. package/dist/src/local/workspace-migration.js +7 -0
  117. package/dist/src/local/worktrees.d.ts +46 -0
  118. package/dist/src/local/worktrees.js +14 -0
  119. package/dist/src/mcp/config.d.ts +75 -0
  120. package/dist/src/mcp/config.js +12 -0
  121. package/dist/src/mcp/connect.d.ts +28 -0
  122. package/dist/src/mcp/connect.js +9 -0
  123. package/dist/src/mcp/hub.d.ts +19 -0
  124. package/dist/src/mcp/hub.js +11 -0
  125. package/dist/src/mcp/oauth.d.ts +46 -0
  126. package/dist/src/mcp/oauth.js +12 -0
  127. package/dist/src/mcp/tools.d.ts +29 -0
  128. package/dist/src/mcp/tools.js +12 -0
  129. package/dist/src/open.d.ts +42 -0
  130. package/dist/src/open.js +44 -0
  131. package/dist/src/plugins/loader.d.ts +21 -0
  132. package/dist/src/plugins/loader.js +11 -0
  133. package/dist/src/postgres.d.ts +19 -0
  134. package/dist/src/postgres.js +8 -0
  135. package/dist/src/providers.d.ts +16 -0
  136. package/dist/src/providers.js +112 -0
  137. package/dist/src/secrets.d.ts +12 -0
  138. package/dist/src/secrets.js +8 -0
  139. package/dist/src/serve/binding.d.ts +39 -0
  140. package/dist/src/serve/binding.js +6 -0
  141. package/dist/src/serve/handler.d.ts +15 -0
  142. package/dist/src/serve/handler.js +8 -0
  143. package/dist/src/serve/stream-server.d.ts +6 -0
  144. package/dist/src/serve/stream-server.js +6 -0
  145. package/dist/src/session-index.d.ts +11 -0
  146. package/dist/src/session-index.js +11 -0
  147. package/dist/src/shell-env.d.ts +14 -0
  148. package/dist/src/shell-env.js +108 -0
  149. package/dist/src/sqlite-workers.d.ts +6 -0
  150. package/dist/src/sqlite-workers.js +6 -0
  151. package/dist/src/sqlite.d.ts +7 -0
  152. package/dist/src/sqlite.js +10 -0
  153. package/dist/src/store-owner.d.ts +15 -0
  154. package/dist/src/store-owner.js +9 -0
  155. package/dist/src/store-rows.d.ts +13 -0
  156. package/dist/src/store-rows.js +26 -0
  157. package/dist/src/store-usage.d.ts +25 -0
  158. package/dist/src/store-usage.js +16 -0
  159. package/dist/src/usage-scan.d.ts +24 -0
  160. package/dist/src/usage-scan.js +65 -0
  161. package/dist/src/usage-service.d.ts +22 -0
  162. package/dist/src/usage-service.js +12 -0
  163. package/dist/src/usage-summary.d.ts +9 -0
  164. package/dist/src/usage-summary.js +16 -0
  165. package/dist/src/usage-types.d.ts +86 -0
  166. package/dist/src/usage-types.js +0 -0
  167. package/dist/src/usage-worker.d.ts +1 -0
  168. package/dist/src/usage-worker.js +25 -0
  169. package/dist/src/usage.d.ts +72 -0
  170. package/dist/src/usage.js +13 -0
  171. package/package.json +132 -3
  172. package/src/canvas-preview.ts +402 -0
  173. package/src/credentials.ts +294 -0
  174. package/src/deploy/cloudflare/README.md +3 -0
  175. package/src/deploy/cloudflare/worker.ts +306 -0
  176. package/src/deploy/cloudflare/wrangler.jsonc +12 -0
  177. package/src/deploy/vercel/README.md +3 -0
  178. package/src/deploy/vercel/nitro.config.ts +29 -0
  179. package/src/deploy/vercel/server/routes/[...].ts +10 -0
  180. package/src/deploy/vercel/server/utils/host.ts +104 -0
  181. package/src/deploy/vercel/server/utils/repair.ts +35 -0
  182. package/src/deploy/vercel/server/utils/step.ts +25 -0
  183. package/src/deploy/vercel/server/workflows/drive.ts +46 -0
  184. package/src/deploy/vercel/tsconfig.json +9 -0
  185. package/src/deploy/vercel/types/bun.d.ts +2 -0
  186. package/src/durable-object.ts +386 -0
  187. package/src/hash.ts +5 -0
  188. package/src/identity-key.ts +20 -0
  189. package/src/identity.ts +66 -0
  190. package/src/index.ts +21 -0
  191. package/src/key-file.ts +53 -0
  192. package/src/listen-fetch.ts +409 -0
  193. package/src/listen.ts +228 -0
  194. package/src/local/command.ts +109 -0
  195. package/src/local/feed.ts +70 -0
  196. package/src/local/files.ts +812 -0
  197. package/src/local/git-watch.ts +189 -0
  198. package/src/local/git.ts +914 -0
  199. package/src/local/github.ts +692 -0
  200. package/src/local/identity.ts +21 -0
  201. package/src/local/jobs.ts +587 -0
  202. package/src/local/legacy-trust.ts +105 -0
  203. package/src/local/patch.ts +334 -0
  204. package/src/local/provider.ts +184 -0
  205. package/src/local/snapshots.ts +776 -0
  206. package/src/local/text.ts +9 -0
  207. package/src/local/tree.ts +398 -0
  208. package/src/local/workspace-migration.ts +95 -0
  209. package/src/local/worktrees.ts +239 -0
  210. package/src/mcp/config.ts +330 -0
  211. package/src/mcp/connect.ts +105 -0
  212. package/src/mcp/hub.ts +836 -0
  213. package/src/mcp/oauth.ts +436 -0
  214. package/src/mcp/tools.ts +333 -0
  215. package/src/open.ts +469 -0
  216. package/src/plugins/loader.ts +186 -0
  217. package/src/postgres.ts +492 -0
  218. package/src/providers.ts +135 -0
  219. package/src/secrets.ts +31 -0
  220. package/src/serve/binding.ts +628 -0
  221. package/src/serve/handler.ts +362 -0
  222. package/src/serve/stream-server.ts +176 -0
  223. package/src/session-index.ts +99 -0
  224. package/src/shell-env.ts +153 -0
  225. package/src/sqlite-workers.ts +283 -0
  226. package/src/sqlite.ts +528 -0
  227. package/src/store-owner.ts +98 -0
  228. package/src/store-rows.ts +110 -0
  229. package/src/store-usage.ts +344 -0
  230. package/src/usage-scan.ts +81 -0
  231. package/src/usage-service.ts +73 -0
  232. package/src/usage-summary.ts +79 -0
  233. package/src/usage-types.ts +87 -0
  234. package/src/usage-worker.ts +24 -0
  235. package/src/usage.ts +701 -0
@@ -0,0 +1,914 @@
1
+ import { lstat, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { asId, SdkError } from "@nyte-ai/schema";
4
+ import type {
5
+ BlameView,
6
+ ChangeSet,
7
+ Cursor,
8
+ FileChange,
9
+ FileStatus,
10
+ GitCommitView,
11
+ GitFileSide,
12
+ GitOutcome,
13
+ GitRevision,
14
+ GitScope,
15
+ GitStatus,
16
+ LineStat,
17
+ PatchEntry,
18
+ PatchSet,
19
+ } from "@nyte-ai/schema";
20
+ import { dev } from "@nyte-ai/core";
21
+ import type { ExecutionEnv, GitApi } from "@nyte-ai/core";
22
+ import { sha256 } from "../hash.ts";
23
+ import { run } from "./command.ts";
24
+ import type { Ran } from "./command.ts";
25
+ import { feed } from "./feed.ts";
26
+ import {
27
+ PATCH_LIMIT,
28
+ lineStat,
29
+ parseNameStatus,
30
+ parseNumstat,
31
+ readPatches,
32
+ textEntry,
33
+ } from "./patch.ts";
34
+ import type { Changed } from "./patch.ts";
35
+ import { createWatcher } from "./git-watch.ts";
36
+ import type { Repository } from "./git-watch.ts";
37
+ import { decodeText } from "./text.ts";
38
+
39
+ type Repo = Extract<GitStatus, { readonly kind: "repository" }>;
40
+
41
+ const PATCH_BATCH = 32;
42
+
43
+ const LOG_DEFAULT = 50;
44
+
45
+ const ENV: Readonly<Record<string, string>> = {
46
+ GIT_OPTIONAL_LOCKS: "0",
47
+ GIT_TERMINAL_PROMPT: "0",
48
+ GIT_LITERAL_PATHSPECS: "1",
49
+ LC_ALL: "C",
50
+ };
51
+
52
+ const CONFIG = ["-c", "core.quotepath=off", "-c", "color.ui=false", "-c", "diff.noprefix=false"];
53
+
54
+ const DIFF = [
55
+ "--no-color",
56
+ "--no-ext-diff",
57
+ "--no-textconv",
58
+ "--src-prefix=a/",
59
+ "--dst-prefix=b/",
60
+ "-M",
61
+ ];
62
+
63
+ /** Returns git over the CLI (`git`, default `git` on the PATH) for the repository holding `env.cwd`; every path it takes and answers is relative to the workspace (`env.cwd`), as `FilesApi`'s and the snapshots' are (§41j): the commands run at the repository root, and where the workspace is a subdirectory of the repository its prefix is added to every path going into git (`stage`, `unstage`, `discard` and `commit` files, `patches`, `contents` and `blame` paths) and stripped from every path coming out (`status`, `changes`, `patches`), the listings limited to it by pathspec (a rename source outside it reads `../…`); `root` stays the repository root, and `log`, `refs`, `branch`, `push` and `commit` of `staged` or `all` are repository-wide. `status` reads `git status --porcelain=v2 --branch -z --untracked-files=all` (`none` outside a repository, `head` null when unborn) with the staged and unstaged line stats (`git diff --numstat`), its `revision` the SHA-256 of those outputs with HEAD and each dirty worktree file's size and modification time, so an edit to an already-modified file moves it; `watch` yields the status whenever that revision moves, re-reading only on fs events that can move it (`createWatcher`: worktree paths git does not ignore and the git directory's HEAD, index and refs, coalesced over 250 ms; a 2 s poll only where `fs.watch` is unavailable). The repository (`git rev-parse`) is looked up once and kept while its git directory holds a HEAD. `changes(scope)` reads statuses with `git diff --name-status -z -M` and line stats with `--numstat` (`--ignore-all-space` under `ignoreWhitespace`, for the stats and patches only, so a whitespace-only change stays listed; `worktree` HEAD to the worktree, `staged` HEAD to the index, `unstaged` the index to the worktree, `commit` its parent (or the empty tree) to it, `branch` the merge base with HEAD to HEAD; HEAD of an unborn repository is the empty tree), untracked files joining `worktree` and `unstaged` with status `untracked` and their line count as added; its `revision` the status revision; `watchChanges` re-reads it as that revision moves. `patches` reads `git diff -U3` for the requested paths the scope reports (paths it does not report are left out), at most 32 per command, one `PatchEntry` per path, a text one carrying the path's section of the unified text as its `patch` (`binary`; `empty` when the scope reports the path but the diff shows no text, as under `ignoreWhitespace`; `too-large` past 1 MiB; `failed` alone when one path's read fails), `digest` the SHA-256 of the path's section; an untracked file is diffed against nothing on the host (its digest that of its patch), `failed` when it is gone or cannot be read. It yields one full `PatchSet` per revision as it moves. `contents` reads both sides (`git cat-file blob <rev>:<path>`, the index, the worktree file, or absent; a rename's old side at its old path; text, `truncated { head }` past 1 MiB, or binary). `log` pages `git log` with the cursor holding the next skip (default 50 a page); `refs` reads `git symbolic-ref`, `git for-each-ref refs/heads` and `git remote`; `blame` reads `git blame --porcelain` of the worktree file. Every write answers `busy { runs: [] }` while `index.lock` exists for the writes that take it (all but `push` and `branch` without checkout; the run-based busy is core's), then compares `expect` with the current status revision (`stale { revision }`): `stage` (`git add -A`), `unstage` (`git restore --staged`, `git rm --cached` when unborn), `discard` (`git restore --worktree`, untracked paths removed with `git clean -f`), `commit` (`staged`, `all` as `-a`, or the listed paths added and committed alone), `branch` (`git switch -c` when `checkout`, else `git branch`), `push` (`--porcelain`, `--set-upstream origin <branch>` when asked; a detached HEAD cannot set one); each answers `applied { revision }`, `unchanged` (`stage`, `unstage`, `discard` and `commit` when the revision did not move or git had nothing to commit, `push` when every ref was already up to date; `branch` always applies), or `rejected { reason }` with git's message. Reads outside a repository throw `SdkError("unavailable")`. */
64
+ export function localGit(env: ExecutionEnv, options?: { readonly git?: string }): GitApi {
65
+ const binary = options?.git ?? "git";
66
+ let emptyTree: Promise<string> | null = null;
67
+
68
+ const git = (
69
+ cwd: string,
70
+ args: readonly string[],
71
+ extra?: { readonly input?: string; readonly limit?: number },
72
+ ): Promise<Ran> => {
73
+ const started = Date.now();
74
+
75
+ return run(binary, [...CONFIG, ...args], { cwd, env: ENV, ...extra }).then((ran) => {
76
+ dev("git", "run", {
77
+ command: args[0],
78
+ args: args.length,
79
+ code: ran.code,
80
+ ms: Date.now() - started,
81
+ });
82
+
83
+ return ran;
84
+ });
85
+ };
86
+
87
+ let located: Promise<Repository | null> | null = null;
88
+
89
+ const lookUp = async (): Promise<Repository | null> => {
90
+ const ran = await git(env.cwd, [
91
+ "rev-parse",
92
+ "--show-toplevel",
93
+ "--absolute-git-dir",
94
+ "--show-prefix",
95
+ "--path-format=absolute",
96
+ "--git-common-dir",
97
+ ]);
98
+
99
+ if (ran.code !== 0) return null;
100
+ const [root, gitDir, prefix = "", commonDir] = ran.stdout.toString("utf8").split("\n");
101
+
102
+ return root === undefined || gitDir === undefined
103
+ ? null
104
+ : { root, gitDir, commonDir: commonDir || gitDir, prefix };
105
+ };
106
+
107
+ const repository = async (): Promise<Repository | null> => {
108
+ const pending = located;
109
+ const known = pending === null ? null : await pending;
110
+
111
+ if (known !== null && located === pending && (await exists(join(known.gitDir, "HEAD"))))
112
+ return known;
113
+
114
+ if (located === pending || located === null) located = lookUp();
115
+
116
+ return located;
117
+ };
118
+
119
+ const forget = (): void => {
120
+ located = null;
121
+ };
122
+
123
+ const required = async (): Promise<Repository> => {
124
+ const found = await repository();
125
+
126
+ if (found === null) throw new SdkError("unavailable", `${env.cwd} is not in a git repository.`);
127
+
128
+ return found;
129
+ };
130
+
131
+ const readRepository = async (repo: Repository): Promise<Repo> => {
132
+ const [porcelain, staged, unstaged] = await Promise.all([
133
+ git(repo.root, [
134
+ "status",
135
+ "--porcelain=v2",
136
+ "--branch",
137
+ "-z",
138
+ "--untracked-files=all",
139
+ ...within(repo),
140
+ ]),
141
+ git(repo.root, ["diff", "--cached", "--numstat", "-z", "-M", ...within(repo)]),
142
+ git(repo.root, ["diff", "--numstat", "-z", ...within(repo)]),
143
+ ]);
144
+
145
+ if (porcelain.code !== 0) throw new SdkError("unavailable", porcelain.stderr.trim());
146
+
147
+ return readStatus(repo, porcelain.stdout.toString("utf8"), {
148
+ staged: staged.stdout.toString("utf8"),
149
+ unstaged: unstaged.stdout.toString("utf8"),
150
+ });
151
+ };
152
+
153
+ const status = async (): Promise<GitStatus> => {
154
+ const repo = await repository();
155
+
156
+ return repo === null ? { kind: "none" } : readRepository(repo);
157
+ };
158
+
159
+ const empty = (root: string): Promise<string> => {
160
+ emptyTree ??= git(root, ["hash-object", "-t", "tree", "--stdin"], { input: "" }).then((ran) =>
161
+ ran.stdout.toString("utf8").trim(),
162
+ );
163
+
164
+ return emptyTree;
165
+ };
166
+
167
+ const resolves = async (root: string, rev: string): Promise<string | null> => {
168
+ const ran = await git(root, ["rev-parse", "--verify", "-q", `${rev}^{commit}`]);
169
+
170
+ return ran.code === 0 ? ran.stdout.toString("utf8").trim() : null;
171
+ };
172
+
173
+ const resolveRange = async (
174
+ root: string,
175
+ scope: GitScope,
176
+ ): Promise<{ readonly args: readonly string[]; readonly untracked: boolean }> => {
177
+ const head = async () => (await resolves(root, "HEAD")) ?? (await empty(root));
178
+
179
+ switch (scope.kind) {
180
+ case "worktree":
181
+ return { args: [await head()], untracked: true };
182
+ case "staged":
183
+ return { args: ["--cached", await head()], untracked: false };
184
+ case "unstaged":
185
+ return { args: [], untracked: true };
186
+ case "commit": {
187
+ const commit = await resolves(root, scope.commit);
188
+
189
+ if (commit === null) throw new SdkError("unknown", `No commit ${scope.commit}.`);
190
+
191
+ return {
192
+ args: [(await resolves(root, `${commit}^`)) ?? (await empty(root)), commit],
193
+ untracked: false,
194
+ };
195
+ }
196
+
197
+ case "branch": {
198
+ const base = await git(root, ["merge-base", scope.base, "HEAD"]);
199
+
200
+ if (base.code !== 0)
201
+ throw new SdkError("unknown", `No merge base between ${scope.base} and HEAD.`);
202
+
203
+ return { args: [base.stdout.toString("utf8").trim(), "HEAD"], untracked: false };
204
+ }
205
+ }
206
+ };
207
+
208
+ const whitespace = (ignore: boolean | undefined): readonly string[] =>
209
+ ignore === true ? ["--ignore-all-space"] : [];
210
+
211
+ const listChanged = async (
212
+ repo: Repository,
213
+ scope: GitScope,
214
+ current: Repo,
215
+ ): Promise<readonly Changed[]> => {
216
+ const range = await resolveRange(repo.root, scope);
217
+
218
+ const ran = await git(repo.root, [
219
+ "diff",
220
+ "--name-status",
221
+ "-z",
222
+ "-M",
223
+ ...range.args,
224
+ ...within(repo),
225
+ ]);
226
+
227
+ if (ran.code !== 0) throw new SdkError("unavailable", ran.stderr.trim());
228
+
229
+ const tracked = parseNameStatus(ran.stdout.toString("utf8")).map((file) =>
230
+ withPaths(file, (path) => relativeToPrefix(repo, path)),
231
+ );
232
+
233
+ return range.untracked
234
+ ? [...tracked, ...current.untracked.map((path): Changed => ({ path, status: "untracked" }))]
235
+ : tracked;
236
+ };
237
+
238
+ const changesAt = async (
239
+ repo: Repository,
240
+ scope: GitScope,
241
+ current: Repo,
242
+ ignoreWhitespace: boolean | undefined,
243
+ ): Promise<ChangeSet> => {
244
+ const range = await resolveRange(repo.root, scope);
245
+
246
+ const [changed, numstat] = await Promise.all([
247
+ listChanged(repo, scope, current),
248
+ git(repo.root, [
249
+ "diff",
250
+ "--numstat",
251
+ "-z",
252
+ "-M",
253
+ ...whitespace(ignoreWhitespace),
254
+ ...range.args,
255
+ ...within(repo),
256
+ ]),
257
+ ]);
258
+
259
+ const stats = parseNumstat(numstat.stdout.toString("utf8"));
260
+
261
+ const files = await Promise.all(
262
+ changed.map(async (file) => ({
263
+ ...file,
264
+ stat:
265
+ file.status === "untracked"
266
+ ? await untrackedStat(join(repo.root, into(repo, file.path)))
267
+ : (stats.get(into(repo, file.path)) ?? { kind: "unknown" as const }),
268
+ })),
269
+ );
270
+
271
+ return { revision: current.revision, files };
272
+ };
273
+
274
+ const patchesAt = async (
275
+ repo: Repository,
276
+ scope: GitScope,
277
+ paths: readonly string[],
278
+ current: Repo,
279
+ ignoreWhitespace: boolean | undefined,
280
+ ): Promise<PatchSet> => {
281
+ const { root } = repo;
282
+
283
+ const changed = new Map(
284
+ (await listChanged(repo, scope, current)).map((file) => [file.path, file]),
285
+ );
286
+
287
+ const wanted = [...new Set(paths)].flatMap((path) => {
288
+ const file = changed.get(path);
289
+
290
+ return file === undefined ? [] : [file];
291
+ });
292
+
293
+ const range = await resolveRange(root, scope);
294
+ const entries = new Map<string, PatchEntry & { readonly fresh: boolean }>();
295
+
296
+ const read = async (files: readonly Changed[], limit: number): Promise<Ran> =>
297
+ git(
298
+ root,
299
+ [
300
+ "diff",
301
+ ...DIFF,
302
+ "-U3",
303
+ ...whitespace(ignoreWhitespace),
304
+ ...range.args,
305
+ "--",
306
+ ...files.flatMap((file) =>
307
+ file.from === undefined ? [file.path] : [file.from, file.path],
308
+ ),
309
+ ],
310
+ { limit },
311
+ );
312
+
313
+ for (let start = 0; start < wanted.length; start += PATCH_BATCH) {
314
+ const batch = wanted.slice(start, start + PATCH_BATCH);
315
+
316
+ const tracked = batch.flatMap((file) =>
317
+ file.status === "untracked" ? [] : [withPaths(file, (path) => into(repo, path))],
318
+ );
319
+
320
+ for (const file of batch)
321
+ if (file.status === "untracked")
322
+ entries.set(file.path, {
323
+ ...(await untrackedEntry(join(root, into(repo, file.path)), file.path)),
324
+ fresh: true,
325
+ });
326
+
327
+ if (tracked.length === 0) continue;
328
+
329
+ for (const entry of await readPatches(tracked, read, (file) =>
330
+ sha256(`${file.path}\u0000`),
331
+ )) {
332
+ const path = relativeToPrefix(repo, entry.path);
333
+ entries.set(path, { ...entry, path, fresh: true });
334
+ }
335
+ }
336
+
337
+ return { revision: current.revision, entries };
338
+ };
339
+
340
+ const blob = async (root: string, spec: string): Promise<GitFileSide> => {
341
+ const ran = await git(root, ["cat-file", "blob", spec], { limit: PATCH_LIMIT });
342
+
343
+ if (ran.code !== 0 && !ran.truncated) return { kind: "absent" };
344
+
345
+ return decodeSide(ran.stdout, ran.truncated);
346
+ };
347
+
348
+ const worktree = async (root: string, path: string): Promise<GitFileSide> => {
349
+ const target = join(root, path);
350
+ const stats = await stat(target).catch(() => null);
351
+
352
+ if (stats === null || !stats.isFile()) return { kind: "absent" };
353
+ const bytes = await readFile(target).catch(() => null);
354
+
355
+ if (bytes === null) return { kind: "absent" };
356
+
357
+ return decodeSide(bytes.subarray(0, PATCH_LIMIT), bytes.byteLength > PATCH_LIMIT);
358
+ };
359
+
360
+ const write = async (
361
+ takesIndex: boolean,
362
+ expect: GitRevision | undefined,
363
+ operation: (
364
+ repo: Repository,
365
+ before: Repo,
366
+ ) => Promise<Ran | "unchanged" | { readonly rejected: string }>,
367
+ moves: "revision" | "always" = "revision",
368
+ ): Promise<GitOutcome> => {
369
+ const repo = await required();
370
+
371
+ if (takesIndex && (await exists(join(repo.gitDir, "index.lock"))))
372
+ return { kind: "busy", runs: [] };
373
+ const before = await readRepository(repo);
374
+
375
+ if (expect !== undefined && expect !== before.revision)
376
+ return { kind: "stale", revision: before.revision };
377
+ const ran = await operation(repo, before);
378
+
379
+ if (ran === "unchanged") return { kind: "unchanged" };
380
+
381
+ if ("rejected" in ran) return { kind: "rejected", reason: ran.rejected };
382
+
383
+ if (ran.code !== 0) {
384
+ const output = `${ran.stdout.toString("utf8")}\n${ran.stderr}`;
385
+
386
+ if (/nothing to commit|no changes added to commit|nothing added to commit/.test(output))
387
+ return { kind: "unchanged" };
388
+
389
+ return {
390
+ kind: "rejected",
391
+ reason:
392
+ ran.stderr.trim() || ran.stdout.toString("utf8").trim() || `git exited with ${ran.code}`,
393
+ };
394
+ }
395
+
396
+ const after = await readRepository(repo);
397
+
398
+ return moves === "revision" && after.revision === before.revision
399
+ ? { kind: "unchanged" }
400
+ : { kind: "applied", revision: after.revision };
401
+ };
402
+
403
+ const watcher = createWatcher(env.cwd, status, repository, forget);
404
+
405
+ const follow = <T>(read: (current: GitStatus) => Promise<T>): AsyncIterable<T> =>
406
+ feed<T>((sink) => {
407
+ let seen: string | null = null;
408
+ let pending: GitStatus | null = null;
409
+ let reading = false;
410
+ let closed = false;
411
+
412
+ const drain = async (): Promise<void> => {
413
+ if (reading) return;
414
+ reading = true;
415
+
416
+ while (pending !== null && !closed) {
417
+ const next: GitStatus = pending;
418
+ pending = null;
419
+
420
+ try {
421
+ sink.push(await read(next));
422
+ } catch (error) {
423
+ closed = true;
424
+ sink.fail(error);
425
+ }
426
+ }
427
+
428
+ reading = false;
429
+ };
430
+
431
+ const unsubscribe = watcher.subscribe(
432
+ (current) => {
433
+ const key = current.kind === "none" ? "none" : current.revision;
434
+
435
+ if (key === seen) return;
436
+ seen = key;
437
+ pending = current;
438
+ void drain();
439
+ },
440
+ (error) => sink.fail(error),
441
+ );
442
+
443
+ return () => {
444
+ closed = true;
445
+ unsubscribe();
446
+ };
447
+ });
448
+
449
+ const inRepo =
450
+ <T>(read: (repo: Repository, current: Repo) => Promise<T>) =>
451
+ async (current: GitStatus): Promise<T> => {
452
+ if (current.kind === "none")
453
+ throw new SdkError("unavailable", `${env.cwd} is not in a git repository.`);
454
+
455
+ return read(await required(), current);
456
+ };
457
+
458
+ return {
459
+ status,
460
+ watch: () => follow(async (current) => current),
461
+ async changes(scope, diff) {
462
+ const repo = await required();
463
+
464
+ return changesAt(repo, scope, await readRepository(repo), diff?.ignoreWhitespace);
465
+ },
466
+ watchChanges: (scope, diff) =>
467
+ follow(inRepo((repo, current) => changesAt(repo, scope, current, diff?.ignoreWhitespace))),
468
+ patches: (scope, paths, diff) =>
469
+ follow(
470
+ inRepo((repo, current) => patchesAt(repo, scope, paths, current, diff?.ignoreWhitespace)),
471
+ ),
472
+ async contents(path, scope) {
473
+ const repo = await required();
474
+ const current = await readRepository(repo);
475
+ const root = repo.root;
476
+
477
+ const from = into(
478
+ repo,
479
+ (await listChanged(repo, scope, current)).find((file) => file.path === path)?.from ?? path,
480
+ );
481
+
482
+ const at = into(repo, path);
483
+ const head = current.head === null ? null : "HEAD";
484
+
485
+ switch (scope.kind) {
486
+ case "worktree":
487
+ return {
488
+ path,
489
+ old: head === null ? { kind: "absent" } : await blob(root, `${head}:${from}`),
490
+ new: await worktree(root, at),
491
+ };
492
+ case "staged":
493
+ return {
494
+ path,
495
+ old: head === null ? { kind: "absent" } : await blob(root, `${head}:${from}`),
496
+ new: await blob(root, `:${at}`),
497
+ };
498
+ case "unstaged":
499
+ return { path, old: await blob(root, `:${from}`), new: await worktree(root, at) };
500
+ case "commit": {
501
+ const parent = await resolves(root, `${scope.commit}^`);
502
+
503
+ return {
504
+ path,
505
+ old: parent === null ? { kind: "absent" } : await blob(root, `${parent}:${from}`),
506
+ new: await blob(root, `${scope.commit}:${at}`),
507
+ };
508
+ }
509
+
510
+ case "branch": {
511
+ const base = (await git(root, ["merge-base", scope.base, "HEAD"])).stdout
512
+ .toString("utf8")
513
+ .trim();
514
+
515
+ return {
516
+ path,
517
+ old: base === "" ? { kind: "absent" } : await blob(root, `${base}:${from}`),
518
+ new: await blob(root, `HEAD:${at}`),
519
+ };
520
+ }
521
+ }
522
+ },
523
+ async log(input) {
524
+ const repo = await required();
525
+ const skip = input?.cursor === undefined ? 0 : Number(input.cursor) || 0;
526
+ const limit = Math.max(1, input?.limit ?? LOG_DEFAULT);
527
+
528
+ const ran = await git(repo.root, [
529
+ "log",
530
+ "-z",
531
+ `--skip=${skip}`,
532
+ `-n${limit + 1}`,
533
+ "--format=%H%x1f%P%x1f%an%x1f%ct%x1f%s",
534
+ ]);
535
+
536
+ if (ran.code !== 0) return { items: [], next: null };
537
+
538
+ const records = ran.stdout
539
+ .toString("utf8")
540
+ .split("\u0000")
541
+ .filter((record) => record !== "");
542
+
543
+ const items = records.slice(0, limit).map((record): GitCommitView => {
544
+ const [id = "", parents = "", author = "", time = "0", subject = ""] =
545
+ record.split("\u001f");
546
+
547
+ return {
548
+ id: asId<"git-commit">(id),
549
+ subject,
550
+ author,
551
+ committedAt: Number(time) * 1000,
552
+ parents:
553
+ parents === "" ? [] : parents.split(" ").map((parent) => asId<"git-commit">(parent)),
554
+ };
555
+ });
556
+
557
+ const next: Cursor | null =
558
+ records.length > limit ? asId<"cursor">(String(skip + limit)) : null;
559
+
560
+ return { items, next };
561
+ },
562
+ async refs() {
563
+ const repo = await required();
564
+
565
+ const [current, branches, remotes] = await Promise.all([
566
+ git(repo.root, ["symbolic-ref", "--short", "-q", "HEAD"]),
567
+ git(repo.root, [
568
+ "for-each-ref",
569
+ "--format=%(refname:short)%00%(objectname)%00%(upstream:short)",
570
+ "refs/heads",
571
+ ]),
572
+ git(repo.root, ["remote"]),
573
+ ]);
574
+
575
+ return {
576
+ current: current.code === 0 ? current.stdout.toString("utf8").trim() : null,
577
+ branches: lines(branches.stdout).map((line) => {
578
+ const [name = "", commit = "", upstream = ""] = line.split("\u0000");
579
+
580
+ return {
581
+ name,
582
+ commit: asId<"git-commit">(commit),
583
+ upstream: upstream === "" ? null : upstream,
584
+ };
585
+ }),
586
+ remotes: lines(remotes.stdout),
587
+ };
588
+ },
589
+ async blame(path) {
590
+ const repo = await required();
591
+ const ran = await git(repo.root, ["blame", "--porcelain", "--", into(repo, path)]);
592
+
593
+ if (ran.code !== 0)
594
+ throw new SdkError("unknown", ran.stderr.trim() || `Cannot blame ${path}.`);
595
+
596
+ return parseBlame(path, ran.stdout.toString("utf8"));
597
+ },
598
+ stage: (paths, guard) =>
599
+ write(true, guard?.expect, (repo) =>
600
+ git(repo.root, ["add", "-A", "--", ...paths.map((path) => into(repo, path))]),
601
+ ),
602
+ unstage: (paths, guard) =>
603
+ write(true, guard?.expect, (repo, before) => {
604
+ const inside = paths.map((path) => into(repo, path));
605
+
606
+ return git(
607
+ repo.root,
608
+ before.head === null
609
+ ? ["rm", "--cached", "-r", "-q", "--", ...inside]
610
+ : ["restore", "--staged", "--", ...inside],
611
+ );
612
+ }),
613
+ discard: (paths, guard) =>
614
+ write(true, guard?.expect, async (repo, before) => {
615
+ const untracked = new Set(before.untracked);
616
+
617
+ const tracked = paths
618
+ .filter((path) => !untracked.has(path))
619
+ .map((path) => into(repo, path));
620
+
621
+ const loose = paths.filter((path) => untracked.has(path)).map((path) => into(repo, path));
622
+
623
+ const restored =
624
+ tracked.length === 0
625
+ ? null
626
+ : await git(repo.root, ["restore", "--worktree", "--", ...tracked]);
627
+
628
+ if (restored !== null && restored.code !== 0) return restored;
629
+
630
+ return loose.length === 0
631
+ ? (restored ?? "unchanged")
632
+ : git(repo.root, ["clean", "-f", "-q", "--", ...loose]);
633
+ }),
634
+ commit: (input, guard) =>
635
+ write(true, guard?.expect, async (repo) => {
636
+ if (input.files === "staged") return git(repo.root, ["commit", "-q", "-m", input.message]);
637
+
638
+ if (input.files === "all")
639
+ return git(repo.root, ["commit", "-q", "-a", "-m", input.message]);
640
+ const files = input.files.map((path) => into(repo, path));
641
+ const added = await git(repo.root, ["add", "-A", "--", ...files]);
642
+
643
+ if (added.code !== 0) return added;
644
+
645
+ return git(repo.root, ["commit", "-q", "-m", input.message, "--", ...files]);
646
+ }),
647
+ branch: (input, guard) =>
648
+ write(
649
+ input.checkout,
650
+ guard?.expect,
651
+ (repo) =>
652
+ git(
653
+ repo.root,
654
+ input.checkout ? ["switch", "-q", "-c", input.name] : ["branch", input.name],
655
+ ),
656
+ "always",
657
+ ),
658
+ push: (input, guard) =>
659
+ write(
660
+ false,
661
+ guard?.expect,
662
+ async (repo, before) => {
663
+ if (input?.setUpstream === true && before.branch === null)
664
+ return { rejected: "HEAD is detached; there is no branch to push." };
665
+
666
+ const ran = await git(
667
+ repo.root,
668
+ input?.setUpstream === true && before.branch !== null
669
+ ? ["push", "--porcelain", "--set-upstream", "origin", before.branch]
670
+ : ["push", "--porcelain"],
671
+ );
672
+
673
+ const flags = lines(ran.stdout).flatMap((line) =>
674
+ /^[ +\-*!=]\t/.test(line) ? [line[0]] : [],
675
+ );
676
+
677
+ return ran.code === 0 && flags.length > 0 && flags.every((flag) => flag === "=")
678
+ ? "unchanged"
679
+ : ran;
680
+ },
681
+ "always",
682
+ ),
683
+ };
684
+ }
685
+
686
+ async function readStatus(
687
+ repo: Repository,
688
+ porcelain: string,
689
+ numstat: { readonly staged: string; readonly unstaged: string },
690
+ ): Promise<Repo> {
691
+ const tokens = porcelain.split("\u0000");
692
+ const stagedStats = parseNumstat(numstat.staged);
693
+ const unstagedStats = parseNumstat(numstat.unstaged);
694
+ const staged: FileChange[] = [];
695
+ const unstaged: FileChange[] = [];
696
+ const untracked: string[] = [];
697
+ const conflicts: string[] = [];
698
+ let head: string | null = null;
699
+ let branch: string | null = null;
700
+ let upstream: string | null = null;
701
+ let ahead = 0;
702
+ let behind = 0;
703
+
704
+ const change = (
705
+ path: string,
706
+ code: string,
707
+ stats: ReadonlyMap<string, LineStat>,
708
+ from: string | undefined,
709
+ ): FileChange => {
710
+ const stat = stats.get(path);
711
+
712
+ const counts =
713
+ stat?.kind === "text"
714
+ ? { added: stat.added, removed: stat.removed }
715
+ : { added: 0, removed: 0 };
716
+
717
+ const status: FileStatus =
718
+ code === "A"
719
+ ? "added"
720
+ : code === "D"
721
+ ? "deleted"
722
+ : code === "R" || code === "C"
723
+ ? "renamed"
724
+ : "modified";
725
+
726
+ return from === undefined || status !== "renamed"
727
+ ? { path, status, ...counts }
728
+ : { path, status, ...counts, from };
729
+ };
730
+
731
+ for (let index = 0; index < tokens.length; index += 1) {
732
+ const token = tokens[index] ?? "";
733
+
734
+ if (token.startsWith("# branch.oid ")) {
735
+ const oid = token.slice("# branch.oid ".length);
736
+ head = oid === "(initial)" ? null : oid;
737
+ } else if (token.startsWith("# branch.head ")) {
738
+ const name = token.slice("# branch.head ".length);
739
+ branch = name === "(detached)" ? null : name;
740
+ } else if (token.startsWith("# branch.upstream "))
741
+ upstream = token.slice("# branch.upstream ".length);
742
+ else if (token.startsWith("# branch.ab ")) {
743
+ const [plus = "+0", minus = "-0"] = token.slice("# branch.ab ".length).split(" ");
744
+ ahead = Number(plus.slice(1)) || 0;
745
+ behind = Number(minus.slice(1)) || 0;
746
+ } else if (token.startsWith("1 ") || token.startsWith("2 ")) {
747
+ const renamed = token.startsWith("2 ");
748
+ const fields = splitFields(token, renamed ? 9 : 8);
749
+ const codes = fields.fields[1] ?? "..";
750
+ const from = renamed ? tokens[index + 1] : undefined;
751
+
752
+ if (renamed) index += 1;
753
+ const x = codes[0] ?? ".";
754
+ const y = codes[1] ?? ".";
755
+
756
+ if (x !== ".") staged.push(change(fields.rest, x, stagedStats, from));
757
+
758
+ if (y !== ".") unstaged.push(change(fields.rest, y, unstagedStats, undefined));
759
+ } else if (token.startsWith("u ")) conflicts.push(splitFields(token, 10).rest);
760
+ else if (token.startsWith("? ")) untracked.push(token.slice(2));
761
+ }
762
+
763
+ const stamps = await Promise.all(
764
+ [...unstaged.map((file) => file.path), ...untracked].map(async (path) => {
765
+ const found = await lstat(join(repo.root, path)).catch(() => null);
766
+
767
+ return `${path}:${found === null ? "-" : `${found.size}:${found.mtimeMs}`}`;
768
+ }),
769
+ );
770
+
771
+ return {
772
+ kind: "repository",
773
+ root: repo.root,
774
+ head: head === null ? null : asId<"git-commit">(head),
775
+ branch,
776
+ upstream: upstream === null ? null : { name: upstream, ahead, behind },
777
+ revision: asId<"git-revision">(
778
+ sha256([porcelain, head ?? "", numstat.staged, numstat.unstaged, ...stamps].join("\u0000")),
779
+ ),
780
+ staged: staged.map((file) => withPaths(file, (path) => relativeToPrefix(repo, path))),
781
+ unstaged: unstaged.map((file) => withPaths(file, (path) => relativeToPrefix(repo, path))),
782
+ untracked: untracked.map((path) => relativeToPrefix(repo, path)),
783
+ conflicts: conflicts.map((path) => relativeToPrefix(repo, path)),
784
+ };
785
+ }
786
+
787
+ function into(repo: Repository, path: string): string {
788
+ return `${repo.prefix}${path}`;
789
+ }
790
+
791
+ function relativeToPrefix(repo: Repository, path: string): string {
792
+ return path.startsWith(repo.prefix)
793
+ ? path.slice(repo.prefix.length)
794
+ : `${"../".repeat(repo.prefix.split("/").length - 1)}${path}`;
795
+ }
796
+
797
+ function within(repo: Repository): readonly string[] {
798
+ return repo.prefix === "" ? [] : ["--", repo.prefix];
799
+ }
800
+
801
+ function withPaths<T extends { readonly path: string; readonly from?: string }>(
802
+ file: T,
803
+ map: (path: string) => string,
804
+ ): T {
805
+ return file.from === undefined
806
+ ? { ...file, path: map(file.path) }
807
+ : { ...file, path: map(file.path), from: map(file.from) };
808
+ }
809
+
810
+ function splitFields(
811
+ token: string,
812
+ count: number,
813
+ ): { readonly fields: readonly string[]; readonly rest: string } {
814
+ const parts = token.split(" ");
815
+ const fields = parts.slice(0, Math.min(count, parts.length - 1));
816
+
817
+ return { fields, rest: parts.slice(fields.length).join(" ") };
818
+ }
819
+
820
+ function parseBlame(path: string, output: string): BlameView {
821
+ const authors = new Map<string, { author: string; at: number }>();
822
+ const rows: BlameView["lines"][number][] = [];
823
+ let commit = "";
824
+ let line = 0;
825
+
826
+ for (const text of output.split("\n")) {
827
+ const header = /^([0-9a-f]{40,64}) \d+ (\d+)(?: \d+)?$/.exec(text);
828
+
829
+ if (header !== null) {
830
+ commit = header[1] ?? "";
831
+ line = Number(header[2]);
832
+
833
+ if (!authors.has(commit)) authors.set(commit, { author: "", at: 0 });
834
+ continue;
835
+ }
836
+
837
+ const known = authors.get(commit);
838
+
839
+ if (text.startsWith("author ") && known !== undefined) known.author = text.slice(7);
840
+ else if (text.startsWith("author-time ") && known !== undefined)
841
+ known.at = Number(text.slice(12)) * 1000;
842
+ else if (text.startsWith("\t"))
843
+ rows.push({
844
+ line,
845
+ commit: asId<"git-commit">(commit),
846
+ author: known?.author ?? "",
847
+ at: known?.at ?? 0,
848
+ });
849
+ }
850
+
851
+ return { path, lines: rows };
852
+ }
853
+
854
+ async function untrackedStat(path: string): Promise<LineStat> {
855
+ const stats = await stat(path).catch(() => null);
856
+
857
+ if (stats === null || !stats.isFile() || stats.size > PATCH_LIMIT) return { kind: "unknown" };
858
+ const bytes = await readFile(path).catch(() => null);
859
+ const text = bytes === null ? null : decodeText(bytes);
860
+
861
+ return text === null ? { kind: "binary" } : lineStat("", text);
862
+ }
863
+
864
+ async function untrackedEntry(target: string, path: string): Promise<PatchEntry> {
865
+ const stats = await stat(target).catch(() => null);
866
+
867
+ if (stats === null || !stats.isFile())
868
+ return {
869
+ path,
870
+ status: "untracked",
871
+ digest: "",
872
+ kind: "failed",
873
+ reason: `${path} is not a file.`,
874
+ };
875
+
876
+ const bytes = await readFile(target).catch((error: unknown) =>
877
+ error instanceof Error ? error.message : String(error),
878
+ );
879
+
880
+ if (typeof bytes === "string")
881
+ return { path, status: "untracked", digest: "", kind: "failed", reason: bytes };
882
+ const digest = sha256(bytes);
883
+
884
+ if (bytes.byteLength > PATCH_LIMIT)
885
+ return { path, status: "untracked", digest, kind: "too-large", limit: PATCH_LIMIT };
886
+ const text = decodeText(bytes);
887
+
888
+ return text === null
889
+ ? { path, status: "untracked", digest, kind: "binary" }
890
+ : textEntry(path, "untracked", null, text);
891
+ }
892
+
893
+ function decodeSide(bytes: Uint8Array, truncated: boolean): GitFileSide {
894
+ if (bytes.includes(0)) return { kind: "binary" };
895
+
896
+ if (truncated) return { kind: "truncated", head: new TextDecoder().decode(bytes) };
897
+ const text = decodeText(bytes);
898
+
899
+ return text === null ? { kind: "binary" } : { kind: "text", text };
900
+ }
901
+
902
+ function lines(output: Buffer): readonly string[] {
903
+ return output
904
+ .toString("utf8")
905
+ .split("\n")
906
+ .filter((line) => line !== "");
907
+ }
908
+
909
+ async function exists(path: string): Promise<boolean> {
910
+ return lstat(path).then(
911
+ () => true,
912
+ () => false,
913
+ );
914
+ }