Skip to main content

blit_remote/
git.rs

1//! Git introspection wire protocol (docs/git.md).
2//!
3//! Mutable, small repository state — HEAD, refs, in-progress operation,
4//! index/worktree status — is *pushed* as whole-snapshot `GIT_STATE`
5//! messages the client applies by replacement ([`GitStateMirror`]).
6//! Immutable, large content — commits, trees, blobs, diffs, patches — is
7//! *pulled* by content address through nonce-correlated request/response
8//! pairs that share an opcode value across directions.
9//!
10//! All integers little-endian, tightly packed, as everywhere in the protocol.
11
12use std::collections::BTreeMap;
13
14/// `S2C_HELLO` feature bit: server supports the `GIT_*` message family.
15pub const FEATURE_GIT: u32 = 1 << 7;
16
17// Opcodes: one contiguous block, `0xA0`-`0xB4`, grouped by role —
18// lifecycle, pushed state, revision and log, object reads, then the
19// repository-wide operations. Request and response share a value where
20// they pair. `0xB5`-`0xBF` are reserved for the family's next additions.
21
22// C2S opcodes.
23
24/// Open (discover) a repository: [0xA0][nonce:2][flags:1][refs_latency_ms:2][status_latency_ms:2][path_len:2][path:N]
25/// `path` is plain UTF-8 (client-chosen filesystem location, like `FS_SYNC`).
26pub const C2S_GIT_OPEN: u8 = 0xA0;
27/// Release a repo id: [0xA1][repo_id:2]
28pub const C2S_GIT_CLOSE: u8 = 0xA1;
29/// Acknowledge a state snapshot: [0xA2][repo_id:2][state_id:4]
30pub const C2S_GIT_ACK: u8 = 0xA2;
31/// Walk `hides..tips`: [0xA7][nonce:2][repo_id:2][flags:1][limit:2][path_len:2][path:N][n_tips:2][tips:32·N][n_hides:2][hides:32·N]
32pub const C2S_GIT_LOG: u8 = 0xA7;
33/// List one tree level: [0xAB][nonce:2][repo_id:2][oid:32][path_len:2][path:N]
34pub const C2S_GIT_TREE: u8 = 0xAB;
35/// Fetch object bytes: [0xAC][nonce:2][repo_id:2][oid:32][path_len:2][path:N][max_len:4]
36pub const C2S_GIT_BLOB: u8 = 0xAC;
37/// File-level diff between two endpoints: [0xAD][nonce:2][repo_id:2][flags:1][old_kind:1][old:32][new_kind:1][new:32][path_len:2][path:N]
38pub const C2S_GIT_DIFF: u8 = 0xAD;
39/// Render-ready patch rows: [0xAE][nonce:2][repo_id:2][flags:1][context:1][old_kind:1][old:32][new_kind:1][new:32][path_len:2][path:N][max_len:4]
40pub const C2S_GIT_PATCH: u8 = 0xAE;
41/// Enumerate index entries: [0xAF][nonce:2][repo_id:2][path_len:2][path:N]
42pub const C2S_GIT_INDEX: u8 = 0xAF;
43/// Advisory cancel of an in-flight request: [0xA3][nonce:2]
44pub const C2S_GIT_CANCEL: u8 = 0xA3;
45/// Merge bases of an oid set: [0xB0][nonce:2][repo_id:2][n_oids:1][oids:32·N]
46pub const C2S_GIT_BASE: u8 = 0xB0;
47/// Resolve a revision spec to commit oids: [0xA6][nonce:2][repo_id:2][spec_len:2][spec:N]
48/// `spec` is any git revision expression — a ref name, (short) oid,
49/// `HEAD~3`, or a range `A..B` / `A...B`. The response gives `tips`/`hides`
50/// ready to feed [`msg_git_log`].
51pub const C2S_GIT_RESOLVE: u8 = 0xA6;
52/// Subscribe to a live log of a spec: [0xA8][log_id:2][repo_id:2][flags:1][limit:2][spec_len:2][spec:N]
53/// The server resolves `spec` and pushes a `GIT_LOG_PAGE`, re-emitting
54/// whenever the resolved endpoints move (a ref the spec names changes).
55/// `log_id` is a client-assigned subscription id (unique per connection).
56/// `flags` are the same `GIT_LOG_*` bits.
57pub const C2S_GIT_LOG_WATCH: u8 = 0xA8;
58/// End a log subscription: [0xA9][log_id:2][repo_id:2]
59pub const C2S_GIT_LOG_UNWATCH: u8 = 0xA9;
60/// Acknowledge a log page (coalescing pacing): [0xAA][log_id:2][repo_id:2][update_id:4]
61pub const C2S_GIT_LOG_ACK: u8 = 0xAA;
62
63/// Enumerate repositories under a path: [0xB1][nonce:2][flags:1][depth:1][path_len:2][path:N][after_len:2][after:N]
64/// A bounded downward search. Allocates no repo ids — it answers "what is
65/// here", not "give me a handle".
66pub const C2S_GIT_DISCOVER: u8 = 0xB1;
67/// Line attribution: [0xB2][nonce:2][repo_id:2][flags:1][oid:32][start_line:4][line_count:4][path_len:2][path:N][after_len:2][after:N]
68pub const C2S_GIT_BLAME: u8 = 0xB2;
69/// Reflog traversal: [0xB3][nonce:2][repo_id:2][flags:1][limit:2][ref_len:2][ref:N][after_len:2][after:N]
70/// `ref` empty = HEAD.
71pub const C2S_GIT_REFLOG: u8 = 0xB3;
72/// Fetch from a remote: [0xB4][nonce:2][repo_id:2][flags:1][timeout_ms:4][remote_len:2][remote:N][n_refspecs:2][(len:2, refspec:N)·N]
73pub const C2S_GIT_FETCH: u8 = 0xB4;
74
75// S2C opcodes.
76
77/// Open outcome: [0xA0][nonce:2][repo_id:2][status:1][oid_format:1][flags:1][workdir_len:2][workdir:N][gitdir_len:2][gitdir:N]
78/// On failure `repo_id` = [`GIT_REPO_ID_INVALID`] and `workdir` carries a
79/// diagnostic; on success both paths are canonical, escaped.
80pub const S2C_GIT_REPO: u8 = 0xA0;
81/// Whole-state snapshot: [0xA4][repo_id:2][state_id:4][flags:1][records:LZ4]
82pub const S2C_GIT_STATE: u8 = 0xA4;
83/// Repo ended server-side: [0xA5][repo_id:2][reason:1]
84pub const S2C_GIT_CLOSED: u8 = 0xA5;
85/// Log response: [0xA7][nonce:2][status:1][flags:1][n_frontier:2][frontier:32·N][records:LZ4]
86pub const S2C_GIT_COMMITS: u8 = 0xA7;
87/// Tree response: [0xAB][nonce:2][status:1][flags:1][records:LZ4]
88pub const S2C_GIT_TREE: u8 = 0xAB;
89/// Blob response: [0xAC][nonce:2][status:1][size:8][data:LZ4]
90/// `size` is always the true object size, even on `TOO_LARGE`.
91pub const S2C_GIT_BLOB: u8 = 0xAC;
92/// Diff response: [0xAD][nonce:2][status:1][flags:1][records:LZ4]
93pub const S2C_GIT_DIFF: u8 = 0xAD;
94/// Patch response: [0xAE][nonce:2][status:1][flags:1][data:LZ4]
95/// `data` is records when `STRUCTURED`, else a classic unified diff.
96pub const S2C_GIT_PATCH: u8 = 0xAE;
97/// Index response: [0xAF][nonce:2][status:1][flags:1][records:LZ4]
98pub const S2C_GIT_INDEX: u8 = 0xAF;
99/// Merge-base response: [0xB0][nonce:2][status:1][n_bases:1][bases:32·N]
100pub const S2C_GIT_BASE: u8 = 0xB0;
101/// Resolve response: [0xA6][nonce:2][status:1][n_tips:2][tips:32·N][n_hides:2][hides:32·N]
102pub const S2C_GIT_RESOLVE: u8 = 0xA6;
103/// Live log page: [0xA8][log_id:2][update_id:4][status:1][flags:1][n_frontier:2][frontier:32·N][records:LZ4]
104/// Same records as `GIT_COMMITS`; re-sent (coalesced, acked) when the
105/// subscription's resolved endpoints move. `flags` bit 0 `MORE` marks a
106/// truncated head page — pull older history statelessly with `GIT_LOG`
107/// from `frontier`.
108pub const S2C_GIT_LOG_PAGE: u8 = 0xA8;
109
110/// Discovery response: [0xB1][nonce:2][status:1][flags:1][records:LZ4]
111pub const S2C_GIT_DISCOVER: u8 = 0xB1;
112/// Blame response: [0xB2][nonce:2][status:1][flags:1][records:LZ4]
113pub const S2C_GIT_BLAME: u8 = 0xB2;
114/// Reflog response: [0xB3][nonce:2][status:1][flags:1][records:LZ4]
115pub const S2C_GIT_REFLOG: u8 = 0xB3;
116/// Fetch response: [0xB4][nonce:2][status:1][flags:1][records:LZ4]
117pub const S2C_GIT_FETCH: u8 = 0xB4;
118
119// Unified status table: every `status` byte in the family (docs/git.md
120// "Statuses"). Codes 0-4 coincide with `FS_SYNCED`'s where semantics overlap.
121pub const GIT_STATUS_OK: u8 = 0;
122/// `repo_id` unknown or already closed.
123pub const GIT_STATUS_UNKNOWN_ID: u8 = 1;
124/// Path or object does not exist.
125pub const GIT_STATUS_NOT_FOUND: u8 = 2;
126/// Object is not what the request requires.
127pub const GIT_STATUS_WRONG_TYPE: u8 = 3;
128pub const GIT_STATUS_PERMISSION: u8 = 4;
129/// Over `max_len` or a size cap; size fields still carry truth.
130pub const GIT_STATUS_TOO_LARGE: u8 = 5;
131/// A budget was exhausted with no way to paginate or truncate.
132pub const GIT_STATUS_BUDGET: u8 = 6;
133/// Malformed request (unknown flags, bad endpoint combination).
134pub const GIT_STATUS_INVALID: u8 = 7;
135/// Ended by `GIT_CANCEL`.
136pub const GIT_STATUS_CANCELLED: u8 = 8;
137/// Diagnostic in the message's detail field where it has one.
138pub const GIT_STATUS_OTHER: u8 = 9;
139/// A precondition failed (a lock was held, or the repository moved under a
140/// request). Shares docs/design/fs-write.md's code rather than minting a
141/// synonym, so a client's status mapping stays one table.
142pub const GIT_STATUS_CONFLICT: u8 = 11;
143/// A MERGE_BASE endpoint over histories with no common ancestor. Distinct
144/// from `INVALID`: the request is well-formed, the repository just has no
145/// such base, and a client can say so instead of blaming itself.
146pub const GIT_STATUS_NO_MERGE_BASE: u8 = 12;
147
148/// Human-readable name for a `GIT_STATUS_*` code. Total: `OTHER` and an
149/// unrecognized code are distinct, so a consumer that logs this text can
150/// tell "the backend failed" from "this build does not know that code".
151pub fn git_status_text(status: u8) -> &'static str {
152    match status {
153        GIT_STATUS_OK => "ok",
154        GIT_STATUS_UNKNOWN_ID => "unknown repo",
155        GIT_STATUS_NOT_FOUND => "not found",
156        GIT_STATUS_WRONG_TYPE => "wrong object type",
157        GIT_STATUS_PERMISSION => "permission denied",
158        GIT_STATUS_TOO_LARGE => "too large",
159        GIT_STATUS_BUDGET => "budget exhausted",
160        GIT_STATUS_INVALID => "invalid request",
161        GIT_STATUS_CANCELLED => "cancelled",
162        GIT_STATUS_OTHER => "backend error",
163        GIT_STATUS_CONFLICT => "conflict",
164        GIT_STATUS_NO_MERGE_BASE => "no merge base",
165        _ => "unknown status",
166    }
167}
168
169// C2S_GIT_OPEN flags (u16: the byte was full, and `src_pty_id` /
170// `parent_repo_id` are plain fields now rather than flag-gated tails).
171/// Stream `GIT_STATE`.
172pub const GIT_OPEN_WATCH: u16 = 1 << 0;
173/// Include index/worktree status records in state; implies `WATCH`.
174pub const GIT_OPEN_STATUS: u16 = 1 << 1;
175/// Status includes untracked files.
176pub const GIT_OPEN_UNTRACKED: u16 = 1 << 2;
177/// Status includes ignored files; implies `UNTRACKED`.
178pub const GIT_OPEN_IGNORED: u16 = 1 << 3;
179/// Include per-branch upstream records in state; implies `WATCH`.
180pub const GIT_OPEN_TRACKING: u16 = 1 << 4;
181/// Include one `STATE_REMOTE` record per configured remote; implies
182/// `WATCH`. URLs go out as configured.
183pub const GIT_OPEN_REMOTES: u16 = 1 << 5;
184/// Every flag this build understands; anything else is `INVALID`.
185pub const GIT_OPEN_KNOWN: u16 = GIT_OPEN_WATCH
186    | GIT_OPEN_STATUS
187    | GIT_OPEN_UNTRACKED
188    | GIT_OPEN_IGNORED
189    | GIT_OPEN_TRACKING
190    | GIT_OPEN_REMOTES;
191
192/// `repo_id` reported by a failed `GIT_REPO`, and the "no context" sentinel
193/// for `GIT_OPEN`'s `src_pty_id` / `parent_repo_id`.
194pub const GIT_REPO_ID_INVALID: u16 = 0xFFFF;
195/// `GIT_OPEN.src_pty_id` / `parent_repo_id`: no such context.
196pub const GIT_OPEN_NO_CONTEXT: u16 = 0xFFFF;
197
198// S2C_GIT_REPO oid_format: the repository hash width; oids on the wire are
199// always 32 bytes, zero-padded past it.
200/// SHA-1: 20 bytes used.
201pub const GIT_OID_FORMAT_SHA1: u8 = 0;
202/// SHA-256: all 32 bytes used.
203pub const GIT_OID_FORMAT_SHA256: u8 = 1;
204
205// S2C_GIT_REPO flags.
206pub const GIT_REPO_BARE: u8 = 1 << 0;
207pub const GIT_REPO_SHALLOW: u8 = 1 << 1;
208/// Sparse-checkout active.
209pub const GIT_REPO_SPARSE: u8 = 1 << 2;
210/// Linked worktree.
211pub const GIT_REPO_LINKED: u8 = 1 << 3;
212/// Reserved for the mutation family (docs/design/git.md "Mutation"); clear
213/// in this build, and clear whenever `BLIT_GIT_WRITE=0`.
214pub const GIT_REPO_WRITABLE: u8 = 1 << 4;
215/// `GIT_FETCH` will be attempted for this repo: fetch is enabled
216/// (`BLIT_GIT_FETCH`) and a `git` binary was found. Capability is answered
217/// per repository rather than per connection because it can differ per
218/// repository.
219pub const GIT_REPO_FETCHABLE: u8 = 1 << 5;
220
221// S2C_GIT_CLOSED reasons.
222pub const GIT_CLOSED_CLIENT_REQUEST: u8 = 0;
223pub const GIT_CLOSED_REPO_GONE: u8 = 1;
224pub const GIT_CLOSED_PERMISSION_LOST: u8 = 2;
225pub const GIT_CLOSED_BACKEND_FAILED: u8 = 3;
226pub const GIT_CLOSED_RESOURCE_LIMIT: u8 = 4;
227
228// S2C_GIT_STATE flags: entry budget hit; counts accurate up to the cap.
229pub const GIT_STATE_REFS_TRUNCATED: u8 = 1 << 0;
230pub const GIT_STATE_STATUS_TRUNCATED: u8 = 1 << 1;
231/// More records for this `state_id` follow. A snapshot past the per-message
232/// byte budget spans several `GIT_STATE` messages sharing one `state_id`;
233/// the client accumulates and replaces its map on the chunk with this bit
234/// clear, so it never observes a half-built snapshot. Only the final chunk
235/// is acked, so the one-in-flight pacing is unchanged.
236pub const GIT_STATE_PARTIAL: u8 = 1 << 2;
237
238// C2S_GIT_LOG flags.
239pub const GIT_LOG_FIRST_PARENT: u8 = 1 << 0;
240/// Topological order; default committer-date.
241pub const GIT_LOG_TOPO: u8 = 1 << 1;
242/// Full commit message; default first line only.
243pub const GIT_LOG_FULL_MESSAGE: u8 = 1 << 2;
244/// `path` must name a single file; the walk tracks it across renames.
245pub const GIT_LOG_FOLLOW: u8 = 1 << 3;
246/// After each commit, emit the object at the rename-adjusted `path`.
247pub const GIT_LOG_PATH_OIDS: u8 = 1 << 4;
248
249// S2C_GIT_COMMITS flags.
250/// Partial page; continue with `tips = frontier` and the same `hides`.
251pub const GIT_COMMITS_MORE: u8 = 1 << 0;
252
253// C2S_GIT_BLOB request flags.
254/// Deliver the whole object or answer `TOO_LARGE`. Without it a request is
255/// a window: the server returns what fits from `offset` and `size` still
256/// carries the true object size, so a viewer can render the head of a file
257/// too large to ship whole.
258pub const GIT_BLOB_WHOLE: u8 = 1 << 0;
259
260// C2S_GIT_DIFF request flags; C2S_GIT_PATCH shares bits 0-5.
261/// Rename/copy detection. The `rename` byte carries the similarity
262/// threshold; this bit alone means the exact-oid join (threshold 100).
263pub const GIT_DIFF_RENAMES: u8 = 1 << 0;
264/// Worktree endpoint reports untracked files as additions.
265pub const GIT_DIFF_UNTRACKED: u8 = 1 << 1;
266pub const GIT_DIFF_IGNORED: u8 = 1 << 2;
267/// Runs of whitespace compare equal, trailing whitespace ignored (git `-b`).
268pub const GIT_DIFF_IGNORE_SPACE_CHANGE: u8 = 1 << 3;
269/// Whitespace ignored entirely (git `-w`).
270pub const GIT_DIFF_IGNORE_ALL_SPACE: u8 = 1 << 4;
271/// Compare on-disk bytes as they are: skip the `text`/`eol` normalization
272/// the worktree side gets by default from the path's gitattributes.
273pub const GIT_DIFF_RAW: u8 = 1 << 5;
274
275// C2S_GIT_PATCH request flags: a u16 whose low six bits are exactly
276// `GIT_DIFF`'s, so one shared prefix rather than two numberings.
277pub const GIT_PATCH_RENAMES: u16 = GIT_DIFF_RENAMES as u16;
278pub const GIT_PATCH_UNTRACKED: u16 = GIT_DIFF_UNTRACKED as u16;
279pub const GIT_PATCH_IGNORED: u16 = GIT_DIFF_IGNORED as u16;
280pub const GIT_PATCH_IGNORE_SPACE_CHANGE: u16 = GIT_DIFF_IGNORE_SPACE_CHANGE as u16;
281pub const GIT_PATCH_IGNORE_ALL_SPACE: u16 = GIT_DIFF_IGNORE_ALL_SPACE as u16;
282pub const GIT_PATCH_RAW: u16 = GIT_DIFF_RAW as u16;
283/// Classic unified diff as raw `data` instead of records.
284pub const GIT_PATCH_TEXT: u16 = 1 << 6;
285/// Character-granularity spans instead of the default word granularity.
286pub const GIT_PATCH_CHAR_SPANS: u16 = 1 << 7;
287/// Skip intraline refinement entirely, for whole-line renderers.
288pub const GIT_PATCH_NO_SPANS: u16 = 1 << 8;
289/// With `TEXT`, emit binary content as git's `GIT binary patch` block
290/// instead of the `Binary files … differ` sentence — git's `--binary`,
291/// under git's own rule that it is asked for rather than assumed. A patch
292/// carrying a 40 MiB PNG is not what a review surface wants by default,
293/// and it is the difference between a patch `git apply` can replay and one
294/// it refuses.
295pub const GIT_PATCH_BINARY: u16 = 1 << 9;
296
297/// The similarity threshold a `rename` byte may carry: `0` is the exact-oid
298/// join, `1..=100` a percentage, anything above is `INVALID`.
299pub const GIT_RENAME_MAX: u8 = 100;
300
301// Response flags: S2C_GIT_TREE / S2C_GIT_DIFF / S2C_GIT_INDEX bit 0 is the
302// entry-budget truncation marker; S2C_GIT_PATCH uses bit 0 for payload form.
303// A truncated response carries a trailing `CURSOR` record unless it is
304// genuinely unresumable.
305pub const GIT_TREE_TRUNCATED: u8 = 1 << 0;
306pub const GIT_DIFF_TRUNCATED: u8 = 1 << 0;
307/// Similarity rename detection was skipped: the unmatched add/delete
308/// candidate set exceeded `BLIT_GIT_RENAME_LIMIT`, so the response fell
309/// back to the exact-oid join. Renders as "rename detection skipped"
310/// instead of unexplained delete+add pairs.
311pub const GIT_DIFF_RENAME_LIMIT: u8 = 1 << 1;
312pub const GIT_INDEX_TRUNCATED: u8 = 1 << 0;
313/// `data` is records (the default); clear = classic unified diff text.
314pub const GIT_PATCH_STRUCTURED: u8 = 1 << 0;
315pub const GIT_PATCH_TRUNCATED: u8 = 1 << 1;
316pub const GIT_DISCOVER_TRUNCATED: u8 = 1 << 0;
317pub const GIT_BLAME_TRUNCATED: u8 = 1 << 0;
318pub const GIT_REFLOG_TRUNCATED: u8 = 1 << 0;
319
320// C2S_GIT_DISCOVER request flags.
321/// Descend into a repository once one is found (off by default, so a tree
322/// of vendored checkouts costs nothing).
323pub const GIT_DISCOVER_NESTED: u8 = 1 << 0;
324/// Report bare repositories too.
325pub const GIT_DISCOVER_BARE: u8 = 1 << 1;
326
327// C2S_GIT_BLAME request flags.
328/// Follow renames (git's `-M`).
329pub const GIT_BLAME_FOLLOW_RENAMES: u8 = 1 << 0;
330/// Follow copies (git's `-C`); materially more expensive.
331pub const GIT_BLAME_FOLLOW_COPIES: u8 = 1 << 1;
332
333// C2S_GIT_REFLOG request flags.
334/// Oldest entry first; default is newest-first, matching `git reflog`.
335pub const GIT_REFLOG_OLDEST_FIRST: u8 = 1 << 0;
336
337// C2S_GIT_FETCH request flags.
338pub const GIT_FETCH_PRUNE: u8 = 1 << 0;
339pub const GIT_FETCH_NO_TAGS: u8 = 1 << 1;
340/// Anchor every fetched tip under `refs/blit/fetch/<remote>/<n>` so a
341/// concurrent `gc` cannot prune it before the client diffs it.
342pub const GIT_FETCH_ANCHOR: u8 = 1 << 2;
343
344// Diff/patch endpoint kinds (docs/git.md "GIT_DIFF").
345pub const GIT_ENDPOINT_EMPTY: u8 = 0;
346pub const GIT_ENDPOINT_COMMIT: u8 = 1;
347pub const GIT_ENDPOINT_TREE: u8 = 2;
348pub const GIT_ENDPOINT_INDEX: u8 = 3;
349pub const GIT_ENDPOINT_WORKTREE: u8 = 4;
350/// Old side only: the server substitutes `merge-base(oid, new)`, reading
351/// the new side as HEAD when it is INDEX or WORKTREE.
352pub const GIT_ENDPOINT_MERGE_BASE: u8 = 5;
353
354/// Reserved family-wide in every records payload: the continuation point of
355/// a response a budget cut short. `TRUNCATED` set with no `CURSOR` record
356/// means the response is genuinely unresumable.
357pub const GIT_RECORD_CURSOR: u8 = 0x7F;
358
359// Record kinds inside GIT_STATE.
360pub const GIT_STATE_RECORD_HEAD: u8 = 0x01;
361pub const GIT_STATE_RECORD_REF: u8 = 0x02;
362pub const GIT_STATE_RECORD_OP: u8 = 0x03;
363pub const GIT_STATE_RECORD_STATUS: u8 = 0x04;
364pub const GIT_STATE_RECORD_UPSTREAM: u8 = 0x05;
365pub const GIT_STATE_RECORD_STASH: u8 = 0x06;
366pub const GIT_STATE_RECORD_REMOTE: u8 = 0x07;
367
368// STATE_REMOTE record flags.
369/// The remote whose `HEAD` the checkout's default branch tracks.
370pub const GIT_REMOTE_DEFAULT: u8 = 1 << 0;
371
372// HEAD record flags.
373pub const GIT_HEAD_DETACHED: u8 = 1 << 0;
374pub const GIT_HEAD_UNBORN: u8 = 1 << 1;
375
376// STATE_REF record flags.
377/// `peeled` is valid (annotated tag).
378pub const GIT_REF_PEELED_VALID: u8 = 1 << 0;
379pub const GIT_REF_SYMBOLIC: u8 = 1 << 1;
380
381// OP record operations.
382pub const GIT_OP_MERGE: u8 = 1;
383pub const GIT_OP_REBASE: u8 = 2;
384pub const GIT_OP_CHERRY_PICK: u8 = 3;
385pub const GIT_OP_REVERT: u8 = 4;
386pub const GIT_OP_BISECT: u8 = 5;
387
388// STATUS record flags.
389pub const GIT_STATUS_ENTRY_CONFLICTED: u8 = 1 << 0;
390
391// UPSTREAM record flags.
392/// Upstream configured but its ref is missing; counts zero.
393pub const GIT_UPSTREAM_GONE: u8 = 1 << 0;
394/// Unset when the walk budget was hit; names still valid.
395pub const GIT_UPSTREAM_COUNTS_VALID: u8 = 1 << 1;
396
397// Record kinds inside GIT_COMMITS.
398pub const GIT_COMMIT_RECORD_COMMIT: u8 = 0x01;
399pub const GIT_COMMIT_RECORD_PATH_AT: u8 = 0x02;
400
401// COMMIT record flags.
402/// Bytes were replaced re-encoding name/email/message to UTF-8.
403pub const GIT_COMMIT_LOSSY_ENCODING: u8 = 1 << 0;
404
405// Record kind inside the GIT_TREE response.
406pub const GIT_TREE_RECORD_ENTRY: u8 = 0x02;
407
408// TREE_ENTRY / PATH_AT object types.
409/// Submodule: the entry's oid is a commit.
410pub const GIT_OTYPE_COMMIT: u8 = 1;
411pub const GIT_OTYPE_TREE: u8 = 2;
412pub const GIT_OTYPE_BLOB: u8 = 3;
413
414// Record kinds inside the GIT_DIFF response.
415pub const GIT_DIFF_RECORD_ENTRY: u8 = 0x03;
416pub const GIT_DIFF_RECORD_BASE: u8 = 0x04;
417
418// DIFF_ENTRY dflags.
419pub const GIT_DIFF_ENTRY_BINARY: u8 = 1 << 0;
420pub const GIT_DIFF_ENTRY_SUBMODULE: u8 = 1 << 1;
421/// The path's gitattributes name a `filter` driver, so the object bytes and
422/// the worktree bytes are not comparable (an LFS pointer against the asset
423/// it stands for). No rows are emitted: a client renders "filtered file
424/// changed" rather than a wrong whole-file rewrite.
425pub const GIT_DIFF_ENTRY_FILTERED: u8 = 1 << 2;
426
427// Record kinds inside the GIT_PATCH response (structured form).
428pub const GIT_PATCH_RECORD_FILE: u8 = 0x01;
429pub const GIT_PATCH_RECORD_ROW: u8 = 0x02;
430pub const GIT_PATCH_RECORD_GAP: u8 = 0x03;
431pub const GIT_PATCH_RECORD_BASE: u8 = 0x04;
432
433// PATCH_FILE flags.
434/// Binary file: no rows follow.
435pub const GIT_PATCH_FILE_BINARY: u8 = 1 << 0;
436/// Filtered file: no rows follow (see `GIT_DIFF_ENTRY_FILTERED`).
437pub const GIT_PATCH_FILE_FILTERED: u8 = 1 << 1;
438
439// Record kind inside the GIT_INDEX response.
440pub const GIT_INDEX_RECORD_ENTRY: u8 = 0x04;
441
442// Record kinds inside the 0x90-block responses.
443pub const GIT_DISCOVER_RECORD_REPO: u8 = 0x01;
444pub const GIT_BLAME_RECORD_RANGE: u8 = 0x01;
445pub const GIT_REFLOG_RECORD_ENTRY: u8 = 0x01;
446pub const GIT_FETCH_RECORD_REF: u8 = 0x01;
447
448// REPO_FOUND record flags.
449pub const GIT_FOUND_BARE: u8 = 1 << 0;
450pub const GIT_FOUND_LINKED: u8 = 1 << 1;
451pub const GIT_FOUND_SUBMODULE: u8 = 1 << 2;
452
453// FETCH_REF record flags.
454pub const GIT_FETCH_REF_FORCED: u8 = 1 << 0;
455pub const GIT_FETCH_REF_PRUNED: u8 = 1 << 1;
456pub const GIT_FETCH_REF_NEW: u8 = 1 << 2;
457/// An existing tag was moved (git's `t` flag, distinct from its `+`). Its own
458/// bit rather than folded into `FORCED`, because git distinguishes the two and
459/// a client showing "the tag you pinned now points elsewhere" wants the
460/// distinction.
461pub const GIT_FETCH_REF_TAG_UPDATE: u8 = 1 << 3;
462
463// INDEX_ENTRY iflags.
464pub const GIT_INDEX_INTENT_TO_ADD: u8 = 1 << 0;
465pub const GIT_INDEX_SKIP_WORKTREE: u8 = 1 << 1;
466
467/// An object id: always 32 bytes on the wire, zero-padded past the
468/// repository's hash width (`GIT_REPO.oid_format`).
469pub type GitOid = [u8; 32];
470
471/// The all-zero oid: absent (unborn branch, unhashed worktree side,
472/// deleted side of a diff).
473pub const GIT_OID_NONE: GitOid = [0; 32];
474
475/// One side of a `GIT_DIFF`/`GIT_PATCH`: `[kind:1][oid:32]`. The oid is
476/// meaningful only for `COMMIT`, `TREE`, and `MERGE_BASE` kinds.
477#[derive(Clone, Copy, Debug, PartialEq, Eq)]
478pub struct GitEndpoint {
479    pub kind: u8,
480    pub oid: GitOid,
481}
482
483/// Decompress a `compress_prepend_size` payload, refusing declared sizes
484/// over the protocol-wide [`crate::MAX_DECOMPRESSED`] *before* allocating
485/// (docs/protocol.md "Compressed payloads").
486fn decompress_guarded(data: &[u8]) -> Option<Vec<u8>> {
487    if data.len() < 4 {
488        return None;
489    }
490    let declared = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
491    if declared > crate::MAX_DECOMPRESSED {
492        return None;
493    }
494    lz4_flex::decompress_size_prepended(data).ok()
495}
496
497// ---------------------------------------------------------------------------
498// Field codec helpers
499// ---------------------------------------------------------------------------
500
501/// Longest string this codec's `u16` length prefix can describe.
502const MAX_STR: usize = u16::MAX as usize;
503
504fn push_str(buf: &mut Vec<u8>, s: &str) {
505    // Clip rather than cast. Most fields here are names and paths of
506    // ordinary length, but a repository is an attacker-supplied input: a
507    // tree entry or ref name has no hard length cap, and the escaping these
508    // strings go through expands a non-UTF-8 byte roughly sixfold, so ~11 KB
509    // of raw bytes can pass 64 KiB. `len as u16` then wrote a wrapped length
510    // and every following field of the response was read at the wrong
511    // offset. A visibly shortened name costs one unhelpful row; a wrapped
512    // prefix costs the whole response.
513    let b = s.as_bytes();
514    let b = if b.len() > MAX_STR {
515        // Back off to a char boundary so the field stays valid UTF-8, which
516        // the decoder requires.
517        let mut end = MAX_STR;
518        while end > 0 && !s.is_char_boundary(end) {
519            end -= 1;
520        }
521        &b[..end]
522    } else {
523        b
524    };
525    buf.extend_from_slice(&(b.len() as u16).to_le_bytes());
526    buf.extend_from_slice(b);
527}
528
529/// A u32-length-prefixed byte string (patch row text, commit messages).
530fn push_bytes(buf: &mut Vec<u8>, b: &[u8]) {
531    buf.extend_from_slice(&(b.len() as u32).to_le_bytes());
532    buf.extend_from_slice(b);
533}
534
535fn push_oids(buf: &mut Vec<u8>, oids: &[GitOid]) {
536    for oid in oids {
537        buf.extend_from_slice(oid);
538    }
539}
540
541fn take_u8(b: &mut &[u8]) -> Option<u8> {
542    let (&x, rest) = b.split_first()?;
543    *b = rest;
544    Some(x)
545}
546
547fn take_u16(b: &mut &[u8]) -> Option<u16> {
548    if b.len() < 2 {
549        return None;
550    }
551    let v = u16::from_le_bytes([b[0], b[1]]);
552    *b = &b[2..];
553    Some(v)
554}
555
556fn take_i16(b: &mut &[u8]) -> Option<i16> {
557    Some(take_u16(b)? as i16)
558}
559
560fn take_u32(b: &mut &[u8]) -> Option<u32> {
561    if b.len() < 4 {
562        return None;
563    }
564    let v = u32::from_le_bytes(b[0..4].try_into().unwrap());
565    *b = &b[4..];
566    Some(v)
567}
568
569fn take_u64(b: &mut &[u8]) -> Option<u64> {
570    if b.len() < 8 {
571        return None;
572    }
573    let v = u64::from_le_bytes(b[0..8].try_into().unwrap());
574    *b = &b[8..];
575    Some(v)
576}
577
578fn take_i64(b: &mut &[u8]) -> Option<i64> {
579    Some(take_u64(b)? as i64)
580}
581
582fn take_oid(b: &mut &[u8]) -> Option<GitOid> {
583    if b.len() < 32 {
584        return None;
585    }
586    let oid: GitOid = b[0..32].try_into().unwrap();
587    *b = &b[32..];
588    Some(oid)
589}
590
591fn take_oids(b: &mut &[u8], n: usize) -> Option<Vec<GitOid>> {
592    if b.len() < n * 32 {
593        return None;
594    }
595    let mut oids = Vec::with_capacity(n);
596    for _ in 0..n {
597        oids.push(take_oid(b)?);
598    }
599    Some(oids)
600}
601
602fn take_str<'a>(b: &mut &'a [u8]) -> Option<&'a str> {
603    let len = take_u16(b)? as usize;
604    if b.len() < len {
605        return None;
606    }
607    let s = std::str::from_utf8(&b[..len]).ok()?;
608    *b = &b[len..];
609    Some(s)
610}
611
612fn take_bytes<'a>(b: &mut &'a [u8]) -> Option<&'a [u8]> {
613    let len = take_u32(b)? as usize;
614    if b.len() < len {
615        return None;
616    }
617    let bytes = &b[..len];
618    *b = &b[len..];
619    Some(bytes)
620}
621
622fn take_endpoint(b: &mut &[u8]) -> Option<GitEndpoint> {
623    let kind = take_u8(b)?;
624    let oid = take_oid(b)?;
625    Some(GitEndpoint { kind, oid })
626}
627
628fn push_endpoint(buf: &mut Vec<u8>, endpoint: GitEndpoint) {
629    buf.push(endpoint.kind);
630    buf.extend_from_slice(&endpoint.oid);
631}
632
633/// Check `msg` starts with `opcode` and return the body after it.
634fn body_of(msg: &[u8], opcode: u8) -> Option<&[u8]> {
635    if msg.first() != Some(&opcode) {
636        return None;
637    }
638    Some(&msg[1..])
639}
640
641// ---------------------------------------------------------------------------
642// C2S message builders and parsers
643// ---------------------------------------------------------------------------
644
645/// A decoded `C2S_GIT_OPEN`.
646///
647/// Both context ids are plain fields with a [`GIT_OPEN_NO_CONTEXT`]
648/// sentinel rather than flag-gated tails, so the message has one parse
649/// shape however it is used.
650#[derive(Clone, Debug, PartialEq, Eq)]
651pub struct GitOpenRequest<'a> {
652    pub nonce: u16,
653    pub flags: u16,
654    /// Per-open settle windows; `0` = the server default.
655    pub refs_latency_ms: u16,
656    pub status_latency_ms: u16,
657    /// `GIT_OPEN_NO_CONTEXT` = none. Otherwise the server joins `path` onto
658    /// that pty's live cwd before upward discovery (docs/ide.md Decision 3).
659    pub src_pty_id: u16,
660    /// `GIT_OPEN_NO_CONTEXT` = none. Otherwise `path` is a submodule path
661    /// relative to that repo's worktree and the server resolves the
662    /// submodule's own gitdir and worktree — a client never has to guess
663    /// where `.gitmodules` put it.
664    pub parent_repo_id: u16,
665    /// Ref prefixes to watch; empty = every ref. A UI that renders branches
666    /// and not tags stops paying for tags at every settle.
667    pub ref_prefixes: Vec<&'a str>,
668    /// Plain UTF-8: a filesystem location the client chose (or, with
669    /// `parent_repo_id`, a repo-relative submodule path).
670    pub path: &'a str,
671}
672
673impl GitOpenRequest<'_> {
674    /// The common case: a plain path with no context and no prefix filter.
675    pub fn new(nonce: u16, flags: u16, path: &str) -> GitOpenRequest<'_> {
676        GitOpenRequest {
677            nonce,
678            flags,
679            refs_latency_ms: 0,
680            status_latency_ms: 0,
681            src_pty_id: GIT_OPEN_NO_CONTEXT,
682            parent_repo_id: GIT_OPEN_NO_CONTEXT,
683            ref_prefixes: Vec::new(),
684            path,
685        }
686    }
687}
688
689pub fn msg_git_open(req: &GitOpenRequest<'_>) -> Vec<u8> {
690    let mut msg = Vec::with_capacity(17 + req.path.len());
691    msg.push(C2S_GIT_OPEN);
692    msg.extend_from_slice(&req.nonce.to_le_bytes());
693    msg.extend_from_slice(&req.flags.to_le_bytes());
694    msg.extend_from_slice(&req.refs_latency_ms.to_le_bytes());
695    msg.extend_from_slice(&req.status_latency_ms.to_le_bytes());
696    msg.extend_from_slice(&req.src_pty_id.to_le_bytes());
697    msg.extend_from_slice(&req.parent_repo_id.to_le_bytes());
698    msg.extend_from_slice(&(req.ref_prefixes.len() as u16).to_le_bytes());
699    for prefix in &req.ref_prefixes {
700        push_str(&mut msg, prefix);
701    }
702    push_str(&mut msg, req.path);
703    msg
704}
705
706pub fn parse_git_open(msg: &[u8]) -> Option<GitOpenRequest<'_>> {
707    let mut b = body_of(msg, C2S_GIT_OPEN)?;
708    let nonce = take_u16(&mut b)?;
709    let flags = take_u16(&mut b)?;
710    let refs_latency_ms = take_u16(&mut b)?;
711    let status_latency_ms = take_u16(&mut b)?;
712    let src_pty_id = take_u16(&mut b)?;
713    let parent_repo_id = take_u16(&mut b)?;
714    let n_prefixes = take_u16(&mut b)? as usize;
715    let mut ref_prefixes = Vec::with_capacity(n_prefixes.min(64));
716    for _ in 0..n_prefixes {
717        ref_prefixes.push(take_str(&mut b)?);
718    }
719    let path = take_str(&mut b)?;
720    Some(GitOpenRequest {
721        nonce,
722        flags,
723        refs_latency_ms,
724        status_latency_ms,
725        src_pty_id,
726        parent_repo_id,
727        ref_prefixes,
728        path,
729    })
730}
731
732/// Rebase a pty-relative `C2S_GIT_OPEN` onto a resolved `cwd`: join
733/// `cwd`/`path` and clear `src_pty_id`, producing the plain path-based open
734/// the handler consumes. `cwd` `None` (source pty gone) keeps `path`
735/// verbatim.
736pub fn git_open_rebase(msg: &[u8], cwd: Option<&str>) -> Option<Vec<u8>> {
737    let req = parse_git_open(msg)?;
738    if req.src_pty_id == GIT_OPEN_NO_CONTEXT {
739        return None;
740    }
741    let joined = cwd.map(|dir| {
742        std::path::Path::new(dir)
743            .join(req.path)
744            .to_string_lossy()
745            .into_owned()
746    });
747    Some(msg_git_open(&GitOpenRequest {
748        nonce: req.nonce,
749        flags: req.flags,
750        refs_latency_ms: req.refs_latency_ms,
751        status_latency_ms: req.status_latency_ms,
752        src_pty_id: GIT_OPEN_NO_CONTEXT,
753        parent_repo_id: req.parent_repo_id,
754        ref_prefixes: req.ref_prefixes,
755        path: joined.as_deref().unwrap_or(req.path),
756    }))
757}
758
759pub fn msg_git_close(repo_id: u16) -> Vec<u8> {
760    let mut msg = Vec::with_capacity(3);
761    msg.push(C2S_GIT_CLOSE);
762    msg.extend_from_slice(&repo_id.to_le_bytes());
763    msg
764}
765
766pub fn parse_git_close(msg: &[u8]) -> Option<u16> {
767    let mut b = body_of(msg, C2S_GIT_CLOSE)?;
768    take_u16(&mut b)
769}
770
771pub fn msg_git_ack(repo_id: u16, state_id: u32) -> Vec<u8> {
772    let mut msg = Vec::with_capacity(7);
773    msg.push(C2S_GIT_ACK);
774    msg.extend_from_slice(&repo_id.to_le_bytes());
775    msg.extend_from_slice(&state_id.to_le_bytes());
776    msg
777}
778
779/// Parse `C2S_GIT_ACK` into `(repo_id, state_id)`.
780pub fn parse_git_ack(msg: &[u8]) -> Option<(u16, u32)> {
781    let mut b = body_of(msg, C2S_GIT_ACK)?;
782    let repo_id = take_u16(&mut b)?;
783    let state_id = take_u32(&mut b)?;
784    Some((repo_id, state_id))
785}
786
787/// A decoded `C2S_GIT_LOG` request.
788#[derive(Clone, Debug, PartialEq, Eq)]
789pub struct GitLogRequest<'a> {
790    pub nonce: u16,
791    pub repo_id: u16,
792    pub flags: u8,
793    /// `0` = server default; clamped to the server maximum.
794    pub limit: u16,
795    /// Empty = no path filter; escaped form.
796    pub path: &'a str,
797    /// Empty = HEAD.
798    pub tips: Vec<GitOid>,
799    pub hides: Vec<GitOid>,
800}
801
802pub fn msg_git_log(
803    nonce: u16,
804    repo_id: u16,
805    flags: u8,
806    limit: u16,
807    path: &str,
808    tips: &[GitOid],
809    hides: &[GitOid],
810) -> Vec<u8> {
811    let mut msg = Vec::with_capacity(14 + path.len() + 32 * (tips.len() + hides.len()));
812    msg.push(C2S_GIT_LOG);
813    msg.extend_from_slice(&nonce.to_le_bytes());
814    msg.extend_from_slice(&repo_id.to_le_bytes());
815    msg.push(flags);
816    msg.extend_from_slice(&limit.to_le_bytes());
817    push_str(&mut msg, path);
818    msg.extend_from_slice(&(tips.len() as u16).to_le_bytes());
819    push_oids(&mut msg, tips);
820    msg.extend_from_slice(&(hides.len() as u16).to_le_bytes());
821    push_oids(&mut msg, hides);
822    msg
823}
824
825pub fn parse_git_log(msg: &[u8]) -> Option<GitLogRequest<'_>> {
826    let mut b = body_of(msg, C2S_GIT_LOG)?;
827    let nonce = take_u16(&mut b)?;
828    let repo_id = take_u16(&mut b)?;
829    let flags = take_u8(&mut b)?;
830    let limit = take_u16(&mut b)?;
831    let path = take_str(&mut b)?;
832    let n_tips = take_u16(&mut b)? as usize;
833    let tips = take_oids(&mut b, n_tips)?;
834    let n_hides = take_u16(&mut b)? as usize;
835    let hides = take_oids(&mut b, n_hides)?;
836    Some(GitLogRequest {
837        nonce,
838        repo_id,
839        flags,
840        limit,
841        path,
842        tips,
843        hides,
844    })
845}
846
847/// A decoded `C2S_GIT_TREE`. `after` empty = from the beginning; set it to
848/// a `CURSOR` record's path to continue a truncated listing.
849#[derive(Clone, Debug, PartialEq, Eq)]
850pub struct GitTreeRequest<'a> {
851    pub nonce: u16,
852    pub repo_id: u16,
853    /// Reserved; a set bit is `INVALID`.
854    pub flags: u8,
855    pub oid: GitOid,
856    pub path: &'a str,
857    pub after: &'a str,
858}
859
860pub fn msg_git_tree(req: &GitTreeRequest<'_>) -> Vec<u8> {
861    let mut msg = Vec::with_capacity(42 + req.path.len() + req.after.len());
862    msg.push(C2S_GIT_TREE);
863    msg.extend_from_slice(&req.nonce.to_le_bytes());
864    msg.extend_from_slice(&req.repo_id.to_le_bytes());
865    msg.push(req.flags);
866    msg.extend_from_slice(&req.oid);
867    push_str(&mut msg, req.path);
868    push_str(&mut msg, req.after);
869    msg
870}
871
872pub fn parse_git_tree(msg: &[u8]) -> Option<GitTreeRequest<'_>> {
873    let mut b = body_of(msg, C2S_GIT_TREE)?;
874    let nonce = take_u16(&mut b)?;
875    let repo_id = take_u16(&mut b)?;
876    let flags = take_u8(&mut b)?;
877    let oid = take_oid(&mut b)?;
878    let path = take_str(&mut b)?;
879    let after = take_str(&mut b)?;
880    Some(GitTreeRequest {
881        nonce,
882        repo_id,
883        flags,
884        oid,
885        path,
886        after,
887    })
888}
889
890/// A decoded `C2S_GIT_BLOB`.
891#[derive(Clone, Debug, PartialEq, Eq)]
892pub struct GitBlobRequest<'a> {
893    pub nonce: u16,
894    pub repo_id: u16,
895    /// `GIT_BLOB_WHOLE` refuses rather than windowing.
896    pub flags: u8,
897    pub oid: GitOid,
898    pub path: &'a str,
899    /// First byte to return. Past the end is `INVALID`; exactly the end is
900    /// `OK` with no data.
901    pub offset: u64,
902    /// `0` = the server default cap.
903    pub max_len: u32,
904}
905
906pub fn msg_git_blob(req: &GitBlobRequest<'_>) -> Vec<u8> {
907    let mut msg = Vec::with_capacity(52 + req.path.len());
908    msg.push(C2S_GIT_BLOB);
909    msg.extend_from_slice(&req.nonce.to_le_bytes());
910    msg.extend_from_slice(&req.repo_id.to_le_bytes());
911    msg.push(req.flags);
912    msg.extend_from_slice(&req.oid);
913    push_str(&mut msg, req.path);
914    msg.extend_from_slice(&req.offset.to_le_bytes());
915    msg.extend_from_slice(&req.max_len.to_le_bytes());
916    msg
917}
918
919pub fn parse_git_blob(msg: &[u8]) -> Option<GitBlobRequest<'_>> {
920    let mut b = body_of(msg, C2S_GIT_BLOB)?;
921    let nonce = take_u16(&mut b)?;
922    let repo_id = take_u16(&mut b)?;
923    let flags = take_u8(&mut b)?;
924    let oid = take_oid(&mut b)?;
925    let path = take_str(&mut b)?;
926    let offset = take_u64(&mut b)?;
927    let max_len = take_u32(&mut b)?;
928    Some(GitBlobRequest {
929        nonce,
930        repo_id,
931        flags,
932        oid,
933        path,
934        offset,
935        max_len,
936    })
937}
938
939/// A decoded `C2S_GIT_DIFF` request.
940#[derive(Clone, Debug, PartialEq, Eq)]
941pub struct GitDiffRequest<'a> {
942    pub nonce: u16,
943    pub repo_id: u16,
944    pub flags: u8,
945    /// Rename similarity threshold: `0` = the exact-oid join, `1..=100` a
946    /// percentage (git's own default is 50), above that `INVALID`.
947    pub rename: u8,
948    pub old: GitEndpoint,
949    pub new: GitEndpoint,
950    /// Empty = whole tree; escaped form.
951    pub path: &'a str,
952    /// Empty = from the beginning; else a `CURSOR` record's path.
953    pub after: &'a str,
954}
955
956pub fn msg_git_diff(req: &GitDiffRequest<'_>) -> Vec<u8> {
957    let mut msg = Vec::with_capacity(77 + req.path.len() + req.after.len());
958    msg.push(C2S_GIT_DIFF);
959    msg.extend_from_slice(&req.nonce.to_le_bytes());
960    msg.extend_from_slice(&req.repo_id.to_le_bytes());
961    msg.push(req.flags);
962    msg.push(req.rename);
963    push_endpoint(&mut msg, req.old);
964    push_endpoint(&mut msg, req.new);
965    push_str(&mut msg, req.path);
966    push_str(&mut msg, req.after);
967    msg
968}
969
970pub fn parse_git_diff(msg: &[u8]) -> Option<GitDiffRequest<'_>> {
971    let mut b = body_of(msg, C2S_GIT_DIFF)?;
972    let nonce = take_u16(&mut b)?;
973    let repo_id = take_u16(&mut b)?;
974    let flags = take_u8(&mut b)?;
975    let rename = take_u8(&mut b)?;
976    let old = take_endpoint(&mut b)?;
977    let new = take_endpoint(&mut b)?;
978    let path = take_str(&mut b)?;
979    let after = take_str(&mut b)?;
980    Some(GitDiffRequest {
981        nonce,
982        repo_id,
983        flags,
984        rename,
985        old,
986        new,
987        path,
988        after,
989    })
990}
991
992/// A decoded `C2S_GIT_PATCH` request.
993#[derive(Clone, Debug, PartialEq, Eq)]
994pub struct GitPatchRequest<'a> {
995    pub nonce: u16,
996    pub repo_id: u16,
997    /// Low six bits are `GIT_DIFF`'s; `TEXT`/`CHAR_SPANS`/`NO_SPANS` follow.
998    pub flags: u16,
999    /// Context lines; `0` = server default (3).
1000    pub context: u8,
1001    /// Rename similarity threshold, as `GIT_DIFF`.
1002    pub rename: u8,
1003    pub old: GitEndpoint,
1004    pub new: GitEndpoint,
1005    /// Non-empty = one file's patch; empty = the whole diff.
1006    pub path: &'a str,
1007    pub max_len: u32,
1008    /// Empty = from the beginning; else a `CURSOR` record's path.
1009    pub after: &'a str,
1010    /// Rows already delivered for `after`, so a file larger than the byte
1011    /// budget resumes mid-hunk instead of restarting forever.
1012    pub after_pos: u64,
1013}
1014
1015pub fn msg_git_patch(req: &GitPatchRequest<'_>) -> Vec<u8> {
1016    let mut msg = Vec::with_capacity(90 + req.path.len() + req.after.len());
1017    msg.push(C2S_GIT_PATCH);
1018    msg.extend_from_slice(&req.nonce.to_le_bytes());
1019    msg.extend_from_slice(&req.repo_id.to_le_bytes());
1020    msg.extend_from_slice(&req.flags.to_le_bytes());
1021    msg.push(req.context);
1022    msg.push(req.rename);
1023    push_endpoint(&mut msg, req.old);
1024    push_endpoint(&mut msg, req.new);
1025    push_str(&mut msg, req.path);
1026    msg.extend_from_slice(&req.max_len.to_le_bytes());
1027    push_str(&mut msg, req.after);
1028    msg.extend_from_slice(&req.after_pos.to_le_bytes());
1029    msg
1030}
1031
1032pub fn parse_git_patch(msg: &[u8]) -> Option<GitPatchRequest<'_>> {
1033    let mut b = body_of(msg, C2S_GIT_PATCH)?;
1034    let nonce = take_u16(&mut b)?;
1035    let repo_id = take_u16(&mut b)?;
1036    let flags = take_u16(&mut b)?;
1037    let context = take_u8(&mut b)?;
1038    let rename = take_u8(&mut b)?;
1039    let old = take_endpoint(&mut b)?;
1040    let new = take_endpoint(&mut b)?;
1041    let path = take_str(&mut b)?;
1042    let max_len = take_u32(&mut b)?;
1043    let after = take_str(&mut b)?;
1044    let after_pos = take_u64(&mut b)?;
1045    Some(GitPatchRequest {
1046        nonce,
1047        repo_id,
1048        flags,
1049        context,
1050        rename,
1051        old,
1052        new,
1053        path,
1054        max_len,
1055        after,
1056        after_pos,
1057    })
1058}
1059
1060/// A decoded `C2S_GIT_INDEX`.
1061#[derive(Clone, Debug, PartialEq, Eq)]
1062pub struct GitIndexRequest<'a> {
1063    pub nonce: u16,
1064    pub repo_id: u16,
1065    /// Reserved; a set bit is `INVALID`.
1066    pub flags: u8,
1067    pub path: &'a str,
1068    pub after: &'a str,
1069}
1070
1071pub fn msg_git_index(req: &GitIndexRequest<'_>) -> Vec<u8> {
1072    let mut msg = Vec::with_capacity(10 + req.path.len() + req.after.len());
1073    msg.push(C2S_GIT_INDEX);
1074    msg.extend_from_slice(&req.nonce.to_le_bytes());
1075    msg.extend_from_slice(&req.repo_id.to_le_bytes());
1076    msg.push(req.flags);
1077    push_str(&mut msg, req.path);
1078    push_str(&mut msg, req.after);
1079    msg
1080}
1081
1082pub fn parse_git_index(msg: &[u8]) -> Option<GitIndexRequest<'_>> {
1083    let mut b = body_of(msg, C2S_GIT_INDEX)?;
1084    let nonce = take_u16(&mut b)?;
1085    let repo_id = take_u16(&mut b)?;
1086    let flags = take_u8(&mut b)?;
1087    let path = take_str(&mut b)?;
1088    let after = take_str(&mut b)?;
1089    Some(GitIndexRequest {
1090        nonce,
1091        repo_id,
1092        flags,
1093        path,
1094        after,
1095    })
1096}
1097
1098pub fn msg_git_cancel(nonce: u16) -> Vec<u8> {
1099    let mut msg = Vec::with_capacity(3);
1100    msg.push(C2S_GIT_CANCEL);
1101    msg.extend_from_slice(&nonce.to_le_bytes());
1102    msg
1103}
1104
1105pub fn parse_git_cancel(msg: &[u8]) -> Option<u16> {
1106    let mut b = body_of(msg, C2S_GIT_CANCEL)?;
1107    take_u16(&mut b)
1108}
1109
1110pub fn msg_git_base(nonce: u16, repo_id: u16, oids: &[GitOid]) -> Vec<u8> {
1111    let mut msg = Vec::with_capacity(6 + 32 * oids.len());
1112    msg.push(C2S_GIT_BASE);
1113    msg.extend_from_slice(&nonce.to_le_bytes());
1114    msg.extend_from_slice(&repo_id.to_le_bytes());
1115    msg.push(oids.len() as u8);
1116    push_oids(&mut msg, oids);
1117    msg
1118}
1119
1120/// Parse `C2S_GIT_BASE` into `(nonce, repo_id, oids)`.
1121pub fn parse_git_base(msg: &[u8]) -> Option<(u16, u16, Vec<GitOid>)> {
1122    let mut b = body_of(msg, C2S_GIT_BASE)?;
1123    let nonce = take_u16(&mut b)?;
1124    let repo_id = take_u16(&mut b)?;
1125    let n = take_u8(&mut b)? as usize;
1126    let oids = take_oids(&mut b, n)?;
1127    Some((nonce, repo_id, oids))
1128}
1129
1130pub fn msg_git_resolve(nonce: u16, repo_id: u16, spec: &str) -> Vec<u8> {
1131    let mut msg = Vec::with_capacity(7 + spec.len());
1132    msg.push(C2S_GIT_RESOLVE);
1133    msg.extend_from_slice(&nonce.to_le_bytes());
1134    msg.extend_from_slice(&repo_id.to_le_bytes());
1135    push_str(&mut msg, spec);
1136    msg
1137}
1138
1139/// Parse `C2S_GIT_RESOLVE` into `(nonce, repo_id, spec)`.
1140pub fn parse_git_resolve(msg: &[u8]) -> Option<(u16, u16, &str)> {
1141    let mut b = body_of(msg, C2S_GIT_RESOLVE)?;
1142    let nonce = take_u16(&mut b)?;
1143    let repo_id = take_u16(&mut b)?;
1144    let spec = take_str(&mut b)?;
1145    Some((nonce, repo_id, spec))
1146}
1147
1148pub fn msg_git_log_watch(log_id: u16, repo_id: u16, flags: u8, limit: u16, spec: &str) -> Vec<u8> {
1149    let mut msg = Vec::with_capacity(10 + spec.len());
1150    msg.push(C2S_GIT_LOG_WATCH);
1151    msg.extend_from_slice(&log_id.to_le_bytes());
1152    msg.extend_from_slice(&repo_id.to_le_bytes());
1153    msg.push(flags);
1154    msg.extend_from_slice(&limit.to_le_bytes());
1155    push_str(&mut msg, spec);
1156    msg
1157}
1158
1159/// Parse `C2S_GIT_LOG_WATCH` into `(log_id, repo_id, flags, limit, spec)`.
1160pub fn parse_git_log_watch(msg: &[u8]) -> Option<(u16, u16, u8, u16, &str)> {
1161    let mut b = body_of(msg, C2S_GIT_LOG_WATCH)?;
1162    let log_id = take_u16(&mut b)?;
1163    let repo_id = take_u16(&mut b)?;
1164    let flags = take_u8(&mut b)?;
1165    let limit = take_u16(&mut b)?;
1166    let spec = take_str(&mut b)?;
1167    Some((log_id, repo_id, flags, limit, spec))
1168}
1169
1170pub fn msg_git_log_unwatch(log_id: u16, repo_id: u16) -> Vec<u8> {
1171    let mut msg = Vec::with_capacity(5);
1172    msg.push(C2S_GIT_LOG_UNWATCH);
1173    msg.extend_from_slice(&log_id.to_le_bytes());
1174    msg.extend_from_slice(&repo_id.to_le_bytes());
1175    msg
1176}
1177
1178/// Parse `C2S_GIT_LOG_UNWATCH` into `(log_id, repo_id)`.
1179pub fn parse_git_log_unwatch(msg: &[u8]) -> Option<(u16, u16)> {
1180    let mut b = body_of(msg, C2S_GIT_LOG_UNWATCH)?;
1181    let log_id = take_u16(&mut b)?;
1182    let repo_id = take_u16(&mut b)?;
1183    Some((log_id, repo_id))
1184}
1185
1186pub fn msg_git_log_ack(log_id: u16, repo_id: u16, update_id: u32) -> Vec<u8> {
1187    let mut msg = Vec::with_capacity(9);
1188    msg.push(C2S_GIT_LOG_ACK);
1189    msg.extend_from_slice(&log_id.to_le_bytes());
1190    msg.extend_from_slice(&repo_id.to_le_bytes());
1191    msg.extend_from_slice(&update_id.to_le_bytes());
1192    msg
1193}
1194
1195/// Parse `C2S_GIT_LOG_ACK` into `(log_id, repo_id, update_id)`.
1196pub fn parse_git_log_ack(msg: &[u8]) -> Option<(u16, u16, u32)> {
1197    let mut b = body_of(msg, C2S_GIT_LOG_ACK)?;
1198    let log_id = take_u16(&mut b)?;
1199    let repo_id = take_u16(&mut b)?;
1200    let update_id = take_u32(&mut b)?;
1201    Some((log_id, repo_id, update_id))
1202}
1203
1204/// A decoded `C2S_GIT_DISCOVER`.
1205#[derive(Clone, Debug, PartialEq, Eq)]
1206pub struct GitDiscoverRequest<'a> {
1207    pub nonce: u16,
1208    pub flags: u8,
1209    /// `0` = the server default; clamped to the server maximum.
1210    pub depth: u8,
1211    /// Plain UTF-8, like `GIT_OPEN`: a filesystem location the client chose.
1212    pub path: &'a str,
1213    pub after: &'a str,
1214}
1215
1216pub fn msg_git_discover(req: &GitDiscoverRequest<'_>) -> Vec<u8> {
1217    let mut msg = Vec::with_capacity(9 + req.path.len() + req.after.len());
1218    msg.push(C2S_GIT_DISCOVER);
1219    msg.extend_from_slice(&req.nonce.to_le_bytes());
1220    msg.push(req.flags);
1221    msg.push(req.depth);
1222    push_str(&mut msg, req.path);
1223    push_str(&mut msg, req.after);
1224    msg
1225}
1226
1227pub fn parse_git_discover(msg: &[u8]) -> Option<GitDiscoverRequest<'_>> {
1228    let mut b = body_of(msg, C2S_GIT_DISCOVER)?;
1229    let nonce = take_u16(&mut b)?;
1230    let flags = take_u8(&mut b)?;
1231    let depth = take_u8(&mut b)?;
1232    let path = take_str(&mut b)?;
1233    let after = take_str(&mut b)?;
1234    Some(GitDiscoverRequest {
1235        nonce,
1236        flags,
1237        depth,
1238        path,
1239        after,
1240    })
1241}
1242
1243/// A decoded `C2S_GIT_BLAME`.
1244#[derive(Clone, Debug, PartialEq, Eq)]
1245pub struct GitBlameRequest<'a> {
1246    pub nonce: u16,
1247    pub repo_id: u16,
1248    pub flags: u8,
1249    /// The commit to blame from; zero = HEAD. The worktree is not
1250    /// blameable (`INVALID`).
1251    pub oid: GitOid,
1252    /// 1-based; `0` is treated as 1. This is also how a truncated blame
1253    /// resumes: re-issue with `start_line` one past the `CURSOR`'s `pos`.
1254    pub start_line: u32,
1255    /// `0` = to end of file, subject to the line budget.
1256    pub line_count: u32,
1257    pub path: &'a str,
1258}
1259
1260pub fn msg_git_blame(req: &GitBlameRequest<'_>) -> Vec<u8> {
1261    let mut msg = Vec::with_capacity(48 + req.path.len());
1262    msg.push(C2S_GIT_BLAME);
1263    msg.extend_from_slice(&req.nonce.to_le_bytes());
1264    msg.extend_from_slice(&req.repo_id.to_le_bytes());
1265    msg.push(req.flags);
1266    msg.extend_from_slice(&req.oid);
1267    msg.extend_from_slice(&req.start_line.to_le_bytes());
1268    msg.extend_from_slice(&req.line_count.to_le_bytes());
1269    push_str(&mut msg, req.path);
1270    msg
1271}
1272
1273pub fn parse_git_blame(msg: &[u8]) -> Option<GitBlameRequest<'_>> {
1274    let mut b = body_of(msg, C2S_GIT_BLAME)?;
1275    let nonce = take_u16(&mut b)?;
1276    let repo_id = take_u16(&mut b)?;
1277    let flags = take_u8(&mut b)?;
1278    let oid = take_oid(&mut b)?;
1279    let start_line = take_u32(&mut b)?;
1280    let line_count = take_u32(&mut b)?;
1281    let path = take_str(&mut b)?;
1282    Some(GitBlameRequest {
1283        nonce,
1284        repo_id,
1285        flags,
1286        oid,
1287        start_line,
1288        line_count,
1289        path,
1290    })
1291}
1292
1293/// A decoded `C2S_GIT_REFLOG`.
1294#[derive(Clone, Debug, PartialEq, Eq)]
1295pub struct GitReflogRequest<'a> {
1296    pub nonce: u16,
1297    pub repo_id: u16,
1298    pub flags: u8,
1299    /// `0` = the server default; clamped to the entry budget.
1300    pub limit: u16,
1301    /// Empty = `HEAD`.
1302    pub ref_name: &'a str,
1303    /// Entries already delivered from the end `OLDEST_FIRST` selects, so a
1304    /// reflog longer than `limit` pages: re-issue with the `CURSOR`'s
1305    /// `pos`. A reflog has no path to name a resume point with, and the
1306    /// file is append-only, so the position is the key — subject to the
1307    /// family's per-item-coherent, whole-response-best-effort contract if
1308    /// entries land between pages.
1309    pub after_pos: u64,
1310}
1311
1312pub fn msg_git_reflog(req: &GitReflogRequest<'_>) -> Vec<u8> {
1313    let mut msg = Vec::with_capacity(18 + req.ref_name.len());
1314    msg.push(C2S_GIT_REFLOG);
1315    msg.extend_from_slice(&req.nonce.to_le_bytes());
1316    msg.extend_from_slice(&req.repo_id.to_le_bytes());
1317    msg.push(req.flags);
1318    msg.extend_from_slice(&req.limit.to_le_bytes());
1319    msg.extend_from_slice(&req.after_pos.to_le_bytes());
1320    push_str(&mut msg, req.ref_name);
1321    msg
1322}
1323
1324pub fn parse_git_reflog(msg: &[u8]) -> Option<GitReflogRequest<'_>> {
1325    let mut b = body_of(msg, C2S_GIT_REFLOG)?;
1326    let nonce = take_u16(&mut b)?;
1327    let repo_id = take_u16(&mut b)?;
1328    let flags = take_u8(&mut b)?;
1329    let limit = take_u16(&mut b)?;
1330    let after_pos = take_u64(&mut b)?;
1331    let ref_name = take_str(&mut b)?;
1332    Some(GitReflogRequest {
1333        nonce,
1334        repo_id,
1335        flags,
1336        limit,
1337        ref_name,
1338        after_pos,
1339    })
1340}
1341
1342/// A decoded `C2S_GIT_FETCH`.
1343#[derive(Clone, Debug, PartialEq, Eq)]
1344pub struct GitFetchRequest<'a> {
1345    pub nonce: u16,
1346    pub repo_id: u16,
1347    pub flags: u8,
1348    /// `0` = the server default, clamped to the server maximum.
1349    pub timeout_ms: u32,
1350    /// Empty = the branch's configured remote, else `origin`.
1351    pub remote: &'a str,
1352    /// Empty = the remote's configured refspecs.
1353    pub refspecs: Vec<&'a str>,
1354}
1355
1356pub fn msg_git_fetch(req: &GitFetchRequest<'_>) -> Vec<u8> {
1357    let mut msg = Vec::with_capacity(14 + req.remote.len());
1358    msg.push(C2S_GIT_FETCH);
1359    msg.extend_from_slice(&req.nonce.to_le_bytes());
1360    msg.extend_from_slice(&req.repo_id.to_le_bytes());
1361    msg.push(req.flags);
1362    msg.extend_from_slice(&req.timeout_ms.to_le_bytes());
1363    push_str(&mut msg, req.remote);
1364    msg.extend_from_slice(&(req.refspecs.len() as u16).to_le_bytes());
1365    for spec in &req.refspecs {
1366        push_str(&mut msg, spec);
1367    }
1368    msg
1369}
1370
1371pub fn parse_git_fetch(msg: &[u8]) -> Option<GitFetchRequest<'_>> {
1372    let mut b = body_of(msg, C2S_GIT_FETCH)?;
1373    let nonce = take_u16(&mut b)?;
1374    let repo_id = take_u16(&mut b)?;
1375    let flags = take_u8(&mut b)?;
1376    let timeout_ms = take_u32(&mut b)?;
1377    let remote = take_str(&mut b)?;
1378    let n = take_u16(&mut b)? as usize;
1379    let mut refspecs = Vec::with_capacity(n.min(64));
1380    for _ in 0..n {
1381        refspecs.push(take_str(&mut b)?);
1382    }
1383    Some(GitFetchRequest {
1384        nonce,
1385        repo_id,
1386        flags,
1387        timeout_ms,
1388        remote,
1389        refspecs,
1390    })
1391}
1392
1393// ---------------------------------------------------------------------------
1394// S2C message builders and parsers
1395// ---------------------------------------------------------------------------
1396
1397/// A decoded `S2C_GIT_REPO`.
1398#[derive(Clone, Debug, PartialEq, Eq)]
1399pub struct GitRepoInfo<'a> {
1400    pub nonce: u16,
1401    pub repo_id: u16,
1402    pub status: u8,
1403    pub oid_format: u8,
1404    pub flags: u8,
1405    /// Canonical worktree root (empty for bare); a diagnostic on failure.
1406    pub workdir: &'a str,
1407    pub gitdir: &'a str,
1408}
1409
1410pub fn msg_git_repo(
1411    nonce: u16,
1412    repo_id: u16,
1413    status: u8,
1414    oid_format: u8,
1415    flags: u8,
1416    workdir: &str,
1417    gitdir: &str,
1418) -> Vec<u8> {
1419    let mut msg = Vec::with_capacity(12 + workdir.len() + gitdir.len());
1420    msg.push(S2C_GIT_REPO);
1421    msg.extend_from_slice(&nonce.to_le_bytes());
1422    msg.extend_from_slice(&repo_id.to_le_bytes());
1423    msg.push(status);
1424    msg.push(oid_format);
1425    msg.push(flags);
1426    push_str(&mut msg, workdir);
1427    push_str(&mut msg, gitdir);
1428    msg
1429}
1430
1431pub fn parse_git_repo(msg: &[u8]) -> Option<GitRepoInfo<'_>> {
1432    let mut b = body_of(msg, S2C_GIT_REPO)?;
1433    let nonce = take_u16(&mut b)?;
1434    let repo_id = take_u16(&mut b)?;
1435    let status = take_u8(&mut b)?;
1436    let oid_format = take_u8(&mut b)?;
1437    let flags = take_u8(&mut b)?;
1438    let workdir = take_str(&mut b)?;
1439    let gitdir = take_str(&mut b)?;
1440    Some(GitRepoInfo {
1441        nonce,
1442        repo_id,
1443        status,
1444        oid_format,
1445        flags,
1446        workdir,
1447        gitdir,
1448    })
1449}
1450
1451/// Build a `GIT_STATE` from an uncompressed records buffer.
1452pub fn msg_git_state(repo_id: u16, state_id: u32, flags: u8, records: &[u8]) -> Vec<u8> {
1453    let compressed = lz4_flex::compress_prepend_size(records);
1454    let mut msg = Vec::with_capacity(8 + compressed.len());
1455    msg.push(S2C_GIT_STATE);
1456    msg.extend_from_slice(&repo_id.to_le_bytes());
1457    msg.extend_from_slice(&state_id.to_le_bytes());
1458    msg.push(flags);
1459    msg.extend_from_slice(&compressed);
1460    msg
1461}
1462
1463/// Parse `S2C_GIT_STATE` into `(repo_id, state_id, flags, records)`,
1464/// decompressed under the standard guard.
1465pub fn parse_git_state(msg: &[u8]) -> Option<(u16, u32, u8, Vec<u8>)> {
1466    let mut b = body_of(msg, S2C_GIT_STATE)?;
1467    let repo_id = take_u16(&mut b)?;
1468    let state_id = take_u32(&mut b)?;
1469    let flags = take_u8(&mut b)?;
1470    let records = decompress_guarded(b)?;
1471    Some((repo_id, state_id, flags, records))
1472}
1473
1474pub fn msg_git_closed(repo_id: u16, reason: u8) -> Vec<u8> {
1475    let mut msg = Vec::with_capacity(4);
1476    msg.push(S2C_GIT_CLOSED);
1477    msg.extend_from_slice(&repo_id.to_le_bytes());
1478    msg.push(reason);
1479    msg
1480}
1481
1482/// Parse `S2C_GIT_CLOSED` into `(repo_id, reason)`.
1483pub fn parse_git_closed(msg: &[u8]) -> Option<(u16, u8)> {
1484    let mut b = body_of(msg, S2C_GIT_CLOSED)?;
1485    let repo_id = take_u16(&mut b)?;
1486    let reason = take_u8(&mut b)?;
1487    Some((repo_id, reason))
1488}
1489
1490/// Build a `GIT_COMMITS` from an uncompressed records buffer.
1491pub fn msg_git_commits(
1492    nonce: u16,
1493    status: u8,
1494    flags: u8,
1495    frontier: &[GitOid],
1496    records: &[u8],
1497) -> Vec<u8> {
1498    let compressed = lz4_flex::compress_prepend_size(records);
1499    let mut msg = Vec::with_capacity(7 + 32 * frontier.len() + compressed.len());
1500    msg.push(S2C_GIT_COMMITS);
1501    msg.extend_from_slice(&nonce.to_le_bytes());
1502    msg.push(status);
1503    msg.push(flags);
1504    msg.extend_from_slice(&(frontier.len() as u16).to_le_bytes());
1505    push_oids(&mut msg, frontier);
1506    msg.extend_from_slice(&compressed);
1507    msg
1508}
1509
1510/// A decoded `S2C_GIT_COMMITS`.
1511#[derive(Clone, Debug, PartialEq, Eq)]
1512pub struct GitCommitsPage {
1513    pub nonce: u16,
1514    pub status: u8,
1515    pub flags: u8,
1516    /// The walk's pending boundary when `MORE` is set: re-issue `GIT_LOG`
1517    /// with `tips = frontier` and the same `hides` to continue.
1518    pub frontier: Vec<GitOid>,
1519    /// Decompressed records buffer; iterate with [`git_commit_records`].
1520    pub records: Vec<u8>,
1521}
1522
1523pub fn parse_git_commits(msg: &[u8]) -> Option<GitCommitsPage> {
1524    let mut b = body_of(msg, S2C_GIT_COMMITS)?;
1525    let nonce = take_u16(&mut b)?;
1526    let status = take_u8(&mut b)?;
1527    let flags = take_u8(&mut b)?;
1528    let n = take_u16(&mut b)? as usize;
1529    let frontier = take_oids(&mut b, n)?;
1530    let records = decompress_guarded(b)?;
1531    Some(GitCommitsPage {
1532        nonce,
1533        status,
1534        flags,
1535        frontier,
1536        records,
1537    })
1538}
1539
1540/// Build the `[nonce:2][status:1][flags:1][payload:LZ4]` response shape
1541/// shared by the tree, diff, patch, and index responses.
1542fn msg_nonce_status_flags_lz4(
1543    opcode: u8,
1544    nonce: u16,
1545    status: u8,
1546    flags: u8,
1547    payload: &[u8],
1548) -> Vec<u8> {
1549    let compressed = lz4_flex::compress_prepend_size(payload);
1550    let mut msg = Vec::with_capacity(5 + compressed.len());
1551    msg.push(opcode);
1552    msg.extend_from_slice(&nonce.to_le_bytes());
1553    msg.push(status);
1554    msg.push(flags);
1555    msg.extend_from_slice(&compressed);
1556    msg
1557}
1558
1559/// Parse the shared `[nonce:2][status:1][flags:1][payload:LZ4]` shape.
1560fn parse_nonce_status_flags_lz4(msg: &[u8], opcode: u8) -> Option<(u16, u8, u8, Vec<u8>)> {
1561    let mut b = body_of(msg, opcode)?;
1562    let nonce = take_u16(&mut b)?;
1563    let status = take_u8(&mut b)?;
1564    let flags = take_u8(&mut b)?;
1565    let payload = decompress_guarded(b)?;
1566    Some((nonce, status, flags, payload))
1567}
1568
1569/// Build a `GIT_TREE` response from an uncompressed records buffer.
1570pub fn msg_git_tree_resp(nonce: u16, status: u8, flags: u8, records: &[u8]) -> Vec<u8> {
1571    msg_nonce_status_flags_lz4(S2C_GIT_TREE, nonce, status, flags, records)
1572}
1573
1574/// Parse an `S2C_GIT_TREE` into `(nonce, status, flags, records)`.
1575pub fn parse_git_tree_resp(msg: &[u8]) -> Option<(u16, u8, u8, Vec<u8>)> {
1576    parse_nonce_status_flags_lz4(msg, S2C_GIT_TREE)
1577}
1578
1579/// Build a `GIT_BLOB` response; `size` is the true object size, `data` the
1580/// (possibly truncated to nothing on error) raw object bytes.
1581pub fn msg_git_blob_resp(nonce: u16, status: u8, size: u64, data: &[u8]) -> Vec<u8> {
1582    let compressed = lz4_flex::compress_prepend_size(data);
1583    let mut msg = Vec::with_capacity(12 + compressed.len());
1584    msg.push(S2C_GIT_BLOB);
1585    msg.extend_from_slice(&nonce.to_le_bytes());
1586    msg.push(status);
1587    msg.extend_from_slice(&size.to_le_bytes());
1588    msg.extend_from_slice(&compressed);
1589    msg
1590}
1591
1592/// Parse an `S2C_GIT_BLOB` into `(nonce, status, size, data)`.
1593pub fn parse_git_blob_resp(msg: &[u8]) -> Option<(u16, u8, u64, Vec<u8>)> {
1594    let mut b = body_of(msg, S2C_GIT_BLOB)?;
1595    let nonce = take_u16(&mut b)?;
1596    let status = take_u8(&mut b)?;
1597    let size = take_u64(&mut b)?;
1598    let data = decompress_guarded(b)?;
1599    Some((nonce, status, size, data))
1600}
1601
1602/// Build a `GIT_DIFF` response from an uncompressed records buffer.
1603pub fn msg_git_diff_resp(nonce: u16, status: u8, flags: u8, records: &[u8]) -> Vec<u8> {
1604    msg_nonce_status_flags_lz4(S2C_GIT_DIFF, nonce, status, flags, records)
1605}
1606
1607/// Parse an `S2C_GIT_DIFF` into `(nonce, status, flags, records)`.
1608pub fn parse_git_diff_resp(msg: &[u8]) -> Option<(u16, u8, u8, Vec<u8>)> {
1609    parse_nonce_status_flags_lz4(msg, S2C_GIT_DIFF)
1610}
1611
1612/// Build a `GIT_PATCH` response. `data` is an uncompressed records buffer
1613/// when `flags` has [`GIT_PATCH_STRUCTURED`], else unified-diff text.
1614pub fn msg_git_patch_resp(nonce: u16, status: u8, flags: u8, data: &[u8]) -> Vec<u8> {
1615    msg_nonce_status_flags_lz4(S2C_GIT_PATCH, nonce, status, flags, data)
1616}
1617
1618/// Parse an `S2C_GIT_PATCH` into `(nonce, status, flags, data)`.
1619pub fn parse_git_patch_resp(msg: &[u8]) -> Option<(u16, u8, u8, Vec<u8>)> {
1620    parse_nonce_status_flags_lz4(msg, S2C_GIT_PATCH)
1621}
1622
1623/// Build a `GIT_INDEX` response from an uncompressed records buffer.
1624pub fn msg_git_index_resp(nonce: u16, status: u8, flags: u8, records: &[u8]) -> Vec<u8> {
1625    msg_nonce_status_flags_lz4(S2C_GIT_INDEX, nonce, status, flags, records)
1626}
1627
1628/// Parse an `S2C_GIT_INDEX` into `(nonce, status, flags, records)`.
1629pub fn parse_git_index_resp(msg: &[u8]) -> Option<(u16, u8, u8, Vec<u8>)> {
1630    parse_nonce_status_flags_lz4(msg, S2C_GIT_INDEX)
1631}
1632
1633/// Build a `GIT_BASE` response; `bases` comes best-first, empty with `OK`
1634/// meaning disjoint histories.
1635pub fn msg_git_base_resp(nonce: u16, status: u8, bases: &[GitOid]) -> Vec<u8> {
1636    let mut msg = Vec::with_capacity(5 + 32 * bases.len());
1637    msg.push(S2C_GIT_BASE);
1638    msg.extend_from_slice(&nonce.to_le_bytes());
1639    msg.push(status);
1640    msg.push(bases.len() as u8);
1641    push_oids(&mut msg, bases);
1642    msg
1643}
1644
1645/// Parse an `S2C_GIT_BASE` into `(nonce, status, bases)`.
1646pub fn parse_git_base_resp(msg: &[u8]) -> Option<(u16, u8, Vec<GitOid>)> {
1647    let mut b = body_of(msg, S2C_GIT_BASE)?;
1648    let nonce = take_u16(&mut b)?;
1649    let status = take_u8(&mut b)?;
1650    let n = take_u8(&mut b)? as usize;
1651    let bases = take_oids(&mut b, n)?;
1652    Some((nonce, status, bases))
1653}
1654
1655pub fn msg_git_resolve_resp(nonce: u16, status: u8, tips: &[GitOid], hides: &[GitOid]) -> Vec<u8> {
1656    let mut msg = Vec::with_capacity(7 + 32 * (tips.len() + hides.len()));
1657    msg.push(S2C_GIT_RESOLVE);
1658    msg.extend_from_slice(&nonce.to_le_bytes());
1659    msg.push(status);
1660    msg.extend_from_slice(&(tips.len() as u16).to_le_bytes());
1661    push_oids(&mut msg, tips);
1662    msg.extend_from_slice(&(hides.len() as u16).to_le_bytes());
1663    push_oids(&mut msg, hides);
1664    msg
1665}
1666
1667/// Parse an `S2C_GIT_RESOLVE` into `(nonce, status, tips, hides)`.
1668pub fn parse_git_resolve_resp(msg: &[u8]) -> Option<(u16, u8, Vec<GitOid>, Vec<GitOid>)> {
1669    let mut b = body_of(msg, S2C_GIT_RESOLVE)?;
1670    let nonce = take_u16(&mut b)?;
1671    let status = take_u8(&mut b)?;
1672    let n_tips = take_u16(&mut b)? as usize;
1673    let tips = take_oids(&mut b, n_tips)?;
1674    let n_hides = take_u16(&mut b)? as usize;
1675    let hides = take_oids(&mut b, n_hides)?;
1676    Some((nonce, status, tips, hides))
1677}
1678
1679pub fn msg_git_log_page(
1680    log_id: u16,
1681    update_id: u32,
1682    status: u8,
1683    flags: u8,
1684    frontier: &[GitOid],
1685    records: &[u8],
1686) -> Vec<u8> {
1687    let compressed = lz4_flex::compress_prepend_size(records);
1688    let mut msg = Vec::with_capacity(11 + 32 * frontier.len() + compressed.len());
1689    msg.push(S2C_GIT_LOG_PAGE);
1690    msg.extend_from_slice(&log_id.to_le_bytes());
1691    msg.extend_from_slice(&update_id.to_le_bytes());
1692    msg.push(status);
1693    msg.push(flags);
1694    msg.extend_from_slice(&(frontier.len() as u16).to_le_bytes());
1695    push_oids(&mut msg, frontier);
1696    msg.extend_from_slice(&compressed);
1697    msg
1698}
1699
1700/// A decoded `S2C_GIT_LOG_PAGE`.
1701#[derive(Clone, Debug, PartialEq, Eq)]
1702pub struct GitLogPage {
1703    pub log_id: u16,
1704    /// Acknowledge with [`msg_git_log_ack`] to receive later updates.
1705    pub update_id: u32,
1706    pub status: u8,
1707    pub flags: u8,
1708    pub frontier: Vec<GitOid>,
1709    pub records: Vec<u8>,
1710}
1711
1712pub fn parse_git_log_page(msg: &[u8]) -> Option<GitLogPage> {
1713    let mut b = body_of(msg, S2C_GIT_LOG_PAGE)?;
1714    let log_id = take_u16(&mut b)?;
1715    let update_id = take_u32(&mut b)?;
1716    let status = take_u8(&mut b)?;
1717    let flags = take_u8(&mut b)?;
1718    let n = take_u16(&mut b)? as usize;
1719    let frontier = take_oids(&mut b, n)?;
1720    let records = decompress_guarded(b)?;
1721    Some(GitLogPage {
1722        log_id,
1723        update_id,
1724        status,
1725        flags,
1726        frontier,
1727        records,
1728    })
1729}
1730
1731// ---------------------------------------------------------------------------
1732// Record codecs
1733//
1734// Every `records:LZ4` payload uses the fs-family framing
1735// (docs/fs-watch.md): `[record_len:4][kind:1][…]`, unknown kinds skipped
1736// via `record_len`, a malformed record ends the payload. Kinds are
1737// namespaced per message type.
1738// ---------------------------------------------------------------------------
1739
1740/// Write the `record_len` placeholder; pair with [`end_record`].
1741fn begin_record(buf: &mut Vec<u8>) -> usize {
1742    let start = buf.len();
1743    buf.extend_from_slice(&0u32.to_le_bytes());
1744    start
1745}
1746
1747fn end_record(buf: &mut [u8], start: usize) {
1748    let len = (buf.len() - start - 4) as u32;
1749    buf[start..start + 4].copy_from_slice(&len.to_le_bytes());
1750}
1751
1752/// Append the family-wide `CURSOR` record: where a budget-truncated
1753/// response stopped, so the client can ask for the rest.
1754fn push_cursor_record(buf: &mut Vec<u8>, after: &str, pos: u64) {
1755    let start = begin_record(buf);
1756    buf.push(GIT_RECORD_CURSOR);
1757    push_str(buf, after);
1758    buf.extend_from_slice(&pos.to_le_bytes());
1759    end_record(buf, start);
1760}
1761
1762fn take_cursor<'a>(b: &mut &'a [u8]) -> Option<(&'a str, u64)> {
1763    let after = take_str(b)?;
1764    let pos = take_u64(b)?;
1765    Some((after, pos))
1766}
1767
1768/// Pop the next framed record as `(kind, body)`. `None` on exhaustion or
1769/// malformed framing.
1770fn next_record<'a>(data: &mut &'a [u8]) -> Option<(u8, &'a [u8])> {
1771    if data.len() < 4 {
1772        return None;
1773    }
1774    let rec_len = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
1775    if rec_len == 0 || data.len() < 4 + rec_len {
1776        return None;
1777    }
1778    let body = &data[4..4 + rec_len];
1779    *data = &data[4 + rec_len..];
1780    Some((body[0], &body[1..]))
1781}
1782
1783/// One decoded record from a `GIT_STATE` payload.
1784#[derive(Clone, Debug, PartialEq, Eq)]
1785pub enum GitStateRecord<'a> {
1786    /// HEAD 0x01: [kind:1][flags:1][oid:32][name_len:2][name:N]
1787    /// `name` is the symbolic target (empty when detached).
1788    Head {
1789        flags: u8,
1790        oid: GitOid,
1791        name: &'a str,
1792    },
1793    /// STATE_REF 0x02: [kind:1][flags:1][oid:32][peeled:32][name_len:2][name:N][target_len:2][target:N]
1794    /// `target` is the symbolic target's full ref name when `SYMBOLIC`,
1795    /// else empty — so `refs/remotes/origin/HEAD` names the default branch
1796    /// instead of only peeling to its oid.
1797    Ref {
1798        flags: u8,
1799        oid: GitOid,
1800        peeled: GitOid,
1801        name: &'a str,
1802        target: &'a str,
1803    },
1804    /// OP 0x03: [kind:1][op:1][oid:32][detail_len:2][detail:N]
1805    /// `oid` is the operation head; an absent record means no operation.
1806    Op {
1807        op: u8,
1808        oid: GitOid,
1809        detail: &'a str,
1810    },
1811    /// STATUS 0x04: [kind:1][staged:1][unstaged:1][flags:1][oid:32][old_len:2][old_path:N][path_len:2][path:N]
1812    /// `staged`/`unstaged` are porcelain letters (ASCII ` `AMDRTU, `?`, `!`);
1813    /// `old_path` is non-empty only for renames.
1814    ///
1815    /// `oid` is the **worktree content's** hash when the status walk read
1816    /// the file, else zero. Without it a write that leaves a file's
1817    /// letters unchanged — an agent editing the same file over and over —
1818    /// produces a byte-identical snapshot, which the engine suppresses, so
1819    /// the server knows the worktree moved and has no way to say so. With
1820    /// it the existing dedupe does the right thing and no new concept is
1821    /// needed.
1822    Status {
1823        staged: u8,
1824        unstaged: u8,
1825        flags: u8,
1826        oid: GitOid,
1827        old_path: &'a str,
1828        path: &'a str,
1829    },
1830    /// UPSTREAM 0x05: [kind:1][flags:1][ahead:4][behind:4][name_len:2][name:N][upstream_len:2][upstream:N]
1831    /// One per local branch with a configured upstream; `name` joins
1832    /// `Ref` records by ref name.
1833    Upstream {
1834        flags: u8,
1835        ahead: u32,
1836        behind: u32,
1837        name: &'a str,
1838        upstream: &'a str,
1839    },
1840    /// STASH 0x06: [kind:1][index:2][oid:32][time:8 i64 s][tz:2 i16 min][msg_len:2][msg:N]
1841    /// `index` is the N of `stash@{N}`, `oid` the stash commit.
1842    Stash {
1843        index: u16,
1844        oid: GitOid,
1845        time: i64,
1846        tz: i16,
1847        msg: &'a str,
1848    },
1849    /// STATE_REMOTE 0x07: [kind:1][flags:1][name_len:2][name:N][fetch_len:2][fetch_url:N][push_len:2][push_url:N]
1850    /// One per configured remote, with the `REMOTES` open flag. URLs go
1851    /// out as configured, userinfo included: the caller already has a
1852    /// shell and can read `.git/config`, so withholding it would only
1853    /// stop them reproducing the remote. `push_url` is empty when it
1854    /// equals `fetch_url`. Not a general config surface: three named
1855    /// fields, no key/value access, no writes.
1856    Remote {
1857        flags: u8,
1858        name: &'a str,
1859        fetch_url: &'a str,
1860        push_url: &'a str,
1861    },
1862}
1863
1864/// Append one record to an uncompressed `GIT_STATE` records buffer.
1865pub fn append_git_state_record(buf: &mut Vec<u8>, record: &GitStateRecord<'_>) {
1866    let start = begin_record(buf);
1867    match record {
1868        GitStateRecord::Head { flags, oid, name } => {
1869            buf.push(GIT_STATE_RECORD_HEAD);
1870            buf.push(*flags);
1871            buf.extend_from_slice(oid);
1872            push_str(buf, name);
1873        }
1874        GitStateRecord::Ref {
1875            flags,
1876            oid,
1877            peeled,
1878            name,
1879            target,
1880        } => {
1881            buf.push(GIT_STATE_RECORD_REF);
1882            buf.push(*flags);
1883            buf.extend_from_slice(oid);
1884            buf.extend_from_slice(peeled);
1885            push_str(buf, name);
1886            push_str(buf, target);
1887        }
1888        GitStateRecord::Op { op, oid, detail } => {
1889            buf.push(GIT_STATE_RECORD_OP);
1890            buf.push(*op);
1891            buf.extend_from_slice(oid);
1892            push_str(buf, detail);
1893        }
1894        GitStateRecord::Status {
1895            staged,
1896            unstaged,
1897            flags,
1898            oid,
1899            old_path,
1900            path,
1901        } => {
1902            buf.push(GIT_STATE_RECORD_STATUS);
1903            buf.push(*staged);
1904            buf.push(*unstaged);
1905            buf.push(*flags);
1906            buf.extend_from_slice(oid);
1907            push_str(buf, old_path);
1908            push_str(buf, path);
1909        }
1910        GitStateRecord::Upstream {
1911            flags,
1912            ahead,
1913            behind,
1914            name,
1915            upstream,
1916        } => {
1917            buf.push(GIT_STATE_RECORD_UPSTREAM);
1918            buf.push(*flags);
1919            buf.extend_from_slice(&ahead.to_le_bytes());
1920            buf.extend_from_slice(&behind.to_le_bytes());
1921            push_str(buf, name);
1922            push_str(buf, upstream);
1923        }
1924        GitStateRecord::Stash {
1925            index,
1926            oid,
1927            time,
1928            tz,
1929            msg,
1930        } => {
1931            buf.push(GIT_STATE_RECORD_STASH);
1932            buf.extend_from_slice(&index.to_le_bytes());
1933            buf.extend_from_slice(oid);
1934            buf.extend_from_slice(&time.to_le_bytes());
1935            buf.extend_from_slice(&tz.to_le_bytes());
1936            push_str(buf, msg);
1937        }
1938        GitStateRecord::Remote {
1939            flags,
1940            name,
1941            fetch_url,
1942            push_url,
1943        } => {
1944            buf.push(GIT_STATE_RECORD_REMOTE);
1945            buf.push(*flags);
1946            push_str(buf, name);
1947            push_str(buf, fetch_url);
1948            push_str(buf, push_url);
1949        }
1950    }
1951    end_record(buf, start);
1952}
1953
1954pub struct GitStateRecordIter<'a> {
1955    data: &'a [u8],
1956}
1957
1958/// Iterate records in an uncompressed `GIT_STATE` payload.
1959pub fn git_state_records(data: &[u8]) -> GitStateRecordIter<'_> {
1960    GitStateRecordIter { data }
1961}
1962
1963impl<'a> Iterator for GitStateRecordIter<'a> {
1964    type Item = GitStateRecord<'a>;
1965
1966    fn next(&mut self) -> Option<GitStateRecord<'a>> {
1967        loop {
1968            let (kind, mut b) = next_record(&mut self.data)?;
1969            match kind {
1970                GIT_STATE_RECORD_HEAD => {
1971                    let flags = take_u8(&mut b)?;
1972                    let oid = take_oid(&mut b)?;
1973                    let name = take_str(&mut b)?;
1974                    return Some(GitStateRecord::Head { flags, oid, name });
1975                }
1976                GIT_STATE_RECORD_REF => {
1977                    let flags = take_u8(&mut b)?;
1978                    let oid = take_oid(&mut b)?;
1979                    let peeled = take_oid(&mut b)?;
1980                    let name = take_str(&mut b)?;
1981                    let target = take_str(&mut b)?;
1982                    return Some(GitStateRecord::Ref {
1983                        flags,
1984                        oid,
1985                        peeled,
1986                        name,
1987                        target,
1988                    });
1989                }
1990                GIT_STATE_RECORD_OP => {
1991                    let op = take_u8(&mut b)?;
1992                    let oid = take_oid(&mut b)?;
1993                    let detail = take_str(&mut b)?;
1994                    return Some(GitStateRecord::Op { op, oid, detail });
1995                }
1996                GIT_STATE_RECORD_STATUS => {
1997                    let staged = take_u8(&mut b)?;
1998                    let unstaged = take_u8(&mut b)?;
1999                    let flags = take_u8(&mut b)?;
2000                    let oid = take_oid(&mut b)?;
2001                    let old_path = take_str(&mut b)?;
2002                    let path = take_str(&mut b)?;
2003                    return Some(GitStateRecord::Status {
2004                        staged,
2005                        unstaged,
2006                        flags,
2007                        oid,
2008                        old_path,
2009                        path,
2010                    });
2011                }
2012                GIT_STATE_RECORD_UPSTREAM => {
2013                    let flags = take_u8(&mut b)?;
2014                    let ahead = take_u32(&mut b)?;
2015                    let behind = take_u32(&mut b)?;
2016                    let name = take_str(&mut b)?;
2017                    let upstream = take_str(&mut b)?;
2018                    return Some(GitStateRecord::Upstream {
2019                        flags,
2020                        ahead,
2021                        behind,
2022                        name,
2023                        upstream,
2024                    });
2025                }
2026                GIT_STATE_RECORD_STASH => {
2027                    let index = take_u16(&mut b)?;
2028                    let oid = take_oid(&mut b)?;
2029                    let time = take_i64(&mut b)?;
2030                    let tz = take_i16(&mut b)?;
2031                    let msg = take_str(&mut b)?;
2032                    return Some(GitStateRecord::Stash {
2033                        index,
2034                        oid,
2035                        time,
2036                        tz,
2037                        msg,
2038                    });
2039                }
2040                GIT_STATE_RECORD_REMOTE => {
2041                    let flags = take_u8(&mut b)?;
2042                    let name = take_str(&mut b)?;
2043                    let fetch_url = take_str(&mut b)?;
2044                    let push_url = take_str(&mut b)?;
2045                    return Some(GitStateRecord::Remote {
2046                        flags,
2047                        name,
2048                        fetch_url,
2049                        push_url,
2050                    });
2051                }
2052                _ => continue, // unknown kind: skip via record_len
2053            }
2054        }
2055    }
2056}
2057
2058/// One decoded record from a `GIT_COMMITS` payload.
2059#[derive(Clone, Debug, PartialEq, Eq)]
2060pub enum GitCommitRecord<'a> {
2061    /// COMMIT 0x01: [kind:1][flags:1][oid:32][tree:32][n_parents:1][parents:32·N]
2062    /// [author_time:8 i64 s][author_tz:2 i16 min][committer_time:8][committer_tz:2]
2063    /// [author_name_len:2][author_name:N][author_email_len:2][email:N]
2064    /// [committer_name_len:2][…][committer_email_len:2][…][msg_len:4][message:N]
2065    Commit {
2066        flags: u8,
2067        oid: GitOid,
2068        tree: GitOid,
2069        parents: Vec<GitOid>,
2070        author_time: i64,
2071        author_tz: i16,
2072        committer_time: i64,
2073        committer_tz: i16,
2074        author_name: &'a str,
2075        author_email: &'a str,
2076        committer_name: &'a str,
2077        committer_email: &'a str,
2078        message: &'a str,
2079    },
2080    /// PATH_AT 0x02: [kind:1][otype:1][mode:4][oid:32][path_len:2][path:N]
2081    /// With `PATH_OIDS`: the object at the followed path as of the preceding
2082    /// COMMIT record; zero oid when that commit deletes it.
2083    PathAt {
2084        otype: u8,
2085        mode: u32,
2086        oid: GitOid,
2087        path: &'a str,
2088    },
2089}
2090
2091/// Append one record to an uncompressed `GIT_COMMITS` records buffer.
2092pub fn append_git_commit_record(buf: &mut Vec<u8>, record: &GitCommitRecord<'_>) {
2093    let start = begin_record(buf);
2094    match record {
2095        GitCommitRecord::Commit {
2096            flags,
2097            oid,
2098            tree,
2099            parents,
2100            author_time,
2101            author_tz,
2102            committer_time,
2103            committer_tz,
2104            author_name,
2105            author_email,
2106            committer_name,
2107            committer_email,
2108            message,
2109        } => {
2110            buf.push(GIT_COMMIT_RECORD_COMMIT);
2111            buf.push(*flags);
2112            buf.extend_from_slice(oid);
2113            buf.extend_from_slice(tree);
2114            buf.push(parents.len() as u8);
2115            push_oids(buf, parents);
2116            buf.extend_from_slice(&author_time.to_le_bytes());
2117            buf.extend_from_slice(&author_tz.to_le_bytes());
2118            buf.extend_from_slice(&committer_time.to_le_bytes());
2119            buf.extend_from_slice(&committer_tz.to_le_bytes());
2120            push_str(buf, author_name);
2121            push_str(buf, author_email);
2122            push_str(buf, committer_name);
2123            push_str(buf, committer_email);
2124            push_bytes(buf, message.as_bytes());
2125        }
2126        GitCommitRecord::PathAt {
2127            otype,
2128            mode,
2129            oid,
2130            path,
2131        } => {
2132            buf.push(GIT_COMMIT_RECORD_PATH_AT);
2133            buf.push(*otype);
2134            buf.extend_from_slice(&mode.to_le_bytes());
2135            buf.extend_from_slice(oid);
2136            push_str(buf, path);
2137        }
2138    }
2139    end_record(buf, start);
2140}
2141
2142pub struct GitCommitRecordIter<'a> {
2143    data: &'a [u8],
2144}
2145
2146/// Iterate records in an uncompressed `GIT_COMMITS` payload.
2147pub fn git_commit_records(data: &[u8]) -> GitCommitRecordIter<'_> {
2148    GitCommitRecordIter { data }
2149}
2150
2151impl<'a> Iterator for GitCommitRecordIter<'a> {
2152    type Item = GitCommitRecord<'a>;
2153
2154    fn next(&mut self) -> Option<GitCommitRecord<'a>> {
2155        loop {
2156            let (kind, mut b) = next_record(&mut self.data)?;
2157            match kind {
2158                GIT_COMMIT_RECORD_COMMIT => {
2159                    let flags = take_u8(&mut b)?;
2160                    let oid = take_oid(&mut b)?;
2161                    let tree = take_oid(&mut b)?;
2162                    let n_parents = take_u8(&mut b)? as usize;
2163                    let parents = take_oids(&mut b, n_parents)?;
2164                    let author_time = take_i64(&mut b)?;
2165                    let author_tz = take_i16(&mut b)?;
2166                    let committer_time = take_i64(&mut b)?;
2167                    let committer_tz = take_i16(&mut b)?;
2168                    let author_name = take_str(&mut b)?;
2169                    let author_email = take_str(&mut b)?;
2170                    let committer_name = take_str(&mut b)?;
2171                    let committer_email = take_str(&mut b)?;
2172                    let message = std::str::from_utf8(take_bytes(&mut b)?).ok()?;
2173                    return Some(GitCommitRecord::Commit {
2174                        flags,
2175                        oid,
2176                        tree,
2177                        parents,
2178                        author_time,
2179                        author_tz,
2180                        committer_time,
2181                        committer_tz,
2182                        author_name,
2183                        author_email,
2184                        committer_name,
2185                        committer_email,
2186                        message,
2187                    });
2188                }
2189                GIT_COMMIT_RECORD_PATH_AT => {
2190                    let otype = take_u8(&mut b)?;
2191                    let mode = take_u32(&mut b)?;
2192                    let oid = take_oid(&mut b)?;
2193                    let path = take_str(&mut b)?;
2194                    return Some(GitCommitRecord::PathAt {
2195                        otype,
2196                        mode,
2197                        oid,
2198                        path,
2199                    });
2200                }
2201                _ => continue, // unknown kind: skip via record_len
2202            }
2203        }
2204    }
2205}
2206
2207/// One decoded record from a `GIT_TREE` response payload.
2208#[derive(Clone, Debug, PartialEq, Eq)]
2209pub enum GitTreeRecord<'a> {
2210    /// TREE_ENTRY 0x02: [kind:1][otype:1][mode:4][oid:32][name_len:2][name:N]
2211    /// `mode` is the raw git mode (100644, 100755, 120000, 40000, 160000).
2212    Entry {
2213        otype: u8,
2214        mode: u32,
2215        oid: GitOid,
2216        name: &'a str,
2217    },
2218    /// CURSOR 0x7F: continue with `after` as the request's `after`.
2219    Cursor { after: &'a str, pos: u64 },
2220}
2221
2222/// Append one record to an uncompressed `GIT_TREE` records buffer.
2223pub fn append_git_tree_record(buf: &mut Vec<u8>, record: &GitTreeRecord<'_>) {
2224    if let GitTreeRecord::Cursor { after, pos } = record {
2225        push_cursor_record(buf, after, *pos);
2226        return;
2227    }
2228    let start = begin_record(buf);
2229    match record {
2230        GitTreeRecord::Entry {
2231            otype,
2232            mode,
2233            oid,
2234            name,
2235        } => {
2236            buf.push(GIT_TREE_RECORD_ENTRY);
2237            buf.push(*otype);
2238            buf.extend_from_slice(&mode.to_le_bytes());
2239            buf.extend_from_slice(oid);
2240            push_str(buf, name);
2241        }
2242        GitTreeRecord::Cursor { .. } => unreachable!("handled above"),
2243    }
2244    end_record(buf, start);
2245}
2246
2247pub struct GitTreeRecordIter<'a> {
2248    data: &'a [u8],
2249}
2250
2251/// Iterate records in an uncompressed `GIT_TREE` response payload.
2252pub fn git_tree_records(data: &[u8]) -> GitTreeRecordIter<'_> {
2253    GitTreeRecordIter { data }
2254}
2255
2256impl<'a> Iterator for GitTreeRecordIter<'a> {
2257    type Item = GitTreeRecord<'a>;
2258
2259    fn next(&mut self) -> Option<GitTreeRecord<'a>> {
2260        loop {
2261            let (kind, mut b) = next_record(&mut self.data)?;
2262            match kind {
2263                GIT_TREE_RECORD_ENTRY => {
2264                    let otype = take_u8(&mut b)?;
2265                    let mode = take_u32(&mut b)?;
2266                    let oid = take_oid(&mut b)?;
2267                    let name = take_str(&mut b)?;
2268                    return Some(GitTreeRecord::Entry {
2269                        otype,
2270                        mode,
2271                        oid,
2272                        name,
2273                    });
2274                }
2275                GIT_RECORD_CURSOR => {
2276                    let (after, pos) = take_cursor(&mut b)?;
2277                    return Some(GitTreeRecord::Cursor { after, pos });
2278                }
2279                _ => continue, // unknown kind: skip via record_len
2280            }
2281        }
2282    }
2283}
2284
2285/// One decoded record from a `GIT_DIFF` response payload.
2286#[derive(Clone, Debug, PartialEq, Eq)]
2287pub enum GitDiffRecord<'a> {
2288    /// DIFF_ENTRY 0x03: [kind:1][st:1][similarity:1][dflags:1]
2289    /// [old_mode:4][new_mode:4][old_oid:32][new_oid:32]
2290    /// [old_len:2][old_path:N][new_len:2][new_path:N]
2291    /// `st` is an ASCII porcelain letter (A M D R C T U); `similarity`
2292    /// 0-100 for renames/copies.
2293    Entry {
2294        st: u8,
2295        similarity: u8,
2296        dflags: u8,
2297        old_mode: u32,
2298        new_mode: u32,
2299        old_oid: GitOid,
2300        new_oid: GitOid,
2301        old_path: &'a str,
2302        new_path: &'a str,
2303    },
2304    /// BASE 0x04: [kind:1][oid:32]
2305    /// First record when a MERGE_BASE endpoint was used: the chosen base.
2306    Base { oid: GitOid },
2307    /// CURSOR 0x7F: continue with `after` as the request's `after`.
2308    Cursor { after: &'a str, pos: u64 },
2309}
2310
2311/// Append one record to an uncompressed `GIT_DIFF` records buffer.
2312pub fn append_git_diff_record(buf: &mut Vec<u8>, record: &GitDiffRecord<'_>) {
2313    if let GitDiffRecord::Cursor { after, pos } = record {
2314        push_cursor_record(buf, after, *pos);
2315        return;
2316    }
2317    let start = begin_record(buf);
2318    match record {
2319        GitDiffRecord::Entry {
2320            st,
2321            similarity,
2322            dflags,
2323            old_mode,
2324            new_mode,
2325            old_oid,
2326            new_oid,
2327            old_path,
2328            new_path,
2329        } => {
2330            buf.push(GIT_DIFF_RECORD_ENTRY);
2331            buf.push(*st);
2332            buf.push(*similarity);
2333            buf.push(*dflags);
2334            buf.extend_from_slice(&old_mode.to_le_bytes());
2335            buf.extend_from_slice(&new_mode.to_le_bytes());
2336            buf.extend_from_slice(old_oid);
2337            buf.extend_from_slice(new_oid);
2338            push_str(buf, old_path);
2339            push_str(buf, new_path);
2340        }
2341        GitDiffRecord::Base { oid } => {
2342            buf.push(GIT_DIFF_RECORD_BASE);
2343            buf.extend_from_slice(oid);
2344        }
2345        GitDiffRecord::Cursor { .. } => unreachable!("handled above"),
2346    }
2347    end_record(buf, start);
2348}
2349
2350pub struct GitDiffRecordIter<'a> {
2351    data: &'a [u8],
2352}
2353
2354/// Iterate records in an uncompressed `GIT_DIFF` response payload.
2355pub fn git_diff_records(data: &[u8]) -> GitDiffRecordIter<'_> {
2356    GitDiffRecordIter { data }
2357}
2358
2359impl<'a> Iterator for GitDiffRecordIter<'a> {
2360    type Item = GitDiffRecord<'a>;
2361
2362    fn next(&mut self) -> Option<GitDiffRecord<'a>> {
2363        loop {
2364            let (kind, mut b) = next_record(&mut self.data)?;
2365            match kind {
2366                GIT_DIFF_RECORD_ENTRY => {
2367                    let st = take_u8(&mut b)?;
2368                    let similarity = take_u8(&mut b)?;
2369                    let dflags = take_u8(&mut b)?;
2370                    let old_mode = take_u32(&mut b)?;
2371                    let new_mode = take_u32(&mut b)?;
2372                    let old_oid = take_oid(&mut b)?;
2373                    let new_oid = take_oid(&mut b)?;
2374                    let old_path = take_str(&mut b)?;
2375                    let new_path = take_str(&mut b)?;
2376                    return Some(GitDiffRecord::Entry {
2377                        st,
2378                        similarity,
2379                        dflags,
2380                        old_mode,
2381                        new_mode,
2382                        old_oid,
2383                        new_oid,
2384                        old_path,
2385                        new_path,
2386                    });
2387                }
2388                GIT_DIFF_RECORD_BASE => {
2389                    let oid = take_oid(&mut b)?;
2390                    return Some(GitDiffRecord::Base { oid });
2391                }
2392                GIT_RECORD_CURSOR => {
2393                    let (after, pos) = take_cursor(&mut b)?;
2394                    return Some(GitDiffRecord::Cursor { after, pos });
2395                }
2396                _ => continue, // unknown kind: skip via record_len
2397            }
2398        }
2399    }
2400}
2401
2402/// One decoded record from a structured `GIT_PATCH` response payload.
2403#[derive(Clone, Debug, PartialEq, Eq)]
2404pub enum GitPatchRecord<'a> {
2405    /// PATCH_FILE 0x01: [kind:1][st:1][similarity:1][flags:1]
2406    /// [old_len:2][old_path:N][new_len:2][new_path:N]
2407    /// Begins a file section. `st`/`similarity` lead, mirroring
2408    /// `DIFF_ENTRY` field for field, so a consumer has one status alphabet
2409    /// and one field order across both views — and so a binary or empty
2410    /// added file, which emits no rows at all, still says whether it was
2411    /// added, deleted or modified. `old_path` carries the old path whenever
2412    /// there is one, not only for renames.
2413    File {
2414        st: u8,
2415        similarity: u8,
2416        flags: u8,
2417        old_path: &'a str,
2418        new_path: &'a str,
2419    },
2420    /// PATCH_ROW 0x02: [kind:1][old_line:4][new_line:4]
2421    /// [old_text_len:4][old_text:N][new_text_len:4][new_text:N]
2422    /// [n_old_spans:2][spans:(start:4,len:4)·N][n_new_spans:2][spans:(start:4,len:4)·N]
2423    /// Line numbers are 1-based; 0 = side absent (pure addition/deletion).
2424    /// Text is the side's true bytes; spans are byte ranges within it.
2425    Row {
2426        old_line: u32,
2427        new_line: u32,
2428        old_text: &'a [u8],
2429        new_text: &'a [u8],
2430        old_spans: Vec<(u32, u32)>,
2431        new_spans: Vec<(u32, u32)>,
2432    },
2433    /// PATCH_GAP 0x03: [kind:1][old_line:4][new_line:4]
2434    /// Elision between hunks (the "@@" of a unified diff).
2435    Gap { old_line: u32, new_line: u32 },
2436    /// BASE 0x04: [kind:1][oid:32] — as in `GIT_DIFF`.
2437    Base { oid: GitOid },
2438    /// CURSOR 0x7F: continue with `after`/`pos` as the request's
2439    /// `after`/`after_pos`, so a file past the byte budget resumes
2440    /// mid-hunk rather than restarting.
2441    Cursor { after: &'a str, pos: u64 },
2442}
2443
2444fn push_spans(buf: &mut Vec<u8>, spans: &[(u32, u32)]) {
2445    buf.extend_from_slice(&(spans.len() as u16).to_le_bytes());
2446    for (start, len) in spans {
2447        buf.extend_from_slice(&start.to_le_bytes());
2448        buf.extend_from_slice(&len.to_le_bytes());
2449    }
2450}
2451
2452fn take_spans(b: &mut &[u8]) -> Option<Vec<(u32, u32)>> {
2453    let n = take_u16(b)? as usize;
2454    if b.len() < n * 8 {
2455        return None;
2456    }
2457    let mut spans = Vec::with_capacity(n);
2458    for _ in 0..n {
2459        let start = take_u32(b)?;
2460        let len = take_u32(b)?;
2461        spans.push((start, len));
2462    }
2463    Some(spans)
2464}
2465
2466/// Append one record to an uncompressed `GIT_PATCH` records buffer.
2467pub fn append_git_patch_record(buf: &mut Vec<u8>, record: &GitPatchRecord<'_>) {
2468    if let GitPatchRecord::Cursor { after, pos } = record {
2469        push_cursor_record(buf, after, *pos);
2470        return;
2471    }
2472    let start = begin_record(buf);
2473    match record {
2474        GitPatchRecord::File {
2475            st,
2476            similarity,
2477            flags,
2478            old_path,
2479            new_path,
2480        } => {
2481            buf.push(GIT_PATCH_RECORD_FILE);
2482            buf.push(*st);
2483            buf.push(*similarity);
2484            buf.push(*flags);
2485            push_str(buf, old_path);
2486            push_str(buf, new_path);
2487        }
2488        GitPatchRecord::Row {
2489            old_line,
2490            new_line,
2491            old_text,
2492            new_text,
2493            old_spans,
2494            new_spans,
2495        } => {
2496            buf.push(GIT_PATCH_RECORD_ROW);
2497            buf.extend_from_slice(&old_line.to_le_bytes());
2498            buf.extend_from_slice(&new_line.to_le_bytes());
2499            push_bytes(buf, old_text);
2500            push_bytes(buf, new_text);
2501            push_spans(buf, old_spans);
2502            push_spans(buf, new_spans);
2503        }
2504        GitPatchRecord::Gap { old_line, new_line } => {
2505            buf.push(GIT_PATCH_RECORD_GAP);
2506            buf.extend_from_slice(&old_line.to_le_bytes());
2507            buf.extend_from_slice(&new_line.to_le_bytes());
2508        }
2509        GitPatchRecord::Base { oid } => {
2510            buf.push(GIT_PATCH_RECORD_BASE);
2511            buf.extend_from_slice(oid);
2512        }
2513        GitPatchRecord::Cursor { .. } => unreachable!("handled above"),
2514    }
2515    end_record(buf, start);
2516}
2517
2518pub struct GitPatchRecordIter<'a> {
2519    data: &'a [u8],
2520}
2521
2522/// Iterate records in an uncompressed structured `GIT_PATCH` payload.
2523pub fn git_patch_records(data: &[u8]) -> GitPatchRecordIter<'_> {
2524    GitPatchRecordIter { data }
2525}
2526
2527impl<'a> Iterator for GitPatchRecordIter<'a> {
2528    type Item = GitPatchRecord<'a>;
2529
2530    fn next(&mut self) -> Option<GitPatchRecord<'a>> {
2531        loop {
2532            let (kind, mut b) = next_record(&mut self.data)?;
2533            match kind {
2534                GIT_PATCH_RECORD_FILE => {
2535                    let st = take_u8(&mut b)?;
2536                    let similarity = take_u8(&mut b)?;
2537                    let flags = take_u8(&mut b)?;
2538                    let old_path = take_str(&mut b)?;
2539                    let new_path = take_str(&mut b)?;
2540                    return Some(GitPatchRecord::File {
2541                        st,
2542                        similarity,
2543                        flags,
2544                        old_path,
2545                        new_path,
2546                    });
2547                }
2548                GIT_PATCH_RECORD_ROW => {
2549                    let old_line = take_u32(&mut b)?;
2550                    let new_line = take_u32(&mut b)?;
2551                    let old_text = take_bytes(&mut b)?;
2552                    let new_text = take_bytes(&mut b)?;
2553                    let old_spans = take_spans(&mut b)?;
2554                    let new_spans = take_spans(&mut b)?;
2555                    return Some(GitPatchRecord::Row {
2556                        old_line,
2557                        new_line,
2558                        old_text,
2559                        new_text,
2560                        old_spans,
2561                        new_spans,
2562                    });
2563                }
2564                GIT_PATCH_RECORD_GAP => {
2565                    let old_line = take_u32(&mut b)?;
2566                    let new_line = take_u32(&mut b)?;
2567                    return Some(GitPatchRecord::Gap { old_line, new_line });
2568                }
2569                GIT_RECORD_CURSOR => {
2570                    let (after, pos) = take_cursor(&mut b)?;
2571                    return Some(GitPatchRecord::Cursor { after, pos });
2572                }
2573                GIT_PATCH_RECORD_BASE => {
2574                    let oid = take_oid(&mut b)?;
2575                    return Some(GitPatchRecord::Base { oid });
2576                }
2577                _ => continue, // unknown kind: skip via record_len
2578            }
2579        }
2580    }
2581}
2582
2583/// One decoded record from a `GIT_INDEX` response payload.
2584#[derive(Clone, Debug, PartialEq, Eq)]
2585pub enum GitIndexRecord<'a> {
2586    /// INDEX_ENTRY 0x04: [kind:1][stage:1][iflags:1][mode:4][size:8][mtime_ns:8][oid:32][path_len:2][path:N]
2587    /// Conflicted paths appear as their stage-1/2/3 entries.
2588    Entry {
2589        stage: u8,
2590        iflags: u8,
2591        mode: u32,
2592        size: u64,
2593        mtime_ns: u64,
2594        oid: GitOid,
2595        path: &'a str,
2596    },
2597    /// CURSOR 0x7F: continue with `after` as the request's `after`.
2598    Cursor { after: &'a str, pos: u64 },
2599}
2600
2601/// Append one record to an uncompressed `GIT_INDEX` records buffer.
2602pub fn append_git_index_record(buf: &mut Vec<u8>, record: &GitIndexRecord<'_>) {
2603    if let GitIndexRecord::Cursor { after, pos } = record {
2604        push_cursor_record(buf, after, *pos);
2605        return;
2606    }
2607    let start = begin_record(buf);
2608    match record {
2609        GitIndexRecord::Entry {
2610            stage,
2611            iflags,
2612            mode,
2613            size,
2614            mtime_ns,
2615            oid,
2616            path,
2617        } => {
2618            buf.push(GIT_INDEX_RECORD_ENTRY);
2619            buf.push(*stage);
2620            buf.push(*iflags);
2621            buf.extend_from_slice(&mode.to_le_bytes());
2622            buf.extend_from_slice(&size.to_le_bytes());
2623            buf.extend_from_slice(&mtime_ns.to_le_bytes());
2624            buf.extend_from_slice(oid);
2625            push_str(buf, path);
2626        }
2627        GitIndexRecord::Cursor { .. } => unreachable!("handled above"),
2628    }
2629    end_record(buf, start);
2630}
2631
2632pub struct GitIndexRecordIter<'a> {
2633    data: &'a [u8],
2634}
2635
2636/// Iterate records in an uncompressed `GIT_INDEX` response payload.
2637pub fn git_index_records(data: &[u8]) -> GitIndexRecordIter<'_> {
2638    GitIndexRecordIter { data }
2639}
2640
2641impl<'a> Iterator for GitIndexRecordIter<'a> {
2642    type Item = GitIndexRecord<'a>;
2643
2644    fn next(&mut self) -> Option<GitIndexRecord<'a>> {
2645        loop {
2646            let (kind, mut b) = next_record(&mut self.data)?;
2647            match kind {
2648                GIT_INDEX_RECORD_ENTRY => {
2649                    let stage = take_u8(&mut b)?;
2650                    let iflags = take_u8(&mut b)?;
2651                    let mode = take_u32(&mut b)?;
2652                    let size = take_u64(&mut b)?;
2653                    let mtime_ns = take_u64(&mut b)?;
2654                    let oid = take_oid(&mut b)?;
2655                    let path = take_str(&mut b)?;
2656                    return Some(GitIndexRecord::Entry {
2657                        stage,
2658                        iflags,
2659                        mode,
2660                        size,
2661                        mtime_ns,
2662                        oid,
2663                        path,
2664                    });
2665                }
2666                GIT_RECORD_CURSOR => {
2667                    let (after, pos) = take_cursor(&mut b)?;
2668                    return Some(GitIndexRecord::Cursor { after, pos });
2669                }
2670                _ => continue, // unknown kind: skip via record_len
2671            }
2672        }
2673    }
2674}
2675
2676// ---------------------------------------------------------------------------
2677// 0x90 block: discover, blame, reflog, fetch
2678// ---------------------------------------------------------------------------
2679
2680macro_rules! records_resp {
2681    ($build:ident, $parse:ident, $opcode:ident) => {
2682        /// Build the response from an uncompressed records buffer.
2683        pub fn $build(nonce: u16, status: u8, flags: u8, records: &[u8]) -> Vec<u8> {
2684            let compressed = lz4_flex::compress_prepend_size(records);
2685            let mut msg = Vec::with_capacity(5 + compressed.len());
2686            msg.push($opcode);
2687            msg.extend_from_slice(&nonce.to_le_bytes());
2688            msg.push(status);
2689            msg.push(flags);
2690            msg.extend_from_slice(&compressed);
2691            msg
2692        }
2693
2694        /// Parse into `(nonce, status, flags, records)`, decompressing.
2695        pub fn $parse(msg: &[u8]) -> Option<(u16, u8, u8, Vec<u8>)> {
2696            let mut b = body_of(msg, $opcode)?;
2697            let nonce = take_u16(&mut b)?;
2698            let status = take_u8(&mut b)?;
2699            let flags = take_u8(&mut b)?;
2700            let records = decompress_guarded(b)?;
2701            Some((nonce, status, flags, records))
2702        }
2703    };
2704}
2705
2706records_resp!(
2707    msg_git_discover_resp,
2708    parse_git_discover_resp,
2709    S2C_GIT_DISCOVER
2710);
2711records_resp!(msg_git_blame_resp, parse_git_blame_resp, S2C_GIT_BLAME);
2712records_resp!(msg_git_reflog_resp, parse_git_reflog_resp, S2C_GIT_REFLOG);
2713records_resp!(msg_git_fetch_resp, parse_git_fetch_resp, S2C_GIT_FETCH);
2714
2715/// One decoded record from a `GIT_DISCOVER` response payload.
2716#[derive(Clone, Debug, PartialEq, Eq)]
2717pub enum GitDiscoverRecord<'a> {
2718    /// REPO_FOUND 0x01: [kind:1][flags:1][workdir_len:2][workdir:N][gitdir_len:2][gitdir:N]
2719    /// Deduped by canonical gitdir — the identity `GIT_OPEN` reports and
2720    /// the one that survives several paths resolving to one repository.
2721    Repo {
2722        flags: u8,
2723        workdir: &'a str,
2724        gitdir: &'a str,
2725    },
2726    Cursor {
2727        after: &'a str,
2728        pos: u64,
2729    },
2730}
2731
2732pub fn append_git_discover_record(buf: &mut Vec<u8>, record: &GitDiscoverRecord<'_>) {
2733    match record {
2734        GitDiscoverRecord::Cursor { after, pos } => push_cursor_record(buf, after, *pos),
2735        GitDiscoverRecord::Repo {
2736            flags,
2737            workdir,
2738            gitdir,
2739        } => {
2740            let start = begin_record(buf);
2741            buf.push(GIT_DISCOVER_RECORD_REPO);
2742            buf.push(*flags);
2743            push_str(buf, workdir);
2744            push_str(buf, gitdir);
2745            end_record(buf, start);
2746        }
2747    }
2748}
2749
2750pub struct GitDiscoverRecordIter<'a> {
2751    data: &'a [u8],
2752}
2753
2754pub fn git_discover_records(data: &[u8]) -> GitDiscoverRecordIter<'_> {
2755    GitDiscoverRecordIter { data }
2756}
2757
2758impl<'a> Iterator for GitDiscoverRecordIter<'a> {
2759    type Item = GitDiscoverRecord<'a>;
2760
2761    fn next(&mut self) -> Option<GitDiscoverRecord<'a>> {
2762        loop {
2763            let (kind, mut b) = next_record(&mut self.data)?;
2764            match kind {
2765                GIT_DISCOVER_RECORD_REPO => {
2766                    let flags = take_u8(&mut b)?;
2767                    let workdir = take_str(&mut b)?;
2768                    let gitdir = take_str(&mut b)?;
2769                    return Some(GitDiscoverRecord::Repo {
2770                        flags,
2771                        workdir,
2772                        gitdir,
2773                    });
2774                }
2775                GIT_RECORD_CURSOR => {
2776                    let (after, pos) = take_cursor(&mut b)?;
2777                    return Some(GitDiscoverRecord::Cursor { after, pos });
2778                }
2779                _ => continue,
2780            }
2781        }
2782    }
2783}
2784
2785/// One decoded record from a `GIT_BLAME` response payload.
2786#[derive(Clone, Debug, PartialEq, Eq)]
2787pub enum GitBlameRecord<'a> {
2788    /// BLAME_RANGE 0x01: [kind:1][flags:1][commit:32][start_line:4][line_count:4][orig_start:4][orig_path_len:2][orig_path:N]
2789    /// One per contiguous attributed range. Author and message are
2790    /// deliberately absent: the client resolves the distinct commit oids
2791    /// with one `GIT_LOG`, or finds them already in its oid-keyed cache,
2792    /// which keeps a viewport blame to a few hundred bytes.
2793    Range {
2794        flags: u8,
2795        commit: GitOid,
2796        start_line: u32,
2797        line_count: u32,
2798        orig_start: u32,
2799        /// Empty unless the range came from a different path.
2800        orig_path: &'a str,
2801    },
2802    Cursor {
2803        after: &'a str,
2804        pos: u64,
2805    },
2806}
2807
2808pub fn append_git_blame_record(buf: &mut Vec<u8>, record: &GitBlameRecord<'_>) {
2809    match record {
2810        GitBlameRecord::Cursor { after, pos } => push_cursor_record(buf, after, *pos),
2811        GitBlameRecord::Range {
2812            flags,
2813            commit,
2814            start_line,
2815            line_count,
2816            orig_start,
2817            orig_path,
2818        } => {
2819            let start = begin_record(buf);
2820            buf.push(GIT_BLAME_RECORD_RANGE);
2821            buf.push(*flags);
2822            buf.extend_from_slice(commit);
2823            buf.extend_from_slice(&start_line.to_le_bytes());
2824            buf.extend_from_slice(&line_count.to_le_bytes());
2825            buf.extend_from_slice(&orig_start.to_le_bytes());
2826            push_str(buf, orig_path);
2827            end_record(buf, start);
2828        }
2829    }
2830}
2831
2832pub struct GitBlameRecordIter<'a> {
2833    data: &'a [u8],
2834}
2835
2836pub fn git_blame_records(data: &[u8]) -> GitBlameRecordIter<'_> {
2837    GitBlameRecordIter { data }
2838}
2839
2840impl<'a> Iterator for GitBlameRecordIter<'a> {
2841    type Item = GitBlameRecord<'a>;
2842
2843    fn next(&mut self) -> Option<GitBlameRecord<'a>> {
2844        loop {
2845            let (kind, mut b) = next_record(&mut self.data)?;
2846            match kind {
2847                GIT_BLAME_RECORD_RANGE => {
2848                    let flags = take_u8(&mut b)?;
2849                    let commit = take_oid(&mut b)?;
2850                    let start_line = take_u32(&mut b)?;
2851                    let line_count = take_u32(&mut b)?;
2852                    let orig_start = take_u32(&mut b)?;
2853                    let orig_path = take_str(&mut b)?;
2854                    return Some(GitBlameRecord::Range {
2855                        flags,
2856                        commit,
2857                        start_line,
2858                        line_count,
2859                        orig_start,
2860                        orig_path,
2861                    });
2862                }
2863                GIT_RECORD_CURSOR => {
2864                    let (after, pos) = take_cursor(&mut b)?;
2865                    return Some(GitBlameRecord::Cursor { after, pos });
2866                }
2867                _ => continue,
2868            }
2869        }
2870    }
2871}
2872
2873/// One decoded record from a `GIT_REFLOG` response payload.
2874#[derive(Clone, Debug, PartialEq, Eq)]
2875pub enum GitReflogRecord<'a> {
2876    /// REFLOG_ENTRY 0x01: [kind:1][flags:1][old:32][new:32][time:8 i64 s][tz:2 i16 min][msg_len:2][msg:N]
2877    /// Entry signatures are omitted: the message carries the operation,
2878    /// which is what a caller reads.
2879    Entry {
2880        flags: u8,
2881        old: GitOid,
2882        new: GitOid,
2883        time: i64,
2884        tz: i16,
2885        msg: &'a str,
2886    },
2887    Cursor {
2888        after: &'a str,
2889        pos: u64,
2890    },
2891}
2892
2893pub fn append_git_reflog_record(buf: &mut Vec<u8>, record: &GitReflogRecord<'_>) {
2894    match record {
2895        GitReflogRecord::Cursor { after, pos } => push_cursor_record(buf, after, *pos),
2896        GitReflogRecord::Entry {
2897            flags,
2898            old,
2899            new,
2900            time,
2901            tz,
2902            msg,
2903        } => {
2904            let start = begin_record(buf);
2905            buf.push(GIT_REFLOG_RECORD_ENTRY);
2906            buf.push(*flags);
2907            buf.extend_from_slice(old);
2908            buf.extend_from_slice(new);
2909            buf.extend_from_slice(&time.to_le_bytes());
2910            buf.extend_from_slice(&tz.to_le_bytes());
2911            push_str(buf, msg);
2912            end_record(buf, start);
2913        }
2914    }
2915}
2916
2917pub struct GitReflogRecordIter<'a> {
2918    data: &'a [u8],
2919}
2920
2921pub fn git_reflog_records(data: &[u8]) -> GitReflogRecordIter<'_> {
2922    GitReflogRecordIter { data }
2923}
2924
2925impl<'a> Iterator for GitReflogRecordIter<'a> {
2926    type Item = GitReflogRecord<'a>;
2927
2928    fn next(&mut self) -> Option<GitReflogRecord<'a>> {
2929        loop {
2930            let (kind, mut b) = next_record(&mut self.data)?;
2931            match kind {
2932                GIT_REFLOG_RECORD_ENTRY => {
2933                    let flags = take_u8(&mut b)?;
2934                    let old = take_oid(&mut b)?;
2935                    let new = take_oid(&mut b)?;
2936                    let time = take_i64(&mut b)?;
2937                    let tz = take_i16(&mut b)?;
2938                    let msg = take_str(&mut b)?;
2939                    return Some(GitReflogRecord::Entry {
2940                        flags,
2941                        old,
2942                        new,
2943                        time,
2944                        tz,
2945                        msg,
2946                    });
2947                }
2948                GIT_RECORD_CURSOR => {
2949                    let (after, pos) = take_cursor(&mut b)?;
2950                    return Some(GitReflogRecord::Cursor { after, pos });
2951                }
2952                _ => continue,
2953            }
2954        }
2955    }
2956}
2957
2958/// One decoded record from a `GIT_FETCH` response payload.
2959#[derive(Clone, Debug, PartialEq, Eq)]
2960pub enum GitFetchRecord<'a> {
2961    /// FETCH_REF 0x01: [kind:1][flags:1][status:1][old:32][new:32][name_len:2][name:N][detail_len:2][detail:N]
2962    /// One per ref the remote answered for. `status` is the unified table,
2963    /// so "did I actually get these commits" is answerable from the reply
2964    /// rather than needing a `resolve` per commit afterwards — a remote can
2965    /// refuse one refspec of several and still exit zero.
2966    Ref {
2967        flags: u8,
2968        status: u8,
2969        old: GitOid,
2970        new: GitOid,
2971        name: &'a str,
2972        detail: &'a str,
2973    },
2974}
2975
2976pub fn append_git_fetch_record(buf: &mut Vec<u8>, record: &GitFetchRecord<'_>) {
2977    let start = begin_record(buf);
2978    match record {
2979        GitFetchRecord::Ref {
2980            flags,
2981            status,
2982            old,
2983            new,
2984            name,
2985            detail,
2986        } => {
2987            buf.push(GIT_FETCH_RECORD_REF);
2988            buf.push(*flags);
2989            buf.push(*status);
2990            buf.extend_from_slice(old);
2991            buf.extend_from_slice(new);
2992            push_str(buf, name);
2993            push_str(buf, detail);
2994        }
2995    }
2996    end_record(buf, start);
2997}
2998
2999pub struct GitFetchRecordIter<'a> {
3000    data: &'a [u8],
3001}
3002
3003pub fn git_fetch_records(data: &[u8]) -> GitFetchRecordIter<'_> {
3004    GitFetchRecordIter { data }
3005}
3006
3007impl<'a> Iterator for GitFetchRecordIter<'a> {
3008    type Item = GitFetchRecord<'a>;
3009
3010    fn next(&mut self) -> Option<GitFetchRecord<'a>> {
3011        loop {
3012            let (kind, mut b) = next_record(&mut self.data)?;
3013            match kind {
3014                GIT_FETCH_RECORD_REF => {
3015                    let flags = take_u8(&mut b)?;
3016                    let status = take_u8(&mut b)?;
3017                    let old = take_oid(&mut b)?;
3018                    let new = take_oid(&mut b)?;
3019                    let name = take_str(&mut b)?;
3020                    let detail = take_str(&mut b)?;
3021                    return Some(GitFetchRecord::Ref {
3022                        flags,
3023                        status,
3024                        old,
3025                        new,
3026                        name,
3027                        detail,
3028                    });
3029                }
3030                _ => continue,
3031            }
3032        }
3033    }
3034}
3035
3036// ---------------------------------------------------------------------------
3037// Client-side state reducer
3038// ---------------------------------------------------------------------------
3039
3040/// The current HEAD.
3041#[derive(Clone, Debug, Default, PartialEq, Eq)]
3042pub struct GitHead {
3043    pub flags: u8,
3044    pub oid: GitOid,
3045    /// Symbolic target (empty when detached).
3046    pub name: String,
3047}
3048
3049/// One ref, keyed by name in [`GitStateMirror::refs`].
3050#[derive(Clone, Debug, Default, PartialEq, Eq)]
3051pub struct GitRefState {
3052    pub flags: u8,
3053    pub oid: GitOid,
3054    /// Valid only with [`GIT_REF_PEELED_VALID`].
3055    pub peeled: GitOid,
3056    /// The symbolic target's ref name, with [`GIT_REF_SYMBOLIC`]; empty
3057    /// otherwise. This is how a client names the default branch instead of
3058    /// guessing `main` then `master`.
3059    pub target: String,
3060}
3061
3062/// One configured remote, keyed by name in [`GitStateMirror::remotes`].
3063#[derive(Clone, Debug, Default, PartialEq, Eq)]
3064pub struct GitRemoteState {
3065    pub flags: u8,
3066    /// Userinfo-stripped: the server never emits a credential.
3067    pub fetch_url: String,
3068    /// Empty when it equals `fetch_url`.
3069    pub push_url: String,
3070}
3071
3072/// The in-progress operation, if any.
3073#[derive(Clone, Debug, Default, PartialEq, Eq)]
3074pub struct GitOpState {
3075    pub op: u8,
3076    pub oid: GitOid,
3077    pub detail: String,
3078}
3079
3080/// One index/worktree status entry.
3081#[derive(Clone, Debug, Default, PartialEq, Eq)]
3082pub struct GitStatusEntry {
3083    pub staged: u8,
3084    pub unstaged: u8,
3085    pub flags: u8,
3086    /// The worktree content hash when the status walk read the file, else
3087    /// zero. A change that leaves the letters alone still moves this.
3088    pub oid: GitOid,
3089    /// Non-empty only for renames.
3090    pub old_path: String,
3091    pub path: String,
3092}
3093
3094/// Upstream tracking for one local branch, keyed by branch ref name in
3095/// [`GitStateMirror::upstreams`].
3096#[derive(Clone, Debug, Default, PartialEq, Eq)]
3097pub struct GitUpstreamState {
3098    pub flags: u8,
3099    pub ahead: u32,
3100    pub behind: u32,
3101    pub upstream: String,
3102}
3103
3104/// One stash entry.
3105#[derive(Clone, Debug, Default, PartialEq, Eq)]
3106pub struct GitStashEntry {
3107    /// The N of `stash@{N}`.
3108    pub index: u16,
3109    pub oid: GitOid,
3110    pub time: i64,
3111    pub tz: i16,
3112    pub message: String,
3113}
3114
3115/// The complete client obligation for `GIT_STATE`: each snapshot replaces
3116/// the whole typed state — no diffing, no staging (docs/git.md
3117/// "GIT_STATE / GIT_ACK").
3118#[derive(Clone, Debug, Default, PartialEq, Eq)]
3119pub struct GitStateMirror {
3120    pub head: Option<GitHead>,
3121    /// Keyed by escaped ref name.
3122    pub refs: BTreeMap<String, GitRefState>,
3123    pub op: Option<GitOpState>,
3124    pub status: Vec<GitStatusEntry>,
3125    /// Keyed by local branch ref name (joins `refs`).
3126    pub upstreams: BTreeMap<String, GitUpstreamState>,
3127    pub stashes: Vec<GitStashEntry>,
3128    /// Keyed by remote name; populated with the `REMOTES` open flag.
3129    pub remotes: BTreeMap<String, GitRemoteState>,
3130    /// The last snapshot's truncation flags (`GIT_STATE_*_TRUNCATED`).
3131    pub flags: u8,
3132    /// Records accumulated from `PARTIAL` chunks of the snapshot in
3133    /// flight. The map above is only replaced once the final chunk lands,
3134    /// so a consumer never observes a half-built snapshot.
3135    pending: Vec<u8>,
3136    pending_state_id: u32,
3137}
3138
3139/// What one `GIT_STATE` message did to the mirror.
3140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3141pub enum GitStateApply {
3142    /// The snapshot is complete and installed; acknowledge this `state_id`.
3143    Complete(u32),
3144    /// A `PARTIAL` chunk was buffered. Nothing to acknowledge yet — the
3145    /// ack goes with the final chunk, so the one-in-flight pacing holds.
3146    Partial,
3147    /// Malformed; the pending buffer was dropped.
3148    Malformed,
3149}
3150
3151impl GitStateApply {
3152    /// The `state_id` to acknowledge, or `None` while a chunked snapshot is
3153    /// still assembling (or on a malformed message) — in both of those
3154    /// cases there is nothing to ack and nothing new to render.
3155    pub fn complete(self) -> Option<u32> {
3156        match self {
3157            GitStateApply::Complete(state_id) => Some(state_id),
3158            GitStateApply::Partial | GitStateApply::Malformed => None,
3159        }
3160    }
3161}
3162
3163impl GitStateMirror {
3164    pub fn new() -> Self {
3165        Self::default()
3166    }
3167
3168    /// Apply one `GIT_STATE` message (starting at the opcode byte),
3169    /// replacing the whole state. A snapshot too large for one message
3170    /// arrives as several `PARTIAL` chunks sharing a `state_id`; those are
3171    /// buffered and installed together.
3172    pub fn apply_state(&mut self, msg: &[u8]) -> GitStateApply {
3173        let Some((_repo_id, state_id, flags, records)) = parse_git_state(msg) else {
3174            self.pending.clear();
3175            return GitStateApply::Malformed;
3176        };
3177        // A chunk for a different snapshot supersedes whatever was buffered:
3178        // the server only ever moves forward, so a stale partial is dead.
3179        if state_id != self.pending_state_id {
3180            self.pending.clear();
3181            self.pending_state_id = state_id;
3182        }
3183        if flags & GIT_STATE_PARTIAL != 0 {
3184            self.pending.extend_from_slice(&records);
3185            return GitStateApply::Partial;
3186        }
3187        let records = if self.pending.is_empty() {
3188            records
3189        } else {
3190            let mut whole = std::mem::take(&mut self.pending);
3191            whole.extend_from_slice(&records);
3192            whole
3193        };
3194        if self.apply_records(flags, &records) {
3195            GitStateApply::Complete(state_id)
3196        } else {
3197            GitStateApply::Malformed
3198        }
3199    }
3200
3201    /// Install a complete records buffer as the new state. Always true
3202    /// today — unknown kinds are skipped and a malformed record ends the
3203    /// payload — but kept as a result so a stricter reducer can refuse.
3204    fn apply_records(&mut self, flags: u8, records: &[u8]) -> bool {
3205        let mut next = GitStateMirror {
3206            flags,
3207            ..Default::default()
3208        };
3209        for record in git_state_records(records) {
3210            match record {
3211                GitStateRecord::Head { flags, oid, name } => {
3212                    next.head = Some(GitHead {
3213                        flags,
3214                        oid,
3215                        name: name.to_string(),
3216                    });
3217                }
3218                GitStateRecord::Ref {
3219                    flags,
3220                    oid,
3221                    peeled,
3222                    name,
3223                    target,
3224                } => {
3225                    next.refs.insert(
3226                        name.to_string(),
3227                        GitRefState {
3228                            flags,
3229                            oid,
3230                            peeled,
3231                            target: target.to_string(),
3232                        },
3233                    );
3234                }
3235                GitStateRecord::Op { op, oid, detail } => {
3236                    next.op = Some(GitOpState {
3237                        op,
3238                        oid,
3239                        detail: detail.to_string(),
3240                    });
3241                }
3242                GitStateRecord::Status {
3243                    staged,
3244                    unstaged,
3245                    flags,
3246                    oid,
3247                    old_path,
3248                    path,
3249                } => {
3250                    next.status.push(GitStatusEntry {
3251                        staged,
3252                        unstaged,
3253                        flags,
3254                        oid,
3255                        old_path: old_path.to_string(),
3256                        path: path.to_string(),
3257                    });
3258                }
3259                GitStateRecord::Upstream {
3260                    flags,
3261                    ahead,
3262                    behind,
3263                    name,
3264                    upstream,
3265                } => {
3266                    next.upstreams.insert(
3267                        name.to_string(),
3268                        GitUpstreamState {
3269                            flags,
3270                            ahead,
3271                            behind,
3272                            upstream: upstream.to_string(),
3273                        },
3274                    );
3275                }
3276                GitStateRecord::Stash {
3277                    index,
3278                    oid,
3279                    time,
3280                    tz,
3281                    msg,
3282                } => {
3283                    next.stashes.push(GitStashEntry {
3284                        index,
3285                        oid,
3286                        time,
3287                        tz,
3288                        message: msg.to_string(),
3289                    });
3290                }
3291                GitStateRecord::Remote {
3292                    flags,
3293                    name,
3294                    fetch_url,
3295                    push_url,
3296                } => {
3297                    next.remotes.insert(
3298                        name.to_string(),
3299                        GitRemoteState {
3300                            flags,
3301                            fetch_url: fetch_url.to_string(),
3302                            push_url: push_url.to_string(),
3303                        },
3304                    );
3305                }
3306            }
3307        }
3308        *self = next;
3309        true
3310    }
3311}
3312
3313#[cfg(test)]
3314mod tests {
3315    use super::*;
3316
3317    /// A field longer than its `u16` prefix must be shortened, not wrapped.
3318    /// A repository is attacker-supplied: escaping expands a non-UTF-8 byte
3319    /// about sixfold, so an ~11 KB tree-entry name — legal, uncapped —
3320    /// escapes past 64 KiB, and `len as u16` then desynced every field after
3321    /// it in the response.
3322    #[test]
3323    fn overlong_strings_are_clipped_not_wrapped() {
3324        let mut buf = Vec::new();
3325        // One byte past the prefix's reach: a wrap would declare length 0.
3326        let long = "a".repeat(MAX_STR + 1);
3327        push_str(&mut buf, &long);
3328        let declared = u16::from_le_bytes([buf[0], buf[1]]) as usize;
3329        assert_eq!(declared, MAX_STR);
3330        assert_eq!(buf.len(), 2 + declared, "prefix must match the bytes");
3331
3332        // Clipping lands on a char boundary, so the field stays decodable —
3333        // 'é' is two bytes, and the cut falls inside one of them.
3334        let mut buf = Vec::new();
3335        let wide = "é".repeat(MAX_STR);
3336        push_str(&mut buf, &wide);
3337        let declared = u16::from_le_bytes([buf[0], buf[1]]) as usize;
3338        assert!(declared <= MAX_STR);
3339        assert_eq!(buf.len(), 2 + declared);
3340        std::str::from_utf8(&buf[2..]).expect("clipped field is still UTF-8");
3341    }
3342
3343    #[test]
3344    fn git_open_pty_context_and_rebase() {
3345        let m = msg_git_open(&GitOpenRequest {
3346            src_pty_id: 9,
3347            ..GitOpenRequest::new(1, GIT_OPEN_WATCH, "sub")
3348        });
3349        let req = parse_git_open(&m).unwrap();
3350        assert_eq!(req.src_pty_id, 9);
3351        assert_eq!(req.parent_repo_id, GIT_OPEN_NO_CONTEXT);
3352        assert_eq!(req.path, "sub");
3353        // Rebase joins cwd + path and clears the pty context, leaving the
3354        // plain path-based open the handler consumes.
3355        let reb = git_open_rebase(&m, Some("/w")).unwrap();
3356        let rebased = parse_git_open(&reb).unwrap();
3357        assert_eq!(rebased.src_pty_id, GIT_OPEN_NO_CONTEXT);
3358        assert_eq!(rebased.path, "/w/sub");
3359        // A context-free open is not a rebase candidate.
3360        let plain = msg_git_open(&GitOpenRequest::new(1, GIT_OPEN_WATCH, "sub"));
3361        assert_eq!(git_open_rebase(&plain, Some("/w")), None);
3362    }
3363
3364    #[test]
3365    fn git_open_parent_repo_and_prefixes() {
3366        // A submodule is named by (parent, path): the server resolves the
3367        // gitdir, so the client never guesses where .gitmodules put it.
3368        let m = msg_git_open(&GitOpenRequest {
3369            parent_repo_id: 4,
3370            ref_prefixes: vec!["refs/heads/", "refs/remotes/origin/"],
3371            ..GitOpenRequest::new(2, GIT_OPEN_WATCH | GIT_OPEN_REMOTES, "vendor/lib")
3372        });
3373        let req = parse_git_open(&m).unwrap();
3374        assert_eq!(req.parent_repo_id, 4);
3375        assert_eq!(req.src_pty_id, GIT_OPEN_NO_CONTEXT);
3376        assert_eq!(
3377            req.ref_prefixes,
3378            vec!["refs/heads/", "refs/remotes/origin/"]
3379        );
3380        assert_eq!(req.path, "vendor/lib");
3381        assert_eq!(req.flags & GIT_OPEN_REMOTES, GIT_OPEN_REMOTES);
3382    }
3383
3384    /// A fixture oid: `fill` repeated over the hash width, zero-padded to
3385    /// 32 bytes like a SHA-1 oid on the wire.
3386    fn oid(fill: u8) -> GitOid {
3387        let mut o = [0u8; 32];
3388        o[..20].fill(fill);
3389        o
3390    }
3391
3392    fn hex(b: &[u8]) -> String {
3393        b.iter().map(|x| format!("{x:02x}")).collect()
3394    }
3395
3396    #[test]
3397    fn request_roundtrips() {
3398        let open = GitOpenRequest {
3399            refs_latency_ms: 50,
3400            status_latency_ms: 500,
3401            ..GitOpenRequest::new(1, GIT_OPEN_WATCH | GIT_OPEN_STATUS, "/repo")
3402        };
3403        assert_eq!(parse_git_open(&msg_git_open(&open)), Some(open));
3404        // Empty path and zero windows (server defaults).
3405        let bare = GitOpenRequest::new(0, 0, "");
3406        assert_eq!(parse_git_open(&msg_git_open(&bare)), Some(bare));
3407
3408        assert_eq!(parse_git_close(&msg_git_close(7)), Some(7));
3409        assert_eq!(
3410            parse_git_ack(&msg_git_ack(7, u32::MAX)),
3411            Some((7, u32::MAX))
3412        );
3413
3414        let tips = vec![oid(0xAA), oid(0xAB)];
3415        let hides = vec![oid(0xBB)];
3416        let msg = msg_git_log(
3417            3,
3418            7,
3419            GIT_LOG_FOLLOW | GIT_LOG_PATH_OIDS,
3420            100,
3421            "src/a.rs",
3422            &tips,
3423            &hides,
3424        );
3425        assert_eq!(
3426            parse_git_log(&msg),
3427            Some(GitLogRequest {
3428                nonce: 3,
3429                repo_id: 7,
3430                flags: GIT_LOG_FOLLOW | GIT_LOG_PATH_OIDS,
3431                limit: 100,
3432                path: "src/a.rs",
3433                tips,
3434                hides,
3435            })
3436        );
3437        // Empty tips (= HEAD), empty hides, no filter.
3438        let msg = msg_git_log(4, 7, 0, 0, "", &[], &[]);
3439        assert_eq!(
3440            parse_git_log(&msg),
3441            Some(GitLogRequest {
3442                nonce: 4,
3443                repo_id: 7,
3444                flags: 0,
3445                limit: 0,
3446                path: "",
3447                tips: vec![],
3448                hides: vec![],
3449            })
3450        );
3451
3452        let tree = GitTreeRequest {
3453            nonce: 4,
3454            repo_id: 7,
3455            flags: 0,
3456            oid: oid(0xCC),
3457            path: "dir/%FF",
3458            after: "dir/%FF/z.txt",
3459        };
3460        assert_eq!(parse_git_tree(&msg_git_tree(&tree)), Some(tree));
3461
3462        // A window: offset plus max_len, with WHOLE clear.
3463        let blob = GitBlobRequest {
3464            nonce: 5,
3465            repo_id: 7,
3466            flags: 0,
3467            oid: oid(0xDD),
3468            path: "",
3469            offset: 1 << 24,
3470            max_len: 1 << 20,
3471        };
3472        assert_eq!(parse_git_blob(&msg_git_blob(&blob)), Some(blob));
3473
3474        let old = GitEndpoint {
3475            kind: GIT_ENDPOINT_COMMIT,
3476            oid: oid(0x11),
3477        };
3478        let new = GitEndpoint {
3479            kind: GIT_ENDPOINT_WORKTREE,
3480            oid: GIT_OID_NONE,
3481        };
3482        let diff = GitDiffRequest {
3483            nonce: 6,
3484            repo_id: 7,
3485            flags: GIT_DIFF_RENAMES,
3486            rename: 50,
3487            old,
3488            new,
3489            path: "sub",
3490            after: "sub/b.txt",
3491        };
3492        assert_eq!(parse_git_diff(&msg_git_diff(&diff)), Some(diff));
3493
3494        let patch = GitPatchRequest {
3495            nonce: 8,
3496            repo_id: 7,
3497            flags: GIT_PATCH_RENAMES | GIT_PATCH_CHAR_SPANS,
3498            context: 5,
3499            rename: 0,
3500            old,
3501            new,
3502            path: "a.txt",
3503            max_len: 1 << 16,
3504            after: "a.txt",
3505            after_pos: 4096,
3506        };
3507        assert_eq!(parse_git_patch(&msg_git_patch(&patch)), Some(patch));
3508
3509        let index = GitIndexRequest {
3510            nonce: 9,
3511            repo_id: 7,
3512            flags: 0,
3513            path: "sub",
3514            after: "",
3515        };
3516        assert_eq!(parse_git_index(&msg_git_index(&index)), Some(index));
3517        assert_eq!(parse_git_cancel(&msg_git_cancel(10)), Some(10));
3518
3519        let oids = vec![oid(0xAA), oid(0xBB), oid(0xCC)];
3520        let msg = msg_git_base(11, 7, &oids);
3521        assert_eq!(parse_git_base(&msg), Some((11, 7, oids)));
3522
3523        assert_eq!(
3524            parse_git_resolve(&msg_git_resolve(12, 7, "main..dev")),
3525            Some((12, 7, "main..dev"))
3526        );
3527        assert_eq!(
3528            parse_git_log_watch(&msg_git_log_watch(1, 7, GIT_LOG_FIRST_PARENT, 100, "main")),
3529            Some((1, 7, GIT_LOG_FIRST_PARENT, 100, "main"))
3530        );
3531        // Empty spec (= HEAD default), zero limit (server default).
3532        assert_eq!(
3533            parse_git_log_watch(&msg_git_log_watch(2, 7, 0, 0, "")),
3534            Some((2, 7, 0, 0, ""))
3535        );
3536        assert_eq!(
3537            parse_git_log_unwatch(&msg_git_log_unwatch(1, 7)),
3538            Some((1, 7))
3539        );
3540        assert_eq!(
3541            parse_git_log_ack(&msg_git_log_ack(1, 7, u32::MAX)),
3542            Some((1, 7, u32::MAX))
3543        );
3544
3545        // Wrong opcode is rejected.
3546        assert_eq!(parse_git_close(&msg_git_cancel(1)), None);
3547        // Truncated message is rejected.
3548        assert_eq!(
3549            parse_git_open(&msg_git_open(&GitOpenRequest::new(1, 0, "x"))[..5]),
3550            None
3551        );
3552    }
3553
3554    #[test]
3555    fn response_roundtrips() {
3556        let msg = msg_git_repo(
3557            1,
3558            2,
3559            GIT_STATUS_OK,
3560            GIT_OID_FORMAT_SHA1,
3561            GIT_REPO_LINKED,
3562            "/w",
3563            "/w/.git",
3564        );
3565        assert_eq!(
3566            parse_git_repo(&msg),
3567            Some(GitRepoInfo {
3568                nonce: 1,
3569                repo_id: 2,
3570                status: GIT_STATUS_OK,
3571                oid_format: GIT_OID_FORMAT_SHA1,
3572                flags: GIT_REPO_LINKED,
3573                workdir: "/w",
3574                gitdir: "/w/.git",
3575            })
3576        );
3577        // Failure shape: invalid repo id, diagnostic in workdir, empty gitdir.
3578        let msg = msg_git_repo(
3579            1,
3580            GIT_REPO_ID_INVALID,
3581            GIT_STATUS_NOT_FOUND,
3582            0,
3583            0,
3584            "no repo",
3585            "",
3586        );
3587        let info = parse_git_repo(&msg).unwrap();
3588        assert_eq!(info.repo_id, GIT_REPO_ID_INVALID);
3589        assert_eq!(info.status, GIT_STATUS_NOT_FOUND);
3590        assert_eq!(info.workdir, "no repo");
3591        assert_eq!(info.gitdir, "");
3592
3593        let msg = msg_git_state(2, 9, GIT_STATE_REFS_TRUNCATED, b"records");
3594        assert_eq!(
3595            parse_git_state(&msg),
3596            Some((2, 9, GIT_STATE_REFS_TRUNCATED, b"records".to_vec()))
3597        );
3598
3599        assert_eq!(
3600            parse_git_closed(&msg_git_closed(2, GIT_CLOSED_REPO_GONE)),
3601            Some((2, GIT_CLOSED_REPO_GONE))
3602        );
3603
3604        let frontier = vec![oid(0xEE)];
3605        let msg = msg_git_commits(3, GIT_STATUS_OK, GIT_COMMITS_MORE, &frontier, b"recs");
3606        assert_eq!(
3607            parse_git_commits(&msg),
3608            Some(GitCommitsPage {
3609                nonce: 3,
3610                status: GIT_STATUS_OK,
3611                flags: GIT_COMMITS_MORE,
3612                frontier,
3613                records: b"recs".to_vec(),
3614            })
3615        );
3616        // Terminal page: empty frontier, empty records.
3617        let msg = msg_git_commits(4, GIT_STATUS_OK, 0, &[], &[]);
3618        assert_eq!(
3619            parse_git_commits(&msg),
3620            Some(GitCommitsPage {
3621                nonce: 4,
3622                status: GIT_STATUS_OK,
3623                flags: 0,
3624                frontier: vec![],
3625                records: vec![],
3626            })
3627        );
3628
3629        let msg = msg_git_tree_resp(5, GIT_STATUS_OK, GIT_TREE_TRUNCATED, b"t");
3630        assert_eq!(
3631            parse_git_tree_resp(&msg),
3632            Some((5, GIT_STATUS_OK, GIT_TREE_TRUNCATED, b"t".to_vec()))
3633        );
3634
3635        let msg = msg_git_blob_resp(6, GIT_STATUS_OK, 11, b"hello world");
3636        assert_eq!(
3637            parse_git_blob_resp(&msg),
3638            Some((6, GIT_STATUS_OK, 11, b"hello world".to_vec()))
3639        );
3640        // TOO_LARGE still carries the true size, with empty data.
3641        let msg = msg_git_blob_resp(7, GIT_STATUS_TOO_LARGE, 1 << 40, &[]);
3642        assert_eq!(
3643            parse_git_blob_resp(&msg),
3644            Some((7, GIT_STATUS_TOO_LARGE, 1 << 40, vec![]))
3645        );
3646
3647        let msg = msg_git_diff_resp(8, GIT_STATUS_OK, 0, b"d");
3648        assert_eq!(
3649            parse_git_diff_resp(&msg),
3650            Some((8, GIT_STATUS_OK, 0, b"d".to_vec()))
3651        );
3652
3653        let msg = msg_git_patch_resp(9, GIT_STATUS_OK, GIT_PATCH_STRUCTURED, b"p");
3654        assert_eq!(
3655            parse_git_patch_resp(&msg),
3656            Some((9, GIT_STATUS_OK, GIT_PATCH_STRUCTURED, b"p".to_vec()))
3657        );
3658
3659        let msg = msg_git_index_resp(10, GIT_STATUS_OK, 0, b"i");
3660        assert_eq!(
3661            parse_git_index_resp(&msg),
3662            Some((10, GIT_STATUS_OK, 0, b"i".to_vec()))
3663        );
3664
3665        let bases = vec![oid(0xAB)];
3666        let msg = msg_git_base_resp(11, GIT_STATUS_OK, &bases);
3667        assert_eq!(parse_git_base_resp(&msg), Some((11, GIT_STATUS_OK, bases)));
3668        // Disjoint histories: OK with zero bases.
3669        let msg = msg_git_base_resp(12, GIT_STATUS_OK, &[]);
3670        assert_eq!(parse_git_base_resp(&msg), Some((12, GIT_STATUS_OK, vec![])));
3671
3672        let tips = vec![oid(0xCC)];
3673        let hides = vec![oid(0xDD)];
3674        let msg = msg_git_resolve_resp(13, GIT_STATUS_OK, &tips, &hides);
3675        assert_eq!(
3676            parse_git_resolve_resp(&msg),
3677            Some((13, GIT_STATUS_OK, tips, hides))
3678        );
3679        // A single tip, no hides (a plain ref/oid).
3680        let msg = msg_git_resolve_resp(14, GIT_STATUS_OK, &[oid(0xCC)], &[]);
3681        assert_eq!(
3682            parse_git_resolve_resp(&msg),
3683            Some((14, GIT_STATUS_OK, vec![oid(0xCC)], vec![]))
3684        );
3685
3686        let frontier = vec![oid(0xEE)];
3687        let msg = msg_git_log_page(1, 42, GIT_STATUS_OK, GIT_COMMITS_MORE, &frontier, b"recs");
3688        assert_eq!(
3689            parse_git_log_page(&msg),
3690            Some(GitLogPage {
3691                log_id: 1,
3692                update_id: 42,
3693                status: GIT_STATUS_OK,
3694                flags: GIT_COMMITS_MORE,
3695                frontier,
3696                records: b"recs".to_vec(),
3697            })
3698        );
3699        // Unresolvable spec: status carries the error, empty frontier/records.
3700        let msg = msg_git_log_page(2, 0, GIT_STATUS_NOT_FOUND, 0, &[], &[]);
3701        assert_eq!(
3702            parse_git_log_page(&msg),
3703            Some(GitLogPage {
3704                log_id: 2,
3705                update_id: 0,
3706                status: GIT_STATUS_NOT_FOUND,
3707                flags: 0,
3708                frontier: vec![],
3709                records: vec![],
3710            })
3711        );
3712    }
3713
3714    #[test]
3715    fn state_record_roundtrip() {
3716        let records = vec![
3717            GitStateRecord::Head {
3718                flags: 0,
3719                oid: oid(0x01),
3720                name: "refs/heads/main",
3721            },
3722            // Unborn HEAD: zero oid, empty name edge on another kind below.
3723            GitStateRecord::Head {
3724                flags: GIT_HEAD_UNBORN,
3725                oid: GIT_OID_NONE,
3726                name: "refs/heads/new",
3727            },
3728            GitStateRecord::Ref {
3729                flags: GIT_REF_PEELED_VALID,
3730                oid: oid(0x02),
3731                peeled: oid(0x03),
3732                name: "refs/tags/v1",
3733                target: "",
3734            },
3735            GitStateRecord::Op {
3736                op: GIT_OP_REBASE,
3737                oid: oid(0x04),
3738                detail: "",
3739            },
3740            GitStateRecord::Status {
3741                staged: b'R',
3742                unstaged: b' ',
3743                flags: 0,
3744                oid: GIT_OID_NONE,
3745                old_path: "old.txt",
3746                path: "new.txt",
3747            },
3748            GitStateRecord::Status {
3749                staged: b'?',
3750                unstaged: b'?',
3751                flags: GIT_STATUS_ENTRY_CONFLICTED,
3752                oid: GIT_OID_NONE,
3753                old_path: "",
3754                path: "%FF.bin",
3755            },
3756            GitStateRecord::Upstream {
3757                flags: GIT_UPSTREAM_COUNTS_VALID,
3758                ahead: 2,
3759                behind: 3,
3760                name: "refs/heads/main",
3761                upstream: "refs/remotes/origin/main",
3762            },
3763            GitStateRecord::Stash {
3764                index: 0,
3765                oid: oid(0x05),
3766                time: 1_700_000_000,
3767                tz: -300,
3768                msg: "WIP on main",
3769            },
3770        ];
3771        let mut buf = Vec::new();
3772        for r in &records {
3773            append_git_state_record(&mut buf, r);
3774        }
3775        let decoded: Vec<_> = git_state_records(&buf).collect();
3776        assert_eq!(decoded, records);
3777    }
3778
3779    #[test]
3780    fn commit_record_roundtrip() {
3781        let records = vec![
3782            GitCommitRecord::Commit {
3783                flags: GIT_COMMIT_LOSSY_ENCODING,
3784                oid: oid(0x0A),
3785                tree: oid(0x0B),
3786                parents: vec![oid(0x0C), oid(0x0D)],
3787                author_time: 1_700_000_000,
3788                author_tz: 60,
3789                committer_time: 1_700_000_001,
3790                committer_tz: -300,
3791                author_name: "Ann Author",
3792                author_email: "ann@example.com",
3793                committer_name: "Cam Committer",
3794                committer_email: "cam@example.com",
3795                message: "subject\n\nbody\n",
3796            },
3797            // Root commit: no parents, empty message.
3798            GitCommitRecord::Commit {
3799                flags: 0,
3800                oid: oid(0x0E),
3801                tree: oid(0x0B),
3802                parents: vec![],
3803                author_time: 0,
3804                author_tz: 0,
3805                committer_time: 0,
3806                committer_tz: 0,
3807                author_name: "",
3808                author_email: "",
3809                committer_name: "",
3810                committer_email: "",
3811                message: "",
3812            },
3813            GitCommitRecord::PathAt {
3814                otype: GIT_OTYPE_BLOB,
3815                mode: 0o100644,
3816                oid: oid(0x0F),
3817                path: "src/lib.rs",
3818            },
3819            // Deleted at this commit: zero oid.
3820            GitCommitRecord::PathAt {
3821                otype: GIT_OTYPE_BLOB,
3822                mode: 0,
3823                oid: GIT_OID_NONE,
3824                path: "gone.rs",
3825            },
3826        ];
3827        let mut buf = Vec::new();
3828        for r in &records {
3829            append_git_commit_record(&mut buf, r);
3830        }
3831        let decoded: Vec<_> = git_commit_records(&buf).collect();
3832        assert_eq!(decoded, records);
3833    }
3834
3835    #[test]
3836    fn tree_record_roundtrip() {
3837        let records = vec![
3838            GitTreeRecord::Entry {
3839                otype: GIT_OTYPE_TREE,
3840                mode: 0o40000,
3841                oid: oid(0x0E),
3842                name: "src",
3843            },
3844            GitTreeRecord::Entry {
3845                otype: GIT_OTYPE_BLOB,
3846                mode: 0o100644,
3847                oid: oid(0x0F),
3848                name: "%FF.bin", // server-escaped non-UTF-8 name
3849            },
3850            GitTreeRecord::Entry {
3851                otype: GIT_OTYPE_COMMIT,
3852                mode: 0o160000,
3853                oid: oid(0x10),
3854                name: "submodule",
3855            },
3856        ];
3857        let mut buf = Vec::new();
3858        for r in &records {
3859            append_git_tree_record(&mut buf, r);
3860        }
3861        let decoded: Vec<_> = git_tree_records(&buf).collect();
3862        assert_eq!(decoded, records);
3863    }
3864
3865    #[test]
3866    fn diff_record_roundtrip() {
3867        let records = vec![
3868            GitDiffRecord::Base { oid: oid(0x10) },
3869            GitDiffRecord::Entry {
3870                st: b'R',
3871                similarity: 90,
3872                dflags: 0,
3873                old_mode: 0o100644,
3874                new_mode: 0o100755,
3875                old_oid: oid(0x11),
3876                new_oid: oid(0x12),
3877                old_path: "old.txt",
3878                new_path: "new.txt",
3879            },
3880            // Untracked addition: absent old side, unhashed new side.
3881            GitDiffRecord::Entry {
3882                st: b'A',
3883                similarity: 0,
3884                dflags: GIT_DIFF_ENTRY_BINARY,
3885                old_mode: 0,
3886                new_mode: 0o100644,
3887                old_oid: GIT_OID_NONE,
3888                new_oid: GIT_OID_NONE,
3889                old_path: "",
3890                new_path: "new.bin",
3891            },
3892        ];
3893        let mut buf = Vec::new();
3894        for r in &records {
3895            append_git_diff_record(&mut buf, r);
3896        }
3897        let decoded: Vec<_> = git_diff_records(&buf).collect();
3898        assert_eq!(decoded, records);
3899    }
3900
3901    #[test]
3902    fn patch_record_roundtrip() {
3903        let records = vec![
3904            GitPatchRecord::Base { oid: oid(0x13) },
3905            GitPatchRecord::File {
3906                st: b'M',
3907                similarity: 0,
3908                flags: 0,
3909                old_path: "a.txt",
3910                new_path: "a.txt",
3911            },
3912            GitPatchRecord::Row {
3913                old_line: 1,
3914                new_line: 1,
3915                old_text: b"hello world",
3916                new_text: b"hallo world",
3917                old_spans: vec![(1, 1)],
3918                new_spans: vec![(1, 1)],
3919            },
3920            // Context row: no spans; pure addition: absent old side.
3921            GitPatchRecord::Row {
3922                old_line: 2,
3923                new_line: 2,
3924                old_text: b"same",
3925                new_text: b"same",
3926                old_spans: vec![],
3927                new_spans: vec![],
3928            },
3929            GitPatchRecord::Row {
3930                old_line: 0,
3931                new_line: 3,
3932                old_text: b"",
3933                new_text: b"added",
3934                old_spans: vec![],
3935                new_spans: vec![(0, 5)],
3936            },
3937            GitPatchRecord::Gap {
3938                old_line: 10,
3939                new_line: 11,
3940            },
3941            GitPatchRecord::File {
3942                st: b'M',
3943                similarity: 0,
3944                flags: GIT_PATCH_FILE_BINARY,
3945                old_path: "img.png",
3946                new_path: "img.png",
3947            },
3948        ];
3949        let mut buf = Vec::new();
3950        for r in &records {
3951            append_git_patch_record(&mut buf, r);
3952        }
3953        let decoded: Vec<_> = git_patch_records(&buf).collect();
3954        assert_eq!(decoded, records);
3955    }
3956
3957    #[test]
3958    fn index_record_roundtrip() {
3959        let records = vec![
3960            GitIndexRecord::Entry {
3961                stage: 0,
3962                iflags: GIT_INDEX_INTENT_TO_ADD,
3963                mode: 0o100644,
3964                size: 5,
3965                mtime_ns: 1_700_000_000_000_000_000,
3966                oid: oid(0x14),
3967                path: "a.txt",
3968            },
3969            // Conflict stage entry.
3970            GitIndexRecord::Entry {
3971                stage: 2,
3972                iflags: 0,
3973                mode: 0o100644,
3974                size: 0,
3975                mtime_ns: 0,
3976                oid: oid(0x15),
3977                path: "conflicted.txt",
3978            },
3979        ];
3980        let mut buf = Vec::new();
3981        for r in &records {
3982            append_git_index_record(&mut buf, r);
3983        }
3984        let decoded: Vec<_> = git_index_records(&buf).collect();
3985        assert_eq!(decoded, records);
3986    }
3987
3988    /// The 0x90 block round-trips, and `CURSOR` decodes in every family
3989    /// that can be truncated — the whole point of the record is that one
3990    /// branch works everywhere.
3991    #[test]
3992    fn second_block_roundtrips() {
3993        let discover = GitDiscoverRequest {
3994            nonce: 1,
3995            flags: GIT_DISCOVER_NESTED,
3996            depth: 3,
3997            path: "/workspace",
3998            after: "/workspace/a",
3999        };
4000        assert_eq!(
4001            parse_git_discover(&msg_git_discover(&discover)),
4002            Some(discover)
4003        );
4004
4005        let blame = GitBlameRequest {
4006            nonce: 2,
4007            repo_id: 7,
4008            flags: GIT_BLAME_FOLLOW_RENAMES,
4009            oid: oid(0x21),
4010            start_line: 100,
4011            line_count: 40,
4012            path: "src/a.rs",
4013        };
4014        assert_eq!(parse_git_blame(&msg_git_blame(&blame)), Some(blame));
4015
4016        let reflog = GitReflogRequest {
4017            nonce: 3,
4018            repo_id: 7,
4019            flags: GIT_REFLOG_OLDEST_FIRST,
4020            limit: 50,
4021            ref_name: "",
4022            after_pos: 20,
4023        };
4024        assert_eq!(parse_git_reflog(&msg_git_reflog(&reflog)), Some(reflog));
4025
4026        let fetch = GitFetchRequest {
4027            nonce: 4,
4028            repo_id: 7,
4029            flags: GIT_FETCH_ANCHOR | GIT_FETCH_PRUNE,
4030            timeout_ms: 30_000,
4031            remote: "origin",
4032            refspecs: vec!["refs/pull/12/head", "deadbeef"],
4033        };
4034        assert_eq!(parse_git_fetch(&msg_git_fetch(&fetch)), Some(fetch));
4035
4036        let mut records = Vec::new();
4037        append_git_discover_record(
4038            &mut records,
4039            &GitDiscoverRecord::Repo {
4040                flags: GIT_FOUND_SUBMODULE,
4041                workdir: "/workspace/a",
4042                gitdir: "/workspace/.git/modules/a",
4043            },
4044        );
4045        append_git_discover_record(
4046            &mut records,
4047            &GitDiscoverRecord::Cursor {
4048                after: "/workspace/a",
4049                pos: 0,
4050            },
4051        );
4052        assert_eq!(git_discover_records(&records).count(), 2);
4053
4054        let mut records = Vec::new();
4055        append_git_blame_record(
4056            &mut records,
4057            &GitBlameRecord::Range {
4058                flags: 0,
4059                commit: oid(0x22),
4060                start_line: 1,
4061                line_count: 12,
4062                orig_start: 40,
4063                orig_path: "src/old.rs",
4064            },
4065        );
4066        assert_eq!(git_blame_records(&records).count(), 1);
4067
4068        let mut records = Vec::new();
4069        append_git_reflog_record(
4070            &mut records,
4071            &GitReflogRecord::Entry {
4072                flags: 0,
4073                old: oid(0x23),
4074                new: oid(0x24),
4075                time: 1_700_000_000,
4076                tz: -480,
4077                msg: "commit (amend): fix",
4078            },
4079        );
4080        assert_eq!(git_reflog_records(&records).count(), 1);
4081
4082        let mut records = Vec::new();
4083        append_git_fetch_record(
4084            &mut records,
4085            &GitFetchRecord::Ref {
4086                flags: GIT_FETCH_REF_NEW,
4087                status: GIT_STATUS_OK,
4088                old: GIT_OID_NONE,
4089                new: oid(0x25),
4090                name: "refs/blit/fetch/origin/0",
4091                detail: "",
4092            },
4093        );
4094        assert_eq!(git_fetch_records(&records).count(), 1);
4095
4096        // One CURSOR shape, decoded by every truncatable family: the point
4097        // of the record is that a client writes the branch once.
4098        let mut buf = Vec::new();
4099        append_git_tree_record(&mut buf, &GitTreeRecord::Cursor { after: "z", pos: 0 });
4100        assert_eq!(
4101            git_tree_records(&buf).next(),
4102            Some(GitTreeRecord::Cursor { after: "z", pos: 0 })
4103        );
4104        let mut buf = Vec::new();
4105        append_git_diff_record(&mut buf, &GitDiffRecord::Cursor { after: "z", pos: 0 });
4106        assert_eq!(
4107            git_diff_records(&buf).next(),
4108            Some(GitDiffRecord::Cursor { after: "z", pos: 0 })
4109        );
4110        let mut buf = Vec::new();
4111        append_git_patch_record(
4112            &mut buf,
4113            &GitPatchRecord::Cursor {
4114                after: "z",
4115                pos: 99,
4116            },
4117        );
4118        assert_eq!(
4119            git_patch_records(&buf).next(),
4120            Some(GitPatchRecord::Cursor {
4121                after: "z",
4122                pos: 99
4123            })
4124        );
4125        let mut buf = Vec::new();
4126        append_git_index_record(&mut buf, &GitIndexRecord::Cursor { after: "z", pos: 0 });
4127        assert_eq!(
4128            git_index_records(&buf).next(),
4129            Some(GitIndexRecord::Cursor { after: "z", pos: 0 })
4130        );
4131    }
4132
4133    /// A snapshot larger than one message arrives as `PARTIAL` chunks: the
4134    /// mirror buffers them, acknowledges only the last, and never exposes a
4135    /// half-built map.
4136    #[test]
4137    fn partial_state_chunks_install_together() {
4138        let mut first = Vec::new();
4139        append_git_state_record(
4140            &mut first,
4141            &GitStateRecord::Head {
4142                flags: 0,
4143                oid: oid(1),
4144                name: "refs/heads/main",
4145            },
4146        );
4147        let mut second = Vec::new();
4148        append_git_state_record(
4149            &mut second,
4150            &GitStateRecord::Ref {
4151                flags: GIT_REF_SYMBOLIC,
4152                oid: oid(2),
4153                peeled: GIT_OID_NONE,
4154                name: "refs/remotes/origin/HEAD",
4155                target: "refs/remotes/origin/trunk",
4156            },
4157        );
4158
4159        let mut mirror = GitStateMirror::new();
4160        assert_eq!(
4161            mirror.apply_state(&msg_git_state(1, 5, GIT_STATE_PARTIAL, &first)),
4162            GitStateApply::Partial
4163        );
4164        // Nothing is visible yet: the snapshot is not complete.
4165        assert!(mirror.head.is_none());
4166        assert_eq!(
4167            mirror.apply_state(&msg_git_state(1, 5, 0, &second)),
4168            GitStateApply::Complete(5)
4169        );
4170        assert_eq!(mirror.head.as_ref().unwrap().name, "refs/heads/main");
4171        assert_eq!(
4172            mirror.refs["refs/remotes/origin/HEAD"].target,
4173            "refs/remotes/origin/trunk"
4174        );
4175    }
4176
4177    #[test]
4178    fn unknown_record_kind_is_skipped() {
4179        // A future record kind with 3 payload bytes, then a valid record,
4180        // for every family. 0x7F is taken (CURSOR, reserved family-wide),
4181        // so an unallocated kind stands in for the next addition.
4182        let mut unknown = Vec::new();
4183        unknown.extend_from_slice(&4u32.to_le_bytes());
4184        unknown.push(0x6E);
4185        unknown.extend_from_slice(&[1, 2, 3]);
4186
4187        let mut buf = unknown.clone();
4188        append_git_state_record(
4189            &mut buf,
4190            &GitStateRecord::Op {
4191                op: GIT_OP_MERGE,
4192                oid: oid(1),
4193                detail: "",
4194            },
4195        );
4196        assert_eq!(git_state_records(&buf).count(), 1);
4197
4198        let mut buf = unknown.clone();
4199        append_git_commit_record(
4200            &mut buf,
4201            &GitCommitRecord::PathAt {
4202                otype: GIT_OTYPE_BLOB,
4203                mode: 0,
4204                oid: oid(1),
4205                path: "p",
4206            },
4207        );
4208        assert_eq!(git_commit_records(&buf).count(), 1);
4209
4210        let mut buf = unknown.clone();
4211        append_git_tree_record(
4212            &mut buf,
4213            &GitTreeRecord::Entry {
4214                otype: GIT_OTYPE_BLOB,
4215                mode: 0,
4216                oid: oid(1),
4217                name: "n",
4218            },
4219        );
4220        assert_eq!(git_tree_records(&buf).count(), 1);
4221
4222        let mut buf = unknown.clone();
4223        append_git_diff_record(&mut buf, &GitDiffRecord::Base { oid: oid(1) });
4224        assert_eq!(git_diff_records(&buf).count(), 1);
4225
4226        let mut buf = unknown.clone();
4227        append_git_patch_record(
4228            &mut buf,
4229            &GitPatchRecord::Gap {
4230                old_line: 1,
4231                new_line: 1,
4232            },
4233        );
4234        assert_eq!(git_patch_records(&buf).count(), 1);
4235
4236        let mut buf = unknown.clone();
4237        append_git_index_record(
4238            &mut buf,
4239            &GitIndexRecord::Entry {
4240                stage: 0,
4241                iflags: 0,
4242                mode: 0,
4243                size: 0,
4244                mtime_ns: 0,
4245                oid: oid(1),
4246                path: "p",
4247            },
4248        );
4249        assert_eq!(git_index_records(&buf).count(), 1);
4250    }
4251
4252    #[test]
4253    fn malformed_record_ends_iteration() {
4254        // A HEAD record whose body is truncated to one byte.
4255        let mut buf = Vec::new();
4256        buf.extend_from_slice(&2u32.to_le_bytes());
4257        buf.push(GIT_STATE_RECORD_HEAD);
4258        buf.push(0);
4259        append_git_state_record(
4260            &mut buf,
4261            &GitStateRecord::Head {
4262                flags: 0,
4263                oid: oid(1),
4264                name: "refs/heads/main",
4265            },
4266        );
4267        assert_eq!(git_state_records(&buf).next(), None);
4268    }
4269
4270    #[test]
4271    fn oversized_declared_length_is_rejected_before_allocation() {
4272        // Hand-forged messages whose LZ4 size prefix declares 1 GiB.
4273        fn forged(opcode: u8, header: &[u8]) -> Vec<u8> {
4274            let mut msg = vec![opcode];
4275            msg.extend_from_slice(header);
4276            msg.extend_from_slice(&(1u32 << 30).to_le_bytes());
4277            msg.extend_from_slice(&[0u8; 16]);
4278            msg
4279        }
4280
4281        let state = forged(S2C_GIT_STATE, &[1, 0, 1, 0, 0, 0, 0]);
4282        assert_eq!(parse_git_state(&state), None);
4283        assert_eq!(
4284            GitStateMirror::new().apply_state(&state),
4285            GitStateApply::Malformed
4286        );
4287        assert_eq!(
4288            parse_git_commits(&forged(S2C_GIT_COMMITS, &[1, 0, 0, 0, 0, 0])),
4289            None
4290        );
4291        assert_eq!(
4292            parse_git_tree_resp(&forged(S2C_GIT_TREE, &[1, 0, 0, 0])),
4293            None
4294        );
4295        assert_eq!(
4296            parse_git_blob_resp(&forged(S2C_GIT_BLOB, &[1, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0])),
4297            None
4298        );
4299        assert_eq!(
4300            parse_git_diff_resp(&forged(S2C_GIT_DIFF, &[1, 0, 0, 0])),
4301            None
4302        );
4303        assert_eq!(
4304            parse_git_patch_resp(&forged(S2C_GIT_PATCH, &[1, 0, 0, 0])),
4305            None
4306        );
4307        assert_eq!(
4308            parse_git_index_resp(&forged(S2C_GIT_INDEX, &[1, 0, 0, 0])),
4309            None
4310        );
4311    }
4312
4313    #[test]
4314    fn state_mirror_replaces_whole_state() {
4315        let mut mirror = GitStateMirror::new();
4316
4317        let mut records = Vec::new();
4318        append_git_state_record(
4319            &mut records,
4320            &GitStateRecord::Head {
4321                flags: 0,
4322                oid: oid(1),
4323                name: "refs/heads/main",
4324            },
4325        );
4326        append_git_state_record(
4327            &mut records,
4328            &GitStateRecord::Ref {
4329                flags: 0,
4330                oid: oid(1),
4331                peeled: GIT_OID_NONE,
4332                name: "refs/heads/main",
4333                target: "",
4334            },
4335        );
4336        append_git_state_record(
4337            &mut records,
4338            &GitStateRecord::Ref {
4339                flags: GIT_REF_PEELED_VALID,
4340                oid: oid(2),
4341                peeled: oid(3),
4342                name: "refs/tags/v1",
4343                target: "",
4344            },
4345        );
4346        append_git_state_record(
4347            &mut records,
4348            &GitStateRecord::Op {
4349                op: GIT_OP_MERGE,
4350                oid: oid(4),
4351                detail: "",
4352            },
4353        );
4354        append_git_state_record(
4355            &mut records,
4356            &GitStateRecord::Status {
4357                staged: b'M',
4358                unstaged: b' ',
4359                flags: 0,
4360                oid: GIT_OID_NONE,
4361                old_path: "",
4362                path: "a.txt",
4363            },
4364        );
4365        append_git_state_record(
4366            &mut records,
4367            &GitStateRecord::Upstream {
4368                flags: GIT_UPSTREAM_COUNTS_VALID,
4369                ahead: 2,
4370                behind: 3,
4371                name: "refs/heads/main",
4372                upstream: "refs/remotes/origin/main",
4373            },
4374        );
4375        append_git_state_record(
4376            &mut records,
4377            &GitStateRecord::Stash {
4378                index: 0,
4379                oid: oid(5),
4380                time: 1_700_000_000,
4381                tz: -300,
4382                msg: "WIP on main",
4383            },
4384        );
4385        let msg = msg_git_state(1, 1, GIT_STATE_STATUS_TRUNCATED, &records);
4386        assert_eq!(mirror.apply_state(&msg), GitStateApply::Complete(1));
4387        assert_eq!(
4388            mirror.head,
4389            Some(GitHead {
4390                flags: 0,
4391                oid: oid(1),
4392                name: "refs/heads/main".to_string(),
4393            })
4394        );
4395        assert_eq!(mirror.refs.len(), 2);
4396        assert_eq!(mirror.refs["refs/tags/v1"].peeled, oid(3));
4397        assert_eq!(mirror.op.as_ref().unwrap().op, GIT_OP_MERGE);
4398        assert_eq!(mirror.status.len(), 1);
4399        assert_eq!(mirror.upstreams["refs/heads/main"].ahead, 2);
4400        assert_eq!(mirror.stashes[0].message, "WIP on main");
4401        assert_eq!(mirror.flags, GIT_STATE_STATUS_TRUNCATED);
4402
4403        // The next snapshot replaces everything: op ended, status cleared,
4404        // detached HEAD.
4405        let mut records = Vec::new();
4406        append_git_state_record(
4407            &mut records,
4408            &GitStateRecord::Head {
4409                flags: GIT_HEAD_DETACHED,
4410                oid: oid(6),
4411                name: "",
4412            },
4413        );
4414        let msg = msg_git_state(1, 2, 0, &records);
4415        assert_eq!(mirror.apply_state(&msg), GitStateApply::Complete(2));
4416        assert_eq!(mirror.head.as_ref().unwrap().flags, GIT_HEAD_DETACHED);
4417        assert!(mirror.refs.is_empty());
4418        assert_eq!(mirror.op, None);
4419        assert!(mirror.status.is_empty());
4420        assert!(mirror.upstreams.is_empty());
4421        assert!(mirror.stashes.is_empty());
4422        assert_eq!(mirror.flags, 0);
4423
4424        // Malformed: wrong opcode, truncated header.
4425        assert_eq!(
4426            mirror.apply_state(&msg_git_closed(1, 0)),
4427            GitStateApply::Malformed
4428        );
4429        assert_eq!(mirror.apply_state(&msg[..6]), GitStateApply::Malformed);
4430    }
4431
4432    /// Byte fixtures shared with the TypeScript codecs
4433    /// (`js/core/src/__tests__/git.test.ts` pins the same hex), so codec
4434    /// drift fails on one side or the other. Buffers that cross LZ4 are
4435    /// pinned uncompressed — LZ4 output may legitimately change across
4436    /// `lz4_flex` versions, while these bytes never can.
4437    #[test]
4438    fn wire_fixtures() {
4439        let zeros = "0".repeat(24);
4440        let zero_oid = "0".repeat(64);
4441        let o = |fill: u8| format!("{}{zeros}", hex(&[fill; 20]));
4442
4443        assert_eq!(
4444            hex(&msg_git_open(&GitOpenRequest {
4445                refs_latency_ms: 50,
4446                status_latency_ms: 500,
4447                ..GitOpenRequest::new(0x0102, GIT_OPEN_WATCH | GIT_OPEN_STATUS, "/repo")
4448            })),
4449            "a0020103003200f401ffffffff000005002f7265706f"
4450        );
4451        // Context ids and a prefix filter, all in the one parse shape.
4452        assert_eq!(
4453            hex(&msg_git_open(&GitOpenRequest {
4454                parent_repo_id: 4,
4455                ref_prefixes: vec!["refs/heads/"],
4456                ..GitOpenRequest::new(1, GIT_OPEN_WATCH, "vendor/lib")
4457            })),
4458            "a00100010000000000ffff04000100\
4459             0b00726566732f68656164732f0a0076656e646f722f6c6962"
4460        );
4461        assert_eq!(hex(&msg_git_close(7)), "a10700");
4462        assert_eq!(hex(&msg_git_ack(7, 0x01020304)), "a2070004030201");
4463        assert_eq!(
4464            hex(&msg_git_log(
4465                3,
4466                7,
4467                GIT_LOG_FIRST_PARENT,
4468                100,
4469                "src",
4470                &[oid(0xAA)],
4471                &[oid(0xBB)]
4472            )),
4473            format!("a70300070001640003007372630100{}0100{}", o(0xAA), o(0xBB))
4474        );
4475        assert_eq!(
4476            hex(&msg_git_tree(&GitTreeRequest {
4477                nonce: 4,
4478                repo_id: 7,
4479                flags: 0,
4480                oid: oid(0xCC),
4481                path: "dir/%FF",
4482                after: "",
4483            })),
4484            format!("ab0400070000{}07006469722f2546460000", o(0xCC))
4485        );
4486        assert_eq!(
4487            hex(&msg_git_blob(&GitBlobRequest {
4488                nonce: 5,
4489                repo_id: 7,
4490                flags: 0,
4491                oid: oid(0xDD),
4492                path: "",
4493                offset: 0,
4494                max_len: 1 << 20,
4495            })),
4496            format!("ac0500070000{}0000000000000000000000001000", o(0xDD))
4497        );
4498        let old = GitEndpoint {
4499            kind: GIT_ENDPOINT_COMMIT,
4500            oid: oid(0x11),
4501        };
4502        let new = GitEndpoint {
4503            kind: GIT_ENDPOINT_WORKTREE,
4504            oid: GIT_OID_NONE,
4505        };
4506        assert_eq!(
4507            hex(&msg_git_diff(&GitDiffRequest {
4508                nonce: 6,
4509                repo_id: 7,
4510                flags: GIT_DIFF_RENAMES,
4511                rename: 50,
4512                old,
4513                new,
4514                path: "",
4515                after: "",
4516            })),
4517            format!("ad06000700013201{}04{zero_oid}00000000", o(0x11))
4518        );
4519        assert_eq!(
4520            hex(&msg_git_patch(&GitPatchRequest {
4521                nonce: 8,
4522                repo_id: 7,
4523                flags: GIT_PATCH_RENAMES | GIT_PATCH_CHAR_SPANS,
4524                context: 5,
4525                rename: 0,
4526                old,
4527                new,
4528                path: "a.txt",
4529                max_len: 0,
4530                after: "",
4531                after_pos: 0,
4532            })),
4533            format!(
4534                "ae080007008100050001{}04{zero_oid}\
4535                 0500612e7478740000000000000000000000000000",
4536                o(0x11)
4537            )
4538        );
4539        assert_eq!(
4540            hex(&msg_git_index(&GitIndexRequest {
4541                nonce: 9,
4542                repo_id: 7,
4543                flags: 0,
4544                path: "sub",
4545                after: "",
4546            })),
4547            "af090007000003007375620000"
4548        );
4549        assert_eq!(hex(&msg_git_cancel(10)), "a30a00");
4550        assert_eq!(
4551            hex(&msg_git_base(11, 7, &[oid(0xAA), oid(0xBB)])),
4552            format!("b00b00070002{}{}", o(0xAA), o(0xBB))
4553        );
4554        assert_eq!(
4555            hex(&msg_git_resolve(12, 7, "main..dev")),
4556            "a60c00070009006d61696e2e2e646576"
4557        );
4558        assert_eq!(
4559            hex(&msg_git_resolve_resp(
4560                12,
4561                GIT_STATUS_OK,
4562                &[oid(0xCC)],
4563                &[oid(0xDD)]
4564            )),
4565            format!("a60c00000100{}0100{}", o(0xCC), o(0xDD))
4566        );
4567        assert_eq!(
4568            hex(&msg_git_log_watch(1, 7, GIT_LOG_FIRST_PARENT, 100, "main")),
4569            "a80100070001640004006d61696e"
4570        );
4571        assert_eq!(hex(&msg_git_log_unwatch(1, 7)), "a901000700");
4572        assert_eq!(
4573            hex(&msg_git_log_ack(1, 7, 0x0102_0304)),
4574            "aa0100070004030201"
4575        );
4576        assert_eq!(
4577            hex(&msg_git_repo(
4578                0x0102,
4579                1,
4580                GIT_STATUS_OK,
4581                GIT_OID_FORMAT_SHA1,
4582                GIT_REPO_LINKED,
4583                "/w",
4584                "/w/.git"
4585            )),
4586            "a00201010000000802002f7707002f772f2e676974"
4587        );
4588        assert_eq!(hex(&msg_git_closed(1, GIT_CLOSED_REPO_GONE)), "a5010001");
4589        assert_eq!(
4590            hex(&msg_git_base_resp(11, GIT_STATUS_OK, &[oid(0xAB)])),
4591            format!("b00b000001{}", o(0xAB))
4592        );
4593
4594        // Records buffers, uncompressed.
4595        let mut state = Vec::new();
4596        append_git_state_record(
4597            &mut state,
4598            &GitStateRecord::Head {
4599                flags: 0,
4600                oid: oid(0x01),
4601                name: "refs/heads/main",
4602            },
4603        );
4604        append_git_state_record(
4605            &mut state,
4606            &GitStateRecord::Upstream {
4607                flags: GIT_UPSTREAM_COUNTS_VALID,
4608                ahead: 2,
4609                behind: 3,
4610                name: "refs/heads/main",
4611                upstream: "refs/remotes/origin/main",
4612            },
4613        );
4614        assert_eq!(
4615            hex(&state),
4616            "33000000010001010101010101010101010101010101010101010000000000000000000000000f00726566732f68656164732f6d61696e35000000050202000000030000000f00726566732f68656164732f6d61696e1800726566732f72656d6f7465732f6f726967696e2f6d61696e"
4617        );
4618
4619        let mut commits = Vec::new();
4620        append_git_commit_record(
4621            &mut commits,
4622            &GitCommitRecord::Commit {
4623                flags: GIT_COMMIT_LOSSY_ENCODING,
4624                oid: oid(0x0A),
4625                tree: oid(0x0B),
4626                parents: vec![oid(0x0C)],
4627                author_time: 1_700_000_000,
4628                author_tz: 60,
4629                committer_time: 1_700_000_001,
4630                committer_tz: -300,
4631                author_name: "Ann Author",
4632                author_email: "ann@example.com",
4633                committer_name: "Cam Committer",
4634                committer_email: "cam@example.com",
4635                message: "subject",
4636            },
4637        );
4638        append_git_commit_record(
4639            &mut commits,
4640            &GitCommitRecord::PathAt {
4641                otype: GIT_OTYPE_BLOB,
4642                mode: 0o100644,
4643                oid: oid(0x0D),
4644                path: "src/lib.rs",
4645            },
4646        );
4647        assert_eq!(
4648            hex(&commits),
4649            "bf00000001010a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0000000000000000000000000b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b000000000000000000000000010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c00000000000000000000000000f15365000000003c0001f1536500000000d4fe0a00416e6e20417574686f720f00616e6e406578616d706c652e636f6d0d0043616d20436f6d6d69747465720f0063616d406578616d706c652e636f6d070000007375626a656374320000000203a48100000d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0000000000000000000000000a007372632f6c69622e7273"
4650        );
4651
4652        let mut tree = Vec::new();
4653        append_git_tree_record(
4654            &mut tree,
4655            &GitTreeRecord::Entry {
4656                otype: GIT_OTYPE_TREE,
4657                mode: 0o40000,
4658                oid: oid(0x0E),
4659                name: "src",
4660            },
4661        );
4662        append_git_tree_record(
4663            &mut tree,
4664            &GitTreeRecord::Entry {
4665                otype: GIT_OTYPE_BLOB,
4666                mode: 0o100644,
4667                oid: oid(0x0F),
4668                name: "%FF.bin",
4669            },
4670        );
4671        assert_eq!(
4672            hex(&tree),
4673            "2b0000000202004000000e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e00000000000000000000000003007372632f0000000203a48100000f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f00000000000000000000000007002546462e62696e"
4674        );
4675
4676        let mut diff = Vec::new();
4677        append_git_diff_record(&mut diff, &GitDiffRecord::Base { oid: oid(0x10) });
4678        append_git_diff_record(
4679            &mut diff,
4680            &GitDiffRecord::Entry {
4681                st: b'R',
4682                similarity: 90,
4683                dflags: 0,
4684                old_mode: 0o100644,
4685                new_mode: 0o100644,
4686                old_oid: oid(0x11),
4687                new_oid: oid(0x12),
4688                old_path: "old.txt",
4689                new_path: "new.txt",
4690            },
4691        );
4692        assert_eq!(
4693            hex(&diff),
4694            "210000000410101010101010101010101010101010101010100000000000000000000000005e00000003525a00a4810000a48100001111111111111111111111111111111111111111000000000000000000000000121212121212121212121212121212121212121200000000000000000000000007006f6c642e74787407006e65772e747874"
4695        );
4696
4697        let mut patch = Vec::new();
4698        append_git_patch_record(
4699            &mut patch,
4700            &GitPatchRecord::File {
4701                st: b'M',
4702                similarity: 0,
4703                flags: 0,
4704                old_path: "a.txt",
4705                new_path: "a.txt",
4706            },
4707        );
4708        append_git_patch_record(
4709            &mut patch,
4710            &GitPatchRecord::Row {
4711                old_line: 1,
4712                new_line: 1,
4713                old_text: b"hello",
4714                new_text: b"hallo",
4715                old_spans: vec![(1, 1)],
4716                new_spans: vec![(1, 1)],
4717            },
4718        );
4719        append_git_patch_record(
4720            &mut patch,
4721            &GitPatchRecord::Gap {
4722                old_line: 3,
4723                new_line: 3,
4724            },
4725        );
4726        assert_eq!(
4727            hex(&patch),
4728            "12000000014d00000500612e7478740500612e7478742f0000000201000000010000000500000068656c6c6f0500000068616c6c6f010001000000010000000100010000000100000009000000030300000003000000"
4729        );
4730
4731        let mut index = Vec::new();
4732        append_git_index_record(
4733            &mut index,
4734            &GitIndexRecord::Entry {
4735                stage: 0,
4736                iflags: GIT_INDEX_INTENT_TO_ADD,
4737                mode: 0o100644,
4738                size: 5,
4739                mtime_ns: 1_700_000_000_000_000_000,
4740                oid: oid(0x14),
4741                path: "a.txt",
4742            },
4743        );
4744        assert_eq!(
4745            hex(&index),
4746            "3e000000040001a4810000050000000000000000002a36fe9c971714141414141414141414141414141414141414140000000000000000000000000500612e747874"
4747        );
4748
4749        // Decode direction: the pinned bytes parse back to the same records.
4750        assert_eq!(git_state_records(&state).count(), 2);
4751        assert_eq!(git_commit_records(&commits).count(), 2);
4752        assert_eq!(git_tree_records(&tree).count(), 2);
4753        assert_eq!(git_diff_records(&diff).count(), 2);
4754        assert_eq!(git_patch_records(&patch).count(), 3);
4755        assert_eq!(git_index_records(&index).count(), 1);
4756    }
4757}