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_content_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 = match detect_status_renames(&store, entries) {
191 Ok(entries) => entries,
192 Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
193 };
194 }
195
196 if porcelain {
197 if opts.porcelain == Some(PorcelainVersion::V2) {
198 render_porcelain_v2(&store, head_tree.as_ref(), &layout, &entries, opts.z)
199 } else {
200 render_porcelain(&entries, opts.z)
201 }
202 } else {
203 render_human(&layout, &entries)
204 }
205}
206
207fn refresh_stat_cache(layout: &RepoLayout, observations: &[mkit_core::worktree::StatObservation]) {
224 if observations.is_empty() {
225 return;
226 }
227 match std::fs::File::open(mkit_core::index::index_path(layout)) {
229 Ok(mut f) => {
230 use std::io::Read as _;
231 let mut header = [0u8; 5];
232 if f.read_exact(&mut header).is_err() || header[4] != mkit_core::index::FORMAT_VERSION {
233 return;
234 }
235 }
236 Err(_) => return,
237 }
238 let Ok(_lock) = mkit_core::repo_lock::acquire(
241 layout.worktree_state_dir(),
242 super::WORKTREE_LOCK,
243 std::time::Duration::from_millis(10),
244 ) else {
245 return;
246 };
247 super::warn_if_served(layout);
248 let Ok(mut fresh) = index::read_index(layout) else {
249 return;
250 };
251 let by_path: std::collections::HashMap<&str, &mkit_core::worktree::StatObservation> =
252 observations.iter().map(|o| (o.path.as_str(), o)).collect();
253 let mut updated = false;
254 for e in &mut fresh.entries {
255 let Some(obs) = by_path.get(e.path.as_str()) else {
256 continue;
257 };
258 if e.object_hash == obs.object_hash
265 && (e.mtime_ns != obs.mtime_ns
266 || e.size != obs.size
267 || e.ino != obs.ino
268 || e.ctime_ns != obs.ctime_ns)
269 {
270 e.mtime_ns = obs.mtime_ns;
271 e.size = obs.size;
272 e.ino = obs.ino;
273 e.ctime_ns = obs.ctime_ns;
274 updated = true;
275 }
276 }
277 if updated {
278 let _ = index::write_index(layout, &fresh);
279 }
280}
281
282fn render_porcelain(entries: &[StatusEntry], z: bool) -> u8 {
293 let disp = |p: &str| super::c_quote_path(p).unwrap_or_else(|| p.to_string());
294 let mut stdout = std::io::stdout().lock();
295 for (xy, path, old_path) in combine_porcelain(entries) {
296 let code = std::str::from_utf8(&xy).unwrap_or("??");
298 match old_path {
299 Some(old) if z => {
302 let _ = write!(stdout, "{code} {path}\0{old}\0");
303 }
304 Some(old) => {
305 let _ = writeln!(stdout, "{code} {} -> {}", disp(old), disp(path));
306 }
307 None if z => {
308 let _ = write!(stdout, "{code} {path}\0");
309 }
310 None => {
311 let _ = writeln!(stdout, "{code} {}", disp(path));
312 }
313 }
314 }
315 exit::OK
316}
317
318fn render_porcelain_v2(
330 store: &ObjectStore,
331 head_tree: Option<&Hash>,
332 layout: &RepoLayout,
333 entries: &[StatusEntry],
334 z: bool,
335) -> u8 {
336 let head_index = match head_tree {
339 Some(h) => match index::from_tree(store, *h) {
340 Ok(i) => i,
341 Err(e) => return emit_err(&format!("read HEAD tree: {e}"), exit::GENERAL_ERROR),
342 },
343 None => Index::new(),
344 };
345 let work_index = match super::read_or_seed_index_from_head(layout, store) {
346 Ok(i) => i,
347 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
348 };
349
350 let mut stdout = std::io::stdout().lock();
351 for (xy, path, old_path) in combine_porcelain(entries) {
352 if xy == [b'?', b'?'] {
353 emit_v2_record(&mut stdout, "? ", path, z);
354 continue;
355 }
356 let x = if xy[0] == b' ' { '.' } else { xy[0] as char };
358 let y = if xy[1] == b' ' { '.' } else { xy[1] as char };
359 if let Some(old) = old_path {
360 let (m_head, h_head) = v2_mode_and_id(&head_index, old);
364 let (m_index, h_index) = v2_mode_and_id(&work_index, path);
365 let m_work = worktree_mode(layout.worktree_root(), path);
366 let prefix =
367 format!("2 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} R100 ");
368 emit_v2_rename_record(&mut stdout, &prefix, path, old, z);
369 continue;
370 }
371 let (m_head, h_head) = v2_mode_and_id(&head_index, path);
372 let (m_index, h_index) = v2_mode_and_id(&work_index, path);
373 let m_work = worktree_mode(layout.worktree_root(), path);
374 let prefix = format!("1 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} ");
375 emit_v2_record(&mut stdout, &prefix, path, z);
376 }
377 exit::OK
378}
379
380fn emit_v2_rename_record(out: &mut impl Write, prefix: &str, new: &str, old: &str, z: bool) {
384 if z {
385 let _ = write!(out, "{prefix}{new}\0{old}\0");
386 } else {
387 let nq = super::c_quote_path(new).unwrap_or_else(|| new.to_string());
388 let oq = super::c_quote_path(old).unwrap_or_else(|| old.to_string());
389 let _ = writeln!(out, "{prefix}{nq}\t{oq}");
390 }
391}
392
393fn emit_v2_record(out: &mut impl Write, prefix: &str, path: &str, z: bool) {
396 if z {
397 let _ = write!(out, "{prefix}{path}\0");
398 } else if let Some(quoted) = super::c_quote_path(path) {
399 let _ = writeln!(out, "{prefix}{quoted}");
400 } else {
401 let _ = writeln!(out, "{prefix}{path}");
402 }
403}
404
405fn v2_mode_and_id(index: &Index, path: &str) -> (&'static str, String) {
408 match index.find_entry(path) {
409 Some(i) if index.entries[i].status != EntryStatus::Removed => {
410 let e = &index.entries[i];
411 (git_mode(e.status), format::hex_hash(&e.object_hash))
412 }
413 _ => ("000000", format::hex_hash(&mkit_core::hash::ZERO)),
414 }
415}
416
417fn git_mode(status: EntryStatus) -> &'static str {
419 match status {
420 EntryStatus::Executable => "100755",
421 EntryStatus::Symlink => "120000",
422 _ => "100644",
423 }
424}
425
426fn worktree_mode(root: &Path, path: &str) -> &'static str {
433 let Ok(meta) = std::fs::symlink_metadata(root.join(path)) else {
434 return "000000";
435 };
436 if meta.is_symlink() {
437 "120000"
438 } else if meta.is_file() {
439 if is_executable(&meta) {
440 "100755"
441 } else {
442 "100644"
443 }
444 } else {
445 "000000"
446 }
447}
448
449#[cfg(unix)]
450fn is_executable(meta: &std::fs::Metadata) -> bool {
451 use std::os::unix::fs::PermissionsExt;
452 meta.permissions().mode() & 0o111 != 0
453}
454
455#[cfg(not(unix))]
456fn is_executable(_meta: &std::fs::Metadata) -> bool {
457 false
458}
459
460fn combine_porcelain(entries: &[StatusEntry]) -> Vec<([u8; 2], &str, Option<&str>)> {
480 let mut tracked_order: Vec<&str> = Vec::new();
481 let mut tracked: std::collections::HashMap<&str, ([u8; 2], Option<&str>)> =
483 std::collections::HashMap::new();
484 let mut untracked: Vec<&str> = Vec::new();
485 for e in entries {
486 if e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added {
489 untracked.push(&e.diff.path);
490 continue;
491 }
492 let c = porcelain_code(e.staging, e.diff.kind).as_bytes();
493 let slot = tracked.entry(&e.diff.path).or_insert_with(|| {
494 tracked_order.push(&e.diff.path);
495 ([b' ', b' '], None)
496 });
497 if c[0] != b' ' {
499 slot.0[0] = c[0];
500 }
501 if c[1] != b' ' {
502 slot.0[1] = c[1];
503 }
504 if e.diff.kind == DiffKind::Renamed {
507 slot.1 = e.diff.old_path.as_deref();
508 }
509 }
510 let mut out: Vec<([u8; 2], &str, Option<&str>)> = tracked_order
511 .into_iter()
512 .map(|p| {
513 let s = tracked[p];
514 (s.0, p, s.1)
515 })
516 .collect();
517 out.extend(untracked.into_iter().map(|p| ([b'?', b'?'], p, None)));
518 out
519}
520
521fn porcelain_code(staging: StatusStaging, kind: DiffKind) -> &'static str {
523 match (staging, kind) {
524 (StatusStaging::Staged, DiffKind::Added) => "A ",
525 (StatusStaging::Staged, DiffKind::Removed) => "D ",
526 (StatusStaging::Staged, DiffKind::Modified) => "M ",
527 (StatusStaging::Staged, DiffKind::ModeChanged) => "T ",
528 (StatusStaging::Unstaged, DiffKind::Added) => "??",
532 (StatusStaging::Unstaged, DiffKind::Removed) => " D",
533 (StatusStaging::Unstaged, DiffKind::Modified) => " M",
534 (StatusStaging::Unstaged, DiffKind::ModeChanged) => " T",
535 (StatusStaging::PartiallyStaged, DiffKind::Added) => "AM",
540 (StatusStaging::PartiallyStaged, DiffKind::Removed) => "MD",
541 (StatusStaging::PartiallyStaged, DiffKind::Modified) => "MM",
542 (StatusStaging::PartiallyStaged, DiffKind::ModeChanged) => "MT",
543 (StatusStaging::Staged | StatusStaging::PartiallyStaged, DiffKind::Renamed) => "R ",
547 (StatusStaging::Unstaged, DiffKind::Renamed) => " R",
548 }
549}
550
551fn render_human(layout: &RepoLayout, entries: &[StatusEntry]) -> u8 {
557 let mut stderr = std::io::stderr().lock();
558
559 match refs::read_head(layout) {
561 Ok(refs::Head::Branch(name)) => {
562 let _ = writeln!(stderr, "On branch {name}");
563 if refs::resolve_head(layout).ok().flatten().is_none() {
564 let _ = writeln!(stderr, "\nNo commits yet");
565 }
566 }
567 Ok(refs::Head::Detached(h)) => {
568 let _ = writeln!(
569 stderr,
570 "HEAD detached at {}",
571 crate::format::short_hash(&h, crate::format::SUMMARY_ABBREV)
572 );
573 }
574 Err(_) => {
575 let _ = writeln!(stderr, "On branch main\n\nNo commits yet");
576 }
577 }
578
579 if entries.is_empty() {
580 let _ = writeln!(stderr, "\nnothing to commit, working tree clean");
581 return exit::OK;
582 }
583
584 let staged: Vec<_> = entries
587 .iter()
588 .filter(|e| e.staging == StatusStaging::Staged)
589 .collect();
590 let partial: Vec<_> = entries
591 .iter()
592 .filter(|e| e.staging == StatusStaging::PartiallyStaged)
593 .collect();
594 let unstaged: Vec<_> = entries
595 .iter()
596 .filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind != DiffKind::Added)
597 .collect();
598 let untracked: Vec<_> = entries
599 .iter()
600 .filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added)
601 .collect();
602
603 if !staged.is_empty() {
604 let _ = writeln!(stderr, "\nChanges to be committed:");
605 let _ = writeln!(
606 stderr,
607 " (use \"mkit restore --staged <file>...\" to unstage)"
608 );
609 for e in &staged {
610 let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
611 }
612 }
613 if !partial.is_empty() {
614 let _ = writeln!(stderr, "\nChanges both staged and not staged:");
615 for e in &partial {
616 let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
617 }
618 }
619 if !unstaged.is_empty() {
620 let _ = writeln!(stderr, "\nChanges not staged for commit:");
621 let _ = writeln!(
622 stderr,
623 " (use \"mkit add <file>...\" to update what will be committed)"
624 );
625 let _ = writeln!(
626 stderr,
627 " (use \"mkit restore <file>...\" to discard changes in working directory)"
628 );
629 for e in &unstaged {
630 let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
631 }
632 }
633 if !untracked.is_empty() {
634 let _ = writeln!(stderr, "\nUntracked files:");
635 let _ = writeln!(
636 stderr,
637 " (use \"mkit add <file>...\" to include in what will be committed)"
638 );
639 for e in &untracked {
640 let _ = writeln!(stderr, "\t{}", e.diff.path);
641 }
642 }
643
644 if staged.is_empty() && partial.is_empty() {
646 if !unstaged.is_empty() {
647 let _ = writeln!(
648 stderr,
649 "\nno changes added to commit (use \"mkit add\" and/or \"mkit commit -a\")"
650 );
651 } else if !untracked.is_empty() {
652 let _ = writeln!(
653 stderr,
654 "\nnothing added to commit but untracked files present (use \"mkit add\" to track)"
655 );
656 }
657 }
658
659 exit::OK
660}
661
662fn human_label(kind: DiffKind) -> &'static str {
664 match kind {
665 DiffKind::Added => "new file:",
666 DiffKind::Removed => "deleted:",
667 DiffKind::Modified => "modified:",
668 DiffKind::ModeChanged => "typechange:",
669 DiffKind::Renamed => "renamed:",
670 }
671}
672
673fn human_path(e: &StatusEntry) -> String {
676 match (e.diff.kind, &e.diff.old_path) {
677 (DiffKind::Renamed, Some(old)) => format!("{old} -> {}", e.diff.path),
678 _ => e.diff.path.clone(),
679 }
680}
681
682fn detect_status_renames(
690 store: &ObjectStore,
691 entries: Vec<StatusEntry>,
692) -> Result<Vec<StatusEntry>, mkit_core::store::StoreError> {
693 let (staged, others): (Vec<StatusEntry>, Vec<StatusEntry>) = entries
694 .into_iter()
695 .partition(|e| e.staging == StatusStaging::Staged);
696 let mut staged_diffs: Vec<DiffEntry> = staged.into_iter().map(|e| e.diff).collect();
697 detect_content_renames(store, &mut staged_diffs)?;
698 let mut out: Vec<StatusEntry> = staged_diffs
699 .into_iter()
700 .map(|d| StatusEntry {
701 diff: d,
702 staging: StatusStaging::Staged,
703 })
704 .chain(others)
705 .collect();
706 out.sort_by(|a, b| {
708 a.diff
709 .path
710 .cmp(&b.diff.path)
711 .then_with(|| staging_rank(a.staging).cmp(&staging_rank(b.staging)))
712 });
713 Ok(out)
714}
715
716fn staging_rank(s: StatusStaging) -> u8 {
717 match s {
718 StatusStaging::Staged => 0,
719 StatusStaging::PartiallyStaged => 1,
720 StatusStaging::Unstaged => 2,
721 }
722}
723
724use super::error as emit_err;
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn porcelain_code_matrix() {
732 assert_eq!(porcelain_code(StatusStaging::Staged, DiffKind::Added), "A ",);
734 assert_eq!(
735 porcelain_code(StatusStaging::Staged, DiffKind::Removed),
736 "D ",
737 );
738 assert_eq!(
739 porcelain_code(StatusStaging::Staged, DiffKind::Modified),
740 "M ",
741 );
742 assert_eq!(
743 porcelain_code(StatusStaging::Unstaged, DiffKind::Added),
744 "??",
745 );
746 assert_eq!(
747 porcelain_code(StatusStaging::Unstaged, DiffKind::Modified),
748 " M",
749 );
750 assert_eq!(
751 porcelain_code(StatusStaging::Unstaged, DiffKind::Removed),
752 " D",
753 );
754 }
755
756 fn entry(path: &str, staging: StatusStaging, kind: DiffKind) -> StatusEntry {
757 StatusEntry {
758 diff: mkit_core::ops::DiffEntry {
759 path: path.to_string(),
760 kind,
761 old_hash: None,
762 new_hash: None,
763 old_mode: None,
764 new_mode: None,
765 old_path: None,
766 },
767 staging,
768 }
769 }
770
771 fn combined(entries: &[StatusEntry]) -> Vec<(String, String)> {
772 combine_porcelain(entries)
773 .into_iter()
774 .map(|(xy, p, _)| (std::str::from_utf8(&xy).unwrap().to_string(), p.to_string()))
775 .collect()
776 }
777
778 #[test]
779 fn combine_merges_staged_and_unstaged_same_path_into_one_record() {
780 use DiffKind::Modified;
781 use StatusStaging::{Staged, Unstaged};
782 let entries = [
785 entry("a.txt", Staged, Modified),
786 entry("a.txt", Unstaged, Modified),
787 ];
788 assert_eq!(combined(&entries), vec![("MM".into(), "a.txt".into())]);
789 }
790
791 #[test]
792 fn combine_staged_add_plus_worktree_modify_is_am() {
793 let entries = [
794 entry("n.txt", StatusStaging::Staged, DiffKind::Added),
795 entry("n.txt", StatusStaging::Unstaged, DiffKind::Modified),
796 ];
797 assert_eq!(combined(&entries), vec![("AM".into(), "n.txt".into())]);
798 }
799
800 #[test]
801 fn combine_preserves_lone_records_and_untracked() {
802 let entries = [
803 entry("staged.txt", StatusStaging::Staged, DiffKind::Added),
804 entry("dirty.txt", StatusStaging::Unstaged, DiffKind::Modified),
805 entry("new.txt", StatusStaging::Unstaged, DiffKind::Added), ];
807 assert_eq!(
808 combined(&entries),
809 vec![
810 ("A ".into(), "staged.txt".into()),
811 (" M".into(), "dirty.txt".into()),
812 ("??".into(), "new.txt".into()),
813 ]
814 );
815 }
816
817 #[test]
818 fn combine_keeps_staged_delete_and_untracked_at_same_path_separate() {
819 use DiffKind::{Added, Removed};
820 use StatusStaging::{Staged, Unstaged};
821 let entries = [
826 entry("a.txt", Staged, Removed),
827 entry("a.txt", Unstaged, Added),
828 ];
829 assert_eq!(
830 combined(&entries),
831 vec![("D ".into(), "a.txt".into()), ("??".into(), "a.txt".into())]
832 );
833 }
834
835 #[test]
836 fn combine_orders_all_tracked_before_untracked_like_git() {
837 use DiffKind::{Added, Modified, Removed};
838 use StatusStaging::{Staged, Unstaged};
839 let entries = [
843 entry("a.txt", Staged, Removed),
844 entry("a.txt", Unstaged, Added),
845 entry("m.txt", Unstaged, Modified),
846 entry("b.txt", Unstaged, Added),
847 ];
848 assert_eq!(
849 combined(&entries),
850 vec![
851 ("D ".into(), "a.txt".into()),
852 (" M".into(), "m.txt".into()),
853 ("??".into(), "a.txt".into()),
854 ("??".into(), "b.txt".into()),
855 ]
856 );
857 }
858
859 #[test]
860 fn porcelain_codes_are_two_chars() {
861 use DiffKind::{Added, ModeChanged, Modified, Removed};
862 use StatusStaging::{PartiallyStaged, Staged, Unstaged};
863 for s in [Staged, Unstaged, PartiallyStaged] {
864 for k in [Added, Removed, Modified, ModeChanged] {
865 assert_eq!(porcelain_code(s, k).len(), 2, "{s:?} + {k:?}");
866 }
867 }
868 }
869}