fn working_file_changes( root: &Path, buffers: &[(PathBuf, Arc)], cancelled: &dyn Fn() -> bool, ) -> Result, String> { WorkingFileReader::default().read(root, buffers, cancelled) } use super::*; fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String { let mut command = Command::new(program); if program == "jj" { command.args([ "--config=user.name=\"Picker Author\"", "--config=user.email=\"picker@example.test\"", "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", ]); } let output = command .args(args) .current_dir(root) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") .env("JJ_CONFIG", "/dev/null") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().into() } fn picker_git_fixture() -> Temporary { let temp = Temporary::new().unwrap(); fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]); fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]); fixture_command( &temp.0, "git", &["config", "user.email", "picker@example.test"], ); temp } fn changes_by_path( root: &Path, buffers: &[(PathBuf, Arc)], ) -> BTreeMap { working_file_changes(root, buffers, &|| false) .unwrap() .into_iter() .map(|change| (change.path.clone(), change)) .collect() } #[test] fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() { use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt}; let temp = picker_git_fixture(); let root = &temp.0; for (name, contents) in [ (".gitignore", "ignored*\n"), ("modified", "original\r\n"), ("unchanged", "same\n"), ("removed", "deleted\n"), ("old", "rename content\n"), ("duplicate-one", "duplicate\n"), ("duplicate-two", "duplicate\n"), ("mode", "mode\n"), ("binary", "before\0binary"), ] { fs::write(root.join(name), contents).unwrap(); } let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") { Ok(()) => Some(non_utf8), Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None, Err(error) => panic!("cannot create byte filename: {error}"), }; fixture_command(root, "git", &["add", "--all"]); fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); fs::write(root.join("modified"), "staged\n").unwrap(); fixture_command(root, "git", &["add", "--", "modified"]); fs::write(root.join("modified"), "unstaged\n").unwrap(); fs::write(root.join("binary"), "after\0binary").unwrap(); if let Some(name) = &non_utf8 { fs::write(root.join(name), "after\n").unwrap(); } fs::remove_file(root.join("removed")).unwrap(); let renamed = "-new [file]\n\t\".txt"; fs::rename(root.join("old"), root.join(renamed)).unwrap(); fs::remove_file(root.join("duplicate-one")).unwrap(); fs::remove_file(root.join("duplicate-two")).unwrap(); fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap(); fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap(); fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); fs::write(root.join("added"), "added\n").unwrap(); fs::write(root.join("ignored"), "ignored\n").unwrap(); fs::write(root.join("ignored-tracked"), "forced\n").unwrap(); fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]); std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap(); let index = fs::read(root.join(".git/index")).unwrap(); let head = fs::read(root.join(".git/HEAD")).unwrap(); let disk = changes_by_path(root, &[]); for name in ["modified", "binary", "mode"] { assert_eq!(disk[Path::new(name)].status, FileStatus::Modified); } if let Some(name) = &non_utf8 { assert_eq!(disk[name].status, FileStatus::Modified); } assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed); assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed); assert_eq!( disk[Path::new(renamed)].old_path.as_deref(), Some(Path::new("old")) ); for name in [ "added", "ignored-tracked", "new-duplicate-one", "new-duplicate-two", ] { assert_eq!(disk[Path::new(name)].status, FileStatus::Added); } for name in ["duplicate-one", "duplicate-two"] { assert_eq!(disk[Path::new(name)].status, FileStatus::Removed); } for name in ["unchanged", "old", "ignored", "link"] { assert!(!disk.contains_key(Path::new(name)), "{name}"); } let overlays = changes_by_path( root, &[ (root.join("modified"), "original\n".into()), (root.join("unchanged"), "dirty\n".into()), (root.join("removed"), "deleted\n".into()), (root.join(renamed), "dirty renamed\n".into()), (root.join("mode"), "mode\n".into()), (root.join("ignored-buffer"), "ignored\n".into()), ], ); assert!(!overlays.contains_key(Path::new("modified"))); assert!(!overlays.contains_key(Path::new("ignored-buffer"))); assert_eq!( overlays[Path::new("unchanged")].status, FileStatus::Modified ); assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed); assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed); assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified); let aliases = Temporary::new().unwrap(); let alias = aliases.0.join("checkout"); std::os::unix::fs::symlink(root, &alias).unwrap(); let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]); assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified); fixture_command(root, "git", &["config", "core.filemode", "0"]); assert!( !changes_by_path(root, &[(root.join("mode"), "mode\n".into())]) .contains_key(Path::new("mode")) ); fixture_command(root, "git", &["config", "core.autocrlf", "1"]); fs::write(root.join("unchanged"), "same\r\n").unwrap(); assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged"))); fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]); fixture_command(root, "git", &["config", "core.filemode", "true"]); fs::write(root.join("unchanged"), "same\n").unwrap(); assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head); assert_eq!( fs::read_to_string(root.join("modified")).unwrap(), "unstaged\n" ); assert!(!root.join("removed").exists()); } #[test] fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() { let temp = picker_git_fixture(); let root = &temp.0; fs::create_dir(root.join("sub")).unwrap(); fs::write(root.join("sub/outgoing"), "out\n").unwrap(); fs::write(root.join("incoming"), "in\n").unwrap(); fixture_command(root, "git", &["add", "--all"]); fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap(); fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap(); let changes = changes_by_path(&root.join("sub"), &[]); assert_eq!(changes.len(), 2); assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed); assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added); assert_eq!(changes[Path::new("incoming")].old_path, None); std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap(); assert!(changes_by_path(&root.join("linked"), &[]).is_empty()); assert!(changes_by_path(&root.join(".git"), &[]).is_empty()); let unrelated = Temporary::new().unwrap(); assert!(changes_by_path(&unrelated.0, &[]).is_empty()); let worktree = temp.0.join("worktree"); fixture_command( root, "git", &["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"], ); fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap(); let changes = changes_by_path(&worktree.join("sub"), &[]); assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified); } #[test] fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() { let temp = picker_git_fixture(); let root = &temp.0; fs::write(root.join(".gitignore"), "ignored\n").unwrap(); fs::write(root.join("tracked"), "tracked\n").unwrap(); fs::write(root.join("untracked"), "untracked\n").unwrap(); fs::write(root.join("ignored"), "ignored\n").unwrap(); fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]); let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]); for name in [".gitignore", "tracked", "untracked", "named"] { assert_eq!(changes[Path::new(name)].status, FileStatus::Added); } assert!(!changes.contains_key(Path::new("ignored"))); assert!(!root.join("named").exists()); fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); fs::write( root.join(".gitattributes"), "tracked filter=evil diff=evil\n", ) .unwrap(); let evil = "sh -c 'touch executed; cat'"; for key in [ "filter.evil.clean", "filter.evil.process", "diff.evil.command", "diff.evil.textconv", ] { fixture_command(root, "git", &["config", key, evil]); } fixture_command(root, "git", &["config", "filter.evil.required", "true"]); fs::write(root.join("tracked"), "changed\n").unwrap(); assert_eq!( changes_by_path(root, &[])[Path::new("tracked")].status, FileStatus::Modified ); assert_eq!( changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())]) .get(Path::new("tracked")), None ); assert!(!root.join("executed").exists()); assert!( working_file_changes(root, &[], &|| true) .unwrap_err() .contains("cancelled") ); assert!( working_file_changes( root, &[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())], &|| false ) .unwrap_err() .contains("16 MiB") ); } #[test] fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() { for colocate in ["--colocate", "--no-colocate"] { let temp = Temporary::new().unwrap(); let root = &temp.0; fixture_command(root, "jj", &["git", "init", colocate, "."]); fs::write(root.join("file"), "baseline\n").unwrap(); assert_eq!( changes_by_path(root, &[])[Path::new("file")].status, FileStatus::Added ); fs::write(root.join(".gitignore"), "ignored\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Baseline"]); fixture_command(root, "jj", &["new"]); let operation = fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ], ); let parent = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@-", "-T", "commit_id", ], ); let store = store_for_jj(root).unwrap(); let head = fs::read(store.join("HEAD")).unwrap(); let index = fs::read(store.join("index")).ok(); fs::write(root.join("file"), "disk\n").unwrap(); fs::write(root.join("new"), "new\n").unwrap(); fs::write(root.join("ignored"), "ignored\n").unwrap(); let changes = changes_by_path(root, &[]); assert_eq!(changes[Path::new("file")].status, FileStatus::Modified); assert_eq!(changes[Path::new("new")].status, FileStatus::Added); assert!(!changes.contains_key(Path::new("ignored"))); assert!( !changes_by_path(root, &[(root.join("file"), "baseline\n".into())]) .contains_key(Path::new("file")) ); assert_eq!(fs::read(store.join("HEAD")).unwrap(), head); assert_eq!(fs::read(store.join("index")).ok(), index); assert_eq!( fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id" ] ), operation ); fixture_command(root, "jj", &["new", "root()"]); fs::write(root.join("sibling"), "sibling\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Sibling"]); fixture_command(root, "jj", &["new", &parent, "@"]); assert!(changes_by_path(root, &[]).is_empty()); } } #[test] fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() { for colocate in ["--colocate", "--no-colocate"] { let temp = Temporary::new().unwrap(); let root = &temp.0; fixture_command(root, "jj", &["git", "init", colocate, "."]); fs::write(root.join("conflicted"), "base\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Base"]); let base = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); fixture_command(root, "jj", &["new"]); fs::write(root.join("conflicted"), "left\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Left"]); let left = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); fixture_command(root, "jj", &["new", &base]); fs::write(root.join("conflicted"), "right\n").unwrap(); fixture_command(root, "jj", &["describe", "-m", "Right"]); let right = fixture_command( root, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id", ], ); fixture_command(root, "jj", &["new", &left, &right]); fixture_command(root, "jj", &["new"]); let projected = fs::read_to_string(root.join("conflicted")).unwrap(); assert!(projected.contains("<<<<<<<")); let operation = fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ], ); let mut reader = WorkingFileReader::default(); assert!(reader.read(root, &[], &|| false).unwrap().is_empty()); fs::write(root.join("conflicted"), "resolved in working file\n").unwrap(); let changes = reader.read(root, &[], &|| false).unwrap(); assert_eq!(changes.len(), 1); assert_eq!(changes[0].path, Path::new("conflicted")); assert_eq!(changes[0].status, FileStatus::Modified); assert!( reader .read( root, &[(root.join("conflicted"), projected.into())], &|| false ) .unwrap() .is_empty() ); assert_eq!( fs::read_to_string(root.join("conflicted")).unwrap(), "resolved in working file\n" ); assert_eq!( fixture_command( root, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id" ] ), operation ); } } #[test] fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { let temp = Temporary::new().unwrap(); let ready = temp.0.join("ready"); let cancelled = Arc::new(AtomicBool::new(false)); let worker_cancelled = Arc::clone(&cancelled); let gate = ready.clone(); let (sent, received) = mpsc::channel(); let worker = thread::spawn(move || { let mut command = Command::new("sh"); command .args([ "-c", "sleep 600 & printf ready > \"$1\"; wait", "vamp-diff-test", ]) .arg(gate); let result = run(command, vec![], &worker_cancelled).map(|_| ()); sent.send(result).unwrap(); }); // File handshake, not a wall-clock scheduling assumption. while !ready.exists() { if let Ok(result) = received.try_recv() { panic!("Process exited before the child was ready: {result:?}"); } thread::yield_now(); } cancelled.store(true, Ordering::Release); assert!(received.recv().unwrap().unwrap_err().contains("cancelled")); worker.join().unwrap(); let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]); let result = run(command, vec![], &AtomicBool::new(false)).unwrap(); assert_eq!(result.code, Some(0)); assert_eq!(result.stdout.len(), 50_000); assert_eq!(result.stderr.len(), 60_000); }