@hwip/cli 0.0.1 → 0.1.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 (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Reading what a session changed in its repository: the one place git is run
3
+ * for the phone's diff review.
4
+ *
5
+ * The arguments are constant. The only values that reach git are an object id
6
+ * this module read back from git and checked (`OBJECT_ID`), a ref name git
7
+ * printed that matched `REMOTE_REF` (it only ever goes to `rev-parse`), and one
8
+ * caller path passed after `--` (with `--literal-pathspecs`, so it can only
9
+ * name itself). Nothing here knows about sealing, trust or the wire — the
10
+ * listener decides who may ask; this decides what git is asked.
11
+ *
12
+ * The repository's own configuration is not constant, and git can run commands
13
+ * it names. What this module switches off: `core.fsmonitor` (on every run),
14
+ * external diff drivers, textconv filters and the nesting of submodules (flags
15
+ * on the patch commands), the lazy fetch of a partial clone
16
+ * (`GIT_NO_LAZY_FETCH`), the listener's own `GIT_DIR` and the like (not passed
17
+ * on), and the `clean` / `process` side of every `filter.<name>` the config
18
+ * defines. That last one is a limit, not a guarantee: the names are listed by
19
+ * one git run and used by the next, so a config changed in between can still
20
+ * run a filter. A file a `filter=` attribute names (in the working tree's attributes
21
+ * or the base revision's) is therefore not diffed at all (it is listed like a binary
22
+ * file): without its filter the working tree would read as plaintext added lines. A
23
+ * patch is asked for one file, never a directory. The README lists what is not closed.
24
+ */
25
+ /** Which two trees a diff compares: HEAD against the working tree, or the point
26
+ * this branch left the repo's default branch against the working tree. */
27
+ export type DiffBase = 'head' | 'default';
28
+ export type DiffFailure = 'git_failed' | 'too_large' | 'no_default_branch' | 'bad_path' | 'bad_range';
29
+ export type DiffResult<T> = ({
30
+ ok: true;
31
+ } & T) | {
32
+ ok: false;
33
+ error: DiffFailure;
34
+ };
35
+ export interface DiffFileEntry {
36
+ path: string;
37
+ /** `M` modified (type changes included) · `A` added · `D` deleted · `?` untracked. */
38
+ status: 'M' | 'A' | 'D' | '?';
39
+ added: number;
40
+ removed: number;
41
+ binary?: true;
42
+ }
43
+ /** One page of a patch is at most this many bytes; the listener narrows it when
44
+ * the sealed frame would not fit. */
45
+ export declare const DIFF_PAGE_MAX_BYTES: number;
46
+ /** Most changed files worth listing. A tree past this is not something anyone
47
+ * reviews on a phone; `truncated` says so instead of the list pretending. */
48
+ export declare const DIFF_MAX_FILES = 200;
49
+ /** An untracked file past this is listed without a line count. */
50
+ export declare const UNTRACKED_COUNT_MAX_BYTES: number;
51
+ interface GitRun {
52
+ status: number | null;
53
+ stdout: string;
54
+ stderr: string;
55
+ /** The output outgrew the buffer — `status` is null and `stdout` is cut. */
56
+ tooLarge: boolean;
57
+ }
58
+ /** When a whole request must be done by: its git runs share one budget, so a
59
+ * slow repository costs the listener's event loop `GIT_TIMEOUT_MS` once. */
60
+ export declare const newDeadline: () => number;
61
+ /** Run git in `repo`. No network, no optional index refresh, no file-system
62
+ * monitor (the repo's `core.fsmonitor` is a command git would run), and
63
+ * pathspecs are taken literally. Past `deadline` it does not start. */
64
+ export declare const gitIn: (repo: string, args: string[], deadline?: number) => GitRun;
65
+ /**
66
+ * `-c` options that blank the commands a repository's `filter.<name>.clean` and
67
+ * `.process` name (and drop `.required`, which would turn the blank into an error): `git diff` runs them to read a working-tree file as it would
68
+ * be committed. The names come from the config itself (`git config` only reads),
69
+ * and each goes to git as one argument, never through a shell.
70
+ */
71
+ export declare const filterOverrides: (repo: string, deadline?: number) => string[] | null;
72
+ /**
73
+ * The paths among `paths` that a `filter=` attribute names, read two ways: from the working tree (what git would
74
+ * run on the file now) and from the base revision (what the stored side was made with). Either one naming a
75
+ * filter is enough: a `.gitattributes` edited, appended to or deleted since the base would otherwise show the
76
+ * working tree's plaintext against the base's ciphertext as all added lines. A filter turns the working-tree file
77
+ * into what the repository stores (ciphertext, an LFS pointer), so these files get no patch. null when git does
78
+ * not answer — a git too old for `check-attr --source` (before 2.40) included.
79
+ */
80
+ export declare const filteredPaths: (repo: string, paths: string[], rev: string, deadline?: number) => Set<string> | null;
81
+ /** The repository the directory belongs to, or null when it is not in one. Asked
82
+ * of git rather than guessed from the path. */
83
+ export declare const repoRootOf: (cwd: string) => string | null;
84
+ /**
85
+ * The object id a base compares against, plus its display name.
86
+ *
87
+ * `head` is HEAD, or the empty tree in a repository with no commit yet (every
88
+ * file is then new). `default` is the merge-base of HEAD and the repo's default
89
+ * branch — `origin/HEAD` when the clone has one, else a local `main` / `master`.
90
+ * Nothing is fetched, so a stale remote means a stale base.
91
+ */
92
+ export declare const resolveBase: (root: string, base: DiffBase, deadline?: number) => DiffResult<{
93
+ rev: string;
94
+ baseName?: string;
95
+ }>;
96
+ /** Where the repository's HEAD stands, as far as git answered: detached, or a branch that may follow an
97
+ * upstream, and only then how far apart the two are. The wire carries the fields flat. */
98
+ export type BranchState = {
99
+ detached: true;
100
+ } | {
101
+ head: string;
102
+ } | {
103
+ head: string;
104
+ upstream: string;
105
+ } | {
106
+ head: string;
107
+ upstream: string;
108
+ ahead: number;
109
+ behind: number;
110
+ };
111
+ /**
112
+ * The branch HEAD is on and, when it follows an upstream, how many commits it is ahead of and behind it.
113
+ * Best effort: `undefined` when HEAD cannot be named (git failed, the deadline passed, or the repository has no
114
+ * commit yet), and a step that fails leaves out its field and the ones after it.
115
+ */
116
+ export declare const branchState: (root: string, deadline: number) => BranchState | undefined;
117
+ /**
118
+ * The files changed against `base`: tracked changes (working tree against the
119
+ * base revision, renames reported as a delete plus an add) and new files that
120
+ * git would add — `.gitignore`d ones are not listed.
121
+ */
122
+ export declare const collectDiffFiles: (root: string, base: DiffBase) => DiffResult<{
123
+ files: DiffFileEntry[];
124
+ truncated: boolean;
125
+ baseName?: string;
126
+ branch?: BranchState;
127
+ }>;
128
+ /**
129
+ * One file's patch against `base`, as unified-diff text with secret-looking
130
+ * strings masked (`masked` says how many). The whole patch is masked before it
131
+ * is paged, so a key block that straddles a page boundary does not leak and the
132
+ * byte offsets are the same on every page. A tracked file's
133
+ * patch comes from `git diff`; a file git calls new (untracked and not ignored)
134
+ * is shown as an addition. An ignored file, or one that is neither, is an empty
135
+ * patch — the existence of an ignored file is not revealed.
136
+ */
137
+ export declare const collectFilePatch: (root: string, base: DiffBase, path: string) => DiffResult<{
138
+ text: string;
139
+ masked: number;
140
+ }>;
141
+ export interface PatchPage {
142
+ chunk: string;
143
+ offset: number;
144
+ /** Bytes in `chunk`. */
145
+ length: number;
146
+ hasMore: boolean;
147
+ /** Bytes in the whole patch. */
148
+ total: number;
149
+ /** Identifies the whole patch: a reader paging through it sees the tree
150
+ * change underneath it as a different etag. */
151
+ etag: string;
152
+ }
153
+ /**
154
+ * Cut a page out of `text` by byte offset, ending on a character boundary.
155
+ * Offsets are bytes so they stay stable across pages; a UTF-8 character that
156
+ * would straddle the cut moves whole to the next page, and an offset that
157
+ * lands inside a character is refused.
158
+ */
159
+ export declare const pagePatch: (text: string, offset: number, maxBytes?: number) => DiffResult<PatchPage>;
160
+ export {};
161
+ //# sourceMappingURL=session-diff.d.ts.map
@@ -0,0 +1,445 @@
1
+ "use strict";
2
+ /**
3
+ * Reading what a session changed in its repository: the one place git is run
4
+ * for the phone's diff review.
5
+ *
6
+ * The arguments are constant. The only values that reach git are an object id
7
+ * this module read back from git and checked (`OBJECT_ID`), a ref name git
8
+ * printed that matched `REMOTE_REF` (it only ever goes to `rev-parse`), and one
9
+ * caller path passed after `--` (with `--literal-pathspecs`, so it can only
10
+ * name itself). Nothing here knows about sealing, trust or the wire — the
11
+ * listener decides who may ask; this decides what git is asked.
12
+ *
13
+ * The repository's own configuration is not constant, and git can run commands
14
+ * it names. What this module switches off: `core.fsmonitor` (on every run),
15
+ * external diff drivers, textconv filters and the nesting of submodules (flags
16
+ * on the patch commands), the lazy fetch of a partial clone
17
+ * (`GIT_NO_LAZY_FETCH`), the listener's own `GIT_DIR` and the like (not passed
18
+ * on), and the `clean` / `process` side of every `filter.<name>` the config
19
+ * defines. That last one is a limit, not a guarantee: the names are listed by
20
+ * one git run and used by the next, so a config changed in between can still
21
+ * run a filter. A file a `filter=` attribute names (in the working tree's attributes
22
+ * or the base revision's) is therefore not diffed at all (it is listed like a binary
23
+ * file): without its filter the working tree would read as plaintext added lines. A
24
+ * patch is asked for one file, never a directory. The README lists what is not closed.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.pagePatch = exports.collectFilePatch = exports.collectDiffFiles = exports.branchState = exports.resolveBase = exports.repoRootOf = exports.filteredPaths = exports.filterOverrides = exports.gitIn = exports.newDeadline = exports.UNTRACKED_COUNT_MAX_BYTES = exports.DIFF_MAX_FILES = exports.DIFF_PAGE_MAX_BYTES = void 0;
28
+ const crypto_1 = require("crypto");
29
+ const child_process_1 = require("child_process");
30
+ const fs_1 = require("fs");
31
+ const path_1 = require("path");
32
+ const secret_mask_js_1 = require("./secret-mask.js");
33
+ /** One page of a patch is at most this many bytes; the listener narrows it when
34
+ * the sealed frame would not fit. */
35
+ exports.DIFF_PAGE_MAX_BYTES = 16 * 1024;
36
+ /** Most changed files worth listing. A tree past this is not something anyone
37
+ * reviews on a phone; `truncated` says so instead of the list pretending. */
38
+ exports.DIFF_MAX_FILES = 200;
39
+ /** A git that runs longer than this is hung, not slow: the listener's event loop
40
+ * waits on it (spawnSync). */
41
+ const GIT_TIMEOUT_MS = 10_000;
42
+ /** The largest output read from one git run. Node's default is 1 MiB and a
43
+ * lockfile patch passes it; past this the answer is "too large", not a cut. */
44
+ const GIT_MAX_BUFFER = 8 * 1024 * 1024;
45
+ /** An untracked file past this is listed without a line count. */
46
+ exports.UNTRACKED_COUNT_MAX_BYTES = 1024 * 1024;
47
+ const OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
48
+ /** The one ref name taken from git's output: under refs/remotes/, so it cannot read as a flag. */
49
+ const REMOTE_REF = /^refs\/remotes\/[\w./-]+$/;
50
+ /** When a whole request must be done by: its git runs share one budget, so a
51
+ * slow repository costs the listener's event loop `GIT_TIMEOUT_MS` once. */
52
+ const newDeadline = () => Date.now() + GIT_TIMEOUT_MS;
53
+ exports.newDeadline = newDeadline;
54
+ /** What the listener's own environment must not hand to git: it names the repository, the index, the
55
+ * diff program or extra config, and would send another repository's diff. */
56
+ const INHERITED_GIT_VARIABLE = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|NAMESPACE|EXTERNAL_DIFF|DIFF_OPTS|ATTR_SOURCE|CONFIG|CONFIG_COUNT|CONFIG_PARAMETERS|CONFIG_KEY_\d+|CONFIG_VALUE_\d+)$/;
57
+ /** The environment git runs in: the listener's, without the variables above, and a partial clone
58
+ * asked for a missing object fails instead of running its `remote.<name>.uploadpack`. */
59
+ const gitEnvironment = () => {
60
+ const env = { ...process.env, GIT_NO_LAZY_FETCH: '1' };
61
+ for (const key of Object.keys(env))
62
+ if (INHERITED_GIT_VARIABLE.test(key))
63
+ delete env[key];
64
+ return env;
65
+ };
66
+ /** Run git in `repo`. No network, no optional index refresh, no file-system
67
+ * monitor (the repo's `core.fsmonitor` is a command git would run), and
68
+ * pathspecs are taken literally. Past `deadline` it does not start. */
69
+ const gitIn = (repo, args, deadline = (0, exports.newDeadline)()) => {
70
+ const left = deadline - Date.now();
71
+ if (left <= 0)
72
+ return { status: null, stdout: '', stderr: 'deadline passed', tooLarge: false };
73
+ const r = (0, child_process_1.spawnSync)('git', ['-C', repo, '--no-optional-locks', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], {
74
+ encoding: 'utf-8',
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ env: gitEnvironment(),
77
+ timeout: left,
78
+ maxBuffer: GIT_MAX_BUFFER,
79
+ });
80
+ return {
81
+ status: r.status,
82
+ stdout: r.stdout ?? '',
83
+ stderr: r.stderr ?? '',
84
+ tooLarge: r.error !== undefined && 'code' in r.error && r.error.code === 'ENOBUFS',
85
+ };
86
+ };
87
+ exports.gitIn = gitIn;
88
+ const failure = (run) => ({
89
+ ok: false,
90
+ error: run.tooLarge ? 'too_large' : 'git_failed',
91
+ });
92
+ /**
93
+ * `-c` options that blank the commands a repository's `filter.<name>.clean` and
94
+ * `.process` name (and drop `.required`, which would turn the blank into an error): `git diff` runs them to read a working-tree file as it would
95
+ * be committed. The names come from the config itself (`git config` only reads),
96
+ * and each goes to git as one argument, never through a shell.
97
+ */
98
+ const filterOverrides = (repo, deadline = (0, exports.newDeadline)()) => {
99
+ const listed = (0, exports.gitIn)(repo, ['config', '--name-only', '--get-regexp', '^filter\\.'], deadline);
100
+ // Status 1 is "no such key": nothing to blank. Anything else is git not answering.
101
+ if (listed.status === 1)
102
+ return [];
103
+ if (listed.status !== 0)
104
+ return null;
105
+ const names = new Set();
106
+ for (const key of listed.stdout.split('\n')) {
107
+ const match = /^filter\.(.+)\.[^.]+$/.exec(key.trim());
108
+ // `-c name=value` splits at the first `=`: a driver named with one cannot be blanked.
109
+ if (match && match[1].includes('='))
110
+ return null;
111
+ // `check-attr` prints a driver's name where it prints `set`, `unset` or `unspecified`: such a name cannot be told from the state.
112
+ if (match && ['set', 'unset', 'unspecified'].includes(match[1]))
113
+ return null;
114
+ if (match)
115
+ names.add(match[1]);
116
+ }
117
+ return [...names].flatMap((name) => [
118
+ '-c',
119
+ `filter.${name}.clean=`,
120
+ '-c',
121
+ `filter.${name}.process=`,
122
+ // A required filter that does nothing is an error for git, not a pass.
123
+ '-c',
124
+ `filter.${name}.required=false`,
125
+ ]);
126
+ };
127
+ exports.filterOverrides = filterOverrides;
128
+ /**
129
+ * `$GIT_DIR/info/attributes` outranks every `.gitattributes`, the base revision's included (`check-attr --source`
130
+ * still reads it), so a line there that unsets `filter` hides the filter on both sides. That is not something to
131
+ * guess around: the request is refused. null-safe: a file that is not there or cannot be read says nothing.
132
+ */
133
+ const infoAttributesUnsetFilter = (repo, deadline) => {
134
+ const where = (0, exports.gitIn)(repo, ['rev-parse', '--git-path', 'info/attributes'], deadline).stdout.trim();
135
+ if (!where)
136
+ return false;
137
+ try {
138
+ const file = (0, path_1.isAbsolute)(where) ? where : (0, path_1.join)(repo, where);
139
+ if ((0, fs_1.lstatSync)(file).size > exports.UNTRACKED_COUNT_MAX_BYTES)
140
+ return true;
141
+ return /(?:^|\s)[-!]filter(?:\s|$)/m.test((0, fs_1.readFileSync)(file, 'utf-8'));
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ };
147
+ /**
148
+ * The paths among `paths` that a `filter=` attribute names, read two ways: from the working tree (what git would
149
+ * run on the file now) and from the base revision (what the stored side was made with). Either one naming a
150
+ * filter is enough: a `.gitattributes` edited, appended to or deleted since the base would otherwise show the
151
+ * working tree's plaintext against the base's ciphertext as all added lines. A filter turns the working-tree file
152
+ * into what the repository stores (ciphertext, an LFS pointer), so these files get no patch. null when git does
153
+ * not answer — a git too old for `check-attr --source` (before 2.40) included.
154
+ */
155
+ const filteredPaths = (repo, paths, rev, deadline = (0, exports.newDeadline)()) => {
156
+ const filtered = new Set();
157
+ if (paths.length === 0)
158
+ return filtered;
159
+ if (infoAttributesUnsetFilter(repo, deadline))
160
+ return null;
161
+ for (const source of [[], [`--source=${rev}`]]) {
162
+ const r = (0, exports.gitIn)(repo, ['check-attr', ...source, '-z', 'filter', '--', ...paths], deadline);
163
+ if (r.status !== 0)
164
+ return null;
165
+ // `<path> NUL filter NUL <value> NUL`; the value is the driver's name, or `set`, `unset`, `unspecified`.
166
+ const parts = r.stdout.split('\0');
167
+ for (let i = 0; i + 2 < parts.length; i += 3) {
168
+ if (parts[i + 2] !== 'unspecified' && parts[i + 2] !== 'unset')
169
+ filtered.add(parts[i]);
170
+ }
171
+ }
172
+ return filtered;
173
+ };
174
+ exports.filteredPaths = filteredPaths;
175
+ /** The repository the directory belongs to, or null when it is not in one. Asked
176
+ * of git rather than guessed from the path. */
177
+ const repoRootOf = (cwd) => {
178
+ const r = (0, exports.gitIn)(cwd, ['rev-parse', '--show-toplevel']);
179
+ const root = r.stdout.trim();
180
+ return r.status === 0 && root ? root : null;
181
+ };
182
+ exports.repoRootOf = repoRootOf;
183
+ /** An object id git printed, or null when what it printed is anything else. */
184
+ const objectId = (run) => {
185
+ const id = run.stdout.trim();
186
+ return run.status === 0 && OBJECT_ID.test(id) ? id : null;
187
+ };
188
+ /** `refs/remotes/origin/main` → `main`, `refs/heads/main` → `main`. */
189
+ const shortRefName = (ref) => ref.replace(/^refs\/remotes\/[^/]+\//, '').replace(/^refs\/heads\//, '');
190
+ /**
191
+ * The object id a base compares against, plus its display name.
192
+ *
193
+ * `head` is HEAD, or the empty tree in a repository with no commit yet (every
194
+ * file is then new). `default` is the merge-base of HEAD and the repo's default
195
+ * branch — `origin/HEAD` when the clone has one, else a local `main` / `master`.
196
+ * Nothing is fetched, so a stale remote means a stale base.
197
+ */
198
+ const resolveBase = (root, base, deadline = (0, exports.newDeadline)()) => {
199
+ if (base === 'head') {
200
+ const head = objectId((0, exports.gitIn)(root, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], deadline));
201
+ if (head)
202
+ return { ok: true, rev: head };
203
+ const empty = (0, exports.gitIn)(root, ['hash-object', '-t', 'tree', '/dev/null'], deadline);
204
+ const tree = objectId(empty);
205
+ return tree ? { ok: true, rev: tree } : failure(empty);
206
+ }
207
+ const candidates = [];
208
+ const symbolic = (0, exports.gitIn)(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], deadline).stdout.trim();
209
+ // Only a ref under refs/remotes/ is taken from this output; anything else is
210
+ // not a thing we would hand back to git as an argument.
211
+ if (REMOTE_REF.test(symbolic))
212
+ candidates.push(symbolic);
213
+ candidates.push('refs/heads/main', 'refs/heads/master');
214
+ for (const ref of candidates) {
215
+ const tip = objectId((0, exports.gitIn)(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], deadline));
216
+ if (!tip)
217
+ continue;
218
+ const mergeBase = objectId((0, exports.gitIn)(root, ['merge-base', tip, 'HEAD'], deadline));
219
+ if (mergeBase)
220
+ return { ok: true, rev: mergeBase, baseName: shortRefName(ref) };
221
+ }
222
+ // A git that ran out of time answered nothing; that is not "no default branch".
223
+ if (Date.now() >= deadline)
224
+ return { ok: false, error: 'git_failed' };
225
+ return { ok: false, error: 'no_default_branch' };
226
+ };
227
+ exports.resolveBase = resolveBase;
228
+ /** On every `git diff`: no external diff program, no textconv filter, and a repository nested in this one is not entered. */
229
+ const DIFF_FLAGS = ['--no-ext-diff', '--no-textconv', '--ignore-submodules=all'];
230
+ const STATUS_LETTERS = { M: 'M', A: 'A', D: 'D' };
231
+ /** `git diff --name-status -z` → path → status. A type change reads as modified. */
232
+ const parseNameStatus = (out) => {
233
+ const parts = out.split('\0');
234
+ const rows = [];
235
+ for (let i = 0; i + 1 < parts.length; i += 2) {
236
+ if (!parts[i + 1])
237
+ continue;
238
+ rows.push({ path: parts[i + 1], status: STATUS_LETTERS[parts[i][0]] ?? 'M' });
239
+ }
240
+ return rows;
241
+ };
242
+ /** `git diff --numstat -z` → path → counts. A binary file reports `-` twice. */
243
+ const parseNumstat = (out) => {
244
+ const counts = new Map();
245
+ for (const record of out.split('\0')) {
246
+ const first = record.indexOf('\t');
247
+ const second = record.indexOf('\t', first + 1);
248
+ if (first < 0 || second < 0)
249
+ continue;
250
+ const added = record.slice(0, first);
251
+ const removed = record.slice(first + 1, second);
252
+ const path = record.slice(second + 1);
253
+ if (!path)
254
+ continue;
255
+ counts.set(path, {
256
+ added: Number.isInteger(Number(added)) ? Number(added) : 0,
257
+ removed: Number.isInteger(Number(removed)) ? Number(removed) : 0,
258
+ binary: added === '-' && removed === '-',
259
+ });
260
+ }
261
+ return counts;
262
+ };
263
+ /** Line count and binary-ness of an untracked file, without a git per file. A
264
+ * symlink or an oversized file is listed with no count rather than followed. */
265
+ const untrackedCounts = (root, path) => {
266
+ try {
267
+ const full = (0, path_1.join)(root, path);
268
+ const stat = (0, fs_1.lstatSync)(full);
269
+ if (!stat.isFile() || stat.size > exports.UNTRACKED_COUNT_MAX_BYTES)
270
+ return { added: 0, removed: 0 };
271
+ const data = (0, fs_1.readFileSync)(full);
272
+ if (data.includes(0))
273
+ return { added: 0, removed: 0, binary: true };
274
+ let lines = 0;
275
+ for (const b of data)
276
+ if (b === 0x0a)
277
+ lines++;
278
+ if (data.length > 0 && data[data.length - 1] !== 0x0a)
279
+ lines++;
280
+ return { added: lines, removed: 0 };
281
+ }
282
+ catch {
283
+ return { added: 0, removed: 0 };
284
+ }
285
+ };
286
+ /**
287
+ * The branch HEAD is on and, when it follows an upstream, how many commits it is ahead of and behind it.
288
+ * Best effort: `undefined` when HEAD cannot be named (git failed, the deadline passed, or the repository has no
289
+ * commit yet), and a step that fails leaves out its field and the ones after it.
290
+ */
291
+ const branchState = (root, deadline) => {
292
+ const head = (0, exports.gitIn)(root, ['rev-parse', '--abbrev-ref', 'HEAD'], deadline);
293
+ const name = head.stdout.trim();
294
+ if (head.status !== 0 || !name)
295
+ return undefined;
296
+ if (name === 'HEAD')
297
+ return { detached: true };
298
+ const upstream = (0, exports.gitIn)(root, ['rev-parse', '--abbrev-ref', '@{upstream}'], deadline);
299
+ const upstreamName = upstream.stdout.trim();
300
+ if (upstream.status !== 0 || !upstreamName)
301
+ return { head: name };
302
+ const counts = (0, exports.gitIn)(root, ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], deadline);
303
+ const match = /^(\d+)\s+(\d+)$/.exec(counts.stdout.trim());
304
+ if (counts.status !== 0 || !match)
305
+ return { head: name, upstream: upstreamName };
306
+ return { head: name, upstream: upstreamName, ahead: Number(match[1]), behind: Number(match[2]) };
307
+ };
308
+ exports.branchState = branchState;
309
+ /**
310
+ * The files changed against `base`: tracked changes (working tree against the
311
+ * base revision, renames reported as a delete plus an add) and new files that
312
+ * git would add — `.gitignore`d ones are not listed.
313
+ */
314
+ const collectDiffFiles = (root, base) => {
315
+ const deadline = (0, exports.newDeadline)();
316
+ const resolved = (0, exports.resolveBase)(root, base, deadline);
317
+ if (!resolved.ok)
318
+ return resolved;
319
+ const safe = (0, exports.filterOverrides)(root, deadline);
320
+ if (!safe)
321
+ return { ok: false, error: 'git_failed' };
322
+ const names = (0, exports.gitIn)(root, [...safe, 'diff', '--name-status', '-z', '--no-renames', ...DIFF_FLAGS, resolved.rev], deadline);
323
+ if (names.status !== 0)
324
+ return failure(names);
325
+ const nums = (0, exports.gitIn)(root, [...safe, 'diff', '--numstat', '-z', '--no-renames', ...DIFF_FLAGS, resolved.rev], deadline);
326
+ if (nums.status !== 0)
327
+ return failure(nums);
328
+ const others = (0, exports.gitIn)(root, ['ls-files', '--others', '--exclude-standard', '-z'], deadline);
329
+ if (others.status !== 0)
330
+ return failure(others);
331
+ const counts = parseNumstat(nums.stdout);
332
+ const entries = parseNameStatus(names.stdout).map(({ path, status }) => {
333
+ const c = counts.get(path);
334
+ return { path, status, added: c?.added ?? 0, removed: c?.removed ?? 0, ...(c?.binary ? { binary: true } : {}) };
335
+ });
336
+ const tracked = new Set(entries.map((e) => e.path));
337
+ const untracked = others.stdout.split('\0').filter((p) => p && !tracked.has(p));
338
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
339
+ // Untracked paths are counted only for the ones that survive the cap: a tree
340
+ // with thousands of new files would otherwise read thousands of them.
341
+ const room = Math.max(0, exports.DIFF_MAX_FILES - entries.length);
342
+ for (const path of untracked.slice(0, room))
343
+ entries.push({ path, status: '?', ...untrackedCounts(root, path) });
344
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
345
+ const total = parseNameStatus(names.stdout).length + untracked.length;
346
+ const shown = entries.slice(0, exports.DIFF_MAX_FILES);
347
+ const filtered = (0, exports.filteredPaths)(root, shown.map((e) => e.path), resolved.rev, deadline);
348
+ if (!filtered)
349
+ return { ok: false, error: 'git_failed' };
350
+ // Last, on what is left of the same budget: a slow `rev-list` costs the band, never the list.
351
+ const branch = (0, exports.branchState)(root, deadline);
352
+ return {
353
+ ok: true,
354
+ // A file under a filter has no patch to show, which is what a binary file looks like on the wire.
355
+ files: shown.map((e) => (filtered.has(e.path) ? { path: e.path, status: e.status, added: 0, removed: 0, binary: true } : e)),
356
+ truncated: total > exports.DIFF_MAX_FILES,
357
+ ...(resolved.baseName ? { baseName: resolved.baseName } : {}),
358
+ ...(branch ? { branch } : {}),
359
+ };
360
+ };
361
+ exports.collectDiffFiles = collectDiffFiles;
362
+ /** A path git can be handed after `--`: non-empty, no NUL, repo-relative. A
363
+ * leading `-` is fine there — after `--` git reads it as a name. */
364
+ const isPlainRelativePath = (path) => path !== '' && !path.includes('\0') && !path.startsWith('/');
365
+ /**
366
+ * One file's patch against `base`, as unified-diff text with secret-looking
367
+ * strings masked (`masked` says how many). The whole patch is masked before it
368
+ * is paged, so a key block that straddles a page boundary does not leak and the
369
+ * byte offsets are the same on every page. A tracked file's
370
+ * patch comes from `git diff`; a file git calls new (untracked and not ignored)
371
+ * is shown as an addition. An ignored file, or one that is neither, is an empty
372
+ * patch — the existence of an ignored file is not revealed.
373
+ */
374
+ const collectFilePatch = (root, base, path) => {
375
+ if (!isPlainRelativePath(path))
376
+ return { ok: false, error: 'bad_path' };
377
+ const deadline = (0, exports.newDeadline)();
378
+ const resolved = (0, exports.resolveBase)(root, base, deadline);
379
+ if (!resolved.ok)
380
+ return resolved;
381
+ const safe = (0, exports.filterOverrides)(root, deadline);
382
+ if (!safe)
383
+ return { ok: false, error: 'git_failed' };
384
+ // One file: a path git would diff as a directory, a pattern or a spelling of another path names files whose
385
+ // filters nobody asked about. What git would diff for it must be that one path, or nothing.
386
+ const named = (0, exports.gitIn)(root, [...safe, 'diff', '--name-only', '-z', '--no-renames', ...DIFF_FLAGS, resolved.rev, '--', path], deadline);
387
+ if (named.status !== 0)
388
+ return failure(named);
389
+ const names = named.stdout.split('\0').filter(Boolean);
390
+ if (names.length > 0 && !(names.length === 1 && names[0] === path))
391
+ return { ok: false, error: 'bad_path' };
392
+ const filtered = (0, exports.filteredPaths)(root, [path], resolved.rev, deadline);
393
+ if (!filtered)
394
+ return { ok: false, error: 'git_failed' };
395
+ if (filtered.size > 0)
396
+ return { ok: true, text: '', masked: 0 };
397
+ const flags = ['--no-color', ...DIFF_FLAGS];
398
+ const tracked = (0, exports.gitIn)(root, [...safe, 'diff', ...flags, '--no-renames', resolved.rev, '--', path], deadline);
399
+ if (tracked.status !== 0)
400
+ return failure(tracked);
401
+ if (tracked.stdout)
402
+ return { ok: true, ...(0, secret_mask_js_1.maskSecrets)(tracked.stdout) };
403
+ const listed = (0, exports.gitIn)(root, ['ls-files', '--others', '--exclude-standard', '-z', '--', path], deadline);
404
+ if (listed.status !== 0)
405
+ return failure(listed);
406
+ if (listed.stdout.split('\0').filter(Boolean).join('\0') !== path)
407
+ return { ok: true, text: '', masked: 0 };
408
+ // `--no-index` exits 1 when the files differ, which is the normal case here.
409
+ const added = (0, exports.gitIn)(root, [...safe, 'diff', ...flags, '--no-index', '--', '/dev/null', path], deadline);
410
+ if (added.status !== 0 && added.status !== 1)
411
+ return failure(added);
412
+ return { ok: true, ...(0, secret_mask_js_1.maskSecrets)(added.stdout) };
413
+ };
414
+ exports.collectFilePatch = collectFilePatch;
415
+ /**
416
+ * Cut a page out of `text` by byte offset, ending on a character boundary.
417
+ * Offsets are bytes so they stay stable across pages; a UTF-8 character that
418
+ * would straddle the cut moves whole to the next page, and an offset that
419
+ * lands inside a character is refused.
420
+ */
421
+ const pagePatch = (text, offset, maxBytes = exports.DIFF_PAGE_MAX_BYTES) => {
422
+ const buf = Buffer.from(text, 'utf-8');
423
+ const total = buf.length;
424
+ if (!Number.isInteger(offset) || offset < 0 || offset > total)
425
+ return { ok: false, error: 'bad_range' };
426
+ const isContinuation = (i) => (buf[i] & 0xc0) === 0x80;
427
+ if (offset < total && isContinuation(offset))
428
+ return { ok: false, error: 'bad_range' };
429
+ let end = Math.min(offset + maxBytes, total);
430
+ while (end > offset && end < total && isContinuation(end))
431
+ end--;
432
+ // A page too small to hold one character would never advance.
433
+ if (end === offset && offset < total)
434
+ return { ok: false, error: 'bad_range' };
435
+ return {
436
+ ok: true,
437
+ chunk: buf.subarray(offset, end).toString('utf-8'),
438
+ offset,
439
+ length: end - offset,
440
+ hasMore: end < total,
441
+ total,
442
+ etag: (0, crypto_1.createHash)('sha256').update(buf).digest('hex').slice(0, 16),
443
+ };
444
+ };
445
+ exports.pagePatch = pagePatch;