use std::fs;
use std::path::Path;
use std::process::{Command, Output};
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run(cwd: &Path, xdg: &Path, args: &[&str]) -> Output {
Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("spawn mkit")
}
fn ok(cwd: &Path, xdg: &Path, args: &[&str]) -> Output {
let out = run(cwd, xdg, args);
assert!(
out.status.success(),
"expected `mkit {}` to succeed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
out
}
fn fail(cwd: &Path, xdg: &Path, args: &[&str]) -> Output {
let out = run(cwd, xdg, args);
assert!(
!out.status.success(),
"expected `mkit {}` to fail but it succeeded",
args.join(" ")
);
out
}
struct Repo {
dir: tempfile::TempDir,
xdg: tempfile::TempDir,
}
impl Repo {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let xdg = tempfile::tempdir().unwrap();
ok(dir.path(), xdg.path(), &["init"]);
ok(dir.path(), xdg.path(), &["keygen"]);
Repo { dir, xdg }
}
fn path(&self) -> &Path {
self.dir.path()
}
fn xdg(&self) -> &Path {
self.xdg.path()
}
fn write(&self, rel: &str, body: &[u8]) {
let p = self.path().join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
fn read(&self, rel: &str) -> String {
fs::read_to_string(self.path().join(rel)).unwrap()
}
fn add(&self, rel: &str) {
ok(self.path(), self.xdg(), &["add", rel]);
}
fn commit(&self, msg: &str) {
ok(self.path(), self.xdg(), &["commit", "-m", msg]);
}
fn commit_file(&self, rel: &str, body: &[u8], msg: &str) {
self.write(rel, body);
self.add(rel);
self.commit(msg);
}
fn head_hash(&self) -> String {
let out = ok(
self.path(),
self.xdg(),
&["log", "--format=json", "-n", "1"],
);
let line = String::from_utf8(out.stdout).unwrap();
let first = line.lines().next().expect("log produced no output");
let needle = "\"hash\":\"";
let start = first.find(needle).expect("log json has hash field") + needle.len();
first[start..start + 64].to_string()
}
fn status_porcelain(&self) -> String {
let out = ok(self.path(), self.xdg(), &["status", "--porcelain"]);
String::from_utf8(out.stdout).unwrap()
}
}
#[test]
fn restore_staged_unstages_leaving_worktree() {
let repo = Repo::new();
repo.commit_file("a.txt", b"v1\n", "base");
repo.write("a.txt", b"v2-staged\n");
repo.add("a.txt");
let before = repo.status_porcelain();
assert!(
before.contains("M a.txt"),
"expected a.txt to be staged-modified before restore: {before:?}"
);
ok(repo.path(), repo.xdg(), &["restore", "--staged", "a.txt"]);
assert_eq!(
repo.read("a.txt"),
"v2-staged\n",
"restore --staged must not touch the worktree file"
);
let porcelain = repo.status_porcelain();
assert!(
porcelain.contains(" M a.txt"),
"after unstage, a.txt must be reported as unstaged-modified: {porcelain:?}"
);
assert!(
!porcelain.contains("M a.txt"),
"after unstage, a.txt must NOT be staged-modified: {porcelain:?}"
);
}
#[test]
fn restore_worktree_discards_unstaged_edit_from_index() {
let repo = Repo::new();
repo.commit_file("a.txt", b"committed\n", "base");
repo.write("a.txt", b"staged\n");
repo.add("a.txt");
repo.write("a.txt", b"dirty-unstaged\n");
let out = fail(repo.path(), repo.xdg(), &["restore", "a.txt"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("unstaged") || stderr.contains("force"),
"expected restore to refuse clobbering an un-staged edit: {stderr}"
);
assert_eq!(repo.read("a.txt"), "dirty-unstaged\n", "file untouched");
ok(repo.path(), repo.xdg(), &["restore", "--force", "a.txt"]);
assert_eq!(
repo.read("a.txt"),
"staged\n",
"restore --force must restore the staged content"
);
}
#[test]
fn restore_worktree_from_head_when_index_clean() {
let repo = Repo::new();
repo.commit_file("a.txt", b"committed\n", "base");
repo.write("a.txt", b"local-edit\n");
let refused = fail(repo.path(), repo.xdg(), &["restore", "a.txt"]);
assert!(
String::from_utf8_lossy(&refused.stderr).contains("force"),
"an un-staged worktree edit is refused without --force"
);
ok(repo.path(), repo.xdg(), &["restore", "--force", "a.txt"]);
assert_eq!(
repo.read("a.txt"),
"committed\n",
"restore --force brings back the committed content"
);
}
#[test]
fn reset_soft_moves_head_only() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "c1");
let c1 = repo.head_hash();
repo.commit_file("a.txt", b"two\n", "c2");
ok(repo.path(), repo.xdg(), &["reset", "--soft", &c1]);
assert_eq!(repo.head_hash(), c1, "reset --soft must move HEAD to c1");
assert_eq!(repo.read("a.txt"), "two\n", "worktree untouched by --soft");
let porcelain = repo.status_porcelain();
assert!(
porcelain.contains("M a.txt"),
"soft reset leaves c2's content staged vs c1: {porcelain:?}"
);
}
#[test]
fn reset_mixed_moves_head_and_index() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "c1");
let c1 = repo.head_hash();
repo.commit_file("a.txt", b"two\n", "c2");
ok(repo.path(), repo.xdg(), &["reset", "--mixed", &c1]);
assert_eq!(repo.head_hash(), c1, "reset --mixed must move HEAD to c1");
assert_eq!(repo.read("a.txt"), "two\n", "worktree untouched by --mixed");
let porcelain = repo.status_porcelain();
assert!(
porcelain.contains(" M a.txt"),
"mixed reset leaves a.txt as an unstaged worktree change: {porcelain:?}"
);
assert!(
!porcelain.contains("M a.txt"),
"mixed reset must clear the staged entry for a.txt: {porcelain:?}"
);
}
#[test]
fn reset_default_target_is_head() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "c1");
let head = repo.head_hash();
repo.write("b.txt", b"new\n");
repo.add("b.txt");
ok(repo.path(), repo.xdg(), &["reset"]);
assert_eq!(repo.head_hash(), head, "bare reset must not move HEAD");
let porcelain = repo.status_porcelain();
assert!(
porcelain.contains("?? b.txt"),
"b.txt should now be untracked after reset: {porcelain:?}"
);
assert!(
!porcelain.contains("A b.txt"),
"reset must clear the staged-add of b.txt: {porcelain:?}"
);
}
#[test]
fn reset_resolves_short_hash_branch_and_relative() {
{
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "c1");
let c1 = repo.head_hash();
repo.commit_file("a.txt", b"two\n", "c2");
let short = &c1[..12];
ok(repo.path(), repo.xdg(), &["reset", "--soft", short]);
assert_eq!(repo.head_hash(), c1, "short-hash target must resolve");
}
{
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "c1");
let c1 = repo.head_hash();
ok(repo.path(), repo.xdg(), &["branch", "saved"]);
repo.commit_file("a.txt", b"two\n", "c2");
ok(repo.path(), repo.xdg(), &["reset", "--soft", "saved"]);
assert_eq!(repo.head_hash(), c1, "branch-name target must resolve");
}
{
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "c1");
let c1 = repo.head_hash();
repo.commit_file("a.txt", b"two\n", "c2");
ok(repo.path(), repo.xdg(), &["reset", "--soft", "HEAD~1"]);
assert_eq!(repo.head_hash(), c1, "HEAD~1 target must resolve");
}
}