use std::io::Write;
use std::path::Path;
use clap::{Parser, ValueEnum};
use mkit_core::Hash;
use mkit_core::index::{self, EntryStatus, Index};
use mkit_core::layout::RepoLayout;
use mkit_core::ops::{
DiffEntry, DiffKind, StatusEntry, StatusStaging, detect_exact_renames, status_diff_observed,
};
use mkit_core::refs;
use mkit_core::store::ObjectStore;
use crate::clap_shim;
use crate::exit;
use crate::format;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum PorcelainVersion {
V1,
V2,
}
#[derive(Debug, Parser)]
#[command(
name = "mkit status",
about = "Show working-tree changes relative to HEAD."
)]
struct StatusOpts {
#[arg(long, value_name = "VERSION", num_args = 0..=1, default_missing_value = "v1")]
porcelain: Option<PorcelainVersion>,
#[arg(short = 's', long = "short")]
short: bool,
#[arg(short = 'z')]
z: bool,
#[arg(long = "no-renames")]
no_renames: bool,
#[arg(long = "find-renames", value_name = "N", num_args = 0..=1, require_equals = true)]
find_renames: Option<String>,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<StatusOpts>("mkit status", args) {
Ok(o) => o,
Err(code) => return code,
};
let porcelain = opts.porcelain.is_some() || opts.short || opts.z;
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
let head_tree: Option<mkit_core::Hash> = match super::current_head_tree(&layout, &store) {
Ok(t) => t,
Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
};
let idx = match index::read_index(&layout) {
Ok(idx) if idx.entries.is_empty() => None,
Ok(idx) => Some(idx),
Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
};
if let Some(t) = &opts.find_renames {
let n = t.trim_end_matches('%');
if !n.is_empty() && n.parse::<u8>().is_err() {
return emit_err(&format!("invalid --find-renames value: {t}"), exit::USAGE);
}
}
let (mut entries, observations) =
match status_diff_observed(&store, head_tree.as_ref(), &cwd, idx.as_ref()) {
Ok(v) => v,
Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
};
if idx.is_some() {
refresh_stat_cache(&layout, &observations);
}
if !opts.no_renames {
entries = detect_status_renames(entries);
}
if porcelain {
if opts.porcelain == Some(PorcelainVersion::V2) {
render_porcelain_v2(&store, head_tree.as_ref(), &layout, &entries, opts.z)
} else {
render_porcelain(&entries, opts.z)
}
} else {
render_human(&layout, &entries)
}
}
fn refresh_stat_cache(layout: &RepoLayout, observations: &[mkit_core::worktree::StatObservation]) {
if observations.is_empty() {
return;
}
match std::fs::File::open(mkit_core::index::index_path(layout)) {
Ok(mut f) => {
use std::io::Read as _;
let mut header = [0u8; 5];
if f.read_exact(&mut header).is_err() || header[4] != mkit_core::index::FORMAT_VERSION {
return;
}
}
Err(_) => return,
}
let Ok(_lock) = mkit_core::repo_lock::acquire(
layout.worktree_state_dir(),
super::WORKTREE_LOCK,
std::time::Duration::from_millis(10),
) else {
return;
};
let Ok(mut fresh) = index::read_index(layout) else {
return;
};
let by_path: std::collections::HashMap<&str, &mkit_core::worktree::StatObservation> =
observations.iter().map(|o| (o.path.as_str(), o)).collect();
let mut updated = false;
for e in &mut fresh.entries {
let Some(obs) = by_path.get(e.path.as_str()) else {
continue;
};
if e.object_hash == obs.object_hash
&& (e.mtime_ns != obs.mtime_ns
|| e.size != obs.size
|| e.ino != obs.ino
|| e.ctime_ns != obs.ctime_ns)
{
e.mtime_ns = obs.mtime_ns;
e.size = obs.size;
e.ino = obs.ino;
e.ctime_ns = obs.ctime_ns;
updated = true;
}
}
if updated {
let _ = index::write_index(layout, &fresh);
}
}
fn render_porcelain(entries: &[StatusEntry], z: bool) -> u8 {
let disp = |p: &str| super::c_quote_path(p).unwrap_or_else(|| p.to_string());
let mut stdout = std::io::stdout().lock();
for (xy, path, old_path) in combine_porcelain(entries) {
let code = std::str::from_utf8(&xy).unwrap_or("??");
match old_path {
Some(old) if z => {
let _ = write!(stdout, "{code} {path}\0{old}\0");
}
Some(old) => {
let _ = writeln!(stdout, "{code} {} -> {}", disp(old), disp(path));
}
None if z => {
let _ = write!(stdout, "{code} {path}\0");
}
None => {
let _ = writeln!(stdout, "{code} {}", disp(path));
}
}
}
exit::OK
}
fn render_porcelain_v2(
store: &ObjectStore,
head_tree: Option<&Hash>,
layout: &RepoLayout,
entries: &[StatusEntry],
z: bool,
) -> u8 {
let head_index = match head_tree {
Some(h) => match index::from_tree(store, *h) {
Ok(i) => i,
Err(e) => return emit_err(&format!("read HEAD tree: {e}"), exit::GENERAL_ERROR),
},
None => Index::new(),
};
let work_index = match super::read_or_seed_index_from_head(layout, store) {
Ok(i) => i,
Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
};
let mut stdout = std::io::stdout().lock();
for (xy, path, old_path) in combine_porcelain(entries) {
if xy == [b'?', b'?'] {
emit_v2_record(&mut stdout, "? ", path, z);
continue;
}
let x = if xy[0] == b' ' { '.' } else { xy[0] as char };
let y = if xy[1] == b' ' { '.' } else { xy[1] as char };
if let Some(old) = old_path {
let (m_head, h_head) = v2_mode_and_id(&head_index, old);
let (m_index, h_index) = v2_mode_and_id(&work_index, path);
let m_work = worktree_mode(layout.worktree_root(), path);
let prefix =
format!("2 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} R100 ");
emit_v2_rename_record(&mut stdout, &prefix, path, old, z);
continue;
}
let (m_head, h_head) = v2_mode_and_id(&head_index, path);
let (m_index, h_index) = v2_mode_and_id(&work_index, path);
let m_work = worktree_mode(layout.worktree_root(), path);
let prefix = format!("1 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} ");
emit_v2_record(&mut stdout, &prefix, path, z);
}
exit::OK
}
fn emit_v2_rename_record(out: &mut impl Write, prefix: &str, new: &str, old: &str, z: bool) {
if z {
let _ = write!(out, "{prefix}{new}\0{old}\0");
} else {
let nq = super::c_quote_path(new).unwrap_or_else(|| new.to_string());
let oq = super::c_quote_path(old).unwrap_or_else(|| old.to_string());
let _ = writeln!(out, "{prefix}{nq}\t{oq}");
}
}
fn emit_v2_record(out: &mut impl Write, prefix: &str, path: &str, z: bool) {
if z {
let _ = write!(out, "{prefix}{path}\0");
} else if let Some(quoted) = super::c_quote_path(path) {
let _ = writeln!(out, "{prefix}{quoted}");
} else {
let _ = writeln!(out, "{prefix}{path}");
}
}
fn v2_mode_and_id(index: &Index, path: &str) -> (&'static str, String) {
match index.find_entry(path) {
Some(i) if index.entries[i].status != EntryStatus::Removed => {
let e = &index.entries[i];
(git_mode(e.status), format::hex_hash(&e.object_hash))
}
_ => ("000000", format::hex_hash(&mkit_core::hash::ZERO)),
}
}
fn git_mode(status: EntryStatus) -> &'static str {
match status {
EntryStatus::Executable => "100755",
EntryStatus::Symlink => "120000",
_ => "100644",
}
}
fn worktree_mode(root: &Path, path: &str) -> &'static str {
let Ok(meta) = std::fs::symlink_metadata(root.join(path)) else {
return "000000";
};
if meta.is_symlink() {
"120000"
} else if meta.is_file() {
if is_executable(&meta) {
"100755"
} else {
"100644"
}
} else {
"000000"
}
}
#[cfg(unix)]
fn is_executable(meta: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_meta: &std::fs::Metadata) -> bool {
false
}
fn combine_porcelain(entries: &[StatusEntry]) -> Vec<([u8; 2], &str, Option<&str>)> {
let mut tracked_order: Vec<&str> = Vec::new();
let mut tracked: std::collections::HashMap<&str, ([u8; 2], Option<&str>)> =
std::collections::HashMap::new();
let mut untracked: Vec<&str> = Vec::new();
for e in entries {
if e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added {
untracked.push(&e.diff.path);
continue;
}
let c = porcelain_code(e.staging, e.diff.kind).as_bytes();
let slot = tracked.entry(&e.diff.path).or_insert_with(|| {
tracked_order.push(&e.diff.path);
([b' ', b' '], None)
});
if c[0] != b' ' {
slot.0[0] = c[0];
}
if c[1] != b' ' {
slot.0[1] = c[1];
}
if e.diff.kind == DiffKind::Renamed {
slot.1 = e.diff.old_path.as_deref();
}
}
let mut out: Vec<([u8; 2], &str, Option<&str>)> = tracked_order
.into_iter()
.map(|p| {
let s = tracked[p];
(s.0, p, s.1)
})
.collect();
out.extend(untracked.into_iter().map(|p| ([b'?', b'?'], p, None)));
out
}
fn porcelain_code(staging: StatusStaging, kind: DiffKind) -> &'static str {
match (staging, kind) {
(StatusStaging::Staged, DiffKind::Added) => "A ",
(StatusStaging::Staged, DiffKind::Removed) => "D ",
(StatusStaging::Staged, DiffKind::Modified) => "M ",
(StatusStaging::Staged, DiffKind::ModeChanged) => "T ",
(StatusStaging::Unstaged, DiffKind::Added) => "??",
(StatusStaging::Unstaged, DiffKind::Removed) => " D",
(StatusStaging::Unstaged, DiffKind::Modified) => " M",
(StatusStaging::Unstaged, DiffKind::ModeChanged) => " T",
(StatusStaging::PartiallyStaged, DiffKind::Added) => "AM",
(StatusStaging::PartiallyStaged, DiffKind::Removed) => "MD",
(StatusStaging::PartiallyStaged, DiffKind::Modified) => "MM",
(StatusStaging::PartiallyStaged, DiffKind::ModeChanged) => "MT",
(StatusStaging::Staged | StatusStaging::PartiallyStaged, DiffKind::Renamed) => "R ",
(StatusStaging::Unstaged, DiffKind::Renamed) => " R",
}
}
fn render_human(layout: &RepoLayout, entries: &[StatusEntry]) -> u8 {
let mut stderr = std::io::stderr().lock();
match refs::read_head(layout) {
Ok(refs::Head::Branch(name)) => {
let _ = writeln!(stderr, "On branch {name}");
if refs::resolve_head(layout).ok().flatten().is_none() {
let _ = writeln!(stderr, "\nNo commits yet");
}
}
Ok(refs::Head::Detached(h)) => {
let _ = writeln!(
stderr,
"HEAD detached at {}",
crate::format::short_hash(&h, crate::format::SUMMARY_ABBREV)
);
}
Err(_) => {
let _ = writeln!(stderr, "On branch main\n\nNo commits yet");
}
}
if entries.is_empty() {
let _ = writeln!(stderr, "\nnothing to commit, working tree clean");
return exit::OK;
}
let staged: Vec<_> = entries
.iter()
.filter(|e| e.staging == StatusStaging::Staged)
.collect();
let partial: Vec<_> = entries
.iter()
.filter(|e| e.staging == StatusStaging::PartiallyStaged)
.collect();
let unstaged: Vec<_> = entries
.iter()
.filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind != DiffKind::Added)
.collect();
let untracked: Vec<_> = entries
.iter()
.filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added)
.collect();
if !staged.is_empty() {
let _ = writeln!(stderr, "\nChanges to be committed:");
let _ = writeln!(
stderr,
" (use \"mkit restore --staged <file>...\" to unstage)"
);
for e in &staged {
let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
}
}
if !partial.is_empty() {
let _ = writeln!(stderr, "\nChanges both staged and not staged:");
for e in &partial {
let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
}
}
if !unstaged.is_empty() {
let _ = writeln!(stderr, "\nChanges not staged for commit:");
let _ = writeln!(
stderr,
" (use \"mkit add <file>...\" to update what will be committed)"
);
let _ = writeln!(
stderr,
" (use \"mkit restore <file>...\" to discard changes in working directory)"
);
for e in &unstaged {
let _ = writeln!(stderr, " {:<12}{}", human_label(e.diff.kind), human_path(e));
}
}
if !untracked.is_empty() {
let _ = writeln!(stderr, "\nUntracked files:");
let _ = writeln!(
stderr,
" (use \"mkit add <file>...\" to include in what will be committed)"
);
for e in &untracked {
let _ = writeln!(stderr, "\t{}", e.diff.path);
}
}
if staged.is_empty() && partial.is_empty() {
if !unstaged.is_empty() {
let _ = writeln!(
stderr,
"\nno changes added to commit (use \"mkit add\" and/or \"mkit commit -a\")"
);
} else if !untracked.is_empty() {
let _ = writeln!(
stderr,
"\nnothing added to commit but untracked files present (use \"mkit add\" to track)"
);
}
}
exit::OK
}
fn human_label(kind: DiffKind) -> &'static str {
match kind {
DiffKind::Added => "new file:",
DiffKind::Removed => "deleted:",
DiffKind::Modified => "modified:",
DiffKind::ModeChanged => "typechange:",
DiffKind::Renamed => "renamed:",
}
}
fn human_path(e: &StatusEntry) -> String {
match (e.diff.kind, &e.diff.old_path) {
(DiffKind::Renamed, Some(old)) => format!("{old} -> {}", e.diff.path),
_ => e.diff.path.clone(),
}
}
fn detect_status_renames(entries: Vec<StatusEntry>) -> Vec<StatusEntry> {
let (staged, others): (Vec<StatusEntry>, Vec<StatusEntry>) = entries
.into_iter()
.partition(|e| e.staging == StatusStaging::Staged);
let mut staged_diffs: Vec<DiffEntry> = staged.into_iter().map(|e| e.diff).collect();
detect_exact_renames(&mut staged_diffs);
let mut out: Vec<StatusEntry> = staged_diffs
.into_iter()
.map(|d| StatusEntry {
diff: d,
staging: StatusStaging::Staged,
})
.chain(others)
.collect();
out.sort_by(|a, b| {
a.diff
.path
.cmp(&b.diff.path)
.then_with(|| staging_rank(a.staging).cmp(&staging_rank(b.staging)))
});
out
}
fn staging_rank(s: StatusStaging) -> u8 {
match s {
StatusStaging::Staged => 0,
StatusStaging::PartiallyStaged => 1,
StatusStaging::Unstaged => 2,
}
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn porcelain_code_matrix() {
assert_eq!(porcelain_code(StatusStaging::Staged, DiffKind::Added), "A ",);
assert_eq!(
porcelain_code(StatusStaging::Staged, DiffKind::Removed),
"D ",
);
assert_eq!(
porcelain_code(StatusStaging::Staged, DiffKind::Modified),
"M ",
);
assert_eq!(
porcelain_code(StatusStaging::Unstaged, DiffKind::Added),
"??",
);
assert_eq!(
porcelain_code(StatusStaging::Unstaged, DiffKind::Modified),
" M",
);
assert_eq!(
porcelain_code(StatusStaging::Unstaged, DiffKind::Removed),
" D",
);
}
fn entry(path: &str, staging: StatusStaging, kind: DiffKind) -> StatusEntry {
StatusEntry {
diff: mkit_core::ops::DiffEntry {
path: path.to_string(),
kind,
old_hash: None,
new_hash: None,
old_mode: None,
new_mode: None,
old_path: None,
},
staging,
}
}
fn combined(entries: &[StatusEntry]) -> Vec<(String, String)> {
combine_porcelain(entries)
.into_iter()
.map(|(xy, p, _)| (std::str::from_utf8(&xy).unwrap().to_string(), p.to_string()))
.collect()
}
#[test]
fn combine_merges_staged_and_unstaged_same_path_into_one_record() {
use DiffKind::Modified;
use StatusStaging::{Staged, Unstaged};
let entries = [
entry("a.txt", Staged, Modified),
entry("a.txt", Unstaged, Modified),
];
assert_eq!(combined(&entries), vec![("MM".into(), "a.txt".into())]);
}
#[test]
fn combine_staged_add_plus_worktree_modify_is_am() {
let entries = [
entry("n.txt", StatusStaging::Staged, DiffKind::Added),
entry("n.txt", StatusStaging::Unstaged, DiffKind::Modified),
];
assert_eq!(combined(&entries), vec![("AM".into(), "n.txt".into())]);
}
#[test]
fn combine_preserves_lone_records_and_untracked() {
let entries = [
entry("staged.txt", StatusStaging::Staged, DiffKind::Added),
entry("dirty.txt", StatusStaging::Unstaged, DiffKind::Modified),
entry("new.txt", StatusStaging::Unstaged, DiffKind::Added), ];
assert_eq!(
combined(&entries),
vec![
("A ".into(), "staged.txt".into()),
(" M".into(), "dirty.txt".into()),
("??".into(), "new.txt".into()),
]
);
}
#[test]
fn combine_keeps_staged_delete_and_untracked_at_same_path_separate() {
use DiffKind::{Added, Removed};
use StatusStaging::{Staged, Unstaged};
let entries = [
entry("a.txt", Staged, Removed),
entry("a.txt", Unstaged, Added),
];
assert_eq!(
combined(&entries),
vec![("D ".into(), "a.txt".into()), ("??".into(), "a.txt".into())]
);
}
#[test]
fn combine_orders_all_tracked_before_untracked_like_git() {
use DiffKind::{Added, Modified, Removed};
use StatusStaging::{Staged, Unstaged};
let entries = [
entry("a.txt", Staged, Removed),
entry("a.txt", Unstaged, Added),
entry("m.txt", Unstaged, Modified),
entry("b.txt", Unstaged, Added),
];
assert_eq!(
combined(&entries),
vec![
("D ".into(), "a.txt".into()),
(" M".into(), "m.txt".into()),
("??".into(), "a.txt".into()),
("??".into(), "b.txt".into()),
]
);
}
#[test]
fn porcelain_codes_are_two_chars() {
use DiffKind::{Added, ModeChanged, Modified, Removed};
use StatusStaging::{PartiallyStaged, Staged, Unstaged};
for s in [Staged, Unstaged, PartiallyStaged] {
for k in [Added, Removed, Modified, ModeChanged] {
assert_eq!(porcelain_code(s, k).len(), 2, "{s:?} + {k:?}");
}
}
}
}