1#![forbid(unsafe_code)]
15#![deny(missing_docs)]
16#![warn(clippy::pedantic)]
17
18#[cfg(feature = "process")]
19use std::ffi::OsString;
20#[cfg(feature = "process")]
21use std::path::Path;
22#[cfg(feature = "process")]
23use std::process::Command;
24#[cfg(feature = "process")]
25use std::sync::OnceLock;
26
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum Change {
30 Added,
32 Modified,
34 Deleted,
36 Renamed,
38 Untracked,
40 Conflicted,
42}
43
44impl Change {
45 #[must_use]
47 pub fn letter(self) -> char {
48 match self {
49 Change::Added => 'A',
50 Change::Modified => 'M',
51 Change::Deleted => 'D',
52 Change::Renamed => 'R',
53 Change::Untracked => '?',
54 Change::Conflicted => 'U',
55 }
56 }
57
58 fn from_code(c: char) -> Option<Change> {
59 match c {
60 'A' => Some(Change::Added),
61 'M' | 'C' => Some(Change::Modified),
63 'D' => Some(Change::Deleted),
64 'R' => Some(Change::Renamed),
65 '?' => Some(Change::Untracked),
66 'U' => Some(Change::Conflicted),
67 _ => None,
68 }
69 }
70}
71
72#[derive(Clone, PartialEq, Eq, Debug)]
75pub struct FileStatus {
76 pub path: String,
78 pub staged: Option<Change>,
80 pub unstaged: Option<Change>,
82}
83
84impl FileStatus {
85 #[must_use]
87 pub fn is_staged(&self) -> bool {
88 self.staged.is_some()
89 }
90
91 #[must_use]
94 pub fn primary(&self) -> Option<Change> {
95 self.staged.or(self.unstaged)
96 }
97}
98
99#[must_use]
105pub fn parse_status(output: &str) -> Vec<FileStatus> {
106 let mut out = Vec::new();
107 for line in output.lines() {
108 if line.len() < 3 {
109 continue;
110 }
111 let bytes = line.as_bytes();
112 let x = bytes[0] as char;
113 let y = bytes[1] as char;
114 let rest = line[3..].trim_end();
115 let path = match rest.rsplit_once(" -> ") {
117 Some((_, new)) => new.to_string(),
118 None => rest.to_string(),
119 };
120 if path.is_empty() {
121 continue;
122 }
123 let (staged, unstaged) = if x == '?' && y == '?' {
124 (None, Some(Change::Untracked))
125 } else {
126 (Change::from_code(x), Change::from_code(y))
127 };
128 out.push(FileStatus {
129 path,
130 staged,
131 unstaged,
132 });
133 }
134 out
135}
136
137#[derive(Clone, Copy, PartialEq, Eq, Debug)]
139pub enum LineMark {
140 Added,
142 Modified,
144 Deleted,
146}
147
148const GUTTER_DIFF_MAX_LINES: usize = 50_000;
150
151const GUTTER_DIFF_DEADLINE: std::time::Duration = std::time::Duration::from_millis(40);
154
155#[must_use]
162pub fn diff_marks(head: &str, current: &str) -> Vec<(usize, LineMark)> {
163 use similar::{Algorithm, DiffOp, TextDiff};
164
165 let current_lines = current.lines().count();
166 if current_lines.max(head.lines().count()) > GUTTER_DIFF_MAX_LINES {
170 return Vec::new();
171 }
172 let diff = TextDiff::configure()
173 .algorithm(Algorithm::Myers)
174 .timeout(GUTTER_DIFF_DEADLINE)
175 .diff_lines(head, current);
176 let mut marks = Vec::new();
177 for op in diff.ops() {
178 match *op {
179 DiffOp::Insert {
180 new_index, new_len, ..
181 } => {
182 for i in new_index..new_index + new_len {
183 marks.push((i, LineMark::Added));
184 }
185 }
186 DiffOp::Replace {
187 new_index, new_len, ..
188 } => {
189 for i in new_index..new_index + new_len {
190 marks.push((i, LineMark::Modified));
191 }
192 }
193 DiffOp::Delete { new_index, .. } => {
194 let at = new_index.min(current_lines.saturating_sub(1));
195 marks.push((at, LineMark::Deleted));
196 }
197 DiffOp::Equal { .. } => {}
198 }
199 }
200 marks
201}
202
203#[derive(Clone, PartialEq, Eq, Debug)]
206pub struct Hunk {
207 pub current_start: usize,
209 pub current_end: usize,
212 pub head_start: usize,
215 pub head_text: String,
218}
219
220impl Hunk {
221 #[must_use]
224 pub fn contains(&self, line: usize) -> bool {
225 if self.current_start == self.current_end {
226 line == self.current_start
227 } else {
228 line >= self.current_start && line < self.current_end
229 }
230 }
231}
232
233#[must_use]
237pub fn hunks(head: &str, current: &str) -> Vec<Hunk> {
238 use similar::{Algorithm, DiffOp, TextDiff};
239
240 let diff = TextDiff::configure()
241 .algorithm(Algorithm::Myers)
242 .diff_lines(head, current);
243 let head_lines: Vec<&str> = head.split_inclusive('\n').collect();
244 let mut out: Vec<Hunk> = Vec::new();
245 let mut run: Option<(usize, usize, usize, usize)> = None;
247
248 let flush = |run: &mut Option<(usize, usize, usize, usize)>, out: &mut Vec<Hunk>| {
249 if let Some((nlo, nhi, olo, ohi)) = run.take() {
250 let head_text = head_lines
251 .get(olo..ohi)
252 .map(<[&str]>::concat)
253 .unwrap_or_default();
254 out.push(Hunk {
255 current_start: nlo,
256 current_end: nhi,
257 head_start: olo,
258 head_text,
259 });
260 }
261 };
262
263 for op in diff.ops() {
264 let (ni, nl, oi, ol) = match *op {
265 DiffOp::Equal { .. } => {
266 flush(&mut run, &mut out);
267 continue;
268 }
269 DiffOp::Insert {
270 new_index,
271 new_len,
272 old_index,
273 ..
274 } => (new_index, new_len, old_index, 0),
275 DiffOp::Delete {
276 new_index,
277 old_index,
278 old_len,
279 ..
280 } => (new_index, 0, old_index, old_len),
281 DiffOp::Replace {
282 new_index,
283 new_len,
284 old_index,
285 old_len,
286 ..
287 } => (new_index, new_len, old_index, old_len),
288 };
289 run = Some(match run {
290 Some((nlo, nhi, olo, ohi)) => {
291 (nlo.min(ni), nhi.max(ni + nl), olo.min(oi), ohi.max(oi + ol))
292 }
293 None => (ni, ni + nl, oi, oi + ol),
294 });
295 }
296 flush(&mut run, &mut out);
297 out
298}
299
300#[derive(Clone, PartialEq, Eq, Debug, Default)]
303pub struct BlameLine {
304 pub hash: String,
306 pub author: String,
308 pub date: String,
310 pub summary: String,
312}
313
314impl BlameLine {
315 #[must_use]
317 pub fn is_uncommitted(&self) -> bool {
318 self.hash.chars().all(|c| c == '0')
319 }
320}
321
322#[must_use]
325pub fn parse_blame_porcelain(output: &str) -> Option<BlameLine> {
326 let mut hash = None;
327 let mut author = String::new();
328 let mut summary = String::new();
329 let mut time: i64 = 0;
330 let mut tz: i32 = 0;
331 for line in output.lines() {
332 if hash.is_none() {
333 if let Some(h) = line.split(' ').next().filter(|h| h.len() == 40) {
335 hash = Some(h.chars().take(8).collect::<String>());
336 }
337 continue;
338 }
339 if let Some(name) = line.strip_prefix("author ") {
340 author = name.to_string();
341 } else if let Some(secs) = line.strip_prefix("author-time ") {
342 time = secs.trim().parse().unwrap_or(0);
343 } else if let Some(off) = line.strip_prefix("author-tz ") {
344 tz = parse_tz_offset(off.trim());
345 } else if let Some(s) = line.strip_prefix("summary ") {
346 summary = s.to_string();
347 }
348 }
349 let hash = hash?;
350 Some(BlameLine {
351 hash,
352 author,
353 date: epoch_to_date(time, tz),
354 summary,
355 })
356}
357
358fn parse_tz_offset(tz: &str) -> i32 {
360 let sign = if tz.starts_with('-') { -1 } else { 1 };
361 let digits: String = tz.chars().filter(char::is_ascii_digit).collect();
362 if digits.len() < 4 {
363 return 0;
364 }
365 let hours: i32 = digits[0..2].parse().unwrap_or(0);
366 let mins: i32 = digits[2..4].parse().unwrap_or(0);
367 sign * (hours * 3600 + mins * 60)
368}
369
370#[must_use]
377fn epoch_to_date(secs: i64, tz_offset: i32) -> String {
378 let days = (secs + i64::from(tz_offset)).div_euclid(86_400);
379 let (y, m, d) = vix_civil_date::civil_from_days(days);
380 format!("{y:04}-{m:02}-{d:02}")
381}
382
383#[cfg(feature = "process")]
384#[must_use]
387pub fn blame_line(dir: &Path, rel_path: &str, line: usize) -> Option<BlameLine> {
388 let spec = format!("{line},{line}");
389 let out = git_stdout(
390 dir,
391 &["blame", "--line-porcelain", "-L", &spec, "--", rel_path],
392 )?;
393 parse_blame_porcelain(&out)
394}
395
396#[cfg(feature = "process")]
399fn git_program() -> &'static OsString {
409 static PROG: OnceLock<OsString> = OnceLock::new();
410 PROG.get_or_init(|| which_on_path("git").unwrap_or_else(|| "git".into()))
411}
412
413#[cfg(feature = "process")]
414fn which_on_path(name: &str) -> Option<OsString> {
417 let exts: Vec<String> = if cfg!(windows) {
418 std::env::var("PATHEXT")
419 .unwrap_or_else(|_| ".EXE".into())
420 .split(';')
421 .map(str::to_string)
422 .collect()
423 } else {
424 vec![String::new()]
425 };
426 let path = std::env::var_os("PATH")?;
427 for dir in std::env::split_paths(&path) {
428 if dir.as_os_str().is_empty() {
429 continue; }
431 for ext in &exts {
432 let candidate = dir.join(format!("{name}{ext}"));
433 if candidate.is_file() {
434 return Some(candidate.into_os_string());
435 }
436 }
437 }
438 None
439}
440
441#[cfg(feature = "process")]
442fn valid_ref_name(name: &str) -> bool {
448 !name.is_empty()
449 && !name.starts_with('-')
450 && !name.chars().any(|c| c.is_whitespace() || c.is_control())
451}
452
453#[cfg(feature = "process")]
454fn git(dir: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
455 Command::new(git_program())
456 .current_dir(dir)
457 .args(args)
458 .output()
459}
460
461#[cfg(feature = "process")]
462fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
464 let out = git(dir, args).ok()?;
465 if out.status.success() {
466 Some(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
467 } else {
468 None
469 }
470}
471
472#[cfg(feature = "process")]
473#[must_use]
475pub fn is_repo(dir: &Path) -> bool {
476 git_stdout(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
477}
478
479#[cfg(feature = "process")]
480#[must_use]
483pub fn branch(dir: &Path) -> Option<String> {
484 let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
485 if name == "HEAD" {
486 git_stdout(dir, &["rev-parse", "--short", "HEAD"])
488 } else {
489 Some(name)
490 }
491}
492
493#[cfg(feature = "process")]
494#[must_use]
496pub fn is_dirty(dir: &Path) -> bool {
497 git_stdout(dir, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
498}
499
500#[cfg(feature = "process")]
501#[must_use]
503pub fn status(dir: &Path) -> Vec<FileStatus> {
504 git_stdout(dir, &["status", "--porcelain"])
505 .map(|s| parse_status(&s))
506 .unwrap_or_default()
507}
508
509#[cfg(feature = "process")]
510#[must_use]
513pub fn head_blob(dir: &Path, rel_path: &str) -> Option<String> {
514 let out = git(dir, &["show", &format!("HEAD:{rel_path}")]).ok()?;
517 out.status
518 .success()
519 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
520}
521
522#[cfg(feature = "process")]
523pub fn stage_all(dir: &Path) -> Result<(), String> {
529 let out = git(dir, &["add", "-A"]).map_err(|e| e.to_string())?;
530 if out.status.success() {
531 Ok(())
532 } else {
533 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
534 }
535}
536
537#[cfg(feature = "process")]
538pub fn stage(dir: &Path, rel_path: &str) -> Result<(), String> {
547 let out = git(dir, &["add", "--", rel_path]).map_err(|e| e.to_string())?;
548 if out.status.success() {
549 Ok(())
550 } else {
551 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
552 }
553}
554
555#[cfg(feature = "process")]
556#[must_use]
559pub fn index_blob(dir: &Path, rel_path: &str) -> Option<String> {
560 let out = git(dir, &["show", &format!(":{rel_path}")]).ok()?;
561 out.status
562 .success()
563 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
564}
565
566#[cfg(feature = "process")]
567pub fn stage_content(dir: &Path, rel_path: &str, content: &str) -> Result<(), String> {
574 let hash = git_stdin(
575 dir,
576 &["hash-object", "-w", "--path", rel_path, "--stdin"],
577 content,
578 )?;
579 let sha = hash.trim();
580 let mode = git_stdout(dir, &["ls-files", "-s", "--", rel_path])
583 .and_then(|s| s.split_whitespace().next().map(str::to_string))
584 .unwrap_or_else(|| "100644".to_string());
585 let info = format!("{mode},{sha},{rel_path}");
586 let out =
587 git(dir, &["update-index", "--add", "--cacheinfo", &info]).map_err(|e| e.to_string())?;
588 if out.status.success() {
589 Ok(())
590 } else {
591 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
592 }
593}
594
595#[cfg(feature = "process")]
596fn git_stdin(dir: &Path, args: &[&str], input: &str) -> Result<String, String> {
598 use std::io::Write;
599 use std::process::Stdio;
600 let mut child = Command::new(git_program())
601 .current_dir(dir)
602 .args(args)
603 .stdin(Stdio::piped())
604 .stdout(Stdio::piped())
605 .stderr(Stdio::piped())
606 .spawn()
607 .map_err(|e| e.to_string())?;
608 child
609 .stdin
610 .take()
611 .ok_or_else(|| "no stdin".to_string())?
612 .write_all(input.as_bytes())
613 .map_err(|e| e.to_string())?;
614 let out = child.wait_with_output().map_err(|e| e.to_string())?;
615 if out.status.success() {
616 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
617 } else {
618 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
619 }
620}
621
622#[cfg(feature = "process")]
623pub fn unstage(dir: &Path, rel_path: &str) -> Result<(), String> {
630 let out = git(dir, &["restore", "--staged", "--", rel_path]).map_err(|e| e.to_string())?;
631 if out.status.success() {
632 Ok(())
633 } else {
634 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
635 }
636}
637
638#[cfg(feature = "process")]
639#[must_use]
644pub fn staged_diff(dir: &Path) -> Option<String> {
645 git_stdout(dir, &["diff", "--staged"]).filter(|d| !d.is_empty())
646}
647
648#[cfg(feature = "process")]
649#[must_use]
652pub fn commit_count(dir: &Path) -> Option<u64> {
653 git_stdout(dir, &["rev-list", "--count", "HEAD"])?
654 .trim()
655 .parse()
656 .ok()
657}
658
659#[cfg(feature = "process")]
660#[must_use]
662pub fn local_branches(dir: &Path) -> Vec<String> {
663 let out = git_stdout(dir, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
664 let mut names: Vec<String> = out
665 .lines()
666 .map(str::trim)
667 .filter(|s| !s.is_empty())
668 .map(String::from)
669 .collect();
670 if let Some(cur) = branch(dir)
671 && let Some(pos) = names.iter().position(|n| *n == cur)
672 {
673 names.swap(0, pos);
674 }
675 names
676}
677
678#[cfg(feature = "process")]
679pub fn checkout(dir: &Path, branch: &str) -> Result<(), String> {
685 if !valid_ref_name(branch) {
686 return Err(format!("invalid branch name: {branch:?}"));
687 }
688 match git(dir, &["switch", "--end-of-options", branch]) {
691 Ok(o) if o.status.success() => Ok(()),
692 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
693 Err(e) => Err(e.to_string()),
694 }
695}
696
697#[cfg(feature = "process")]
698pub fn create_branch(dir: &Path, name: &str) -> Result<(), String> {
705 if !valid_ref_name(name) {
706 return Err(format!("invalid branch name: {name:?}"));
707 }
708 match git(dir, &["switch", "-c", name]) {
709 Ok(o) if o.status.success() => Ok(()),
710 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
711 Err(e) => Err(e.to_string()),
712 }
713}
714
715#[cfg(feature = "process")]
716pub fn commit(dir: &Path, message: &str) -> Result<(), String> {
722 run_ok(dir, &["commit", "-m", message])
723}
724
725#[must_use]
731pub fn pinentry_wants_tty(agent_conf: &str) -> bool {
732 agent_conf
733 .lines()
734 .filter_map(|l| {
735 let l = l.trim();
736 if l.starts_with('#') {
737 return None;
738 }
739 l.strip_prefix("pinentry-program")
740 .filter(|rest| rest.starts_with(char::is_whitespace))
741 .map(str::trim)
742 })
743 .next_back()
744 .is_some_and(|program| {
745 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
746 name.contains("curses") || name.contains("tty")
747 })
748}
749
750#[cfg(feature = "process")]
751#[must_use]
756pub fn commit_signing_needs_tty(dir: &Path) -> bool {
757 if git_stdout(dir, &["config", "--bool", "--get", "commit.gpgsign"]).as_deref() != Some("true")
758 {
759 return false;
760 }
761 if git_stdout(dir, &["config", "--get", "gpg.format"]).as_deref() == Some("ssh") {
762 return false;
763 }
764 let home = std::env::var_os("GNUPGHOME")
765 .map(std::path::PathBuf::from)
766 .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".gnupg")));
767 home.and_then(|h| std::fs::read_to_string(h.join("gpg-agent.conf")).ok())
768 .is_some_and(|conf| pinentry_wants_tty(&conf))
769}
770
771#[cfg(feature = "process")]
772pub fn commit_amend(dir: &Path) -> Result<(), String> {
778 run_ok(dir, &["commit", "--amend", "--no-edit"])
779}
780
781#[cfg(feature = "process")]
782pub fn stash_push(dir: &Path) -> Result<(), String> {
787 run_ok(dir, &["stash", "push"])
788}
789
790#[derive(Clone, Debug, PartialEq, Eq)]
794pub struct LogEntry {
795 pub sha: String,
797 pub abbrev: String,
799 pub author: String,
801 pub date: String,
803 pub subject: String,
805}
806
807#[derive(Clone, Debug, PartialEq, Eq)]
810pub enum LogScope {
811 Repo,
813 File(String),
817}
818
819pub struct LogPanel {
822 pub entries: Vec<LogEntry>,
824 pub scope: LogScope,
826 pub selected: usize,
828 pub scroll: usize,
830}
831
832impl LogPanel {
833 #[must_use]
835 pub fn new(entries: Vec<LogEntry>, scope: LogScope) -> Self {
836 LogPanel {
837 entries,
838 scope,
839 selected: 0,
840 scroll: 0,
841 }
842 }
843
844 pub fn up(&mut self) {
846 self.selected = vix_list_state::up(self.selected);
847 }
848
849 pub fn down(&mut self) {
851 self.selected = vix_list_state::down(self.selected, self.entries.len());
852 }
853
854 pub fn page_up(&mut self, page: usize) {
856 self.selected = vix_list_state::page_up(self.selected, page);
857 }
858
859 pub fn page_down(&mut self, page: usize) {
861 self.selected = vix_list_state::page_down(self.selected, page, self.entries.len());
862 }
863
864 pub fn ensure_visible(&mut self, height: usize) {
866 self.scroll =
867 vix_list_state::ensure_visible(self.selected, self.scroll, height, self.entries.len());
868 }
869
870 #[must_use]
872 pub fn selected_sha(&self) -> Option<&str> {
873 self.entries.get(self.selected).map(|e| e.sha.as_str())
874 }
875
876 #[must_use]
878 pub fn selected_abbrev(&self) -> Option<&str> {
879 self.entries.get(self.selected).map(|e| e.abbrev.as_str())
880 }
881}
882
883#[cfg(feature = "process")]
884const LOG_FIELD_SEP: &str = "\u{1f}";
888#[cfg(feature = "process")]
889const LOG_RECORD_SEP: &str = "\u{1e}";
890
891#[cfg(feature = "process")]
892fn parse_log(output: &str) -> Vec<LogEntry> {
896 output
897 .split(LOG_RECORD_SEP)
898 .filter_map(|record| {
899 let record = record.trim_start_matches('\n');
900 let mut fields = record.split(LOG_FIELD_SEP);
901 let sha = fields.next()?.to_string();
902 let abbrev = fields.next()?.to_string();
903 let author = fields.next()?.to_string();
904 let date = fields.next()?.to_string();
905 let subject = fields.next()?.to_string();
906 if sha.is_empty() {
907 return None;
908 }
909 Some(LogEntry {
910 sha,
911 abbrev,
912 author,
913 date,
914 subject,
915 })
916 })
917 .collect()
918}
919
920#[cfg(feature = "process")]
921fn log_format_arg() -> String {
924 format!(
925 "--format=%H{LOG_FIELD_SEP}%h{LOG_FIELD_SEP}%an{LOG_FIELD_SEP}%ad{LOG_FIELD_SEP}%s{LOG_RECORD_SEP}"
926 )
927}
928
929#[cfg(feature = "process")]
930#[must_use]
932pub fn log(dir: &Path, max: usize) -> Vec<LogEntry> {
933 let n = max.to_string();
934 let fmt = log_format_arg();
935 git_stdout(dir, &["log", &fmt, "--date=short", "-n", &n])
936 .map(|s| parse_log(&s))
937 .unwrap_or_default()
938}
939
940#[cfg(feature = "process")]
941#[must_use]
944pub fn file_log(dir: &Path, rel_path: &str, max: usize) -> Vec<LogEntry> {
945 let n = max.to_string();
946 let fmt = log_format_arg();
947 git_stdout(
948 dir,
949 &[
950 "log",
951 &fmt,
952 "--date=short",
953 "--follow",
954 "-n",
955 &n,
956 "--",
957 rel_path,
958 ],
959 )
960 .map(|s| parse_log(&s))
961 .unwrap_or_default()
962}
963
964#[cfg(feature = "process")]
965#[must_use]
969pub fn show_commit(dir: &Path, sha: &str, rel_path: Option<&str>) -> Option<String> {
970 if !valid_ref_name(sha) {
971 return None;
972 }
973 let mut args = vec!["show", "--end-of-options", sha];
976 if let Some(p) = rel_path {
977 args.push("--");
978 args.push(p);
979 }
980 git_stdout(dir, &args)
981}
982
983#[cfg(feature = "process")]
984#[must_use]
989pub fn show_file_at(dir: &Path, revision: &str, rel_path: &str) -> Option<String> {
990 if !valid_ref_name(revision) {
991 return None;
992 }
993 let out = git(
994 dir,
995 &[
996 "show",
997 "--end-of-options",
998 &format!("{revision}:{rel_path}"),
999 ],
1000 )
1001 .ok()?;
1002 out.status
1003 .success()
1004 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1005}
1006
1007#[cfg(feature = "process")]
1008#[must_use]
1012pub fn resolve_short_sha(dir: &Path, revision: &str) -> Option<String> {
1013 if !valid_ref_name(revision) {
1014 return None;
1015 }
1016 git_stdout(dir, &["rev-parse", "--short", "--end-of-options", revision])
1017}
1018
1019#[cfg(feature = "process")]
1020pub fn stash_pop(dir: &Path) -> Result<(), String> {
1025 run_ok(dir, &["stash", "pop"])
1026}
1027
1028#[cfg(feature = "process")]
1029fn run_ok(dir: &Path, args: &[&str]) -> Result<(), String> {
1031 match git(dir, args) {
1032 Ok(o) if o.status.success() => Ok(()),
1033 Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
1034 Err(e) => Err(e.to_string()),
1035 }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use super::*;
1041
1042 #[cfg(feature = "process")]
1043 #[test]
1044 fn valid_ref_name_rejects_option_shaped_and_control_input() {
1045 assert!(valid_ref_name("main"));
1046 assert!(valid_ref_name("feature/x-y"));
1047 assert!(!valid_ref_name("--detach"));
1049 assert!(!valid_ref_name("-oProxyCommand=x"));
1050 assert!(!valid_ref_name("--upload-pack=evil"));
1051 assert!(!valid_ref_name(""));
1053 assert!(!valid_ref_name("a b"));
1054 assert!(!valid_ref_name("a\nb"));
1055 }
1056
1057 #[cfg(feature = "process")]
1058 #[test]
1059 fn checkout_and_create_branch_refuse_dash_prefixed_names() {
1060 let dir = std::env::temp_dir();
1062 assert!(checkout(&dir, "--detach").is_err());
1063 assert!(create_branch(&dir, "-c").is_err());
1064 assert!(create_branch(&dir, "--orphan").is_err());
1065 }
1066
1067 #[cfg(feature = "process")]
1068 #[test]
1069 fn show_commit_and_show_file_at_refuse_option_shaped_revisions() {
1070 let dir = std::env::temp_dir();
1072 assert!(show_commit(&dir, "--upload-pack=evil", None).is_none());
1073 assert!(show_file_at(&dir, "--upload-pack=evil", "a.rs").is_none());
1074 assert!(resolve_short_sha(&dir, "--upload-pack=evil").is_none());
1075 }
1076
1077 #[cfg(feature = "process")]
1078 #[test]
1079 fn stage_and_unstage_report_the_real_git_error() {
1080 let dir = std::env::temp_dir().join(format!("vix-git-notrepo-{}", std::process::id()));
1086 let _ = std::fs::remove_dir_all(&dir);
1087 std::fs::create_dir_all(&dir).unwrap();
1088 let err = stage(&dir, "a.txt").expect_err("not a repo, so this must fail");
1089 assert!(!err.is_empty(), "a real message, not silently discarded");
1090 let err = unstage(&dir, "a.txt").expect_err("not a repo, so this must fail");
1091 assert!(!err.is_empty(), "a real message, not silently discarded");
1092 let _ = std::fs::remove_dir_all(&dir);
1093 }
1094
1095 #[cfg(feature = "process")]
1096 #[test]
1097 fn parse_log_splits_records_and_fields() {
1098 let output = format!(
1099 "abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}Ada Lovelace{LOG_FIELD_SEP}2026-01-02\
1100 {LOG_FIELD_SEP}Add the analytical engine{LOG_RECORD_SEP}\
1101 def456{LOG_FIELD_SEP}def{LOG_FIELD_SEP}Grace Hopper{LOG_FIELD_SEP}2026-01-01\
1102 {LOG_FIELD_SEP}Initial commit{LOG_RECORD_SEP}"
1103 );
1104 let entries = parse_log(&output);
1105 assert_eq!(entries.len(), 2);
1106 assert_eq!(entries[0].sha, "abc123");
1107 assert_eq!(entries[0].abbrev, "abc");
1108 assert_eq!(entries[0].author, "Ada Lovelace");
1109 assert_eq!(entries[0].date, "2026-01-02");
1110 assert_eq!(entries[0].subject, "Add the analytical engine");
1111 assert_eq!(entries[1].author, "Grace Hopper");
1112 }
1113
1114 #[cfg(feature = "process")]
1115 #[test]
1116 fn parse_log_skips_a_trailing_empty_record() {
1117 let output = format!(
1120 "abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}Ada Lovelace{LOG_FIELD_SEP}2026-01-02\
1121 {LOG_FIELD_SEP}subject{LOG_RECORD_SEP}"
1122 );
1123 assert_eq!(parse_log(&output).len(), 1);
1124 }
1125
1126 #[cfg(feature = "process")]
1127 #[test]
1128 fn parse_log_ignores_a_truncated_record() {
1129 let output = format!("abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}only three fields");
1130 assert_eq!(parse_log(&output), [] as [LogEntry; 0]);
1131 }
1132
1133 #[test]
1134 fn log_panel_navigation_reports_the_selected_hashes() {
1135 let mut p = LogPanel::new(
1136 vec![
1137 LogEntry {
1138 sha: "aaa111".into(),
1139 abbrev: "aaa".into(),
1140 author: "A".into(),
1141 date: "2026-01-01".into(),
1142 subject: "first".into(),
1143 },
1144 LogEntry {
1145 sha: "bbb222".into(),
1146 abbrev: "bbb".into(),
1147 author: "B".into(),
1148 date: "2026-01-02".into(),
1149 subject: "second".into(),
1150 },
1151 ],
1152 LogScope::Repo,
1153 );
1154 assert_eq!(p.selected_sha(), Some("aaa111"));
1155 assert_eq!(p.selected_abbrev(), Some("aaa"));
1156 p.down();
1157 assert_eq!(p.selected_sha(), Some("bbb222"));
1158 p.down(); assert_eq!(p.selected_sha(), Some("bbb222"));
1160 p.up();
1161 assert_eq!(p.selected_sha(), Some("aaa111"));
1162 }
1163
1164 #[cfg(feature = "process")]
1165 #[test]
1166 fn which_on_path_never_returns_a_relative_or_cwd_path() {
1167 if let Some(p) = which_on_path("git") {
1170 assert!(
1171 Path::new(&p).is_absolute(),
1172 "git resolved to non-absolute: {p:?}"
1173 );
1174 }
1175 }
1176
1177 #[test]
1178 fn hunks_groups_a_modified_line() {
1179 let h = hunks("a\nb\nc\n", "a\nB\nc\n");
1180 assert_eq!(h.len(), 1);
1181 assert_eq!(h[0].current_start, 1);
1182 assert_eq!(h[0].current_end, 2);
1183 assert_eq!(h[0].head_text, "b\n");
1184 assert!(h[0].contains(1));
1185 assert!(!h[0].contains(0));
1186 }
1187
1188 #[test]
1189 fn hunks_addition_has_empty_head_text() {
1190 let h = hunks("a\nc\n", "a\nb1\nb2\nc\n");
1191 assert_eq!(h.len(), 1);
1192 assert_eq!((h[0].current_start, h[0].current_end), (1, 3));
1193 assert_eq!(h[0].head_text, ""); }
1195
1196 #[test]
1197 fn hunks_deletion_anchors_a_zero_width_range() {
1198 let h = hunks("a\nb\nc\n", "a\nc\n");
1199 assert_eq!(h.len(), 1);
1200 assert_eq!(h[0].current_start, h[0].current_end); assert_eq!(h[0].head_text, "b\n"); assert!(h[0].contains(h[0].current_start));
1203 }
1204
1205 #[test]
1206 fn hunks_finds_multiple_regions() {
1207 let h = hunks("a\nb\nc\nd\ne\n", "A\nb\nc\nD\ne\n");
1208 assert_eq!(h.len(), 2);
1209 assert_eq!(h[0].current_start, 0);
1210 assert_eq!(h[1].current_start, 3);
1211 }
1212
1213 #[test]
1214 fn hunks_identical_text_is_empty() {
1215 assert_eq!(hunks("x\ny\n", "x\ny\n"), [] as [Hunk; 0]);
1216 }
1217
1218 #[test]
1219 fn parse_status_reads_xy_codes_and_paths() {
1220 let out = " M src/app.rs\nA src/new.rs\n?? notes.txt\nMM both.rs\nD gone.rs\n";
1221 let rows = parse_status(out);
1222 assert_eq!(rows.len(), 5);
1223
1224 assert_eq!(rows[0].path, "src/app.rs");
1225 assert_eq!(rows[0].staged, None);
1226 assert_eq!(rows[0].unstaged, Some(Change::Modified));
1227
1228 assert_eq!(rows[1].path, "src/new.rs");
1229 assert_eq!(rows[1].staged, Some(Change::Added));
1230 assert_eq!(rows[1].unstaged, None);
1231
1232 assert_eq!(rows[2].path, "notes.txt");
1233 assert_eq!(rows[2].primary(), Some(Change::Untracked));
1234
1235 assert_eq!(rows[3].staged, Some(Change::Modified));
1236 assert_eq!(rows[3].unstaged, Some(Change::Modified));
1237
1238 assert_eq!(rows[4].path, "gone.rs");
1239 assert_eq!(rows[4].staged, Some(Change::Deleted));
1240 }
1241
1242 #[test]
1243 fn parse_status_records_rename_destination() {
1244 let rows = parse_status("R old/name.rs -> new/name.rs\n");
1245 assert_eq!(rows.len(), 1);
1246 assert_eq!(rows[0].path, "new/name.rs");
1247 assert_eq!(rows[0].staged, Some(Change::Renamed));
1248 }
1249
1250 #[test]
1251 fn parse_status_skips_blank_and_short_lines() {
1252 assert_eq!(parse_status("\n\nx\n"), [] as [FileStatus; 0]);
1253 }
1254
1255 #[test]
1256 fn diff_marks_flags_added_modified_and_deleted() {
1257 let head = "alpha\nbeta\ngamma\n";
1258 let current = "alpha\nBETA\ngamma\ndelta\n";
1260 let marks = diff_marks(head, current);
1261 assert!(
1262 marks.contains(&(1, LineMark::Modified)),
1263 "line 2 modified: {marks:?}"
1264 );
1265 assert!(
1266 marks.contains(&(3, LineMark::Added)),
1267 "line 4 added: {marks:?}"
1268 );
1269 }
1270
1271 #[test]
1272 fn diff_marks_flags_deletion() {
1273 let head = "a\nb\nc\n";
1274 let current = "a\nc\n"; let marks = diff_marks(head, current);
1276 assert!(
1277 marks.iter().any(|&(_, m)| m == LineMark::Deleted),
1278 "a deletion is marked: {marks:?}"
1279 );
1280 }
1281
1282 #[test]
1283 fn diff_marks_skips_huge_buffers_and_finishes_fast_on_rewrites() {
1284 let big = "x\n".repeat(GUTTER_DIFF_MAX_LINES + 1);
1286 assert_eq!(diff_marks("", &big), vec![]);
1287 let head = (0..20_000).fold(String::new(), |a, i| a + &format!("a{i}\n"));
1290 let cur = (0..20_000).fold(String::new(), |a, i| {
1291 a + &format!(
1292 "b{}
1293",
1294 i * 7 % 20_000
1295 )
1296 });
1297 let start = std::time::Instant::now();
1298 let _ = diff_marks(&head, &cur);
1299 assert!(start.elapsed() < std::time::Duration::from_secs(2));
1300 }
1301
1302 #[test]
1303 fn pinentry_wants_tty_reads_the_last_program_line() {
1304 assert!(pinentry_wants_tty(
1305 "pinentry-program /usr/bin/pinentry-curses\n"
1306 ));
1307 assert!(pinentry_wants_tty(
1308 "pinentry-program /opt/homebrew/bin/pinentry-tty"
1309 ));
1310 assert!(!pinentry_wants_tty(
1311 "pinentry-program /usr/bin/pinentry-mac\n"
1312 ));
1313 assert!(!pinentry_wants_tty("default-cache-ttl 600\n"));
1314 assert!(!pinentry_wants_tty("# pinentry-program pinentry-curses\n"));
1316 assert!(!pinentry_wants_tty(
1317 "pinentry-program /usr/bin/pinentry-curses\npinentry-program /usr/bin/pinentry-gtk-2\n"
1318 ));
1319 assert!(pinentry_wants_tty(
1320 "pinentry-program /usr/bin/pinentry-gtk-2\npinentry-program /usr/bin/pinentry-curses\n"
1321 ));
1322 assert!(!pinentry_wants_tty(""));
1323 }
1324
1325 #[test]
1326 fn diff_marks_empty_when_identical() {
1327 assert_eq!(diff_marks("x\ny\n", "x\ny\n"), [] as [(usize, LineMark); 0]);
1328 }
1329
1330 #[test]
1331 fn parse_blame_porcelain_extracts_fields() {
1332 let out = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 12 12 1\n\
1333 author Ada Lovelace\n\
1334 author-mail <ada@example.com>\n\
1335 author-time 1700000000\n\
1336 author-tz +0000\n\
1337 committer Ada Lovelace\n\
1338 summary Add the analytical engine\n\
1339 filename engine.rs\n\
1340 \tlet x = 1;\n";
1341 let b = parse_blame_porcelain(out).expect("parsed");
1342 assert_eq!(b.hash, "1a2b3c4d");
1343 assert_eq!(b.author, "Ada Lovelace");
1344 assert_eq!(b.date, "2023-11-14");
1345 assert_eq!(b.summary, "Add the analytical engine");
1346 assert!(!b.is_uncommitted());
1347 }
1348
1349 #[test]
1350 fn parse_blame_porcelain_flags_uncommitted() {
1351 let out = "0000000000000000000000000000000000000000 1 1 1\n\
1352 author Not Committed Yet\n\
1353 author-time 1700000000\n\
1354 author-tz +0000\n\
1355 summary Version of engine.rs from engine.rs\n\
1356 \tlet y = 2;\n";
1357 let b = parse_blame_porcelain(out).expect("parsed");
1358 assert!(b.is_uncommitted());
1359 assert_eq!(b.author, "Not Committed Yet");
1360 }
1361
1362 #[test]
1363 fn parse_blame_porcelain_none_without_header() {
1364 assert!(parse_blame_porcelain("not a blame\n").is_none());
1365 }
1366
1367 #[test]
1368 fn epoch_to_date_applies_tz_offset() {
1369 assert_eq!(epoch_to_date(1_700_000_000, 0), "2023-11-14");
1371 assert_eq!(epoch_to_date(1_700_000_000, 2 * 3600), "2023-11-15");
1372 assert_eq!(epoch_to_date(0, 0), "1970-01-01");
1373 }
1374
1375 #[test]
1376 fn change_letters() {
1377 assert_eq!(Change::Modified.letter(), 'M');
1378 assert_eq!(Change::Untracked.letter(), '?');
1379 }
1380
1381 #[cfg(feature = "process")]
1384 #[test]
1385 #[ignore = "needs git and an in-tree checkout"]
1386 fn runners_report_branch_in_this_repo() {
1387 let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1388 assert!(is_repo(dir));
1389 assert!(branch(dir).is_some());
1390 }
1391}