crates/gunnar-client/tests/porcelain.rs
//! The ordinary verbs, cross-checked against stock `git`.
//!
//! Every fixture is built by `git 2.53`, and every assertion compares gunnar's
//! answer to what `git log` / `git diff --name-status` / `git status --porcelain`
//! / `git for-each-ref` say about the same repository. Where gunnar *writes*
//! (`commit`, `branch`, `tag`, `checkout`), stock git reads the result back.
//!
//! Hermetic; no network.
mod common;
use common::*;
use gix::hash::Kind;
use gunnar_client::porcelain::{self, ChangeState, FileChange};
#[test]
fn log_matches_git_log() {
for kind in BOTH_HASH_KINDS {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, kind);
commit_file(&repo_path, "a.txt", "one\n", "first");
commit_file(&repo_path, "b.txt", "two\n", "second");
commit_file(&repo_path, "c.txt", "three\n", "third");
let repo = open(&repo_path);
let entries = porcelain::log(&repo, None, None).unwrap();
let expected: Vec<String> = git(&repo_path, &["log", "--format=%H"])
.lines()
.map(str::to_string)
.collect();
let got: Vec<String> = entries.iter().map(|c| c.id.to_hex().to_string()).collect();
assert_eq!(got, expected, "{kind:?}: log order or contents differ");
assert_eq!(entries[0].summary, "third");
assert_eq!(entries[0].author, "Fixture <fixture@example.invalid>");
assert_eq!(entries[0].time_unix, 1_700_000_000);
assert_eq!(
entries.last().unwrap().parents.len(),
0,
"the root has no parent"
);
assert_eq!(entries[0].parents.len(), 1);
assert_eq!(
entries[0].tree.to_hex().to_string(),
git(&repo_path, &["rev-parse", "HEAD^{tree}"])
);
let limited = porcelain::log(&repo, None, Some(2)).unwrap();
assert_eq!(limited.len(), 2, "the limit must actually limit");
let from_rev = porcelain::log(&repo, Some("HEAD~1"), None).unwrap();
assert_eq!(from_rev.len(), 2, "walking from an older tip sees less");
assert_eq!(from_rev[0].summary, "second");
}
}
#[test]
fn diff_matches_git_diff_name_status() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
commit_file(&repo_path, "keep.txt", "keep\n", "first");
std::fs::write(repo_path.join("gone.txt"), "gone\n").unwrap();
git(&repo_path, &["add", "gone.txt"]);
git(&repo_path, &["commit", "-q", "-m", "second"]);
std::fs::remove_file(repo_path.join("gone.txt")).unwrap();
std::fs::write(repo_path.join("keep.txt"), "changed\n").unwrap();
std::fs::write(repo_path.join("new.txt"), "new\n").unwrap();
git(&repo_path, &["add", "-A"]);
git(&repo_path, &["commit", "-q", "-m", "third"]);
let repo = open(&repo_path);
let head = repo.head_id().unwrap().detach();
let changes = porcelain::diff_commit(&repo, head).unwrap();
let mut got: Vec<String> = changes
.iter()
.map(|c| {
let tag = match c {
FileChange::Added { .. } => "A",
FileChange::Deleted { .. } => "D",
FileChange::Modified { .. } => "M",
FileChange::Renamed { .. } => "R",
};
format!("{tag}\t{}", c.path())
})
.collect();
let mut expected: Vec<String> = git(&repo_path, &["diff", "--name-status", "HEAD~1", "HEAD"])
.lines()
.map(str::to_string)
.collect();
got.sort();
expected.sort();
assert_eq!(got, expected);
}
/// The first commit's diff is its whole content, not nothing.
#[test]
fn a_root_commit_diffs_against_the_empty_tree() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
let root = commit_file(&repo_path, "a.txt", "one\n", "first");
let repo = open(&repo_path);
let changes = porcelain::diff_commit(
&repo,
gix::hash::ObjectId::from_hex(root.as_bytes()).unwrap(),
)
.unwrap();
assert_eq!(changes.len(), 1, "{changes:?}");
assert!(matches!(&changes[0], FileChange::Added { path, .. } if path == "a.txt"));
}
#[test]
fn status_matches_git_status_porcelain() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
commit_file(&repo_path, "tracked.txt", "one\n", "first");
assert!(
porcelain::is_clean(&open(&repo_path)).unwrap(),
"a freshly committed tree is clean"
);
assert_eq!(git(&repo_path, &["status", "--porcelain"]), "");
std::fs::write(repo_path.join("tracked.txt"), "modified\n").unwrap();
std::fs::write(repo_path.join("untracked.txt"), "new\n").unwrap();
std::fs::write(repo_path.join("staged.txt"), "staged\n").unwrap();
git(&repo_path, &["add", "staged.txt"]);
let entries = porcelain::status(&open(&repo_path)).unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert!(paths.contains(&"tracked.txt"), "{entries:?}");
assert!(paths.contains(&"untracked.txt"), "{entries:?}");
assert!(paths.contains(&"staged.txt"), "{entries:?}");
let by_path = |p: &str| entries.iter().find(|e| e.path == p).unwrap().state;
assert_eq!(by_path("tracked.txt"), ChangeState::Modified);
assert_eq!(by_path("untracked.txt"), ChangeState::Untracked);
assert_eq!(by_path("staged.txt"), ChangeState::Staged);
assert!(!porcelain::is_clean(&open(&repo_path)).unwrap());
// And stock git agrees on the SET of paths.
let mut theirs: Vec<String> = git(&repo_path, &["status", "--porcelain"])
.lines()
.map(|l| l[3..].to_string())
.collect();
let mut ours: Vec<String> = entries.iter().map(|e| e.path.clone()).collect();
theirs.sort();
theirs.dedup();
ours.sort();
ours.dedup();
assert_eq!(ours, theirs);
}
#[test]
fn status_on_a_bare_repository_is_an_error_not_an_empty_list() {
let dir = tempfile::tempdir().unwrap();
let bare = dir.path().join("b.git");
init_bare(&bare, Kind::Sha1);
let err = porcelain::status(&open(&bare)).unwrap_err();
// The chain, not the bare `Display`: a variant's own `Display` is its layer
// label ("repository") and the sentence lives in its `#[source]`. `{err:#}`
// is what `gunnar` prints and therefore what a user reads.
let shown = gunnar_client::chain(&err);
assert!(
shown.contains("bare"),
"an empty status from a bare repo would read as 'clean': {shown}"
);
}
#[test]
fn commit_all_is_read_back_by_stock_git() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
commit_file(&repo_path, "a.txt", "one\n", "first");
std::fs::write(repo_path.join("b.txt"), "two\n").unwrap();
std::fs::write(repo_path.join("a.txt"), "one and a half\n").unwrap();
let id = porcelain::commit_all(&repo_path, "written by gunnar").unwrap();
assert_eq!(
git(&repo_path, &["rev-parse", "HEAD"]),
id.to_hex().to_string(),
"HEAD did not move to the new commit"
);
assert_eq!(
git(&repo_path, &["log", "-1", "--format=%s"]),
"written by gunnar"
);
assert_eq!(git(&repo_path, &["show", "HEAD:b.txt"]), "two");
assert_eq!(git(&repo_path, &["show", "HEAD:a.txt"]), "one and a half");
assert_eq!(
git(&repo_path, &["status", "--porcelain"]),
"",
"the tree should be clean after a snapshot commit"
);
assert!(git_try(&repo_path, &["fsck", "--strict", "--no-progress"]).is_ok());
}
/// A deletion in the working tree must reach the commit. A snapshot that only
/// ever adds would pass every "is the new file there" assertion.
#[test]
fn commit_all_captures_a_deletion() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
commit_file(&repo_path, "a.txt", "one\n", "first");
commit_file(&repo_path, "b.txt", "two\n", "second");
std::fs::remove_file(repo_path.join("b.txt")).unwrap();
porcelain::commit_all(&repo_path, "drop b").unwrap();
assert!(
git_try(&repo_path, &["cat-file", "-e", "HEAD:b.txt"]).is_err(),
"the deletion did not reach the commit"
);
assert!(git_try(&repo_path, &["cat-file", "-e", "HEAD:a.txt"]).is_ok());
}
#[test]
fn branches_are_created_listed_and_deleted() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
let head = commit_file(&repo_path, "a.txt", "one\n", "first");
let repo = open(&repo_path);
porcelain::create_branch(&repo, "topic", None).unwrap();
assert_eq!(
git(&repo_path, &["rev-parse", "refs/heads/topic"]),
head,
"stock git does not see the branch gunnar created"
);
let listed = porcelain::branches(&repo).unwrap();
let names: Vec<&str> = listed.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, vec!["main", "topic"]);
assert!(listed.iter().find(|b| b.name == "main").unwrap().is_head);
assert!(!listed.iter().find(|b| b.name == "topic").unwrap().is_head);
// Creating it twice must fail rather than silently move it.
assert!(porcelain::create_branch(&repo, "topic", None).is_err());
// The current branch must not be deletable.
assert!(porcelain::delete_branch(&repo, "main").is_err());
porcelain::delete_branch(&repo, "topic").unwrap();
assert!(
git_try(&repo_path, &["rev-parse", "refs/heads/topic"]).is_err(),
"the branch survived the delete"
);
assert!(
porcelain::delete_branch(&repo, "topic").is_err(),
"and deleting it again fails"
);
}
#[test]
fn tags_lightweight_and_annotated_are_read_back_by_stock_git() {
for kind in BOTH_HASH_KINDS {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, kind);
let head_sha = commit_file(&repo_path, "a.txt", "one\n", "first");
let head = gix::hash::ObjectId::from_hex(head_sha.as_bytes()).unwrap();
porcelain::tag_lightweight(&repo_path, "light", head).unwrap();
let repo = open(&repo_path);
let tag_id = porcelain::tag_annotated(&repo, "heavy", head, "a release", None).unwrap();
assert_eq!(
git(&repo_path, &["cat-file", "-t", "refs/tags/light"]),
"commit"
);
assert_eq!(
git(&repo_path, &["cat-file", "-t", "refs/tags/heavy"]),
"tag"
);
assert_eq!(
git(&repo_path, &["rev-parse", "refs/tags/heavy"]),
tag_id.to_hex().to_string()
);
assert_eq!(
git(&repo_path, &["rev-parse", "refs/tags/heavy^{commit}"]),
head_sha,
"{kind:?}: the annotated tag does not peel to the commit"
);
assert!(git_try(&repo_path, &["fsck", "--strict", "--no-progress"]).is_ok());
let listed = porcelain::tags(&repo).unwrap();
let heavy = listed.iter().find(|t| t.name == "heavy").unwrap();
let light = listed.iter().find(|t| t.name == "light").unwrap();
assert!(heavy.annotated, "{kind:?}");
assert_eq!(heavy.peeled, Some(head));
assert_eq!(heavy.target, tag_id);
assert!(
!light.annotated,
"{kind:?}: a lightweight tag has no tag object"
);
assert_eq!(light.target, head);
assert_eq!(light.peeled, None);
// A second annotated tag of the same name must fail, not overwrite.
assert!(porcelain::tag_annotated(&repo, "heavy", head, "again", None).is_err());
}
}
#[test]
fn checkout_moves_head_and_materialises_the_worktree() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
let first = commit_file(&repo_path, "a.txt", "one\n", "first");
commit_file(&repo_path, "b.txt", "two\n", "second");
assert!(repo_path.join("b.txt").exists());
let first_id = gix::hash::ObjectId::from_hex(first.as_bytes()).unwrap();
porcelain::checkout_detached(&repo_path, first_id).unwrap();
assert_eq!(git(&repo_path, &["rev-parse", "HEAD"]), first);
assert!(
!repo_path.join("b.txt").exists(),
"the worktree still holds a file the checked-out commit does not have"
);
assert_eq!(
std::fs::read_to_string(repo_path.join("a.txt")).unwrap(),
"one\n"
);
assert_eq!(
git(&repo_path, &["status", "--porcelain"]),
"",
"the materialised worktree must be clean against its own commit"
);
// …and back onto a branch.
let second = git(&repo_path, &["rev-parse", "refs/heads/main"]);
porcelain::checkout_branch(
&repo_path,
"main",
gix::hash::ObjectId::from_hex(second.as_bytes()).unwrap(),
)
.unwrap();
assert_eq!(git(&repo_path, &["rev-parse", "HEAD"]), second);
assert!(repo_path.join("b.txt").exists());
assert_eq!(
git(&repo_path, &["symbolic-ref", "HEAD"]),
"refs/heads/main"
);
}
/// `commit_tree` is the plumbing form: the caller decides the tree and parents.
#[test]
fn commit_tree_writes_a_commit_stock_git_accepts() {
let dir = tempfile::tempdir().unwrap();
let repo_path = dir.path().join("r");
init_worktree(&repo_path, Kind::Sha1);
let parent_sha = commit_file(&repo_path, "a.txt", "one\n", "first");
let parent = gix::hash::ObjectId::from_hex(parent_sha.as_bytes()).unwrap();
let tree =
gix::hash::ObjectId::from_hex(git(&repo_path, &["rev-parse", "HEAD^{tree}"]).as_bytes())
.unwrap();
let repo = open(&repo_path);
let id =
porcelain::commit_tree(&repo, "refs/heads/side", "empty change", tree, &[parent]).unwrap();
assert_eq!(
git(&repo_path, &["rev-parse", "refs/heads/side"]),
id.to_hex().to_string()
);
assert_eq!(
git(&repo_path, &["log", "-1", "--format=%s", "refs/heads/side"]),
"empty change"
);
assert_eq!(
git(&repo_path, &["rev-parse", "refs/heads/side^"]),
parent_sha
);
assert!(git_try(&repo_path, &["fsck", "--strict", "--no-progress"]).is_ok());
}