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::ops::{DiffKind, StatusEntry, StatusStaging, status_diff_observed};
72use mkit_core::refs;
73use mkit_core::store::ObjectStore;
74
75use crate::clap_shim;
76use crate::exit;
77use crate::format;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
80enum PorcelainVersion {
81 V1,
82 V2,
83}
84
85#[derive(Debug, Parser)]
86#[command(
87 name = "mkit status",
88 about = "Show working-tree changes relative to HEAD."
89)]
90struct StatusOpts {
91 #[arg(long, value_name = "VERSION", num_args = 0..=1, default_missing_value = "v1")]
94 porcelain: Option<PorcelainVersion>,
95
96 #[arg(short = 's', long = "short")]
99 short: bool,
100
101 #[arg(short = 'z')]
105 z: bool,
106}
107
108#[must_use]
109pub fn run(args: &[String]) -> u8 {
110 let opts = match clap_shim::parse::<StatusOpts>("mkit status", args) {
111 Ok(o) => o,
112 Err(code) => return code,
113 };
114 let porcelain = opts.porcelain.is_some() || opts.short || opts.z;
117
118 let cwd = match std::env::current_dir() {
119 Ok(p) => p,
120 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
121 };
122 let store = match ObjectStore::open(&cwd) {
123 Ok(s) => s,
124 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
125 };
126 let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
127
128 let head_tree: Option<mkit_core::Hash> = match super::current_head_tree(&cwd, &store) {
132 Ok(t) => t,
133 Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
134 };
135
136 let idx = match index::read_index(&cwd) {
140 Ok(idx) if idx.entries.is_empty() => None,
141 Ok(idx) => Some(idx),
142 Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
143 };
144
145 let (entries, observations) =
146 match status_diff_observed(&store, head_tree.as_ref(), &cwd, idx.as_ref()) {
147 Ok(v) => v,
148 Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
149 };
150
151 if idx.is_some() {
157 refresh_stat_cache(&cwd, &observations);
158 }
159
160 if porcelain {
161 if opts.porcelain == Some(PorcelainVersion::V2) {
162 render_porcelain_v2(&store, head_tree.as_ref(), &cwd, &entries, opts.z)
163 } else {
164 render_porcelain(&entries, opts.z)
165 }
166 } else {
167 render_human(&mkit_dir, &entries)
168 }
169}
170
171fn refresh_stat_cache(root: &Path, observations: &[mkit_core::worktree::StatObservation]) {
188 if observations.is_empty() {
189 return;
190 }
191 match std::fs::File::open(mkit_core::index::index_path(root)) {
193 Ok(mut f) => {
194 use std::io::Read as _;
195 let mut header = [0u8; 5];
196 if f.read_exact(&mut header).is_err() || header[4] != mkit_core::index::FORMAT_VERSION {
197 return;
198 }
199 }
200 Err(_) => return,
201 }
202 let Ok(_lock) = mkit_core::repo_lock::acquire(
205 &root.join(mkit_core::MKIT_DIR),
206 super::WORKTREE_LOCK,
207 std::time::Duration::from_millis(10),
208 ) else {
209 return;
210 };
211 let Ok(mut fresh) = index::read_index(root) else {
212 return;
213 };
214 let by_path: std::collections::HashMap<&str, &mkit_core::worktree::StatObservation> =
215 observations.iter().map(|o| (o.path.as_str(), o)).collect();
216 let mut updated = false;
217 for e in &mut fresh.entries {
218 let Some(obs) = by_path.get(e.path.as_str()) else {
219 continue;
220 };
221 if e.object_hash == obs.object_hash
228 && (e.mtime_ns != obs.mtime_ns
229 || e.size != obs.size
230 || e.ino != obs.ino
231 || e.ctime_ns != obs.ctime_ns)
232 {
233 e.mtime_ns = obs.mtime_ns;
234 e.size = obs.size;
235 e.ino = obs.ino;
236 e.ctime_ns = obs.ctime_ns;
237 updated = true;
238 }
239 }
240 if updated {
241 let _ = index::write_index(root, &fresh);
242 }
243}
244
245fn render_porcelain(entries: &[StatusEntry], z: bool) -> u8 {
256 let mut stdout = std::io::stdout().lock();
257 for (xy, path) in combine_porcelain(entries) {
258 let code = std::str::from_utf8(&xy).unwrap_or("??");
260 if z {
261 let _ = write!(stdout, "{code} {path}\0");
262 } else if let Some(quoted) = super::c_quote_path(path) {
263 let _ = writeln!(stdout, "{code} {quoted}");
264 } else {
265 let _ = writeln!(stdout, "{code} {path}");
266 }
267 }
268 exit::OK
269}
270
271fn render_porcelain_v2(
282 store: &ObjectStore,
283 head_tree: Option<&Hash>,
284 root: &Path,
285 entries: &[StatusEntry],
286 z: bool,
287) -> u8 {
288 let head_index = match head_tree {
291 Some(h) => match index::from_tree(store, *h) {
292 Ok(i) => i,
293 Err(e) => return emit_err(&format!("read HEAD tree: {e}"), exit::GENERAL_ERROR),
294 },
295 None => Index::new(),
296 };
297 let work_index = match super::read_or_seed_index_from_head(root, store) {
298 Ok(i) => i,
299 Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
300 };
301
302 let mut stdout = std::io::stdout().lock();
303 for (xy, path) in combine_porcelain(entries) {
304 if xy == [b'?', b'?'] {
305 emit_v2_record(&mut stdout, "? ", path, z);
306 continue;
307 }
308 let x = if xy[0] == b' ' { '.' } else { xy[0] as char };
310 let y = if xy[1] == b' ' { '.' } else { xy[1] as char };
311 let (m_head, h_head) = v2_mode_and_id(&head_index, path);
312 let (m_index, h_index) = v2_mode_and_id(&work_index, path);
313 let m_work = worktree_mode(root, path);
314 let prefix = format!("1 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} ");
315 emit_v2_record(&mut stdout, &prefix, path, z);
316 }
317 exit::OK
318}
319
320fn emit_v2_record(out: &mut impl Write, prefix: &str, path: &str, z: bool) {
323 if z {
324 let _ = write!(out, "{prefix}{path}\0");
325 } else if let Some(quoted) = super::c_quote_path(path) {
326 let _ = writeln!(out, "{prefix}{quoted}");
327 } else {
328 let _ = writeln!(out, "{prefix}{path}");
329 }
330}
331
332fn v2_mode_and_id(index: &Index, path: &str) -> (&'static str, String) {
335 match index.find_entry(path) {
336 Some(i) if index.entries[i].status != EntryStatus::Removed => {
337 let e = &index.entries[i];
338 (git_mode(e.status), format::hex_hash(&e.object_hash))
339 }
340 _ => ("000000", format::hex_hash(&mkit_core::hash::ZERO)),
341 }
342}
343
344fn git_mode(status: EntryStatus) -> &'static str {
346 match status {
347 EntryStatus::Executable => "100755",
348 EntryStatus::Symlink => "120000",
349 _ => "100644",
350 }
351}
352
353fn worktree_mode(root: &Path, path: &str) -> &'static str {
360 let Ok(meta) = std::fs::symlink_metadata(root.join(path)) else {
361 return "000000";
362 };
363 if meta.is_symlink() {
364 "120000"
365 } else if meta.is_file() {
366 if is_executable(&meta) {
367 "100755"
368 } else {
369 "100644"
370 }
371 } else {
372 "000000"
373 }
374}
375
376#[cfg(unix)]
377fn is_executable(meta: &std::fs::Metadata) -> bool {
378 use std::os::unix::fs::PermissionsExt;
379 meta.permissions().mode() & 0o111 != 0
380}
381
382#[cfg(not(unix))]
383fn is_executable(_meta: &std::fs::Metadata) -> bool {
384 false
385}
386
387fn combine_porcelain(entries: &[StatusEntry]) -> Vec<([u8; 2], &str)> {
407 let mut tracked_order: Vec<&str> = Vec::new();
408 let mut tracked: std::collections::HashMap<&str, [u8; 2]> = std::collections::HashMap::new();
409 let mut untracked: Vec<&str> = Vec::new();
410 for e in entries {
411 if e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added {
414 untracked.push(&e.diff.path);
415 continue;
416 }
417 let c = porcelain_code(e.staging, e.diff.kind).as_bytes();
418 let slot = tracked.entry(&e.diff.path).or_insert_with(|| {
419 tracked_order.push(&e.diff.path);
420 [b' ', b' ']
421 });
422 if c[0] != b' ' {
424 slot[0] = c[0];
425 }
426 if c[1] != b' ' {
427 slot[1] = c[1];
428 }
429 }
430 let mut out: Vec<([u8; 2], &str)> =
431 tracked_order.into_iter().map(|p| (tracked[p], p)).collect();
432 out.extend(untracked.into_iter().map(|p| ([b'?', b'?'], p)));
433 out
434}
435
436fn porcelain_code(staging: StatusStaging, kind: DiffKind) -> &'static str {
438 match (staging, kind) {
439 (StatusStaging::Staged, DiffKind::Added) => "A ",
440 (StatusStaging::Staged, DiffKind::Removed) => "D ",
441 (StatusStaging::Staged, DiffKind::Modified) => "M ",
442 (StatusStaging::Staged, DiffKind::ModeChanged) => "T ",
443 (StatusStaging::Unstaged, DiffKind::Added) => "??",
447 (StatusStaging::Unstaged, DiffKind::Removed) => " D",
448 (StatusStaging::Unstaged, DiffKind::Modified) => " M",
449 (StatusStaging::Unstaged, DiffKind::ModeChanged) => " T",
450 (StatusStaging::PartiallyStaged, DiffKind::Added) => "AM",
455 (StatusStaging::PartiallyStaged, DiffKind::Removed) => "MD",
456 (StatusStaging::PartiallyStaged, DiffKind::Modified) => "MM",
457 (StatusStaging::PartiallyStaged, DiffKind::ModeChanged) => "MT",
458 }
459}
460
461fn render_human(mkit_dir: &std::path::Path, entries: &[StatusEntry]) -> u8 {
466 let mut stderr = std::io::stderr().lock();
467
468 match refs::read_head(mkit_dir) {
470 Ok(refs::Head::Branch(name)) => {
471 let _ = writeln!(stderr, "on branch {name}");
472 }
473 Ok(refs::Head::Detached(h)) => {
474 let _ = writeln!(stderr, "detached HEAD at {}", mkit_core::hash::to_hex(&h));
475 }
476 Err(_) => {
477 let _ = writeln!(stderr, "no HEAD yet");
478 }
479 }
480
481 if entries.is_empty() {
482 let _ = writeln!(stderr, "nothing to commit, working tree clean");
483 return exit::OK;
484 }
485
486 let staged: Vec<_> = entries
487 .iter()
488 .filter(|e| e.staging == StatusStaging::Staged)
489 .collect();
490 let unstaged: Vec<_> = entries
491 .iter()
492 .filter(|e| e.staging == StatusStaging::Unstaged)
493 .collect();
494 let partial: Vec<_> = entries
495 .iter()
496 .filter(|e| e.staging == StatusStaging::PartiallyStaged)
497 .collect();
498
499 if !staged.is_empty() {
500 let _ = writeln!(stderr, "\nChanges to be committed:");
501 for e in &staged {
502 let tag = diff_tag(e.diff.kind);
503 let _ = writeln!(stderr, " {tag} {}", e.diff.path);
504 }
505 }
506 if !unstaged.is_empty() {
507 let _ = writeln!(stderr, "\nChanges not staged for commit:");
508 for e in &unstaged {
509 let tag = diff_tag(e.diff.kind);
510 let _ = writeln!(stderr, " {tag} {}", e.diff.path);
511 }
512 }
513 if !partial.is_empty() {
514 let _ = writeln!(stderr, "\nChanges partially staged:");
515 for e in &partial {
516 let tag = diff_tag(e.diff.kind);
517 let _ = writeln!(stderr, " {tag} {}", e.diff.path);
518 }
519 }
520
521 exit::OK
522}
523
524fn diff_tag(kind: DiffKind) -> &'static str {
525 match kind {
526 DiffKind::Added => "A",
527 DiffKind::Removed => "D",
528 DiffKind::Modified => "M",
529 DiffKind::ModeChanged => "T",
530 }
531}
532
533fn emit_err(msg: &str, code: u8) -> u8 {
534 let mut stderr = std::io::stderr().lock();
535 let _ = writeln!(stderr, "error: {msg}");
536 code
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn porcelain_code_matrix() {
545 assert_eq!(porcelain_code(StatusStaging::Staged, DiffKind::Added), "A ",);
547 assert_eq!(
548 porcelain_code(StatusStaging::Staged, DiffKind::Removed),
549 "D ",
550 );
551 assert_eq!(
552 porcelain_code(StatusStaging::Staged, DiffKind::Modified),
553 "M ",
554 );
555 assert_eq!(
556 porcelain_code(StatusStaging::Unstaged, DiffKind::Added),
557 "??",
558 );
559 assert_eq!(
560 porcelain_code(StatusStaging::Unstaged, DiffKind::Modified),
561 " M",
562 );
563 assert_eq!(
564 porcelain_code(StatusStaging::Unstaged, DiffKind::Removed),
565 " D",
566 );
567 }
568
569 fn entry(path: &str, staging: StatusStaging, kind: DiffKind) -> StatusEntry {
570 StatusEntry {
571 diff: mkit_core::ops::DiffEntry {
572 path: path.to_string(),
573 kind,
574 old_hash: None,
575 new_hash: None,
576 old_mode: None,
577 new_mode: None,
578 },
579 staging,
580 }
581 }
582
583 fn combined(entries: &[StatusEntry]) -> Vec<(String, String)> {
584 combine_porcelain(entries)
585 .into_iter()
586 .map(|(xy, p)| (std::str::from_utf8(&xy).unwrap().to_string(), p.to_string()))
587 .collect()
588 }
589
590 #[test]
591 fn combine_merges_staged_and_unstaged_same_path_into_one_record() {
592 use DiffKind::Modified;
593 use StatusStaging::{Staged, Unstaged};
594 let entries = [
597 entry("a.txt", Staged, Modified),
598 entry("a.txt", Unstaged, Modified),
599 ];
600 assert_eq!(combined(&entries), vec![("MM".into(), "a.txt".into())]);
601 }
602
603 #[test]
604 fn combine_staged_add_plus_worktree_modify_is_am() {
605 let entries = [
606 entry("n.txt", StatusStaging::Staged, DiffKind::Added),
607 entry("n.txt", StatusStaging::Unstaged, DiffKind::Modified),
608 ];
609 assert_eq!(combined(&entries), vec![("AM".into(), "n.txt".into())]);
610 }
611
612 #[test]
613 fn combine_preserves_lone_records_and_untracked() {
614 let entries = [
615 entry("staged.txt", StatusStaging::Staged, DiffKind::Added),
616 entry("dirty.txt", StatusStaging::Unstaged, DiffKind::Modified),
617 entry("new.txt", StatusStaging::Unstaged, DiffKind::Added), ];
619 assert_eq!(
620 combined(&entries),
621 vec![
622 ("A ".into(), "staged.txt".into()),
623 (" M".into(), "dirty.txt".into()),
624 ("??".into(), "new.txt".into()),
625 ]
626 );
627 }
628
629 #[test]
630 fn combine_keeps_staged_delete_and_untracked_at_same_path_separate() {
631 use DiffKind::{Added, Removed};
632 use StatusStaging::{Staged, Unstaged};
633 let entries = [
638 entry("a.txt", Staged, Removed),
639 entry("a.txt", Unstaged, Added),
640 ];
641 assert_eq!(
642 combined(&entries),
643 vec![("D ".into(), "a.txt".into()), ("??".into(), "a.txt".into())]
644 );
645 }
646
647 #[test]
648 fn combine_orders_all_tracked_before_untracked_like_git() {
649 use DiffKind::{Added, Modified, Removed};
650 use StatusStaging::{Staged, Unstaged};
651 let entries = [
655 entry("a.txt", Staged, Removed),
656 entry("a.txt", Unstaged, Added),
657 entry("m.txt", Unstaged, Modified),
658 entry("b.txt", Unstaged, Added),
659 ];
660 assert_eq!(
661 combined(&entries),
662 vec![
663 ("D ".into(), "a.txt".into()),
664 (" M".into(), "m.txt".into()),
665 ("??".into(), "a.txt".into()),
666 ("??".into(), "b.txt".into()),
667 ]
668 );
669 }
670
671 #[test]
672 fn porcelain_codes_are_two_chars() {
673 use DiffKind::{Added, ModeChanged, Modified, Removed};
674 use StatusStaging::{PartiallyStaged, Staged, Unstaged};
675 for s in [Staged, Unstaged, PartiallyStaged] {
676 for k in [Added, Removed, Modified, ModeChanged] {
677 assert_eq!(porcelain_code(s, k).len(), 2, "{s:?} + {k:?}");
678 }
679 }
680 }
681}