feat: resolve current jj working-copy conflicts

Add a dedicated native resolver workspace with all side/base inputs,
shared editable Results, region choices, retained per-file status,
responsive rendering, and explicit nontext file choices.

Guard writes with jj working-copy locking, frozen checkout identity,
exact disk baselines, native dirty-buffer protection, and asynchronous
jj snapshot/status confirmation. Historical comparisons remain readonly.

Verified all 916 tests and live isolated terminal workflows covering text
regions, narrow layouts, binary bytes and symlink targets/types.
Clippy completes with pre-existing warnings only.

Size exception: 2614 implementation additions/deletions excluding tests
and docs. Native workspace integration, explicit file choices and the
safe save/check operation are retained as one complete behavioral slice.
This commit is contained in:
Valdemar Lorenzen
2026-10-14 15:49:26 +02:00
parent dcfa50319c
commit a3aef713e2
13 changed files with 3591 additions and 20 deletions
+262
View File
@@ -0,0 +1,262 @@
#![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);
assert_eq!(session.editor().context().text, draft);
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
);
let source = session.editor().resolver().unwrap().files[0]
.sources
.iter()
.position(|source| !source.base)
.unwrap()
+ 1;
session.execute(&format!("resolve.use_source {source}"));
assert_ne!(session.editor().context().text, draft);
assert!(session.editor().context().text.starts_with("draft\n"));
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, draft);
session.execute(&format!("resolve.use_source {source}"));
session.execute("resolve.next");
session.execute(&format!("resolve.use_source {source}"));
let resolved_text = session.editor().context().text;
assert!(resolved_text.starts_with("draft\n"));
assert!(resolved_text.contains("context_15\n"));
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());
}