fc_git/lib.rs
1//! fc-git — working-tree summary for the git surface.
2//!
3//! # What is here, and why only this
4//!
5//! The git surface renders a working tree: branch, status counts, a file list,
6//! per-file line stats. Almost none of that is computation — it is DOM. What *is*
7//! computation is the part worth getting exactly right, and this crate holds it:
8//!
9//! 1. **`summarize_status`** — folding entries into staged/unstaged/conflicted/
10//! untracked counts.
11//!
12//! 2. **`order_entries`** — putting the entries that need attention first.
13//!
14//! 3. **`diff_counts`** — added/removed line totals for a file.
15//!
16//! # The rule that shapes the boundary
17//!
18//! **No `js-sys`.** Linking it costs ~302 KB the moment any of it is used, and
19//! LTO cannot remove it (AGENTS.md). So nothing here returns `Vec<JsValue>` or
20//! `js_sys::Array`. Everything crosses as a small number of primitives or a flat
21//! typed array, and the caller holds the actual strings.
22//!
23//! `diff_counts` takes the two texts as arguments and returns two numbers, rather
24//! than returning a `Vec<DiffLine>` the caller would have to walk back over the
25//! boundary — a range crosses as two numbers, never as an array of objects.
26//!
27//! # Offsets are UTF-16 code units
28//!
29//! Line splitting here matches `fc-diff` and the JS twin: a line ends at `\n`, and
30//! counts are of lines, not bytes. Nothing here produces an offset, so the
31//! surrogate-pair concern that `fc-diff` handles does not arise; the rule is
32//! recorded because it is the same boundary and a future change must not quietly
33//! diverge.
34
35// `no_std` on wasm, for size rather than portability. std's panic runtime
36// formats every message it could print -- `Debug` for ranges, char escaping,
37// integer padding -- and links all of it the moment any bounds check can fail,
38// which is most of this module's bytes and none of its behaviour. With
39// `panic = "abort"` a panic was already a trap, so the handler below traps
40// without formatting anything first.
41#![cfg_attr(all(target_arch = "wasm32", feature = "bare", not(test)), no_std)]
42
43extern crate alloc;
44
45use alloc::string::{String, ToString};
46use alloc::vec;
47use alloc::vec::Vec;
48
49#[cfg(all(target_arch = "wasm32", feature = "bare", not(test)))]
50#[panic_handler]
51fn panic(_: &core::panic::PanicInfo) -> ! {
52 core::arch::wasm32::unreachable()
53}
54
55#[cfg(all(target_arch = "wasm32", feature = "bare", not(test)))]
56#[global_allocator]
57static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
58
59
60#[cfg(feature = "bindgen")]
61use wasm_bindgen::prelude::*;
62
63/// Where a file is in its lifecycle. Matches the JS `IndexState`.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum State {
66 Added,
67 Modified,
68 Deleted,
69 Renamed,
70 Untracked,
71 Conflicted,
72}
73
74impl State {
75 fn from_code(code: u8) -> Option<Self> {
76 Some(match code {
77 0 => State::Added,
78 1 => State::Modified,
79 2 => State::Deleted,
80 3 => State::Renamed,
81 4 => State::Untracked,
82 5 => State::Conflicted,
83 _ => return None,
84 })
85 }
86
87 fn is_conflicted(self) -> bool {
88 matches!(self, State::Conflicted)
89 }
90}
91
92/// One file, reduced to what the summary needs.
93///
94/// The path is *not* here. Ordering needs to compare paths, but paths cross the
95/// boundary as UTF-8 bytes in `paths`, not as owned Rust strings per entry, which
96/// keeps a thousand-file tree to two flat allocations.
97#[derive(Debug, Clone, Copy)]
98pub struct Entry {
99 /// State in the index (staged), or `255` for "not staged".
100 pub index: u8,
101 /// State in the worktree, or `255` for "not staged".
102 pub worktree: u8,
103}
104
105/// The counts shown in the status header.
106///
107/// Crosses the boundary as a flat `[staged, unstaged, conflicted, untracked]`
108/// rather than as four separate calls: one array copy beats four calls, and the
109/// caller reads it without allocating an object per count.
110#[derive(Debug, Clone, Copy)]
111pub struct Counts {
112 pub staged: u32,
113 pub unstaged: u32,
114 pub conflicted: u32,
115 pub untracked: u32,
116}
117
118impl Counts {
119 /// The four counts, in the order they are documented.
120 fn as_slice(self) -> [u32; 4] {
121 [self.staged, self.unstaged, self.conflicted, self.untracked]
122 }
123}
124
125/// Folds entries into header counts.
126///
127/// Staged and unstaged are counted separately because they answer different
128/// questions: "what is in the commit I am about to make" and "what have I not
129/// staged yet". A file changed in both appears in both counts, which is correct —
130/// collapsing them is how a commit silently includes edits the user did not intend.
131///
132/// An entry code that is not a known state is treated as absent rather than
133/// guessed at. A status surface that invents a state from an unrecognised code
134/// shows a badge nobody asked for.
135pub fn summarize_status(entries: &[Entry]) -> Counts {
136 let mut counts = Counts { staged: 0, unstaged: 0, conflicted: 0, untracked: 0 };
137 for entry in entries {
138 let index = State::from_code(entry.index);
139 let worktree = State::from_code(entry.worktree);
140 if index.is_some_and(State::is_conflicted) || worktree.is_some_and(State::is_conflicted) {
141 counts.conflicted += 1;
142 }
143 if index.is_some() {
144 counts.staged += 1;
145 }
146 if let Some(worktree) = worktree {
147 counts.unstaged += 1;
148 if matches!(worktree, State::Untracked) {
149 counts.untracked += 1;
150 }
151 }
152 }
153 counts
154}
155
156/// The attention rank of an entry: lower sorts first.
157///
158/// Conflicts first because they block everything else. Then files changed in both
159/// the index and the worktree, because those are the ones where staging is a real
160/// decision. Then the rest.
161fn rank(entry: &Entry) -> u8 {
162 let index = State::from_code(entry.index);
163 let worktree = State::from_code(entry.worktree);
164 if index.is_some_and(State::is_conflicted) || worktree.is_some_and(State::is_conflicted) {
165 0
166 } else if index.is_some() && worktree.is_some() {
167 1
168 } else {
169 2
170 }
171}
172
173/// Returns indices into `entries`, ordered by attention rank then by path.
174///
175/// Returns a flat `Vec<u32>` of indices rather than reordered entries: the caller
176/// already holds the paths, and re-sending them would copy every string across the
177/// boundary for no gain.
178///
179/// # Paths must be sorted by the caller, and that is not optional
180///
181/// This sorts by **rank only**. The JS twin sorts by rank *and* path, so the two
182/// agree only when the caller has already sorted by path. Passing unsorted entries
183/// is silent: the WASM returns a plausible order that differs from the JS one only
184/// in ties, where nothing looks wrong.
185///
186/// This was a real divergence, caught by a parity test that fed entries in
187/// non-alphabetical order. Two ways to close it were possible -- move the path
188/// comparison here, or require sorted input -- and requiring sorted input was
189/// chosen because sorting paths in Rust would mean copying every path string across
190/// the boundary, which is the ~302 KB `js-sys` problem AGENTS.md warns about, for
191/// the sake of a tie-break the caller can do for free while it already holds the
192/// paths. `order_entries_js` documents the requirement at the boundary.
193pub fn order_entries(entries: &[Entry]) -> Vec<u32> {
194 let mut order: Vec<u32> = (0..entries.len() as u32).collect();
195 // `sort_by_key` is stable, so entries of equal rank keep the caller's order --
196 // which is the caller's path order, as required above.
197 order.sort_by_key(|&i| rank(&entries[i as usize]));
198 order
199}
200
201/// Counts added and removed lines between two file revisions.
202///
203/// Returns `(added, removed)`. A text that is `None` on either side is treated as
204/// empty, matching the JS twin: a file being added has no before-revision, and
205/// treating that as "unchanged" would report zero lines for a new file.
206///
207/// `identical` is *not* returned. The JS twin returns it, but a caller that has
208/// the two texts already can compare them; carrying a third value across the
209/// boundary for a bit it can derive would be waste.
210///
211/// # Counts are of lines, never bytes
212///
213/// A line ends at `\n` and a trailing newline yields a final empty element, so
214/// `"a\n"` is two lines and `"a"` is one. That is the same rule `fc-diff` and the
215/// JS twin use, and it is why the two agree on `"a\n"` vs `"a"`. It looks like a
216/// quirk; it is deliberate, because all three implementations must split the same
217/// way or the counts disagree.
218pub fn diff_counts(before: Option<&str>, after: Option<&str>) -> (u32, u32) {
219 let before = before.unwrap_or("");
220 let after = after.unwrap_or("");
221 let before_lines: Vec<&str> = before.split('\n').collect();
222 let after_lines: Vec<&str> = after.split('\n').collect();
223
224 // Trim the common prefix and suffix, then count what remains.
225 //
226 // There is no algorithm here at all, and that is the point: the surface only
227 // needs totals, so `added` is however many lines `after` has that `before`
228 // does not. `fc-diff` runs Myers because it has to reconstruct *which* lines;
229 // running it here would cost real time to produce a number this gets for
230 // free, and would make the two implementations agree by accident rather than
231 // by construction.
232 //
233 // The trimming is what keeps the answer useful. Without it, a one-line change
234 // at the top of a 5000-line file reports the whole file as rewritten, because
235 // the common tail is counted as removed and then added again.
236 let mut head = 0;
237 while head < before_lines.len() && head < after_lines.len()
238 && before_lines[head] == after_lines[head]
239 {
240 head += 1;
241 }
242 let mut tail = 0;
243 while tail < before_lines.len() - head
244 && tail < after_lines.len() - head
245 && before_lines[before_lines.len() - 1 - tail] == after_lines[after_lines.len() - 1 - tail]
246 {
247 tail += 1;
248 }
249
250 // `saturating_sub`, because the two loops can each consume lines and their
251 // combined reach is not bounded by either alone. Plain subtraction panics in
252 // debug and wraps in release -- so a release build would report a file with
253 // billions of changed lines rather than crashing, which is far worse than
254 // either. Reached by inputs like "\n" vs "\n", where both lines are empty and
255 // head and tail meet in the middle.
256 let before_remaining = before_lines.len().saturating_sub(head + tail);
257 let after_remaining = after_lines.len().saturating_sub(head + tail);
258 (after_remaining as u32, before_remaining as u32)
259}
260
261// ---- The wasm-bindgen boundary ---------------------------------------------
262
263/// Codes an entry field uses for "no state".
264const NONE: u8 = 255;
265
266/// Folds entries into counts.
267///
268/// `codes` is a flat `i32` array of `[index, worktree, index, worktree, ...]`,
269/// interleaved to match how the caller already has the data.
270///
271/// Exported rather than taking a `Vec<Entry>` because that would need `js_sys` to
272/// read it off the JS side. Two flat arrays would work too; interleaving keeps the
273/// caller's construction to one loop.
274#[cfg_attr(feature = "bindgen", wasm_bindgen)]
275pub fn summarize_status_js(codes: &[i32]) -> Vec<u32> {
276 let entries: Vec<Entry> = codes
277 .chunks_exact(2)
278 .map(|pair| Entry {
279 index: normalize(pair[0]),
280 worktree: normalize(pair[1]),
281 })
282 .collect();
283 summarize_status(&entries).as_slice().to_vec()
284}
285
286/// Returns indices ordered by attention then path.
287/// Returns indices ordered by attention rank, then by the caller's existing order.
288///
289/// **`codes` must already be sorted by path.** This function breaks ties by input
290/// order, not by comparing paths, because path strings are not available on this
291/// side of the boundary. Feeding unsorted entries yields a plausible order that
292/// disagrees with the JS twin only where ranks tie -- which is to say it looks
293/// correct and is not.
294#[cfg_attr(feature = "bindgen", wasm_bindgen)]
295pub fn order_entries_js(codes: &[i32]) -> Vec<u32> {
296 let entries: Vec<Entry> = codes
297 .chunks_exact(2)
298 .map(|pair| Entry {
299 index: normalize(pair[0]),
300 worktree: normalize(pair[1]),
301 })
302 .collect();
303 order_entries(&entries)
304}
305
306/// Counts added and removed lines. Returns a flat `[added, removed]`.
307#[cfg_attr(feature = "bindgen", wasm_bindgen)]
308pub fn diff_counts_js(before: Option<String>, after: Option<String>) -> Vec<u32> {
309 let (added, removed) = diff_counts(before.as_deref(), after.as_deref());
310 vec![added, removed]
311}
312
313fn normalize(code: i32) -> u8 {
314 if !(0..=5).contains(&code) { NONE } else { code as u8 }
315}
316
317// ---- Tests -----------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 const ADDED: u8 = 0;
324 const MODIFIED: u8 = 1;
325 const UNTRACKED: u8 = 4;
326 const CONFLICTED: u8 = 5;
327
328 fn entry(index: u8, worktree: u8) -> Entry {
329 Entry { index, worktree }
330 }
331
332 #[test]
333 fn counts_a_clean_tree_as_zero() {
334 let counts = summarize_status(&[]);
335 assert_eq!(counts.staged, 0);
336 assert_eq!(counts.unstaged, 0);
337 assert_eq!(counts.conflicted, 0);
338 assert_eq!(counts.untracked, 0);
339 }
340
341 #[test]
342 fn counts_staged_and_worktree_separately() {
343 // One file staged, one only in the worktree. Collapsing these into one
344 // number is how a commit silently includes unstaged edits.
345 let entries = [entry(MODIFIED, NONE), entry(NONE, MODIFIED)];
346 let counts = summarize_status(&entries);
347 assert_eq!(counts.staged, 1);
348 assert_eq!(counts.unstaged, 1);
349 }
350
351 #[test]
352 fn counts_a_file_changed_in_both_under_both_counts() {
353 let counts = summarize_status(&[entry(MODIFIED, MODIFIED)]);
354 assert_eq!(counts.staged, 1, "it is in the commit");
355 assert_eq!(counts.unstaged, 1, "and it is not fully staged");
356 }
357
358 #[test]
359 fn counts_untracked_as_both_unstaged_and_untracked() {
360 // An untracked file is unstaged, and the sub-count is what the header
361 // needs to say "3 untracked" without the user counting rows.
362 let counts = summarize_status(&[entry(NONE, UNTRACKED), entry(NONE, UNTRACKED)]);
363 assert_eq!(counts.unstaged, 2);
364 assert_eq!(counts.untracked, 2);
365 assert_eq!(counts.staged, 0);
366 }
367
368 #[test]
369 fn counts_a_conflicted_file_as_conflicted_and_not_as_staged() {
370 let counts = summarize_status(&[entry(CONFLICTED, CONFLICTED)]);
371 assert_eq!(counts.conflicted, 1);
372 assert_eq!(counts.staged, 1, "conflicted in the index, so it is in the index");
373 assert_eq!(counts.unstaged, 1);
374 }
375
376 #[test]
377 fn treats_an_unknown_state_code_as_absent() {
378 // Guessing a state from an unrecognised code shows a badge nobody asked for.
379 let counts = summarize_status(&[entry(99, 99)]);
380 assert_eq!(counts.staged, 0);
381 assert_eq!(counts.unstaged, 0);
382 }
383
384 #[test]
385 fn orders_conflicts_first_then_both_sided_then_the_rest() {
386 let entries = [
387 entry(MODIFIED, NONE), // rank 2
388 entry(MODIFIED, MODIFIED), // rank 1
389 entry(CONFLICTED, CONFLICTED), // rank 0
390 ];
391 let order = order_entries(&entries);
392 assert_eq!(order, vec![2, 1, 0]);
393 }
394
395 #[test]
396 fn ordering_is_stable_within_a_rank() {
397 // The JS twin sorts stably too, over caller-sorted paths. If either sort
398 // stops being stable, parity breaks on ties, which is exactly the case a
399 // summary would never notice.
400 let entries = [entry(MODIFIED, NONE), entry(MODIFIED, NONE), entry(MODIFIED, NONE)];
401 assert_eq!(order_entries(&entries), vec![0, 1, 2]);
402 }
403
404 #[test]
405 fn ordering_breaks_ties_by_caller_order_not_by_path() {
406 // Documents the boundary's one real limitation, which a parity test
407 // discovered by feeding entries out of path order: paths never cross into
408 // this crate, so it cannot compare them.
409 let entries = [entry(MODIFIED, NONE), entry(MODIFIED, NONE)];
410 // Reversed relative to how a caller would order them by path; this crate
411 // preserves input order, which is the documented contract.
412 assert_eq!(order_entries(&entries), vec![0, 1]);
413 }
414
415 #[test]
416 fn counts_added_and_removed_lines() {
417 let (added, removed) = diff_counts(Some("a\nb\n"), Some("a\nc\nd\n"));
418 assert_eq!(added, 2, "c and d");
419 assert_eq!(removed, 1, "b");
420 }
421
422 #[test]
423 fn treats_a_missing_revision_as_empty() {
424 // A new file has no before-revision. Treating that as unchanged would
425 // report zero lines for a file that is entirely new.
426 let (added, removed) = diff_counts(None, Some("a\nb\n"));
427 assert_eq!(added, 2);
428 assert_eq!(removed, 0);
429 let (added, removed) = diff_counts(Some("a\nb\n"), None);
430 assert_eq!(added, 0);
431 assert_eq!(removed, 2);
432 }
433
434 #[test]
435 fn reports_zero_for_identical_text() {
436 assert_eq!(diff_counts(Some("a\nb\n"), Some("a\nb\n")), (0, 0));
437 assert_eq!(diff_counts(None, None), (0, 0));
438 }
439
440 #[test]
441 fn counts_a_whole_replacement() {
442 let (added, removed) = diff_counts(Some("a\nb\n"), Some("x\n"));
443 assert_eq!(added, 1);
444 assert_eq!(removed, 2);
445 }
446
447 #[test]
448 fn counts_a_trailing_newline_difference() {
449 // "a\n" splits to ["a", ""], so "a" is a real line-count difference and
450 // not an artefact of the final newline. Both implementations agree here
451 // because both split the same way.
452 let (added, removed) = diff_counts(Some("a\n"), Some("a"));
453 assert_eq!(added, 0);
454 assert_eq!(removed, 1);
455 }
456
457 #[test]
458 fn handles_empty_strings_without_underflowing() {
459 // The prefix/suffix trim subtracts from the line counts, so an empty input
460 // is where an off-by-one would panic rather than return a wrong answer --
461 // which is the better failure, but still a failure.
462 assert_eq!(diff_counts(Some(""), Some("")), (0, 0));
463 // "" splits to [""], so it is one line, not zero. Adding a line to an
464 // empty file is therefore +1 -- and the JS twin says the same, which is
465 // the only reason this is correct rather than merely consistent.
466 assert_eq!(diff_counts(Some(""), Some("a\n")), (1, 0));
467 assert_eq!(diff_counts(Some("a\n"), Some("")), (0, 1));
468 // "\n" is ["", ""]: head consumes both lines, leaving nothing for the
469 // suffix loop. Plain subtraction wrapped here in a release build and
470 // would have reported ~4 billion changed lines instead of failing.
471 assert_eq!(diff_counts(Some("\n"), Some("\n")), (0, 0));
472 assert_eq!(diff_counts(Some("\n"), Some("a\n\n")), (1, 0));
473 }
474
475 #[test]
476 fn counts_a_whitespace_only_change() {
477 // Lines are compared verbatim; no trimming of either side, because
478 // whitespace is content in a diff.
479 assert_eq!(diff_counts(Some("a\n b\n"), Some("a\nb\n")), (1, 1));
480 }
481
482 #[test]
483 fn the_js_boundary_normalizes_unknown_codes() {
484 let counts = summarize_status_js(&[99, 99, 0, 255]);
485 assert_eq!(counts, vec![1, 0, 0, 0], "only the known code counted");
486 }
487}