use std::path::Path;
use std::process::Command;
use skillfile_core::conflict::{clear_conflict, read_conflict};
use skillfile_core::error::SkillfileError;
use skillfile_core::models::{short_sha, ConflictState};
use skillfile_core::parser::{find_entry_in, parse_manifest, MANIFEST_NAME};
use skillfile_core::progress;
use skillfile_deploy::paths::{installed_dir_files, installed_path};
use skillfile_sources::strategy::is_dir_entry;
use skillfile_sources::sync::{fetch_dir_at_sha, fetch_file_at_sha};
use crate::patch::{
apply_patch_pure, dir_patch_path, generate_patch, has_patch, read_patch,
remove_all_dir_patches, remove_patch, write_dir_patch, write_patch,
};
type FileMap = std::collections::HashMap<String, String>;
struct MergeInput<'a> {
base: &'a str,
theirs: &'a str,
yours: &'a str,
}
struct MergeResult {
merged: String,
has_conflicts: bool,
}
struct ResolveEntryCtx<'a> {
entry: &'a skillfile_core::models::Entry,
repo_root: &'a Path,
}
struct DirMergeCtx<'a> {
entry: &'a skillfile_core::models::Entry,
installed: &'a std::collections::HashMap<String, std::path::PathBuf>,
repo_root: &'a Path,
}
fn three_way_merge(input: &MergeInput<'_>, filename: &str) -> Result<MergeResult, SkillfileError> {
use std::io::Write;
let tmpdir = tempfile::tempdir()
.map_err(|e| SkillfileError::Manifest(format!("failed to create temp dir: {e}")))?;
let base_f = tmpdir.path().join(format!("base_{filename}"));
let theirs_f = tmpdir.path().join(format!("theirs_{filename}"));
let yours_f = tmpdir.path().join(format!("yours_{filename}"));
std::fs::File::create(&base_f)
.and_then(|mut f| f.write_all(input.base.as_bytes()))
.map_err(|e| SkillfileError::Manifest(format!("failed to write temp file: {e}")))?;
std::fs::File::create(&theirs_f)
.and_then(|mut f| f.write_all(input.theirs.as_bytes()))
.map_err(|e| SkillfileError::Manifest(format!("failed to write temp file: {e}")))?;
std::fs::File::create(&yours_f)
.and_then(|mut f| f.write_all(input.yours.as_bytes()))
.map_err(|e| SkillfileError::Manifest(format!("failed to write temp file: {e}")))?;
let output = Command::new("git")
.args([
"merge-file",
"-p",
"--diff3",
&yours_f.to_string_lossy(),
&base_f.to_string_lossy(),
&theirs_f.to_string_lossy(),
])
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SkillfileError::Install(
"`git` not found — install git to use `skillfile resolve`".into(),
)
} else {
SkillfileError::Install(format!("git merge-file failed: {e}"))
}
})?;
let has_conflicts = !output.status.success();
let merged = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(MergeResult {
merged,
has_conflicts,
})
}
fn open_in_editor(content: &str, filename: &str) -> Result<String, SkillfileError> {
use std::io::Write;
let editor = std::env::var("MERGETOOL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let tmp = tempfile::Builder::new()
.suffix(&format!("_{filename}"))
.tempfile()
.map_err(|e| SkillfileError::Manifest(format!("failed to create temp file: {e}")))?;
tmp.as_file()
.write_all(content.as_bytes())
.map_err(|e| SkillfileError::Manifest(format!("failed to write temp file: {e}")))?;
let path = tmp.into_temp_path();
Command::new(&editor)
.arg(path.as_os_str())
.status()
.map_err(|e| SkillfileError::Install(format!("failed to open editor '{editor}': {e}")))?;
let result = std::fs::read_to_string(&path)
.map_err(|e| SkillfileError::Manifest(format!("failed to read temp file: {e}")))?;
Ok(result)
}
fn reconstruct_yours_single(
ctx: &ResolveEntryCtx<'_>,
base: &str,
installed: &std::path::Path,
) -> Result<String, SkillfileError> {
if has_patch(ctx.entry, ctx.repo_root) {
let patch_text = read_patch(ctx.entry, ctx.repo_root)?;
return apply_patch_pure(base, &patch_text);
}
if !installed.exists() {
return Err(SkillfileError::Manifest(format!(
"'{}' is not installed at {}",
ctx.entry.name,
installed.display()
)));
}
Ok(std::fs::read_to_string(installed)?)
}
fn resolve_conflicts_or_clean(
result: MergeResult,
entry_name: &str,
filename: &str,
) -> Result<Option<String>, SkillfileError> {
if !result.has_conflicts {
progress!(" clean merge — no conflicts in '{entry_name}'");
return Ok(Some(result.merged));
}
eprintln!(
"\nConflicts detected in '{entry_name}'. Opening in editor to resolve...\n Save and close when done."
);
let resolved = open_in_editor(&result.merged, filename)?;
if resolved.contains("<<<<<<<") {
eprintln!("error: conflict markers still present — resolve all conflicts and try again");
return Ok(None);
}
Ok(Some(resolved))
}
fn resolve_single_file(
entry: &skillfile_core::models::Entry,
conflict: &ConflictState,
repo_root: &Path,
) -> Result<(), SkillfileError> {
let filename = format!("{}.md", entry.name);
let client = skillfile_sources::http::UreqClient::new();
progress!(
" fetching upstream at old sha={} (common ancestor) ...",
short_sha(&conflict.old_sha)
);
let base = fetch_file_at_sha(&client, entry, &conflict.old_sha)?;
progress!("done");
progress!(
" fetching upstream at new sha={} ...",
short_sha(&conflict.new_sha)
);
let theirs = fetch_file_at_sha(&client, entry, &conflict.new_sha)?;
progress!("done");
let manifest = crate::config::parse_and_resolve(&repo_root.join(MANIFEST_NAME))?;
let installed = installed_path(entry, &manifest, repo_root)?;
let ctx = ResolveEntryCtx { entry, repo_root };
let yours = reconstruct_yours_single(&ctx, &base, &installed)?;
progress!(" merging ...");
let input = MergeInput {
base: &base,
theirs: &theirs,
yours: &yours,
};
let result = three_way_merge(&input, &filename)?;
let Some(merged) = resolve_conflicts_or_clean(result, &entry.name, &filename)? else {
return Ok(());
};
std::fs::write(&installed, &merged)?;
let patch_text = generate_patch(&theirs, &merged, &filename);
if patch_text.is_empty() {
remove_patch(entry, repo_root)?;
progress!(
" merged result matches upstream — removed pin for '{}'",
entry.name
);
} else {
write_patch(entry, &patch_text, repo_root)?;
progress!(" updated .skillfile/patches/ for '{}'", entry.name);
}
clear_conflict(repo_root)?;
println!(
"\nResolved. Run `skillfile install` to deploy '{}'.",
entry.name
);
Ok(())
}
struct UpstreamVersions<'a> {
filenames: &'a [String],
base: &'a FileMap,
theirs: &'a FileMap,
}
fn merge_all_files(
upstream: &UpstreamVersions<'_>,
ctx: &DirMergeCtx<'_>,
) -> Result<Option<(FileMap, bool)>, SkillfileError> {
let mut merged_results = FileMap::new();
let mut any_conflict = false;
for filename in upstream.filenames {
let base = upstream.base.get(filename).map_or("", String::as_str);
let theirs = upstream.theirs.get(filename).map_or("", String::as_str);
let p = dir_patch_path(ctx.entry, filename, ctx.repo_root);
let yours = if p.exists() {
let patch_text = std::fs::read_to_string(&p)?;
apply_patch_pure(base, &patch_text)?
} else {
match ctx.installed.get(filename) {
Some(inst_path) if inst_path.exists() => std::fs::read_to_string(inst_path)?,
_ => base.to_string(),
}
};
let input = MergeInput {
base,
theirs,
yours: &yours,
};
let result = three_way_merge(&input, filename)?;
if result.has_conflicts {
any_conflict = true;
eprintln!("\n Conflicts in '{filename}'. Opening in editor...");
}
let Some(merged) = resolve_conflicts_or_clean(result, filename, filename)? else {
return Ok(None);
};
merged_results.insert(filename.clone(), merged);
}
Ok(Some((merged_results, any_conflict)))
}
fn write_merged_results(
merged_results: &FileMap,
theirs_files: &FileMap,
ctx: &DirMergeCtx<'_>,
) -> Result<(), SkillfileError> {
remove_all_dir_patches(ctx.entry, ctx.repo_root)?;
let mut pinned: Vec<String> = Vec::new();
for (filename, merged_text) in merged_results {
let theirs = theirs_files.get(filename).map_or("", String::as_str);
if let Some(inst_path) = ctx.installed.get(filename) {
std::fs::write(inst_path, merged_text)?;
}
let patch_text = generate_patch(theirs, merged_text, filename);
if !patch_text.is_empty() {
write_dir_patch(
&dir_patch_path(ctx.entry, filename, ctx.repo_root),
&patch_text,
)?;
pinned.push(filename.clone());
}
}
if pinned.is_empty() {
progress!(
" merged result matches upstream — no pin needed for '{}'",
ctx.entry.name
);
} else {
progress!(
" updated .skillfile/patches/ for '{}' ({})",
ctx.entry.name,
pinned.join(", ")
);
}
Ok(())
}
fn resolve_dir_entry(
entry: &skillfile_core::models::Entry,
conflict: &ConflictState,
repo_root: &Path,
) -> Result<(), SkillfileError> {
let client = skillfile_sources::http::UreqClient::new();
progress!(
" fetching upstream at old sha={} (common ancestor) ...",
short_sha(&conflict.old_sha)
);
let base_files = fetch_dir_at_sha(&client, entry, &conflict.old_sha)?;
progress!("done");
progress!(
" fetching upstream at new sha={} ...",
short_sha(&conflict.new_sha)
);
let theirs_files = fetch_dir_at_sha(&client, entry, &conflict.new_sha)?;
progress!("done");
let manifest = crate::config::parse_and_resolve(&repo_root.join(MANIFEST_NAME))?;
let installed = installed_dir_files(entry, &manifest, repo_root)?;
if installed.is_empty() {
return Err(SkillfileError::Manifest(format!(
"'{}' is not installed",
entry.name
)));
}
let mut all_filenames: Vec<String> = base_files
.keys()
.chain(theirs_files.keys())
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
all_filenames.sort();
let dir_ctx = DirMergeCtx {
entry,
installed: &installed,
repo_root,
};
let upstream = UpstreamVersions {
filenames: &all_filenames,
base: &base_files,
theirs: &theirs_files,
};
let Some((merged_results, any_conflict)) = merge_all_files(&upstream, &dir_ctx)? else {
return Ok(());
};
if !any_conflict {
progress!(" all files merged cleanly in '{}'", entry.name);
}
write_merged_results(&merged_results, &theirs_files, &dir_ctx)?;
clear_conflict(repo_root)?;
println!(
"\nResolved. Run `skillfile install` to deploy '{}'.",
entry.name
);
Ok(())
}
pub fn cmd_resolve(
name: Option<&str>,
abort: bool,
repo_root: &Path,
) -> Result<(), SkillfileError> {
if abort {
let conflict = read_conflict(repo_root)?;
match conflict {
None => {
println!("No pending conflict to abort.");
}
Some(c) => {
clear_conflict(repo_root)?;
println!(
"Conflict for '{}' cleared. Run `skillfile install` to continue.",
c.entry
);
}
}
return Ok(());
}
let name = name
.ok_or_else(|| SkillfileError::Manifest("entry name required (unless --abort)".into()))?;
let manifest_path = repo_root.join(MANIFEST_NAME);
let result = parse_manifest(&manifest_path)?;
let entry = find_entry_in(name, &result.manifest)?;
let conflict = read_conflict(repo_root)?;
let conflict = match conflict {
None => {
return Err(SkillfileError::Manifest(format!(
"no pending conflict for '{name}' — \
`skillfile resolve` is only available after a conflict is detected by `skillfile install --update`"
)))
}
Some(c) if c.entry != name => {
return Err(SkillfileError::Manifest(format!(
"no pending conflict for '{name}' — \
`skillfile resolve` is only available after a conflict is detected by `skillfile install --update`"
)))
}
Some(c) => c,
};
if is_dir_entry(entry) {
resolve_dir_entry(entry, &conflict, repo_root)
} else {
resolve_single_file(entry, &conflict, repo_root)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_manifest(dir: &Path, content: &str) {
std::fs::write(dir.join(MANIFEST_NAME), content).unwrap();
}
#[test]
fn resolve_abort_no_conflict_noop() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "");
let result = cmd_resolve(None, true, dir.path());
assert!(result.is_ok());
}
#[test]
fn resolve_no_conflict_errors() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "github skill owner/repo skills/test.md\n");
let result = cmd_resolve(Some("test"), false, dir.path());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("no pending conflict"));
}
#[test]
fn resolve_no_manifest_errors() {
let dir = tempfile::tempdir().unwrap();
let result = cmd_resolve(Some("test"), false, dir.path());
assert!(result.is_err());
}
#[test]
fn three_way_merge_clean() {
let input = MergeInput {
base: "line1\nline2\n",
theirs: "line1\nline2\nline3-theirs\n",
yours: "line0-yours\nline1\nline2\n",
};
let result = three_way_merge(&input, "test.md").unwrap();
assert!(!result.has_conflicts, "expected clean merge");
assert!(
result.merged.contains("line3-theirs"),
"merged missing theirs change"
);
assert!(
result.merged.contains("line0-yours"),
"merged missing yours change"
);
}
#[test]
fn three_way_merge_conflict() {
let input = MergeInput {
base: "line1\n",
theirs: "THEIRS\n",
yours: "YOURS\n",
};
let result = three_way_merge(&input, "test.md").unwrap();
assert!(result.has_conflicts, "expected conflict");
assert!(
result.merged.contains("<<<<<<<"),
"expected conflict markers"
);
}
#[test]
fn three_way_merge_clean_when_only_yours_changed() {
let base = "original content\n";
let yours = "modified content\n";
let theirs = base; let input = MergeInput {
base,
theirs,
yours,
};
let result = three_way_merge(&input, "test.md").unwrap();
assert!(!result.has_conflicts);
assert_eq!(result.merged, yours);
}
#[test]
fn cmd_resolve_no_name_no_abort_errors() {
let dir = tempfile::tempdir().unwrap();
write_manifest(dir.path(), "github skill owner/repo skills/test.md\n");
let result = cmd_resolve(None, false, dir.path());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("entry name required"),
"expected 'entry name required' in error message"
);
}
}