#![cfg(unix)] use std::{ fs, path::PathBuf, process::Command, sync::atomic::{AtomicU64, Ordering}, thread, time::{Duration, Instant}, }; use vamp::{ editor::{Input, ResolverStatus}, session::EditorSession, }; static NEXT: AtomicU64 = AtomicU64::new(0); struct Fixture { root: PathBuf, base: String, } impl Fixture { fn jj(&self, args: &[&str]) -> String { let result = Command::new("jj") .args(["--no-pager", "--color=never"]) .args(args) .current_dir(&self.root) .env("JJ_CONFIG", "/dev/null") .env("JJ_USER", "Resolver Test") .env("JJ_EMAIL", "resolver@example.invalid") .env("JJ_TIMESTAMP", "2000-01-01T00:00:00Z") .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .output() .unwrap(); assert!( result.status.success(), "{}", String::from_utf8_lossy(&result.stderr) ); String::from_utf8(result.stdout).unwrap().trim().into() } fn new() -> Self { let root = std::env::temp_dir().join(format!( "vamp-resolve-session-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&root).unwrap(); let mut fixture = Self { root, base: String::new(), }; fixture.jj(&["git", "init", "--no-colocate", "."]); for name in ["first.txt", "second.txt"] { fs::write(fixture.root.join(name), Self::text("base")).unwrap(); } fixture.jj(&["describe", "-m", "base"]); fixture.base = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); fixture.jj(&["new", &fixture.base]); for name in ["first.txt", "second.txt"] { fs::write(fixture.root.join(name), Self::text("left")).unwrap(); } fixture.jj(&["describe", "-m", "left"]); let left = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); fixture.jj(&["new", &fixture.base]); for name in ["first.txt", "second.txt"] { fs::write(fixture.root.join(name), Self::text("right")).unwrap(); } fixture.jj(&["describe", "-m", "right"]); let right = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); fixture.jj(&["new", &left, &right, "-m", "conflicted result"]); fixture } fn text(side: &str) -> String { format!( "{side} first\n{}{side} last\n", (0..30) .map(|n| format!("context_{n}\n")) .collect::() ) } fn open(&self) -> EditorSession { let config = self.root.join("test-config.rb"); fs::write( &config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n", ) .unwrap(); EditorSession::open( Some(self.root.join("first.txt")), vec!["text".into()], Some(&config), ) .unwrap() } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } fn ready(session: &mut EditorSession) { let deadline = Instant::now() + Duration::from_secs(15); loop { session.poll(); if session.editor().resolver().is_some_and(|resolver| { !resolver.loading && resolver .files .iter() .all(|file| file.status != ResolverStatus::Checking) }) { return; } assert!( Instant::now() < deadline, "resolver did not settle: {}", session.editor().message() ); thread::sleep(Duration::from_millis(1)); } } #[test] fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() { let fixture = Fixture::new(); let original_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap(); let mut session = fixture.open(); for input in [ Input::Char('i'), Input::Paste("draft\n".into()), Input::Escape, ] { session.handle(input); } let draft = session.editor().context().text; let operation = fixture.jj(&[ "--ignore-working-copy", "op", "log", "--no-graph", "--limit", "1", "-T", "id", ]); session.execute("resolve"); ready(&mut session); let seeded = format!("draft\n{}", Fixture::text("left")); assert_eq!(session.editor().context().text, seeded); assert_ne!(seeded, draft); assert!(session.editor().dirty()); assert_eq!( fs::read_to_string(fixture.root.join("first.txt")).unwrap(), original_disk ); assert_eq!( fixture.jj(&[ "--ignore-working-copy", "op", "log", "--no-graph", "--limit", "1", "-T", "id" ]), operation ); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, draft); session.handle(Input::Char('U')); assert_eq!(session.editor().context().text, seeded); let source = session.editor().resolver().unwrap().files[0] .sources .iter() .enumerate() .filter(|(_, source)| !source.base) .nth(1) .unwrap() .0 + 1; session.execute(&format!("resolve.use_source {source}")); let chosen = session.editor().context().text; assert_ne!(chosen, seeded); assert!(chosen.starts_with("draft\n")); assert!(chosen.contains("context_15\n")); session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, seeded); session.execute(&format!("resolve.use_source {source}")); let cursor = session.editor().cursor(); let resolved_text = session.editor().context().text; session.execute("resolve.view.toggle"); session.execute("resolve.view.toggle"); assert_eq!(session.editor().context().text, resolved_text); assert_eq!(session.editor().cursor(), cursor); session.execute("w"); ready(&mut session); let resolver = session.editor().resolver().unwrap(); assert_eq!(resolver.files.len(), 2); assert_eq!( resolver.files[resolver.current_file].status, ResolverStatus::Resolved ); assert_eq!( fs::read_to_string(fixture.root.join("first.txt")).unwrap(), resolved_text ); assert!( !fixture .jj(&["--ignore-working-copy", "resolve", "--list"]) .contains("first.txt") ); assert!( fixture .jj(&["--ignore-working-copy", "resolve", "--list"]) .contains("second.txt") ); } #[test] fn resolver_refuses_save_after_checkout_change_even_when_forced_and_preserves_draft() { let fixture = Fixture::new(); let mut session = fixture.open(); session.execute("resolve"); ready(&mut session); for input in [ Input::Char('i'), Input::Paste("keep my draft\n".into()), Input::Escape, ] { session.handle(input); } let draft = session.editor().context().text; fixture.jj(&["new", &fixture.base]); let checkout_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap(); for save in ["w", "w!"] { session.execute(save); ready(&mut session); assert_eq!( fs::read_to_string(fixture.root.join("first.txt")).unwrap(), checkout_disk ); assert_eq!(session.editor().context().text, draft); assert!(session.editor().dirty()); } session.execute("resolve.close"); assert_eq!(session.editor().context().text, draft); assert!(session.editor().dirty()); } #[test] fn resolver_save_preserves_external_writes_and_keeps_the_unsaved_result() { let fixture = Fixture::new(); let mut session = fixture.open(); session.execute("resolve"); ready(&mut session); for input in [ Input::Char('i'), Input::Paste("local draft\n".into()), Input::Escape, ] { session.handle(input); } let draft = session.editor().context().text; fs::write(fixture.root.join("first.txt"), "external edit\n").unwrap(); session.execute("w"); ready(&mut session); assert_eq!( fs::read_to_string(fixture.root.join("first.txt")).unwrap(), "external edit\n" ); assert_eq!(session.editor().context().text, draft); assert!(session.editor().dirty()); }