use std::{env, fs}; use ely_gpui_component::git::{LineKind, Stretch}; use gpui::SharedString; use super::*; /// A fresh folder under the temp dir. fn folder(name: &str) -> PathBuf { let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id())); if folder.exists() { fs::remove_dir_all(&folder).expect("a stale test folder goes"); } fs::create_dir_all(&folder).expect("a test folder"); folder } /// Runs git in `root` to set a test up. fn prepare(root: &Path, args: &[&str]) { let done = Command::new("git") .arg("-C") .arg(root) .args(args) .output() .expect("git runs"); let error = String::from_utf8_lossy(&done.stderr); assert!(done.status.success(), "git {args:?}: {error}"); } /// A new repository on `main` whose own config keeps the user's away. fn repository(name: &str) -> PathBuf { let root = folder(name); prepare(&root, &["init", "-q", "-b", "main"]); for [key, value] in [ ["user.name", "Ely"], ["user.email", "ely@example.com"], ["commit.gpgsign", "false"], ["core.hooksPath", "no-hooks"], ["core.autocrlf", "false"], ["merge.conflictStyle", "merge"], ["status.showUntrackedFiles", "normal"], ] { prepare(&root, &["config", key, value]); } root } /// Writes `text` to `path` under `root`, making its folders. fn write(root: &Path, path: &str, text: &str) { let path = root.join(path); fs::create_dir_all(path.parent().expect("a parent")).expect("folders"); fs::write(path, text).expect("a file"); } fn change(path: &str, status: GitStatus) -> Change { Change { path: path.into(), from: None, status, } } fn read(root: &Path) -> Repo { repo(root).expect("status").expect("a work tree") } #[test] fn status_records_read_with_head() { let out = b"# branch.oid 1a2b\0# branch.head main\0# branch.upstream origin/main\0\ # branch.ab +1 -0\0\ 1 .M N... 100644 100644 100644 aaa bbb src/a b.rs\0\ 2 RM N... 100644 100644 100644 aaa bbb R100 new name.rs\0old name.rs\0\ u UU N... 100644 100644 100644 100644 aaa bbb ccc clash.rs\0? notes/\0"; let (head, records) = parse(out).expect("records"); let main = Head { branch: Some("main".into()), commit: Some("1a2b".into()), }; assert_eq!(head, main); let record = |x, y, path: &str, from: Option<&str>| Record { x, y, path: path.into(), from: from.map(Into::into), }; let expected = [ record(b'.', b'M', "src/a b.rs", None), record(b'R', b'M', "new name.rs", Some("old name.rs")), record(b'U', b'U', "clash.rs", None), record(b'?', b'?', "notes/", None), ]; assert_eq!(records, expected); let unborn = b"# branch.oid (initial)\0# branch.head (detached)\0"; let (head, _) = parse(unborn).expect("headers"); assert_eq!(head, Head::default(), "no commit yet, no branch"); } #[test] fn records_git_never_writes_are_errors() { for out in [ &b"1 .M N... 100644\0"[..], b"x junk\0", b"2 R. N... 1 2 3 a b R100 new\0", b"1 M N... 1 2 3 a b p\0", b"?\0", b"1 .M N... 1 2 3 a b \0", ] { assert!(parse(out).is_err(), "{:?}", String::from_utf8_lossy(out)); } } #[test] fn both_codes_combine_into_one_mark() { use GitStatus::*; for (codes, status) in [ (b"??", Untracked), (b"UU", Conflicted), (b"AA", Conflicted), (b"DU", Conflicted), (b".D", Deleted), (b"RM", Renamed), (b"AM", Added), (b"C.", Added), (b".T", Modified), (b"MM", Modified), ] { let named = String::from_utf8_lossy(codes); assert_eq!(kind(codes[0], codes[1]), Some(status), "{named}"); } assert_eq!(kind(b'.', b'.'), None); assert_eq!(kind(b'!', b'!'), None); } #[test] fn a_work_tree_reports_changes_under_the_folder() { let root = repository("git-changes"); for (path, text) in [ ("kept.txt", "kept"), ("gone.txt", "gone"), ("src/main.rs", "fn main() {}"), ("src/inner/deep.rs", "deep"), ] { write(&root, path, text); } prepare(&root, &["add", "."]); prepare(&root, &["commit", "-qm", "first"]); write(&root, "src/main.rs", "fn main() { }"); fs::remove_file(root.join("gone.txt")).expect("a removal"); write(&root, "added.txt", "new"); prepare(&root, &["add", "added.txt"]); write(&root, "notes/sub/todo.txt", "todo"); let mut all = changes(&root).expect("status").expect("a work tree"); all.sort_by(|a, b| a.0.cmp(&b.0)); let expected = [ ("added.txt", GitStatus::Added), ("gone.txt", GitStatus::Deleted), ("notes", GitStatus::Untracked), ("src/main.rs", GitStatus::Modified), ] .map(|(path, status)| (root.join(path), status)); assert_eq!(all, expected); let src = root.join("src"); let main = (src.join("main.rs"), GitStatus::Modified); assert_eq!(changes(&src).expect("status"), Some(vec![main])); let sub = root.join("notes").join("sub"); let whole = (sub.clone(), GitStatus::Untracked); assert_eq!( changes(&sub).expect("status"), Some(vec![whole]), "a folder inside an untracked one is untracked whole" ); assert_eq!(changes(&root.join(".git")).expect("rev-parse"), None); assert_eq!(changes(&folder("git-outside")).expect("rev-parse"), None); } #[cfg(unix)] #[test] fn a_folder_named_with_a_newline_keeps_its_whole_prefix() { let root = repository("git-newline"); write(&root, "line\nbreak/tracked.txt", "first"); prepare(&root, &["add", "."]); prepare(&root, &["commit", "-qm", "first"]); write(&root, "line\nbreak/tracked.txt", "second"); let inner = root.join("line\nbreak"); let edit = (inner.join("tracked.txt"), GitStatus::Modified); assert_eq!(changes(&inner).expect("status"), Some(vec![edit])); } #[test] fn a_repository_lists_its_branch_and_each_section() { let root = repository("git-repo"); for path in ["kept.txt", "gone.txt", "old.txt", "src/main.rs"] { write(&root, path, "first\n"); } prepare(&root, &["add", "."]); prepare(&root, &["commit", "-qm", "first"]); write(&root, "src/main.rs", "second\n"); fs::remove_file(root.join("gone.txt")).expect("a removal"); write(&root, "added.txt", "new\n"); write(&root, "kept.txt", "staged\n"); prepare(&root, &["add", "added.txt", "kept.txt"]); write(&root, "kept.txt", "staged, then changed\n"); prepare(&root, &["mv", "old.txt", "new.txt"]); write(&root, "notes/todo.txt", "todo\n"); let state = read(&root.join("src")); let real = |path: &Path| fs::canonicalize(path).expect("a real path"); assert_eq!( real(&state.top), real(&root), "the top, from a folder under it" ); assert_eq!(state.branch.as_deref(), Some("main")); assert!(state.commit.is_some()); let renamed = Change { path: "new.txt".into(), from: Some("old.txt".into()), status: GitStatus::Renamed, }; let staged = [ change("added.txt", GitStatus::Added), change("kept.txt", GitStatus::Modified), renamed, ]; assert_eq!(state.staged, staged); let unstaged = [ change("gone.txt", GitStatus::Deleted), change("kept.txt", GitStatus::Modified), change("src/main.rs", GitStatus::Modified), ]; assert_eq!(state.unstaged, unstaged); assert_eq!(state.untracked, [change("notes/", GitStatus::Untracked)]); assert!(state.conflicted.is_empty()); prepare(&root, &["commit", "-qm", "second"]); prepare(&root, &["checkout", "-q", "--detach"]); assert_eq!(read(&root).branch, None, "a detached HEAD has no branch"); assert_eq!(repo(&root.join(".git")).expect("rev-parse"), None); assert_eq!(repo(&folder("git-repo-outside")).expect("rev-parse"), None); } #[test] fn files_stage_and_unstage_by_their_literal_paths() { let root = repository("git-stage"); write(&root, "star[1].txt", "a\n"); write(&root, "star1.txt", "b\n"); let star = change("star[1].txt", GitStatus::Untracked); stage(&root, &[star]).expect("a stage before the first commit"); let state = read(&root); assert_eq!(state.commit, None); let added = change("star[1].txt", GitStatus::Added); assert_eq!(state.staged, [added], "the brackets match nothing else"); assert_eq!(state.untracked, [change("star1.txt", GitStatus::Untracked)]); unstage(&root, &state.staged).expect("an unstage before the first commit"); let state = read(&root); assert!(state.staged.is_empty(), "{:?}", state.staged); assert_eq!(state.untracked.len(), 2); stage(&root, &state.untracked).expect("a stage"); commit(&root, "first", false).expect("a commit"); prepare(&root, &["mv", "star1.txt", "moved.txt"]); unstage(&root, &read(&root).staged).expect("an unstaged rename"); let state = read(&root); assert!( state.staged.is_empty(), "both sides of the rename: {state:?}" ); assert_eq!(state.unstaged, [change("star1.txt", GitStatus::Deleted)]); assert_eq!(state.untracked, [change("moved.txt", GitStatus::Untracked)]); assert!( stage(&root, &[]).is_err(), "no path would stage the whole tree" ); assert!(unstage(&root, &[]).is_err()); } #[cfg(unix)] #[test] fn a_name_with_a_newline_stages_whole() { let root = repository("git-stage-newline"); write(&root, "line\nfeed.txt", "a\n"); let new = change("line\nfeed.txt", GitStatus::Untracked); stage(&root, &[new]).expect("a stage"); let added = change("line\nfeed.txt", GitStatus::Added); assert_eq!(read(&root).staged, [added]); } #[test] fn a_commit_takes_the_staged_files_and_heads_the_log() { let root = repository("git-commit"); write(&root, "a.txt", "a\n"); stage(&root, &[change("a.txt", GitStatus::Untracked)]).expect("a stage"); commit(&root, "first subject\n\nIts body.", false).expect("a commit"); write(&root, "b.txt", "b\n"); stage(&root, &[change("b.txt", GitStatus::Untracked)]).expect("a stage"); commit(&root, "second", false).expect("a commit"); let commits = log(&root, 10).expect("a log"); let [newest, first] = commits.as_slice() else { panic!("two commits: {commits:?}"); }; assert_eq!(newest.subject.as_ref(), "second"); assert_eq!(first.subject.as_ref(), "first subject"); assert_eq!(newest.parents, std::slice::from_ref(&first.id)); assert!(first.parents.is_empty()); assert!(newest.id.chars().all(|c| c.is_ascii_hexdigit()) && !newest.id.is_empty()); assert_eq!(newest.author.as_ref(), "Ely"); assert!(newest.when.ends_with("ago"), "{}", newest.when); assert_eq!(newest.refs, [SharedString::from("HEAD -> main")]); assert!(first.refs.is_empty()); assert_eq!(log(&root, 1).expect("a log").len(), 1); let error = commit(&root, "nothing", false).expect_err("nothing is staged"); assert!(format!("{error:#}").contains("nothing"), "{error:#}"); } #[test] fn a_copy_keeps_no_source_to_unstage_or_diff() { let root = repository("git-copy"); prepare(&root, &["config", "status.renames", "copies"]); prepare(&root, &["config", "diff.renames", "copies"]); let text: String = (1..=50).map(|n| format!("line {n}\n")).collect(); write(&root, "source.txt", &text); prepare(&root, &["add", "."]); prepare(&root, &["commit", "-qm", "first"]); write(&root, "copy.txt", &text); write(&root, "source.txt", &format!("{text}more\n")); prepare(&root, &["add", "."]); let source = change("source.txt", GitStatus::Modified); let copy = change("copy.txt", GitStatus::Added); assert_eq!(read(&root).staged, [copy.clone(), source.clone()]); let stretches = diff(&root, ©, ChangeSection::Staged).expect("a diff"); let Some([Stretch::Hunk { lines, .. }]) = stretches.as_deref() else { panic!("one hunk: {stretches:?}"); }; let added = lines.iter().all(|line| line.kind == LineKind::Added); assert!( added && lines.len() == 50, "the copy's lines alone: {lines:?}" ); unstage(&root, &[copy]).expect("an unstaged copy"); assert_eq!(read(&root).staged, [source], "the source stays staged"); } #[cfg(unix)] #[test] fn names_that_show_alike_stay_apart() { let root = repository("git-bytes"); write(&root, "bad\u{FFFD}.txt", "text\n"); prepare(&root, &["add", "."]); let blob = run(&root, &["hash-object", "-w", "--stdin"], b"text\n").expect("a blob"); // A name not in UTF-8, which macOS refuses on disk, goes straight into the index. let mut entry = b"100644 ".to_vec(); entry.extend(blob.trim_ascii()); entry.extend(b"\tbad\xff.txt\0"); run(&root, &["update-index", "-z", "--index-info"], &entry).expect("an index entry"); let raw = ::from_bytes(b"bad\xff.txt"); let staged = read(&root).staged; let shown: Vec<_> = staged .iter() .map(|change| change.path.to_string_lossy()) .collect(); assert_eq!( shown, ["bad\u{FFFD}.txt", "bad\u{FFFD}.txt"], "two names show alike" ); let bytes = staged.iter().find(|change| change.path == raw); let bytes = bytes.expect("the name in bytes").clone(); unstage(&root, &[bytes]).expect("an unstage"); let left = [change("bad\u{FFFD}.txt", GitStatus::Added)]; assert_eq!(read(&root).staged, left, "the other name stays staged"); } #[test] fn an_amend_rewrites_the_last_commit() { let root = repository("git-amend"); assert!( commit(&root, "nothing yet", true).is_err(), "no commit to amend" ); write(&root, "a.txt", "a\n"); stage(&root, &[change("a.txt", GitStatus::Untracked)]).expect("a stage"); commit(&root, "first", false).expect("a commit"); commit(&root, "first, reworded", true).expect("an amend with nothing staged"); let commits = log(&root, 10).expect("a log"); let subjects: Vec<&str> = commits .iter() .map(|commit| commit.subject.as_ref()) .collect(); assert_eq!(subjects, ["first, reworded"]); } #[test] fn a_discard_restores_edits_and_deletes_new_files() { let root = repository("git-discard"); for path in ["edited.txt", "both.txt", "gone.txt"] { write(&root, path, "base\n"); } prepare(&root, &["add", "."]); prepare(&root, &["commit", "-qm", "base"]); write(&root, "edited.txt", "edit\n"); write(&root, "both.txt", "staged\n"); prepare(&root, &["add", "both.txt"]); write(&root, "both.txt", "staged, then edited\n"); fs::remove_file(root.join("gone.txt")).expect("a removal"); write(&root, "intended.txt", "intended\n"); prepare(&root, &["add", "-N", "intended.txt"]); write(&root, "loose.txt", "loose\n"); write(&root, "notes/todo.txt", "todo\n"); write(&root, "nested/inner.txt", "inner\n"); prepare(&root.join("nested"), &["init", "-q"]); let state = read(&root); for change in &state.unstaged { discard(&root, change, ChangeSection::Unstaged).expect("a discarded edit"); } let (nested, loose): (Vec<_>, Vec<_>) = (state.untracked.iter()).partition(|change| change.path == Path::new("nested/")); for change in loose { discard(&root, change, ChangeSection::Untracked).expect("a deleted file"); } let error = discard(&root, nested[0], ChangeSection::Untracked).expect_err("git skips it"); assert!( format!("{error:#}").contains("removed nothing"), "{error:#}" ); let staged = read(&root).staged; let kept = discard(&root, &staged[0], ChangeSection::Staged); assert!(kept.is_err(), "a staged change is not discarded"); let text = |path| fs::read_to_string(root.join(path)).expect("a file"); assert_eq!(text("edited.txt"), "base\n"); assert_eq!(text("both.txt"), "staged\n", "the staged part stays"); assert_eq!(text("gone.txt"), "base\n", "a deletion is undone"); for path in ["intended.txt", "loose.txt", "notes"] { assert!(!root.join(path).exists(), "{path} goes"); } let state = read(&root); assert_eq!(state.staged, [change("both.txt", GitStatus::Modified)]); assert!(state.unstaged.is_empty(), "{:?}", state.unstaged); assert_eq!(state.untracked, [change("nested/", GitStatus::Untracked)]); } mod diffs;