Skip to main content

zsh/extensions/p10k/
git.rs

1//! p10k git status backend — native replacement for gitstatusd.
2//!
3//! Implementation strategy (native `.git` reader, subprocess gated):
4//! - Repo discovery + branch/commit/action come from reading `.git` directly:
5//!   `.git` may be a file containing `gitdir: <path>` (worktrees/submodules);
6//!   `HEAD` is either a symbolic ref (`ref: refs/heads/<branch>`) or a detached
7//!   commit hash; in-progress action state (rebase/merge/cherry-pick/bisect/
8//!   revert/am) is detected from marker files/dirs in the gitdir, mirroring
9//!   gitstatus's action names exactly (gitstatus/src/git.cc `RepoState`, which
10//!   itself mirrors libgit2 `git_repository_state`). Branch tips resolve via
11//!   loose refs then `packed-refs` in the common dir.
12//! - NATIVE fields (no subprocess): branch, commit, action, remote_branch
13//!   (from `.git/config` `branch.<name>.remote`/`branch.<name>.merge`,
14//!   branch-only name per gitstatus.plugin.zsh:39,97), unstaged + conflicted
15//!   (native `.git/index` v2/v3/v4 parse + lstat scan, port of gitstatus
16//!   index.cc IsModified), stashes (`.git/logs/refs/stash` reflog line count —
17//!   exactly what `git stash list` counts), tag (refs/tags loose scan +
18//!   packed-refs peel lines, port of gitstatus tag_db.cc TagForCommit), and
19//!   ahead/behind in the common case (upstream tip == HEAD tip, or no
20//!   upstream ⇒ 0/0, matching gitstatusd's absent-upstream output).
21//! - SUBPROCESS fields (`git status --porcelain=v2 --branch --show-stash`,
22//!   ONE exec per cache miss, gated to only these fields):
23//!   * staged  — requires diffing the index against the HEAD tree
24//!     (gitstatus repo.cc:127-243 does `git_diff_tree_to_index`); reading the
25//!     HEAD tree needs the object store (zlib-inflated loose objects and
26//!     packfiles) and no inflate impl exists in-tree (Cargo.toml has zstd,
27//!     not zlib), so it comes from the porcelain subprocess.
28//!   * untracked — requires a faithful .gitignore engine (nested .gitignore,
29//!     info/exclude, core.excludesFile, negations, dir-only patterns, `**`),
30//!     so it comes from the porcelain subprocess as well.
31//!   * ahead/behind when the branch and upstream tips DIVERGE — the commit
32//!     count needs an object-store commit walk (same zlib wall as staged);
33//!     `# branch.ab` from the porcelain fallback is kept for this case only.
34//!   * everything, when the native index parse fails (unsupported version,
35//!     sparse-index tree entries, oversized index, corrupt data).
36//! - Tag lookup: loose ANNOTATED tags are peeled through one memoised
37//!   `git cat-file --batch-check` (the object store needs zlib); packed
38//!   tags use the `^<oid>` peel lines (gitstatus peels via libgit2,
39//!   tag_db.cc:295-330 TagHasTarget).
40//! - Caches: two, with different lifetimes.
41//!   * `cache()` — the combined snapshot, per-gitdir, ~2s TTL, additionally
42//!     invalidated when `.git/index` or `.git/HEAD` mtime changes. A hit is
43//!     a map lookup.
44//!   * `sub_cache()` — the last COMPLETED porcelain parse, per-gitdir, no
45//!     TTL. It exists because the subprocess is run ASYNCHRONOUSLY, the way
46//!     p10k queries gitstatusd: the prompt waits at most
47//!     `POWERLEVEL9K_VCS_MAX_SYNC_LATENCY_SECONDS` (0.05 default) and then
48//!     renders the LAST KNOWN status while the daemon answers
49//!     (p10k:3799-3812). So only the first prompt in a repo ever blocks
50//!     (SUBPROCESS_BUDGET); after that the snapshot is served immediately
51//!     and a refresh runs on a helper thread, at most one per repo. The
52//!     child is never killed — killing it, as this code used to do, threw
53//!     away every answer in any repo where `git status` is slower than the
54//!     budget, so the segment never rendered AND the full cost was paid
55//!     again on the very next prompt.
56//!   The cost of that async model is that staged/untracked (and diverged
57//!   ahead/behind) can be one prompt stale; every NATIVE field is always
58//!   recomputed fresh.
59//! - Perf: native path is one ~600KB index read + one lstat per index entry
60//!   + a few small ref/config/log reads. The lstat scan dominates and is
61//!   latency-bound, so it fans out over up to MAX_STAT_THREADS threads the
62//!   way gitstatusd's does (`gitstatusd -t <n>`): measured on this repo at
63//!   5,831 entries, 63-132ms serial vs 20-24ms parallel. Indexes over
64//!   NATIVE_SCAN_MAX entries skip the native scan to protect the budget.
65//! - No new crate dependencies; std only.
66//!
67//! p10k consumes these fields as the `VCS_STATUS_*` parameters that gitstatusd
68//! used to publish (p10k:3775-3783 — `$VCS_STATUS_COMMIT`,
69//! `$VCS_STATUS_LOCAL_BRANCH`, `$VCS_STATUS_REMOTE_BRANCH`,
70//! `$VCS_STATUS_ACTION`, `$VCS_STATUS_NUM_STAGED`, `$VCS_STATUS_NUM_UNSTAGED`,
71//! `$VCS_STATUS_NUM_CONFLICTED`, `$VCS_STATUS_NUM_UNTRACKED`,
72//! `$VCS_STATUS_COMMITS_AHEAD`, `$VCS_STATUS_COMMITS_BEHIND`,
73//! `$VCS_STATUS_STASHES`, `$VCS_STATUS_TAG`). The tag p10k renders
74//! (p10k:3959-3961) is gitstatus's EXACT match of a tag to the HEAD commit
75//! (tag_db.cc:119-148 TagForCommit), not a `git describe` nearest-tag.
76
77use crate::extensions::p10k::gitconfig::GitConfig;
78use std::collections::HashMap;
79use std::ffi::OsStr;
80use std::fs;
81use std::io::Read;
82use std::os::unix::ffi::OsStrExt;
83use std::path::{Path, PathBuf};
84use std::process::{Command, Stdio};
85use std::sync::mpsc;
86use std::sync::{Mutex, OnceLock};
87use std::time::{Duration, Instant, SystemTime};
88
89/// One snapshot of a repo's state, shaped after gitstatusd's response fields
90/// (gitstatus/src/response.cc) as p10k reads them (p10k:3775-3783).
91#[derive(Debug, Clone, Default)]
92pub struct GitStatus {
93    pub branch: String,
94    pub commit: String,
95    pub action: String, // rebase/merge/etc, "" if none
96    pub ahead: i64,
97    pub behind: i64,
98    pub staged: i64,
99    pub unstaged: i64,
100    pub untracked: i64,
101    pub conflicted: i64,
102    pub stashes: i64,
103    pub tag: String,
104    pub remote_branch: String,
105    /// `VCS_STATUS_REMOTE_URL` — configured URL of the upstream's remote
106    /// (gitstatus git.cc:215 `git_remote_url`); "" with no upstream or a
107    /// local-branch upstream (`remote = .`).
108    pub remote_url: String,
109    /// `VCS_STATUS_WORKDIR` — top-level working directory.
110    pub workdir: String,
111    /// `VCS_STATUS_REMOTE_NAME` — the tracking remote (`.` for a local
112    /// upstream branch); "" without a resolvable upstream.
113    pub remote_name: String,
114    /// `VCS_STATUS_PUSH_REMOTE_NAME` / `_URL` — `branch.<b>.pushRemote`, else
115    /// `remote.pushDefault`; "" when it or its tracking ref cannot be
116    /// resolved (gitstatus git.cc GetPushRemote).
117    pub push_remote_name: String,
118    pub push_remote_url: String,
119    /// `VCS_STATUS_PUSH_COMMITS_AHEAD` / `_BEHIND` against that ref.
120    pub push_ahead: i64,
121    pub push_behind: i64,
122    /// `VCS_STATUS_INDEX_SIZE` — entries in the index.
123    pub index_size: i64,
124    /// `VCS_STATUS_NUM_SKIP_WORKTREE` / `_NUM_ASSUME_UNCHANGED`.
125    pub skip_worktree: i64,
126    pub assume_unchanged: i64,
127    /// `VCS_STATUS_NUM_STAGED_NEW` / `_STAGED_DELETED` / `_UNSTAGED_DELETED`,
128    /// each at most the matching staged/unstaged count.
129    pub staged_new: i64,
130    pub staged_deleted: i64,
131    pub unstaged_deleted: i64,
132    /// `VCS_STATUS_HAS_*` is `-1` (unknown) when the index is larger than
133    /// `POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY`.
134    pub dirty_unknown: bool,
135}
136
137/// The reporting limits p10k hands gitstatusd (`-s -u -d -c -m -e`,
138/// p10k:8866-8890): at most this many changes of each kind (negative:
139/// unlimited), the index size above which unstaged/untracked are not
140/// computed (negative: never), and whether untracked directories are
141/// counted file by file.
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct GitLimits {
144    pub max_staged: i64,
145    pub max_unstaged: i64,
146    pub max_untracked: i64,
147    pub max_conflicted: i64,
148    pub dirty_max_index_size: i64,
149    pub recurse_untracked_dirs: bool,
150}
151
152impl Default for GitLimits {
153    /// gitstatusd's defaults (options.cc).
154    fn default() -> Self {
155        GitLimits {
156            max_staged: 1,
157            max_unstaged: 1,
158            max_untracked: 1,
159            max_conflicted: 1,
160            dirty_max_index_size: -1,
161            recurse_untracked_dirs: false,
162        }
163    }
164}
165
166impl GitLimits {
167    /// The `POWERLEVEL9K_VCS_*` parameters, with p10k's defaults.
168    pub fn from_params() -> Self {
169        use crate::extensions::p10k::shared::{global_bool, global_int};
170        GitLimits {
171            max_staged: global_int("VCS_STAGED_MAX_NUM", 1),
172            max_unstaged: global_int("VCS_UNSTAGED_MAX_NUM", 1),
173            max_untracked: global_int("VCS_UNTRACKED_MAX_NUM", 1),
174            max_conflicted: global_int("VCS_CONFLICTED_MAX_NUM", 1),
175            dirty_max_index_size: global_int("VCS_MAX_INDEX_SIZE_DIRTY", -1),
176            recurse_untracked_dirs: global_bool("VCS_RECURSE_UNTRACKED_DIRS", false),
177        }
178    }
179
180    /// `std::min(count, limit)` with a negative limit meaning no limit.
181    fn clamp(count: i64, limit: i64) -> i64 {
182        if limit < 0 {
183            count
184        } else {
185            count.min(limit)
186        }
187    }
188
189    /// The part of `GetIndexStats` that follows the scans (repo.cc:205-237):
190    /// zero the dirty counts of an oversized index, then apply the maxima.
191    fn apply(&self, s: &mut GitStatus, index_size: i64) {
192        let too_large = self.dirty_max_index_size >= 0 && index_size > self.dirty_max_index_size;
193        if too_large {
194            s.unstaged = 0;
195            s.untracked = 0;
196            s.unstaged_deleted = 0;
197        }
198        s.dirty_unknown = too_large;
199        s.staged = Self::clamp(s.staged, self.max_staged);
200        s.unstaged = Self::clamp(s.unstaged, self.max_unstaged);
201        s.conflicted = Self::clamp(s.conflicted, self.max_conflicted);
202        s.untracked = Self::clamp(s.untracked, self.max_untracked);
203        s.staged_new = s.staged_new.min(s.staged);
204        s.staged_deleted = s.staged_deleted.min(s.staged);
205        s.unstaged_deleted = s.unstaged_deleted.min(s.unstaged);
206    }
207}
208
209/// Cache TTL. gitstatusd recomputed on every prompt but kept the repo open;
210/// we amortize the index scan + subprocess instead.
211const CACHE_TTL: Duration = Duration::from_secs(2);
212/// Prompt latency budget for the `git status` subprocess.
213const SUBPROCESS_BUDGET: Duration = Duration::from_millis(45);
214/// Native index scan cap: one lstat per entry; 20k entries ≈ ~10-20ms warm,
215/// which is the most the prompt path should ever spend. Bigger indexes fall
216/// back to the subprocess for unstaged/conflicted (gitstatus has the same
217/// escape hatch via its dirty_max_index_size option).
218const NATIVE_SCAN_MAX: usize = 20_000;
219/// Index-entry count below which the worktree scan stays on one thread —
220/// below this the spawn/join overhead outweighs the latency it hides.
221const PAR_STAT_MIN: usize = 512;
222/// Upper bound on worktree-scan threads. gitstatusd defaults its pool to the
223/// core count (`gitstatusd -t`, gitstatus/src/options.cc), but the prompt is
224/// not the only thing running; past ~8 the lstat queue, not the CPU, is the
225/// limit.
226const MAX_STAT_THREADS: usize = 8;
227
228struct CacheEntry {
229    at: Instant,
230    head_mtime: Option<SystemTime>,
231    index_mtime: Option<SystemTime>,
232    limits: GitLimits,
233    status: GitStatus,
234}
235
236fn cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
237    static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
238    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
239}
240
241/// Last completed `git status --porcelain=v2` parse per gitdir, kept apart
242/// from `cache()` because it has its OWN lifetime: a background refresh
243/// lands here whenever the child finishes, long after the prompt that
244/// started it returned. Only the subprocess-only fields of the value are
245/// ever read (module doc); the rest is recomputed natively every time.
246struct SubEntry {
247    at: Instant,
248    mode: UntrackedMode,
249    status: GitStatus,
250}
251
252/// `git status --untracked-files=<mode>`: how untracked files are counted.
253#[derive(Clone, Copy, Debug, PartialEq)]
254enum UntrackedMode {
255    No,
256    Normal,
257    All,
258}
259
260impl UntrackedMode {
261    fn arg(self) -> &'static str {
262        match self {
263            UntrackedMode::No => "--untracked-files=no",
264            UntrackedMode::Normal => "--untracked-files=normal",
265            UntrackedMode::All => "--untracked-files=all",
266        }
267    }
268}
269
270fn sub_cache() -> &'static Mutex<HashMap<PathBuf, SubEntry>> {
271    static SUB: OnceLock<Mutex<HashMap<PathBuf, SubEntry>>> = OnceLock::new();
272    SUB.get_or_init(|| Mutex::new(HashMap::new()))
273}
274
275/// gitdirs with a `git status` child already running. p10k likewise keeps at
276/// most one outstanding gitstatusd request per repo (p10k:3799-3812 —
277/// `_p9k_vcs_status_deferred`), so a slow repo cannot pile up work.
278fn sub_inflight() -> &'static Mutex<std::collections::HashSet<PathBuf>> {
279    static IN: OnceLock<Mutex<std::collections::HashSet<PathBuf>>> = OnceLock::new();
280    IN.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
281}
282
283/// Bound on both caches — one entry per repo visited, generous because the
284/// map is per-repo, not per-directory.
285const CACHE_CAP: usize = 64;
286
287/// Start a `git status --porcelain=v2` for `repo` unless one is already
288/// running, and wait up to `wait` for it.
289///
290/// Returns the parse when the child answered inside `wait`. Otherwise the
291/// child is NOT killed: it keeps running on its helper thread and stores its
292/// answer in `sub_cache()` for the next prompt. That is the gitstatusd
293/// contract p10k is written against — the prompt waits at most
294/// `POWERLEVEL9K_VCS_MAX_SYNC_LATENCY_SECONDS` (0.05 by default,
295/// p10k:7495) and then renders the last known status while the daemon
296/// finishes (p10k:3799-3812). Killing the child instead, as this code did
297/// before, threw away every answer in any repo where `git status` is slower
298/// than the budget — so the snapshot never appeared at all, and the full
299/// cost was paid again on the very next prompt.
300fn refresh_porcelain(
301    repo: &Repo,
302    mode: UntrackedMode,
303    wait: Option<Duration>,
304) -> Option<GitStatus> {
305    {
306        let mut set = sub_inflight().lock().ok()?;
307        if !set.insert(repo.git_dir.clone()) {
308            return None; // already refreshing; nothing new to serve yet
309        }
310    }
311
312    let work_dir = repo.work_dir.clone();
313    let git_dir = repo.git_dir.clone();
314    let (tx, rx) = mpsc::channel();
315    crate::signal_thread::spawn(move || {
316        let out = run_porcelain(&work_dir, mode);
317        if let Some(out) = out {
318            let mut sub = GitStatus::default();
319            parse_porcelain_v2(&out, &mut sub);
320            if let Ok(mut map) = sub_cache().lock() {
321                if map.len() >= CACHE_CAP && !map.contains_key(&git_dir) {
322                    evict_oldest(&mut map, |e: &SubEntry| e.at);
323                }
324                map.insert(
325                    git_dir.clone(),
326                    SubEntry {
327                        at: Instant::now(),
328                        mode,
329                        status: sub.clone(),
330                    },
331                );
332            }
333            let _ = tx.send(sub);
334        }
335        if let Ok(mut set) = sub_inflight().lock() {
336            set.remove(&git_dir);
337        }
338    });
339
340    match wait {
341        Some(d) => rx.recv_timeout(d).ok(),
342        None => None,
343    }
344}
345
346/// Drop the stalest entry of a capped cache.
347fn evict_oldest<V>(map: &mut HashMap<PathBuf, V>, at: impl Fn(&V) -> Instant) {
348    if let Some(oldest) = map
349        .iter()
350        .max_by_key(|(_, v)| at(v).elapsed())
351        .map(|(k, _)| k.clone())
352    {
353        map.remove(&oldest);
354    }
355}
356
357/// Public entry point per the p10k module contract. `dir` is any directory;
358/// the repo is discovered by walking up. None when not inside a git repo, or
359/// when the first-ever status of a repo exceeds the latency budget.
360pub fn git_status_for(dir: &Path) -> Option<GitStatus> {
361    git_status_with(dir, &GitLimits::from_params())
362}
363
364/// [`git_status_for`] under explicit reporting limits.
365pub fn git_status_with(dir: &Path, limits: &GitLimits) -> Option<GitStatus> {
366    let repo = discover_repo(dir)?;
367    let head_mtime = mtime_of(&repo.git_dir.join("HEAD"));
368    let index_mtime = mtime_of(&repo.git_dir.join("index"));
369
370    // Fresh-enough cache hit: TTL not expired AND HEAD/index untouched.
371    if let Ok(map) = cache().lock() {
372        if let Some(e) = map.get(&repo.git_dir) {
373            if e.at.elapsed() < CACHE_TTL
374                && e.limits == *limits
375                && e.head_mtime == head_mtime
376                && e.index_mtime == index_mtime
377            {
378                return Some(e.status.clone());
379            }
380        }
381    }
382
383    // -------- native layer (no subprocess) --------
384    let mut status = GitStatus::default();
385    read_head(&repo, &mut status);
386    status.workdir = repo.work_dir.to_string_lossy().into_owned();
387    status.action = repo_action(&repo.git_dir); // gitstatus git.cc:43-110 RepoState
388
389    // libgit2's view of the configuration: system, XDG, global, the repo's
390    // own file and (with extensions.worktreeConfig) config.worktree, with
391    // include / includeIf followed.
392    let cfg = GitConfig::load_stack(
393        &repo.common_dir,
394        &repo.git_dir,
395        Some(status.branch.as_str()).filter(|b| !b.is_empty()),
396    );
397    let caps = RepoCaps::from_config(&cfg);
398
399    // Git options gitstatusd honours on top of the -s/-u/-d/-c limits
400    // (repo.cc:116-145): a repo that turns untracked or dirty reporting off.
401    let mut lim = *limits;
402    let off = |section: &str, key: &str| cfg.bool_value(section, key) == Some(false);
403    if off("status", "showuntrackedfiles") || off("bash", "showuntrackedfiles") {
404        lim.max_untracked = 0;
405    }
406    if off("bash", "showdirtystate") {
407        lim.max_staged = 0;
408        lim.max_unstaged = 0;
409        lim.max_conflicted = 0;
410    }
411    let untracked_mode = if lim.max_untracked == 0 {
412        UntrackedMode::No
413    } else if lim.recurse_untracked_dirs {
414        UntrackedMode::All
415    } else {
416        UntrackedMode::Normal
417    };
418
419    // Upstream from branch.<name>.remote / branch.<name>.merge. REMOTE_BRANCH
420    // is the branch-only name (gitstatus.plugin.zsh:39 `VCS_STATUS_REMOTE_BRANCH=master`),
421    // which is what p10k compares against the local branch (p10k:3967-3968).
422    let mut ab_native = false;
423    if !status.branch.is_empty() {
424        match tracking_of(&cfg, &repo.common_dir, &status.branch) {
425            Some(t) => {
426                status.remote_branch = t.branch;
427                status.remote_name = t.remote.clone();
428                // gitstatus git.cc:204-215 — the URL of a real remote.
429                if t.remote != "." {
430                    status.remote_url =
431                        cfg.get("remote", &t.remote, "url").unwrap_or_default().to_string();
432                }
433                if t.tip == status.commit {
434                    // Tips equal ⇒ ahead=behind=0 without any object walk;
435                    // diverged tips take the counts from porcelain.
436                    ab_native = true;
437                }
438            }
439            // No upstream, or its ref is gone: gitstatusd reports 0/0.
440            None => ab_native = true,
441        }
442        if let Some(p) = push_target_of(&cfg, &repo.common_dir, &status.branch) {
443            status.push_remote_name = p.remote;
444            status.push_remote_url = p.url;
445            if p.tip != status.commit {
446                if let Some((ahead, behind)) = rev_list_counts(&repo.common_dir, &status.commit, &p.refname) {
447                    status.push_ahead = ahead;
448                    status.push_behind = behind;
449                }
450            }
451        }
452    } else {
453        ab_native = true; // detached HEAD has no upstream
454    }
455
456    // Stashes: reflog line count — `git stash list` is exactly this reflog.
457    let native_stash = stash_count(&repo.common_dir);
458    if let Some(n) = native_stash {
459        status.stashes = n;
460    }
461
462    // Tag: exact match of refs/tags/* against the HEAD commit
463    // (tag_db.cc:119-148 TagForCommit).
464    if !status.commit.is_empty() {
465        status.tag = tag_for_commit(&repo.common_dir, &status.commit);
466    }
467
468    // Index scan: unstaged + conflicted natively.
469    let index_size = index_entry_count(&repo.git_dir);
470    status.index_size = index_size;
471    let native_counts = read_index_counts(&repo, &caps);
472    if let Some(ic) = &native_counts {
473        status.unstaged = ic.unstaged;
474        status.conflicted = ic.conflicted;
475        status.skip_worktree = ic.skip_worktree;
476        status.assume_unchanged = ic.assume_unchanged;
477    }
478
479    // -------- subprocess fields (staged/untracked always; the rest only
480    // when the native layer could not produce them — see module doc) --------
481    // Asynchronous, exactly as p10k queries gitstatusd: serve the last
482    // snapshot immediately and let a refresh land in the background; only
483    // the first-ever prompt in a repo blocks, and then only for
484    // SUBPROCESS_BUDGET. The native fields above are always fresh; a
485    // background refresh can leave staged/untracked one prompt stale, which
486    // is the same window p10k lives with (p10k:3799-3812).
487    let prev_sub = sub_cache()
488        .lock()
489        .ok()
490        .and_then(|m| {
491            m.get(&repo.git_dir)
492                .filter(|e| e.mode == untracked_mode)
493                .map(|e| e.status.clone())
494        });
495    let sub = match prev_sub {
496        Some(s) => {
497            // Have something to render: never block, just kick the refresh.
498            refresh_porcelain(&repo, untracked_mode, None);
499            Some(s)
500        }
501        // Nothing cached yet — wait out the budget so a first paint in a
502        // fast repo is already complete.
503        None => refresh_porcelain(&repo, untracked_mode, Some(SUBPROCESS_BUDGET)),
504    };
505
506    let sub = match sub {
507        Some(s) => s,
508        // First prompt in this repo and `git status` is still running: the
509        // subprocess-only fields have no value yet, and a segment claiming
510        // "0 staged, 0 untracked" would be a lie. Same as before: no
511        // segment this prompt, real numbers on the next one.
512        None => return None,
513    };
514
515    status.staged = sub.staged;
516    status.untracked = sub.untracked;
517    status.staged_new = sub.staged_new;
518    status.staged_deleted = sub.staged_deleted;
519    status.unstaged_deleted = sub.unstaged_deleted;
520    if native_counts.is_none() {
521        status.unstaged = sub.unstaged;
522        status.conflicted = sub.conflicted;
523    }
524    if !ab_native {
525        status.ahead = sub.ahead;
526        status.behind = sub.behind;
527    }
528    if native_stash.is_none() {
529        status.stashes = sub.stashes; // --show-stash fallback
530    }
531    if status.commit.is_empty() {
532        status.commit = sub.commit;
533    }
534    if status.branch.is_empty() {
535        status.branch = sub.branch;
536    }
537    if status.remote_branch.is_empty() && !sub.remote_branch.is_empty() {
538        // `# branch.upstream` is `<remote>/<branch>`; strip the remote
539        // (remote names cannot contain '/') to the branch-only shape.
540        status.remote_branch = match sub.remote_branch.split_once('/') {
541            Some((_, b)) => b.to_string(),
542            None => sub.remote_branch,
543        };
544    }
545
546    lim.apply(&mut status, index_size);
547
548    if let Ok(mut map) = cache().lock() {
549        // Bound the cache: one entry per repo visited; a long-lived shell
550        // hopping across many repos must not grow this without limit.
551        if map.len() >= CACHE_CAP && !map.contains_key(&repo.git_dir) {
552            evict_oldest(&mut map, |e: &CacheEntry| e.at);
553        }
554        map.insert(
555            repo.git_dir.clone(),
556            CacheEntry {
557                at: Instant::now(),
558                head_mtime,
559                index_mtime,
560                limits: *limits,
561                status: status.clone(),
562            },
563        );
564    }
565    Some(status)
566}
567
568// ---------------------------------------------------------------------------
569// Repo discovery
570// ---------------------------------------------------------------------------
571
572struct Repo {
573    /// Directory containing HEAD, index, rebase state, ... (per-worktree).
574    git_dir: PathBuf,
575    /// Directory containing refs/, packed-refs, config, logs/refs/stash
576    /// (shared across worktrees).
577    common_dir: PathBuf,
578    /// Top-level working directory (where `git status` runs).
579    work_dir: PathBuf,
580}
581
582/// Walk up from `dir` until a `.git` entry is found. A `.git` directory is
583/// the gitdir itself; a `.git` file holds `gitdir: <path>` (worktree or
584/// submodule checkout). The common dir is the gitdir unless a `commondir`
585/// file redirects it (linked worktrees).
586fn discover_repo(dir: &Path) -> Option<Repo> {
587    let mut cur = if dir.is_absolute() {
588        dir.to_path_buf()
589    } else {
590        std::env::current_dir().ok()?.join(dir)
591    };
592    loop {
593        let dotgit = cur.join(".git");
594        if let Ok(meta) = fs::symlink_metadata(&dotgit) {
595            let git_dir = if meta.is_dir() {
596                dotgit
597            } else {
598                // `.git` file: `gitdir: /abs/path` or `gitdir: ../rel/path`
599                let text = fs::read_to_string(&dotgit).ok()?;
600                let target = text.strip_prefix("gitdir:")?.trim();
601                let p = Path::new(target);
602                if p.is_absolute() {
603                    p.to_path_buf()
604                } else {
605                    cur.join(p)
606                }
607            };
608            let common_dir = match fs::read_to_string(git_dir.join("commondir")) {
609                Ok(text) => {
610                    let t = text.trim();
611                    let p = Path::new(t);
612                    if p.is_absolute() {
613                        p.to_path_buf()
614                    } else {
615                        git_dir.join(p)
616                    }
617                }
618                Err(_) => git_dir.clone(),
619            };
620            return Some(Repo {
621                git_dir,
622                common_dir,
623                work_dir: cur,
624            });
625        }
626        if !cur.pop() {
627            return None;
628        }
629    }
630}
631
632fn mtime_of(p: &Path) -> Option<SystemTime> {
633    fs::metadata(p).ok().and_then(|m| m.modified().ok())
634}
635
636// ---------------------------------------------------------------------------
637// HEAD / refs
638// ---------------------------------------------------------------------------
639
640/// Fill `branch` and `commit` from HEAD. Symbolic HEAD names the branch and
641/// the tip commit resolves through loose refs then packed-refs; detached
642/// HEAD is a bare hash with no branch (gitstatusd reports LOCAL_BRANCH=""
643/// for detached HEAD; p10k then falls back to the commit, p10k:3775).
644fn read_head(repo: &Repo, status: &mut GitStatus) {
645    let head = match fs::read_to_string(repo.git_dir.join("HEAD")) {
646        Ok(s) => s,
647        Err(e) => {
648            tracing::warn!("p10k git: unreadable HEAD in {:?}: {e}", repo.git_dir);
649            return;
650        }
651    };
652    let head = head.trim();
653    if let Some(refname) = head.strip_prefix("ref: ") {
654        let refname = refname.trim();
655        status.branch = refname
656            .strip_prefix("refs/heads/")
657            .unwrap_or(refname)
658            .to_string();
659        if let Some(oid) = resolve_ref(&repo.common_dir, refname) {
660            status.commit = oid;
661        }
662    } else if head.len() >= 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
663        status.commit = head.to_string(); // detached HEAD
664    }
665}
666
667/// Resolve a fully-qualified ref to its commit hash: loose ref file first,
668/// then a linear scan of packed-refs (`<oid> <refname>` lines; `^<oid>`
669/// peel lines and `#` comments skipped).
670fn resolve_ref(common_dir: &Path, refname: &str) -> Option<String> {
671    if let Ok(s) = fs::read_to_string(common_dir.join(refname)) {
672        let s = s.trim();
673        if !s.is_empty() {
674            return Some(s.to_string());
675        }
676    }
677    let packed = fs::read_to_string(common_dir.join("packed-refs")).ok()?;
678    for line in packed.lines() {
679        if line.starts_with('#') || line.starts_with('^') {
680            continue;
681        }
682        if let Some((oid, name)) = line.split_once(' ') {
683            if name == refname {
684                return Some(oid.to_string());
685            }
686        }
687    }
688    None
689}
690
691// ---------------------------------------------------------------------------
692// In-progress action
693// ---------------------------------------------------------------------------
694
695/// Port of gitstatus RepoState (gitstatus/src/git.cc:43-110), which maps
696/// libgit2 `git_repository_state` to names that "mostly match gitaction in
697/// vcs_info" (git.cc:46-47). Detection order and marker files follow libgit2
698/// repository.c: rebase-merge (interactive?) > rebase-apply
699/// (rebasing/applying?) > MERGE_HEAD > REVERT_HEAD > CHERRY_PICK_HEAD >
700/// BISECT_LOG. A rebase/am in flight appends " <next>/<last>" progress
701/// (git.cc:96-108).
702fn repo_action(git_dir: &Path) -> String {
703    let exists = |name: &str| git_dir.join(name).exists();
704    let read1 = |name: &str| -> String {
705        // git.cc:87-92 ReadFile — first whitespace-delimited token.
706        fs::read_to_string(git_dir.join(name))
707            .ok()
708            .and_then(|s| s.split_whitespace().next().map(str::to_string))
709            .unwrap_or_default()
710    };
711
712    let state: &str = if exists("rebase-merge") {
713        if exists("rebase-merge/interactive") {
714            "rebase-i" // git.cc:68 GIT_REPOSITORY_STATE_REBASE_INTERACTIVE
715        } else {
716            "rebase-m" // git.cc:70 GIT_REPOSITORY_STATE_REBASE_MERGE
717        }
718    } else if exists("rebase-apply") {
719        if exists("rebase-apply/rebasing") {
720            "rebase" // git.cc:66 GIT_REPOSITORY_STATE_REBASE
721        } else if exists("rebase-apply/applying") {
722            "am" // git.cc:72 GIT_REPOSITORY_STATE_APPLY_MAILBOX
723        } else {
724            "am/rebase" // git.cc:74 GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE
725        }
726    } else if exists("MERGE_HEAD") {
727        "merge" // git.cc:54 GIT_REPOSITORY_STATE_MERGE
728    } else if exists("REVERT_HEAD") {
729        if exists("sequencer/todo") {
730            "revert-seq" // git.cc:58 GIT_REPOSITORY_STATE_REVERT_SEQUENCE
731        } else {
732            "revert" // git.cc:56 GIT_REPOSITORY_STATE_REVERT
733        }
734    } else if exists("CHERRY_PICK_HEAD") {
735        if exists("sequencer/todo") {
736            "cherry-seq" // git.cc:62 GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE
737        } else {
738            "cherry" // git.cc:60 GIT_REPOSITORY_STATE_CHERRYPICK
739        }
740    } else if exists("BISECT_LOG") {
741        "bisect" // git.cc:64 GIT_REPOSITORY_STATE_BISECT
742    } else {
743        "" // git.cc:52 GIT_REPOSITORY_STATE_NONE
744    };
745
746    // git.cc:96-102 — rebase-merge carries msgnum/end, rebase-apply next/last.
747    let (next, last) = if exists("rebase-merge") {
748        (read1("rebase-merge/msgnum"), read1("rebase-merge/end"))
749    } else if exists("rebase-apply") {
750        (read1("rebase-apply/next"), read1("rebase-apply/last"))
751    } else {
752        (String::new(), String::new())
753    };
754
755    // git.cc:104-107 — append " next/last" only when both are present.
756    if !next.is_empty() && !last.is_empty() {
757        format!("{state} {next}/{last}")
758    } else {
759        state.to_string()
760    }
761}
762
763/// git_refspec_transform: map `refname` through one fetch refspec
764/// (`[+]<src>[*]:<dst>[*]`); `None` when its source does not match.
765fn refspec_transform(spec: &str, refname: &str) -> Option<String> {
766    let spec = spec.strip_prefix('+').unwrap_or(spec);
767    let (src, dst) = spec.split_once(':')?;
768    match src.split_once('*') {
769        Some((pre, post)) => {
770            let middle = refname.strip_prefix(pre)?.strip_suffix(post)?;
771            let (dst_pre, dst_post) = dst.split_once('*')?;
772            Some(format!("{dst_pre}{middle}{dst_post}"))
773        }
774        None => (src == refname).then(|| dst.to_string()),
775    }
776}
777
778/// `refname` as the tracking ref of `remote` (the first fetch refspec of
779/// the remote that matches it). `None` when the remote is not configured.
780fn remote_tracking_ref(cfg: &GitConfig, remote: &str, refname: &str) -> Option<String> {
781    if !cfg.has_group("remote", remote) {
782        return None;
783    }
784    cfg.get_all("remote", remote, "fetch")
785        .iter()
786        .find_map(|spec| refspec_transform(spec, refname))
787}
788
789/// The upstream of a branch, as gitstatus git.cc GetRemote resolves it.
790struct Tracking {
791    /// `origin`, or `.` for a local upstream branch.
792    remote: String,
793    /// The upstream's short name without the remote (`main`).
794    branch: String,
795    /// The commit its ref points at.
796    tip: String,
797}
798
799/// `branch.<name>.remote` + `branch.<name>.merge`, mapped to a tracking ref
800/// through the remote's fetch refspecs (`.` keeps the merge ref). `None`
801/// unless that ref exists (git.cc:203-204 `git_reference_lookup`).
802fn tracking_of(cfg: &GitConfig, common_dir: &Path, branch: &str) -> Option<Tracking> {
803    let remote = cfg.get("branch", branch, "remote").filter(|r| !r.is_empty())?;
804    let merge = cfg.get("branch", branch, "merge").filter(|m| !m.is_empty())?;
805    let refname = if remote == "." {
806        merge.to_string()
807    } else {
808        remote_tracking_ref(cfg, remote, merge)?
809    };
810    let tip = resolve_ref(common_dir, &refname)?;
811    let short = refname
812        .strip_prefix("refs/heads/")
813        .or_else(|| refname.strip_prefix("refs/remotes/"))
814        .unwrap_or(&refname);
815    let name = if remote == "." {
816        short
817    } else {
818        short.strip_prefix(remote).and_then(|s| s.strip_prefix('/')).unwrap_or(short)
819    };
820    Some(Tracking { remote: remote.to_string(), branch: name.to_string(), tip })
821}
822
823/// Where a branch is pushed, as git.cc GetPushRemote resolves it.
824struct PushTarget {
825    remote: String,
826    url: String,
827    refname: String,
828    tip: String,
829}
830
831/// `branch.<name>.pushRemote`, else `remote.pushDefault`; the ref is the
832/// branch itself mapped through that remote's fetch refspecs. `None` when
833/// there is no such remote or its ref does not exist.
834fn push_target_of(cfg: &GitConfig, common_dir: &Path, branch: &str) -> Option<PushTarget> {
835    let remote = cfg
836        .get("branch", branch, "pushremote")
837        .or_else(|| cfg.get("remote", "", "pushdefault"))
838        .filter(|r| !r.is_empty())?;
839    let local = format!("refs/heads/{branch}");
840    let refname = if remote == "." {
841        local
842    } else {
843        remote_tracking_ref(cfg, remote, &local)?
844    };
845    let tip = resolve_ref(common_dir, &refname)?;
846    let url = if remote == "." {
847        String::new()
848    } else {
849        cfg.get("remote", remote, "url").unwrap_or_default().to_string()
850    };
851    Some(PushTarget { remote: remote.to_string(), url, refname, tip })
852}
853
854/// `(commits in head not in other, commits in other not in head)` — the
855/// two `CountRange` walks of gitstatus (`other..HEAD`, `HEAD..other`).
856fn rev_list_counts(common_dir: &Path, head: &str, other: &str) -> Option<(i64, i64)> {
857    let out = Command::new("git")
858        .arg(format!("--git-dir={}", common_dir.display()))
859        .args(["rev-list", "--left-right", "--count"])
860        .arg(format!("{head}...{other}"))
861        .env("GIT_OPTIONAL_LOCKS", "0")
862        .stdin(Stdio::null())
863        .stderr(Stdio::null())
864        .output()
865        .ok()?;
866    let text = String::from_utf8_lossy(&out.stdout);
867    let mut it = text.split_whitespace().map(|n| n.parse::<i64>());
868    match (it.next(), it.next()) {
869        (Some(Ok(a)), Some(Ok(b))) => Some((a, b)),
870        _ => None,
871    }
872}
873
874/// Port of gitstatus RepoCaps (index.cc:308-318): repository capabilities
875/// that steer the lstat comparison. libgit2 defaults both to true on unix;
876/// `git clone`/`git init` write them explicitly.
877#[derive(Clone, Copy)]
878struct RepoCaps {
879    trust_filemode: bool, // core.filemode  (index.cc:309 is_filemode_trustworthy)
880    has_symlinks: bool,   // core.symlinks  (index.cc:310 index_supports_symlinks)
881    hash_len: usize,      // 20 (sha1) / 32 (extensions.objectformat = sha256)
882}
883
884impl RepoCaps {
885    fn from_config(cfg: &GitConfig) -> RepoCaps {
886        RepoCaps {
887            trust_filemode: cfg.get_bool("core", "filemode", true),
888            has_symlinks: cfg.get_bool("core", "symlinks", true),
889            hash_len: match cfg.get("extensions", "", "objectformat") {
890                Some("sha256") => 32,
891                _ => 20,
892            },
893        }
894    }
895}
896
897// ---------------------------------------------------------------------------
898// .git/index (v2/v3/v4) → unstaged + conflicted
899// ---------------------------------------------------------------------------
900
901struct IndexCounts {
902    unstaged: i64,
903    conflicted: i64,
904    /// Entries with the skip-worktree / assume-valid ("assume unchanged")
905    /// bit set (gitstatus index.cc).
906    skip_worktree: i64,
907    assume_unchanged: i64,
908}
909
910/// The entry count in the `.git/index` header (`VCS_STATUS_INDEX_SIZE`);
911/// 0 when there is no readable index.
912fn index_entry_count(git_dir: &Path) -> i64 {
913    use std::io::Read as _;
914    let mut head = [0u8; 12];
915    let read = fs::File::open(git_dir.join("index")).and_then(|mut f| f.read_exact(&mut head));
916    match read {
917        Ok(()) if &head[0..4] == b"DIRC" => i64::from(u32::from_be_bytes([head[8], head[9], head[10], head[11]])),
918        _ => 0,
919    }
920}
921
922fn be32(data: &[u8], pos: usize) -> Option<u32> {
923    data.get(pos..pos + 4)
924        .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
925}
926
927fn be16(data: &[u8], pos: usize) -> Option<u16> {
928    data.get(pos..pos + 2)
929        .map(|b| u16::from_be_bytes([b[0], b[1]]))
930}
931
932/// git varint.c decode_varint — "offset encoding": 7 bits per byte, MSB is
933/// the continuation bit, and each continuation adds 1 before shifting so
934/// that multi-byte encodings have no redundant forms.
935fn decode_varint(data: &[u8], pos: &mut usize) -> Option<u64> {
936    let mut c = *data.get(*pos)?;
937    *pos += 1;
938    let mut val = u64::from(c & 0x7f);
939    let mut rounds = 0;
940    while c & 0x80 != 0 {
941        rounds += 1;
942        if rounds > 9 {
943            return None; // > 63 significant bits ⇒ corrupt
944        }
945        c = *data.get(*pos)?;
946        *pos += 1;
947        val = val.checked_add(1)?.checked_shl(7)? | u64::from(c & 0x7f);
948    }
949    Some(val)
950}
951
952/// The per-entry stat fields the modified check needs (gitformat-index.txt
953/// entry layout; offsets relative to the entry start):
954///   0 ctime-sec  4 ctime-nsec  8 mtime-sec  12 mtime-nsec  16 dev  20 ino
955///   24 mode  28 uid  32 gid  36 file-size  (all u32 big-endian, truncated)
956///   40 .. 40+H  object id (H = 20 sha1 / 32 sha256)
957///   40+H  flags u16be: bit15 assume-valid, bit14 extended (v3+),
958///         bits13-12 stage, bits11-0 name length (0xFFF when longer)
959///   [+2 extended flags u16be when the extended bit is set (v3+):
960///         bit14 skip-worktree, bit13 intent-to-add]
961///   then the path: v2/v3 NUL-terminated + NUL padding to a multiple of 8
962///   bytes from the entry start (1-8 NULs); v4 prefix-compressed — varint N
963///   (bytes to strip from the END of the previous path) + NUL-terminated
964///   suffix, no padding.
965#[derive(Clone, Copy)]
966struct EntryStat {
967    mode: u32,
968    ino: u32,
969    size: u32,
970    mt_s: u32,
971    mt_ns: u32,
972}
973
974const S_IFMT: u32 = 0o170000;
975const S_IFREG: u32 = 0o100000;
976const S_IFLNK: u32 = 0o120000;
977const S_IFDIR: u32 = 0o040000;
978
979/// Port of gitstatus IsModified (index.cc:71-104) — decides whether a
980/// stage-0 index entry differs from its worktree file. gitstatus compares
981/// exactly ino / stage / fsize / mtime / mode (index.cc:93-99); dev, uid,
982/// gid and ctime are NOT compared.
983fn is_modified(e: &EntryStat, st: &fs::Metadata, caps: &RepoCaps) -> bool {
984    use std::os::unix::fs::MetadataExt;
985    // index.cc:71-83 — normalize the worktree mode before comparing.
986    let st_mode = st.mode();
987    let mode = if st_mode & S_IFMT == S_IFREG {
988        if !caps.has_symlinks && e.mode & S_IFMT == S_IFLNK {
989            e.mode
990        } else if !caps.trust_filemode {
991            e.mode
992        } else {
993            S_IFREG | if st_mode & 0o100 != 0 { 0o755 } else { 0o644 }
994        }
995    } else {
996        st_mode & S_IFMT
997    };
998    // index.cc:93 — ino: 0 in the index matches anything; else compare the
999    // u32-truncated st_ino exactly as C does.
1000    if e.ino != 0 && e.ino != st.ino() as u32 {
1001        return true;
1002    }
1003    // index.cc:97 — fsize. NOTE racy-git: git smudges racily-clean entries
1004    // to file_size 0 on index write, forcing a content check; gitstatus (and
1005    // git) would then hash the file. We instead COUNT the entry as unstaged
1006    // per the racy-git convention — a just-touched-but-identical file may
1007    // show unstaged for one prompt until the next `git status`/`git add`
1008    // rewrites the index. Never wrong about truly-dirty files.
1009    if u64::from(e.size) != st.size() {
1010        return true;
1011    }
1012    // index.cc:61-69 MTimeEq — seconds must match; nanoseconds match, or the
1013    // index stores 0 nsec (git built without USE_NSEC — the GITSTATUS_ZERO_NSEC
1014    // build flag; applied unconditionally here since a 0-nsec index entry vs
1015    // a real-nsec filesystem is the common macOS/Linux packaged-git case).
1016    if e.mt_s as i64 != st.mtime() || !(e.mt_ns as i64 == st.mtime_nsec() || e.mt_ns == 0) {
1017        return true;
1018    }
1019    // index.cc:99 — mode.
1020    if e.mode != mode {
1021        return true;
1022    }
1023    false
1024}
1025
1026/// lstat every collected entry and count the modified ones (index.cc:191-199
1027/// StatFiles + IsModified). One lstat(2) per index entry is latency-bound,
1028/// not CPU-bound, which is why gitstatusd fans the same scan out over its
1029/// thread pool (`gitstatusd -t <n>`); measured here at 5,831 entries the
1030/// serial scan is ~115-133ms and an 8-way scan ~28-49ms.
1031///
1032/// The count is order-independent (a plain sum over entries), so splitting
1033/// the slice changes nothing about the result.
1034fn count_modified(entries: &[(EntryStat, PathBuf)], caps: &RepoCaps) -> i64 {
1035    fn scan(chunk: &[(EntryStat, PathBuf)], caps: &RepoCaps) -> i64 {
1036        chunk
1037            .iter()
1038            .filter(|(e, path)| match fs::symlink_metadata(path) {
1039                // index.cc:195 — ENOENT ⇒ deleted, other errors ⇒ unreadable;
1040                // both are dirty candidates.
1041                Err(_) => true,
1042                Ok(st) => is_modified(e, &st, caps),
1043            })
1044            .count() as i64
1045    }
1046
1047    let nthreads = std::thread::available_parallelism()
1048        .map(|n| n.get())
1049        .unwrap_or(1)
1050        .min(MAX_STAT_THREADS);
1051    if entries.len() < PAR_STAT_MIN || nthreads < 2 {
1052        return scan(entries, caps);
1053    }
1054    let caps = *caps;
1055    let chunk_len = entries.len().div_ceil(nthreads);
1056    std::thread::scope(|s| {
1057        let handles: Vec<_> = entries
1058            .chunks(chunk_len)
1059            .map(|c| s.spawn(move || scan(c, &caps)))
1060            .collect();
1061        handles.into_iter().map(|h| h.join().unwrap_or(0)).sum()
1062    })
1063}
1064
1065/// Parse `.git/index` and lstat each stage-0 entry: unstaged = entries whose
1066/// worktree file is missing/unreadable/modified (index.cc:191-199 StatFiles
1067/// + IsModified), conflicted = distinct paths with stage > 0. Returns None
1068/// when the native parse cannot proceed (unsupported version, sparse-index
1069/// tree entries, oversized index, corrupt data) — the caller then takes both
1070/// counts from the porcelain subprocess. The trailing checksum is NOT
1071/// verified (read-only prompt path; no SHA-1 impl in-tree — worst case a
1072/// torn index yields wrong counts for one 2s cache window).
1073fn read_index_counts(repo: &Repo, caps: &RepoCaps) -> Option<IndexCounts> {
1074    let data = fs::read(repo.git_dir.join("index")).ok()?;
1075    // Header (12 bytes): "DIRC", version u32be, entry count u32be; the file
1076    // ends with a hash-len checksum trailer.
1077    if data.len() < 12 + caps.hash_len || &data[0..4] != b"DIRC" {
1078        tracing::debug!(
1079            "p10k git: index missing/short/bad magic in {:?}",
1080            repo.git_dir
1081        );
1082        return None;
1083    }
1084    let version = be32(&data, 4)?;
1085    if !(2..=4).contains(&version) {
1086        tracing::debug!("p10k git: unsupported index version {version}");
1087        return None;
1088    }
1089    let nentries = be32(&data, 8)? as usize;
1090    if nentries > NATIVE_SCAN_MAX {
1091        tracing::debug!(
1092            "p10k git: index has {nentries} entries > {NATIVE_SCAN_MAX}, deferring to subprocess"
1093        );
1094        return None;
1095    }
1096
1097    let mut pos = 12usize;
1098    let mut prev_path: Vec<u8> = Vec::new(); // v4 prefix compression state
1099    let mut last_conflict: Vec<u8> = Vec::new();
1100    let mut unstaged = 0i64;
1101    let mut conflicted = 0i64;
1102    let (mut skip_count, mut assume_count) = (0i64, 0i64);
1103    // Entries whose worktree file must be lstat'ed, collected here and
1104    // scanned in parallel once the (inherently serial) parse is done.
1105    let mut to_stat: Vec<(EntryStat, PathBuf)> = Vec::with_capacity(nentries);
1106
1107    for _ in 0..nentries {
1108        let start = pos;
1109        // Fixed stat area (40) + oid + flags.
1110        if pos + 40 + caps.hash_len + 2 > data.len() {
1111            return None;
1112        }
1113        let e = EntryStat {
1114            mt_s: be32(&data, pos + 8)?,
1115            mt_ns: be32(&data, pos + 12)?,
1116            ino: be32(&data, pos + 20)?,
1117            mode: be32(&data, pos + 24)?,
1118            size: be32(&data, pos + 36)?,
1119        };
1120        let flags = be16(&data, pos + 40 + caps.hash_len)?;
1121        pos += 40 + caps.hash_len + 2;
1122
1123        let assume_valid = flags & 0x8000 != 0;
1124        let extended = flags & 0x4000 != 0;
1125        let stage = (flags >> 12) & 0x3;
1126        let name_len = (flags & 0xFFF) as usize;
1127
1128        let mut skip_worktree = false;
1129        let mut intent_to_add = false;
1130        if extended {
1131            if version < 3 {
1132                return None; // extended bit is invalid in v2 ⇒ corrupt
1133            }
1134            let ext = be16(&data, pos)?;
1135            pos += 2;
1136            skip_worktree = ext & 0x4000 != 0;
1137            intent_to_add = ext & 0x2000 != 0;
1138        }
1139
1140        // Path.
1141        if version == 4 {
1142            let strip = decode_varint(&data, &mut pos)? as usize;
1143            if strip > prev_path.len() {
1144                return None;
1145            }
1146            let keep = prev_path.len() - strip;
1147            prev_path.truncate(keep);
1148            let nul = data.get(pos..)?.iter().position(|&b| b == 0)?;
1149            prev_path.extend_from_slice(&data[pos..pos + nul]);
1150            pos += nul + 1; // v4: no padding
1151        } else {
1152            let actual_len = if name_len < 0xFFF {
1153                name_len
1154            } else {
1155                data.get(pos..)?.iter().position(|&b| b == 0)?
1156            };
1157            prev_path.clear();
1158            prev_path.extend_from_slice(data.get(pos..pos + actual_len)?);
1159            // v2/v3 entry size: (fixed + namelen + 8) & !7 — NUL padding to a
1160            // multiple of 8 from the entry start, ≥ 1 NUL (read-cache.c
1161            // ondisk_ce_size).
1162            let fixed = pos - start;
1163            pos = start + ((fixed + actual_len + 8) & !7);
1164            if pos > data.len() {
1165                return None;
1166            }
1167        }
1168
1169        // ---- evaluate the entry ----
1170        if stage != 0 {
1171            // Unmerged: 2-3 entries per path (stages 1/2/3, adjacent since
1172            // the index is name-then-stage sorted); count the PATH once,
1173            // matching one `u` line per path in porcelain v2.
1174            if prev_path != last_conflict {
1175                conflicted += 1;
1176                last_conflict.clear();
1177                last_conflict.extend_from_slice(&prev_path);
1178            }
1179            continue;
1180        }
1181        skip_count += i64::from(skip_worktree);
1182        assume_count += i64::from(assume_valid);
1183        if skip_worktree || assume_valid {
1184            // git status ignores skip-worktree entries; assume-valid
1185            // (`git update-index --assume-unchanged`) means "trust the index"
1186            // — no stat, matching git.
1187            continue;
1188        }
1189        if intent_to_add {
1190            // `git add -N`: porcelain v2 reports `1 .A` — an unstaged add.
1191            unstaged += 1;
1192            continue;
1193        }
1194        if e.mode & S_IFMT == S_IFDIR {
1195            // Sparse-index tree entry (extensions.sparseIndex): expanding it
1196            // needs the object store — defer everything to the subprocess.
1197            tracing::debug!(
1198                "p10k git: sparse index in {:?}, deferring to subprocess",
1199                repo.git_dir
1200            );
1201            return None;
1202        }
1203        // Defer the lstat: gitstatus does the whole worktree scan on a thread
1204        // pool (index.cc:191-199 StatFiles runs under ParallelForEach), and
1205        // the parse cannot itself be parallelised because v4 paths are
1206        // prefix-compressed against the previous entry.
1207        to_stat.push((e, repo.work_dir.join(OsStr::from_bytes(&prev_path))));
1208    }
1209    unstaged += count_modified(&to_stat, caps);
1210    // Extensions (TREE, REUC, UNTR, ...) follow the entries; none are needed
1211    // for these two counts, so parsing stops here.
1212
1213    Some(IndexCounts {
1214        unstaged,
1215        conflicted,
1216        skip_worktree: skip_count,
1217        assume_unchanged: assume_count,
1218    })
1219}
1220
1221// ---------------------------------------------------------------------------
1222// Stashes — .git/logs/refs/stash reflog
1223// ---------------------------------------------------------------------------
1224
1225/// Stash count = line count of the stash reflog; `git stash list` renders
1226/// exactly this reflog, one line per stash. The file lives in the COMMON dir
1227/// (reflogs other than HEAD are shared across worktrees). Absent file = no
1228/// stashes (git deletes it when the last stash is dropped). None only on a
1229/// real read error (the caller then falls back to porcelain `# stash`).
1230fn stash_count(common_dir: &Path) -> Option<i64> {
1231    match fs::read(common_dir.join("logs/refs/stash")) {
1232        Ok(bytes) => Some(bytes.iter().filter(|&&b| b == b'\n').count() as i64),
1233        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
1234        Err(e) => {
1235            tracing::debug!("p10k git: stash reflog unreadable: {e}");
1236            None
1237        }
1238    }
1239}
1240
1241// ---------------------------------------------------------------------------
1242// Tag — port of gitstatus TagDb::TagForCommit (tag_db.cc:119-148)
1243// ---------------------------------------------------------------------------
1244
1245/// Fully peeled target (`<oid>^{}`) of each object id. Objects are
1246/// immutable, so results are memoised for the process; unseen ids are
1247/// resolved by ONE `git cat-file --batch-check` (the object store needs
1248/// zlib, which this crate does not link). Ids git cannot resolve map to
1249/// themselves.
1250fn peel_objects<'a>(
1251    common_dir: &Path,
1252    oids: impl Iterator<Item = &'a str>,
1253) -> HashMap<String, String> {
1254    use std::io::Write;
1255    static MEMO: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
1256    let memo = MEMO.get_or_init(Default::default);
1257    let wanted: Vec<&str> = oids.collect();
1258    let unseen: Vec<&str> = {
1259        let m = memo.lock().unwrap();
1260        let mut u: Vec<&str> = wanted.iter().copied().filter(|o| !m.contains_key(*o)).collect();
1261        u.sort_unstable();
1262        u.dedup();
1263        u
1264    };
1265    if !unseen.is_empty() {
1266        let child = Command::new("git")
1267            .arg(format!("--git-dir={}", common_dir.display()))
1268            .args(["cat-file", "--batch-check=%(objectname)"])
1269            .env("GIT_OPTIONAL_LOCKS", "0")
1270            .stdin(Stdio::piped())
1271            .stdout(Stdio::piped())
1272            .stderr(Stdio::null())
1273            .spawn();
1274        let mut answers: Vec<String> = Vec::new();
1275        if let Ok(mut child) = child {
1276            if let Some(mut stdin) = child.stdin.take() {
1277                let input: String = unseen.iter().map(|o| format!("{o}^{{}}\n")).collect();
1278                let _ = stdin.write_all(input.as_bytes());
1279            }
1280            if let Ok(out) = child.wait_with_output() {
1281                answers = String::from_utf8_lossy(&out.stdout)
1282                    .lines()
1283                    .map(str::to_string)
1284                    .collect();
1285            }
1286        }
1287        let mut m = memo.lock().unwrap();
1288        for (i, oid) in unseen.iter().enumerate() {
1289            // `<input> missing` lines never equal a 40/64-hex id.
1290            let peeled = answers
1291                .get(i)
1292                .filter(|a| !a.contains(' '))
1293                .cloned()
1294                .unwrap_or_else(|| (*oid).to_string());
1295            m.insert((*oid).to_string(), peeled);
1296        }
1297    }
1298    let m = memo.lock().unwrap();
1299    wanted
1300        .iter()
1301        .filter_map(|o| m.get(*o).map(|p| ((*o).to_string(), p.clone())))
1302        .collect()
1303}
1304
1305/// The tag p10k shows for HEAD: the lexicographically GREATEST tag name whose
1306/// (peeled) target equals the HEAD commit (tag_db.cc:130 `if (res < tag ...)`
1307/// and :143 — max over loose and packed matches). "" when no tag points at
1308/// HEAD. Loose tags shadow same-named packed tags (tag_db.cc:135,143
1309/// IsLooseTag). Loose ANNOTATED tags (file holds a tag object id) are
1310/// peeled with [`peel_objects`].
1311fn tag_for_commit(common_dir: &Path, commit: &str) -> String {
1312    let mut best = String::new();
1313    let mut loose_names: Vec<String> = Vec::new();
1314    let mut loose_candidates: Vec<(String, String)> = Vec::new();
1315
1316    // Loose tags: flat scan of refs/tags, mirroring tag_db.cc:150-162
1317    // ReadLooseTags (non-recursive; nested names like refs/tags/foo/bar are
1318    // missed — gitstatus has the same TODO at tag_db.cc:158).
1319    if let Ok(rd) = fs::read_dir(common_dir.join("refs/tags")) {
1320        for entry in rd.flatten() {
1321            let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
1322            if !is_file {
1323                continue;
1324            }
1325            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
1326                continue;
1327            };
1328            if let Ok(oid) = fs::read_to_string(entry.path()) {
1329                let oid = oid.trim();
1330                // Lightweight tag: the file IS the commit id.
1331                if oid == commit {
1332                    if best < name {
1333                        best = name.clone();
1334                    }
1335                } else if !oid.is_empty() {
1336                    // Possibly an annotated tag (the file holds the tag
1337                    // object id): peeled below.
1338                    loose_candidates.push((name.clone(), oid.to_string()));
1339                }
1340            }
1341            loose_names.push(name);
1342        }
1343    }
1344
1345    // Loose ANNOTATED tags (tag_db.cc:295-330 TagHasTarget peels through
1346    // libgit2): the tag object's final target, memoised per object id.
1347    if !loose_candidates.is_empty() {
1348        let peeled = peel_objects(common_dir, loose_candidates.iter().map(|(_, o)| o.as_str()));
1349        for (name, oid) in &loose_candidates {
1350            if peeled.get(oid).is_some_and(|t| t == commit) && best < *name {
1351                best = name.clone();
1352            }
1353        }
1354    }
1355
1356    // Packed tags. tag_db.cc:228-231: without a `#` header line the refs
1357    // cannot be assumed fully-peeled — drop all packed tags. tag_db.cc:236-238:
1358    // a header missing " fully-peeled"/" sorted" only warns and continues.
1359    let Ok(packed) = fs::read_to_string(common_dir.join("packed-refs")) else {
1360        return best;
1361    };
1362    if !packed.starts_with('#') {
1363        tracing::warn!("p10k git: packed-refs doesn't have a header. Won't resolve packed tags.");
1364        return best;
1365    }
1366    if let Some(header) = packed.lines().next() {
1367        if !header.contains(" fully-peeled") || !header.contains(" sorted") {
1368            tracing::warn!("p10k git: packed-refs has unexpected header."); // tag_db.cc:237
1369        }
1370    }
1371    let mut lines = packed.lines().peekable();
1372    while let Some(line) = lines.next() {
1373        if line.starts_with('#') || line.starts_with('^') {
1374            continue;
1375        }
1376        let Some((oid, refname)) = line.split_once(' ') else {
1377            continue;
1378        };
1379        // A following `^<oid>` line is the peeled target of an annotated tag
1380        // (tag_db.cc:256-263 replaces the id with the peeled one).
1381        let effective = match lines.peek() {
1382            Some(peel) if peel.starts_with('^') => lines
1383                .next()
1384                .and_then(|p| p.strip_prefix('^'))
1385                .unwrap_or(oid),
1386            _ => oid,
1387        };
1388        let Some(name) = refname.strip_prefix("refs/tags/") else {
1389            continue;
1390        };
1391        if effective == commit
1392            && !loose_names.iter().any(|l| l == name) // loose shadows packed
1393            && best.as_str() < name
1394        {
1395            best = name.to_string();
1396        }
1397    }
1398    best
1399}
1400
1401// ---------------------------------------------------------------------------
1402// Commit message — VCS_STATUS_COMMIT_ENCODING / VCS_STATUS_COMMIT_SUMMARY
1403// ---------------------------------------------------------------------------
1404
1405/// `Truncate(msg.summary, opts.max_commit_summary_length)`; gitstatusd's
1406/// `-z` default.
1407const MAX_COMMIT_SUMMARY_LEN: usize = 256;
1408
1409/// libgit2 `git_commit_summary`: the first paragraph of the message with
1410/// each run of whitespace containing a newline collapsed to one space and
1411/// trailing whitespace dropped.
1412fn commit_summary_of(message: &str) -> String {
1413    let msg = message.trim_start_matches('\n').as_bytes();
1414    let mut out: Vec<u8> = Vec::new();
1415    let mut space: Option<usize> = None;
1416    let mut space_has_newline = false;
1417    for (i, &c) in msg.iter().enumerate() {
1418        if c == b'\n' {
1419            // End of the first paragraph: the message ends, a blank line
1420            // follows, or the next line holds only whitespace.
1421            let rest = &msg[i + 1..];
1422            let next_blank = rest
1423                .iter()
1424                .find(|&&b| b == b'\n' || !b.is_ascii_whitespace())
1425                .is_none_or(|&b| b == b'\n');
1426            if next_blank {
1427                break;
1428            }
1429        }
1430        if c.is_ascii_whitespace() {
1431            if space.is_none() {
1432                space = Some(i);
1433                space_has_newline = false;
1434            }
1435            space_has_newline |= c == b'\n';
1436        } else {
1437            if let Some(start) = space.take() {
1438                if space_has_newline {
1439                    out.push(b' ');
1440                } else {
1441                    out.extend_from_slice(&msg[start..i]);
1442                }
1443            }
1444            out.push(c);
1445        }
1446    }
1447    out.truncate(MAX_COMMIT_SUMMARY_LEN);
1448    String::from_utf8_lossy(&out).into_owned()
1449}
1450
1451/// `(encoding, summary)` of `commit` in the repository at `workdir`; both
1452/// empty for an unreadable commit. Memoised per object id (commits are
1453/// immutable): one `git cat-file commit` the first time a HEAD is seen.
1454pub fn commit_message(workdir: &Path, commit: &str) -> (String, String) {
1455    static MEMO: OnceLock<Mutex<HashMap<String, (String, String)>>> = OnceLock::new();
1456    let memo = MEMO.get_or_init(Default::default);
1457    if commit.is_empty() {
1458        return Default::default();
1459    }
1460    if let Some(hit) = memo.lock().ok().and_then(|m| m.get(commit).cloned()) {
1461        return hit;
1462    }
1463    let out = Command::new("git")
1464        .arg("-C")
1465        .arg(workdir)
1466        .args(["cat-file", "commit", commit])
1467        .env("GIT_OPTIONAL_LOCKS", "0")
1468        .stdin(Stdio::null())
1469        .stderr(Stdio::null())
1470        .output();
1471    let result = match out {
1472        Ok(o) if o.status.success() => {
1473            let raw = String::from_utf8_lossy(&o.stdout).into_owned();
1474            let (headers, message) = raw.split_once("\n\n").unwrap_or((raw.as_str(), ""));
1475            let encoding = headers
1476                .lines()
1477                .find_map(|l| l.strip_prefix("encoding "))
1478                .unwrap_or_default()
1479                .to_string();
1480            (encoding, commit_summary_of(message))
1481        }
1482        _ => Default::default(),
1483    };
1484    if let Ok(mut m) = memo.lock() {
1485        if m.len() > 256 {
1486            m.clear();
1487        }
1488        m.insert(commit.to_string(), result.clone());
1489    }
1490    result
1491}
1492
1493// ---------------------------------------------------------------------------
1494// Gated subprocess: `git status --porcelain=v2` for staged / untracked
1495// (+ ahead-behind on divergence, + everything on native-parse failure)
1496// ---------------------------------------------------------------------------
1497
1498/// Run the one subprocess. Output is drained on a helper thread so a huge
1499/// dirty tree can't deadlock on a full pipe; the caller waits at most
1500/// SUBPROCESS_BUDGET. Returns None on spawn failure or timeout (child is
1501/// killed and reaped).
1502fn run_porcelain(work_dir: &Path, mode: UntrackedMode) -> Option<String> {
1503    let mut child = match Command::new("git")
1504        .arg("-C")
1505        .arg(work_dir)
1506        .args(["status", "--porcelain=v2", "--branch", "--show-stash"])
1507        .arg(mode.arg())
1508        // Match gitstatusd: never take optional locks / touch the index
1509        // from a prompt-driven read.
1510        .env("GIT_OPTIONAL_LOCKS", "0")
1511        .stdin(Stdio::null())
1512        .stdout(Stdio::piped())
1513        .stderr(Stdio::null())
1514        .spawn()
1515    {
1516        Ok(c) => c,
1517        Err(e) => {
1518            tracing::warn!("p10k git: failed to spawn git status: {e}");
1519            return None;
1520        }
1521    };
1522
1523    // Runs to completion on the caller's (always a helper) thread — the
1524    // prompt-latency budget is applied by `refresh_porcelain`, which decides
1525    // how long to WAIT for this, never how long to let it run. See that
1526    // function for why the child is no longer killed on a timeout.
1527    let mut stdout = child.stdout.take()?;
1528    let mut out = String::new();
1529    let read = stdout.read_to_string(&mut out);
1530    let _ = child.wait();
1531    match read {
1532        Ok(_) => Some(out),
1533        Err(e) => {
1534            tracing::warn!("p10k git: reading git status output failed: {e}");
1535            None
1536        }
1537    }
1538}
1539
1540/// Parse `git status --porcelain=v2 --branch --show-stash` output into the
1541/// count fields (plus upstream/ahead/behind and commit/branch fallbacks).
1542///
1543/// Header lines:
1544///   `# branch.oid <oid>`            — commit (fallback if HEAD read failed)
1545///   `# branch.head <name>`          — `(detached)` when detached
1546///   `# branch.upstream <r>/<b>`     — remote_branch
1547///   `# branch.ab +A -B`             — ahead/behind
1548///   `# stash <N>`                   — stash count (--show-stash)
1549/// Entry lines:
1550///   `1 XY ...` / `2 XY ...`         — X!='.' staged++, Y!='.' unstaged++
1551///   `u ...`                         — conflicted++ (unmerged)
1552///   `? <path>`                      — untracked++
1553fn parse_porcelain_v2(out: &str, status: &mut GitStatus) {
1554    for line in out.lines() {
1555        if let Some(rest) = line.strip_prefix("# ") {
1556            if let Some(oid) = rest.strip_prefix("branch.oid ") {
1557                if status.commit.is_empty() && oid != "(initial)" {
1558                    status.commit = oid.to_string();
1559                }
1560            } else if let Some(head) = rest.strip_prefix("branch.head ") {
1561                if status.branch.is_empty() && head != "(detached)" {
1562                    status.branch = head.to_string();
1563                }
1564            } else if let Some(up) = rest.strip_prefix("branch.upstream ") {
1565                status.remote_branch = up.to_string();
1566            } else if let Some(ab) = rest.strip_prefix("branch.ab ") {
1567                for tok in ab.split_whitespace() {
1568                    if let Some(a) = tok.strip_prefix('+') {
1569                        status.ahead = a.parse().unwrap_or(0);
1570                    } else if let Some(b) = tok.strip_prefix('-') {
1571                        status.behind = b.parse().unwrap_or(0);
1572                    }
1573                }
1574            } else if let Some(n) = rest.strip_prefix("stash ") {
1575                status.stashes = n.trim().parse().unwrap_or(0);
1576            }
1577        } else if line.starts_with("1 ") || line.starts_with("2 ") {
1578            // XY at bytes 2..4: X = staged state, Y = worktree state.
1579            let bytes = line.as_bytes();
1580            if bytes.len() >= 4 {
1581                if bytes[2] != b'.' {
1582                    status.staged += 1;
1583                }
1584                if bytes[3] != b'.' {
1585                    status.unstaged += 1;
1586                }
1587                // X = A: staged new file; X = D / Y = D: deleted.
1588                if bytes[2] == b'A' {
1589                    status.staged_new += 1;
1590                }
1591                if bytes[2] == b'D' {
1592                    status.staged_deleted += 1;
1593                }
1594                if bytes[3] == b'D' {
1595                    status.unstaged_deleted += 1;
1596                }
1597            }
1598        } else if line.starts_with("u ") {
1599            status.conflicted += 1;
1600        } else if line.starts_with("? ") {
1601            status.untracked += 1;
1602        }
1603        // `! <path>` (ignored entries) are not counted — p10k doesn't use them.
1604    }
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::*;
1610
1611    // ---- porcelain fallback parser (unchanged behavior) ----
1612
1613    #[test]
1614    fn porcelain_headers_and_counts() {
1615        let out = "\
1616# branch.oid 47a386b42798b5ed5d89bc617920d9152479013e
1617# branch.head main
1618# branch.upstream origin/main
1619# branch.ab +3 -2
1620# stash 1
16211 .M N... 100644 100644 100644 aaaa bbbb src/lib.rs
16221 M. N... 100644 100644 100644 aaaa bbbb src/exec.rs
16231 MM N... 100644 100644 100644 aaaa bbbb src/hist.rs
16242 R. N... 100644 100644 100644 aaaa bbbb R100 new.rs\tnew_old.rs
1625u UU N... 100644 100644 100644 100644 aaaa bbbb cccc conflict.rs
1626? untracked_a.rs
1627? untracked_b.rs
1628";
1629        let mut s = GitStatus::default();
1630        parse_porcelain_v2(out, &mut s);
1631        assert_eq!(s.commit, "47a386b42798b5ed5d89bc617920d9152479013e");
1632        assert_eq!(s.branch, "main");
1633        assert_eq!(s.remote_branch, "origin/main");
1634        assert_eq!(s.ahead, 3);
1635        assert_eq!(s.behind, 2);
1636        assert_eq!(s.stashes, 1);
1637        // staged: "M." + "MM" + "R." = 3; unstaged: ".M" + "MM" = 2
1638        assert_eq!(s.staged, 3);
1639        assert_eq!(s.unstaged, 2);
1640        assert_eq!(s.conflicted, 1);
1641        assert_eq!(s.untracked, 2);
1642    }
1643
1644    #[test]
1645    fn porcelain_detached_and_initial() {
1646        let out = "\
1647# branch.oid (initial)
1648# branch.head (detached)
1649";
1650        let mut s = GitStatus::default();
1651        parse_porcelain_v2(out, &mut s);
1652        assert_eq!(s.commit, "");
1653        assert_eq!(s.branch, "");
1654        assert_eq!(s.remote_branch, "");
1655        assert_eq!(s.ahead, 0);
1656        assert_eq!(s.behind, 0);
1657        assert_eq!(s.stashes, 0);
1658    }
1659
1660    #[test]
1661    fn porcelain_does_not_clobber_head_read() {
1662        // branch/commit already resolved from .git/HEAD win over headers.
1663        let out = "\
1664# branch.oid ffffffffffffffffffffffffffffffffffffffff
1665# branch.head porcelain-branch
1666";
1667        let mut s = GitStatus {
1668            branch: "head-branch".into(),
1669            commit: "1111111111111111111111111111111111111111".into(),
1670            ..GitStatus::default()
1671        };
1672        parse_porcelain_v2(out, &mut s);
1673        assert_eq!(s.branch, "head-branch");
1674        assert_eq!(s.commit, "1111111111111111111111111111111111111111");
1675    }
1676
1677    #[test]
1678    fn porcelain_no_upstream_no_stash_line() {
1679        let out = "\
1680# branch.oid aaaa
1681# branch.head feature/x
16821 A. N... 000000 100644 100644 0000 aaaa new_file.rs
1683";
1684        let mut s = GitStatus::default();
1685        parse_porcelain_v2(out, &mut s);
1686        assert_eq!(s.branch, "feature/x");
1687        assert_eq!(s.remote_branch, "");
1688        assert_eq!(s.ahead, 0);
1689        assert_eq!(s.behind, 0);
1690        assert_eq!(s.staged, 1);
1691        assert_eq!(s.unstaged, 0);
1692    }
1693
1694    #[test]
1695    fn porcelain_ignores_ignored_entries_and_short_lines() {
1696        let out = "! ignored.log\n1\n\n? u.rs\n";
1697        let mut s = GitStatus::default();
1698        parse_porcelain_v2(out, &mut s);
1699        assert_eq!(s.staged, 0);
1700        assert_eq!(s.unstaged, 0);
1701        assert_eq!(s.untracked, 1);
1702    }
1703
1704    // ---- native index parse ----
1705
1706    fn caps() -> RepoCaps {
1707        RepoCaps {
1708            trust_filemode: true,
1709            has_symlinks: true,
1710            hash_len: 20,
1711        }
1712    }
1713
1714    /// Build one v2 on-disk entry: 40-byte stat area + 20-byte oid + flags +
1715    /// NUL-padded path (entry size = (62 + len + 8) & !7).
1716    fn v2_entry(
1717        path: &[u8],
1718        mode: u32,
1719        ino: u32,
1720        size: u32,
1721        mt_s: u32,
1722        mt_ns: u32,
1723        stage: u16,
1724    ) -> Vec<u8> {
1725        let mut e = Vec::new();
1726        for v in [0u32, 0, mt_s, mt_ns, 0, ino, mode, 0, 0, size] {
1727            e.extend_from_slice(&v.to_be_bytes());
1728        }
1729        e.extend_from_slice(&[0u8; 20]); // oid (not read by the parser)
1730        let flags: u16 = ((stage & 0x3) << 12) | (path.len().min(0xFFF) as u16);
1731        e.extend_from_slice(&flags.to_be_bytes());
1732        e.extend_from_slice(path);
1733        let total = (62 + path.len() + 8) & !7;
1734        e.resize(total, 0);
1735        e
1736    }
1737
1738    fn v2_index(entries: &[Vec<u8>]) -> Vec<u8> {
1739        let mut d = b"DIRC".to_vec();
1740        d.extend_from_slice(&2u32.to_be_bytes());
1741        d.extend_from_slice(&(entries.len() as u32).to_be_bytes());
1742        for e in entries {
1743            d.extend_from_slice(e);
1744        }
1745        d.extend_from_slice(&[0u8; 20]); // checksum trailer (not verified)
1746        d
1747    }
1748
1749    fn temp_repo(index: &[u8]) -> (tempfile::TempDir, Repo) {
1750        let td = tempfile::tempdir().expect("tempdir");
1751        let git_dir = td.path().join(".git");
1752        fs::create_dir_all(&git_dir).expect("mkdir .git");
1753        fs::write(git_dir.join("index"), index).expect("write index");
1754        let repo = Repo {
1755            common_dir: git_dir.clone(),
1756            git_dir,
1757            work_dir: td.path().to_path_buf(),
1758        };
1759        (td, repo)
1760    }
1761
1762    #[test]
1763    fn index_header_rejects_bad_magic_and_version() {
1764        // Bad magic on a fixture byte literal.
1765        let mut bad = b"XDIR".to_vec();
1766        bad.extend_from_slice(&2u32.to_be_bytes());
1767        bad.extend_from_slice(&0u32.to_be_bytes());
1768        bad.extend_from_slice(&[0u8; 20]);
1769        let (_td, repo) = temp_repo(&bad);
1770        assert!(read_index_counts(&repo, &caps()).is_none());
1771
1772        // Unsupported version 5.
1773        let mut v5 = b"DIRC".to_vec();
1774        v5.extend_from_slice(&5u32.to_be_bytes());
1775        v5.extend_from_slice(&0u32.to_be_bytes());
1776        v5.extend_from_slice(&[0u8; 20]);
1777        let (_td, repo) = temp_repo(&v5);
1778        assert!(read_index_counts(&repo, &caps()).is_none());
1779    }
1780
1781    #[test]
1782    fn index_v2_empty_and_conflicts() {
1783        // Header-only v2 index (fixture byte literal): 0 entries, 0 counts.
1784        let (_td, repo) = temp_repo(&v2_index(&[]));
1785        let ic = read_index_counts(&repo, &caps()).expect("empty index parses");
1786        assert_eq!(ic.unstaged, 0);
1787        assert_eq!(ic.conflicted, 0);
1788
1789        // Stages 1+2 for one path = ONE conflicted path (matches one `u`
1790        // porcelain line), no lstat performed for unmerged entries.
1791        let e1 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 1);
1792        let e2 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 2);
1793        let (_td, repo) = temp_repo(&v2_index(&[e1, e2]));
1794        let ic = read_index_counts(&repo, &caps()).expect("parses");
1795        assert_eq!(ic.conflicted, 1);
1796        assert_eq!(ic.unstaged, 0);
1797    }
1798
1799    #[test]
1800    fn index_v2_lstat_clean_deleted_modified() {
1801        use std::os::unix::fs::MetadataExt;
1802        let td = tempfile::tempdir().expect("tempdir");
1803        let f = td.path().join("a.rs");
1804        fs::write(&f, b"x").expect("write file");
1805        let st = fs::symlink_metadata(&f).expect("stat");
1806        let mode = S_IFREG | if st.mode() & 0o100 != 0 { 0o755 } else { 0o644 };
1807
1808        // Entry stat-matching the real file → clean.
1809        let clean = v2_entry(
1810            b"a.rs",
1811            mode,
1812            st.ino() as u32,
1813            st.size() as u32,
1814            st.mtime() as u32,
1815            st.mtime_nsec() as u32,
1816            0,
1817        );
1818        // Same but wrong size → modified (index.cc:97).
1819        let dirty = v2_entry(
1820            b"b.rs",
1821            mode,
1822            0,
1823            st.size() as u32 + 7,
1824            st.mtime() as u32,
1825            0,
1826            0,
1827        );
1828        fs::write(td.path().join("b.rs"), b"x").expect("write b");
1829        // Entry whose worktree file does not exist → deleted (index.cc:195).
1830        let gone = v2_entry(b"gone.rs", mode, 0, 1, 1, 0, 0);
1831
1832        let git_dir = td.path().join(".git");
1833        fs::create_dir_all(&git_dir).expect("mkdir");
1834        fs::write(git_dir.join("index"), v2_index(&[clean, dirty, gone])).expect("write index");
1835        let repo = Repo {
1836            common_dir: git_dir.clone(),
1837            git_dir,
1838            work_dir: td.path().to_path_buf(),
1839        };
1840        let ic = read_index_counts(&repo, &caps()).expect("parses");
1841        assert_eq!(ic.unstaged, 2); // dirty + gone; clean not counted
1842        assert_eq!(ic.conflicted, 0);
1843    }
1844
1845    #[test]
1846    fn varint_offset_encoding() {
1847        // git varint.c: 0x7f → 127; 0x80 0x01 → ((0+1)<<7)|1 = 129.
1848        let mut p = 0;
1849        assert_eq!(decode_varint(&[0x7f], &mut p), Some(127));
1850        let mut p = 0;
1851        assert_eq!(decode_varint(&[0x80, 0x01], &mut p), Some(129));
1852        assert_eq!(p, 2);
1853        // Truncated continuation → None.
1854        let mut p = 0;
1855        assert_eq!(decode_varint(&[0x80], &mut p), None);
1856    }
1857
1858    // ---- packed-refs tags + stash log + config ----
1859
1860    const C: &str = "47a386b42798b5ed5d89bc617920d9152479013e";
1861
1862    #[test]
1863    fn packed_refs_tag_peel_and_max_name() {
1864        let td = tempfile::tempdir().expect("tempdir");
1865        // Annotated v1.0.0 peels to C; lightweight v0.9.0 points at C
1866        // directly; v0.5.0 points elsewhere.
1867        let packed = format!(
1868            "# pack-refs with: peeled fully-peeled sorted \n\
1869             aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.5.0\n\
1870             {C} refs/tags/v0.9.0\n\
1871             bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v1.0.0\n\
1872             ^{C}\n"
1873        );
1874        fs::write(td.path().join("packed-refs"), packed).expect("write");
1875        // Lexicographic max of the two matches (tag_db.cc:130,143).
1876        assert_eq!(tag_for_commit(td.path(), C), "v1.0.0");
1877
1878        // Loose lightweight tag beats packed by name order and shadows a
1879        // same-named packed entry.
1880        let tags = td.path().join("refs/tags");
1881        fs::create_dir_all(&tags).expect("mkdir");
1882        fs::write(tags.join("v2.0"), format!("{C}\n")).expect("write");
1883        assert_eq!(tag_for_commit(td.path(), C), "v2.0");
1884    }
1885
1886    #[test]
1887    fn packed_refs_without_header_drops_packed_tags() {
1888        // tag_db.cc:228-231 — no `#` header ⇒ packed tags unusable.
1889        let td = tempfile::tempdir().expect("tempdir");
1890        fs::write(
1891            td.path().join("packed-refs"),
1892            format!("{C} refs/tags/v9.9.9\n"),
1893        )
1894        .expect("write");
1895        assert_eq!(tag_for_commit(td.path(), C), "");
1896    }
1897
1898    #[test]
1899    fn stash_log_count() {
1900        let td = tempfile::tempdir().expect("tempdir");
1901        assert_eq!(stash_count(td.path()), Some(0)); // absent = 0, exact
1902        let logs = td.path().join("logs/refs");
1903        fs::create_dir_all(&logs).expect("mkdir");
1904        fs::write(
1905            logs.join("stash"),
1906            "0000 1111 user <u@example.com> 1 +0000\tWIP one\n\
1907             1111 2222 user <u@example.com> 2 +0000\tWIP two\n",
1908        )
1909        .expect("write");
1910        assert_eq!(stash_count(td.path()), Some(2));
1911    }
1912
1913    #[test]
1914    fn config_caps_and_tracking_through_fetch_refspecs() {
1915        let td = tempfile::tempdir().expect("tempdir");
1916        let common = td.path();
1917        let ref_file = |name: &str, oid: &str| {
1918            let p = common.join(name);
1919            fs::create_dir_all(p.parent().unwrap()).unwrap();
1920            fs::write(p, format!("{oid}\n")).unwrap();
1921        };
1922        ref_file("refs/remotes/origin/feature/x", &"a".repeat(40));
1923        ref_file("refs/remotes/mirror/feature/x", &"b".repeat(40));
1924        ref_file("refs/heads/main", &"c".repeat(40));
1925        let cfg = GitConfig::parse(
1926            "[core]\n\
1927             \tfilemode = false\n\
1928             \tsymlinks = true\n\
1929             [extensions]\n\
1930             \tobjectformat = sha256\n\
1931             [remote \"origin\"]\n\
1932             \turl = git@example.com:o.git\n\
1933             \tfetch = +refs/heads/*:refs/remotes/origin/*\n\
1934             [remote \"mirror\"]\n\
1935             \turl = git@example.com:m.git\n\
1936             \tfetch = +refs/heads/feature/*:refs/remotes/mirror/feature/*\n\
1937             [remote \"nofetch\"]\n\
1938             \turl = x\n\
1939             [remote]\n\
1940             \tpushDefault = mirror\n\
1941             [branch \"feature/x\"]\n\
1942             \tremote = origin\n\
1943             \tmerge = refs/heads/feature/x\n\
1944             [branch \"local\"]\n\
1945             \tremote = .\n\
1946             \tmerge = refs/heads/main\n\
1947             [branch \"gone\"]\n\
1948             \tremote = origin\n\
1949             \tmerge = refs/heads/gone\n\
1950             [branch \"nofetch\"]\n\
1951             \tremote = nofetch\n\
1952             \tmerge = refs/heads/nofetch\n\
1953             [branch \"pushed\"]\n\
1954             \tremote = origin\n\
1955             \tmerge = refs/heads/pushed\n\
1956             \tpushRemote = origin\n",
1957        );
1958        let t = tracking_of(&cfg, common, "feature/x").expect("tracking");
1959        assert_eq!((t.remote.as_str(), t.branch.as_str()), ("origin", "feature/x"));
1960        assert_eq!(t.tip, "a".repeat(40));
1961        let l = tracking_of(&cfg, common, "local").expect("local upstream");
1962        assert_eq!((l.remote.as_str(), l.branch.as_str()), (".", "main"));
1963        // upstream ref missing / remote without a fetch refspec / no config
1964        assert!(tracking_of(&cfg, common, "gone").is_none());
1965        assert!(tracking_of(&cfg, common, "nofetch").is_none());
1966        assert!(tracking_of(&cfg, common, "nope").is_none());
1967
1968        // remote.pushDefault applies when the branch has no pushRemote, and
1969        // the branch maps through THAT remote's refspec
1970        let p = push_target_of(&cfg, common, "feature/x").expect("push target");
1971        assert_eq!(p.remote, "mirror");
1972        assert_eq!(p.url, "git@example.com:m.git");
1973        assert_eq!(p.refname, "refs/remotes/mirror/feature/x");
1974        assert_eq!(p.tip, "b".repeat(40));
1975        // the mirror refspec only covers feature/*
1976        assert!(push_target_of(&cfg, common, "other").is_none());
1977        // its tracking ref is absent
1978        assert!(push_target_of(&cfg, common, "pushed").is_none());
1979
1980        let caps = RepoCaps::from_config(&cfg);
1981        assert!(!caps.trust_filemode);
1982        assert!(caps.has_symlinks);
1983        assert_eq!(caps.hash_len, 32);
1984    }
1985
1986    #[test]
1987    fn refspec_transform_handles_globs_and_exact_names() {
1988        assert_eq!(
1989            refspec_transform("+refs/heads/*:refs/remotes/o/*", "refs/heads/a/b").as_deref(),
1990            Some("refs/remotes/o/a/b")
1991        );
1992        assert_eq!(
1993            refspec_transform("refs/heads/main:refs/remotes/o/trunk", "refs/heads/main").as_deref(),
1994            Some("refs/remotes/o/trunk")
1995        );
1996        assert_eq!(refspec_transform("refs/heads/main:x", "refs/heads/other"), None);
1997        assert_eq!(refspec_transform("refs/tags/*:refs/tags/*", "refs/heads/a"), None);
1998    }
1999
2000    #[test]
2001    fn limits_zero_oversized_index_and_clamp_counts() {
2002        let mut s = GitStatus {
2003            staged: 5,
2004            unstaged: 4,
2005            untracked: 9,
2006            conflicted: 3,
2007            staged_new: 4,
2008            staged_deleted: 2,
2009            unstaged_deleted: 4,
2010            ..Default::default()
2011        };
2012        // the defaults report at most one of each
2013        GitLimits::default().apply(&mut s, 100);
2014        assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (1, 1, 1, 1));
2015        assert_eq!((s.staged_new, s.staged_deleted, s.unstaged_deleted), (1, 1, 1));
2016        assert!(!s.dirty_unknown);
2017
2018        let mut s = GitStatus { staged: 5, unstaged: 4, untracked: 9, conflicted: 3, ..Default::default() };
2019        let lim = GitLimits {
2020            max_staged: -1,
2021            max_unstaged: 3,
2022            max_untracked: -1,
2023            max_conflicted: 0,
2024            dirty_max_index_size: 50,
2025            recurse_untracked_dirs: false,
2026        };
2027        lim.apply(&mut s, 49);
2028        assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (5, 3, 9, 0));
2029        // over the dirty index size: unstaged and untracked are zeroed, staged stays
2030        let mut s = GitStatus { staged: 5, unstaged: 4, untracked: 9, conflicted: 0, ..Default::default() };
2031        lim.apply(&mut s, 51);
2032        assert_eq!((s.staged, s.unstaged, s.untracked), (5, 0, 0));
2033        assert!(s.dirty_unknown);
2034    }
2035
2036    #[test]
2037    fn porcelain_classifies_new_and_deleted() {
2038        let mut s = GitStatus::default();
2039        parse_porcelain_v2(
2040            "1 A. N... 000000 100644 100644 a b new.rs\n\
2041             1 D. N... 100644 000000 000000 a b gone.rs\n\
2042             1 .D N... 100644 100644 000000 a b removed.rs\n\
2043             1 MM N... 100644 100644 100644 a b both.rs\n",
2044            &mut s,
2045        );
2046        assert_eq!((s.staged, s.staged_new, s.staged_deleted), (3, 1, 1));
2047        assert_eq!((s.unstaged, s.unstaged_deleted), (2, 1));
2048    }
2049    #[test]
2050    fn commit_summary_is_the_collapsed_first_paragraph() {
2051        assert_eq!(commit_summary_of("fix the thing\n\nlong body\n"), "fix the thing");
2052        assert_eq!(commit_summary_of("\n\nleading blanks\n"), "leading blanks");
2053        assert_eq!(
2054            commit_summary_of("wrapped\nsubject  line\nthird\n\nbody"),
2055            "wrapped subject  line third"
2056        );
2057        // a whitespace-only line ends the paragraph; trailing space is dropped
2058        assert_eq!(commit_summary_of("a b  \n   \nbody"), "a b");
2059        assert_eq!(commit_summary_of(&"x".repeat(400)).len(), MAX_COMMIT_SUMMARY_LEN);
2060        assert_eq!(commit_summary_of(""), "");
2061    }
2062}