use std::path::Path;
use std::sync::Arc;
use camino::Utf8PathBuf;
use serde::Serialize;
use void_core::ops::merge_state::{is_merge_in_progress, read_merge_state};
use void_core::store::FsStore;
use void_core::workspace::checkout::{checkout_tree, CheckoutOptions};
use void_core::workspace::stage::{stage_paths, StageOptions};
use void_core::cid;
use crate::context::{find_void_dir, open_repo, void_err_to_cli};
use crate::observer::ProgressObserver;
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct ResolveArgs {
pub paths: Vec<String>,
pub ours: bool,
pub theirs: bool,
pub all: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveOutput {
pub resolved: Vec<String>,
pub remaining: usize,
pub merge_complete: bool,
}
pub fn run(cwd: &Path, args: ResolveArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("resolve", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let workspace = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?;
let repo = open_repo(workspace)?;
let vault = repo.vault().clone();
if args.ours && args.theirs {
return Err(CliError::invalid_args(
"cannot specify both --ours and --theirs",
));
}
if !args.ours && !args.theirs {
return Err(CliError::invalid_args(
"must specify either --ours or --theirs",
));
}
if !is_merge_in_progress(&void_dir) {
return Err(CliError::conflict("no merge in progress"));
}
let merge_state = read_merge_state(&void_dir)
.map_err(void_err_to_cli)?
.ok_or_else(|| CliError::conflict("no merge in progress"))?;
if args.all && !args.paths.is_empty() {
return Err(CliError::invalid_args(
"cannot specify both --all and paths",
));
}
let paths_to_resolve = if args.all {
merge_state.conflicts.clone()
} else {
if args.paths.is_empty() {
return Err(CliError::invalid_args("no paths specified"));
}
for path in &args.paths {
if !merge_state.conflicts.contains(path) {
return Err(CliError::not_found(format!(
"path '{}' is not in the conflict list",
path
)));
}
}
args.paths.clone()
};
let total_conflicts = merge_state.conflicts.len();
let strategy = if args.ours { "ours" } else { "theirs" };
ctx.progress(format!(
"Resolving {} file(s) using {}...",
paths_to_resolve.len(),
strategy
));
let commit_cid_bytes = if args.ours {
merge_state.orig_head.clone()
} else {
merge_state.merge_head.clone()
};
let commit_cid = cid::from_bytes(commit_cid_bytes.as_bytes())
.map_err(|e| CliError::internal(format!("invalid commit CID: {}", e)))?;
let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects"))
.map_err(|e| CliError::internal(format!("invalid objects path: {}", e)))?;
let store = FsStore::new(objects_dir).map_err(void_err_to_cli)?;
let observer: Arc<ProgressObserver> = if ctx.use_json() {
Arc::new(ProgressObserver::new_hidden())
} else {
Arc::new(ProgressObserver::new("Checking out files..."))
};
let checkout_opts = CheckoutOptions {
paths: Some(paths_to_resolve.clone()),
force: true, observer: Some(observer.clone()),
workspace_dir: None,
include_large: false,
};
checkout_tree(&store, &*vault, &commit_cid, workspace, &checkout_opts)
.map_err(void_err_to_cli)?;
observer.finish();
let stage_observer: Arc<ProgressObserver> = if ctx.use_json() {
Arc::new(ProgressObserver::new_hidden())
} else {
Arc::new(ProgressObserver::new("Staging resolved files..."))
};
let stage_opts = StageOptions {
ctx: repo.context().clone(),
patterns: paths_to_resolve.clone(),
observer: Some(stage_observer.clone()),
};
stage_paths(stage_opts).map_err(void_err_to_cli)?;
stage_observer.finish();
let remaining = total_conflicts - paths_to_resolve.len();
let merge_complete = remaining == 0;
if !ctx.use_json() {
for path in &paths_to_resolve {
ctx.info(format!("Resolved '{}' using {}", path, strategy));
}
if merge_complete {
ctx.info(
"All conflicts resolved. Run 'void merge --continue' to complete the merge."
.to_string(),
);
} else {
ctx.info(format!("{} conflict(s) remaining", remaining));
}
}
Ok(ResolveOutput {
resolved: paths_to_resolve,
remaining,
merge_complete,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::CliOptions;
use std::fs;
use tempfile::tempdir;
use void_core::crypto::{self, CommitCid};
use void_core::ops::merge_state::{write_merge_state, MergeState};
fn default_opts() -> CliOptions {
CliOptions {
human: true,
..Default::default()
}
}
fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir_all(void_dir.join("objects")).unwrap();
fs::create_dir_all(void_dir.join("refs/heads")).unwrap();
let key = crypto::generate_key();
let home = tempdir().unwrap();
let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());
let repo_secret = hex::encode(crypto::generate_key());
fs::write(
void_dir.join("config.json"),
format!(r#"{{"repoSecret": "{}"}}"#, repo_secret),
)
.unwrap();
(dir, void_dir, home, guard)
}
#[test]
fn test_resolve_no_merge_in_progress() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = ResolveArgs {
paths: vec!["src/main.rs".to_string()],
ours: true,
theirs: false,
all: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_resolve_both_flags_error() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let state = MergeState {
merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03]),
merge_base: None,
orig_head: CommitCid::from_bytes(vec![0x04, 0x05, 0x06]),
conflicts: vec!["src/main.rs".to_string()],
message: "Merge".to_string(),
};
write_merge_state(&void_dir, &state).unwrap();
let args = ResolveArgs {
paths: vec!["src/main.rs".to_string()],
ours: true,
theirs: true,
all: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_resolve_no_flags_error() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let state = MergeState {
merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03]),
merge_base: None,
orig_head: CommitCid::from_bytes(vec![0x04, 0x05, 0x06]),
conflicts: vec!["src/main.rs".to_string()],
message: "Merge".to_string(),
};
write_merge_state(&void_dir, &state).unwrap();
let args = ResolveArgs {
paths: vec!["src/main.rs".to_string()],
ours: false,
theirs: false,
all: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_resolve_no_paths_error() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let state = MergeState {
merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03]),
merge_base: None,
orig_head: CommitCid::from_bytes(vec![0x04, 0x05, 0x06]),
conflicts: vec!["src/main.rs".to_string()],
message: "Merge".to_string(),
};
write_merge_state(&void_dir, &state).unwrap();
let args = ResolveArgs {
paths: vec![],
ours: true,
theirs: false,
all: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_resolve_path_not_in_conflicts() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let state = MergeState {
merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03]),
merge_base: None,
orig_head: CommitCid::from_bytes(vec![0x04, 0x05, 0x06]),
conflicts: vec!["other/file.rs".to_string()],
message: "Merge".to_string(),
};
write_merge_state(&void_dir, &state).unwrap();
let args = ResolveArgs {
paths: vec!["src/main.rs".to_string()],
ours: true,
theirs: false,
all: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_resolve_all_with_paths_error() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let state = MergeState {
merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03]),
merge_base: None,
orig_head: CommitCid::from_bytes(vec![0x04, 0x05, 0x06]),
conflicts: vec!["src/main.rs".to_string()],
message: "Merge".to_string(),
};
write_merge_state(&void_dir, &state).unwrap();
let args = ResolveArgs {
paths: vec!["src/main.rs".to_string()],
ours: true,
theirs: false,
all: true,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_resolve_output_serialization() {
let output = ResolveOutput {
resolved: vec!["src/main.rs".to_string(), "lib/utils.rs".to_string()],
remaining: 1,
merge_complete: false,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"resolved\""));
assert!(json.contains("\"src/main.rs\""));
assert!(json.contains("\"lib/utils.rs\""));
assert!(json.contains("\"remaining\":1"));
assert!(json.contains("\"mergeComplete\":false"));
}
#[test]
fn test_resolve_output_serialization_complete() {
let output = ResolveOutput {
resolved: vec!["README.md".to_string()],
remaining: 0,
merge_complete: true,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"remaining\":0"));
assert!(json.contains("\"mergeComplete\":true"));
}
}