Skip to main content

zsh/extensions/p10k/
git.rs

1//! p10k git status backend — native replacement for gitstatusd.
2//!
3//! Phase-2 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). Not faked natively.
28//!   * untracked — requires a faithful .gitignore engine (nested .gitignore,
29//!     info/exclude, core.excludesFile, negations, dir-only patterns, `**`),
30//!     which is out of scope for this file. Not faked natively.
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 gap: loose ANNOTATED tags (a `refs/tags/x` file holding a tag
37//!   object id, not the commit id) cannot be peeled without the object store
38//!   and are skipped natively; packed-refs `^<oid>` peel lines cover annotated
39//!   tags in the normal (packed) case. gitstatus peels via libgit2
40//!   (tag_db.cc:295-330 TagHasTarget); this is a documented divergence, not
41//!   an approximation.
42//! - Cache: per-gitdir entry with ~2s TTL, additionally invalidated when
43//!   `.git/index` or `.git/HEAD` mtime changes. The prompt is never blocked
44//!   more than ~45ms: the subprocess output is read on a helper thread and
45//!   awaited via `mpsc::recv_timeout`; on timeout the child is killed and the
46//!   fresh NATIVE fields are returned with the stale cached subprocess-only
47//!   fields merged in (or None when there is no cache yet).
48//! - Perf: native path is one ~400KB index read + one lstat per index entry
49//!   (3,884 entries on this repo) + a few small ref/config/log reads.
50//!   Target < 5ms warm on this repo (cache hit is a map lookup; cache miss is
51//!   dominated by the lstat scan, low single-digit ms warm). Indexes over
52//!   NATIVE_SCAN_MAX entries skip the native scan to protect the budget.
53//! - No new crate dependencies; std only.
54//!
55//! p10k consumes these fields as the `VCS_STATUS_*` parameters that gitstatusd
56//! used to publish (p10k:3775-3783 — `$VCS_STATUS_COMMIT`,
57//! `$VCS_STATUS_LOCAL_BRANCH`, `$VCS_STATUS_REMOTE_BRANCH`,
58//! `$VCS_STATUS_ACTION`, `$VCS_STATUS_NUM_STAGED`, `$VCS_STATUS_NUM_UNSTAGED`,
59//! `$VCS_STATUS_NUM_CONFLICTED`, `$VCS_STATUS_NUM_UNTRACKED`,
60//! `$VCS_STATUS_COMMITS_AHEAD`, `$VCS_STATUS_COMMITS_BEHIND`,
61//! `$VCS_STATUS_STASHES`, `$VCS_STATUS_TAG`). The tag p10k renders
62//! (p10k:3959-3961) is gitstatus's EXACT match of a tag to the HEAD commit
63//! (tag_db.cc:119-148 TagForCommit), not a `git describe` nearest-tag.
64
65use std::collections::HashMap;
66use std::ffi::OsStr;
67use std::fs;
68use std::io::Read;
69use std::os::unix::ffi::OsStrExt;
70use std::path::{Path, PathBuf};
71use std::process::{Command, Stdio};
72use std::sync::mpsc;
73use std::sync::{Mutex, OnceLock};
74use std::time::{Duration, Instant, SystemTime};
75
76/// One snapshot of a repo's state, shaped after gitstatusd's response fields
77/// (gitstatus/src/response.cc) as p10k reads them (p10k:3775-3783).
78#[derive(Debug, Clone, Default)]
79pub struct GitStatus {
80    pub branch: String,
81    pub commit: String,
82    pub action: String, // rebase/merge/etc, "" if none
83    pub ahead: i64,
84    pub behind: i64,
85    pub staged: i64,
86    pub unstaged: i64,
87    pub untracked: i64,
88    pub conflicted: i64,
89    pub stashes: i64,
90    pub tag: String,
91    pub remote_branch: String,
92}
93
94/// Cache TTL. gitstatusd recomputed on every prompt but kept the repo open;
95/// we amortize the index scan + subprocess instead.
96const CACHE_TTL: Duration = Duration::from_secs(2);
97/// Prompt latency budget for the `git status` subprocess.
98const SUBPROCESS_BUDGET: Duration = Duration::from_millis(45);
99/// Native index scan cap: one lstat per entry; 20k entries ≈ ~10-20ms warm,
100/// which is the most the prompt path should ever spend. Bigger indexes fall
101/// back to the subprocess for unstaged/conflicted (gitstatus has the same
102/// escape hatch via its dirty_max_index_size option).
103const NATIVE_SCAN_MAX: usize = 20_000;
104
105struct CacheEntry {
106    at: Instant,
107    head_mtime: Option<SystemTime>,
108    index_mtime: Option<SystemTime>,
109    status: GitStatus,
110}
111
112fn cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
113    static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
114    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
115}
116
117/// Public entry point per the p10k module contract. `dir` is any directory;
118/// the repo is discovered by walking up. None when not inside a git repo, or
119/// when the first-ever status of a repo exceeds the latency budget.
120pub fn git_status_for(dir: &Path) -> Option<GitStatus> {
121    let repo = discover_repo(dir)?;
122    let head_mtime = mtime_of(&repo.git_dir.join("HEAD"));
123    let index_mtime = mtime_of(&repo.git_dir.join("index"));
124
125    // Fresh-enough cache hit: TTL not expired AND HEAD/index untouched.
126    if let Ok(map) = cache().lock() {
127        if let Some(e) = map.get(&repo.git_dir) {
128            if e.at.elapsed() < CACHE_TTL
129                && e.head_mtime == head_mtime
130                && e.index_mtime == index_mtime
131            {
132                return Some(e.status.clone());
133            }
134        }
135    }
136
137    // -------- native layer (no subprocess) --------
138    let mut status = GitStatus::default();
139    read_head(&repo, &mut status);
140    status.action = repo_action(&repo.git_dir); // gitstatus git.cc:43-110 RepoState
141
142    let cfg = GitConfig::load(&repo.common_dir.join("config"));
143    let caps = RepoCaps::from_config(&cfg);
144
145    // Upstream from branch.<name>.remote / branch.<name>.merge. REMOTE_BRANCH
146    // is the branch-only name (gitstatus.plugin.zsh:39 `VCS_STATUS_REMOTE_BRANCH=master`),
147    // which is what p10k compares against the local branch (p10k:3967-3968).
148    let mut ab_native = false;
149    if !status.branch.is_empty() {
150        match upstream_of(&cfg, &status.branch) {
151            Some((remote, up_branch)) => {
152                status.remote_branch = up_branch.clone();
153                // `remote = .` means the upstream is a local branch.
154                let up_ref = if remote == "." {
155                    format!("refs/heads/{up_branch}")
156                } else {
157                    format!("refs/remotes/{remote}/{up_branch}")
158                };
159                match resolve_ref(&repo.common_dir, &up_ref) {
160                    Some(tip) if tip == status.commit => {
161                        // Tips equal ⇒ ahead=behind=0 without any object walk.
162                        ab_native = true;
163                    }
164                    Some(_) => {
165                        // Diverged: counting commits needs an object-store
166                        // walk (zlib) — keep `# branch.ab` from the porcelain
167                        // fallback for this case only (see module doc).
168                    }
169                    None => {
170                        // Upstream configured but its ref is gone (deleted
171                        // remote branch). gitstatusd reports 0/0 here.
172                        ab_native = true;
173                    }
174                }
175            }
176            None => ab_native = true, // no upstream ⇒ 0/0, matches gitstatusd
177        }
178    } else {
179        ab_native = true; // detached HEAD has no upstream
180    }
181
182    // Stashes: reflog line count — `git stash list` is exactly this reflog.
183    let native_stash = stash_count(&repo.common_dir);
184    if let Some(n) = native_stash {
185        status.stashes = n;
186    }
187
188    // Tag: exact match of refs/tags/* against the HEAD commit
189    // (tag_db.cc:119-148 TagForCommit).
190    if !status.commit.is_empty() {
191        status.tag = tag_for_commit(&repo.common_dir, &status.commit);
192    }
193
194    // Index scan: unstaged + conflicted natively.
195    let native_counts = read_index_counts(&repo, &caps);
196    if let Some(ic) = &native_counts {
197        status.unstaged = ic.unstaged;
198        status.conflicted = ic.conflicted;
199    }
200
201    // -------- gated subprocess (staged/untracked always; the rest only when
202    // the native layer could not produce them — see module doc) --------
203    match run_porcelain(&repo.work_dir) {
204        Some(out) => {
205            let mut sub = GitStatus::default();
206            parse_porcelain_v2(&out, &mut sub);
207            status.staged = sub.staged;
208            status.untracked = sub.untracked;
209            if native_counts.is_none() {
210                status.unstaged = sub.unstaged;
211                status.conflicted = sub.conflicted;
212            }
213            if !ab_native {
214                status.ahead = sub.ahead;
215                status.behind = sub.behind;
216            }
217            if native_stash.is_none() {
218                status.stashes = sub.stashes; // --show-stash fallback
219            }
220            if status.commit.is_empty() {
221                status.commit = sub.commit;
222            }
223            if status.branch.is_empty() {
224                status.branch = sub.branch;
225            }
226            if status.remote_branch.is_empty() && !sub.remote_branch.is_empty() {
227                // `# branch.upstream` is `<remote>/<branch>`; strip the remote
228                // (remote names cannot contain '/') to the branch-only shape.
229                status.remote_branch = match sub.remote_branch.split_once('/') {
230                    Some((_, b)) => b.to_string(),
231                    None => sub.remote_branch,
232                };
233            }
234            if let Ok(mut map) = cache().lock() {
235                // Bound the cache: one entry per repo visited; a
236                // long-lived shell hopping across many repos must not
237                // grow this without limit. Evict the stalest entry
238                // once over the cap (cap is generous — the map is
239                // per-repo, not per-directory).
240                const CACHE_CAP: usize = 64;
241                if map.len() >= CACHE_CAP && !map.contains_key(&repo.git_dir) {
242                    if let Some(oldest) = map
243                        .iter()
244                        .max_by_key(|(_, e)| e.at.elapsed())
245                        .map(|(k, _)| k.clone())
246                    {
247                        map.remove(&oldest);
248                    }
249                }
250                map.insert(
251                    repo.git_dir.clone(),
252                    CacheEntry {
253                        at: Instant::now(),
254                        head_mtime,
255                        index_mtime,
256                        status: status.clone(),
257                    },
258                );
259            }
260            Some(status)
261        }
262        // Subprocess overran the budget: return the FRESH native fields with
263        // the stale cached subprocess-only fields merged in (slightly-old
264        // staged/untracked beat a blocked prompt); None when no cache yet.
265        None => {
266            if let Ok(map) = cache().lock() {
267                if let Some(e) = map.get(&repo.git_dir) {
268                    status.staged = e.status.staged;
269                    status.untracked = e.status.untracked;
270                    if native_counts.is_none() {
271                        status.unstaged = e.status.unstaged;
272                        status.conflicted = e.status.conflicted;
273                    }
274                    if !ab_native {
275                        status.ahead = e.status.ahead;
276                        status.behind = e.status.behind;
277                    }
278                    if native_stash.is_none() {
279                        status.stashes = e.status.stashes;
280                    }
281                    return Some(status);
282                }
283            }
284            None
285        }
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Repo discovery
291// ---------------------------------------------------------------------------
292
293struct Repo {
294    /// Directory containing HEAD, index, rebase state, ... (per-worktree).
295    git_dir: PathBuf,
296    /// Directory containing refs/, packed-refs, config, logs/refs/stash
297    /// (shared across worktrees).
298    common_dir: PathBuf,
299    /// Top-level working directory (where `git status` runs).
300    work_dir: PathBuf,
301}
302
303/// Walk up from `dir` until a `.git` entry is found. A `.git` directory is
304/// the gitdir itself; a `.git` file holds `gitdir: <path>` (worktree or
305/// submodule checkout). The common dir is the gitdir unless a `commondir`
306/// file redirects it (linked worktrees).
307fn discover_repo(dir: &Path) -> Option<Repo> {
308    let mut cur = if dir.is_absolute() {
309        dir.to_path_buf()
310    } else {
311        std::env::current_dir().ok()?.join(dir)
312    };
313    loop {
314        let dotgit = cur.join(".git");
315        if let Ok(meta) = fs::symlink_metadata(&dotgit) {
316            let git_dir = if meta.is_dir() {
317                dotgit
318            } else {
319                // `.git` file: `gitdir: /abs/path` or `gitdir: ../rel/path`
320                let text = fs::read_to_string(&dotgit).ok()?;
321                let target = text.strip_prefix("gitdir:")?.trim();
322                let p = Path::new(target);
323                if p.is_absolute() {
324                    p.to_path_buf()
325                } else {
326                    cur.join(p)
327                }
328            };
329            let common_dir = match fs::read_to_string(git_dir.join("commondir")) {
330                Ok(text) => {
331                    let t = text.trim();
332                    let p = Path::new(t);
333                    if p.is_absolute() {
334                        p.to_path_buf()
335                    } else {
336                        git_dir.join(p)
337                    }
338                }
339                Err(_) => git_dir.clone(),
340            };
341            return Some(Repo {
342                git_dir,
343                common_dir,
344                work_dir: cur,
345            });
346        }
347        if !cur.pop() {
348            return None;
349        }
350    }
351}
352
353fn mtime_of(p: &Path) -> Option<SystemTime> {
354    fs::metadata(p).ok().and_then(|m| m.modified().ok())
355}
356
357// ---------------------------------------------------------------------------
358// HEAD / refs
359// ---------------------------------------------------------------------------
360
361/// Fill `branch` and `commit` from HEAD. Symbolic HEAD names the branch and
362/// the tip commit resolves through loose refs then packed-refs; detached
363/// HEAD is a bare hash with no branch (gitstatusd reports LOCAL_BRANCH=""
364/// for detached HEAD; p10k then falls back to the commit, p10k:3775).
365fn read_head(repo: &Repo, status: &mut GitStatus) {
366    let head = match fs::read_to_string(repo.git_dir.join("HEAD")) {
367        Ok(s) => s,
368        Err(e) => {
369            tracing::warn!("p10k git: unreadable HEAD in {:?}: {e}", repo.git_dir);
370            return;
371        }
372    };
373    let head = head.trim();
374    if let Some(refname) = head.strip_prefix("ref: ") {
375        let refname = refname.trim();
376        status.branch = refname
377            .strip_prefix("refs/heads/")
378            .unwrap_or(refname)
379            .to_string();
380        if let Some(oid) = resolve_ref(&repo.common_dir, refname) {
381            status.commit = oid;
382        }
383    } else if head.len() >= 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
384        status.commit = head.to_string(); // detached HEAD
385    }
386}
387
388/// Resolve a fully-qualified ref to its commit hash: loose ref file first,
389/// then a linear scan of packed-refs (`<oid> <refname>` lines; `^<oid>`
390/// peel lines and `#` comments skipped).
391fn resolve_ref(common_dir: &Path, refname: &str) -> Option<String> {
392    if let Ok(s) = fs::read_to_string(common_dir.join(refname)) {
393        let s = s.trim();
394        if !s.is_empty() {
395            return Some(s.to_string());
396        }
397    }
398    let packed = fs::read_to_string(common_dir.join("packed-refs")).ok()?;
399    for line in packed.lines() {
400        if line.starts_with('#') || line.starts_with('^') {
401            continue;
402        }
403        if let Some((oid, name)) = line.split_once(' ') {
404            if name == refname {
405                return Some(oid.to_string());
406            }
407        }
408    }
409    None
410}
411
412// ---------------------------------------------------------------------------
413// In-progress action
414// ---------------------------------------------------------------------------
415
416/// Port of gitstatus RepoState (gitstatus/src/git.cc:43-110), which maps
417/// libgit2 `git_repository_state` to names that "mostly match gitaction in
418/// vcs_info" (git.cc:46-47). Detection order and marker files follow libgit2
419/// repository.c: rebase-merge (interactive?) > rebase-apply
420/// (rebasing/applying?) > MERGE_HEAD > REVERT_HEAD > CHERRY_PICK_HEAD >
421/// BISECT_LOG. A rebase/am in flight appends " <next>/<last>" progress
422/// (git.cc:96-108).
423fn repo_action(git_dir: &Path) -> String {
424    let exists = |name: &str| git_dir.join(name).exists();
425    let read1 = |name: &str| -> String {
426        // git.cc:87-92 ReadFile — first whitespace-delimited token.
427        fs::read_to_string(git_dir.join(name))
428            .ok()
429            .and_then(|s| s.split_whitespace().next().map(str::to_string))
430            .unwrap_or_default()
431    };
432
433    let state: &str = if exists("rebase-merge") {
434        if exists("rebase-merge/interactive") {
435            "rebase-i" // git.cc:68 GIT_REPOSITORY_STATE_REBASE_INTERACTIVE
436        } else {
437            "rebase-m" // git.cc:70 GIT_REPOSITORY_STATE_REBASE_MERGE
438        }
439    } else if exists("rebase-apply") {
440        if exists("rebase-apply/rebasing") {
441            "rebase" // git.cc:66 GIT_REPOSITORY_STATE_REBASE
442        } else if exists("rebase-apply/applying") {
443            "am" // git.cc:72 GIT_REPOSITORY_STATE_APPLY_MAILBOX
444        } else {
445            "am/rebase" // git.cc:74 GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE
446        }
447    } else if exists("MERGE_HEAD") {
448        "merge" // git.cc:54 GIT_REPOSITORY_STATE_MERGE
449    } else if exists("REVERT_HEAD") {
450        if exists("sequencer/todo") {
451            "revert-seq" // git.cc:58 GIT_REPOSITORY_STATE_REVERT_SEQUENCE
452        } else {
453            "revert" // git.cc:56 GIT_REPOSITORY_STATE_REVERT
454        }
455    } else if exists("CHERRY_PICK_HEAD") {
456        if exists("sequencer/todo") {
457            "cherry-seq" // git.cc:62 GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE
458        } else {
459            "cherry" // git.cc:60 GIT_REPOSITORY_STATE_CHERRYPICK
460        }
461    } else if exists("BISECT_LOG") {
462        "bisect" // git.cc:64 GIT_REPOSITORY_STATE_BISECT
463    } else {
464        "" // git.cc:52 GIT_REPOSITORY_STATE_NONE
465    };
466
467    // git.cc:96-102 — rebase-merge carries msgnum/end, rebase-apply next/last.
468    let (next, last) = if exists("rebase-merge") {
469        (read1("rebase-merge/msgnum"), read1("rebase-merge/end"))
470    } else if exists("rebase-apply") {
471        (read1("rebase-apply/next"), read1("rebase-apply/last"))
472    } else {
473        (String::new(), String::new())
474    };
475
476    // git.cc:104-107 — append " next/last" only when both are present.
477    if !next.is_empty() && !last.is_empty() {
478        format!("{state} {next}/{last}")
479    } else {
480        state.to_string()
481    }
482}
483
484// ---------------------------------------------------------------------------
485// .git/config (minimal parser: sections, subsections, key=value)
486// ---------------------------------------------------------------------------
487
488/// Minimal `.git/config` reader for `branch.<name>.remote/merge`,
489/// `core.filemode/symlinks`, and `extensions.objectformat`. Sections and key
490/// names are case-insensitive, subsections case-sensitive (git-config(1)).
491/// Not supported (all rare for these keys; missing keys degrade to defaults
492/// or to the porcelain fallback): `include.path`/`includeIf`, multi-line
493/// quoted values, `\n`-escapes inside values.
494struct GitConfig {
495    /// key = "<section-lower>\0<subsection-verbatim>\0<key-lower>"
496    map: HashMap<String, String>,
497}
498
499impl GitConfig {
500    fn load(path: &Path) -> GitConfig {
501        let text = fs::read_to_string(path).unwrap_or_default();
502        GitConfig::parse(&text)
503    }
504
505    fn parse(text: &str) -> GitConfig {
506        let mut map = HashMap::new();
507        let mut sect = String::new();
508        let mut sub = String::new();
509        for raw in text.lines() {
510            let line = raw.trim();
511            if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
512                continue;
513            }
514            if let Some(body) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
515                // `[section]` or `[section "sub\"section"]`
516                match body.split_once(char::is_whitespace) {
517                    Some((s, rest)) => {
518                        sect = s.to_ascii_lowercase();
519                        let rest = rest.trim();
520                        sub = rest
521                            .strip_prefix('"')
522                            .and_then(|r| r.strip_suffix('"'))
523                            .map(|r| r.replace("\\\\", "\\").replace("\\\"", "\""))
524                            .unwrap_or_else(|| rest.to_string());
525                    }
526                    None => {
527                        sect = body.to_ascii_lowercase();
528                        sub = String::new();
529                    }
530                }
531                continue;
532            }
533            // `key = value`, or bare `key` (implicit boolean true).
534            let (key, mut val) = match line.split_once('=') {
535                Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
536                None => (line.to_ascii_lowercase(), "true".to_string()),
537            };
538            // Strip an unquoted trailing comment, then surrounding quotes.
539            if !val.starts_with('"') {
540                if let Some(i) = val.find(['#', ';']) {
541                    val.truncate(i);
542                    val = val.trim_end().to_string();
543                }
544            } else if let Some(stripped) = val
545                .strip_prefix('"')
546                .and_then(|v| v.strip_suffix('"'))
547            {
548                val = stripped.replace("\\\\", "\\").replace("\\\"", "\"");
549            }
550            map.insert(format!("{sect}\0{sub}\0{key}"), val);
551        }
552        GitConfig { map }
553    }
554
555    fn get(&self, section: &str, subsection: &str, key: &str) -> Option<&str> {
556        self.map
557            .get(&format!("{section}\0{subsection}\0{key}"))
558            .map(String::as_str)
559    }
560
561    fn get_bool(&self, section: &str, key: &str, default: bool) -> bool {
562        match self.get(section, "", key) {
563            Some(v) => matches!(
564                v.to_ascii_lowercase().as_str(),
565                "true" | "yes" | "on" | "1"
566            ),
567            None => default,
568        }
569    }
570}
571
572/// `branch.<name>.remote` + `branch.<name>.merge` → (remote, upstream branch
573/// name). The merge value is a full ref (`refs/heads/main`); the returned
574/// name is branch-only, matching gitstatusd's VCS_STATUS_REMOTE_BRANCH shape
575/// (gitstatus.plugin.zsh:39,97).
576fn upstream_of(cfg: &GitConfig, branch: &str) -> Option<(String, String)> {
577    let remote = cfg.get("branch", branch, "remote")?;
578    let merge = cfg.get("branch", branch, "merge")?;
579    let name = merge.strip_prefix("refs/heads/").unwrap_or(merge);
580    if remote.is_empty() || name.is_empty() {
581        return None;
582    }
583    Some((remote.to_string(), name.to_string()))
584}
585
586/// Port of gitstatus RepoCaps (index.cc:308-318): repository capabilities
587/// that steer the lstat comparison. libgit2 defaults both to true on unix;
588/// `git clone`/`git init` write them explicitly.
589struct RepoCaps {
590    trust_filemode: bool, // core.filemode  (index.cc:309 is_filemode_trustworthy)
591    has_symlinks: bool,   // core.symlinks  (index.cc:310 index_supports_symlinks)
592    hash_len: usize,      // 20 (sha1) / 32 (extensions.objectformat = sha256)
593}
594
595impl RepoCaps {
596    fn from_config(cfg: &GitConfig) -> RepoCaps {
597        RepoCaps {
598            trust_filemode: cfg.get_bool("core", "filemode", true),
599            has_symlinks: cfg.get_bool("core", "symlinks", true),
600            hash_len: match cfg.get("extensions", "", "objectformat") {
601                Some("sha256") => 32,
602                _ => 20,
603            },
604        }
605    }
606}
607
608// ---------------------------------------------------------------------------
609// .git/index (v2/v3/v4) → unstaged + conflicted
610// ---------------------------------------------------------------------------
611
612struct IndexCounts {
613    unstaged: i64,
614    conflicted: i64,
615}
616
617fn be32(data: &[u8], pos: usize) -> Option<u32> {
618    data.get(pos..pos + 4)
619        .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
620}
621
622fn be16(data: &[u8], pos: usize) -> Option<u16> {
623    data.get(pos..pos + 2)
624        .map(|b| u16::from_be_bytes([b[0], b[1]]))
625}
626
627/// git varint.c decode_varint — "offset encoding": 7 bits per byte, MSB is
628/// the continuation bit, and each continuation adds 1 before shifting so
629/// that multi-byte encodings have no redundant forms.
630fn decode_varint(data: &[u8], pos: &mut usize) -> Option<u64> {
631    let mut c = *data.get(*pos)?;
632    *pos += 1;
633    let mut val = u64::from(c & 0x7f);
634    let mut rounds = 0;
635    while c & 0x80 != 0 {
636        rounds += 1;
637        if rounds > 9 {
638            return None; // > 63 significant bits ⇒ corrupt
639        }
640        c = *data.get(*pos)?;
641        *pos += 1;
642        val = val.checked_add(1)?.checked_shl(7)? | u64::from(c & 0x7f);
643    }
644    Some(val)
645}
646
647/// The per-entry stat fields the modified check needs (gitformat-index.txt
648/// entry layout; offsets relative to the entry start):
649///   0 ctime-sec  4 ctime-nsec  8 mtime-sec  12 mtime-nsec  16 dev  20 ino
650///   24 mode  28 uid  32 gid  36 file-size  (all u32 big-endian, truncated)
651///   40 .. 40+H  object id (H = 20 sha1 / 32 sha256)
652///   40+H  flags u16be: bit15 assume-valid, bit14 extended (v3+),
653///         bits13-12 stage, bits11-0 name length (0xFFF when longer)
654///   [+2 extended flags u16be when the extended bit is set (v3+):
655///         bit14 skip-worktree, bit13 intent-to-add]
656///   then the path: v2/v3 NUL-terminated + NUL padding to a multiple of 8
657///   bytes from the entry start (1-8 NULs); v4 prefix-compressed — varint N
658///   (bytes to strip from the END of the previous path) + NUL-terminated
659///   suffix, no padding.
660struct EntryStat {
661    mode: u32,
662    ino: u32,
663    size: u32,
664    mt_s: u32,
665    mt_ns: u32,
666}
667
668const S_IFMT: u32 = 0o170000;
669const S_IFREG: u32 = 0o100000;
670const S_IFLNK: u32 = 0o120000;
671const S_IFDIR: u32 = 0o040000;
672
673/// Port of gitstatus IsModified (index.cc:71-104) — decides whether a
674/// stage-0 index entry differs from its worktree file. gitstatus compares
675/// exactly ino / stage / fsize / mtime / mode (index.cc:93-99); dev, uid,
676/// gid and ctime are NOT compared.
677fn is_modified(e: &EntryStat, st: &fs::Metadata, caps: &RepoCaps) -> bool {
678    use std::os::unix::fs::MetadataExt;
679    // index.cc:71-83 — normalize the worktree mode before comparing.
680    let st_mode = st.mode();
681    let mode = if st_mode & S_IFMT == S_IFREG {
682        if !caps.has_symlinks && e.mode & S_IFMT == S_IFLNK {
683            e.mode
684        } else if !caps.trust_filemode {
685            e.mode
686        } else {
687            S_IFREG | if st_mode & 0o100 != 0 { 0o755 } else { 0o644 }
688        }
689    } else {
690        st_mode & S_IFMT
691    };
692    // index.cc:93 — ino: 0 in the index matches anything; else compare the
693    // u32-truncated st_ino exactly as C does.
694    if e.ino != 0 && e.ino != st.ino() as u32 {
695        return true;
696    }
697    // index.cc:97 — fsize. NOTE racy-git: git smudges racily-clean entries
698    // to file_size 0 on index write, forcing a content check; gitstatus (and
699    // git) would then hash the file. We instead COUNT the entry as unstaged
700    // per the racy-git convention — a just-touched-but-identical file may
701    // show unstaged for one prompt until the next `git status`/`git add`
702    // rewrites the index. Never wrong about truly-dirty files.
703    if u64::from(e.size) != st.size() {
704        return true;
705    }
706    // index.cc:61-69 MTimeEq — seconds must match; nanoseconds match, or the
707    // index stores 0 nsec (git built without USE_NSEC — the GITSTATUS_ZERO_NSEC
708    // build flag; applied unconditionally here since a 0-nsec index entry vs
709    // a real-nsec filesystem is the common macOS/Linux packaged-git case).
710    if e.mt_s as i64 != st.mtime() || !(e.mt_ns as i64 == st.mtime_nsec() || e.mt_ns == 0) {
711        return true;
712    }
713    // index.cc:99 — mode.
714    if e.mode != mode {
715        return true;
716    }
717    false
718}
719
720/// Parse `.git/index` and lstat each stage-0 entry: unstaged = entries whose
721/// worktree file is missing/unreadable/modified (index.cc:191-199 StatFiles
722/// + IsModified), conflicted = distinct paths with stage > 0. Returns None
723/// when the native parse cannot proceed (unsupported version, sparse-index
724/// tree entries, oversized index, corrupt data) — the caller then takes both
725/// counts from the porcelain subprocess. The trailing checksum is NOT
726/// verified (read-only prompt path; no SHA-1 impl in-tree — worst case a
727/// torn index yields wrong counts for one 2s cache window).
728fn read_index_counts(repo: &Repo, caps: &RepoCaps) -> Option<IndexCounts> {
729    let data = fs::read(repo.git_dir.join("index")).ok()?;
730    // Header (12 bytes): "DIRC", version u32be, entry count u32be; the file
731    // ends with a hash-len checksum trailer.
732    if data.len() < 12 + caps.hash_len || &data[0..4] != b"DIRC" {
733        tracing::debug!("p10k git: index missing/short/bad magic in {:?}", repo.git_dir);
734        return None;
735    }
736    let version = be32(&data, 4)?;
737    if !(2..=4).contains(&version) {
738        tracing::debug!("p10k git: unsupported index version {version}");
739        return None;
740    }
741    let nentries = be32(&data, 8)? as usize;
742    if nentries > NATIVE_SCAN_MAX {
743        tracing::debug!("p10k git: index has {nentries} entries > {NATIVE_SCAN_MAX}, deferring to subprocess");
744        return None;
745    }
746
747    let mut pos = 12usize;
748    let mut prev_path: Vec<u8> = Vec::new(); // v4 prefix compression state
749    let mut last_conflict: Vec<u8> = Vec::new();
750    let mut unstaged = 0i64;
751    let mut conflicted = 0i64;
752
753    for _ in 0..nentries {
754        let start = pos;
755        // Fixed stat area (40) + oid + flags.
756        if pos + 40 + caps.hash_len + 2 > data.len() {
757            return None;
758        }
759        let e = EntryStat {
760            mt_s: be32(&data, pos + 8)?,
761            mt_ns: be32(&data, pos + 12)?,
762            ino: be32(&data, pos + 20)?,
763            mode: be32(&data, pos + 24)?,
764            size: be32(&data, pos + 36)?,
765        };
766        let flags = be16(&data, pos + 40 + caps.hash_len)?;
767        pos += 40 + caps.hash_len + 2;
768
769        let assume_valid = flags & 0x8000 != 0;
770        let extended = flags & 0x4000 != 0;
771        let stage = (flags >> 12) & 0x3;
772        let name_len = (flags & 0xFFF) as usize;
773
774        let mut skip_worktree = false;
775        let mut intent_to_add = false;
776        if extended {
777            if version < 3 {
778                return None; // extended bit is invalid in v2 ⇒ corrupt
779            }
780            let ext = be16(&data, pos)?;
781            pos += 2;
782            skip_worktree = ext & 0x4000 != 0;
783            intent_to_add = ext & 0x2000 != 0;
784        }
785
786        // Path.
787        if version == 4 {
788            let strip = decode_varint(&data, &mut pos)? as usize;
789            if strip > prev_path.len() {
790                return None;
791            }
792            let keep = prev_path.len() - strip;
793            prev_path.truncate(keep);
794            let nul = data.get(pos..)?.iter().position(|&b| b == 0)?;
795            prev_path.extend_from_slice(&data[pos..pos + nul]);
796            pos += nul + 1; // v4: no padding
797        } else {
798            let actual_len = if name_len < 0xFFF {
799                name_len
800            } else {
801                data.get(pos..)?.iter().position(|&b| b == 0)?
802            };
803            prev_path.clear();
804            prev_path.extend_from_slice(data.get(pos..pos + actual_len)?);
805            // v2/v3 entry size: (fixed + namelen + 8) & !7 — NUL padding to a
806            // multiple of 8 from the entry start, ≥ 1 NUL (read-cache.c
807            // ondisk_ce_size).
808            let fixed = pos - start;
809            pos = start + ((fixed + actual_len + 8) & !7);
810            if pos > data.len() {
811                return None;
812            }
813        }
814
815        // ---- evaluate the entry ----
816        if stage != 0 {
817            // Unmerged: 2-3 entries per path (stages 1/2/3, adjacent since
818            // the index is name-then-stage sorted); count the PATH once,
819            // matching one `u` line per path in porcelain v2.
820            if prev_path != last_conflict {
821                conflicted += 1;
822                last_conflict.clear();
823                last_conflict.extend_from_slice(&prev_path);
824            }
825            continue;
826        }
827        if skip_worktree || assume_valid {
828            // git status ignores skip-worktree entries; assume-valid
829            // (`git update-index --assume-unchanged`) means "trust the index"
830            // — no stat, matching git.
831            continue;
832        }
833        if intent_to_add {
834            // `git add -N`: porcelain v2 reports `1 .A` — an unstaged add.
835            unstaged += 1;
836            continue;
837        }
838        if e.mode & S_IFMT == S_IFDIR {
839            // Sparse-index tree entry (extensions.sparseIndex): expanding it
840            // needs the object store — defer everything to the subprocess.
841            tracing::debug!("p10k git: sparse index in {:?}, deferring to subprocess", repo.git_dir);
842            return None;
843        }
844        let full = repo.work_dir.join(OsStr::from_bytes(&prev_path));
845        match fs::symlink_metadata(&full) {
846            // index.cc:195 — ENOENT ⇒ deleted, other errors ⇒ unreadable;
847            // both are dirty candidates.
848            Err(_) => unstaged += 1,
849            Ok(st) => {
850                if is_modified(&e, &st, caps) {
851                    unstaged += 1;
852                }
853            }
854        }
855    }
856    // Extensions (TREE, REUC, UNTR, ...) follow the entries; none are needed
857    // for these two counts, so parsing stops here.
858
859    Some(IndexCounts {
860        unstaged,
861        conflicted,
862    })
863}
864
865// ---------------------------------------------------------------------------
866// Stashes — .git/logs/refs/stash reflog
867// ---------------------------------------------------------------------------
868
869/// Stash count = line count of the stash reflog; `git stash list` renders
870/// exactly this reflog, one line per stash. The file lives in the COMMON dir
871/// (reflogs other than HEAD are shared across worktrees). Absent file = no
872/// stashes (git deletes it when the last stash is dropped). None only on a
873/// real read error (the caller then falls back to porcelain `# stash`).
874fn stash_count(common_dir: &Path) -> Option<i64> {
875    match fs::read(common_dir.join("logs/refs/stash")) {
876        Ok(bytes) => Some(bytes.iter().filter(|&&b| b == b'\n').count() as i64),
877        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
878        Err(e) => {
879            tracing::debug!("p10k git: stash reflog unreadable: {e}");
880            None
881        }
882    }
883}
884
885// ---------------------------------------------------------------------------
886// Tag — port of gitstatus TagDb::TagForCommit (tag_db.cc:119-148)
887// ---------------------------------------------------------------------------
888
889/// The tag p10k shows for HEAD: the lexicographically GREATEST tag name whose
890/// (peeled) target equals the HEAD commit (tag_db.cc:130 `if (res < tag ...)`
891/// and :143 — max over loose and packed matches). "" when no tag points at
892/// HEAD. Loose tags shadow same-named packed tags (tag_db.cc:135,143
893/// IsLooseTag). Loose ANNOTATED tags (file holds a tag object id) cannot be
894/// peeled without the object store and are skipped — see module doc.
895fn tag_for_commit(common_dir: &Path, commit: &str) -> String {
896    let mut best = String::new();
897    let mut loose_names: Vec<String> = Vec::new();
898
899    // Loose tags: flat scan of refs/tags, mirroring tag_db.cc:150-162
900    // ReadLooseTags (non-recursive; nested names like refs/tags/foo/bar are
901    // missed — gitstatus has the same TODO at tag_db.cc:158).
902    if let Ok(rd) = fs::read_dir(common_dir.join("refs/tags")) {
903        for entry in rd.flatten() {
904            let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
905            if !is_file {
906                continue;
907            }
908            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
909                continue;
910            };
911            if let Ok(oid) = fs::read_to_string(entry.path()) {
912                // Lightweight tag: the file IS the commit id. An annotated
913                // tag's file holds the tag object id ⇒ no match here (skipped
914                // per module doc).
915                if oid.trim() == commit && best < name {
916                    best = name.clone();
917                }
918            }
919            loose_names.push(name);
920        }
921    }
922
923    // Packed tags. tag_db.cc:228-231: without a `#` header line the refs
924    // cannot be assumed fully-peeled — drop all packed tags. tag_db.cc:236-238:
925    // a header missing " fully-peeled"/" sorted" only warns and continues.
926    let Ok(packed) = fs::read_to_string(common_dir.join("packed-refs")) else {
927        return best;
928    };
929    if !packed.starts_with('#') {
930        tracing::warn!("p10k git: packed-refs doesn't have a header. Won't resolve packed tags.");
931        return best;
932    }
933    if let Some(header) = packed.lines().next() {
934        if !header.contains(" fully-peeled") || !header.contains(" sorted") {
935            tracing::warn!("p10k git: packed-refs has unexpected header."); // tag_db.cc:237
936        }
937    }
938    let mut lines = packed.lines().peekable();
939    while let Some(line) = lines.next() {
940        if line.starts_with('#') || line.starts_with('^') {
941            continue;
942        }
943        let Some((oid, refname)) = line.split_once(' ') else {
944            continue;
945        };
946        // A following `^<oid>` line is the peeled target of an annotated tag
947        // (tag_db.cc:256-263 replaces the id with the peeled one).
948        let effective = match lines.peek() {
949            Some(peel) if peel.starts_with('^') => lines
950                .next()
951                .and_then(|p| p.strip_prefix('^'))
952                .unwrap_or(oid),
953            _ => oid,
954        };
955        let Some(name) = refname.strip_prefix("refs/tags/") else {
956            continue;
957        };
958        if effective == commit
959            && !loose_names.iter().any(|l| l == name) // loose shadows packed
960            && best.as_str() < name
961        {
962            best = name.to_string();
963        }
964    }
965    best
966}
967
968// ---------------------------------------------------------------------------
969// Gated subprocess: `git status --porcelain=v2` for staged / untracked
970// (+ ahead-behind on divergence, + everything on native-parse failure)
971// ---------------------------------------------------------------------------
972
973/// Run the one subprocess. Output is drained on a helper thread so a huge
974/// dirty tree can't deadlock on a full pipe; the caller waits at most
975/// SUBPROCESS_BUDGET. Returns None on spawn failure or timeout (child is
976/// killed and reaped).
977fn run_porcelain(work_dir: &Path) -> Option<String> {
978    let mut child = match Command::new("git")
979        .arg("-C")
980        .arg(work_dir)
981        .args(["status", "--porcelain=v2", "--branch", "--show-stash"])
982        // Match gitstatusd: never take optional locks / touch the index
983        // from a prompt-driven read.
984        .env("GIT_OPTIONAL_LOCKS", "0")
985        .stdin(Stdio::null())
986        .stdout(Stdio::piped())
987        .stderr(Stdio::null())
988        .spawn()
989    {
990        Ok(c) => c,
991        Err(e) => {
992            tracing::warn!("p10k git: failed to spawn git status: {e}");
993            return None;
994        }
995    };
996
997    let mut stdout = child.stdout.take()?;
998    let (tx, rx) = mpsc::channel();
999    std::thread::spawn(move || {
1000        let mut out = String::new();
1001        let _ = stdout.read_to_string(&mut out);
1002        let _ = tx.send(out);
1003    });
1004
1005    match rx.recv_timeout(SUBPROCESS_BUDGET) {
1006        Ok(out) => {
1007            let _ = child.wait();
1008            Some(out)
1009        }
1010        Err(_) => {
1011            tracing::debug!(
1012                "p10k git: git status exceeded {SUBPROCESS_BUDGET:?} in {work_dir:?}, serving cache"
1013            );
1014            let _ = child.kill();
1015            let _ = child.wait();
1016            None
1017        }
1018    }
1019}
1020
1021/// Parse `git status --porcelain=v2 --branch --show-stash` output into the
1022/// count fields (plus upstream/ahead/behind and commit/branch fallbacks).
1023///
1024/// Header lines:
1025///   `# branch.oid <oid>`            — commit (fallback if HEAD read failed)
1026///   `# branch.head <name>`          — `(detached)` when detached
1027///   `# branch.upstream <r>/<b>`     — remote_branch
1028///   `# branch.ab +A -B`             — ahead/behind
1029///   `# stash <N>`                   — stash count (--show-stash)
1030/// Entry lines:
1031///   `1 XY ...` / `2 XY ...`         — X!='.' staged++, Y!='.' unstaged++
1032///   `u ...`                         — conflicted++ (unmerged)
1033///   `? <path>`                      — untracked++
1034fn parse_porcelain_v2(out: &str, status: &mut GitStatus) {
1035    for line in out.lines() {
1036        if let Some(rest) = line.strip_prefix("# ") {
1037            if let Some(oid) = rest.strip_prefix("branch.oid ") {
1038                if status.commit.is_empty() && oid != "(initial)" {
1039                    status.commit = oid.to_string();
1040                }
1041            } else if let Some(head) = rest.strip_prefix("branch.head ") {
1042                if status.branch.is_empty() && head != "(detached)" {
1043                    status.branch = head.to_string();
1044                }
1045            } else if let Some(up) = rest.strip_prefix("branch.upstream ") {
1046                status.remote_branch = up.to_string();
1047            } else if let Some(ab) = rest.strip_prefix("branch.ab ") {
1048                for tok in ab.split_whitespace() {
1049                    if let Some(a) = tok.strip_prefix('+') {
1050                        status.ahead = a.parse().unwrap_or(0);
1051                    } else if let Some(b) = tok.strip_prefix('-') {
1052                        status.behind = b.parse().unwrap_or(0);
1053                    }
1054                }
1055            } else if let Some(n) = rest.strip_prefix("stash ") {
1056                status.stashes = n.trim().parse().unwrap_or(0);
1057            }
1058        } else if line.starts_with("1 ") || line.starts_with("2 ") {
1059            // XY at bytes 2..4: X = staged state, Y = worktree state.
1060            let bytes = line.as_bytes();
1061            if bytes.len() >= 4 {
1062                if bytes[2] != b'.' {
1063                    status.staged += 1;
1064                }
1065                if bytes[3] != b'.' {
1066                    status.unstaged += 1;
1067                }
1068            }
1069        } else if line.starts_with("u ") {
1070            status.conflicted += 1;
1071        } else if line.starts_with("? ") {
1072            status.untracked += 1;
1073        }
1074        // `! <path>` (ignored entries) are not counted — p10k doesn't use them.
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    // ---- porcelain fallback parser (unchanged behavior) ----
1083
1084    #[test]
1085    fn porcelain_headers_and_counts() {
1086        let out = "\
1087# branch.oid 47a386b42798b5ed5d89bc617920d9152479013e
1088# branch.head main
1089# branch.upstream origin/main
1090# branch.ab +3 -2
1091# stash 1
10921 .M N... 100644 100644 100644 aaaa bbbb src/lib.rs
10931 M. N... 100644 100644 100644 aaaa bbbb src/exec.rs
10941 MM N... 100644 100644 100644 aaaa bbbb src/hist.rs
10952 R. N... 100644 100644 100644 aaaa bbbb R100 new.rs\tnew_old.rs
1096u UU N... 100644 100644 100644 100644 aaaa bbbb cccc conflict.rs
1097? untracked_a.rs
1098? untracked_b.rs
1099";
1100        let mut s = GitStatus::default();
1101        parse_porcelain_v2(out, &mut s);
1102        assert_eq!(s.commit, "47a386b42798b5ed5d89bc617920d9152479013e");
1103        assert_eq!(s.branch, "main");
1104        assert_eq!(s.remote_branch, "origin/main");
1105        assert_eq!(s.ahead, 3);
1106        assert_eq!(s.behind, 2);
1107        assert_eq!(s.stashes, 1);
1108        // staged: "M." + "MM" + "R." = 3; unstaged: ".M" + "MM" = 2
1109        assert_eq!(s.staged, 3);
1110        assert_eq!(s.unstaged, 2);
1111        assert_eq!(s.conflicted, 1);
1112        assert_eq!(s.untracked, 2);
1113    }
1114
1115    #[test]
1116    fn porcelain_detached_and_initial() {
1117        let out = "\
1118# branch.oid (initial)
1119# branch.head (detached)
1120";
1121        let mut s = GitStatus::default();
1122        parse_porcelain_v2(out, &mut s);
1123        assert_eq!(s.commit, "");
1124        assert_eq!(s.branch, "");
1125        assert_eq!(s.remote_branch, "");
1126        assert_eq!(s.ahead, 0);
1127        assert_eq!(s.behind, 0);
1128        assert_eq!(s.stashes, 0);
1129    }
1130
1131    #[test]
1132    fn porcelain_does_not_clobber_head_read() {
1133        // branch/commit already resolved from .git/HEAD win over headers.
1134        let out = "\
1135# branch.oid ffffffffffffffffffffffffffffffffffffffff
1136# branch.head porcelain-branch
1137";
1138        let mut s = GitStatus {
1139            branch: "head-branch".into(),
1140            commit: "1111111111111111111111111111111111111111".into(),
1141            ..GitStatus::default()
1142        };
1143        parse_porcelain_v2(out, &mut s);
1144        assert_eq!(s.branch, "head-branch");
1145        assert_eq!(s.commit, "1111111111111111111111111111111111111111");
1146    }
1147
1148    #[test]
1149    fn porcelain_no_upstream_no_stash_line() {
1150        let out = "\
1151# branch.oid aaaa
1152# branch.head feature/x
11531 A. N... 000000 100644 100644 0000 aaaa new_file.rs
1154";
1155        let mut s = GitStatus::default();
1156        parse_porcelain_v2(out, &mut s);
1157        assert_eq!(s.branch, "feature/x");
1158        assert_eq!(s.remote_branch, "");
1159        assert_eq!(s.ahead, 0);
1160        assert_eq!(s.behind, 0);
1161        assert_eq!(s.staged, 1);
1162        assert_eq!(s.unstaged, 0);
1163    }
1164
1165    #[test]
1166    fn porcelain_ignores_ignored_entries_and_short_lines() {
1167        let out = "! ignored.log\n1\n\n? u.rs\n";
1168        let mut s = GitStatus::default();
1169        parse_porcelain_v2(out, &mut s);
1170        assert_eq!(s.staged, 0);
1171        assert_eq!(s.unstaged, 0);
1172        assert_eq!(s.untracked, 1);
1173    }
1174
1175    // ---- native index parse ----
1176
1177    fn caps() -> RepoCaps {
1178        RepoCaps {
1179            trust_filemode: true,
1180            has_symlinks: true,
1181            hash_len: 20,
1182        }
1183    }
1184
1185    /// Build one v2 on-disk entry: 40-byte stat area + 20-byte oid + flags +
1186    /// NUL-padded path (entry size = (62 + len + 8) & !7).
1187    fn v2_entry(path: &[u8], mode: u32, ino: u32, size: u32, mt_s: u32, mt_ns: u32, stage: u16) -> Vec<u8> {
1188        let mut e = Vec::new();
1189        for v in [0u32, 0, mt_s, mt_ns, 0, ino, mode, 0, 0, size] {
1190            e.extend_from_slice(&v.to_be_bytes());
1191        }
1192        e.extend_from_slice(&[0u8; 20]); // oid (not read by the parser)
1193        let flags: u16 = ((stage & 0x3) << 12) | (path.len().min(0xFFF) as u16);
1194        e.extend_from_slice(&flags.to_be_bytes());
1195        e.extend_from_slice(path);
1196        let total = (62 + path.len() + 8) & !7;
1197        e.resize(total, 0);
1198        e
1199    }
1200
1201    fn v2_index(entries: &[Vec<u8>]) -> Vec<u8> {
1202        let mut d = b"DIRC".to_vec();
1203        d.extend_from_slice(&2u32.to_be_bytes());
1204        d.extend_from_slice(&(entries.len() as u32).to_be_bytes());
1205        for e in entries {
1206            d.extend_from_slice(e);
1207        }
1208        d.extend_from_slice(&[0u8; 20]); // checksum trailer (not verified)
1209        d
1210    }
1211
1212    fn temp_repo(index: &[u8]) -> (tempfile::TempDir, Repo) {
1213        let td = tempfile::tempdir().expect("tempdir");
1214        let git_dir = td.path().join(".git");
1215        fs::create_dir_all(&git_dir).expect("mkdir .git");
1216        fs::write(git_dir.join("index"), index).expect("write index");
1217        let repo = Repo {
1218            common_dir: git_dir.clone(),
1219            git_dir,
1220            work_dir: td.path().to_path_buf(),
1221        };
1222        (td, repo)
1223    }
1224
1225    #[test]
1226    fn index_header_rejects_bad_magic_and_version() {
1227        // Bad magic on a fixture byte literal.
1228        let mut bad = b"XDIR".to_vec();
1229        bad.extend_from_slice(&2u32.to_be_bytes());
1230        bad.extend_from_slice(&0u32.to_be_bytes());
1231        bad.extend_from_slice(&[0u8; 20]);
1232        let (_td, repo) = temp_repo(&bad);
1233        assert!(read_index_counts(&repo, &caps()).is_none());
1234
1235        // Unsupported version 5.
1236        let mut v5 = b"DIRC".to_vec();
1237        v5.extend_from_slice(&5u32.to_be_bytes());
1238        v5.extend_from_slice(&0u32.to_be_bytes());
1239        v5.extend_from_slice(&[0u8; 20]);
1240        let (_td, repo) = temp_repo(&v5);
1241        assert!(read_index_counts(&repo, &caps()).is_none());
1242    }
1243
1244    #[test]
1245    fn index_v2_empty_and_conflicts() {
1246        // Header-only v2 index (fixture byte literal): 0 entries, 0 counts.
1247        let (_td, repo) = temp_repo(&v2_index(&[]));
1248        let ic = read_index_counts(&repo, &caps()).expect("empty index parses");
1249        assert_eq!(ic.unstaged, 0);
1250        assert_eq!(ic.conflicted, 0);
1251
1252        // Stages 1+2 for one path = ONE conflicted path (matches one `u`
1253        // porcelain line), no lstat performed for unmerged entries.
1254        let e1 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 1);
1255        let e2 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 2);
1256        let (_td, repo) = temp_repo(&v2_index(&[e1, e2]));
1257        let ic = read_index_counts(&repo, &caps()).expect("parses");
1258        assert_eq!(ic.conflicted, 1);
1259        assert_eq!(ic.unstaged, 0);
1260    }
1261
1262    #[test]
1263    fn index_v2_lstat_clean_deleted_modified() {
1264        use std::os::unix::fs::MetadataExt;
1265        let td = tempfile::tempdir().expect("tempdir");
1266        let f = td.path().join("a.rs");
1267        fs::write(&f, b"x").expect("write file");
1268        let st = fs::symlink_metadata(&f).expect("stat");
1269        let mode = S_IFREG | if st.mode() & 0o100 != 0 { 0o755 } else { 0o644 };
1270
1271        // Entry stat-matching the real file → clean.
1272        let clean = v2_entry(
1273            b"a.rs",
1274            mode,
1275            st.ino() as u32,
1276            st.size() as u32,
1277            st.mtime() as u32,
1278            st.mtime_nsec() as u32,
1279            0,
1280        );
1281        // Same but wrong size → modified (index.cc:97).
1282        let dirty = v2_entry(
1283            b"b.rs",
1284            mode,
1285            0,
1286            st.size() as u32 + 7,
1287            st.mtime() as u32,
1288            0,
1289            0,
1290        );
1291        fs::write(td.path().join("b.rs"), b"x").expect("write b");
1292        // Entry whose worktree file does not exist → deleted (index.cc:195).
1293        let gone = v2_entry(b"gone.rs", mode, 0, 1, 1, 0, 0);
1294
1295        let git_dir = td.path().join(".git");
1296        fs::create_dir_all(&git_dir).expect("mkdir");
1297        fs::write(git_dir.join("index"), v2_index(&[clean, dirty, gone])).expect("write index");
1298        let repo = Repo {
1299            common_dir: git_dir.clone(),
1300            git_dir,
1301            work_dir: td.path().to_path_buf(),
1302        };
1303        let ic = read_index_counts(&repo, &caps()).expect("parses");
1304        assert_eq!(ic.unstaged, 2); // dirty + gone; clean not counted
1305        assert_eq!(ic.conflicted, 0);
1306    }
1307
1308    #[test]
1309    fn varint_offset_encoding() {
1310        // git varint.c: 0x7f → 127; 0x80 0x01 → ((0+1)<<7)|1 = 129.
1311        let mut p = 0;
1312        assert_eq!(decode_varint(&[0x7f], &mut p), Some(127));
1313        let mut p = 0;
1314        assert_eq!(decode_varint(&[0x80, 0x01], &mut p), Some(129));
1315        assert_eq!(p, 2);
1316        // Truncated continuation → None.
1317        let mut p = 0;
1318        assert_eq!(decode_varint(&[0x80], &mut p), None);
1319    }
1320
1321    // ---- packed-refs tags + stash log + config ----
1322
1323    const C: &str = "47a386b42798b5ed5d89bc617920d9152479013e";
1324
1325    #[test]
1326    fn packed_refs_tag_peel_and_max_name() {
1327        let td = tempfile::tempdir().expect("tempdir");
1328        // Annotated v1.0.0 peels to C; lightweight v0.9.0 points at C
1329        // directly; v0.5.0 points elsewhere.
1330        let packed = format!(
1331            "# pack-refs with: peeled fully-peeled sorted \n\
1332             aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.5.0\n\
1333             {C} refs/tags/v0.9.0\n\
1334             bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v1.0.0\n\
1335             ^{C}\n"
1336        );
1337        fs::write(td.path().join("packed-refs"), packed).expect("write");
1338        // Lexicographic max of the two matches (tag_db.cc:130,143).
1339        assert_eq!(tag_for_commit(td.path(), C), "v1.0.0");
1340
1341        // Loose lightweight tag beats packed by name order and shadows a
1342        // same-named packed entry.
1343        let tags = td.path().join("refs/tags");
1344        fs::create_dir_all(&tags).expect("mkdir");
1345        fs::write(tags.join("v2.0"), format!("{C}\n")).expect("write");
1346        assert_eq!(tag_for_commit(td.path(), C), "v2.0");
1347    }
1348
1349    #[test]
1350    fn packed_refs_without_header_drops_packed_tags() {
1351        // tag_db.cc:228-231 — no `#` header ⇒ packed tags unusable.
1352        let td = tempfile::tempdir().expect("tempdir");
1353        fs::write(
1354            td.path().join("packed-refs"),
1355            format!("{C} refs/tags/v9.9.9\n"),
1356        )
1357        .expect("write");
1358        assert_eq!(tag_for_commit(td.path(), C), "");
1359    }
1360
1361    #[test]
1362    fn stash_log_count() {
1363        let td = tempfile::tempdir().expect("tempdir");
1364        assert_eq!(stash_count(td.path()), Some(0)); // absent = 0, exact
1365        let logs = td.path().join("logs/refs");
1366        fs::create_dir_all(&logs).expect("mkdir");
1367        fs::write(
1368            logs.join("stash"),
1369            "0000 1111 user <u@example.com> 1 +0000\tWIP one\n\
1370             1111 2222 user <u@example.com> 2 +0000\tWIP two\n",
1371        )
1372        .expect("write");
1373        assert_eq!(stash_count(td.path()), Some(2));
1374    }
1375
1376    #[test]
1377    fn config_upstream_and_caps() {
1378        let cfg = GitConfig::parse(
1379            "[core]\n\
1380             \tfilemode = false\n\
1381             \tsymlinks = true\n\
1382             [extensions]\n\
1383             \tobjectformat = sha256\n\
1384             [branch \"feature/x\"]\n\
1385             \tremote = origin\n\
1386             \tmerge = refs/heads/feature/x\n\
1387             [branch \"local\"]\n\
1388             \tremote = .\n\
1389             \tmerge = refs/heads/main\n",
1390        );
1391        assert_eq!(
1392            upstream_of(&cfg, "feature/x"),
1393            Some(("origin".to_string(), "feature/x".to_string()))
1394        );
1395        assert_eq!(
1396            upstream_of(&cfg, "local"),
1397            Some((".".to_string(), "main".to_string()))
1398        );
1399        assert_eq!(upstream_of(&cfg, "nope"), None);
1400        let caps = RepoCaps::from_config(&cfg);
1401        assert!(!caps.trust_filemode);
1402        assert!(caps.has_symlinks);
1403        assert_eq!(caps.hash_len, 32);
1404    }
1405}