1use std::io::Write;
65
66use std::path::Path;
67
68use clap::{Parser, ValueEnum};
69use mkit_core::Hash;
70use mkit_core::index::{self, EntryStatus, Index};
71use mkit_core::layout::RepoLayout;
72use mkit_core::ops::{
73 DiffEntry, DiffKind, StatusEntry, StatusStaging, detect_exact_renames, status_diff_observed,
74};
75use mkit_core::refs;
76use mkit_core::store::ObjectStore;
77
78use crate::clap_shim;
79use crate::exit;
80use crate::format;
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
83enum PorcelainVersion {
84 V1,
85 V2,
86}
87
88#[derive(Debug, Parser)]
89#[command(
90 name = "mkit status",
91 about = "Show working-tree changes relative to HEAD."
92)]
93struct StatusOpts {
94 #[arg(long, value_name = "VERSION", num_args = 0..=1, default_missing_value = "v1")]
97 porcelain: Option<PorcelainVersion>,
98
99 #[arg(short = 's', long = "short")]
102 short: bool,
103
104 #[arg(short = 'z')]
108 z: bool,
109
110 #[arg(long = "no-renames")]
113 no_renames: bool,
114
115 #[arg(long = "find-renames", value_name = "N", num_args = 0..=1, require_equals = true)]
119 find_renames: Option<String>,
120}
121
122#[must_use]
123pub fn run(args: &[String]) -> u8 {
124 let opts = match clap_shim::parse::<StatusOpts>("mkit status", args) {
125 Ok(o) => o,
126 Err(code) => return code,
127 };
128 let porcelain = opts.porcelain.is_some() || opts.short || opts.z;
131
132 let cwd = match std::env::current_dir() {
133 Ok(p) => p,
134 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
135 };
136 let layout = match super::resolve_layout(&cwd) {
137 Ok(layout) => layout,
138 Err(code) => return code,
139 };
140 let store = match ObjectStore::open(&layout) {
141 Ok(s) => s,
142 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
143 };
144
145 let head_tree: Option<mkit_core::Hash> = match super::current_head_tree(&layout, &store) {
149 Ok(t) => t,
150 Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
151 };
152
153 let idx = match index::read_index(&layout) {
157 Ok(idx) if idx.entries.is_empty() => None,
158 Ok(idx) => Some(idx),
159 Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
160 };
161
162 if let Some(t) = &opts.find_renames {
166 let n = t.trim_end_matches('%');
167 if !n.is_empty() && n.parse::<u8>().is_err() {
168 return emit_err(&format!("invalid --find-renames value: {t}"), exit::USAGE);
169 }
170 }
171
172 let (mut entries, observations) =
173 match status_diff_observed(&store, head_tree.as_ref(), &cwd, idx.as_ref()) {
174 Ok(v) => v,
175 Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
176 };
177
178 if idx.is_some() {
184 refresh_stat_cache(&layout, &observations);
185 }
186
187 if !opts.no_renames {
190 entries = detect_status_renames(entries);
191 }
192
193 if porcelain {
194 if opts.porcelain == Some(PorcelainVersion::V2) {
195 render_porcelain_v2(&store, head_tree.as_ref(), &layout, &entries, opts.z)
196 } else {
197 render_porcelain(&entries, opts.z)
198 }
199 } else {
200 render_human(&layout, &entries)
201 }
202}
203
204fn refresh_stat_cache(layout: &RepoLayout, observations: &[mkit_core::worktree::StatObservation]) {
221 if observations.is_empty() {
222 return;
223 }
224 match std::fs::File::open(mkit_core::index::index_path(layout)) {
226 Ok(mut f) => {
227 use std::io::Read as _;
228 let mut header = [0u8; 5];
229 if f.read_exact(&mut header).is_err() || header[4] != mkit_core::index::FORMAT_VERSION {
230 return;
231 }
232 }
233 Err(_) => return,
234 }
235 let Ok(_lock) = mkit_core::repo_lock::acquire(
238 layout.worktree_state_dir(),
239 super::WORKTREE_LOCK,
240 std::time::Duration::from_millis(10),
241 ) else {
242 return;
243 };
244 let Ok(mut fresh) = index::read_index(layout) else {
245 return;
246 };
247 let by_path: std::collections::HashMap<&str, &mkit_core::worktree::StatObservation> =
248 observations.iter().map(|o| (o.path.as_str(), o)).collect();
249 let mut updated = false;
250 for e in &mut fresh.entries {
251 let Some(obs) = by_path.get(e.path.as_str()) else {
252 continue;
253 };
254 if e.object_hash == obs.object_hash
261 && (e.mtime_ns != obs.mtime_ns
262 || e.size != obs.size
263 || e.ino != obs.ino
264 || e.ctime_ns != obs.ctime_ns)
265 {
266 e.mtime_ns = obs.mtime_ns;
267 e.size = obs.size;
268 e.ino = obs.ino;
269 e.ctime_ns = obs.ctime_ns;
270 updated = true;
271 }
272 }
273 if updated {
274 let _ = index::write_index(layout, &fresh);
275 }
276}
277
278fn render_porcelain(entries: &[StatusEntry], z: bool) -> u8 {
289 let disp = |p: &str| super::c_quote_path(p).unwrap_or_else(|| p.to_string());
290 let mut stdout = std::io::stdout().lock();
291 for (xy, path, old_path) in combine_porcelain(entries) {
292 let code = std::str::from_utf8(&xy).unwrap_or("??");
294 match old_path {
295 Some(old) if z => {
298 let _ = write!(stdout, "{code} {path}\0{old}\0");
299 }
300 Some(old) => {
301 let _ = writeln!(stdout, "{code} {} -> {}", disp(old), disp(path));
302 }
303 None if z => {
304 let _ = write!(stdout, "{code} {path}\0");
305 }
306 None => {
307 let _ = writeln!(stdout, "{code} {}", disp(path));
308 }
309 }
310 }
311 exit::OK
312}
313
314fn render_porcelain_v2(
326 store: &ObjectStore,
327 head_tree: Option<&Hash>,
328 layout: &RepoLayout,
329 entries: &[StatusEntry],
330 z: bool,
331) -> u8 {
332 let head_index = match head_tree {
335 Some(h) => match index::from_tree(store, *h) {
336 Ok(i) => i,
337 Err(e) => return emit_err(&format!("read HEAD tree: {e}"), exit::GENERAL_ERROR),
338 },
339 None => Index::new(),
340 };
341 let work_index = match super::read_or_seed_index_from_head(layout, store) {
342 Ok(i) => i,
343 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
344 };
345
346 let mut stdout = std::io::stdout().lock();
347 for (xy, path, old_path) in combine_porcelain(entries) {
348 if xy == [b'?', b'?'] {
349 emit_v2_record(&mut stdout, "? ", path, z);
350 continue;
351 }
352 let x = if xy[0] == b' ' { '.' } else { xy[0] as char };
354 let y = if xy[1] == b' ' { '.' } else { xy[1] as char };
355 if let Some(old) = old_path {
356 let (m_head, h_head) = v2_mode_and_id(&head_index, old);
360 let (m_index, h_index) = v2_mode_and_id(&work_index, path);
361 let m_work = worktree_mode(layout.worktree_root(), path);
362 let prefix =
363 format!("2 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} R100 ");
364 emit_v2_rename_record(&mut stdout, &prefix, path, old, z);
365 continue;
366 }
367 let (m_head, h_head) = v2_mode_and_id(&head_index, path);
368 let (m_index, h_index) = v2_mode_and_id(&work_index, path);
369 let m_work = worktree_mode(layout.worktree_root(), path);
370 let prefix = format!("1 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} ");
371 emit_v2_record(&mut stdout, &prefix, path, z);
372 }
373 exit::OK
374}
375
376fn emit_v2_rename_record(out: &mut impl Write, prefix: &str, new: &str, old: &str, z: bool) {
380 if z {
381 let _ = write!(out, "{prefix}{new}\0{old}\0");
382 } else {
383 let nq = super::c_quote_path(new).unwrap_or_else(|| new.to_string());
384 let oq = super::c_quote_path(old).unwrap_or_else(|| old.to_string());
385 let _ = writeln!(out, "{prefix}{nq}\t{oq}");
386 }
387}
388
389fn emit_v2_record(out: &mut impl Write, prefix: &str, path: &str, z: bool) {
392 if z {
393 let _ = write!(out, "{prefix}{path}\0");
394 } else if let Some(quoted) = super::c_quote_path(path) {
395 let _ = writeln!(out, "{prefix}{quoted}");
396 } else {
397 let _ = writeln!(out, "{prefix}{path}");
398 }
399}
400
401fn v2_mode_and_id(index: &Index, path: &str) -> (&'static str, String) {
404 match index.find_entry(path) {
405 Some(i) if index.entries[i].status != EntryStatus::Removed => {
406 let e = &index.entries[i];
407 (git_mode(e.status), format::hex_hash(&e.object_hash))
408 }
409 _ => ("000000", format::hex_hash(&mkit_core::hash::ZERO)),
410 }
411}
412
413fn git_mode(status: EntryStatus) -> &'static str {
415 match status {
416 EntryStatus::Executable => "100755",
417 EntryStatus::Symlink => "120000",
418 _ => "100644",
419 }
420}
421
422fn worktree_mode(root: &Path, path: &str) -> &'static str {
429 let Ok(meta) = std::fs::symlink_metadata(root.join(path)) else {
430 return "000000";
431 };
432 if meta.is_symlink() {
433 "120000"
434 } else if meta.is_file() {
435 if is_executable(&meta) {
436 "100755"
437 } else {
438 "100644"
439 }
440 } else {
441 "000000"
442 }
443}
444
445#[cfg(unix)]
446fn is_executable(meta: &std::fs::Metadata) -> bool {
447 use std::os::unix::fs::PermissionsExt;
448 meta.permissions().mode() & 0o111 != 0
449}
450
451#[cfg(not(unix))]
452fn is_executable(_meta: &std::fs::Metadata) -> bool {
453 false
454}
455
456fn combine_porcelain(entries: &[StatusEntry]) -> Vec<([u8; 2], &str, Option<&str>)> {
476 let mut tracked_order: Vec<&str> = Vec::new();
477 let mut tracked: std::collections::HashMap<&str, ([u8; 2], Option<&str>)> =
479 std::collections::HashMap::new();
480 let mut untracked: Vec<&str> = Vec::new();
481 for e in entries {
482 if e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added {
485 untracked.push(&e.diff.path);
486 continue;
487 }
488 let c = porcelain_code(e.staging, e.diff.kind).as_bytes();
489 let slot = tracked.entry(&e.diff.path).or_insert_with(|| {
490 tracked_order.push(&e.diff.path);
491 ([b' ', b' '], None)
492 });
493 if c[0] != b' ' {
495 slot.0[0] = c[0];
496 }
497 if c[1] != b' ' {
498 slot.0[1] = c[1];
499 }
500 if e.diff.kind == DiffKind::Renamed {
503 slot.1 = e.diff.old_path.as_deref();
504 }
505 }
506 let mut out: Vec<([u8; 2], &str, Option<&str>)> = tracked_order
507 .into_iter()
508 .map(|p| {
509 let s = tracked[p];
510 (s.0, p, s.1)
511 })
512 .collect();
513 out.extend(untracked.into_iter().map(|p| ([b'?', b'?'], p, None)));
514 out
515}
516
517fn porcelain_code(staging: StatusStaging, kind: DiffKind) -> &'static str {
519 match (staging, kind) {
520 (StatusStaging::Staged, DiffKind::Added) => "A ",
521 (StatusStaging::Staged, DiffKind::Removed) => "D ",
522 (StatusStaging::Staged, DiffKind::Modified) => "M ",
523 (StatusStaging::Staged, DiffKind::ModeChanged) => "T ",
524 (StatusStaging::Unstaged, DiffKind::Added) => "??",
528 (StatusStaging::Unstaged, DiffKind::Removed) => " D",
529 (StatusStaging::Unstaged, DiffKind::Modified) => " M",
530 (StatusStaging::Unstaged, DiffKind::ModeChanged) => " T",
531 (StatusStaging::PartiallyStaged, DiffKind::Added) => "AM",
536 (StatusStaging::PartiallyStaged, DiffKind::Removed) => "MD",
537 (StatusStaging::PartiallyStaged, DiffKind::Modified) => "MM",
538 (StatusStaging::PartiallyStaged, DiffKind::ModeChanged) => "MT",
539 (StatusStaging::Staged | StatusStaging::PartiallyStaged, DiffKind::Renamed) => "R ",
543 (StatusStaging::Unstaged, DiffKind::Renamed) => " R",
544 }
545}
546
547fn render_human(layout: &RepoLayout, entries: &[StatusEntry]) -> u8 {
553 let mut stderr = std::io::stderr().lock();
554
555 match refs::read_head(layout) {
557 Ok(refs::Head::Branch(name)) => {
558 let _ = writeln!(stderr, "On branch {name}");
559 if refs::resolve_head(layout).ok().flatten().is_none() {
560 let _ = writeln!(stderr, "\nNo commits yet");
561 }
562 }
563 Ok(refs::Head::Detached(h)) => {
564 let _ = writeln!(
565 stderr,
566 "HEAD detached at {}",
567 crate::format::short_hash(&h, crate::format::SUMMARY_ABBREV)
568 );
569 }
570 Err(_) => {
571 let _ = writeln!(stderr, "On branch main\n\nNo commits yet");
572 }
573 }
574
575 if entries.is_empty() {
576 let _ = writeln!(stderr, "\nnothing to commit, working tree clean");
577 return exit::OK;
578 }
579
580 let staged: Vec<_> = entries
583 .iter()
584 .filter(|e| e.staging == StatusStaging::Staged)
585 .collect();
586 let partial: Vec<_> = entries
587 .iter()
588 .filter(|e| e.staging == StatusStaging::PartiallyStaged)
589 .collect();
590 let unstaged: Vec<_> = entries
591 .iter()
592 .filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind != DiffKind::Added)
593 .collect();
594 let untracked: Vec<_> = entries
595 .iter()
596 .filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added)
597 .collect();
598
599 if !staged.is_empty() {
600 let _ = writeln!(stderr, "\nChanges to be committed:");
601 let _ = writeln!(
602 stderr,
603 " (use \"mkit restore --staged <file>...\" to unstage)"
604 );
605 for e in &staged {
606 let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
607 }
608 }
609 if !partial.is_empty() {
610 let _ = writeln!(stderr, "\nChanges both staged and not staged:");
611 for e in &partial {
612 let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
613 }
614 }
615 if !unstaged.is_empty() {
616 let _ = writeln!(stderr, "\nChanges not staged for commit:");
617 let _ = writeln!(
618 stderr,
619 " (use \"mkit add <file>...\" to update what will be committed)"
620 );
621 let _ = writeln!(
622 stderr,
623 " (use \"mkit restore <file>...\" to discard changes in working directory)"
624 );
625 for e in &unstaged {
626 let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
627 }
628 }
629 if !untracked.is_empty() {
630 let _ = writeln!(stderr, "\nUntracked files:");
631 let _ = writeln!(
632 stderr,
633 " (use \"mkit add <file>...\" to include in what will be committed)"
634 );
635 for e in &untracked {
636 let _ = writeln!(stderr, "\t{}", e.diff.path);
637 }
638 }
639
640 if staged.is_empty() && partial.is_empty() {
642 if !unstaged.is_empty() {
643 let _ = writeln!(
644 stderr,
645 "\nno changes added to commit (use \"mkit add\" and/or \"mkit commit -a\")"
646 );
647 } else if !untracked.is_empty() {
648 let _ = writeln!(
649 stderr,
650 "\nnothing added to commit but untracked files present (use \"mkit add\" to track)"
651 );
652 }
653 }
654
655 exit::OK
656}
657
658fn human_label(kind: DiffKind) -> &'static str {
660 match kind {
661 DiffKind::Added => "new file:",
662 DiffKind::Removed => "deleted:",
663 DiffKind::Modified => "modified:",
664 DiffKind::ModeChanged => "typechange:",
665 DiffKind::Renamed => "renamed:",
666 }
667}
668
669fn human_path(e: &StatusEntry) -> String {
672 match (e.diff.kind, &e.diff.old_path) {
673 (DiffKind::Renamed, Some(old)) => format!("{old} -> {}", e.diff.path),
674 _ => e.diff.path.clone(),
675 }
676}
677
678fn detect_status_renames(entries: Vec<StatusEntry>) -> Vec<StatusEntry> {
686 let (staged, others): (Vec<StatusEntry>, Vec<StatusEntry>) = entries
687 .into_iter()
688 .partition(|e| e.staging == StatusStaging::Staged);
689 let mut staged_diffs: Vec<DiffEntry> = staged.into_iter().map(|e| e.diff).collect();
690 detect_exact_renames(&mut staged_diffs);
691 let mut out: Vec<StatusEntry> = staged_diffs
692 .into_iter()
693 .map(|d| StatusEntry {
694 diff: d,
695 staging: StatusStaging::Staged,
696 })
697 .chain(others)
698 .collect();
699 out.sort_by(|a, b| {
701 a.diff
702 .path
703 .cmp(&b.diff.path)
704 .then_with(|| staging_rank(a.staging).cmp(&staging_rank(b.staging)))
705 });
706 out
707}
708
709fn staging_rank(s: StatusStaging) -> u8 {
710 match s {
711 StatusStaging::Staged => 0,
712 StatusStaging::PartiallyStaged => 1,
713 StatusStaging::Unstaged => 2,
714 }
715}
716
717use super::error as emit_err;
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
724 fn porcelain_code_matrix() {
725 assert_eq!(porcelain_code(StatusStaging::Staged, DiffKind::Added), "A ",);
727 assert_eq!(
728 porcelain_code(StatusStaging::Staged, DiffKind::Removed),
729 "D ",
730 );
731 assert_eq!(
732 porcelain_code(StatusStaging::Staged, DiffKind::Modified),
733 "M ",
734 );
735 assert_eq!(
736 porcelain_code(StatusStaging::Unstaged, DiffKind::Added),
737 "??",
738 );
739 assert_eq!(
740 porcelain_code(StatusStaging::Unstaged, DiffKind::Modified),
741 " M",
742 );
743 assert_eq!(
744 porcelain_code(StatusStaging::Unstaged, DiffKind::Removed),
745 " D",
746 );
747 }
748
749 fn entry(path: &str, staging: StatusStaging, kind: DiffKind) -> StatusEntry {
750 StatusEntry {
751 diff: mkit_core::ops::DiffEntry {
752 path: path.to_string(),
753 kind,
754 old_hash: None,
755 new_hash: None,
756 old_mode: None,
757 new_mode: None,
758 old_path: None,
759 },
760 staging,
761 }
762 }
763
764 fn combined(entries: &[StatusEntry]) -> Vec<(String, String)> {
765 combine_porcelain(entries)
766 .into_iter()
767 .map(|(xy, p, _)| (std::str::from_utf8(&xy).unwrap().to_string(), p.to_string()))
768 .collect()
769 }
770
771 #[test]
772 fn combine_merges_staged_and_unstaged_same_path_into_one_record() {
773 use DiffKind::Modified;
774 use StatusStaging::{Staged, Unstaged};
775 let entries = [
778 entry("a.txt", Staged, Modified),
779 entry("a.txt", Unstaged, Modified),
780 ];
781 assert_eq!(combined(&entries), vec![("MM".into(), "a.txt".into())]);
782 }
783
784 #[test]
785 fn combine_staged_add_plus_worktree_modify_is_am() {
786 let entries = [
787 entry("n.txt", StatusStaging::Staged, DiffKind::Added),
788 entry("n.txt", StatusStaging::Unstaged, DiffKind::Modified),
789 ];
790 assert_eq!(combined(&entries), vec![("AM".into(), "n.txt".into())]);
791 }
792
793 #[test]
794 fn combine_preserves_lone_records_and_untracked() {
795 let entries = [
796 entry("staged.txt", StatusStaging::Staged, DiffKind::Added),
797 entry("dirty.txt", StatusStaging::Unstaged, DiffKind::Modified),
798 entry("new.txt", StatusStaging::Unstaged, DiffKind::Added), ];
800 assert_eq!(
801 combined(&entries),
802 vec![
803 ("A ".into(), "staged.txt".into()),
804 (" M".into(), "dirty.txt".into()),
805 ("??".into(), "new.txt".into()),
806 ]
807 );
808 }
809
810 #[test]
811 fn combine_keeps_staged_delete_and_untracked_at_same_path_separate() {
812 use DiffKind::{Added, Removed};
813 use StatusStaging::{Staged, Unstaged};
814 let entries = [
819 entry("a.txt", Staged, Removed),
820 entry("a.txt", Unstaged, Added),
821 ];
822 assert_eq!(
823 combined(&entries),
824 vec![("D ".into(), "a.txt".into()), ("??".into(), "a.txt".into())]
825 );
826 }
827
828 #[test]
829 fn combine_orders_all_tracked_before_untracked_like_git() {
830 use DiffKind::{Added, Modified, Removed};
831 use StatusStaging::{Staged, Unstaged};
832 let entries = [
836 entry("a.txt", Staged, Removed),
837 entry("a.txt", Unstaged, Added),
838 entry("m.txt", Unstaged, Modified),
839 entry("b.txt", Unstaged, Added),
840 ];
841 assert_eq!(
842 combined(&entries),
843 vec![
844 ("D ".into(), "a.txt".into()),
845 (" M".into(), "m.txt".into()),
846 ("??".into(), "a.txt".into()),
847 ("??".into(), "b.txt".into()),
848 ]
849 );
850 }
851
852 #[test]
853 fn porcelain_codes_are_two_chars() {
854 use DiffKind::{Added, ModeChanged, Modified, Removed};
855 use StatusStaging::{PartiallyStaged, Staged, Unstaged};
856 for s in [Staged, Unstaged, PartiallyStaged] {
857 for k in [Added, Removed, Modified, ModeChanged] {
858 assert_eq!(porcelain_code(s, k).len(), 2, "{s:?} + {k:?}");
859 }
860 }
861 }
862}