use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use clap::Parser;
use mkit_core::hash::Hash;
use mkit_core::index::{self, EntryStatus, Index, IndexEntry};
use mkit_core::layout::RepoLayout;
use mkit_core::object::Object;
use mkit_core::ops::restore::{RestoreOptions, SparsePattern, restore_tree_to_worktree};
use mkit_core::store::ObjectStore;
use mkit_core::worktree;
use crate::clap_shim;
use crate::exit;
#[derive(Debug, Parser)]
#[command(
name = "mkit restore",
about = "Restore worktree files (discard local changes) or unstage them."
)]
struct RestoreOpts {
#[arg(short = 'S', long)]
staged: bool,
#[arg(short = 'W', long)]
worktree: bool,
#[arg(long, value_name = "REV")]
source: Option<String>,
#[arg(short = 'f', long)]
force: bool,
#[arg(required = true)]
paths: Vec<String>,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<RestoreOpts>("mkit restore", args) {
Ok(o) => o,
Err(code) => return code,
};
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 _lock = match super::acquire_worktree_lock(&layout) {
Ok(l) => l,
Err(code) => return code,
};
let do_staged = opts.staged;
let do_worktree = opts.worktree || !opts.staged;
let mut idx = match super::read_or_seed_index_from_head(&layout, &store) {
Ok(i) => i,
Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
};
let head_tree = match super::current_head_tree(&layout, &store) {
Ok(t) => t,
Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
};
let source_tree: Option<Hash> = match &opts.source {
Some(spec) => match resolve_source_tree(&store, &layout, spec) {
Ok(t) => Some(t),
Err((msg, code)) => return emit_err(&msg, code),
},
None => None,
};
let restore_index: Option<Index> = match resolve_restore_index(&store, source_tree, head_tree) {
Ok(i) => i,
Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
};
let mut rels: Vec<String> = Vec::with_capacity(opts.paths.len());
for raw in &opts.paths {
match index_path_for_arg(&cwd, Path::new(raw)) {
Ok(p) => rels.push(p),
Err(e) => return emit_err(&e, exit::DATAERR),
}
}
if do_staged && let Err(code) = restore_staged(&layout, &mut idx, restore_index.as_ref(), &rels)
{
return code;
}
if do_worktree
&& let Err(code) = restore_worktree(
&cwd,
&store,
&idx,
restore_index.as_ref(),
&rels,
source_tree.is_some(),
opts.force,
)
{
return code;
}
exit::OK
}
fn resolve_source_tree(
store: &ObjectStore,
layout: &RepoLayout,
spec: &str,
) -> Result<Hash, (String, u8)> {
let commit = super::revspec::resolve_revision(store, layout, spec)
.map_err(|e| (format!("bad --source '{spec}': {e}"), exit::GENERAL_ERROR))?;
match store.read_object(&commit) {
Ok(Object::Commit(c)) => Ok(c.tree_hash),
Ok(Object::Remix(r)) => Ok(r.tree_hash),
Ok(Object::Tree(_)) => Ok(commit),
Ok(_) => Err((
format!("--source '{spec}' does not resolve to a commit or tree"),
exit::GENERAL_ERROR,
)),
Err(e) => Err((format!("read --source object: {e}"), exit::GENERAL_ERROR)),
}
}
fn resolve_restore_index(
store: &ObjectStore,
source_tree: Option<Hash>,
head_tree: Option<Hash>,
) -> Result<Option<Index>, String> {
let tree = source_tree.or(head_tree);
match tree {
Some(t) => index::from_tree(store, t)
.map(Some)
.map_err(|e| format!("read source tree: {e}")),
None => Ok(None),
}
}
fn restore_staged(
layout: &RepoLayout,
idx: &mut Index,
restore_index: Option<&Index>,
rels: &[String],
) -> Result<(), u8> {
let mut matched_any = false;
for rel in rels {
let in_index = entry_matches(idx, rel);
let in_source = restore_index
.map(|src| entry_matches(src, rel))
.unwrap_or_default();
if in_index.is_empty() && in_source.is_empty() {
return Err(emit_err(
&format!("pathspec '{rel}' did not match any tracked or staged files"),
exit::GENERAL_ERROR,
));
}
matched_any = true;
let mut affected: Vec<String> = in_index
.iter()
.chain(in_source.iter())
.map(|e| e.path.clone())
.collect();
affected.sort_unstable();
affected.dedup();
for path in affected {
let source_entry =
restore_index.and_then(|src| src.find_entry(&path).map(|i| src.entries[i].clone()));
apply_index_restore(idx, &path, source_entry);
}
}
if !matched_any {
return Ok(());
}
index::write_index(layout, idx)
.map_err(|e| emit_err(&format!("write index: {e}"), exit::CANTCREAT))
}
fn apply_index_restore(idx: &mut Index, path: &str, source: Option<IndexEntry>) {
match source {
Some(src) => idx.upsert_entry(src),
None => {
idx.remove_path(path);
}
}
}
fn restore_worktree(
cwd: &Path,
store: &ObjectStore,
idx: &Index,
restore_index: Option<&Index>,
rels: &[String],
explicit_source: bool,
force: bool,
) -> Result<(), u8> {
let source = if explicit_source {
restore_index.unwrap_or(idx)
} else {
idx
};
let mut to_write: Vec<IndexEntry> = Vec::new();
for rel in rels {
let matches = entry_matches(source, rel);
if matches.is_empty() {
return Err(emit_err(
&format!("pathspec '{rel}' did not match any tracked files"),
exit::GENERAL_ERROR,
));
}
to_write.extend(matches);
}
to_write.sort_by(|a, b| a.path.cmp(&b.path));
to_write.dedup_by(|a, b| a.path == b.path);
if !force {
for entry in &to_write {
if let Some(reason) = dirty_reason(cwd, store, idx, &entry.path) {
return Err(emit_err(&reason, exit::GENERAL_ERROR));
}
}
}
let source_tree = match worktree::build_tree_from_index(store, source) {
Ok(t) => t,
Err(e) => {
return Err(emit_err(
&format!("build source tree: {e}"),
exit::GENERAL_ERROR,
));
}
};
let patterns: Vec<SparsePattern> = to_write
.iter()
.map(|e| SparsePattern {
pattern: e.path.clone(),
negated: false,
dir_only: false,
})
.collect();
let restore_opts = RestoreOptions {
clean: false,
sparse_patterns: Some(patterns),
};
if let Err(e) = restore_tree_to_worktree(store, &source_tree, cwd, &restore_opts) {
return Err(emit_err(&format!("restore worktree: {e}"), exit::CANTCREAT));
}
Ok(())
}
fn entry_matches(idx: &Index, rel: &str) -> Vec<IndexEntry> {
idx.entries
.iter()
.filter(|e| {
e.status != EntryStatus::Removed && super::index_path_matches_or_descends(&e.path, rel)
})
.cloned()
.collect()
}
fn dirty_reason(root: &Path, _store: &ObjectStore, idx: &Index, path: &str) -> Option<String> {
let staged = idx
.entries
.iter()
.find(|e| e.path == path && e.status != EntryStatus::Removed)?;
let abs = root.join(path);
let meta = abs.symlink_metadata().ok()?;
let work_hash = if meta.file_type().is_symlink() {
let target = std::fs::read_link(&abs).ok()?;
let target_str = target.to_str()?;
symlink_blob_hash(target_str)?
} else if meta.file_type().is_file() {
worktree::read_regular_file_bounded(&abs)
.ok()
.and_then(|(_, data)| worktree::hash_file_object(&data).ok())?
} else {
return None;
};
if work_hash == staged.object_hash {
None
} else {
Some(format!(
"'{path}' has unstaged changes; use --force to discard them"
))
}
}
fn symlink_blob_hash(target: &str) -> Option<Hash> {
let prologue = mkit_core::serialize::blob_prologue(target.len()).ok()?;
let mut hasher = mkit_core::hash::Hasher::new();
hasher.update(&prologue).update(target.as_bytes());
Some(hasher.finalize())
}
fn index_path_for_arg(root: &Path, arg: &Path) -> Result<String, String> {
let rel = if arg.is_absolute() {
absolute_arg_to_repo_relative(root, arg)?
} else {
arg.to_path_buf()
};
let mut parts: Vec<String> = Vec::new();
for component in rel.as_path().components() {
match component {
Component::Normal(part) => {
let part = part
.to_str()
.ok_or_else(|| "path is not valid UTF-8".to_string())?;
parts.push(part.to_string());
}
Component::CurDir => {}
Component::ParentDir => {
if parts.pop().is_none() {
return Err(format!("invalid path: {}", arg.display()));
}
}
Component::Prefix(_) | Component::RootDir => {
return Err(format!("invalid path: {}", arg.display()));
}
}
}
let path = parts.join("/");
if !index::validate_index_path(&path) {
return Err(format!("invalid path: {path}"));
}
Ok(path)
}
fn absolute_arg_to_repo_relative(root: &Path, arg: &Path) -> Result<PathBuf, String> {
let root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
if let Ok(rel) = arg.strip_prefix(&root) {
return Ok(rel.to_path_buf());
}
let mut suffix: Vec<OsString> = vec![
arg.file_name()
.ok_or_else(|| format!("invalid path: {}", arg.display()))?
.to_os_string(),
];
let mut ancestor = arg
.parent()
.ok_or_else(|| format!("invalid path: {}", arg.display()))?;
while ancestor.symlink_metadata().is_err() {
let name = ancestor
.file_name()
.ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
suffix.push(name.to_os_string());
ancestor = ancestor
.parent()
.ok_or_else(|| format!("path is outside repository: {}", arg.display()))?;
}
let mut normalized = ancestor
.canonicalize()
.map_err(|e| format!("path {}: {e}", ancestor.display()))?;
for component in suffix.iter().rev() {
normalized.push(component);
}
normalized
.strip_prefix(&root)
.map(Path::to_path_buf)
.map_err(|_| format!("path is outside repository: {}", arg.display()))
}
use super::error as emit_err;