f3f1f51d9e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
205 lines
9.1 KiB
Rust
205 lines
9.1 KiB
Rust
//! Real git acceptance tests; each fixture owns a private repository.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use notescraft_git::{ChangeKind, Error, Git, LineKind, git_available};
|
|
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
struct Repo(PathBuf);
|
|
|
|
impl Repo {
|
|
fn new() -> Option<Self> {
|
|
if !git_available() {
|
|
eprintln!("git missing: skipping real git test");
|
|
return None;
|
|
}
|
|
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/tmp").join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir_all(&path).expect("fixture");
|
|
Some(Self(path))
|
|
}
|
|
|
|
fn git(&self) -> Git {
|
|
Git::open(&self.0)
|
|
}
|
|
|
|
fn run(&self, args: &[&str]) -> String {
|
|
let out = Command::new("git").args(args).current_dir(&self.0).output().expect("git");
|
|
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
|
String::from_utf8(out.stdout).expect("utf8")
|
|
}
|
|
|
|
fn init(&self) {
|
|
self.git().init().expect("init");
|
|
self.run(&["config", "user.email", "test@example.invalid"]);
|
|
self.run(&["config", "user.name", "NotesCraft test"]);
|
|
}
|
|
|
|
fn write(&self, path: &str, text: impl AsRef<[u8]>) {
|
|
let path = self.0.join(path);
|
|
std::fs::create_dir_all(path.parent().expect("parent")).expect("parent dir");
|
|
std::fs::write(path, text).expect("write");
|
|
}
|
|
|
|
fn seed(&self) {
|
|
self.init();
|
|
self.write("note.md", "one\ntwo\nthree\n");
|
|
self.git().stage(&["note.md".into()]).expect("stage");
|
|
self.git().commit("initial").expect("commit");
|
|
}
|
|
}
|
|
|
|
impl Drop for Repo {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn init_preserves_ignore_and_open_does_not_write() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
let git = repo.git();
|
|
assert!(!git.is_repo());
|
|
assert!(!repo.0.join(".git").exists());
|
|
repo.init();
|
|
assert!(git.is_repo());
|
|
assert_eq!(std::fs::read_to_string(repo.0.join(".gitignore")).expect("ignore"), ".notescraft/\n.obsidian/workspace*\n");
|
|
repo.write(".gitignore", "custom\n");
|
|
git.init().expect("reinit");
|
|
assert_eq!(std::fs::read_to_string(repo.0.join(".gitignore")).expect("ignore"), "custom\n");
|
|
}
|
|
|
|
#[test]
|
|
fn status_and_diff_track_both_sides_and_line_numbers() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
repo.seed();
|
|
let git = repo.git();
|
|
repo.write("note.md", "one\nchanged\nthree\nextra\n");
|
|
repo.write("new file.md", "new\n");
|
|
repo.write(".notescraft/private", "hidden");
|
|
let status = git.status().expect("status");
|
|
assert!(status.branch.is_some());
|
|
assert_eq!(status.changes.iter().find(|c| c.path == "note.md").expect("note").worktree, ChangeKind::Modified);
|
|
assert_eq!(status.changes.iter().find(|c| c.path == "new file.md").expect("new").worktree, ChangeKind::Untracked);
|
|
assert!(!status.changes.iter().any(|c| c.path.starts_with(".notescraft/")));
|
|
let diff = git.diff("note.md", false).expect("diff");
|
|
let lines = &diff.hunks.first().expect("hunk").lines;
|
|
assert!(lines.iter().any(|l| l.kind == LineKind::Del && l.old_no == Some(2) && l.text == "two"));
|
|
assert!(lines.iter().any(|l| l.kind == LineKind::Add && l.new_no == Some(2) && l.text == "changed"));
|
|
git.stage(&["note.md".into()]).expect("stage");
|
|
assert_eq!(git.status().expect("status").changes.iter().find(|c| c.path == "note.md").expect("note").index, ChangeKind::Modified);
|
|
assert_ne!(git.diff("note.md", true).expect("staged diff").hunks, []);
|
|
git.unstage(&["note.md".into()]).expect("unstage");
|
|
git.restore(&["note.md".into(), "new file.md".into()]).expect("restore");
|
|
assert!(!repo.0.join("new file.md").exists());
|
|
assert_eq!(git.show("HEAD", "note.md").expect("show"), "one\ntwo\nthree\n");
|
|
}
|
|
|
|
#[test]
|
|
fn commit_history_restore_and_stdin_message() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
repo.seed();
|
|
let git = repo.git();
|
|
repo.write("note.md", "new version\n");
|
|
git.stage(&["note.md".into()]).expect("stage");
|
|
let message = " quote ' and \" and $(touch x)\n\nsecond line ";
|
|
let hash = git.commit(message).expect("commit");
|
|
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
|
|
assert_eq!(repo.run(&["log", "-1", "--format=%B"]).trim(), message.trim());
|
|
assert!(!repo.0.join("x").exists());
|
|
assert_eq!(git.log(10, None).expect("log").len(), 2);
|
|
assert_eq!(git.log(10, Some("note.md")).expect("path log").len(), 2);
|
|
git.restore_version("HEAD~1", "note.md").expect("old version");
|
|
assert_eq!(std::fs::read_to_string(repo.0.join("note.md")).expect("read"), "one\ntwo\nthree\n");
|
|
assert!(matches!(git.commit(""), Err(Error::InvalidMessage)));
|
|
assert!(matches!(git.commit(&"x".repeat(4097)), Err(Error::InvalidMessage)));
|
|
}
|
|
|
|
#[test]
|
|
fn untracked_and_binary_diff_and_empty_commit() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
repo.seed();
|
|
let git = repo.git();
|
|
assert!(matches!(git.commit("empty"), Err(Error::NothingToCommit)));
|
|
repo.write("untracked.md", "hello\n");
|
|
let diff = git.diff("untracked.md", false).expect("untracked diff");
|
|
assert_eq!(diff.hunks.first().expect("hunk").lines.first().expect("line").new_no, Some(1));
|
|
repo.write("binary", [0, 1, 2, 0]);
|
|
assert!(git.diff("binary", false).expect("binary").binary);
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_paths_revisions_and_not_a_repository() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
let git = repo.git();
|
|
assert!(matches!(git.status(), Err(Error::NotARepository)));
|
|
assert!(matches!(git.diff("note.md", false), Err(Error::NotARepository)));
|
|
assert!(matches!(git.stage(&["note.md".into()]), Err(Error::NotARepository)));
|
|
assert!(matches!(git.unstage(&["note.md".into()]), Err(Error::NotARepository)));
|
|
assert!(matches!(git.restore(&["note.md".into()]), Err(Error::NotARepository)));
|
|
assert!(matches!(git.commit("message"), Err(Error::NotARepository)));
|
|
assert!(matches!(git.log(1, None), Err(Error::NotARepository)));
|
|
assert!(matches!(git.show("HEAD", "note.md"), Err(Error::NotARepository)));
|
|
assert!(matches!(git.restore_version("HEAD", "note.md"), Err(Error::NotARepository)));
|
|
repo.seed();
|
|
for path in ["-rf", "--output=/tmp/x", "../x", "a/../../x", ".git/config", "/tmp/x", "C:/x", "a\0x", ":(glob)*"] {
|
|
assert!(git.diff(path, false).is_err(), "{path:?}");
|
|
assert!(git.stage(&[path.into()]).is_err(), "{path:?}");
|
|
assert!(git.unstage(&[path.into()]).is_err(), "{path:?}");
|
|
assert!(git.restore(&[path.into()]).is_err(), "{path:?}");
|
|
assert!(git.show("HEAD", path).is_err(), "{path:?}");
|
|
assert!(git.restore_version("HEAD", path).is_err(), "{path:?}");
|
|
}
|
|
for path in [".notescraft/a", ".obsidian/a", "a/../.obsidian/a"] {
|
|
assert!(git.stage(&[path.into()]).is_err());
|
|
assert!(git.restore_version("HEAD", path).is_err());
|
|
}
|
|
for rev in ["--upload-pack=x", "HEAD; rm -rf", "HEAD~", "HEAD~-1", "abcd:path", "HEAD~999999999999999999999999999"] {
|
|
assert!(git.show(rev, "note.md").is_err());
|
|
assert!(git.restore_version(rev, "note.md").is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn status_deleted_renamed_and_conflicted() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
repo.seed();
|
|
let git = repo.git();
|
|
std::fs::remove_file(repo.0.join("note.md")).expect("delete");
|
|
assert_eq!(git.status().expect("status").changes.iter().find(|c| c.path == "note.md").expect("note").worktree, ChangeKind::Deleted);
|
|
git.restore(&["note.md".into()]).expect("restore");
|
|
repo.run(&["mv", "note.md", "renamed.md"]);
|
|
let status = git.status().expect("status");
|
|
let change = status.changes.iter().find(|c| c.path == "renamed.md").expect("rename");
|
|
assert_eq!(change.index, ChangeKind::Renamed);
|
|
assert_eq!(change.orig_path.as_deref(), Some("note.md"));
|
|
git.commit("rename").expect("commit");
|
|
repo.run(&["checkout", "-b", "other"]);
|
|
repo.write("renamed.md", "other\n");
|
|
git.stage(&["renamed.md".into()]).expect("stage");
|
|
git.commit("other").expect("commit");
|
|
repo.run(&["checkout", "-"]);
|
|
repo.write("renamed.md", "ours\n");
|
|
git.stage(&["renamed.md".into()]).expect("stage");
|
|
git.commit("ours").expect("commit");
|
|
assert!(!Command::new("git").args(["merge", "other"]).current_dir(&repo.0).output().expect("merge").status.success());
|
|
let status = git.status().expect("status");
|
|
let conflict = status.changes.iter().find(|c| c.path == "renamed.md").expect("conflict");
|
|
assert_eq!(conflict.index, ChangeKind::Conflicted);
|
|
assert_eq!(conflict.worktree, ChangeKind::Conflicted);
|
|
}
|
|
|
|
#[test]
|
|
fn huge_diff_is_bounded_and_reported() {
|
|
let Some(repo) = Repo::new() else { return };
|
|
repo.seed();
|
|
repo.write("large.md", "long line\n".repeat(25_000));
|
|
let diff = repo.git().diff("large.md", false).expect("large diff");
|
|
assert!(diff.truncated);
|
|
assert_eq!(diff.hunks.iter().map(|h| h.lines.len()).sum::<usize>(), 20_000);
|
|
repo.write("large.md", "x".repeat(3 * 1024 * 1024));
|
|
assert!(repo.git().diff("large.md", false).expect("byte cap").truncated);
|
|
}
|