use super::*; use std::ffi::OsString; static NEXT_TEST: AtomicU64 = AtomicU64::new(0); struct Temporary(PathBuf); impl Temporary { fn new() -> Self { let path = std::env::temp_dir().join(format!( "vamp-gutter-test-{}-{}", std::process::id(), NEXT_TEST.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&path).unwrap(); Self(path) } } impl Drop for Temporary { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn command(root: &Path, program: &str, args: &[&str]) -> String { let mut command = Command::new(program); if program == "jj" { command.args([ "--config=user.name=\"Gutter Author\"", "--config=user.email=\"gutter@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 git_fixture() -> (Temporary, PathBuf) { let temp = Temporary::new(); command(&temp.0, "git", &["init", "--quiet", "--template="]); command(&temp.0, "git", &["config", "user.name", "Gutter Author"]); command( &temp.0, "git", &["config", "user.email", "gutter@example.test"], ); let path = temp.0.join("-literal [file]\n\t\".txt"); fs::write(&path, "first\nsecond\nthird\n").unwrap(); command( &temp.0, "git", &["add", "--", path.file_name().unwrap().to_str().unwrap()], ); command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]); (temp, path) } #[test] fn maps_replacements_surplus_deletions_empty_and_eof_rows() { use Change::*; for (before, after, expected) in [ ("first\nsecond\n", "first\nsecond\n", vec![None, None, None]), ( "first\r\nsecond\r\n", "first\nsecond\n", vec![None, None, None], ), ("first\rsecond\r", "first\nsecond\n", vec![None, None, None]), ( "first\r\nsecond\r\nthird\r\n", "first\nchanged\nthird\n", vec![None, Some(Modified), None, None], ), ( "first\nsecond\nthird\n", "first\rchanged\rthird\r", vec![None, Some(Modified), None, None], ), ("first\n", "first\nnew\n", vec![None, Some(Added), None]), ( "old\n", "changed\nsurplus\n", vec![Some(Modified), Some(Added), None], ), ( "old\nextra\nlast\n", "changed\nlast\n", vec![Some(Modified), None, None], ), ( "first\nremoved\nlast\n", "first\nlast\n", vec![None, Some(Removed), None], ), ("first\nremoved\n", "first\n", vec![None, Some(Removed)]), ("first\nremoved", "first", vec![Some(Modified)]), ("first\nremoved", "first\n", vec![None, Some(Removed)]), ("removed\n", "", vec![Some(Removed)]), ("", "new\n", vec![Some(Added), None]), ("", "", vec![None]), ("same", "same\n", vec![Some(Modified), None]), ] { assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}"); } } #[test] fn git_crlf_baseline_matches_normalized_editor_rows() { let (temp, _) = git_fixture(); let path = temp.0.join("crlf"); fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap(); command(&temp.0, "git", &["add", "--", "crlf"]); command( &temp.0, "git", &["commit", "--quiet", "-m", "CRLF baseline"], ); assert_eq!( read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(), vec![None, None, None, None] ); assert_eq!( read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(), vec![None, Some(Change::Modified), None, None] ); } #[test] fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() { let (temp, path) = git_fixture(); fs::write(&path, "staged\nsecond\nthird\n").unwrap(); command( &temp.0, "git", &["add", "--", path.file_name().unwrap().to_str().unwrap()], ); fs::write(&path, "staged\ndisk\nthird\n").unwrap(); let index = fs::read(temp.0.join(".git/index")).unwrap(); let head = command(&temp.0, "git", &["rev-parse", "HEAD"]); assert_eq!( read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(), vec![ Some(Change::Modified), Some(Change::Modified), Some(Change::Modified), None ] ); assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index); assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head); assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n"); fs::remove_file(&path).unwrap(); assert_eq!( read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(), vec![None, None, None, None] ); assert_eq!( read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(), vec![Some(Change::Added), None] ); let untracked = temp.0.join("untracked"); fs::write(&untracked, "unsaved\n").unwrap(); assert_eq!( read_file(&untracked, "unsaved\n", &|| false).unwrap(), vec![Some(Change::Added), None] ); } #[test] fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() { let temp = Temporary::new(); let path = temp.0.join("new/named"); assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty()); command(&temp.0, "git", &["init", "--quiet", "--template="]); assert_eq!( read_file(&path, "unsaved\n", &|| false).unwrap(), vec![Some(Change::Added), None] ); let disk = temp.0.join("untracked"); fs::write(&disk, "disk\n").unwrap(); assert_eq!( read_file(&disk, "unsaved\n", &|| false).unwrap(), vec![Some(Change::Added), None] ); fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap(); assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty()); assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty()); assert!( read_file(&temp.0.join(".git/config"), "text\n", &|| false) .unwrap() .is_empty() ); assert!( read_file(&disk, "binary\0text", &|| false) .unwrap() .is_empty() ); std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap(); assert!( read_file(&temp.0.join("link"), "text\n", &|| false) .unwrap() .is_empty() ); std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap(); assert!( read_file(&temp.0.join("linked/untracked"), "text\n", &|| false) .unwrap() .is_empty() ); } #[test] fn git_non_utf8_filename_is_literal() { use std::os::unix::ffi::OsStringExt; let (temp, _) = git_fixture(); let name = OsString::from_vec(b"non-utf8-\xff".to_vec()); let path = temp.0.join(&name); match fs::write(&path, "old\n") { Ok(()) => {} // 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) => return, Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"), } let mut add = git(&temp.0); add.args(["add", "--"]).arg(&name); checked(add, &|| false).unwrap(); command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]); assert_eq!( read_file(&path, "changed\n", &|| false).unwrap(), vec![Some(Change::Modified), None] ); } #[test] fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() { for colocate in ["--colocate", "--no-colocate"] { let temp = Temporary::new(); command(&temp.0, "jj", &["git", "init", colocate, "."]); let name = "-literal [file]\n\t\".txt"; let path = temp.0.join(name); fs::write(&path, "original\n").unwrap(); assert_eq!( read_file(&path, "unsaved\n", &|| false).unwrap(), vec![Some(Change::Added), None] ); fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap(); assert!( read_file(&temp.0.join("ignored"), "text\n", &|| false) .unwrap() .is_empty() ); command(&temp.0, "jj", &["describe", "-m", "Baseline"]); command(&temp.0, "jj", &["new"]); let operation = command( &temp.0, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ], ); let store = store_for_jj(&temp.0).unwrap(); let refs = fs::read(store.join("HEAD")).unwrap(); fs::write(&path, "disk edit\n").unwrap(); assert_eq!( read_file(&path, "inserted\noriginal\n", &|| false).unwrap(), vec![Some(Change::Added), None, None] ); assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs); assert_eq!( command( &temp.0, "jj", &[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ] ), operation ); let parent = command( &temp.0, "jj", &[ "--ignore-working-copy", "log", "--no-graph", "-r", "@-", "-T", "commit_id", ], ); command(&temp.0, "jj", &["new", "root()"]); fs::write(temp.0.join("sibling"), "sibling\n").unwrap(); command(&temp.0, "jj", &["describe", "-m", "Sibling"]); command(&temp.0, "jj", &["new", &parent, "@"]); assert!( read_file(&path, "original\n", &|| false) .unwrap() .is_empty() ); } } #[test] fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() { let (_temp, path) = git_fixture(); let mut worker = Worker::start().unwrap(); let oversized: Arc = "x".repeat(FILE_LIMIT + 1).into(); for id in 0..20 { worker .request( id, vec![ (5, path.clone(), "first\nchanged\nthird\n".into()), (8, path.clone(), Arc::clone(&oversized)), ], None, ) .unwrap(); } loop { if let Some((id, result, picker)) = worker.poll() { assert!(picker.is_none()); assert_eq!(id, 19); assert_eq!(result[0].0, 5); assert_eq!( result[0].1.as_ref().unwrap(), &vec![None, Some(Change::Modified), None, None] ); assert_eq!(result[1].0, 8); assert_eq!( result[1].1.as_ref().unwrap_err(), "Gutter file exceeds 16 MiB" ); break; } thread::yield_now(); } assert_eq!( worker .request( 20, vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())], None ) .unwrap_err() .kind(), io::ErrorKind::InvalidInput ); } #[test] fn picker_status_is_optional_and_errors_do_not_discard_line_results() { let (temp, path) = git_fixture(); let mut worker = Worker::start().unwrap(); worker .request( 1, vec![], Some(( temp.0.clone(), vec![(path.clone(), "dirty buffer\n".into())], )), ) .unwrap(); loop { if let Some((id, lines, picker)) = worker.poll() { assert_eq!(id, 1); assert!(lines.is_empty()); let picker = picker.unwrap(); assert_eq!(picker.root, temp.0); let changes = picker.result.unwrap(); assert_eq!(changes.len(), 1); assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap()); assert_eq!(changes[0].status, crate::diff::FileStatus::Modified); break; } thread::yield_now(); } worker .request( 2, vec![(4, path.clone(), "first\nchanged\nthird\n".into())], Some(( temp.0.clone(), vec![(path, "x".repeat(FILE_LIMIT + 1).into())], )), ) .unwrap(); loop { if let Some((id, lines, picker)) = worker.poll() { assert_eq!(id, 2); assert_eq!(lines[0].0, 4); assert!(lines[0].1.is_ok()); assert!(picker.unwrap().result.unwrap_err().contains("16 MiB")); break; } thread::yield_now(); } }