use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use colored::Colorize;
fn supported_patterns() -> Vec<String> {
let mut exts = weave_core::supported_merge_extensions();
exts.extend(
weave_core::LINE_RULE_EXTENSIONS
.iter()
.map(|e| e.to_string()),
);
exts.sort_unstable();
exts.dedup();
exts.into_iter().map(|ext| format!("*{}", ext)).collect()
}
pub(crate) fn run(
driver_path: Option<&str>,
local: bool,
global: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if !global {
let git_dir = Path::new(".git");
if !git_dir.exists() {
return Err("Not in a git repository. Run `weave setup` from the repo root, or `weave setup --global` to configure git for every repo.".into());
}
}
let driver = if let Some(p) = driver_path {
p.to_string()
} else {
which_driver()?
};
if !Path::new(&driver).exists()
&& Command::new("which")
.arg(&driver)
.output()
.map(|o| !o.status.success())
.unwrap_or(true)
{
eprintln!(
"{} Driver binary '{}' not found. Build with `cargo build --release` first.",
"warning:".yellow().bold(),
driver
);
}
let driver_cmd = format!("{} %O %A %B %L %P", driver);
let git_config = |args: &[&str]| -> std::io::Result<std::process::ExitStatus> {
let mut full = vec!["config"];
if global {
full.push("--global");
}
full.extend_from_slice(args);
Command::new("git").args(&full).status()
};
if !git_config(&["merge.weave.name", "Entity-level semantic merge"])?.success() {
return Err("Failed to set merge.weave.name".into());
}
if !git_config(&["merge.weave.driver", &driver_cmd])?.success() {
return Err("Failed to set merge.weave.driver".into());
}
println!(
"{} Configured git merge driver{}: {}",
"✓".green().bold(),
if global { " (global)" } else { "" },
driver_cmd
);
let (attributes_label, attributes_path) = attributes_target(local, global)?;
if local || global {
if let Some(parent) = attributes_path.parent() {
fs::create_dir_all(parent)?;
}
}
let mut existing = if attributes_path.exists() {
fs::read_to_string(&attributes_path)?
} else {
String::new()
};
let added = add_supported_patterns(&mut existing);
if added > 0 {
fs::write(&attributes_path, &existing)?;
println!(
"{} Updated {} ({} patterns added)",
"✓".green().bold(),
attributes_label,
added
);
} else {
println!(
"{} {} already configured",
"✓".green().bold(),
attributes_label
);
}
println!(
"\n{} Weave is ready. Merge conflicts will now be resolved at the entity level.",
"Done!".green().bold()
);
Ok(())
}
pub(crate) fn unsetup() -> Result<(), Box<dyn std::error::Error>> {
let git_dir = Path::new(".git");
if !git_dir.exists() {
return Err("Not in a git repository. Run `weave unsetup` from the repo root.".into());
}
let _ = Command::new("git")
.args(["config", "--remove-section", "merge.weave"])
.status();
println!(
"{} Removed weave merge driver from git config",
"✓".green().bold()
);
clean_attributes(Path::new(".gitattributes"), ".gitattributes")?;
if let Ok(local_attributes_path) = git_path("info/attributes") {
clean_attributes(&local_attributes_path, ".git/info/attributes")?;
}
println!(
"\n{} Weave has been removed. Git will use its default merge strategy.",
"Done!".green().bold()
);
Ok(())
}
fn attributes_target(
local: bool,
global: bool,
) -> Result<(String, PathBuf), Box<dyn std::error::Error>> {
if global {
let path = global_attributes_path()?;
Ok((path.display().to_string(), path))
} else if local {
Ok((
".git/info/attributes".to_string(),
git_path("info/attributes")?,
))
} else {
Ok((
".gitattributes".to_string(),
PathBuf::from(".gitattributes"),
))
}
}
fn global_attributes_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
if let Ok(out) = Command::new("git")
.args(["config", "--global", "core.attributesfile"])
.output()
{
if out.status.success() {
let configured = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !configured.is_empty() {
let expanded = if let Some(rest) = configured.strip_prefix("~/") {
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"))?;
PathBuf::from(home).join(rest)
} else {
PathBuf::from(configured)
};
return Ok(expanded);
}
}
}
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"))?;
Ok(PathBuf::from(home)
.join(".config")
.join("git")
.join("attributes"))
}
fn add_supported_patterns(existing: &mut String) -> usize {
let mut added = 0;
for glob in supported_patterns() {
let pattern = format!("{} merge=weave", glob);
if !existing.contains(&pattern) {
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&pattern);
existing.push('\n');
added += 1;
}
}
added
}
fn clean_attributes(path: &Path, label: &str) -> Result<(), Box<dyn std::error::Error>> {
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(path)?;
let filtered: Vec<&str> = content
.lines()
.filter(|line| !line.contains("merge=weave"))
.collect();
let new_content = filtered.join("\n");
if filtered.is_empty() || new_content.trim().is_empty() {
fs::remove_file(path)?;
println!(
"{} Removed {} (was only weave patterns)",
"✓".green().bold(),
label
);
} else {
let mut out = new_content;
if !out.ends_with('\n') {
out.push('\n');
}
fs::write(path, out)?;
println!(
"{} Cleaned weave patterns from {}",
"✓".green().bold(),
label
);
}
Ok(())
}
fn git_path(requested_path: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(["rev-parse", "--git-path", requested_path])
.output()?;
if !output.status.success() {
return Err(format!("Failed to resolve git path '{}'", requested_path).into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err(format!("Git returned an empty path for '{}'", requested_path).into());
}
Ok(PathBuf::from(path))
}
fn which_driver() -> Result<String, Box<dyn std::error::Error>> {
if let Ok(output) = Command::new("which").arg("weave-driver").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(path);
}
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join("weave-driver");
if candidate.exists() {
return Ok(candidate.to_string_lossy().to_string());
}
}
}
Ok("weave-driver".to_string())
}
#[cfg(test)]
mod tests {
use super::{add_supported_patterns, supported_patterns};
#[test]
fn add_supported_patterns_adds_all_supported_patterns() {
let mut existing = String::new();
let added = add_supported_patterns(&mut existing);
assert_eq!(added, supported_patterns().len());
for glob in supported_patterns() {
assert!(existing.contains(&format!("{} merge=weave", glob)));
}
}
#[test]
fn add_supported_patterns_is_idempotent() {
let mut existing = String::from("*.ts merge=weave\n");
let first_added = add_supported_patterns(&mut existing);
let after_first = existing.clone();
let second_added = add_supported_patterns(&mut existing);
assert_eq!(first_added, supported_patterns().len() - 1);
assert_eq!(second_added, 0);
assert_eq!(existing, after_first);
}
#[test]
fn every_pattern_is_listed_once() {
let patterns = supported_patterns();
let mut seen = patterns.clone();
seen.sort_unstable();
let before = seen.len();
seen.dedup();
assert_eq!(before, seen.len(), "supported_patterns() has a duplicate");
}
#[test]
fn every_pattern_is_a_single_extension_glob() {
for glob in supported_patterns() {
assert!(glob.starts_with("*."), "{glob} is not a `*.<ext>` glob");
}
}
#[test]
fn the_mts_cts_and_registry_langs_are_claimed() {
let patterns = supported_patterns();
for ext in [
".mts", ".cts", ".mjs", ".cjs", ".kt", ".tf", ".hcl", ".ml", ".mli", ".zig", ".elm",
".clj", ".edn", ".d", ".lua", ".fish", ".nix", ".sql", ".tex", ".pl", ".csv",
] {
assert!(
patterns.contains(&format!("*{ext}")),
"setup must write `*{ext} merge=weave`"
);
}
}
#[test]
fn the_line_rule_extensions_are_claimed() {
let patterns = supported_patterns();
for ext in weave_core::LINE_RULE_EXTENSIONS {
assert!(patterns.contains(&format!("*{ext}")), "{ext}");
}
}
#[test]
fn the_languages_that_fail_the_coverage_sweep_are_not_listed() {
let patterns = supported_patterns();
for ext in [".hs", ".vue", ".svelte", ".erb"] {
assert!(
!patterns.contains(&format!("*{ext}")),
"{ext} fails the coverage sweep and must not be claimed here"
);
}
}
}