//! Current-working-copy conflict inputs and guarded saves. Browsing never snapshots jj. use crate::repository::{jj_command, safe_path, store_for_jj}; use std::{ collections::BTreeMap, ffi::OsString, fs::{self, File, OpenOptions}, io::Write, ops::Range, path::{Path, PathBuf}, process::Command, sync::atomic::{AtomicU64, Ordering}, }; const LIMIT: usize = 16 * 1024 * 1024; static NEXT: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Eq, PartialEq)] pub struct Checkout { commit: String, change: String, operation: String, state: Vec, } #[derive(Clone, Debug)] pub struct Workspace { pub root: PathBuf, pub checkout: Checkout, pub files: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FileKind { Text, Binary, Symlink, Deleted, Submodule, Directory, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Status { Unresolved, Resolved, Unconfirmed, } #[derive(Clone, Debug)] pub struct Source { pub label: String, pub base: bool, pub kind: FileKind, pub bytes: Vec, pub tree_id: String, term: usize, sides: usize, bases: usize, node: Node, } impl Source { pub fn text(&self) -> Option<&str> { (self.kind == FileKind::Text) .then(|| std::str::from_utf8(&self.bytes).ok()) .flatten() } } #[derive(Clone, Debug)] pub struct ConflictFile { pub path: PathBuf, pub sources: Vec, pub result: Option, pub notice: Option, pub status: Status, baseline: Node, } #[derive(Clone, Debug)] pub enum Resolution { Text(String), Source(usize), } #[derive(Clone, Debug)] pub struct SaveOutcome { pub checkout: Checkout, pub file: ConflictFile, /// A write succeeded but jj confirmation failed. Never interpret this as resolved. pub confirmation: Option, } #[derive(Clone, Debug, Eq, PartialEq)] enum Node { Absent, File(Vec, u32), Symlink(Vec), Directory(Vec<(OsString, Node)>), Submodule(String), } impl Node { fn kind(&self) -> FileKind { match self { Self::Absent => FileKind::Deleted, Self::File(bytes, _) if std::str::from_utf8(bytes).is_ok_and(|s| { s.chars() .all(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t')) }) => { FileKind::Text } Self::File(..) => FileKind::Binary, Self::Symlink(_) => FileKind::Symlink, Self::Directory(_) => FileKind::Directory, Self::Submodule(_) => FileKind::Submodule, } } fn bytes(&self) -> &[u8] { match self { Self::File(bytes, _) | Self::Symlink(bytes) => bytes, _ => &[], } } } fn check(cancelled: &dyn Fn() -> bool) -> Result<(), String> { if cancelled() { Err("Conflict operation cancelled".into()) } else { Ok(()) } } fn run(command: Command, cancelled: &dyn Fn() -> bool) -> Result, String> { let description = format!("{command:?}"); crate::blame::run(command, &[], cancelled)? .ok_or_else(|| format!("Jujutsu conflict command failed: {description}")) } fn text(bytes: Vec) -> Result { String::from_utf8(bytes).map_err(|_| "Jujutsu returned non-UTF-8 metadata".into()) } fn git(store: &Path) -> Command { let mut command = Command::new("git"); command.arg("--no-pager").arg("--git-dir").arg(store).args([ "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ]); command } fn identity(root: &Path, cancelled: &dyn Fn() -> bool) -> Result { check(cancelled)?; let state_path = root.join(".jj/working_copy/checkout"); let state = fs::read(&state_path).map_err(|e| format!("Cannot read local jj checkout: {e}"))?; let mut command = jj_command(root); command.args([ "log", "--no-graph", "-r", "@", "-T", "\"[\" ++ json(commit_id) ++ \",\" ++ json(change_id) ++ \"]\"", ]); let (commit, change): (String, String) = serde_json::from_slice(&run(command, cancelled)?) .map_err(|e| format!("Invalid jj checkout identity: {e}"))?; let mut command = jj_command(root); command.args(["op", "log", "--no-graph", "-n", "1", "-T", "id"]); let operation = text(run(command, cancelled)?)?; if fs::read(state_path).map_err(|e| e.to_string())? != state { return Err("Jujutsu checkout changed while reading; reopen :resolve".into()); } Ok(Checkout { commit, change, operation, state, }) } fn entries( root: &Path, revision: &str, cancelled: &dyn Fn() -> bool, ) -> Result, String> { let revision = format!("commit_id({})", serde_json::to_string(revision).unwrap()); let mut command = jj_command(root); command.args([ "file", "list", "-r", &revision, "-T", "if(conflict, json(path) ++ \"\\n\")", ]); let mut paths = Vec::new(); for line in run(command, cancelled)? .split(|b| *b == b'\n') .filter(|line| !line.is_empty()) { let path: String = serde_json::from_slice(line).map_err(|e| format!("Invalid jj conflict path: {e}"))?; let path = PathBuf::from(path); safe_path(&path)?; paths.push(path); if paths.len() > 100_000 { return Err("More than 100000 conflicted files".into()); } } Ok(paths) } fn oid(value: &str) -> Result<&str, String> { if matches!(value.len(), 40 | 64) && value.bytes().all(|b| b.is_ascii_hexdigit()) { Ok(value) } else { Err("Invalid Git object ID in jj conflict inputs".into()) } } fn trees(store: &Path, commit: &str, cancelled: &dyn Fn() -> bool) -> Result, String> { let mut command = git(store); command.args(["cat-file", "commit", oid(commit)?]); let bytes = run(command, cancelled)?; // jj's Git format is an alternating add/remove/add merge, not a 3-way assumption. // https://github.com/jj-vcs/jj/blob/v0.46.0/lib/src/git_backend.rs let headers = bytes .split(|b| *b == b'\n') .take_while(|line| !line.is_empty()); for line in headers { if let Some(value) = line.strip_prefix(b"jj:trees ") { let value = std::str::from_utf8(value).map_err(|_| "Invalid jj:trees header")?; let trees = value .split(' ') .map(|id| oid(id).map(str::to_owned)) .collect::, _>>()?; if trees.len() < 3 || trees.len() % 2 == 0 { return Err("Invalid jj merge-tree shape".into()); } return Ok(trees); } } Err("Conflicted jj commit has no supported jj:trees header; legacy conflict storage is unsupported".into()) } fn provenance( root: &Path, store: &Path, commit: &str, cancelled: &dyn Fn() -> bool, ) -> Result, String> { let mut command = jj_command(root); let revision = format!( "ancestors(commit_id({}))", serde_json::to_string(commit).unwrap() ); // ponytail: inspect the nearest 256 ancestors once; deeper inputs show their // exact tree ID. Expand the bounded batch only if provenance needs it. command.args(["log", "--no-graph", "-r", &revision, "-n", "256", "-T", "\"[\" ++ json(commit_id) ++ \",\" ++ json(change_id) ++ \",\" ++ json(local_bookmarks.map(|b| b.name())) ++ \"]\\n\""]); let mut commits = BTreeMap::new(); let mut input = Vec::new(); for line in run(command, cancelled)? .split(|b| *b == b'\n') .filter(|line| !line.is_empty()) { let (id, change, bookmarks): (String, String, Vec) = serde_json::from_slice(line).map_err(|e| format!("Invalid source provenance: {e}"))?; if id.bytes().all(|b| b == b'0') { continue; } oid(&id)?; let label = format!( "{}{}", &change[..change.len().min(12)], if bookmarks.is_empty() { String::new() } else { format!(" [{}]", bookmarks.join(", ")) } ); input.extend_from_slice(id.as_bytes()); input.push(b'\n'); commits.insert(id, (!bookmarks.is_empty(), label)); } if input.is_empty() { return Ok(BTreeMap::new()); } let mut command = git(store); command.args([ "log", "--no-walk=unsorted", "--no-decorate", "--no-show-signature", "--format=%H %T", "--stdin", ]); let output = crate::blame::run(command, &input, cancelled)? .ok_or("Cannot read source provenance trees")?; let mut labels: BTreeMap = BTreeMap::new(); for line in text(output)?.lines() { let (id, tree) = line .split_once(' ') .ok_or("Invalid source provenance tree record")?; oid(tree)?; let label = commits.get(id).ok_or("Unknown source provenance commit")?; if labels.get(tree).is_none_or(|old| !old.0 && label.0) { labels.insert(tree.to_owned(), label.clone()); } } Ok(labels .into_iter() .map(|(tree, (_, label))| (tree, label)) .collect()) } fn os(bytes: Vec) -> Result { #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; Ok(OsString::from_vec(bytes)) } #[cfg(not(unix))] { String::from_utf8(bytes) .map(OsString::from) .map_err(|_| "Non-UTF-8 path".into()) } } fn os_bytes(value: OsString) -> Vec { #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; value.into_vec() } #[cfg(not(unix))] { value.to_string_lossy().as_bytes().to_vec() } } fn blob( store: &Path, id: &str, budget: &mut usize, cancelled: &dyn Fn() -> bool, ) -> Result, String> { let mut command = git(store); command.args(["cat-file", "blob", oid(id)?]); let bytes = run(command, cancelled)?; if bytes.len() > LIMIT { return Err("Conflict input exceeds 16 MiB".into()); } *budget = budget .checked_sub(bytes.len()) .ok_or("Conflict source and disk inputs exceed 64 MiB total")?; Ok(bytes) } fn tree_entries( store: &Path, tree: &str, path: Option<&Path>, budget: &mut usize, cancelled: &dyn Fn() -> bool, ) -> Result, String> { let mut command = git(store); command.args(["--literal-pathspecs", "ls-tree", "-z", oid(tree)?]); if let Some(path) = path { command.arg("--").arg(path); } let mut result = Vec::new(); for record in run(command, cancelled)? .split(|b| *b == 0) .filter(|r| !r.is_empty()) { check(cancelled)?; let (header, name) = record.split_at( record .iter() .position(|b| *b == b'\t') .ok_or("Invalid Git tree record")?, ); let fields = std::str::from_utf8(header) .map_err(|_| "Invalid Git tree header")? .split(' ') .collect::>(); if fields.len() != 3 { return Err("Invalid Git tree fields".into()); } let mode = u32::from_str_radix(fields[0], 8).map_err(|_| "Invalid Git mode")?; let id = oid(fields[2])?; let node = match mode { 0o100644 | 0o100755 => Node::File(blob(store, id, budget, cancelled)?, mode & 0o777), 0o120000 => Node::Symlink(blob(store, id, budget, cancelled)?), 0o040000 => Node::Directory(tree_entries(store, id, None, budget, cancelled)?), 0o160000 => Node::Submodule(id.to_owned()), _ => return Err(format!("Unsupported Git conflict mode {mode:o}")), }; result.push((os(name[1..].to_vec())?, node)); } result.sort_by(|a, b| a.0.cmp(&b.0)); Ok(result) } fn safe_ancestors(root: &Path, path: &Path) -> Result<(), String> { safe_path(path)?; let mut full = root.to_path_buf(); for part in path.parent().into_iter().flat_map(Path::components) { full.push(part); let metadata = fs::symlink_metadata(&full) .map_err(|e| format!("Unsafe conflict parent {}: {e}", full.display()))?; if !metadata.is_dir() || metadata.file_type().is_symlink() { return Err(format!( "Conflict parent is not a real directory: {}", full.display() )); } } Ok(()) } fn disk(path: &Path, budget: &mut usize, cancelled: &dyn Fn() -> bool) -> Result { check(cancelled)?; let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Node::Absent), Err(e) => return Err(format!("{}: {e}", path.display())), }; if metadata.file_type().is_symlink() { return Ok(Node::Symlink(os_bytes( fs::read_link(path) .map_err(|e| e.to_string())? .into_os_string(), ))); } if metadata.is_dir() { let mut entries = Vec::new(); for entry in fs::read_dir(path).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; safe_path(Path::new(&entry.file_name()))?; entries.push((entry.file_name(), disk(&entry.path(), budget, cancelled)?)); } entries.sort_by(|a, b| a.0.cmp(&b.0)); return Ok(Node::Directory(entries)); } if !metadata.is_file() { return Err(format!("Unsupported filesystem object: {}", path.display())); } if metadata.len() > LIMIT as u64 || metadata.len() > *budget as u64 { return Err("Conflict disk contents exceed 16 MiB per file or 64 MiB total".into()); } let bytes = fs::read(path).map_err(|e| e.to_string())?; *budget = budget .checked_sub(bytes.len()) .ok_or("Conflict disk contents exceed 64 MiB")?; #[cfg(unix)] let mode = { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o777 }; #[cfg(not(unix))] let mode = 0o644; Ok(Node::File(bytes, mode)) } fn disk_result(node: &Node) -> Option { (node.kind() == FileKind::Text) .then(|| String::from_utf8(node.bytes().to_vec()).ok()) .flatten() } pub fn load(root: &Path, cancelled: &dyn Fn() -> bool) -> Result { let root = fs::canonicalize(root).map_err(|e| e.to_string())?; let root = root .ancestors() .find(|p| p.join(".jj").is_dir()) .ok_or("Resolve requires a current Jujutsu working copy")? .to_path_buf(); let checkout = identity(&root, cancelled)?; let paths = entries(&root, &checkout.commit, cancelled)?; let store = store_for_jj(&root)?; let trees = if paths.is_empty() { Vec::new() } else { trees(&store, &checkout.commit, cancelled)? }; let (labels, provenance_notice) = if paths.is_empty() { (BTreeMap::new(), None) } else { match provenance(&root, &store, &checkout.commit, cancelled) { Ok(labels) => (labels, None), Err(error) => { check(cancelled)?; ( BTreeMap::new(), Some(format!( "Source provenance unavailable; showing exact tree IDs: {error}" )), ) } } }; let mut files = Vec::new(); let mut budget = 64 * 1024 * 1024; for path in paths { check(cancelled)?; safe_ancestors(&root, &path)?; let mut terms = Vec::new(); for tree in &trees { let mut values = tree_entries(&store, tree, Some(&path), &mut budget, cancelled)?; if values.len() > 1 { return Err("Ambiguous Git tree conflict path".into()); } terms.push(values.pop().map_or(Node::Absent, |(_, value)| value)); } // Cancel equal add/remove terms exactly as jj simplifies a path's merge. let mut keep = vec![true; terms.len()]; for base in (1..terms.len()).step_by(2) { if let Some(side) = (0..terms.len()) .step_by(2) .find(|side| keep[*side] && terms[*side] == terms[base]) { keep[base] = false; keep[side] = false; } } let mut sides = 0; let mut bases = 0; let mut sources = Vec::new(); let num_sides = keep .iter() .enumerate() .filter(|(i, kept)| **kept && i % 2 == 0) .count(); let num_bases = keep .iter() .enumerate() .filter(|(i, kept)| **kept && i % 2 == 1) .count(); for (index, node) in terms.into_iter().enumerate().filter(|(i, _)| keep[*i]) { let base = index % 2 == 1; let term = if base { let n = bases; bases += 1; n } else { let n = sides; sides += 1; n }; let tree_id = trees[index].clone(); let provenance = labels .get(&tree_id) .cloned() .unwrap_or_else(|| format!("tree {}", &tree_id[..12])); sources.push(Source { label: format!( "{} #{} - {} ({:?}{})", if base { "Base" } else { "Side" }, term + 1, provenance, node.kind(), if matches!(&node, Node::File(_, mode) if mode & 0o111 != 0) { ", executable" } else { "" } ), base, kind: node.kind(), bytes: node.bytes().to_vec(), tree_id, term, sides: num_sides, bases: num_bases, node, }); } let baseline = disk(&root.join(&path), &mut budget, cancelled)?; let nontext = sources .iter() .any(|s| s.kind != FileKind::Text && !(s.base && s.kind == FileKind::Deleted)); let result = if nontext { None } else { disk_result(&baseline) }; let notice = match (nontext, &provenance_notice) { (true, Some(error)) => Some(format!("Non-text/type conflict: choose an explicit whole-file source; byte content and file type are preserved. {error}")), (true, None) => Some("Non-text/type conflict: choose an explicit whole-file source; byte content and file type are preserved".into()), (false, notice) => notice.clone(), }; files.push(ConflictFile { path, sources, result, notice, status: Status::Unresolved, baseline, }); } if identity(&root, cancelled)? != checkout { return Err("Jujutsu checkout changed while loading; reopen :resolve".into()); } Ok(Workspace { root, checkout, files, }) } // jj uses flock on this exact path, unlinks it before unlocking, and retries // unlinked descriptors. Use the same protocol so checkout changes cannot race writes. struct WorkingCopyLock { path: PathBuf, _file: File, } impl WorkingCopyLock { fn acquire(root: &Path) -> Result { let path = root.join(".jj/working_copy/working_copy.lock"); loop { let file = OpenOptions::new() .create(true) .write(true) .truncate(false) .open(&path) .map_err(|e| e.to_string())?; file.try_lock() .map_err(|e| format!("Jujutsu working copy is busy: {e}"))?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; if file.metadata().map_err(|e| e.to_string())?.nlink() == 0 { continue; } } return Ok(Self { path, _file: file }); } } } impl Drop for WorkingCopyLock { fn drop(&mut self) { let _ = fs::remove_file(&self.path); } } struct Staged(PathBuf); impl Drop for Staged { fn drop(&mut self) { let _ = remove(&self.0); } } impl Staged { fn new(parent: &Path) -> Result { for _ in 0..100 { let path = parent.join(format!( ".vamp-resolve-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); let mut builder = fs::DirBuilder::new(); #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; builder.mode(0o700); } match builder.create(&path) { Ok(()) => return Ok(Self(path)), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e.to_string()), } } Err("Cannot create a private conflict save directory".into()) } } fn remove(path: &Path) -> Result<(), String> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path), Ok(_) => fs::remove_file(path), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(e) => return Err(e.to_string()), } .map_err(|e| e.to_string()) } fn materialize(path: &Path, node: &Node, cancelled: &dyn Fn() -> bool) -> Result<(), String> { check(cancelled)?; match node { Node::Absent => Ok(()), Node::File(bytes, mode) => { let mut file = OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| e.to_string())?; file.write_all(bytes).and_then(|_| file.sync_all()).map_err(|e| e.to_string())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(*mode)).map_err(|e| e.to_string())?; } Ok(()) } Node::Symlink(bytes) => { let target = PathBuf::from(os(bytes.clone())?); #[cfg(unix)] { std::os::unix::fs::symlink(target, path).map_err(|e| e.to_string()) } #[cfg(not(unix))] { let _ = target; Err("Symlink resolution requires Unix symlink support".into()) } } Node::Directory(entries) => { fs::create_dir(path).map_err(|e| e.to_string())?; for (name, value) in entries { safe_path(Path::new(name))?; materialize(&path.join(name), value, cancelled)?; } Ok(()) } Node::Submodule(_) => Err("Jujutsu does not materialize Git submodules; resolve this source with jj outside the editor".into()), } } pub fn save( workspace: &Workspace, file_index: usize, resolution: Resolution, cancelled: &dyn Fn() -> bool, ) -> Result { check(cancelled)?; let file = workspace .files .get(file_index) .ok_or("Unknown resolver file")?; let desired = match resolution { Resolution::Text(text) => { if file.result.is_none() || (file.status != Status::Resolved && file.sources.iter().any(|source| { source.kind != FileKind::Text && !(source.base && source.kind == FileKind::Deleted) })) { return Err( "Choose an explicit whole-file source for this non-text/type conflict".into(), ); } if text.len() > LIMIT { return Err("Resolution exceeds 16 MiB".into()); } let mode = match &file.baseline { Node::File(_, mode) => *mode, _ => 0o644, }; Node::File(text.into_bytes(), mode) } Resolution::Source(index) => file .sources .get(index) .ok_or("Unknown conflict source")? .node .clone(), }; if fs::canonicalize(&workspace.root).map_err(|e| e.to_string())? != workspace.root { return Err("Jujutsu workspace path changed; no file written. Reopen :resolve".into()); } let lock = WorkingCopyLock::acquire(&workspace.root)?; if identity(&workspace.root, cancelled)? != workspace.checkout { return Err("Jujutsu checkout changed; no file written. Reopen :resolve".into()); } safe_ancestors(&workspace.root, &file.path)?; let path = workspace.root.join(&file.path); if disk(&path, &mut (64 * 1024 * 1024), cancelled)? != file.baseline { return Err("Conflict file changed externally; no file written. Reopen :resolve".into()); } if matches!(file.baseline, Node::Directory(_)) && !file .sources .iter() .any(|source| source.node == file.baseline) { return Err("Directory contains untracked or modified children; no files written. Preserve those files before choosing a whole-file resolution".into()); } let staged = Staged::new(path.parent().ok_or("Missing conflict parent")?)?; let replacement = staged.0.join("result"); materialize(&replacement, &desired, cancelled)?; check(cancelled)?; // Recheck immediately before replacing, including filesystem ancestors. safe_ancestors(&workspace.root, &file.path)?; if identity(&workspace.root, cancelled)? != workspace.checkout || disk(&path, &mut (64 * 1024 * 1024), cancelled)? != file.baseline { return Err("Checkout or conflict file changed; no file written. Reopen :resolve".into()); } if matches!(desired, Node::Absent | Node::Directory(_)) || matches!(file.baseline, Node::Directory(_)) { let backup = staged.0.join("original"); if !matches!(file.baseline, Node::Absent) { fs::rename(&path, &backup).map_err(|e| e.to_string())?; } if !matches!(desired, Node::Absent) && let Err(error) = fs::rename(&replacement, &path) { if !matches!(file.baseline, Node::Absent) && let Err(restore) = fs::rename(&backup, &path) { let location = backup.display().to_string(); std::mem::forget(staged); return Err(format!( "Cannot install resolution: {error}; cannot restore original: {restore}; original retained at {location}" )); } return Err(error.to_string()); } } else { fs::rename(&replacement, &path).map_err(|e| e.to_string())?; } let cleanup_error = remove(&staged.0).err().map(|error| format!("Saved; private backup cleanup failed at {}: {error}; jj confirmation was not attempted", staged.0.display())); drop(staged); drop(lock); let mut saved = file.clone(); saved.baseline = desired; saved.result = disk_result(&saved.baseline); saved.status = Status::Unconfirmed; if let Some(error) = cleanup_error { return Ok(SaveOutcome { checkout: workspace.checkout.clone(), file: saved, confirmation: Some(error), }); } // A cancellation after the write must still produce a saved/unconfirmed // outcome, rather than leaving the UI believing nothing reached disk. let confirmation: Result = (|| { let before = identity(&workspace.root, cancelled)?; if before != workspace.checkout { return Err("Checkout changed after writing; jj confirmation was not attempted".into()); } let mut command = Command::new("jj"); command .arg("--repository") .arg(&workspace.root) .current_dir(&workspace.root) .args([ "--no-pager", "--color=never", "--config=snapshot.auto-update-stale=false", "util", "snapshot", ]); run(command, cancelled)?; let checkout = identity(&workspace.root, cancelled)?; if checkout.change != workspace.checkout.change { return Err("Jujutsu changed checkout during confirmation".into()); } let conflicts = entries(&workspace.root, &checkout.commit, cancelled)?; if identity(&workspace.root, cancelled)? != checkout { return Err("Jujutsu changed during conflict status confirmation".into()); } if disk(&path, &mut (64 * 1024 * 1024), cancelled)? != saved.baseline { return Err( "Conflict file changed during jj confirmation; saved content cannot be confirmed" .into(), ); } saved.status = if conflicts.contains(&saved.path) { Status::Unresolved } else { Status::Resolved }; Ok(checkout) })(); match confirmation { Ok(checkout) => Ok(SaveOutcome { checkout, file: saved, confirmation: None, }), Err(error) => Ok(SaveOutcome { checkout: workspace.checkout.clone(), file: saved, confirmation: Some(format!("Saved; jj status unconfirmed: {error}")), }), } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Region { pub lines: Range, pub sides: Vec, pub bases: Vec, } impl Region { fn selected(&self, source: &Source) -> Result<&str, String> { if !matches!(source.kind, FileKind::Text | FileKind::Deleted) { return Err("Non-text sources require an explicit whole-file choice".into()); } if self.sides.len() != source.sides || self.bases.len() != source.bases { return Err("Conflict region no longer has the original side/base count; edit it manually or choose a whole file".into()); } let terms = if source.base { &self.bases } else { &self.sides }; terms.get(source.term).map(String::as_str).ok_or_else(|| "Conflict region no longer matches this source; edit it manually or choose a whole file".into()) } pub fn choose(&self, source: &Source) -> Result { self.selected(source).map(str::to_owned) } } fn marker(line: &str) -> Option<(u8, usize)> { let bytes = line.as_bytes(); let first = *bytes.first()?; if !b"<>+-%%|=^\\".contains(&first) { return None; } let width = bytes.iter().take_while(|b| **b == first).count(); (width >= 7 && matches!(bytes.get(width), None | Some(b' ' | b'\r' | b'\n'))) .then_some((first, width)) } fn parse_region(lines: &[&str], width: usize) -> Option<(Vec, Vec)> { let mut sides = Vec::new(); let mut bases = Vec::new(); if lines.iter().any(|line| marker(line) == Some((b'|', width))) { let mut section = 0; sides.push(String::new()); bases.push(String::new()); sides.push(String::new()); for line in lines { match marker(line) { Some((b'|', n)) if n == width && section == 0 => section = 1, Some((b'=', n)) if n == width && section == 1 => section = 2, _ => match section { 0 => sides[0].push_str(line), 1 => bases[0].push_str(line), _ => sides[1].push_str(line), }, } } return (section == 2).then_some((sides, bases)); } let mut current = None; for line in lines { match marker(line) { Some((b'+', n)) if n == width => { sides.push(String::new()); current = Some((b'+', sides.len() - 1, 0)); } Some((b'-', n)) if n == width => { bases.push(String::new()); current = Some((b'-', bases.len() - 1, 0)); } Some((b'%', n)) if n == width => { bases.push(String::new()); sides.push(String::new()); current = Some((b'%', sides.len() - 1, bases.len() - 1)); } Some((b'^' | b'\\', n)) if n == width && current.is_some_and(|(kind, _, _)| kind == b'%') => {} _ => match current? { (b'+', side, _) => sides[side].push_str(line), (b'-', base, _) => bases[base].push_str(line), (b'%', side, base) => match line.as_bytes().first()? { b' ' => { sides[side].push_str(&line[1..]); bases[base].push_str(&line[1..]); } b'+' => sides[side].push_str(&line[1..]), b'-' => bases[base].push_str(&line[1..]), _ => return None, }, _ => return None, }, } } (sides.len() >= 2 && bases.len() + 1 == sides.len()).then_some((sides, bases)) } /// Recognize jj snapshot/diff/diff-experimental and Git diff3 markers at any /// marker width. Recognition is local; jj alone decides saved resolution status. pub fn regions(text: &str) -> Vec { let lines = text.split_inclusive('\n').collect::>(); let mut regions: Vec = Vec::new(); let mut starts = Vec::new(); for (row, line) in lines.iter().enumerate() { let width = match marker(line) { Some((b'<', width)) => { starts.push((row, width)); continue; } Some((b'>', width)) => width, _ => continue, }; let Some(position) = starts .iter() .rposition(|(_, candidate)| *candidate == width) else { continue; }; let (start, _) = starts[position]; starts.truncate(position); if let Some((mut sides, mut bases)) = parse_region(&lines[start + 1..row], width) { // jj adds a separator EOL to every term if any input lacks its final // newline; the end marker then has no EOL (jj conflicts.rs protocol). if !lines[row].ends_with('\n') { for term in sides.iter_mut().chain(bases.iter_mut()) { if term.ends_with("\r\n") { term.truncate(term.len() - 2); } else if term.ends_with('\n') { term.pop(); } } } regions.retain(|region| region.lines.start < start || region.lines.end > row + 1); regions.push(Region { lines: start..row + 1, sides, bases, }); } } regions } /// Marker-free working text and the original choices, never historical full files. /// Like jj-diffconflicts, replace marker sections in the working buffer, not /// entire branch snapshots: https://github.com/rafikdraoui/jj-diffconflicts. #[derive(Clone, Debug)] pub struct Projection { seed_index: usize, alternatives: Vec, } impl Projection { /// Freeze every choice from current working text. Indices are indices in /// `ConflictFile::sources`, including bases. Later draft edits are independent. pub fn new(raw: &str, sources: &[Source], seed_index: usize) -> Result { if seed_index >= sources.len() { return Err("No such conflict source".into()); } let mut offsets = vec![0]; for line in raw.split_inclusive('\n') { offsets.push(offsets.last().unwrap() + line.len()); } let mut alternatives = sources .iter() .map(|_| String::with_capacity(raw.len())) .collect::>(); let mut end = 0; for region in regions(raw) { let outside = &raw[end..offsets[region.lines.start]]; for (alternative, source) in alternatives.iter_mut().zip(sources) { alternative.push_str(outside); alternative.push_str(region.selected(source)?); } end = offsets[region.lines.end]; } for alternative in &mut alternatives { alternative.push_str(&raw[end..]); } Ok(Self { seed_index, alternatives, }) } pub fn seed(&self) -> &str { &self.alternatives[self.seed_index] } pub fn alternative(&self, index: usize) -> Result<&str, String> { self.alternatives .get(index) .map(String::as_str) .ok_or_else(|| "No such conflict source".into()) } } /// Project the selected side/base through current materialized working text. /// Bytes outside recognized marker regions, including the final EOL, are retained. pub fn project(text: &str, source: &Source) -> Result { Projection::new(text, std::slice::from_ref(source), 0) .map(|mut projection| projection.alternatives.pop().unwrap()) } #[cfg(all(test, unix))] #[path = "../tests/unit/resolve.rs"] mod tests; #[cfg(test)] #[path = "../tests/unit/resolve_projection.rs"] mod projection_tests;