#![cfg(unix)] use std::{ collections::BTreeMap, ffi::OsString, fs, os::unix::{ ffi::OsStringExt, fs::{PermissionsExt, symlink}, }, path::{Path, PathBuf}, process::{Command, Output}, sync::{ Arc, atomic::{AtomicBool, AtomicU64, Ordering}, }, }; use vamp::{ diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget}, repository::Repository, }; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let path = std::env::temp_dir().join(format!( "vamp-repository-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&path).unwrap(); Self(path) } fn dir(&self, name: &str) -> PathBuf { let path = self.0.join(name); fs::create_dir(&path).unwrap(); path } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn command(program: &str, root: &Path, args: &[&str]) -> Output { Command::new(program) .current_dir(root) .args(args) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_OPTIONAL_LOCKS", "0") .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") .env("JJ_CONFIG", "/dev/null") .env("JJ_USER", "Diff Test") .env("JJ_EMAIL", "diff@example.invalid") .output() .unwrap() } fn success(program: &str, root: &Path, args: &[&str]) -> String { let result = command(program, root, args); assert!( result.status.success(), "{program} {args:?}: {}", String::from_utf8_lossy(&result.stderr) ); String::from_utf8(result.stdout) .unwrap() .trim_end_matches('\n') .into() } fn git(root: &Path, args: &[&str]) -> String { success("git", root, args) } fn jj(root: &Path, args: &[&str]) -> String { success("jj", root, args) } fn init_git(root: &Path) { git(root, &["init", "--initial-branch=local"]); git(root, &["config", "user.name", "Diff Test"]); git(root, &["config", "user.email", "diff@example.invalid"]); } fn init_jj(root: &Path, colocate: bool) { jj( root, &[ "git", "init", if colocate { "--colocate" } else { "--no-colocate" }, ], ); jj(root, &["config", "set", "--repo", "user.name", "Diff Test"]); jj( root, &[ "config", "set", "--repo", "user.email", "diff@example.invalid", ], ); } fn open(root: &Path) -> Repository { Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap() } fn revision(id: &str, kind: RepoKind) -> Revision { Revision { target: RevisionTarget::Commit(id.into()), label: id.into(), description: String::new(), category: if kind == RepoKind::Jujutsu { Category::Revisions } else { Category::Commits }, } } fn working(kind: RepoKind) -> Revision { let mut r = revision("working", kind); r.target = RevisionTarget::Working; r } fn compare_loaded( repository: &mut Repository, before: &Revision, after: &Revision, buffers: &[(PathBuf, String)], ) -> Result { let mut comparison = repository.compare(1, before, after, ComparisonMode::Snapshots, buffers)?; for (index, file) in comparison.files.iter_mut().enumerate() { *file = repository.load_file(1, index)?; if file .notice .as_deref() .is_some_and(|notice| notice.contains("Binary or non-UTF-8")) { comparison.editable_paths.remove(&file.path); } } Ok(comparison) } fn changed<'a>(comparison: &'a Comparison, path: &Path) -> &'a vamp::diff::ChangedFile { comparison .files .iter() .find(|f| f.path == path) .unwrap_or_else(|| panic!("Missing changed file {path:?}: {:?}", comparison.files)) } // Capture active metadata, checkout bytes, executable bits and link targets. Access // times are intentionally excluded: reading a repository must not change its data. fn disk_state(root: &Path) -> BTreeMap)> { fn visit(root: &Path, path: &Path, result: &mut BTreeMap)>) { for entry in fs::read_dir(path).unwrap() { let entry = entry.unwrap(); let full = entry.path(); let metadata = fs::symlink_metadata(&full).unwrap(); if metadata.is_dir() { visit(root, &full, result); } else { let data = if metadata.file_type().is_symlink() { use std::os::unix::ffi::OsStrExt; fs::read_link(&full) .unwrap() .as_os_str() .as_bytes() .to_vec() } else { fs::read(&full).unwrap() }; result.insert( full.strip_prefix(root).unwrap().to_path_buf(), (metadata.permissions().mode(), data), ); } } } let mut result = BTreeMap::new(); visit(root, root, &mut result); result } #[test] fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() { let fixture = Fixture::new(); let root = fixture.dir("git"); init_git(&root); for (path, data) in [ ("text", &b"one\nthree\n"[..]), ("removed", b"removed\n"), ("empty-before", b""), ("newline", b"same\n"), ("crlf", b"a\r\n"), ("rename-old", b"move\n"), ("binary", b"\0old"), ("ignored-tracked", b"old\n"), ("executable", b"script\n"), ("type-switch", b"target"), (".gitignore", b"ignored-*\n"), ] { fs::write(root.join(path), data).unwrap(); } git(&root, &["add", "."]); git(&root, &["add", "--force", "ignored-tracked"]); git(&root, &["commit", "-m", "baseline"]); git(&root, &["tag", "-a", "annotated", "-m", "tag"]); git( &root, &["tag", "-a", "nested", "-m", "nested tag", "annotated"], ); let id = git(&root, &["rev-parse", "HEAD"]); fs::write(root.join("text"), b"one\nstaged\nthree\n").unwrap(); git(&root, &["add", "text"]); fs::write(root.join("text"), b"one\ntwo\nthree\n").unwrap(); fs::remove_file(root.join("removed")).unwrap(); fs::write(root.join("empty-before"), b"first\n").unwrap(); fs::write(root.join("empty-added"), b"").unwrap(); fs::write(root.join("added"), b"added\n").unwrap(); fs::write(root.join("newline"), b"same").unwrap(); fs::write(root.join("crlf"), b"b\r\n").unwrap(); fs::write(root.join("binary"), b"\0new").unwrap(); fs::write(root.join("ignored-tracked"), b"changed\n").unwrap(); fs::write(root.join("ignored-untracked"), b"must not appear\n").unwrap(); fs::rename(root.join("rename-old"), root.join("rename-new")).unwrap(); fs::remove_file(root.join("type-switch")).unwrap(); symlink("target", root.join("type-switch")).unwrap(); fs::set_permissions(root.join("executable"), fs::Permissions::from_mode(0o755)).unwrap(); let odd = PathBuf::from("-tab\tand\nnewline"); fs::write(root.join(&odd), b"odd\n").unwrap(); let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); let non_utf8_created = match fs::write(root.join(&non_utf8), b"bytes\n") { Ok(()) => true, // APFS rejects non-UTF-8 names before repository code can read them. Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => false, Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"), }; symlink("/not/a/path/to/read", root.join("link")).unwrap(); let state = disk_state(&root); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); assert_eq!(catalog.kind, RepoKind::Git); assert_eq!(catalog.entries[0].target, RevisionTarget::Working); assert!( catalog .entries .iter() .any(|r| r.label == id && r.description == "baseline") ); assert!(catalog.entries.iter().any(|r| r.label == "nested" && r.category == Category::Tags && r.target == RevisionTarget::Commit(id.clone()))); let comparison = compare_loaded( &mut repository, &revision(&id, RepoKind::Git), &working(RepoKind::Git), &[], ) .unwrap(); let text = changed(&comparison, Path::new("text")); assert_eq!(comparison.root, fs::canonicalize(&root).unwrap()); assert!(comparison.editable_paths.contains(Path::new("text"))); assert!(comparison.editable_paths.contains(Path::new("empty-added"))); for path in ["binary", "link", "removed", "type-switch"] { assert!(!comparison.editable_paths.contains(Path::new(path))); } assert_eq!(text.after, ["one", "two", "three"]); assert_eq!( ( text.hunks[0].before_start, text.hunks[0].before_len, text.hunks[0].after_start, text.hunks[0].after_len ), (1, 0, 1, 1) ); let empty = changed(&comparison, Path::new("empty-before")); assert_eq!( ( empty.hunks[0].before_start, empty.hunks[0].before_len, empty.hunks[0].after_start, empty.hunks[0].after_len ), (0, 0, 0, 1) ); let deleted = changed(&comparison, Path::new("removed")); assert_eq!(deleted.status, FileStatus::Removed); assert_eq!( ( deleted.hunks[0].before_start, deleted.hunks[0].before_len, deleted.hunks[0].after_start, deleted.hunks[0].after_len ), (0, 1, 0, 0) ); assert_eq!( changed(&comparison, Path::new("empty-added")).status, FileStatus::Added ); assert!( changed(&comparison, Path::new("empty-added")) .after .is_empty() ); assert_eq!(changed(&comparison, &odd).after, ["odd"]); if non_utf8_created { assert_eq!(changed(&comparison, &non_utf8).after, ["bytes"]); } assert_eq!(changed(&comparison, Path::new("crlf")).after, ["b\r"]); assert!( changed(&comparison, Path::new("newline")) .notice .as_ref() .unwrap() .contains("EOF newline") ); let type_switch = changed(&comparison, Path::new("type-switch")); assert_eq!(type_switch.before, type_switch.after); assert!( type_switch .notice .as_ref() .unwrap() .contains("100644 → 120000") ); let binary = changed(&comparison, Path::new("binary")); assert!(binary.before.is_empty() && binary.after.is_empty() && binary.hunks.is_empty()); assert!(binary.notice.as_ref().unwrap().contains("Binary")); assert_eq!( changed(&comparison, Path::new("link")).after, ["/not/a/path/to/read"] ); assert!( changed(&comparison, Path::new("link")) .notice .as_ref() .unwrap() .contains("Symbolic link") ); assert!( changed(&comparison, Path::new("executable")) .notice .as_ref() .unwrap() .contains("mode changed") ); assert_eq!( changed(&comparison, Path::new("ignored-tracked")).after, ["changed"] ); assert!( !comparison .files .iter() .any(|f| f.path == Path::new("ignored-untracked")) ); let moved = changed(&comparison, Path::new("rename-new")); assert_eq!(moved.status, FileStatus::Renamed); assert_eq!(moved.old_path.as_deref(), Some(Path::new("rename-old"))); assert_eq!(moved.before, moved.after); assert!(moved.hunks.is_empty()); let reverse = compare_loaded( &mut repository, &working(RepoKind::Git), &revision(&id, RepoKind::Git), &[], ) .unwrap(); assert!(reverse.editable_paths.is_empty()); assert_eq!( changed(&reverse, Path::new("added")).status, FileStatus::Removed ); assert!( repository .compare( 1, &working(RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[] ) .unwrap() .files .is_empty() ); assert_eq!(disk_state(&root), state); } #[test] fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_stays_private() { let fixture = Fixture::new(); let source = fixture.dir("source"); init_git(&source); fs::write(source.join("file"), "ancestor\n").unwrap(); git(&source, &["add", "."]); git(&source, &["commit", "-m", "remote ancestor needle"]); let ancestor = git(&source, &["rev-parse", "HEAD"]); git(&source, &["tag", "-a", "ancestor-tag", "-m", "annotated"]); fs::write(source.join("file"), "remote tip\n").unwrap(); git(&source, &["commit", "-am", "remote tip"]); let tip = git(&source, &["rev-parse", "HEAD"]); git(&source, &["branch", "never-fetched"]); let bare = fixture.0.join("remote.git"); git( &fixture.0, &[ "clone", "--bare", source.to_str().unwrap(), bare.to_str().unwrap(), ], ); let active = fixture.dir("active"); init_git(&active); fs::write(active.join("file"), "local\n").unwrap(); git(&active, &["add", "."]); git(&active, &["commit", "-m", "local"]); let local = git(&active, &["rev-parse", "HEAD"]); git( &active, &["remote", "add", "origin", bare.to_str().unwrap()], ); let state = disk_state(&active); let mut repository = open(&active); let catalog = repository.list_remote("origin").unwrap(); let remote = catalog .entries .iter() .find(|r| { r.target == (RevisionTarget::Remote { remote: "origin".into(), reference: "refs/heads/never-fetched".into(), }) }) .unwrap() .clone(); let tag = catalog .entries .iter() .find(|r| { r.target == (RevisionTarget::Remote { remote: "origin".into(), reference: "refs/tags/ancestor-tag".into(), }) }) .unwrap() .clone(); assert!( catalog .entries .iter() .any(|r| r.target == RevisionTarget::FetchRemote("origin".into())) ); assert!( !catalog .entries .iter() .any(|r| r.target == RevisionTarget::Commit(ancestor.clone())) ); assert!( !command("git", &active, &["cat-file", "-e", &tip]) .status .success() ); let comparison = compare_loaded( &mut repository, &revision(&local, RepoKind::Git), &remote, &[(active.join("file"), "unsaved local text\n".into())], ) .unwrap(); assert!(comparison.editable_paths.is_empty()); assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]); assert_eq!( changed(&comparison, Path::new("file")).after, ["remote tip"] ); let comparison = compare_loaded(&mut repository, &tag, &remote, &[]).unwrap(); assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]); let comparison = compare_loaded(&mut repository, &remote, &working(RepoKind::Git), &[]).unwrap(); assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]); let loaded = repository.fetch_remote("origin").unwrap(); let historic = loaded .entries .iter() .find(|r| { r.target == RevisionTarget::Commit(ancestor.clone()) && r.category == Category::Commits }) .unwrap(); assert_eq!(historic.label, ancestor); assert_eq!(historic.description, "remote ancestor needle"); let comparison = compare_loaded( &mut repository, historic, &revision(&local, RepoKind::Git), &[], ) .unwrap(); assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]); assert!( !command("git", &active, &["cat-file", "-e", &tip]) .status .success() ); assert_eq!(disk_state(&active), state); } #[test] fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_operations() { for colocate in [false, true] { let fixture = Fixture::new(); let active = fixture.dir("active"); init_jj(&active, colocate); fs::write(active.join("file"), "baseline\n").unwrap(); fs::write(active.join("deleted"), "delete\n").unwrap(); fs::write(active.join(".gitignore"), "ignored-*\n").unwrap(); jj(&active, &["describe", "-m", "jj baseline searchable"]); let id = jj( &active, &[ "--ignore-working-copy", "--at-operation=@", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); jj(&active, &["bookmark", "create", "baseline"]); jj(&active, &["new"]); fs::write(active.join("file"), "disk\n").unwrap(); fs::remove_file(active.join("deleted")).unwrap(); fs::write(active.join("addition"), "new\n").unwrap(); fs::write(active.join("ignored-untracked"), "ignored\n").unwrap(); let state = disk_state(&active); let mut repository = open(&active); let catalog = repository.catalog().unwrap(); assert_eq!(catalog.kind, RepoKind::Jujutsu); let baseline = catalog .entries .iter() .find(|r| { r.target == RevisionTarget::Commit(id.clone()) && r.category == Category::Revisions }) .unwrap(); assert!( catalog .entries .iter() .any(|r| r.label == "baseline" && r.category == Category::Bookmarks) ); let comparison = compare_loaded(&mut repository, baseline, &working(RepoKind::Jujutsu), &[]).unwrap(); assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]); assert_eq!( changed(&comparison, Path::new("deleted")).status, FileStatus::Removed ); assert_eq!( changed(&comparison, Path::new("addition")).status, FileStatus::Added ); assert!( !comparison .files .iter() .any(|f| f.path == Path::new("ignored-untracked")) ); let synthetic_root = revision(&"0".repeat(40), RepoKind::Jujutsu); let branch = repository .compare( 20, &synthetic_root, baseline, ComparisonMode::BranchChanges, &[], ) .unwrap(); assert!(branch.before.ends_with(&"0".repeat(40))); assert!( branch .files .iter() .any(|file| file.path == Path::new("file")) ); let reversed = repository .compare( 21, baseline, &synthetic_root, ComparisonMode::BranchChanges, &[], ) .unwrap(); assert!(reversed.files.is_empty()); assert!( repository .compare(22, baseline, baseline, ComparisonMode::BranchChanges, &[]) .unwrap() .files .is_empty() ); assert_eq!(disk_state(&active), state); } } #[test] fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_change_history() { let fixture = Fixture::new(); let source = fixture.dir("source"); init_jj(&source, false); fs::write(source.join("file"), "remote ancestor\n").unwrap(); jj(&source, &["describe", "-m", "remote historic needle"]); let ancestor = jj( &source, &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); jj(&source, &["new"]); fs::write(source.join("file"), "remote tip\n").unwrap(); jj(&source, &["describe", "-m", "remote tip"]); let tip = jj( &source, &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); jj(&source, &["bookmark", "create", "never-fetched"]); let bare = fixture.dir("remote.git"); jj(&source, &["git", "export"]); // Build remote fixture data by copying exported refs, never by pushing. git( &fixture.0, &[ "clone", "--bare", "--no-hardlinks", source.join(".jj/repo/store/git").to_str().unwrap(), bare.to_str().unwrap(), ], ); let active = fixture.dir("active"); init_jj(&active, false); fs::write(active.join("file"), "local\n").unwrap(); jj(&active, &["describe", "-m", "local"]); jj(&active, &["new"]); jj( &active, &["git", "remote", "add", "origin", bare.to_str().unwrap()], ); fs::write(active.join("unsnapshotted"), "keep disk\n").unwrap(); let state = disk_state(&active); let mut repository = open(&active); let catalog = repository.list_remote("origin").unwrap(); let remote = catalog .entries .iter() .find(|r| { r.target == (RevisionTarget::Remote { remote: "origin".into(), reference: "refs/heads/never-fetched".into(), }) }) .unwrap() .clone(); assert!( !catalog .entries .iter() .any(|r| r.target == RevisionTarget::Commit(ancestor.clone())) ); let local = catalog.entries.iter().find(|revision| { matches!(&revision.target, RevisionTarget::Commit(id) if id.bytes().any(|byte| byte != b'0')) }).unwrap(); let branch = repository .compare(60, local, &remote, ComparisonMode::BranchChanges, &[]) .unwrap(); assert!(branch.before.ends_with(&"0".repeat(40))); assert_eq!(branch.files.len(), 1); assert_eq!(repository.load_file(60, 0).unwrap().after, ["remote tip"]); let comparison = compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap(); assert_eq!( changed(&comparison, Path::new("file")).before, ["remote tip"] ); assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]); let loaded = repository.fetch_remote("origin").unwrap(); let historic = loaded .entries .iter() .find(|r| { r.target == RevisionTarget::Commit(ancestor.clone()) && r.category == Category::Revisions }) .unwrap(); let comparison = compare_loaded(&mut repository, historic, &remote, &[]).unwrap(); assert_eq!( changed(&comparison, Path::new("file")).before, ["remote ancestor"] ); assert_eq!( changed(&comparison, Path::new("file")).after, ["remote tip"] ); let branch = repository .compare(61, historic, &remote, ComparisonMode::BranchChanges, &[]) .unwrap(); assert!(branch.before.ends_with(&ancestor)); assert_eq!( repository.load_file(61, 0).unwrap().before, ["remote ancestor"] ); let store = active.join(".jj/repo/store"); let active_store = fs::canonicalize(store.join(fs::read_to_string(store.join("git_target")).unwrap().trim())) .unwrap(); assert!( !command( "git", &active, &[ "--git-dir", active_store.to_str().unwrap(), "cat-file", "-e", &tip ] ) .status .success() ); assert_eq!(disk_state(&active), state); } #[test] fn errors_are_explicit_and_do_not_modify_active_repository() { let fixture = Fixture::new(); assert!(Repository::open(&fixture.0, Arc::new(AtomicBool::new(false))).is_err()); let root = fixture.dir("active"); init_git(&root); fs::write(root.join("file"), "baseline\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "baseline"]); let id = git(&root, &["rev-parse", "HEAD"]); git( &root, &[ "remote", "add", "broken", fixture.0.join("missing.git").to_str().unwrap(), ], ); let state = disk_state(&root); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); assert!(catalog.notice.is_none()); assert!( catalog .entries .iter() .any(|revision| revision.target == RevisionTarget::ListRemote("broken".into())) ); assert!(repository.list_remote("broken").is_err()); assert!(repository.fetch_remote("broken").is_err()); assert!( repository .fetch_remote("unknown") .unwrap_err() .contains("Unknown configured remote") ); assert!( repository .compare( 1, &revision("--bad", RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[] ) .unwrap_err() .contains("Invalid immutable commit") ); assert!( repository .compare( 1, &revision(&"f".repeat(40), RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[], ) .unwrap_err() .contains("unavailable") ); let loader = Revision { target: RevisionTarget::FetchRemote("broken".into()), ..working(RepoKind::Git) }; assert!( repository .compare( 1, &loader, &revision(&id, RepoKind::Git), ComparisonMode::Snapshots, &[] ) .unwrap_err() .contains("history loader") ); assert!( Repository::open(&root, Arc::new(AtomicBool::new(true))) .err() .unwrap() .contains("cancelled") ); assert_eq!(disk_state(&root), state); } #[test] fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit() { let fixture = Fixture::new(); let child = fixture.dir("child"); init_git(&child); fs::write(child.join("file"), "first\n").unwrap(); git(&child, &["add", "."]); git(&child, &["commit", "-m", "first"]); let first = git(&child, &["rev-parse", "HEAD"]); let root = fixture.dir("active"); init_git(&root); git( &root, &[ "-c", "protocol.file.allow=always", "submodule", "add", child.to_str().unwrap(), "nested", ], ); git(&root, &["commit", "-m", "submodule"]); let baseline = git(&root, &["rev-parse", "HEAD"]); let mut repository = open(&root); assert!( repository .compare( 1, &revision(&baseline, RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[] ) .unwrap() .files .is_empty() ); fs::write(child.join("file"), "second\n").unwrap(); git(&child, &["commit", "-am", "second"]); let second = git(&child, &["rev-parse", "HEAD"]); git(&root.join("nested"), &["fetch", "origin"]); git(&root.join("nested"), &["checkout", "--detach", &second]); let state = disk_state(&root); let comparison = compare_loaded( &mut repository, &revision(&baseline, RepoKind::Git), &working(RepoKind::Git), &[], ) .unwrap(); let submodule = changed(&comparison, Path::new("nested")); assert_eq!(submodule.status, FileStatus::Modified); let notice = submodule.notice.as_ref().unwrap(); assert!( notice.contains(&first) && notice.contains(&second) && notice.contains("not traversed") ); assert!(submodule.before.is_empty() && submodule.after.is_empty()); assert!( !comparison .files .iter() .any(|f| f.path.starts_with("nested/file")) ); assert_eq!(disk_state(&root), state); } #[test] fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() { let fixture = Fixture::new(); let mut ids = vec![]; let mut urls = vec![]; for name in ["one", "two"] { let source = fixture.dir(name); init_git(&source); fs::write(source.join("file"), format!("{name}\n")).unwrap(); git(&source, &["add", "."]); git( &source, &["commit", "-m", &format!("{name} tag-only needle")], ); ids.push(git(&source, &["rev-parse", "HEAD"])); git(&source, &["tag", "collision"]); git(&source, &["update-ref", "-d", "refs/heads/local"]); let bare = fixture.0.join(format!("{name}.git")); git( &fixture.0, &[ "clone", "--bare", source.to_str().unwrap(), bare.to_str().unwrap(), ], ); urls.push(bare); } let active = fixture.dir("active"); init_git(&active); fs::write(active.join("local"), "local\n").unwrap(); git(&active, &["add", "."]); git(&active, &["commit", "-m", "local"]); for (name, url) in ["one", "two"].into_iter().zip(&urls) { git(&active, &["remote", "add", name, url.to_str().unwrap()]); } let state = disk_state(&active); let mut repository = open(&active); repository.fetch_remote("one").unwrap(); let loaded = repository.fetch_remote("two").unwrap(); for (name, id) in ["one", "two"].into_iter().zip(&ids) { assert!( loaded .entries .iter() .any(|r| r.target == RevisionTarget::Commit(id.clone()) && r.description == format!("{name} tag-only needle")) ); } let comparison = compare_loaded( &mut repository, &revision(&ids[0], RepoKind::Git), &revision(&ids[1], RepoKind::Git), &[], ) .unwrap(); assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]); assert_eq!(changed(&comparison, Path::new("file")).after, ["two"]); assert_eq!(disk_state(&active), state); } #[test] fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable() { let fixture = Fixture::new(); let root = fixture.dir("active"); init_jj(&root, false); fs::write(root.join("conflict"), "base\n").unwrap(); fs::write(root.join("ordinary"), "old\n").unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj( &root, &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); jj(&root, &["new", &base]); fs::write(root.join("conflict"), "left\n").unwrap(); fs::write(root.join("ordinary"), "left ordinary\n").unwrap(); jj(&root, &["describe", "-m", "left"]); let left = jj( &root, &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); jj(&root, &["new", &base]); fs::write(root.join("conflict"), "right\n").unwrap(); jj(&root, &["describe", "-m", "right"]); let right = jj( &root, &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); jj(&root, &["new", &left, &right, "-m", "conflicted merge"]); let merged = jj( &root, &[ "--ignore-working-copy", "--at-operation=@", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); let projection = command( "jj", &root, &[ "--ignore-working-copy", "--at-operation=@", "file", "show", "-r", &merged, "-T", "", "--", "root-file:\"conflict\"", ], ); assert!( projection.status.success(), "{}", String::from_utf8_lossy(&projection.stderr) ); let projection = String::from_utf8(projection.stdout).unwrap(); let expected: Vec<_> = projection .strip_suffix('\n') .unwrap_or(&projection) .split('\n') .map(str::to_owned) .collect(); let state = disk_state(&root); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); assert!( catalog .entries .iter() .any(|r| r.target == RevisionTarget::Commit(merged.clone())) ); let comparison = compare_loaded( &mut repository, &revision(&base, RepoKind::Jujutsu), &revision(&merged, RepoKind::Jujutsu), &[], ) .unwrap(); let conflict = changed(&comparison, Path::new("conflict")); assert_eq!(conflict.after, expected); assert!( conflict .notice .as_ref() .unwrap() .contains("conflict projection") ); assert!(!conflict.hunks.is_empty()); let ordinary = changed(&comparison, Path::new("ordinary")); assert_eq!(ordinary.before, ["old"]); assert_eq!(ordinary.after, ["left ordinary"]); assert!(ordinary.notice.is_none()); let branch = repository .compare( 30, &revision(&base, RepoKind::Jujutsu), &revision(&merged, RepoKind::Jujutsu), ComparisonMode::BranchChanges, &[], ) .unwrap(); let index = branch .files .iter() .position(|file| file.path == Path::new("conflict")) .unwrap(); let conflict = repository.load_file(30, index).unwrap(); assert_eq!(conflict.after, expected); assert!( conflict .notice .as_deref() .unwrap() .contains("conflict projection") ); let working_comparison = compare_loaded( &mut repository, &revision(&merged, RepoKind::Jujutsu), &working(RepoKind::Jujutsu), &[], ) .unwrap(); assert!( changed(&working_comparison, Path::new("conflict")) .notice .as_ref() .unwrap() .contains("conflict projection") ); assert_eq!(disk_state(&root), state); } #[test] fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths() { let fixture = Fixture::new(); let root = fs::canonicalize(fixture.dir("overlay")).unwrap(); init_git(&root); fs::create_dir(root.join("sub")).unwrap(); fs::write(root.join("file"), "base\n").unwrap(); fs::write(root.join("sub/file"), "nested\n").unwrap(); fs::write(root.join("binary"), b"\0binary").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "base"]); let id = git(&root, &["rev-parse", "HEAD"]); let outside = fixture.dir("outside"); fs::write(outside.join("file"), "outside\n").unwrap(); symlink(&outside, root.join("linked")).unwrap(); symlink("file", root.join("link")).unwrap(); let state = disk_state(&root); let buffers = vec![ (root.join("file"), "unsaved\n".into()), (root.join("binary"), "not binary\n".into()), (root.join("missing"), "not on disk\n".into()), (root.join("link"), "not a regular file\n".into()), (root.join("linked/file"), "outside via link\n".into()), (outside.join("file"), "outside\n".into()), (root.join("sub/../../outside/file"), "escaped\n".into()), (root.join(".git/config"), "metadata\n".into()), ]; let mut repository = open(&root.join("sub")); let base = revision(&id, RepoKind::Git); let checkout = working(RepoKind::Git); let comparison = compare_loaded(&mut repository, &base, &checkout, &buffers).unwrap(); assert_eq!(comparison.root, fs::canonicalize(&root).unwrap()); assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]); assert!(comparison.editable_paths.contains(Path::new("file"))); for path in [ "binary", "missing", "link", "linked", "linked/file", ".git/config", ] { assert!(!comparison.editable_paths.contains(Path::new(path))); } assert!( !comparison .files .iter() .any(|file| file.path == Path::new("binary") || file.path == Path::new("missing") || file.path == Path::new("linked/file")) ); let immutable = compare_loaded(&mut repository, &checkout, &base, &buffers).unwrap(); assert!(immutable.editable_paths.is_empty()); assert!( !immutable .files .iter() .any(|file| file.path == Path::new("file")) ); assert!( repository .compare(1, &base, &base, ComparisonMode::Snapshots, &buffers) .unwrap() .files .is_empty() ); let same_checkout = compare_loaded(&mut repository, &checkout, &checkout, &buffers).unwrap(); assert_eq!(changed(&same_checkout, Path::new("file")).before, ["base"]); assert_eq!( changed(&same_checkout, Path::new("file")).after, ["unsaved"] ); assert_eq!(disk_state(&root), state); assert_eq!( fs::read_to_string(outside.join("file")).unwrap(), "outside\n" ); } #[test] fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details() { let fixture = Fixture::new(); let root = fixture.dir("lazy"); init_git(&root); fs::write(root.join("unchanged-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap(); fs::write(root.join("selected"), "before\n").unwrap(); fs::write(root.join("unselected"), "old\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "before"]); let before = git(&root, &["rev-parse", "HEAD"]); fs::write(root.join("selected"), "after\n").unwrap(); fs::write(root.join("unselected"), vec![b'y'; 17 * 1024 * 1024]).unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "after"]); let after = git(&root, &["rev-parse", "HEAD"]); let mut repository = open(&root); let comparison = repository .compare( 100, &revision(&before, RepoKind::Git), &revision(&after, RepoKind::Git), ComparisonMode::Snapshots, &[], ) .unwrap(); assert_eq!(comparison.files.len(), 2); assert!(comparison.files.iter().all(|file| !file.loaded && file.before.is_empty() && file.after.is_empty() && file.hunks.is_empty())); let index = comparison .files .iter() .position(|file| file.path == Path::new("selected")) .unwrap(); let selected = repository.load_file(100, index).unwrap(); assert!(selected.loaded); assert_eq!(selected.before, ["before"]); assert_eq!(selected.after, ["after"]); assert_eq!(selected.hunks.len(), 1); assert!(repository.load_file(99, index).is_err()); let empty = repository .compare( 101, &revision(&after, RepoKind::Git), &revision(&after, RepoKind::Git), ComparisonMode::Snapshots, &[], ) .unwrap(); assert!(empty.files.is_empty()); assert!(repository.load_file(100, index).is_err()); } #[test] fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() { let fixture = Fixture::new(); let root = fixture.dir("frozen").canonicalize().unwrap(); init_git(&root); fs::write(root.join("file"), "committed\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "before"]); fs::write(root.join("file"), "disk before\n").unwrap(); let mut repository = open(&root); let comparison = repository .compare( 50, &working(RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[(root.join("file"), "unsaved after\n".into())], ) .unwrap(); assert_eq!(comparison.files.len(), 1); assert!(!comparison.files[0].loaded); fs::write(root.join("file"), "later disk\n").unwrap(); let details = repository.load_file(50, 0).unwrap(); assert_eq!(details.before, ["disk before"]); assert_eq!(details.after, ["unsaved after"]); let again = repository.load_file(50, 0).unwrap(); assert_eq!(again.before, details.before); assert_eq!(again.after, details.after); } #[test] fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_mutation() { let fixture = Fixture::new(); let active = fixture.dir("active\nquoted\"\\"); init_git(&active); fs::write(active.join("common"), "base\n").unwrap(); git(&active, &["add", "."]); git(&active, &["commit", "-m", "base"]); let base = git(&active, &["rev-parse", "HEAD"]); let source = fixture.0.join("source"); git( &fixture.0, &["clone", active.to_str().unwrap(), source.to_str().unwrap()], ); git(&source, &["config", "user.name", "Diff Test"]); git(&source, &["config", "user.email", "diff@example.invalid"]); fs::write(source.join("remote-only"), "remote\n").unwrap(); git(&source, &["add", "."]); git(&source, &["commit", "-m", "remote"]); fs::write(active.join("local-only"), "local\n").unwrap(); git(&active, &["add", "."]); git(&active, &["commit", "-m", "local"]); let local = revision(&git(&active, &["rev-parse", "HEAD"]), RepoKind::Git); git( &active, &["remote", "add", "origin", source.to_str().unwrap()], ); let state = disk_state(&active); let mut repository = open(&active); let catalog = repository.list_remote("origin").unwrap(); let remote = catalog .entries .iter() .find(|r| matches!(r.target, RevisionTarget::Remote { .. })) .unwrap(); let branch = repository .compare(40, &local, remote, ComparisonMode::BranchChanges, &[]) .unwrap(); assert_eq!(branch.files.len(), 1); assert_eq!(branch.files[0].path, Path::new("remote-only")); assert!(branch.before.ends_with(&base)); assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]); let reverse = repository .compare(41, remote, &local, ComparisonMode::BranchChanges, &[]) .unwrap(); assert_eq!(reverse.files.len(), 1); assert_eq!(reverse.files[0].path, Path::new("local-only")); assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]); assert!( repository .compare(42, &local, &local, ComparisonMode::BranchChanges, &[]) .unwrap() .files .is_empty() ); assert_eq!(disk_state(&active), state); } #[test] fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fallback() { let fixture = Fixture::new(); let root = fixture.dir("graphs"); init_git(&root); fs::write(root.join("file"), "base\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "base"]); let base = git(&root, &["rev-parse", "HEAD"]); let tree = git(&root, &["rev-parse", "HEAD^{tree}"]); let left = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "left"]); let right = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "right"]); let one = git( &root, &["commit-tree", &tree, "-p", &left, "-p", &right, "-m", "one"], ); let two = git( &root, &["commit-tree", &tree, "-p", &right, "-p", &left, "-m", "two"], ); let unrelated = git(&root, &["commit-tree", &tree, "-m", "unrelated"]); let state = disk_state(&root); let mut repository = open(&root); for (before, after, message) in [ (&base, &unrelated, "no merge base"), (&one, &two, "multiple merge bases"), ] { let before = revision(before, RepoKind::Git); let after = revision(after, RepoKind::Git); assert!( repository .compare(50, &before, &after, ComparisonMode::BranchChanges, &[]) .unwrap_err() .contains(message) ); assert!( repository .compare(51, &before, &after, ComparisonMode::Snapshots, &[]) .unwrap() .files .is_empty() ); } assert_eq!(disk_state(&root), state); }