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}