Files
vamp/tests/resolve.rs
T
Valdemar Lorenzen ef4c38d8a7 feat: resolve conflicts through focused two-way drafts
Replace the marker-filled three-pane resolver with a marker-free native
Draft versus frozen Alternative projected from current working text.
Preserve preexisting outside edits, native undo, dirty-save guards, all
sides/bases, and draft caret/viewports across separate History views.
Reuse DiffState alignment/highlighting/excerpts for Resolution and merge
its base-aligned rows for readonly three-way History. Hide explorer and
source-selector chrome by default.

Verified 926 tests plus actual terminal projection, History highlighting,
hunk choice, optional explorer, and guarded jj-confirmed save workflows.
Clippy completes with pre-existing warnings only. Smoke fixtures stay
unresolved in this ancestry; remove that ancestry before publication.

Size exception: approximately 1650 implementation added/deleted lines,
excluding tests/docs, kept coherent because native draft lifecycle and
aligned rendering must change together without parallel legacy modes.
2026-10-14 17:01:45 +02:00

276 lines
8.5 KiB
Rust
Executable File

#![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::<String>()
)
}
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.selected_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());
}