Files
vamp/src/resolve.rs
T
2026-10-16 18:53:02 +02:00

1059 lines
37 KiB
Rust

//! 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<u8>,
}
#[derive(Clone, Debug)]
pub struct Workspace {
pub root: PathBuf,
pub checkout: Checkout,
pub files: Vec<ConflictFile>,
}
#[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<u8>,
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<Source>,
pub result: Option<String>,
pub notice: Option<String>,
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<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Node {
Absent,
File(Vec<u8>, u32),
Symlink(Vec<u8>),
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<Vec<u8>, String> {
let description = format!("{command:?}");
crate::blame::run(command, &[], cancelled)?
.ok_or_else(|| format!("Jujutsu conflict command failed: {description}"))
}
fn text(bytes: Vec<u8>) -> Result<String, String> {
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<Checkout, String> {
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<Vec<PathBuf>, 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<Vec<String>, 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::<Result<Vec<_>, _>>()?;
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<BTreeMap<String, String>, 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<String>) =
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<String, (bool, String)> = 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<u8>) -> Result<OsString, String> {
#[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<u8> {
#[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<Vec<u8>, 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<Vec<(OsString, Node)>, 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::<Vec<_>>();
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<Node, String> {
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<String> {
(node.kind() == FileKind::Text)
.then(|| String::from_utf8(node.bytes().to_vec()).ok())
.flatten()
}
pub fn load(root: &Path, cancelled: &dyn Fn() -> bool) -> Result<Workspace, String> {
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<Self, String> {
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<Self, String> {
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<SaveOutcome, String> {
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<Checkout, String> = (|| {
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<usize>,
pub sides: Vec<String>,
pub bases: Vec<String>,
}
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<String, String> {
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<String>, Vec<String>)> {
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<Region> {
let lines = text.split_inclusive('\n').collect::<Vec<_>>();
let mut regions: Vec<Region> = 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<String>,
}
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<Self, String> {
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::<Vec<_>>();
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<String, String> {
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;