Skip to main content

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}