@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.
- package/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- 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;
|