1059 lines
37 KiB
Rust
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;
|