use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use ito_config::types::{WorktreeInitConfig, WorktreesConfig};
use crate::errors::{CoreError, CoreResult};
use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
const WORKTREE_INCLUDE_FILE: &str = ".worktree-include";
pub fn resolve_include_files(
config: &WorktreeInitConfig,
source_root: &Path,
) -> CoreResult<Vec<PathBuf>> {
let patterns = collect_patterns(config, source_root)?;
expand_globs(&patterns, source_root)
}
pub fn parse_worktree_include_file(content: &str) -> Vec<String> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(String::from)
.collect()
}
pub fn copy_include_files(
config: &WorktreeInitConfig,
source_root: &Path,
dest_root: &Path,
) -> CoreResult<Vec<PathBuf>> {
let relative_paths = resolve_include_files(config, source_root)?;
let mut copied = Vec::new();
for rel_path in &relative_paths {
let src = source_root.join(rel_path);
let dst = dest_root.join(rel_path);
if !src.exists() {
continue;
}
if dst.exists() {
continue;
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|err| {
CoreError::io(
format!(
"Cannot create directory '{}' in worktree '{}' for include file '{}'.\n\
Fix: ensure the worktree path is writable.",
parent.display(),
dest_root.display(),
rel_path.display(),
),
err,
)
})?;
}
fs::copy(&src, &dst).map_err(|err| {
CoreError::io(
format!(
"Cannot copy '{}' to '{}' during worktree initialization.\n\
Fix: ensure the source file is readable and the destination is writable.",
src.display(),
dst.display(),
),
err,
)
})?;
copied.push(rel_path.clone());
}
Ok(copied)
}
pub fn init_worktree(
source_root: &Path,
dest_root: &Path,
config: &WorktreesConfig,
) -> CoreResult<()> {
let runner = SystemProcessRunner;
init_worktree_with_runner(&runner, source_root, dest_root, config)
}
pub(crate) fn init_worktree_with_runner(
runner: &dyn ProcessRunner,
source_root: &Path,
dest_root: &Path,
config: &WorktreesConfig,
) -> CoreResult<()> {
copy_include_files(&config.init, source_root, dest_root)?;
run_setup_with_runner(runner, dest_root, config)?;
Ok(())
}
pub fn run_worktree_setup(worktree_root: &Path, config: &WorktreesConfig) -> CoreResult<()> {
let runner = SystemProcessRunner;
run_setup_with_runner(&runner, worktree_root, config)
}
pub(crate) fn run_setup_with_runner(
runner: &dyn ProcessRunner,
worktree_root: &Path,
config: &WorktreesConfig,
) -> CoreResult<()> {
let Some(setup) = &config.init.setup else {
return Ok(());
};
if setup.is_empty() {
return Ok(());
}
let commands = setup.as_commands();
let (shell, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("sh", "-c")
};
for cmd in &commands {
let request = ProcessRequest::new(shell)
.arg(flag)
.arg(*cmd)
.current_dir(worktree_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot run worktree setup command '{}' in '{}'.\n\
Process failed to start: {err}\n\
Fix: ensure the command exists and the worktree path is accessible.",
cmd,
worktree_root.display(),
))
})?;
if !output.success {
let detail = if !output.stderr.trim().is_empty() {
output.stderr.trim().to_string()
} else if !output.stdout.trim().is_empty() {
output.stdout.trim().to_string()
} else {
format!("exit code {}", output.exit_code)
};
return Err(CoreError::process(format!(
"Worktree setup command failed: '{}'\n\
Working directory: {}\n\
Output: {detail}\n\
Fix: verify the command works when run manually in that directory.",
cmd,
worktree_root.display(),
)));
}
}
Ok(())
}
fn collect_patterns(config: &WorktreeInitConfig, source_root: &Path) -> CoreResult<Vec<String>> {
let mut patterns: Vec<String> = config.include.clone();
let include_path = source_root.join(WORKTREE_INCLUDE_FILE);
if include_path.is_file() {
let content = fs::read_to_string(&include_path).map_err(|err| {
CoreError::io(
format!(
"Cannot read '{}' in '{}'.\n\
Fix: ensure the file is readable or remove it.",
WORKTREE_INCLUDE_FILE,
source_root.display(),
),
err,
)
})?;
let file_patterns = parse_worktree_include_file(&content);
patterns.extend(file_patterns);
}
Ok(patterns)
}
fn expand_globs(patterns: &[String], source_root: &Path) -> CoreResult<Vec<PathBuf>> {
let mut result = BTreeSet::new();
let canonical_root = source_root.canonicalize().map_err(|err| {
CoreError::io(
format!(
"Cannot canonicalize source root '{}'.\n\
Fix: ensure the directory exists and is readable.",
source_root.display(),
),
err,
)
})?;
for pattern in patterns {
if pattern.is_empty() {
continue;
}
let escaped_root = glob::Pattern::escape(&source_root.to_string_lossy());
let full_pattern = format!("{escaped_root}/{pattern}");
let entries = glob::glob(&full_pattern).map_err(|err| {
CoreError::validation(format!(
"Invalid glob pattern '{}' in worktree include configuration.\n\
Pattern error: {err}\n\
Fix: use valid glob syntax (e.g. '*.env', '.envrc', 'config/*.toml').",
pattern,
))
})?;
for entry in entries {
let path = entry.map_err(|err| {
CoreError::io(
format!(
"Error reading filesystem while expanding glob '{}'.\n\
Fix: ensure the source directory '{}' is readable.",
pattern,
source_root.display(),
),
err.into_error(),
)
})?;
if !path.is_file() {
continue;
}
let Ok(canonical_path) = path.canonicalize() else {
continue;
};
let Ok(rel) = canonical_path.strip_prefix(&canonical_root) else {
continue;
};
result.insert(rel.to_path_buf());
}
}
Ok(result.into_iter().collect())
}
#[cfg(test)]
#[path = "worktree_init_tests.rs"]
mod worktree_init_tests;