1use crate::extensions::p10k::gitconfig::GitConfig;
78use std::collections::HashMap;
79use std::ffi::OsStr;
80use std::fs;
81use std::io::Read;
82use std::os::unix::ffi::OsStrExt;
83use std::path::{Path, PathBuf};
84use std::process::{Command, Stdio};
85use std::sync::mpsc;
86use std::sync::{Mutex, OnceLock};
87use std::time::{Duration, Instant, SystemTime};
88
89#[derive(Debug, Clone, Default)]
92pub struct GitStatus {
93 pub branch: String,
94 pub commit: String,
95 pub action: String, pub ahead: i64,
97 pub behind: i64,
98 pub staged: i64,
99 pub unstaged: i64,
100 pub untracked: i64,
101 pub conflicted: i64,
102 pub stashes: i64,
103 pub tag: String,
104 pub remote_branch: String,
105 pub remote_url: String,
109 pub workdir: String,
111 pub remote_name: String,
114 pub push_remote_name: String,
118 pub push_remote_url: String,
119 pub push_ahead: i64,
121 pub push_behind: i64,
122 pub index_size: i64,
124 pub skip_worktree: i64,
126 pub assume_unchanged: i64,
127 pub staged_new: i64,
130 pub staged_deleted: i64,
131 pub unstaged_deleted: i64,
132 pub dirty_unknown: bool,
135}
136
137#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct GitLimits {
144 pub max_staged: i64,
145 pub max_unstaged: i64,
146 pub max_untracked: i64,
147 pub max_conflicted: i64,
148 pub dirty_max_index_size: i64,
149 pub recurse_untracked_dirs: bool,
150}
151
152impl Default for GitLimits {
153 fn default() -> Self {
155 GitLimits {
156 max_staged: 1,
157 max_unstaged: 1,
158 max_untracked: 1,
159 max_conflicted: 1,
160 dirty_max_index_size: -1,
161 recurse_untracked_dirs: false,
162 }
163 }
164}
165
166impl GitLimits {
167 pub fn from_params() -> Self {
169 use crate::extensions::p10k::shared::{global_bool, global_int};
170 GitLimits {
171 max_staged: global_int("VCS_STAGED_MAX_NUM", 1),
172 max_unstaged: global_int("VCS_UNSTAGED_MAX_NUM", 1),
173 max_untracked: global_int("VCS_UNTRACKED_MAX_NUM", 1),
174 max_conflicted: global_int("VCS_CONFLICTED_MAX_NUM", 1),
175 dirty_max_index_size: global_int("VCS_MAX_INDEX_SIZE_DIRTY", -1),
176 recurse_untracked_dirs: global_bool("VCS_RECURSE_UNTRACKED_DIRS", false),
177 }
178 }
179
180 fn clamp(count: i64, limit: i64) -> i64 {
182 if limit < 0 {
183 count
184 } else {
185 count.min(limit)
186 }
187 }
188
189 fn apply(&self, s: &mut GitStatus, index_size: i64) {
192 let too_large = self.dirty_max_index_size >= 0 && index_size > self.dirty_max_index_size;
193 if too_large {
194 s.unstaged = 0;
195 s.untracked = 0;
196 s.unstaged_deleted = 0;
197 }
198 s.dirty_unknown = too_large;
199 s.staged = Self::clamp(s.staged, self.max_staged);
200 s.unstaged = Self::clamp(s.unstaged, self.max_unstaged);
201 s.conflicted = Self::clamp(s.conflicted, self.max_conflicted);
202 s.untracked = Self::clamp(s.untracked, self.max_untracked);
203 s.staged_new = s.staged_new.min(s.staged);
204 s.staged_deleted = s.staged_deleted.min(s.staged);
205 s.unstaged_deleted = s.unstaged_deleted.min(s.unstaged);
206 }
207}
208
209const CACHE_TTL: Duration = Duration::from_secs(2);
212const SUBPROCESS_BUDGET: Duration = Duration::from_millis(45);
214const NATIVE_SCAN_MAX: usize = 20_000;
219const PAR_STAT_MIN: usize = 512;
222const MAX_STAT_THREADS: usize = 8;
227
228struct CacheEntry {
229 at: Instant,
230 head_mtime: Option<SystemTime>,
231 index_mtime: Option<SystemTime>,
232 limits: GitLimits,
233 status: GitStatus,
234}
235
236fn cache() -> &'static Mutex<HashMap<PathBuf, CacheEntry>> {
237 static CACHE: OnceLock<Mutex<HashMap<PathBuf, CacheEntry>>> = OnceLock::new();
238 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
239}
240
241struct SubEntry {
247 at: Instant,
248 mode: UntrackedMode,
249 status: GitStatus,
250}
251
252#[derive(Clone, Copy, Debug, PartialEq)]
254enum UntrackedMode {
255 No,
256 Normal,
257 All,
258}
259
260impl UntrackedMode {
261 fn arg(self) -> &'static str {
262 match self {
263 UntrackedMode::No => "--untracked-files=no",
264 UntrackedMode::Normal => "--untracked-files=normal",
265 UntrackedMode::All => "--untracked-files=all",
266 }
267 }
268}
269
270fn sub_cache() -> &'static Mutex<HashMap<PathBuf, SubEntry>> {
271 static SUB: OnceLock<Mutex<HashMap<PathBuf, SubEntry>>> = OnceLock::new();
272 SUB.get_or_init(|| Mutex::new(HashMap::new()))
273}
274
275fn sub_inflight() -> &'static Mutex<std::collections::HashSet<PathBuf>> {
279 static IN: OnceLock<Mutex<std::collections::HashSet<PathBuf>>> = OnceLock::new();
280 IN.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
281}
282
283const CACHE_CAP: usize = 64;
286
287fn refresh_porcelain(
301 repo: &Repo,
302 mode: UntrackedMode,
303 wait: Option<Duration>,
304) -> Option<GitStatus> {
305 {
306 let mut set = sub_inflight().lock().ok()?;
307 if !set.insert(repo.git_dir.clone()) {
308 return None; }
310 }
311
312 let work_dir = repo.work_dir.clone();
313 let git_dir = repo.git_dir.clone();
314 let (tx, rx) = mpsc::channel();
315 crate::signal_thread::spawn(move || {
316 let out = run_porcelain(&work_dir, mode);
317 if let Some(out) = out {
318 let mut sub = GitStatus::default();
319 parse_porcelain_v2(&out, &mut sub);
320 if let Ok(mut map) = sub_cache().lock() {
321 if map.len() >= CACHE_CAP && !map.contains_key(&git_dir) {
322 evict_oldest(&mut map, |e: &SubEntry| e.at);
323 }
324 map.insert(
325 git_dir.clone(),
326 SubEntry {
327 at: Instant::now(),
328 mode,
329 status: sub.clone(),
330 },
331 );
332 }
333 let _ = tx.send(sub);
334 }
335 if let Ok(mut set) = sub_inflight().lock() {
336 set.remove(&git_dir);
337 }
338 });
339
340 match wait {
341 Some(d) => rx.recv_timeout(d).ok(),
342 None => None,
343 }
344}
345
346fn evict_oldest<V>(map: &mut HashMap<PathBuf, V>, at: impl Fn(&V) -> Instant) {
348 if let Some(oldest) = map
349 .iter()
350 .max_by_key(|(_, v)| at(v).elapsed())
351 .map(|(k, _)| k.clone())
352 {
353 map.remove(&oldest);
354 }
355}
356
357pub fn git_status_for(dir: &Path) -> Option<GitStatus> {
361 git_status_with(dir, &GitLimits::from_params())
362}
363
364pub fn git_status_with(dir: &Path, limits: &GitLimits) -> Option<GitStatus> {
366 let repo = discover_repo(dir)?;
367 let head_mtime = mtime_of(&repo.git_dir.join("HEAD"));
368 let index_mtime = mtime_of(&repo.git_dir.join("index"));
369
370 if let Ok(map) = cache().lock() {
372 if let Some(e) = map.get(&repo.git_dir) {
373 if e.at.elapsed() < CACHE_TTL
374 && e.limits == *limits
375 && e.head_mtime == head_mtime
376 && e.index_mtime == index_mtime
377 {
378 return Some(e.status.clone());
379 }
380 }
381 }
382
383 let mut status = GitStatus::default();
385 read_head(&repo, &mut status);
386 status.workdir = repo.work_dir.to_string_lossy().into_owned();
387 status.action = repo_action(&repo.git_dir); let cfg = GitConfig::load_stack(
393 &repo.common_dir,
394 &repo.git_dir,
395 Some(status.branch.as_str()).filter(|b| !b.is_empty()),
396 );
397 let caps = RepoCaps::from_config(&cfg);
398
399 let mut lim = *limits;
402 let off = |section: &str, key: &str| cfg.bool_value(section, key) == Some(false);
403 if off("status", "showuntrackedfiles") || off("bash", "showuntrackedfiles") {
404 lim.max_untracked = 0;
405 }
406 if off("bash", "showdirtystate") {
407 lim.max_staged = 0;
408 lim.max_unstaged = 0;
409 lim.max_conflicted = 0;
410 }
411 let untracked_mode = if lim.max_untracked == 0 {
412 UntrackedMode::No
413 } else if lim.recurse_untracked_dirs {
414 UntrackedMode::All
415 } else {
416 UntrackedMode::Normal
417 };
418
419 let mut ab_native = false;
423 if !status.branch.is_empty() {
424 match tracking_of(&cfg, &repo.common_dir, &status.branch) {
425 Some(t) => {
426 status.remote_branch = t.branch;
427 status.remote_name = t.remote.clone();
428 if t.remote != "." {
430 status.remote_url =
431 cfg.get("remote", &t.remote, "url").unwrap_or_default().to_string();
432 }
433 if t.tip == status.commit {
434 ab_native = true;
437 }
438 }
439 None => ab_native = true,
441 }
442 if let Some(p) = push_target_of(&cfg, &repo.common_dir, &status.branch) {
443 status.push_remote_name = p.remote;
444 status.push_remote_url = p.url;
445 if p.tip != status.commit {
446 if let Some((ahead, behind)) = rev_list_counts(&repo.common_dir, &status.commit, &p.refname) {
447 status.push_ahead = ahead;
448 status.push_behind = behind;
449 }
450 }
451 }
452 } else {
453 ab_native = true; }
455
456 let native_stash = stash_count(&repo.common_dir);
458 if let Some(n) = native_stash {
459 status.stashes = n;
460 }
461
462 if !status.commit.is_empty() {
465 status.tag = tag_for_commit(&repo.common_dir, &status.commit);
466 }
467
468 let index_size = index_entry_count(&repo.git_dir);
470 status.index_size = index_size;
471 let native_counts = read_index_counts(&repo, &caps);
472 if let Some(ic) = &native_counts {
473 status.unstaged = ic.unstaged;
474 status.conflicted = ic.conflicted;
475 status.skip_worktree = ic.skip_worktree;
476 status.assume_unchanged = ic.assume_unchanged;
477 }
478
479 let prev_sub = sub_cache()
488 .lock()
489 .ok()
490 .and_then(|m| {
491 m.get(&repo.git_dir)
492 .filter(|e| e.mode == untracked_mode)
493 .map(|e| e.status.clone())
494 });
495 let sub = match prev_sub {
496 Some(s) => {
497 refresh_porcelain(&repo, untracked_mode, None);
499 Some(s)
500 }
501 None => refresh_porcelain(&repo, untracked_mode, Some(SUBPROCESS_BUDGET)),
504 };
505
506 let sub = match sub {
507 Some(s) => s,
508 None => return None,
513 };
514
515 status.staged = sub.staged;
516 status.untracked = sub.untracked;
517 status.staged_new = sub.staged_new;
518 status.staged_deleted = sub.staged_deleted;
519 status.unstaged_deleted = sub.unstaged_deleted;
520 if native_counts.is_none() {
521 status.unstaged = sub.unstaged;
522 status.conflicted = sub.conflicted;
523 }
524 if !ab_native {
525 status.ahead = sub.ahead;
526 status.behind = sub.behind;
527 }
528 if native_stash.is_none() {
529 status.stashes = sub.stashes; }
531 if status.commit.is_empty() {
532 status.commit = sub.commit;
533 }
534 if status.branch.is_empty() {
535 status.branch = sub.branch;
536 }
537 if status.remote_branch.is_empty() && !sub.remote_branch.is_empty() {
538 status.remote_branch = match sub.remote_branch.split_once('/') {
541 Some((_, b)) => b.to_string(),
542 None => sub.remote_branch,
543 };
544 }
545
546 lim.apply(&mut status, index_size);
547
548 if let Ok(mut map) = cache().lock() {
549 if map.len() >= CACHE_CAP && !map.contains_key(&repo.git_dir) {
552 evict_oldest(&mut map, |e: &CacheEntry| e.at);
553 }
554 map.insert(
555 repo.git_dir.clone(),
556 CacheEntry {
557 at: Instant::now(),
558 head_mtime,
559 index_mtime,
560 limits: *limits,
561 status: status.clone(),
562 },
563 );
564 }
565 Some(status)
566}
567
568struct Repo {
573 git_dir: PathBuf,
575 common_dir: PathBuf,
578 work_dir: PathBuf,
580}
581
582fn discover_repo(dir: &Path) -> Option<Repo> {
587 let mut cur = if dir.is_absolute() {
588 dir.to_path_buf()
589 } else {
590 std::env::current_dir().ok()?.join(dir)
591 };
592 loop {
593 let dotgit = cur.join(".git");
594 if let Ok(meta) = fs::symlink_metadata(&dotgit) {
595 let git_dir = if meta.is_dir() {
596 dotgit
597 } else {
598 let text = fs::read_to_string(&dotgit).ok()?;
600 let target = text.strip_prefix("gitdir:")?.trim();
601 let p = Path::new(target);
602 if p.is_absolute() {
603 p.to_path_buf()
604 } else {
605 cur.join(p)
606 }
607 };
608 let common_dir = match fs::read_to_string(git_dir.join("commondir")) {
609 Ok(text) => {
610 let t = text.trim();
611 let p = Path::new(t);
612 if p.is_absolute() {
613 p.to_path_buf()
614 } else {
615 git_dir.join(p)
616 }
617 }
618 Err(_) => git_dir.clone(),
619 };
620 return Some(Repo {
621 git_dir,
622 common_dir,
623 work_dir: cur,
624 });
625 }
626 if !cur.pop() {
627 return None;
628 }
629 }
630}
631
632fn mtime_of(p: &Path) -> Option<SystemTime> {
633 fs::metadata(p).ok().and_then(|m| m.modified().ok())
634}
635
636fn read_head(repo: &Repo, status: &mut GitStatus) {
645 let head = match fs::read_to_string(repo.git_dir.join("HEAD")) {
646 Ok(s) => s,
647 Err(e) => {
648 tracing::warn!("p10k git: unreadable HEAD in {:?}: {e}", repo.git_dir);
649 return;
650 }
651 };
652 let head = head.trim();
653 if let Some(refname) = head.strip_prefix("ref: ") {
654 let refname = refname.trim();
655 status.branch = refname
656 .strip_prefix("refs/heads/")
657 .unwrap_or(refname)
658 .to_string();
659 if let Some(oid) = resolve_ref(&repo.common_dir, refname) {
660 status.commit = oid;
661 }
662 } else if head.len() >= 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
663 status.commit = head.to_string(); }
665}
666
667fn resolve_ref(common_dir: &Path, refname: &str) -> Option<String> {
671 if let Ok(s) = fs::read_to_string(common_dir.join(refname)) {
672 let s = s.trim();
673 if !s.is_empty() {
674 return Some(s.to_string());
675 }
676 }
677 let packed = fs::read_to_string(common_dir.join("packed-refs")).ok()?;
678 for line in packed.lines() {
679 if line.starts_with('#') || line.starts_with('^') {
680 continue;
681 }
682 if let Some((oid, name)) = line.split_once(' ') {
683 if name == refname {
684 return Some(oid.to_string());
685 }
686 }
687 }
688 None
689}
690
691fn repo_action(git_dir: &Path) -> String {
703 let exists = |name: &str| git_dir.join(name).exists();
704 let read1 = |name: &str| -> String {
705 fs::read_to_string(git_dir.join(name))
707 .ok()
708 .and_then(|s| s.split_whitespace().next().map(str::to_string))
709 .unwrap_or_default()
710 };
711
712 let state: &str = if exists("rebase-merge") {
713 if exists("rebase-merge/interactive") {
714 "rebase-i" } else {
716 "rebase-m" }
718 } else if exists("rebase-apply") {
719 if exists("rebase-apply/rebasing") {
720 "rebase" } else if exists("rebase-apply/applying") {
722 "am" } else {
724 "am/rebase" }
726 } else if exists("MERGE_HEAD") {
727 "merge" } else if exists("REVERT_HEAD") {
729 if exists("sequencer/todo") {
730 "revert-seq" } else {
732 "revert" }
734 } else if exists("CHERRY_PICK_HEAD") {
735 if exists("sequencer/todo") {
736 "cherry-seq" } else {
738 "cherry" }
740 } else if exists("BISECT_LOG") {
741 "bisect" } else {
743 "" };
745
746 let (next, last) = if exists("rebase-merge") {
748 (read1("rebase-merge/msgnum"), read1("rebase-merge/end"))
749 } else if exists("rebase-apply") {
750 (read1("rebase-apply/next"), read1("rebase-apply/last"))
751 } else {
752 (String::new(), String::new())
753 };
754
755 if !next.is_empty() && !last.is_empty() {
757 format!("{state} {next}/{last}")
758 } else {
759 state.to_string()
760 }
761}
762
763fn refspec_transform(spec: &str, refname: &str) -> Option<String> {
766 let spec = spec.strip_prefix('+').unwrap_or(spec);
767 let (src, dst) = spec.split_once(':')?;
768 match src.split_once('*') {
769 Some((pre, post)) => {
770 let middle = refname.strip_prefix(pre)?.strip_suffix(post)?;
771 let (dst_pre, dst_post) = dst.split_once('*')?;
772 Some(format!("{dst_pre}{middle}{dst_post}"))
773 }
774 None => (src == refname).then(|| dst.to_string()),
775 }
776}
777
778fn remote_tracking_ref(cfg: &GitConfig, remote: &str, refname: &str) -> Option<String> {
781 if !cfg.has_group("remote", remote) {
782 return None;
783 }
784 cfg.get_all("remote", remote, "fetch")
785 .iter()
786 .find_map(|spec| refspec_transform(spec, refname))
787}
788
789struct Tracking {
791 remote: String,
793 branch: String,
795 tip: String,
797}
798
799fn tracking_of(cfg: &GitConfig, common_dir: &Path, branch: &str) -> Option<Tracking> {
803 let remote = cfg.get("branch", branch, "remote").filter(|r| !r.is_empty())?;
804 let merge = cfg.get("branch", branch, "merge").filter(|m| !m.is_empty())?;
805 let refname = if remote == "." {
806 merge.to_string()
807 } else {
808 remote_tracking_ref(cfg, remote, merge)?
809 };
810 let tip = resolve_ref(common_dir, &refname)?;
811 let short = refname
812 .strip_prefix("refs/heads/")
813 .or_else(|| refname.strip_prefix("refs/remotes/"))
814 .unwrap_or(&refname);
815 let name = if remote == "." {
816 short
817 } else {
818 short.strip_prefix(remote).and_then(|s| s.strip_prefix('/')).unwrap_or(short)
819 };
820 Some(Tracking { remote: remote.to_string(), branch: name.to_string(), tip })
821}
822
823struct PushTarget {
825 remote: String,
826 url: String,
827 refname: String,
828 tip: String,
829}
830
831fn push_target_of(cfg: &GitConfig, common_dir: &Path, branch: &str) -> Option<PushTarget> {
835 let remote = cfg
836 .get("branch", branch, "pushremote")
837 .or_else(|| cfg.get("remote", "", "pushdefault"))
838 .filter(|r| !r.is_empty())?;
839 let local = format!("refs/heads/{branch}");
840 let refname = if remote == "." {
841 local
842 } else {
843 remote_tracking_ref(cfg, remote, &local)?
844 };
845 let tip = resolve_ref(common_dir, &refname)?;
846 let url = if remote == "." {
847 String::new()
848 } else {
849 cfg.get("remote", remote, "url").unwrap_or_default().to_string()
850 };
851 Some(PushTarget { remote: remote.to_string(), url, refname, tip })
852}
853
854fn rev_list_counts(common_dir: &Path, head: &str, other: &str) -> Option<(i64, i64)> {
857 let out = Command::new("git")
858 .arg(format!("--git-dir={}", common_dir.display()))
859 .args(["rev-list", "--left-right", "--count"])
860 .arg(format!("{head}...{other}"))
861 .env("GIT_OPTIONAL_LOCKS", "0")
862 .stdin(Stdio::null())
863 .stderr(Stdio::null())
864 .output()
865 .ok()?;
866 let text = String::from_utf8_lossy(&out.stdout);
867 let mut it = text.split_whitespace().map(|n| n.parse::<i64>());
868 match (it.next(), it.next()) {
869 (Some(Ok(a)), Some(Ok(b))) => Some((a, b)),
870 _ => None,
871 }
872}
873
874#[derive(Clone, Copy)]
878struct RepoCaps {
879 trust_filemode: bool, has_symlinks: bool, hash_len: usize, }
883
884impl RepoCaps {
885 fn from_config(cfg: &GitConfig) -> RepoCaps {
886 RepoCaps {
887 trust_filemode: cfg.get_bool("core", "filemode", true),
888 has_symlinks: cfg.get_bool("core", "symlinks", true),
889 hash_len: match cfg.get("extensions", "", "objectformat") {
890 Some("sha256") => 32,
891 _ => 20,
892 },
893 }
894 }
895}
896
897struct IndexCounts {
902 unstaged: i64,
903 conflicted: i64,
904 skip_worktree: i64,
907 assume_unchanged: i64,
908}
909
910fn index_entry_count(git_dir: &Path) -> i64 {
913 use std::io::Read as _;
914 let mut head = [0u8; 12];
915 let read = fs::File::open(git_dir.join("index")).and_then(|mut f| f.read_exact(&mut head));
916 match read {
917 Ok(()) if &head[0..4] == b"DIRC" => i64::from(u32::from_be_bytes([head[8], head[9], head[10], head[11]])),
918 _ => 0,
919 }
920}
921
922fn be32(data: &[u8], pos: usize) -> Option<u32> {
923 data.get(pos..pos + 4)
924 .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
925}
926
927fn be16(data: &[u8], pos: usize) -> Option<u16> {
928 data.get(pos..pos + 2)
929 .map(|b| u16::from_be_bytes([b[0], b[1]]))
930}
931
932fn decode_varint(data: &[u8], pos: &mut usize) -> Option<u64> {
936 let mut c = *data.get(*pos)?;
937 *pos += 1;
938 let mut val = u64::from(c & 0x7f);
939 let mut rounds = 0;
940 while c & 0x80 != 0 {
941 rounds += 1;
942 if rounds > 9 {
943 return None; }
945 c = *data.get(*pos)?;
946 *pos += 1;
947 val = val.checked_add(1)?.checked_shl(7)? | u64::from(c & 0x7f);
948 }
949 Some(val)
950}
951
952#[derive(Clone, Copy)]
966struct EntryStat {
967 mode: u32,
968 ino: u32,
969 size: u32,
970 mt_s: u32,
971 mt_ns: u32,
972}
973
974const S_IFMT: u32 = 0o170000;
975const S_IFREG: u32 = 0o100000;
976const S_IFLNK: u32 = 0o120000;
977const S_IFDIR: u32 = 0o040000;
978
979fn is_modified(e: &EntryStat, st: &fs::Metadata, caps: &RepoCaps) -> bool {
984 use std::os::unix::fs::MetadataExt;
985 let st_mode = st.mode();
987 let mode = if st_mode & S_IFMT == S_IFREG {
988 if !caps.has_symlinks && e.mode & S_IFMT == S_IFLNK {
989 e.mode
990 } else if !caps.trust_filemode {
991 e.mode
992 } else {
993 S_IFREG | if st_mode & 0o100 != 0 { 0o755 } else { 0o644 }
994 }
995 } else {
996 st_mode & S_IFMT
997 };
998 if e.ino != 0 && e.ino != st.ino() as u32 {
1001 return true;
1002 }
1003 if u64::from(e.size) != st.size() {
1010 return true;
1011 }
1012 if e.mt_s as i64 != st.mtime() || !(e.mt_ns as i64 == st.mtime_nsec() || e.mt_ns == 0) {
1017 return true;
1018 }
1019 if e.mode != mode {
1021 return true;
1022 }
1023 false
1024}
1025
1026fn count_modified(entries: &[(EntryStat, PathBuf)], caps: &RepoCaps) -> i64 {
1035 fn scan(chunk: &[(EntryStat, PathBuf)], caps: &RepoCaps) -> i64 {
1036 chunk
1037 .iter()
1038 .filter(|(e, path)| match fs::symlink_metadata(path) {
1039 Err(_) => true,
1042 Ok(st) => is_modified(e, &st, caps),
1043 })
1044 .count() as i64
1045 }
1046
1047 let nthreads = std::thread::available_parallelism()
1048 .map(|n| n.get())
1049 .unwrap_or(1)
1050 .min(MAX_STAT_THREADS);
1051 if entries.len() < PAR_STAT_MIN || nthreads < 2 {
1052 return scan(entries, caps);
1053 }
1054 let caps = *caps;
1055 let chunk_len = entries.len().div_ceil(nthreads);
1056 std::thread::scope(|s| {
1057 let handles: Vec<_> = entries
1058 .chunks(chunk_len)
1059 .map(|c| s.spawn(move || scan(c, &caps)))
1060 .collect();
1061 handles.into_iter().map(|h| h.join().unwrap_or(0)).sum()
1062 })
1063}
1064
1065fn read_index_counts(repo: &Repo, caps: &RepoCaps) -> Option<IndexCounts> {
1074 let data = fs::read(repo.git_dir.join("index")).ok()?;
1075 if data.len() < 12 + caps.hash_len || &data[0..4] != b"DIRC" {
1078 tracing::debug!(
1079 "p10k git: index missing/short/bad magic in {:?}",
1080 repo.git_dir
1081 );
1082 return None;
1083 }
1084 let version = be32(&data, 4)?;
1085 if !(2..=4).contains(&version) {
1086 tracing::debug!("p10k git: unsupported index version {version}");
1087 return None;
1088 }
1089 let nentries = be32(&data, 8)? as usize;
1090 if nentries > NATIVE_SCAN_MAX {
1091 tracing::debug!(
1092 "p10k git: index has {nentries} entries > {NATIVE_SCAN_MAX}, deferring to subprocess"
1093 );
1094 return None;
1095 }
1096
1097 let mut pos = 12usize;
1098 let mut prev_path: Vec<u8> = Vec::new(); let mut last_conflict: Vec<u8> = Vec::new();
1100 let mut unstaged = 0i64;
1101 let mut conflicted = 0i64;
1102 let (mut skip_count, mut assume_count) = (0i64, 0i64);
1103 let mut to_stat: Vec<(EntryStat, PathBuf)> = Vec::with_capacity(nentries);
1106
1107 for _ in 0..nentries {
1108 let start = pos;
1109 if pos + 40 + caps.hash_len + 2 > data.len() {
1111 return None;
1112 }
1113 let e = EntryStat {
1114 mt_s: be32(&data, pos + 8)?,
1115 mt_ns: be32(&data, pos + 12)?,
1116 ino: be32(&data, pos + 20)?,
1117 mode: be32(&data, pos + 24)?,
1118 size: be32(&data, pos + 36)?,
1119 };
1120 let flags = be16(&data, pos + 40 + caps.hash_len)?;
1121 pos += 40 + caps.hash_len + 2;
1122
1123 let assume_valid = flags & 0x8000 != 0;
1124 let extended = flags & 0x4000 != 0;
1125 let stage = (flags >> 12) & 0x3;
1126 let name_len = (flags & 0xFFF) as usize;
1127
1128 let mut skip_worktree = false;
1129 let mut intent_to_add = false;
1130 if extended {
1131 if version < 3 {
1132 return None; }
1134 let ext = be16(&data, pos)?;
1135 pos += 2;
1136 skip_worktree = ext & 0x4000 != 0;
1137 intent_to_add = ext & 0x2000 != 0;
1138 }
1139
1140 if version == 4 {
1142 let strip = decode_varint(&data, &mut pos)? as usize;
1143 if strip > prev_path.len() {
1144 return None;
1145 }
1146 let keep = prev_path.len() - strip;
1147 prev_path.truncate(keep);
1148 let nul = data.get(pos..)?.iter().position(|&b| b == 0)?;
1149 prev_path.extend_from_slice(&data[pos..pos + nul]);
1150 pos += nul + 1; } else {
1152 let actual_len = if name_len < 0xFFF {
1153 name_len
1154 } else {
1155 data.get(pos..)?.iter().position(|&b| b == 0)?
1156 };
1157 prev_path.clear();
1158 prev_path.extend_from_slice(data.get(pos..pos + actual_len)?);
1159 let fixed = pos - start;
1163 pos = start + ((fixed + actual_len + 8) & !7);
1164 if pos > data.len() {
1165 return None;
1166 }
1167 }
1168
1169 if stage != 0 {
1171 if prev_path != last_conflict {
1175 conflicted += 1;
1176 last_conflict.clear();
1177 last_conflict.extend_from_slice(&prev_path);
1178 }
1179 continue;
1180 }
1181 skip_count += i64::from(skip_worktree);
1182 assume_count += i64::from(assume_valid);
1183 if skip_worktree || assume_valid {
1184 continue;
1188 }
1189 if intent_to_add {
1190 unstaged += 1;
1192 continue;
1193 }
1194 if e.mode & S_IFMT == S_IFDIR {
1195 tracing::debug!(
1198 "p10k git: sparse index in {:?}, deferring to subprocess",
1199 repo.git_dir
1200 );
1201 return None;
1202 }
1203 to_stat.push((e, repo.work_dir.join(OsStr::from_bytes(&prev_path))));
1208 }
1209 unstaged += count_modified(&to_stat, caps);
1210 Some(IndexCounts {
1214 unstaged,
1215 conflicted,
1216 skip_worktree: skip_count,
1217 assume_unchanged: assume_count,
1218 })
1219}
1220
1221fn stash_count(common_dir: &Path) -> Option<i64> {
1231 match fs::read(common_dir.join("logs/refs/stash")) {
1232 Ok(bytes) => Some(bytes.iter().filter(|&&b| b == b'\n').count() as i64),
1233 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(0),
1234 Err(e) => {
1235 tracing::debug!("p10k git: stash reflog unreadable: {e}");
1236 None
1237 }
1238 }
1239}
1240
1241fn peel_objects<'a>(
1251 common_dir: &Path,
1252 oids: impl Iterator<Item = &'a str>,
1253) -> HashMap<String, String> {
1254 use std::io::Write;
1255 static MEMO: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
1256 let memo = MEMO.get_or_init(Default::default);
1257 let wanted: Vec<&str> = oids.collect();
1258 let unseen: Vec<&str> = {
1259 let m = memo.lock().unwrap();
1260 let mut u: Vec<&str> = wanted.iter().copied().filter(|o| !m.contains_key(*o)).collect();
1261 u.sort_unstable();
1262 u.dedup();
1263 u
1264 };
1265 if !unseen.is_empty() {
1266 let child = Command::new("git")
1267 .arg(format!("--git-dir={}", common_dir.display()))
1268 .args(["cat-file", "--batch-check=%(objectname)"])
1269 .env("GIT_OPTIONAL_LOCKS", "0")
1270 .stdin(Stdio::piped())
1271 .stdout(Stdio::piped())
1272 .stderr(Stdio::null())
1273 .spawn();
1274 let mut answers: Vec<String> = Vec::new();
1275 if let Ok(mut child) = child {
1276 if let Some(mut stdin) = child.stdin.take() {
1277 let input: String = unseen.iter().map(|o| format!("{o}^{{}}\n")).collect();
1278 let _ = stdin.write_all(input.as_bytes());
1279 }
1280 if let Ok(out) = child.wait_with_output() {
1281 answers = String::from_utf8_lossy(&out.stdout)
1282 .lines()
1283 .map(str::to_string)
1284 .collect();
1285 }
1286 }
1287 let mut m = memo.lock().unwrap();
1288 for (i, oid) in unseen.iter().enumerate() {
1289 let peeled = answers
1291 .get(i)
1292 .filter(|a| !a.contains(' '))
1293 .cloned()
1294 .unwrap_or_else(|| (*oid).to_string());
1295 m.insert((*oid).to_string(), peeled);
1296 }
1297 }
1298 let m = memo.lock().unwrap();
1299 wanted
1300 .iter()
1301 .filter_map(|o| m.get(*o).map(|p| ((*o).to_string(), p.clone())))
1302 .collect()
1303}
1304
1305fn tag_for_commit(common_dir: &Path, commit: &str) -> String {
1312 let mut best = String::new();
1313 let mut loose_names: Vec<String> = Vec::new();
1314 let mut loose_candidates: Vec<(String, String)> = Vec::new();
1315
1316 if let Ok(rd) = fs::read_dir(common_dir.join("refs/tags")) {
1320 for entry in rd.flatten() {
1321 let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
1322 if !is_file {
1323 continue;
1324 }
1325 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
1326 continue;
1327 };
1328 if let Ok(oid) = fs::read_to_string(entry.path()) {
1329 let oid = oid.trim();
1330 if oid == commit {
1332 if best < name {
1333 best = name.clone();
1334 }
1335 } else if !oid.is_empty() {
1336 loose_candidates.push((name.clone(), oid.to_string()));
1339 }
1340 }
1341 loose_names.push(name);
1342 }
1343 }
1344
1345 if !loose_candidates.is_empty() {
1348 let peeled = peel_objects(common_dir, loose_candidates.iter().map(|(_, o)| o.as_str()));
1349 for (name, oid) in &loose_candidates {
1350 if peeled.get(oid).is_some_and(|t| t == commit) && best < *name {
1351 best = name.clone();
1352 }
1353 }
1354 }
1355
1356 let Ok(packed) = fs::read_to_string(common_dir.join("packed-refs")) else {
1360 return best;
1361 };
1362 if !packed.starts_with('#') {
1363 tracing::warn!("p10k git: packed-refs doesn't have a header. Won't resolve packed tags.");
1364 return best;
1365 }
1366 if let Some(header) = packed.lines().next() {
1367 if !header.contains(" fully-peeled") || !header.contains(" sorted") {
1368 tracing::warn!("p10k git: packed-refs has unexpected header."); }
1370 }
1371 let mut lines = packed.lines().peekable();
1372 while let Some(line) = lines.next() {
1373 if line.starts_with('#') || line.starts_with('^') {
1374 continue;
1375 }
1376 let Some((oid, refname)) = line.split_once(' ') else {
1377 continue;
1378 };
1379 let effective = match lines.peek() {
1382 Some(peel) if peel.starts_with('^') => lines
1383 .next()
1384 .and_then(|p| p.strip_prefix('^'))
1385 .unwrap_or(oid),
1386 _ => oid,
1387 };
1388 let Some(name) = refname.strip_prefix("refs/tags/") else {
1389 continue;
1390 };
1391 if effective == commit
1392 && !loose_names.iter().any(|l| l == name) && best.as_str() < name
1394 {
1395 best = name.to_string();
1396 }
1397 }
1398 best
1399}
1400
1401const MAX_COMMIT_SUMMARY_LEN: usize = 256;
1408
1409fn commit_summary_of(message: &str) -> String {
1413 let msg = message.trim_start_matches('\n').as_bytes();
1414 let mut out: Vec<u8> = Vec::new();
1415 let mut space: Option<usize> = None;
1416 let mut space_has_newline = false;
1417 for (i, &c) in msg.iter().enumerate() {
1418 if c == b'\n' {
1419 let rest = &msg[i + 1..];
1422 let next_blank = rest
1423 .iter()
1424 .find(|&&b| b == b'\n' || !b.is_ascii_whitespace())
1425 .is_none_or(|&b| b == b'\n');
1426 if next_blank {
1427 break;
1428 }
1429 }
1430 if c.is_ascii_whitespace() {
1431 if space.is_none() {
1432 space = Some(i);
1433 space_has_newline = false;
1434 }
1435 space_has_newline |= c == b'\n';
1436 } else {
1437 if let Some(start) = space.take() {
1438 if space_has_newline {
1439 out.push(b' ');
1440 } else {
1441 out.extend_from_slice(&msg[start..i]);
1442 }
1443 }
1444 out.push(c);
1445 }
1446 }
1447 out.truncate(MAX_COMMIT_SUMMARY_LEN);
1448 String::from_utf8_lossy(&out).into_owned()
1449}
1450
1451pub fn commit_message(workdir: &Path, commit: &str) -> (String, String) {
1455 static MEMO: OnceLock<Mutex<HashMap<String, (String, String)>>> = OnceLock::new();
1456 let memo = MEMO.get_or_init(Default::default);
1457 if commit.is_empty() {
1458 return Default::default();
1459 }
1460 if let Some(hit) = memo.lock().ok().and_then(|m| m.get(commit).cloned()) {
1461 return hit;
1462 }
1463 let out = Command::new("git")
1464 .arg("-C")
1465 .arg(workdir)
1466 .args(["cat-file", "commit", commit])
1467 .env("GIT_OPTIONAL_LOCKS", "0")
1468 .stdin(Stdio::null())
1469 .stderr(Stdio::null())
1470 .output();
1471 let result = match out {
1472 Ok(o) if o.status.success() => {
1473 let raw = String::from_utf8_lossy(&o.stdout).into_owned();
1474 let (headers, message) = raw.split_once("\n\n").unwrap_or((raw.as_str(), ""));
1475 let encoding = headers
1476 .lines()
1477 .find_map(|l| l.strip_prefix("encoding "))
1478 .unwrap_or_default()
1479 .to_string();
1480 (encoding, commit_summary_of(message))
1481 }
1482 _ => Default::default(),
1483 };
1484 if let Ok(mut m) = memo.lock() {
1485 if m.len() > 256 {
1486 m.clear();
1487 }
1488 m.insert(commit.to_string(), result.clone());
1489 }
1490 result
1491}
1492
1493fn run_porcelain(work_dir: &Path, mode: UntrackedMode) -> Option<String> {
1503 let mut child = match Command::new("git")
1504 .arg("-C")
1505 .arg(work_dir)
1506 .args(["status", "--porcelain=v2", "--branch", "--show-stash"])
1507 .arg(mode.arg())
1508 .env("GIT_OPTIONAL_LOCKS", "0")
1511 .stdin(Stdio::null())
1512 .stdout(Stdio::piped())
1513 .stderr(Stdio::null())
1514 .spawn()
1515 {
1516 Ok(c) => c,
1517 Err(e) => {
1518 tracing::warn!("p10k git: failed to spawn git status: {e}");
1519 return None;
1520 }
1521 };
1522
1523 let mut stdout = child.stdout.take()?;
1528 let mut out = String::new();
1529 let read = stdout.read_to_string(&mut out);
1530 let _ = child.wait();
1531 match read {
1532 Ok(_) => Some(out),
1533 Err(e) => {
1534 tracing::warn!("p10k git: reading git status output failed: {e}");
1535 None
1536 }
1537 }
1538}
1539
1540fn parse_porcelain_v2(out: &str, status: &mut GitStatus) {
1554 for line in out.lines() {
1555 if let Some(rest) = line.strip_prefix("# ") {
1556 if let Some(oid) = rest.strip_prefix("branch.oid ") {
1557 if status.commit.is_empty() && oid != "(initial)" {
1558 status.commit = oid.to_string();
1559 }
1560 } else if let Some(head) = rest.strip_prefix("branch.head ") {
1561 if status.branch.is_empty() && head != "(detached)" {
1562 status.branch = head.to_string();
1563 }
1564 } else if let Some(up) = rest.strip_prefix("branch.upstream ") {
1565 status.remote_branch = up.to_string();
1566 } else if let Some(ab) = rest.strip_prefix("branch.ab ") {
1567 for tok in ab.split_whitespace() {
1568 if let Some(a) = tok.strip_prefix('+') {
1569 status.ahead = a.parse().unwrap_or(0);
1570 } else if let Some(b) = tok.strip_prefix('-') {
1571 status.behind = b.parse().unwrap_or(0);
1572 }
1573 }
1574 } else if let Some(n) = rest.strip_prefix("stash ") {
1575 status.stashes = n.trim().parse().unwrap_or(0);
1576 }
1577 } else if line.starts_with("1 ") || line.starts_with("2 ") {
1578 let bytes = line.as_bytes();
1580 if bytes.len() >= 4 {
1581 if bytes[2] != b'.' {
1582 status.staged += 1;
1583 }
1584 if bytes[3] != b'.' {
1585 status.unstaged += 1;
1586 }
1587 if bytes[2] == b'A' {
1589 status.staged_new += 1;
1590 }
1591 if bytes[2] == b'D' {
1592 status.staged_deleted += 1;
1593 }
1594 if bytes[3] == b'D' {
1595 status.unstaged_deleted += 1;
1596 }
1597 }
1598 } else if line.starts_with("u ") {
1599 status.conflicted += 1;
1600 } else if line.starts_with("? ") {
1601 status.untracked += 1;
1602 }
1603 }
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609 use super::*;
1610
1611 #[test]
1614 fn porcelain_headers_and_counts() {
1615 let out = "\
1616# branch.oid 47a386b42798b5ed5d89bc617920d9152479013e
1617# branch.head main
1618# branch.upstream origin/main
1619# branch.ab +3 -2
1620# stash 1
16211 .M N... 100644 100644 100644 aaaa bbbb src/lib.rs
16221 M. N... 100644 100644 100644 aaaa bbbb src/exec.rs
16231 MM N... 100644 100644 100644 aaaa bbbb src/hist.rs
16242 R. N... 100644 100644 100644 aaaa bbbb R100 new.rs\tnew_old.rs
1625u UU N... 100644 100644 100644 100644 aaaa bbbb cccc conflict.rs
1626? untracked_a.rs
1627? untracked_b.rs
1628";
1629 let mut s = GitStatus::default();
1630 parse_porcelain_v2(out, &mut s);
1631 assert_eq!(s.commit, "47a386b42798b5ed5d89bc617920d9152479013e");
1632 assert_eq!(s.branch, "main");
1633 assert_eq!(s.remote_branch, "origin/main");
1634 assert_eq!(s.ahead, 3);
1635 assert_eq!(s.behind, 2);
1636 assert_eq!(s.stashes, 1);
1637 assert_eq!(s.staged, 3);
1639 assert_eq!(s.unstaged, 2);
1640 assert_eq!(s.conflicted, 1);
1641 assert_eq!(s.untracked, 2);
1642 }
1643
1644 #[test]
1645 fn porcelain_detached_and_initial() {
1646 let out = "\
1647# branch.oid (initial)
1648# branch.head (detached)
1649";
1650 let mut s = GitStatus::default();
1651 parse_porcelain_v2(out, &mut s);
1652 assert_eq!(s.commit, "");
1653 assert_eq!(s.branch, "");
1654 assert_eq!(s.remote_branch, "");
1655 assert_eq!(s.ahead, 0);
1656 assert_eq!(s.behind, 0);
1657 assert_eq!(s.stashes, 0);
1658 }
1659
1660 #[test]
1661 fn porcelain_does_not_clobber_head_read() {
1662 let out = "\
1664# branch.oid ffffffffffffffffffffffffffffffffffffffff
1665# branch.head porcelain-branch
1666";
1667 let mut s = GitStatus {
1668 branch: "head-branch".into(),
1669 commit: "1111111111111111111111111111111111111111".into(),
1670 ..GitStatus::default()
1671 };
1672 parse_porcelain_v2(out, &mut s);
1673 assert_eq!(s.branch, "head-branch");
1674 assert_eq!(s.commit, "1111111111111111111111111111111111111111");
1675 }
1676
1677 #[test]
1678 fn porcelain_no_upstream_no_stash_line() {
1679 let out = "\
1680# branch.oid aaaa
1681# branch.head feature/x
16821 A. N... 000000 100644 100644 0000 aaaa new_file.rs
1683";
1684 let mut s = GitStatus::default();
1685 parse_porcelain_v2(out, &mut s);
1686 assert_eq!(s.branch, "feature/x");
1687 assert_eq!(s.remote_branch, "");
1688 assert_eq!(s.ahead, 0);
1689 assert_eq!(s.behind, 0);
1690 assert_eq!(s.staged, 1);
1691 assert_eq!(s.unstaged, 0);
1692 }
1693
1694 #[test]
1695 fn porcelain_ignores_ignored_entries_and_short_lines() {
1696 let out = "! ignored.log\n1\n\n? u.rs\n";
1697 let mut s = GitStatus::default();
1698 parse_porcelain_v2(out, &mut s);
1699 assert_eq!(s.staged, 0);
1700 assert_eq!(s.unstaged, 0);
1701 assert_eq!(s.untracked, 1);
1702 }
1703
1704 fn caps() -> RepoCaps {
1707 RepoCaps {
1708 trust_filemode: true,
1709 has_symlinks: true,
1710 hash_len: 20,
1711 }
1712 }
1713
1714 fn v2_entry(
1717 path: &[u8],
1718 mode: u32,
1719 ino: u32,
1720 size: u32,
1721 mt_s: u32,
1722 mt_ns: u32,
1723 stage: u16,
1724 ) -> Vec<u8> {
1725 let mut e = Vec::new();
1726 for v in [0u32, 0, mt_s, mt_ns, 0, ino, mode, 0, 0, size] {
1727 e.extend_from_slice(&v.to_be_bytes());
1728 }
1729 e.extend_from_slice(&[0u8; 20]); let flags: u16 = ((stage & 0x3) << 12) | (path.len().min(0xFFF) as u16);
1731 e.extend_from_slice(&flags.to_be_bytes());
1732 e.extend_from_slice(path);
1733 let total = (62 + path.len() + 8) & !7;
1734 e.resize(total, 0);
1735 e
1736 }
1737
1738 fn v2_index(entries: &[Vec<u8>]) -> Vec<u8> {
1739 let mut d = b"DIRC".to_vec();
1740 d.extend_from_slice(&2u32.to_be_bytes());
1741 d.extend_from_slice(&(entries.len() as u32).to_be_bytes());
1742 for e in entries {
1743 d.extend_from_slice(e);
1744 }
1745 d.extend_from_slice(&[0u8; 20]); d
1747 }
1748
1749 fn temp_repo(index: &[u8]) -> (tempfile::TempDir, Repo) {
1750 let td = tempfile::tempdir().expect("tempdir");
1751 let git_dir = td.path().join(".git");
1752 fs::create_dir_all(&git_dir).expect("mkdir .git");
1753 fs::write(git_dir.join("index"), index).expect("write index");
1754 let repo = Repo {
1755 common_dir: git_dir.clone(),
1756 git_dir,
1757 work_dir: td.path().to_path_buf(),
1758 };
1759 (td, repo)
1760 }
1761
1762 #[test]
1763 fn index_header_rejects_bad_magic_and_version() {
1764 let mut bad = b"XDIR".to_vec();
1766 bad.extend_from_slice(&2u32.to_be_bytes());
1767 bad.extend_from_slice(&0u32.to_be_bytes());
1768 bad.extend_from_slice(&[0u8; 20]);
1769 let (_td, repo) = temp_repo(&bad);
1770 assert!(read_index_counts(&repo, &caps()).is_none());
1771
1772 let mut v5 = b"DIRC".to_vec();
1774 v5.extend_from_slice(&5u32.to_be_bytes());
1775 v5.extend_from_slice(&0u32.to_be_bytes());
1776 v5.extend_from_slice(&[0u8; 20]);
1777 let (_td, repo) = temp_repo(&v5);
1778 assert!(read_index_counts(&repo, &caps()).is_none());
1779 }
1780
1781 #[test]
1782 fn index_v2_empty_and_conflicts() {
1783 let (_td, repo) = temp_repo(&v2_index(&[]));
1785 let ic = read_index_counts(&repo, &caps()).expect("empty index parses");
1786 assert_eq!(ic.unstaged, 0);
1787 assert_eq!(ic.conflicted, 0);
1788
1789 let e1 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 1);
1792 let e2 = v2_entry(b"c.rs", 0o100644, 1, 5, 1, 0, 2);
1793 let (_td, repo) = temp_repo(&v2_index(&[e1, e2]));
1794 let ic = read_index_counts(&repo, &caps()).expect("parses");
1795 assert_eq!(ic.conflicted, 1);
1796 assert_eq!(ic.unstaged, 0);
1797 }
1798
1799 #[test]
1800 fn index_v2_lstat_clean_deleted_modified() {
1801 use std::os::unix::fs::MetadataExt;
1802 let td = tempfile::tempdir().expect("tempdir");
1803 let f = td.path().join("a.rs");
1804 fs::write(&f, b"x").expect("write file");
1805 let st = fs::symlink_metadata(&f).expect("stat");
1806 let mode = S_IFREG | if st.mode() & 0o100 != 0 { 0o755 } else { 0o644 };
1807
1808 let clean = v2_entry(
1810 b"a.rs",
1811 mode,
1812 st.ino() as u32,
1813 st.size() as u32,
1814 st.mtime() as u32,
1815 st.mtime_nsec() as u32,
1816 0,
1817 );
1818 let dirty = v2_entry(
1820 b"b.rs",
1821 mode,
1822 0,
1823 st.size() as u32 + 7,
1824 st.mtime() as u32,
1825 0,
1826 0,
1827 );
1828 fs::write(td.path().join("b.rs"), b"x").expect("write b");
1829 let gone = v2_entry(b"gone.rs", mode, 0, 1, 1, 0, 0);
1831
1832 let git_dir = td.path().join(".git");
1833 fs::create_dir_all(&git_dir).expect("mkdir");
1834 fs::write(git_dir.join("index"), v2_index(&[clean, dirty, gone])).expect("write index");
1835 let repo = Repo {
1836 common_dir: git_dir.clone(),
1837 git_dir,
1838 work_dir: td.path().to_path_buf(),
1839 };
1840 let ic = read_index_counts(&repo, &caps()).expect("parses");
1841 assert_eq!(ic.unstaged, 2); assert_eq!(ic.conflicted, 0);
1843 }
1844
1845 #[test]
1846 fn varint_offset_encoding() {
1847 let mut p = 0;
1849 assert_eq!(decode_varint(&[0x7f], &mut p), Some(127));
1850 let mut p = 0;
1851 assert_eq!(decode_varint(&[0x80, 0x01], &mut p), Some(129));
1852 assert_eq!(p, 2);
1853 let mut p = 0;
1855 assert_eq!(decode_varint(&[0x80], &mut p), None);
1856 }
1857
1858 const C: &str = "47a386b42798b5ed5d89bc617920d9152479013e";
1861
1862 #[test]
1863 fn packed_refs_tag_peel_and_max_name() {
1864 let td = tempfile::tempdir().expect("tempdir");
1865 let packed = format!(
1868 "# pack-refs with: peeled fully-peeled sorted \n\
1869 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.5.0\n\
1870 {C} refs/tags/v0.9.0\n\
1871 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/tags/v1.0.0\n\
1872 ^{C}\n"
1873 );
1874 fs::write(td.path().join("packed-refs"), packed).expect("write");
1875 assert_eq!(tag_for_commit(td.path(), C), "v1.0.0");
1877
1878 let tags = td.path().join("refs/tags");
1881 fs::create_dir_all(&tags).expect("mkdir");
1882 fs::write(tags.join("v2.0"), format!("{C}\n")).expect("write");
1883 assert_eq!(tag_for_commit(td.path(), C), "v2.0");
1884 }
1885
1886 #[test]
1887 fn packed_refs_without_header_drops_packed_tags() {
1888 let td = tempfile::tempdir().expect("tempdir");
1890 fs::write(
1891 td.path().join("packed-refs"),
1892 format!("{C} refs/tags/v9.9.9\n"),
1893 )
1894 .expect("write");
1895 assert_eq!(tag_for_commit(td.path(), C), "");
1896 }
1897
1898 #[test]
1899 fn stash_log_count() {
1900 let td = tempfile::tempdir().expect("tempdir");
1901 assert_eq!(stash_count(td.path()), Some(0)); let logs = td.path().join("logs/refs");
1903 fs::create_dir_all(&logs).expect("mkdir");
1904 fs::write(
1905 logs.join("stash"),
1906 "0000 1111 user <u@example.com> 1 +0000\tWIP one\n\
1907 1111 2222 user <u@example.com> 2 +0000\tWIP two\n",
1908 )
1909 .expect("write");
1910 assert_eq!(stash_count(td.path()), Some(2));
1911 }
1912
1913 #[test]
1914 fn config_caps_and_tracking_through_fetch_refspecs() {
1915 let td = tempfile::tempdir().expect("tempdir");
1916 let common = td.path();
1917 let ref_file = |name: &str, oid: &str| {
1918 let p = common.join(name);
1919 fs::create_dir_all(p.parent().unwrap()).unwrap();
1920 fs::write(p, format!("{oid}\n")).unwrap();
1921 };
1922 ref_file("refs/remotes/origin/feature/x", &"a".repeat(40));
1923 ref_file("refs/remotes/mirror/feature/x", &"b".repeat(40));
1924 ref_file("refs/heads/main", &"c".repeat(40));
1925 let cfg = GitConfig::parse(
1926 "[core]\n\
1927 \tfilemode = false\n\
1928 \tsymlinks = true\n\
1929 [extensions]\n\
1930 \tobjectformat = sha256\n\
1931 [remote \"origin\"]\n\
1932 \turl = git@example.com:o.git\n\
1933 \tfetch = +refs/heads/*:refs/remotes/origin/*\n\
1934 [remote \"mirror\"]\n\
1935 \turl = git@example.com:m.git\n\
1936 \tfetch = +refs/heads/feature/*:refs/remotes/mirror/feature/*\n\
1937 [remote \"nofetch\"]\n\
1938 \turl = x\n\
1939 [remote]\n\
1940 \tpushDefault = mirror\n\
1941 [branch \"feature/x\"]\n\
1942 \tremote = origin\n\
1943 \tmerge = refs/heads/feature/x\n\
1944 [branch \"local\"]\n\
1945 \tremote = .\n\
1946 \tmerge = refs/heads/main\n\
1947 [branch \"gone\"]\n\
1948 \tremote = origin\n\
1949 \tmerge = refs/heads/gone\n\
1950 [branch \"nofetch\"]\n\
1951 \tremote = nofetch\n\
1952 \tmerge = refs/heads/nofetch\n\
1953 [branch \"pushed\"]\n\
1954 \tremote = origin\n\
1955 \tmerge = refs/heads/pushed\n\
1956 \tpushRemote = origin\n",
1957 );
1958 let t = tracking_of(&cfg, common, "feature/x").expect("tracking");
1959 assert_eq!((t.remote.as_str(), t.branch.as_str()), ("origin", "feature/x"));
1960 assert_eq!(t.tip, "a".repeat(40));
1961 let l = tracking_of(&cfg, common, "local").expect("local upstream");
1962 assert_eq!((l.remote.as_str(), l.branch.as_str()), (".", "main"));
1963 assert!(tracking_of(&cfg, common, "gone").is_none());
1965 assert!(tracking_of(&cfg, common, "nofetch").is_none());
1966 assert!(tracking_of(&cfg, common, "nope").is_none());
1967
1968 let p = push_target_of(&cfg, common, "feature/x").expect("push target");
1971 assert_eq!(p.remote, "mirror");
1972 assert_eq!(p.url, "git@example.com:m.git");
1973 assert_eq!(p.refname, "refs/remotes/mirror/feature/x");
1974 assert_eq!(p.tip, "b".repeat(40));
1975 assert!(push_target_of(&cfg, common, "other").is_none());
1977 assert!(push_target_of(&cfg, common, "pushed").is_none());
1979
1980 let caps = RepoCaps::from_config(&cfg);
1981 assert!(!caps.trust_filemode);
1982 assert!(caps.has_symlinks);
1983 assert_eq!(caps.hash_len, 32);
1984 }
1985
1986 #[test]
1987 fn refspec_transform_handles_globs_and_exact_names() {
1988 assert_eq!(
1989 refspec_transform("+refs/heads/*:refs/remotes/o/*", "refs/heads/a/b").as_deref(),
1990 Some("refs/remotes/o/a/b")
1991 );
1992 assert_eq!(
1993 refspec_transform("refs/heads/main:refs/remotes/o/trunk", "refs/heads/main").as_deref(),
1994 Some("refs/remotes/o/trunk")
1995 );
1996 assert_eq!(refspec_transform("refs/heads/main:x", "refs/heads/other"), None);
1997 assert_eq!(refspec_transform("refs/tags/*:refs/tags/*", "refs/heads/a"), None);
1998 }
1999
2000 #[test]
2001 fn limits_zero_oversized_index_and_clamp_counts() {
2002 let mut s = GitStatus {
2003 staged: 5,
2004 unstaged: 4,
2005 untracked: 9,
2006 conflicted: 3,
2007 staged_new: 4,
2008 staged_deleted: 2,
2009 unstaged_deleted: 4,
2010 ..Default::default()
2011 };
2012 GitLimits::default().apply(&mut s, 100);
2014 assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (1, 1, 1, 1));
2015 assert_eq!((s.staged_new, s.staged_deleted, s.unstaged_deleted), (1, 1, 1));
2016 assert!(!s.dirty_unknown);
2017
2018 let mut s = GitStatus { staged: 5, unstaged: 4, untracked: 9, conflicted: 3, ..Default::default() };
2019 let lim = GitLimits {
2020 max_staged: -1,
2021 max_unstaged: 3,
2022 max_untracked: -1,
2023 max_conflicted: 0,
2024 dirty_max_index_size: 50,
2025 recurse_untracked_dirs: false,
2026 };
2027 lim.apply(&mut s, 49);
2028 assert_eq!((s.staged, s.unstaged, s.untracked, s.conflicted), (5, 3, 9, 0));
2029 let mut s = GitStatus { staged: 5, unstaged: 4, untracked: 9, conflicted: 0, ..Default::default() };
2031 lim.apply(&mut s, 51);
2032 assert_eq!((s.staged, s.unstaged, s.untracked), (5, 0, 0));
2033 assert!(s.dirty_unknown);
2034 }
2035
2036 #[test]
2037 fn porcelain_classifies_new_and_deleted() {
2038 let mut s = GitStatus::default();
2039 parse_porcelain_v2(
2040 "1 A. N... 000000 100644 100644 a b new.rs\n\
2041 1 D. N... 100644 000000 000000 a b gone.rs\n\
2042 1 .D N... 100644 100644 000000 a b removed.rs\n\
2043 1 MM N... 100644 100644 100644 a b both.rs\n",
2044 &mut s,
2045 );
2046 assert_eq!((s.staged, s.staged_new, s.staged_deleted), (3, 1, 1));
2047 assert_eq!((s.unstaged, s.unstaged_deleted), (2, 1));
2048 }
2049 #[test]
2050 fn commit_summary_is_the_collapsed_first_paragraph() {
2051 assert_eq!(commit_summary_of("fix the thing\n\nlong body\n"), "fix the thing");
2052 assert_eq!(commit_summary_of("\n\nleading blanks\n"), "leading blanks");
2053 assert_eq!(
2054 commit_summary_of("wrapped\nsubject line\nthird\n\nbody"),
2055 "wrapped subject line third"
2056 );
2057 assert_eq!(commit_summary_of("a b \n \nbody"), "a b");
2059 assert_eq!(commit_summary_of(&"x".repeat(400)).len(), MAX_COMMIT_SUMMARY_LEN);
2060 assert_eq!(commit_summary_of(""), "");
2061 }
2062}