mod support;
use std::path::Path;
use grit_lib::error::Error;
use grit_lib::index::write_tree::{write_tree_update_index, WriteTreeFlags};
use grit_lib::objects::ObjectId;
use grit_lib::porcelain::reset::{reset_to_commit, ResetMode, ResetOptions};
use grit_lib::repo::{InitOptions, Repository};
use grit_lib::revwalk::rev_parse::resolve_revision_as_commit_without_index_dwim;
use grit_lib::{objects, refs, RefStorageFormat};
use grit_test_support::git_cmd;
use support::{
assert_porcelain_v2_matches_git, git_status_porcelain_v2_lines, grit_status_porcelain_v2_lines,
open_grit_repo,
};
use tempfile::TempDir;
fn identity() -> String {
"T <t@t> 0 +0000".into()
}
fn commit_file(
repo: &Repository,
path: &str,
contents: &str,
parent: Option<ObjectId>,
) -> ObjectId {
let wt = repo.work_tree().expect("wt");
std::fs::write(wt.join(path), contents).expect("write");
let mut index = repo.index_handle().load().expect("index");
let oid = repo
.odb()
.write(objects::ObjectKind::Blob, contents.as_bytes())
.expect("blob");
let entry = grit_lib::index::entry_from_stat(
&wt.join(path),
path.as_bytes(),
oid,
grit_lib::index::MODE_REGULAR,
)
.expect("entry");
index.add_or_replace(entry);
index.sort();
let tree = write_tree_update_index(repo.odb(), &mut index, "", WriteTreeFlags::silent())
.expect("tree");
repo.index_handle().write(&mut index).expect("write index");
let commit = objects::CommitData {
tree,
parents: parent.into_iter().collect(),
author: "T <t@t> 0 +0000".into(),
committer: "T <t@t> 0 +0000".into(),
author_raw: vec![],
committer_raw: vec![],
encoding: None,
message: "c\n".into(),
raw_message: None,
extra_headers: Vec::new(),
};
let co = repo
.odb()
.write(
objects::ObjectKind::Commit,
&objects::serialize_commit(&commit),
)
.expect("commit");
refs::write_ref(repo.git_dir(), "refs/heads/main", &co).expect("branch");
refs::write_symbolic_ref(repo.git_dir(), "HEAD", "refs/heads/main").expect("symhead");
co
}
#[test]
fn mixed_reset_readable_by_git() {
let dir = TempDir::new().expect("tempdir");
let repo = Repository::init(
dir.path(),
&InitOptions::new()
.with_initial_branch("main".to_owned())
.with_ref_storage(RefStorageFormat::Files),
)
.expect("init");
let c1 = commit_file(&repo, "f", "1\n", None);
commit_file(&repo, "f", "2\n", Some(c1));
reset_to_commit(
&repo,
&ResetOptions::new(c1, ResetMode::Mixed, identity(), "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("reset");
git_cmd(&["fsck"]).in_dir(dir.path()).suc();
git_cmd(&["status", "--porcelain"]).in_dir(dir.path()).suc();
}
fn init_git_repo(root: &Path) {
git_cmd(&["init", "-b", "main"]).in_dir(root).suc();
git_cmd(&["config", "user.email", "t@t.com"])
.in_dir(root)
.suc();
git_cmd(&["config", "user.name", "T"]).in_dir(root).suc();
}
fn two_commit_history(root: &Path) -> ObjectId {
std::fs::write(root.join("a"), "v1\n").expect("write");
git_cmd(&["add", "a"]).in_dir(root).suc();
git_cmd(&["commit", "-m", "c1"]).in_dir(root).suc();
let c1_hex = git_cmd(&["rev-parse", "HEAD"])
.in_dir(root)
.suc()
.stdout
.trim()
.to_owned();
std::fs::write(root.join("a"), "v2\n").expect("write");
git_cmd(&["commit", "-am", "c2"]).in_dir(root).suc();
ObjectId::from_hex(&c1_hex).expect("c1 oid")
}
#[test]
fn reset_keep_preserves_unrelated_local_changes_like_git() {
let dir = TempDir::new().expect("tempdir");
init_git_repo(dir.path());
two_commit_history(dir.path());
std::fs::write(dir.path().join("b"), "local\n").expect("local b");
git_cmd(&["reset", "--keep", "HEAD~1"])
.in_dir(dir.path())
.suc();
let git_lines = git_status_porcelain_v2_lines(dir.path());
let dir2 = TempDir::new().expect("tempdir2");
init_git_repo(dir2.path());
two_commit_history(dir2.path());
std::fs::write(dir2.path().join("b"), "local\n").expect("local b");
let repo = open_grit_repo(dir2.path());
let parent = resolve_revision_as_commit_without_index_dwim(&repo, "HEAD~1").expect("parent");
reset_to_commit(
&repo,
&ResetOptions::new(parent, ResetMode::Keep, "T <t@t.com> 0 +0000", "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("keep reset");
assert_porcelain_v2_matches_git(dir2.path(), &repo);
let grit_lines = grit_status_porcelain_v2_lines(&repo);
assert_eq!(grit_lines, git_lines);
}
#[test]
fn reset_keep_refuses_when_changed_file_has_local_edits() {
let dir = TempDir::new().expect("tempdir");
init_git_repo(dir.path());
two_commit_history(dir.path());
std::fs::write(dir.path().join("a"), "edited\n").expect("edit a");
git_cmd(&["reset", "--keep", "HEAD~1"])
.in_dir(dir.path())
.with_status(128);
let dir2 = TempDir::new().expect("tempdir2");
init_git_repo(dir2.path());
two_commit_history(dir2.path());
std::fs::write(dir2.path().join("a"), "edited\n").expect("edit a");
let repo = open_grit_repo(dir2.path());
let head_before = refs::resolve_ref(repo.git_dir(), "HEAD").expect("head");
let parent = resolve_revision_as_commit_without_index_dwim(&repo, "HEAD~1").expect("parent");
let err = reset_to_commit(
&repo,
&ResetOptions::new(parent, ResetMode::Keep, "T <t@t.com> 0 +0000", "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect_err("grit should refuse");
assert!(
matches!(err, Error::WouldOverwrite { .. }),
"expected WouldOverwrite, got {err:?}"
);
assert_eq!(
refs::resolve_ref(repo.git_dir(), "HEAD").expect("head"),
head_before
);
let git_lines = git_status_porcelain_v2_lines(dir.path());
let grit_lines = grit_status_porcelain_v2_lines(&repo);
assert_eq!(grit_lines, git_lines);
}
#[test]
fn reset_dry_run_hard_lists_overwritten_paths_and_changes_nothing() {
let dir = TempDir::new().expect("tempdir");
let repo = Repository::init(
dir.path(),
&InitOptions::new()
.with_initial_branch("main".to_owned())
.with_ref_storage(RefStorageFormat::Files),
)
.expect("init");
let c1 = {
commit_file(&repo, "a", "1\n", None);
refs::resolve_ref(repo.git_dir(), "HEAD").expect("head")
};
commit_file(&repo, "a", "2\n", Some(c1));
let head_before = refs::resolve_ref(repo.git_dir(), "HEAD").expect("head");
let wt = repo.work_tree().expect("wt");
std::fs::write(wt.join("a"), "dirty\n").expect("dirty");
let mut opts = ResetOptions::new(c1, ResetMode::Hard, "T <t@t.com> 0 +0000", "reset");
opts.dry_run = true;
let outcome = reset_to_commit(
&repo,
&opts,
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("dry-run");
assert!(outcome.dry_run);
assert!(outcome.would_overwrite.iter().any(|p| p == "a"));
assert_eq!(
refs::resolve_ref(repo.git_dir(), "HEAD").expect("head"),
head_before
);
assert_eq!(std::fs::read(wt.join("a")).expect("disk"), b"dirty\n");
}
#[test]
fn reset_orphaned_commits_main_only_git_init() {
let dir = TempDir::new().expect("tempdir");
init_git_repo(dir.path());
std::fs::write(dir.path().join("f.txt"), "1").expect("write");
git_cmd(&["add", "-A"]).in_dir(dir.path()).suc();
git_cmd(&["commit", "-m", "c1"]).in_dir(dir.path()).suc();
let c1_hex = git_cmd(&["rev-parse", "HEAD"])
.in_dir(dir.path())
.suc()
.stdout
.trim()
.to_owned();
for v in ["2", "3"] {
std::fs::write(dir.path().join("f.txt"), v).expect("write");
git_cmd(&["add", "-A"]).in_dir(dir.path()).suc();
git_cmd(&["commit", "-m", &format!("c{v}")])
.in_dir(dir.path())
.suc();
}
let c2_hex = git_cmd(&["rev-parse", "HEAD~1"])
.in_dir(dir.path())
.suc()
.stdout
.trim()
.to_owned();
let c3_hex = git_cmd(&["rev-parse", "HEAD"])
.in_dir(dir.path())
.suc()
.stdout
.trim()
.to_owned();
let repo = open_grit_repo(dir.path());
let c1 = resolve_revision_as_commit_without_index_dwim(&repo, "HEAD~2").expect("c1");
assert_eq!(c1, ObjectId::from_hex(&c1_hex).expect("c1 hex"));
let mut opts = ResetOptions::new(c1, ResetMode::Hard, "T <t@t.com> 0 +0000", "reset");
opts.dry_run = true;
let preview = reset_to_commit(
&repo,
&opts,
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("dry-run reset");
assert_eq!(preview.orphaned_total, 2, "c2 and c3 should orphan");
assert_eq!(preview.orphaned_commits.len(), 2);
assert_eq!(
preview.orphaned_commits[0].oid,
ObjectId::from_hex(&c3_hex).expect("c3")
);
assert_eq!(
preview.orphaned_commits[1].oid,
ObjectId::from_hex(&c2_hex).expect("c2")
);
assert_eq!(preview.orphaned_commits[0].subject, "c3");
assert_eq!(preview.orphaned_commits[1].subject, "c2");
}
#[test]
fn reset_orphaned_commits_excludes_commits_held_by_other_refs() {
let dir = TempDir::new().expect("tempdir");
let repo = Repository::init(
dir.path(),
&InitOptions::new()
.with_initial_branch("main".to_owned())
.with_ref_storage(RefStorageFormat::Files),
)
.expect("init");
let c1 = commit_file(&repo, "f", "1\n", None);
let c2 = commit_file(&repo, "f", "2\n", Some(c1));
let c3 = commit_file(&repo, "f", "3\n", Some(c2));
refs::write_ref(repo.git_dir(), "refs/heads/keep", &c2).expect("branch keep");
let outcome = reset_to_commit(
&repo,
&ResetOptions::new(c1, ResetMode::Mixed, "T <t@t.com> 0 +0000", "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("reset");
assert_eq!(outcome.orphaned_total, 1);
assert_eq!(outcome.orphaned_commits.len(), 1);
assert_eq!(outcome.orphaned_commits[0].oid, c3);
let _ = c2;
}
#[test]
fn reset_orphan_zero_when_other_ref_holds_old_tip() {
let dir = TempDir::new().expect("tempdir");
let repo = Repository::init(
dir.path(),
&InitOptions::new()
.with_initial_branch("main".to_owned())
.with_ref_storage(RefStorageFormat::Files),
)
.expect("init");
let c1 = commit_file(&repo, "f", "1\n", None);
let c2 = commit_file(&repo, "f", "2\n", Some(c1));
let c3 = commit_file(&repo, "f", "3\n", Some(c2));
refs::write_ref(repo.git_dir(), "refs/heads/hold", &c3).expect("hold");
let outcome = reset_to_commit(
&repo,
&ResetOptions::new(c1, ResetMode::Mixed, "T <t@t.com> 0 +0000", "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("reset");
assert_eq!(outcome.orphaned_total, 0);
}
#[test]
fn reset_keep_rejects_unmerged_index_like_git() {
let dir = TempDir::new().expect("tempdir");
init_git_repo(dir.path());
two_commit_history(dir.path());
let tip = git_cmd(&["rev-parse", "HEAD"])
.in_dir(dir.path())
.suc()
.stdout
.trim()
.to_owned();
git_cmd(&["checkout", "-b", "side", "HEAD~1"])
.in_dir(dir.path())
.suc();
std::fs::write(dir.path().join("a"), "side\n").expect("side");
git_cmd(&["commit", "-am", "side"]).in_dir(dir.path()).suc();
git_cmd(&["checkout", "main"]).in_dir(dir.path()).suc();
std::fs::write(dir.path().join("a"), "main\n").expect("main");
git_cmd(&["commit", "-am", "main"]).in_dir(dir.path()).suc();
git_cmd(&["merge", "side", "--no-commit", "--no-ff"])
.in_dir(dir.path())
.with_status(1);
let _ = git_cmd(&["reset", "--keep", "HEAD"])
.in_dir(dir.path())
.with_status(128);
let repo = open_grit_repo(dir.path());
let head = refs::resolve_ref(repo.git_dir(), "HEAD").expect("head");
let err = reset_to_commit(
&repo,
&ResetOptions::new(head, ResetMode::Keep, "T <t@t.com> 0 +0000", "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect_err("grit keep should refuse unmerged index");
assert!(matches!(err, Error::IndexUnmerged));
let _ = tip;
}
#[test]
fn reset_forward_has_no_orphan_walk() {
let dir = TempDir::new().expect("tempdir");
let repo = Repository::init(
dir.path(),
&InitOptions::new()
.with_initial_branch("main".to_owned())
.with_ref_storage(RefStorageFormat::Files),
)
.expect("init");
let c1 = commit_file(&repo, "f", "1\n", None);
let c2 = commit_file(&repo, "f", "2\n", Some(c1));
let outcome = reset_to_commit(
&repo,
&ResetOptions::new(c2, ResetMode::Mixed, "T <t@t.com> 0 +0000", "reset"),
&mut grit_lib::progress::NullProgress,
&grit_lib::progress::NeverCancel,
)
.expect("reset");
assert_eq!(outcome.orphaned_total, 0);
assert!(outcome.orphaned_commits.is_empty());
}