#![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::{CheckoutRefresh, 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 checkout_comparison(refresh: CheckoutRefresh) -> Comparison { let CheckoutRefresh::Checkout(comparison) = refresh else { panic!("Expected checkout-only refresh"); }; comparison } 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 comparison_revisions_freeze_commits_and_address_working_contents() { for kind in [RepoKind::Git, RepoKind::Jujutsu] { let fixture = Fixture::new(); let root = fixture.dir("identity").canonicalize().unwrap(); match kind { RepoKind::Git => init_git(&root), RepoKind::Jujutsu => init_jj(&root, false), } fs::write(root.join("file"), "committed\n").unwrap(); let id = match kind { RepoKind::Git => { git(&root, &["add", "."]); git(&root, &["commit", "-m", "baseline"]); git(&root, &["rev-parse", "HEAD"]) } RepoKind::Jujutsu => { jj(&root, &["describe", "-m", "baseline"]); jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]) } }; fs::write(root.join("file"), "working\n").unwrap(); let state = disk_state(&root); let before = revision(&id, kind); let after = working(kind); let mut repository = open(&root); let first = repository .compare(1, &before, &after, ComparisonMode::Snapshots, &[]) .unwrap(); assert_eq!(first.revisions[0], id); assert!(first.revisions[1].starts_with("working:")); assert_eq!(first.blame[0].as_ref().unwrap().commit, id); assert_eq!( first.blame[1] .as_ref() .map(|baseline| baseline.commit.as_str()), (kind == RepoKind::Git).then_some(id.as_str()) ); let reopened = open(&root) .compare(2, &before, &after, ComparisonMode::Snapshots, &[]) .unwrap(); assert_eq!(first.revisions, reopened.revisions); let overlay = repository .compare( 3, &before, &after, ComparisonMode::Snapshots, &[(root.join("file"), "unsaved\n".into())], ) .unwrap(); assert_ne!(overlay.revisions[1], first.revisions[1]); let repeated = repository .compare( 4, &before, &after, ComparisonMode::Snapshots, &[(root.join("file"), "unsaved\n".into())], ) .unwrap(); assert_eq!(repeated.revisions, overlay.revisions); let working_pair = repository .compare(5, &after, &after, ComparisonMode::Snapshots, &[]) .unwrap(); assert_eq!(working_pair.revisions[0], first.revisions[1]); assert_eq!(working_pair.revisions[0], working_pair.revisions[1]); assert_eq!( working_pair.blame, [first.blame[1].clone(), first.blame[1].clone()] ); assert_eq!(disk_state(&root), state); fs::write(root.join("file"), "working").unwrap(); let no_newline = repository .compare(6, &before, &after, ComparisonMode::Snapshots, &[]) .unwrap(); assert_ne!(no_newline.revisions[1], first.revisions[1]); } } #[test] fn unopened_catalog_counts_frozen_text_but_not_binary_modes_or_exact_renames() { let fixture = Fixture::new(); let root = fixture.dir("stats"); init_git(&root); for (path, bytes) in [ ("first", &b"old\nkeep\n"[..]), ("second", b"remove\nkeep\n"), ("removed", b"gone\n"), ("rename-old", b"move\n"), ("binary", b"\0old"), ("mode", b"script\n"), ("crlf", b"a\r\n"), ("eof", b"same\n"), ] { fs::write(root.join(path), bytes).unwrap(); } git(&root, &["add", "."]); git(&root, &["commit", "-m", "before"]); let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); for (path, bytes) in [ ("first", &b"new\nextra\nkeep\n"[..]), ("second", b"keep\n"), ("added", b"one\nlast"), ("binary", b"\0new"), ("crlf", b"b\r\n"), ("eof", b"same"), ] { fs::write(root.join(path), bytes).unwrap(); } fs::remove_file(root.join("removed")).unwrap(); fs::rename(root.join("rename-old"), root.join("-renamed\t\n")).unwrap(); fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); let mut repository = open(&root); let catalog = repository .compare( 1, &before, &working(RepoKind::Git), ComparisonMode::Snapshots, &[], ) .unwrap(); assert!(catalog.files.iter().all(|file| !file.loaded && file.before.is_empty() && file.after.is_empty() && file.hunks.is_empty())); for (path, counts) in [ ("first", (2, 1)), ("second", (0, 1)), ("added", (2, 0)), ("removed", (0, 1)), ("-renamed\t\n", (0, 0)), ("binary", (0, 0)), ("mode", (0, 0)), ("crlf", (1, 1)), ("eof", (1, 1)), ] { assert_eq!( changed(&catalog, Path::new(path)).stats, Some(counts), "{path}" ); } assert_eq!( changed(&catalog, Path::new("-renamed\t\n")).status, FileStatus::Renamed ); fs::write(root.join("second"), "changed after freezing\n").unwrap(); for (index, file) in catalog.files.iter().enumerate() { let loaded = repository.load_file(1, index).unwrap(); let counts = loaded.hunks.iter().fold((0, 0), |(added, removed), hunk| { (added + hunk.after_len, removed + hunk.before_len) }); assert_eq!(file.stats, Some(counts), "{}", file.path.display()); } git(&root, &["add", "-A"]); git(&root, &["commit", "-m", "advance checkout"]); let refreshed = repository .refresh_checkout(1, 2, true, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(changed(&refreshed, Path::new("first")).stats, Some((2, 1))); assert_eq!(changed(&refreshed, Path::new("second")).stats, Some((1, 2))); } #[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.contains(Path::new("text"))); assert!(!reverse.editable_paths.contains(Path::new("added"))); assert!(!reverse.editable_paths.contains(Path::new("link"))); assert_eq!(changed(&reverse, Path::new("text")).after, ["one", "three"]); 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); // An advertised remote alias already available at HEAD is eligible without // importing refs or substituting the local unsaved overlay for pinned bytes. git( &source, &["remote", "add", "origin", bare.to_str().unwrap()], ); let source_state = disk_state(&source); let mut checked_out = open(&source); let advertised = checked_out.list_remote("origin").unwrap(); let alias = advertised .entries .iter() .find(|entry| entry.target == remote.target) .unwrap(); let pinned = checked_out .compare( 20, &revision(&ancestor, RepoKind::Git), alias, ComparisonMode::Snapshots, &[(source.join("file"), "dirty live\n".into())], ) .unwrap(); assert!(pinned.editable_paths.contains(Path::new("file"))); assert_eq!(checked_out.load_file(20, 0).unwrap().after, ["remote tip"]); assert_eq!(disk_state(&source), source_state); git(&source, &["checkout", "--detach", &ancestor]); let readonly = checked_out .refresh_checkout(20, 21, false, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert!(readonly.editable_paths.is_empty()); assert_eq!(checked_out.load_file(21, 0).unwrap().after, ["remote tip"]); git(&source, &["checkout", "--detach", &tip]); let promoted = checked_out .refresh_checkout(21, 22, false, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(promoted.editable_paths, pinned.editable_paths); assert_eq!(checked_out.load_file(22, 0).unwrap().after, ["remote tip"]); } #[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_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching_source() { for colocate in [false, true] { let fixture = Fixture::new(); let root = fixture.dir("bookmarks"); init_jj(&root, colocate); fs::write(root.join("file"), "base\n").unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["bookmark", "create", "topic"]); jj(&root, &["new"]); fs::write(root.join("file"), "next\n").unwrap(); jj(&root, &["describe", "-m", "next"]); let next = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); let topic = catalog .entries .iter() .find(|entry| entry.label == "topic") .unwrap(); assert_eq!( topic.target, RevisionTarget::Bookmark { name: "topic".into(), remote: None } ); repository .compare( 1, &revision(&"0".repeat(40), RepoKind::Jujutsu), topic, ComparisonMode::Snapshots, &[], ) .unwrap(); jj(&root, &["bookmark", "set", "topic", "-r", &next]); let state = disk_state(&root); let Some(CheckoutRefresh::Bookmark(comparison)) = repository.refresh_checkout(1, 2, false, &[]).unwrap() else { panic!("Bookmark movement must replace contents even when follow is disabled"); }; assert_eq!(comparison.after, topic.label); assert!(comparison.blame[0].is_none()); assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next); assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]); assert!(repository.load_file(1, 0).is_err()); assert!( repository .refresh_checkout(2, 3, false, &[]) .unwrap() .is_none() ); assert_eq!(disk_state(&root), state); let change = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "change_id"]); fs::write(root.join("file"), "rewritten\n").unwrap(); jj(&root, &["describe", "-m", "rewritten"]); assert_eq!( jj(&root, &["log", "--no-graph", "-r", "@", "-T", "change_id"]), change ); let state = disk_state(&root); assert!(matches!( repository.refresh_checkout(2, 3, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); assert_eq!(repository.load_file(3, 0).unwrap().after, ["rewritten"]); assert_eq!(disk_state(&root), state); jj(&root, &["describe", "-m", "description-only rewrite"]); assert!(matches!( repository.refresh_checkout(3, 4, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); assert_eq!(repository.load_file(4, 0).unwrap().after, ["rewritten"]); repository .compare( 5, &revision(&"0".repeat(40), RepoKind::Jujutsu), &revision(&base, RepoKind::Jujutsu), ComparisonMode::Snapshots, &[], ) .unwrap(); assert!( repository .refresh_checkout(5, 6, false, &[]) .unwrap() .is_none() ); assert_eq!(repository.load_file(5, 0).unwrap().after, ["base"]); } } #[test] fn jj_bookmark_refresh_retains_frozen_working_before_and_working_after_overlays() { let fixture = Fixture::new(); let root = fixture.dir("bookmark-working").canonicalize().unwrap(); init_jj(&root, false); fs::write(root.join("file"), "base\n").unwrap(); jj(&root, &["describe", "-m", "base"]); jj(&root, &["bookmark", "create", "topic"]); jj(&root, &["new"]); fs::write(root.join("file"), "frozen disk\n").unwrap(); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); let topic = catalog .entries .iter() .find(|entry| entry.label == "topic") .unwrap(); repository .compare( 1, &working(RepoKind::Jujutsu), topic, ComparisonMode::Snapshots, &[], ) .unwrap(); fs::write(root.join("file"), "new topic\n").unwrap(); jj(&root, &["describe", "-m", "next"]); jj(&root, &["bookmark", "set", "topic", "-r", "@"]); assert!(matches!( repository.refresh_checkout(1, 2, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); let file = repository.load_file(2, 0).unwrap(); assert_eq!(file.before, ["frozen disk"]); assert_eq!(file.after, ["new topic"]); repository .compare( 3, topic, &working(RepoKind::Jujutsu), ComparisonMode::Snapshots, &[(root.join("file"), "old overlay\n".into())], ) .unwrap(); fs::write(root.join("file"), "rewritten topic\n").unwrap(); jj(&root, &["describe", "-m", "rewrite"]); let state = disk_state(&root); assert!(matches!( repository .refresh_checkout(3, 4, false, &[(root.join("file"), "dirty buffer\n".into())]) .unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); let file = repository.load_file(4, 0).unwrap(); assert_eq!(file.before, ["rewritten topic"]); // Live buffer rebinding is a frontend concern; a bookmark refresh must not // advance the unrelated frozen Working endpoint while follow is disabled. assert_eq!(file.after, ["old overlay"]); assert_eq!(disk_state(&root), state); jj(&root, &["new"]); fs::write(root.join("file"), "advanced checkout\n").unwrap(); jj(&root, &["describe", "-m", "advance"]); jj(&root, &["bookmark", "set", "topic", "-r", "@"]); assert!(matches!( repository.refresh_checkout(4, 5, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); assert_eq!(repository.load_file(5, 0).unwrap().after, ["old overlay"]); assert!(matches!( repository .refresh_checkout(5, 6, true, &[(root.join("file"), "new overlay\n".into())]) .unwrap(), Some(CheckoutRefresh::Checkout(_)) )); let file = repository.load_file(6, 0).unwrap(); assert_eq!(file.before, ["advanced checkout"]); assert_eq!(file.after, ["new overlay"]); } #[test] fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion() { let fixture = Fixture::new(); let root = fixture.dir("bookmark-branch"); init_jj(&root, false); fs::write(root.join("file"), "base\n").unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["bookmark", "create", "before"]); jj(&root, &["new"]); fs::write(root.join("file"), "tip\n").unwrap(); jj(&root, &["describe", "-m", "tip"]); let tip = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["bookmark", "create", "after"]); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); let before = catalog .entries .iter() .find(|entry| entry.label == "before") .unwrap(); let after = catalog .entries .iter() .find(|entry| entry.label == "after") .unwrap(); let original = repository .compare(1, before, after, ComparisonMode::BranchChanges, &[]) .unwrap(); assert!(original.before.ends_with(&base)); assert_eq!(original.revisions, [base.clone(), tip.clone()]); assert_eq!(original.blame[0].as_ref().unwrap().commit, base); assert_eq!(original.blame[1].as_ref().unwrap().commit, tip); jj(&root, &["bookmark", "set", "before", "-r", &tip]); let Some(CheckoutRefresh::Bookmark(comparison)) = repository.refresh_checkout(1, 2, false, &[]).unwrap() else { panic!("Moved Before must recompute merge base"); }; assert!(comparison.before.ends_with(&tip)); assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]); assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip); assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip); assert!(comparison.files.is_empty()); jj(&root, &["bookmark", "delete", "after"]); let state = disk_state(&root); assert!( repository .refresh_checkout(2, 3, false, &[]) .unwrap_err() .contains("missing or deleted") ); assert!(repository.load_file(3, 0).is_err()); assert_eq!(disk_state(&root), state); jj(&root, &["bookmark", "create", "after", "-r", &tip]); // A restored bookmark at the same ID is a successful no-op (or eligibility // promotion), not another comparison-content replacement. assert!(!matches!( repository.refresh_checkout(2, 3, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); jj( &root, &["bookmark", "set", "after", "-r", &base, "--allow-backwards"], ); let Some(CheckoutRefresh::Bookmark(comparison)) = repository.refresh_checkout(3, 4, false, &[]).unwrap() else { panic!("Backward bookmark movement must refresh"); }; assert!(comparison.before.ends_with(&base)); assert!(comparison.files.is_empty()); } #[test] fn jj_bookmark_refresh_uses_exact_names_and_keeps_last_view_through_conflicts() { let fixture = Fixture::new(); let root = fixture.dir("bookmark-conflict"); init_jj(&root, false); fs::write(root.join("file"), "base\n").unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let name = "topic'quoted"; let argument = "\"topic'quoted\""; jj(&root, &["bookmark", "create", argument]); jj(&root, &["bookmark", "create", "topic"]); jj(&root, &["new"]); fs::write(root.join("file"), "left\n").unwrap(); jj(&root, &["describe", "-m", "left"]); let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["bookmark", "create", "keep-left"]); jj(&root, &["new", &base]); fs::write(root.join("file"), "right\n").unwrap(); jj(&root, &["describe", "-m", "right"]); let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let mut repository = open(&root); let catalog = repository.catalog().unwrap(); let selected = catalog .entries .iter() .find(|entry| entry.label == name) .unwrap(); repository .compare( 1, &revision(&"0".repeat(40), RepoKind::Jujutsu), selected, ComparisonMode::Snapshots, &[], ) .unwrap(); jj(&root, &["bookmark", "set", "topic", "-r", &left]); assert!( repository .refresh_checkout(1, 2, false, &[]) .unwrap() .is_none() ); let operation = jj( &root, &["op", "log", "--no-graph", "--limit", "1", "-T", "id"], ); jj(&root, &["bookmark", "set", argument, "-r", &left]); jj( &root, &[ &format!("--at-operation={operation}"), "bookmark", "set", argument, "-r", &right, ], ); // Merge the fixture's concurrent operations, not the viewer's read queries. jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let state = disk_state(&root); assert!( repository .refresh_checkout(1, 2, false, &[]) .unwrap_err() .contains("conflicted") ); assert_eq!(repository.load_file(1, 0).unwrap().after, ["base"]); assert!(repository.load_file(2, 0).is_err()); assert_eq!(disk_state(&root), state); jj( &root, &[ "bookmark", "set", argument, "-r", &left, "--allow-backwards", ], ); assert!(matches!( repository.refresh_checkout(1, 2, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); assert_eq!(repository.load_file(2, 0).unwrap().after, ["left"]); } #[test] fn jj_known_remote_bookmark_refreshes_locally_but_private_catalog_entries_stay_frozen() { let fixture = Fixture::new(); let source = fixture.dir("source"); init_jj(&source, false); fs::write(source.join("file"), "first\n").unwrap(); jj(&source, &["describe", "-m", "first"]); jj(&source, &["bookmark", "create", "topic"]); jj(&source, &["bookmark", "create", "deleted"]); jj(&source, &["git", "export"]); let root = fixture.dir("active"); init_jj(&root, false); jj( &root, &[ "git", "remote", "add", "origin", source.join(".jj/repo/store/git").to_str().unwrap(), ], ); let mut repository = open(&root); let private = repository.fetch_remote("origin").unwrap(); let frozen = private .entries .iter() .find(|entry| { entry.label == "topic@origin" && matches!(entry.target, RevisionTarget::Commit(_)) }) .unwrap(); jj(&root, &["git", "fetch", "--remote", "origin"]); jj(&root, &["bookmark", "track", "deleted@origin"]); jj(&root, &["bookmark", "delete", "deleted"]); let catalog = repository.catalog().unwrap(); let remote = catalog .entries .iter() .find(|entry| { entry.target == (RevisionTarget::Bookmark { name: "topic".into(), remote: Some("origin".into()), }) }) .unwrap(); repository .compare( 1, &revision(&"0".repeat(40), RepoKind::Jujutsu), remote, ComparisonMode::Snapshots, &[], ) .unwrap(); jj(&source, &["new"]); fs::write(source.join("file"), "second\n").unwrap(); jj(&source, &["describe", "-m", "second"]); jj(&source, &["bookmark", "set", "topic", "-r", "@"]); jj(&source, &["git", "export"]); // Remote movement alone does not cause a network poll by the viewer. assert!( repository .refresh_checkout(1, 2, false, &[]) .unwrap() .is_none() ); jj(&root, &["git", "fetch", "--remote", "origin"]); let state = disk_state(&root); assert!(matches!( repository.refresh_checkout(1, 2, false, &[]).unwrap(), Some(CheckoutRefresh::Bookmark(_)) )); assert_eq!(repository.load_file(2, 0).unwrap().after, ["second"]); assert_eq!(disk_state(&root), state); repository .compare( 3, &revision(&"0".repeat(40), RepoKind::Jujutsu), frozen, ComparisonMode::Snapshots, &[], ) .unwrap(); assert!( repository .refresh_checkout(3, 4, false, &[]) .unwrap() .is_none() ); assert_eq!(repository.load_file(3, 0).unwrap().after, ["first"]); } #[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"]); assert!(branch.editable_paths.is_empty()); 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); assert!( repository .refresh_checkout(61, 62, false, &[]) .unwrap() .is_none() ); // Simulate importing and checking out the privately selected endpoint behind // the viewer. Its initially unavailable active-jj identity must be retried. jj(&active, &["git", "fetch", "--remote", "origin"]); jj(&active, &["edit", "--ignore-immutable", &tip]); let checked_out_state = disk_state(&active); let promoted = repository .refresh_checkout( 61, 62, false, &[(active.join("file"), "dirty live\n".into())], ) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(promoted.before, branch.before); assert_eq!(promoted.after, branch.after); assert_eq!(promoted.blame, branch.blame); assert!(promoted.editable_paths.contains(Path::new("file"))); assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]); assert_eq!(disk_state(&active), checked_out_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(); symlink("ordinary", root.join("unchanged-link")).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(); assert_eq!( comparison .files .iter() .map(|file| file.path.as_path()) .collect::>(), [Path::new("conflict"), Path::new("ordinary")] ); 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(); assert_eq!(changed(&branch, Path::new("conflict")).stats, None); assert_eq!(changed(&branch, Path::new("ordinary")).stats, Some((1, 1))); 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 conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() { let fixture = Fixture::new(); let root = fixture.dir("lazy-conflict"); init_jj(&root, false); jj( &root, &[ "config", "set", "--repo", "snapshot.max-new-file-size", "100MiB", ], ); let tail_line = "x".repeat(1023); let tail = format!("{tail_line}\n").repeat(17 * 1024 + 1); fs::write(root.join("conflict"), format!("base\n{tail}")).unwrap(); fs::write(root.join("unselected"), "old\n").unwrap(); symlink("unselected", root.join("unchanged-link")).unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &base]); fs::write(root.join("conflict"), format!("left\n{tail}")).unwrap(); fs::write(root.join("unselected"), vec![b'x'; 17 * 1024 * 1024]).unwrap(); jj(&root, &["describe", "-m", "left"]); let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &base]); fs::write(root.join("conflict"), format!("right\n{tail}")).unwrap(); jj(&root, &["describe", "-m", "right"]); let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &left, &right, "-m", "conflicted merge"]); let merged = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let state = disk_state(&root); let mut repository = open(&root); let comparison = repository .compare( 1, &revision(&base, RepoKind::Jujutsu), &revision(&merged, RepoKind::Jujutsu), ComparisonMode::BranchChanges, &[], ) .unwrap(); assert_eq!( comparison .files .iter() .map(|file| file.path.as_path()) .collect::>(), [Path::new("conflict"), Path::new("unselected")] ); let conflict = repository.load_file(1, 0).unwrap(); assert_eq!(conflict.before[0], "base"); assert_eq!(conflict.before.len(), 17 * 1024 + 2); assert!(conflict.before[1..].iter().all(|line| line == &tail_line)); let projection = jj( &root, &[ "--ignore-working-copy", "file", "show", "-r", &merged, "-T", "", "--", "conflict", ], ); assert!(projection.len() > 16 * 1024 * 1024); assert_eq!(conflict.after, projection.lines().collect::>()); assert!(!conflict.hunks.is_empty()); let large = repository.load_file(1, 1).unwrap(); assert_eq!(large.after.len(), 1); assert_eq!(large.after[0].len(), 17 * 1024 * 1024); assert!(large.after[0].bytes().all(|byte| byte == b'x')); assert_eq!(disk_state(&root), state); jj(&root, &["new", &merged]); fs::write(root.join("extra"), "extra\n").unwrap(); jj( &root, &["describe", "-m", "unrelated change retaining conflict"], ); let next = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let comparison = repository .compare( 2, &revision(&merged, RepoKind::Jujutsu), &revision(&next, RepoKind::Jujutsu), ComparisonMode::Snapshots, &[], ) .unwrap(); assert_eq!( comparison .files .iter() .map(|file| file.path.as_path()) .collect::>(), [Path::new("extra")] ); } #[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 comparison_size_and_line_ceilings_are_lazy_and_explicit() { let fixture = Fixture::new(); let root = fixture.dir("ceilings"); init_git(&root); git(&root, &["commit", "--allow-empty", "-m", "before"]); let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); fs::File::create(root.join("too-many-bytes")) .unwrap() .set_len(64 * 1024 * 1024 + 1) .unwrap(); fs::write(root.join("too-many-lines"), vec![b'\n'; 4_000_001]).unwrap(); fs::write(root.join("selected"), "still available\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "after"]); let after = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); let mut repository = open(&root); let catalog = repository .compare(1, &before, &after, ComparisonMode::Snapshots, &[]) .unwrap(); assert_eq!(catalog.files.len(), 3); assert!(catalog.files.iter().all(|file| !file.loaded)); assert_eq!(changed(&catalog, Path::new("too-many-bytes")).stats, None); assert_eq!(changed(&catalog, Path::new("selected")).stats, Some((1, 0))); for (index, entry) in catalog.files.iter().enumerate() { match entry.path.to_str().unwrap() { "selected" => assert_eq!( repository.load_file(1, index).unwrap().after, ["still available"] ), "too-many-bytes" => assert!( repository .load_file(1, index) .unwrap_err() .contains("64 MiB per-side comparison file limit") ), "too-many-lines" => assert!( repository .load_file(1, index) .unwrap_err() .contains("4000000-line per-side comparison limit") ), path => panic!("Unexpected file {path}"), } } } #[test] fn large_git_and_jj_comparisons_load_exact_snapshots_and_frozen_working_overlays() { let fixture = Fixture::new(); // Both modified sides exceed 52.3 MB; the Working pair exceeds the old // aggregate 64 MiB budget as well as its old 16 MiB per-file limit. let body_line = "x".repeat(1023); let mut contents = format!( "before\n{}", format!("{body_line}\n").repeat(52_300_000 / 1024 + 1) ); let line_count = contents.lines().count(); for kind in [RepoKind::Git, RepoKind::Jujutsu] { let root = fixture .dir(if kind == RepoKind::Git { "git" } else { "jj" }) .canonicalize() .unwrap(); match kind { RepoKind::Git => init_git(&root), RepoKind::Jujutsu => { init_jj(&root, false); jj( &root, &[ "config", "set", "--repo", "snapshot.max-new-file-size", "100MiB", ], ); } } let set_header = |contents: &mut String, header: &str| { let end = contents.find('\n').unwrap(); contents.replace_range(..end, header); }; set_header(&mut contents, "before"); fs::write(root.join("modified"), &contents).unwrap(); set_header(&mut contents, "removed"); fs::write(root.join("removed"), &contents).unwrap(); let before = match kind { RepoKind::Git => { git(&root, &["add", "."]); git(&root, &["commit", "-m", "large before"]); git(&root, &["rev-parse", "HEAD"]) } RepoKind::Jujutsu => { jj(&root, &["describe", "-m", "large before"]); let id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &id]); id } }; set_header(&mut contents, "after"); fs::write(root.join("modified"), &contents).unwrap(); fs::remove_file(root.join("removed")).unwrap(); set_header(&mut contents, "added"); fs::write(root.join("added"), &contents).unwrap(); let after = match kind { RepoKind::Git => { git(&root, &["add", "."]); git(&root, &["commit", "-m", "large after"]); git(&root, &["rev-parse", "HEAD"]) } RepoKind::Jujutsu => { jj(&root, &["describe", "-m", "large after"]); jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]) } }; let assert_contents = |lines: &[String], header: &str| { assert_eq!(lines.len(), line_count); assert_eq!(lines[0], header); assert!(lines[1..].iter().all(|line| line == &body_line)); }; let cancelled = Arc::new(AtomicBool::new(false)); let mut repository = Repository::open(&root, cancelled.clone()).unwrap(); let catalog = repository .compare( 1, &revision(&before, kind), &revision(&after, kind), ComparisonMode::Snapshots, &[], ) .unwrap(); assert_eq!(catalog.files.len(), 3); assert!(catalog.files.iter().all(|file| !file.loaded && file.before.is_empty() && file.after.is_empty() && file.hunks.is_empty())); for (index, entry) in catalog.files.iter().enumerate() { let file = repository.load_file(1, index).unwrap(); assert!(file.loaded); assert_eq!(file.hunks.len(), 1); let hunk = &file.hunks[0]; let expected = match entry.path.to_str().unwrap() { "added" => { assert_eq!(file.status, FileStatus::Added); assert!(file.before.is_empty()); assert_contents(&file.after, "added"); (0, 0, 0, line_count) } "removed" => { assert_eq!(file.status, FileStatus::Removed); assert_contents(&file.before, "removed"); assert!(file.after.is_empty()); (0, line_count, 0, 0) } "modified" => { assert_eq!(file.status, FileStatus::Modified); assert_contents(&file.before, "before"); assert_contents(&file.after, "after"); (0, 1, 0, 1) } path => panic!("Unexpected file {path}"), }; assert_eq!( ( hunk.before_start, hunk.before_len, hunk.after_start, hunk.after_len ), expected ); assert!(file.before.is_empty() || file.before_eof); assert!(file.after.is_empty() || file.after_eof); } set_header(&mut contents, "overlay"); let buffers = [(root.join("modified"), contents)]; let working = repository .compare( 2, &revision(&after, kind), &working(kind), ComparisonMode::Snapshots, &buffers, ) .unwrap(); assert_eq!(working.files.len(), 1); assert!(!working.files[0].loaded); assert!(working.editable_paths.contains(Path::new("modified"))); fs::write(root.join("modified"), "later disk contents\n").unwrap(); cancelled.store(true, Ordering::Release); assert!( repository .load_file(2, 0) .unwrap_err() .contains("cancelled") ); cancelled.store(false, Ordering::Release); let file = repository.load_file(2, 0).unwrap(); assert_contents(&file.before, "after"); assert_contents(&file.after, "overlay"); assert_eq!(file.hunks.len(), 1); let hunk = &file.hunks[0]; assert_eq!( ( hunk.before_start, hunk.before_len, hunk.after_start, hunk.after_len ), (0, 1, 0, 1) ); contents = buffers.into_iter().next().unwrap().1; } } #[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 git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_text() { let fixture = Fixture::new(); let root = fixture.dir("follow").canonicalize().unwrap(); init_git(&root); fs::write(root.join("file"), "base\n").unwrap(); fs::write(root.join("removed"), "remove me\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "base"]); let base = git(&root, &["rev-parse", "HEAD"]); fs::write(root.join("file"), "original disk\n").unwrap(); let mut repository = open(&root); let original = compare_loaded( &mut repository, &revision(&base, RepoKind::Git), &working(RepoKind::Git), &[], ) .unwrap(); fs::write(root.join("file"), "later disk\n").unwrap(); assert!( repository .refresh_checkout(1, 2, true, &[]) .unwrap() .is_none() ); assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]); fs::remove_file(root.join("removed")).unwrap(); fs::write(root.join("added"), "new file\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "descendant"]); let tip = git(&root, &["rev-parse", "HEAD"]); let state = disk_state(&root); assert!( repository .refresh_checkout(99, 2, true, &[]) .unwrap() .is_none() ); assert!( repository .refresh_checkout(1, 2, false, &[]) .unwrap() .is_none() ); assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]); let mut refreshed = repository .refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(refreshed.before, original.before); assert_eq!(refreshed.after, original.after); assert_eq!(refreshed.blame[0], original.blame[0]); assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip); for (index, file) in refreshed.files.iter_mut().enumerate() { *file = repository.load_file(2, index).unwrap(); } assert_eq!(changed(&refreshed, Path::new("file")).before, ["base"]); assert_eq!(changed(&refreshed, Path::new("file")).after, ["unsaved"]); assert_eq!( changed(&refreshed, Path::new("added")).status, FileStatus::Added ); assert_eq!( changed(&refreshed, Path::new("removed")).status, FileStatus::Removed ); assert!(refreshed.editable_paths.contains(Path::new("file"))); assert!(refreshed.editable_paths.contains(Path::new("added"))); assert!(!refreshed.editable_paths.contains(Path::new("removed"))); assert!(repository.load_file(1, 0).is_err()); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); assert_eq!(disk_state(&root), state); git(&root, &["checkout", "--detach", &base]); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); git(&root, &["checkout", "-b", "other"]); fs::write(root.join("file"), "other branch\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "unrelated to tip"]); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); let index = refreshed .files .iter() .position(|file| file.path == Path::new("file")) .unwrap(); assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]); git(&root, &["checkout", "--detach", &tip]); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); } #[test] fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure() { let fixture = Fixture::new(); let root = fixture.dir("frozen-follow").canonicalize().unwrap(); init_git(&root); fs::write(root.join("file"), "base\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "base"]); let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); fs::write(root.join("file"), "frozen before\n").unwrap(); let cancelled = Arc::new(AtomicBool::new(false)); let mut repository = Repository::open(&root, cancelled.clone()).unwrap(); repository .compare( 10, &working(RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[(root.join("file"), "frozen after\n".into())], ) .unwrap(); fs::write(root.join("file"), "next checkout\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "next"]); cancelled.store(true, Ordering::Release); assert!(repository.refresh_checkout(10, 11, true, &[]).is_err()); cancelled.store(false, Ordering::Release); assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]); fs::File::create(root.join("oversized")) .unwrap() .set_len(64 * 1024 * 1024 + 1) .unwrap(); assert!( repository .refresh_checkout(10, 11, true, &[]) .unwrap_err() .contains("64 MiB per-side comparison file limit") ); let original = repository.load_file(10, 0).unwrap(); assert_eq!(original.before, ["frozen before"]); assert_eq!(original.after, ["frozen after"]); assert!(repository.load_file(11, 0).is_err()); fs::remove_file(root.join("oversized")).unwrap(); let refreshed = repository .refresh_checkout(10, 11, true, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(refreshed.files.len(), 1); let file = repository.load_file(11, 0).unwrap(); assert_eq!(file.before, ["frozen before"]); assert_eq!(file.after, ["next checkout"]); let tip = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); let pinned = repository .compare(12, &base, &tip, ComparisonMode::Snapshots, &[]) .unwrap(); assert!(pinned.editable_paths.contains(Path::new("file"))); fs::write(root.join("file"), "newer checkout\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "newer"]); assert!( repository .refresh_checkout(99, 13, false, &[]) .unwrap() .is_none() ); let readonly = repository .refresh_checkout(12, 13, false, &[(root.join("file"), "dirty live\n".into())]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(readonly.before, pinned.before); assert_eq!(readonly.after, pinned.after); assert!(readonly.editable_paths.is_empty()); assert_eq!(readonly.files.len(), pinned.files.len()); assert_eq!(readonly.files[0].path, pinned.files[0].path); assert!(repository.load_file(12, 0).is_err()); assert_eq!( repository.load_file(13, 0).unwrap().after, ["next checkout"] ); assert!( repository .refresh_checkout(13, 14, false, &[]) .unwrap() .is_none() ); let RevisionTarget::Commit(tip_id) = &tip.target else { unreachable!() }; git(&root, &["checkout", "--detach", tip_id]); let promoted = repository .refresh_checkout(13, 14, false, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(promoted.editable_paths, pinned.editable_paths); fs::write(root.join("file"), "dirty disk\n").unwrap(); assert_eq!( repository.load_file(14, 0).unwrap().after, ["next checkout"] ); cancelled.store(true, Ordering::Release); assert!(repository.refresh_checkout(14, 15, false, &[]).is_err()); cancelled.store(false, Ordering::Release); let recovered = repository .refresh_checkout(14, 15, false, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(recovered.editable_paths, pinned.editable_paths); assert_eq!( repository.load_file(15, 0).unwrap().after, ["next checkout"] ); } #[test] fn refresh_keeps_historical_blob_sources_lazy_even_with_working_files() { let fixture = Fixture::new(); let root = fixture.dir("failed-materialization"); init_git(&root); fs::write(root.join("a-file"), "frozen source\n").unwrap(); fs::write(root.join("z-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "base"]); let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git); fs::remove_file(root.join("a-file")).unwrap(); fs::remove_file(root.join("z-large")).unwrap(); let mut repository = open(&root); repository .compare( 1, &base, &working(RepoKind::Git), ComparisonMode::Snapshots, &[], ) .unwrap(); assert_eq!( repository.load_file(1, 0).unwrap().before, ["frozen source"] ); fs::write(root.join("a-file"), "new checkout\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "descendant"]); let refreshed = repository .refresh_checkout(1, 2, true, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert!(refreshed.files.iter().all(|file| !file.loaded)); let index = refreshed .files .iter() .position(|file| file.path == Path::new("a-file")) .unwrap(); let file = repository.load_file(2, index).unwrap(); assert_eq!(file.before, ["frozen source"]); assert_eq!(file.after, ["new checkout"]); assert_eq!(file.status, FileStatus::Modified); assert!(repository.load_file(1, index).is_err()); } #[test] fn unborn_git_working_after_safely_advances_to_its_first_commit() { let fixture = Fixture::new(); let root = fixture.dir("unborn"); init_git(&root); let mut repository = open(&root); repository .compare( 1, &working(RepoKind::Git), &working(RepoKind::Git), ComparisonMode::Snapshots, &[], ) .unwrap(); assert!( repository .refresh_checkout(1, 2, true, &[]) .unwrap() .is_none() ); fs::write(root.join("first"), "initial\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "initial"]); let comparison = repository .refresh_checkout(1, 2, true, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(comparison.files.len(), 1); assert_eq!(repository.load_file(2, 0).unwrap().after, ["initial"]); } #[test] fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() { for colocate in [false, true] { let fixture = Fixture::new(); let root = fixture.dir("jj-follow").canonicalize().unwrap(); init_jj(&root, colocate); fs::write(root.join("file"), "base\n").unwrap(); fs::write(root.join("removed"), "remove me\n").unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new"]); fs::write(root.join("file"), "original disk\n").unwrap(); let mut repository = open(&root); let initial = compare_loaded( &mut repository, &revision(&base, RepoKind::Jujutsu), &working(RepoKind::Jujutsu), &[], ) .unwrap(); assert_eq!(initial.blame[1].as_ref().unwrap().commit, base); fs::write(root.join("file"), "same-change rewrite\n").unwrap(); jj(&root, &["describe", "-m", "rewritten"]); let state = disk_state(&root); assert!( repository .refresh_checkout(1, 2, true, &[]) .unwrap() .is_none() ); assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]); assert_eq!(disk_state(&root), state); jj(&root, &["new"]); let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]); fs::remove_file(root.join("removed")).unwrap(); fs::write(root.join("added"), "new file\n").unwrap(); let state = disk_state(&root); let mut comparison = repository .refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(comparison.blame[0], initial.blame[0]); assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent); for (index, file) in comparison.files.iter_mut().enumerate() { *file = repository.load_file(2, index).unwrap(); } assert_eq!(changed(&comparison, Path::new("file")).before, ["base"]); assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]); assert_eq!( changed(&comparison, Path::new("added")).status, FileStatus::Added ); assert_eq!( changed(&comparison, Path::new("removed")).status, FileStatus::Removed ); assert!(repository.load_file(1, 0).is_err()); assert_eq!(disk_state(&root), state); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); jj(&root, &["edit", &base]); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); jj(&root, &["new", &base]); assert!( repository .refresh_checkout(2, 3, true, &[]) .unwrap() .is_none() ); let index = comparison .files .iter() .position(|file| file.path == Path::new("file")) .unwrap(); assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]); repository .compare( 10, &working(RepoKind::Jujutsu), &working(RepoKind::Jujutsu), ComparisonMode::Snapshots, &[(root.join("file"), "frozen after\n".into())], ) .unwrap(); fs::write(root.join("file"), "new checkout\n").unwrap(); jj(&root, &["new"]); let refreshed = repository .refresh_checkout(10, 11, true, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(refreshed.files.len(), 1); let file = repository.load_file(11, 0).unwrap(); assert_eq!(file.before, ["base"]); assert_eq!(file.after, ["new checkout"]); let historical = revision(&base, RepoKind::Jujutsu); repository .compare(12, &historical, &historical, ComparisonMode::Snapshots, &[]) .unwrap(); jj(&root, &["new"]); assert!( repository .refresh_checkout(12, 13, true, &[]) .unwrap() .is_none() ); let tip_id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); let pinned_tip = revision(&tip_id, RepoKind::Jujutsu); let pinned = repository .compare(20, &historical, &pinned_tip, ComparisonMode::Snapshots, &[]) .unwrap(); assert!(pinned.editable_paths.contains(Path::new("file"))); let index = pinned .files .iter() .position(|file| file.path == Path::new("file")) .unwrap(); assert_eq!( repository.load_file(20, index).unwrap().after, ["new checkout"] ); fs::write(root.join("file"), "rewritten checkout\n").unwrap(); jj(&root, &["describe", "-m", "same pinned change"]); let rewritten = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); assert_ne!(rewritten, tip_id); assert!( repository .refresh_checkout(20, 21, false, &[]) .unwrap() .is_none() ); assert_eq!( repository.load_file(20, index).unwrap().after, ["new checkout"] ); let same_change = repository .compare(21, &historical, &pinned_tip, ComparisonMode::Snapshots, &[]) .unwrap(); assert_eq!(same_change.editable_paths, pinned.editable_paths); jj(&root, &["new"]); let readonly = repository .refresh_checkout(21, 22, false, &[(root.join("file"), "dirty live\n".into())]) .unwrap() .map(checkout_comparison) .unwrap(); assert!(readonly.editable_paths.is_empty()); assert_eq!(readonly.before, pinned.before); assert_eq!(readonly.after, pinned.after); assert_eq!(readonly.files.len(), pinned.files.len()); assert_eq!( repository.load_file(22, index).unwrap().after, ["new checkout"] ); jj(&root, &["edit", &rewritten]); let promoted = repository .refresh_checkout(22, 23, false, &[]) .unwrap() .map(checkout_comparison) .unwrap(); assert_eq!(promoted.editable_paths, pinned.editable_paths); assert_eq!( repository.load_file(23, index).unwrap().after, ["new checkout"] ); } } #[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!(branch.blame[0].as_ref().unwrap().commit, base); assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active)); 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!(reverse.blame[0].as_ref().unwrap().commit, base); assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label); 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); } #[test] fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() { let fixture = Fixture::new(); let source = fixture.dir("source"); init_git(&source); fs::write(source.join("common"), "base\n").unwrap(); git(&source, &["add", "."]); git(&source, &["commit", "-m", "base"]); let base = git(&source, &["rev-parse", "HEAD"]); fs::write(source.join("target-only"), "not part of the PR\n").unwrap(); git(&source, &["add", "."]); git(&source, &["commit", "-m", "target advanced"]); let target = git(&source, &["rev-parse", "HEAD"]); git(&source, &["checkout", "--detach", &base]); fs::write(source.join("common"), "fork change\n").unwrap(); git(&source, &["add", "."]); git(&source, &["commit", "-m", "fork tip"]); let head = git(&source, &["rev-parse", "HEAD"]); git(&source, &["update-ref", "refs/pull/7/head", &head]); for (index, kind) in [RepoKind::Git, RepoKind::Jujutsu, RepoKind::Jujutsu] .into_iter() .enumerate() { let root = fixture.dir(&format!("active-{index}")); if kind == RepoKind::Git { init_git(&root); fs::write(root.join("unrelated"), "local\n").unwrap(); git(&root, &["add", "."]); git(&root, &["commit", "-m", "unrelated local"]); git( &root, &["remote", "add", "origin", source.to_str().unwrap()], ); } else { init_jj(&root, index == 2); jj( &root, &["git", "remote", "add", "origin", source.to_str().unwrap()], ); } fs::write(root.join("unsaved"), "keep\n").unwrap(); let state = disk_state(&root); let pinned = |reference: &str, id: &str| Revision { target: RevisionTarget::RemoteCommit { remote: "origin".into(), reference: reference.into(), id: id.into(), }, ..revision(id, kind) }; let before = pinned("refs/heads/local", &target); let after = pinned("refs/pull/7/head", &head); let mut repository = open(&root); let comparison = repository .compare(70, &before, &after, ComparisonMode::BranchChanges, &[]) .unwrap(); assert!(comparison.before.ends_with(&base)); assert_eq!(comparison.files.len(), 1); assert_eq!(comparison.files[0].path, Path::new("common")); assert!(comparison.editable_paths.is_empty()); let file = repository.load_file(70, 0).unwrap(); assert_eq!(file.before, ["base"]); assert_eq!(file.after, ["fork change"]); assert_eq!(disk_state(&root), state); // A force-push cannot silently replace the immutable tip selected in the picker. let absent = pinned("refs/pull/7/head", &"f".repeat(40)); assert!( repository .compare(71, &before, &absent, ComparisonMode::BranchChanges, &[]) .unwrap_err() .contains("reopen :pull_requests") ); assert_eq!(disk_state(&root), state); } } #[test] fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() { let fixture = Fixture::new(); let root = fixture.dir("blame-parents"); init_jj(&root, false); fs::write(root.join("file"), "base\n").unwrap(); jj(&root, &["describe", "-m", "base"]); let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new"]); let state = disk_state(&root); let mut repository = open(&root); let one_parent = repository .compare( 1, &working(RepoKind::Jujutsu), &working(RepoKind::Jujutsu), ComparisonMode::Snapshots, &[], ) .unwrap(); assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base); assert_eq!(one_parent.blame[0], one_parent.blame[1]); assert_eq!(disk_state(&root), state); jj(&root, &["new", "root()"]); fs::write(root.join("other"), "sibling\n").unwrap(); jj(&root, &["describe", "-m", "sibling"]); let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&root, &["new", &base, &sibling]); let state = disk_state(&root); let merged = repository .compare( 2, &working(RepoKind::Jujutsu), &working(RepoKind::Jujutsu), ComparisonMode::Snapshots, &[], ) .unwrap(); assert_eq!(merged.blame, [None, None]); assert_eq!(disk_state(&root), state); }