b9f29d258c
Replace the 16 MiB comparison rejection with independent bounded per-side loading and frozen Working blobs. Bound diff computation, defer large inline highlighting, index source-row navigation, and expose actual load failures. Verified the real 52.3 MB parser through repository/session APIs and terminal rendering with EOF/start navigation.
2869 lines
98 KiB
Rust
2869 lines
98 KiB
Rust
#![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<Comparison, String> {
|
|
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<PathBuf, (u32, Vec<u8>)> {
|
|
fn visit(root: &Path, path: &Path, result: &mut BTreeMap<PathBuf, (u32, Vec<u8>)>) {
|
|
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:"));
|
|
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!(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 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_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()]);
|
|
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!(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!(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::<Vec<_>>(),
|
|
[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();
|
|
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::<Vec<_>>(),
|
|
[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::<Vec<_>>());
|
|
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::<Vec<_>>(),
|
|
[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));
|
|
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);
|
|
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);
|
|
compare_loaded(
|
|
&mut repository,
|
|
&revision(&base, RepoKind::Jujutsu),
|
|
&working(RepoKind::Jujutsu),
|
|
&[],
|
|
)
|
|
.unwrap();
|
|
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"]);
|
|
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();
|
|
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!(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);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|