use anyhow::Result;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::color;
use crate::commands::common::{get_main_repo_root, parse_all_worktrees};
use crate::config::{self, HookActions};
use crate::hooks;
#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
pub fn cmd_sync(run: bool, copy: bool, link: bool, color_mode: color::ColorMode) -> Result<()> {
let repo_root = get_main_repo_root()?;
let cfg = config::Config::load_from_repo_root(&repo_root)?;
let (do_run, do_copy, do_link) = if !run && !copy && !link {
(true, true, true)
} else {
(run, copy, link)
};
let create = cfg.hooks.create;
let actions = HookActions {
run: if do_run { create.run } else { vec![] },
copy: if do_copy { create.copy } else { vec![] },
link: if do_link { create.link } else { vec![] },
};
if actions.run.is_empty() && actions.copy.is_empty() && actions.link.is_empty() {
eprintln!("No hook actions configured for hooks.create. Nothing to sync.");
return Ok(());
}
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&repo_root)
.output()
.map_err(|e| anyhow::anyhow!("Failed to execute git worktree list: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree list failed: {}", stderr.trim());
}
let list_stdout = String::from_utf8_lossy(&output.stdout);
let (_main_path, worktrees) = parse_all_worktrees(&list_stdout);
if worktrees.is_empty() {
eprintln!("No non-main worktrees found. Nothing to sync.");
return Ok(());
}
let mp = MultiProgress::new();
let is_tty = color_mode.should_colorize();
let mut errors: Vec<String> = vec![];
for (path, branch) in &worktrees {
let label = branch.as_deref().unwrap_or(path.as_str());
let header_pb = if is_tty {
let pb = mp.add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
pb.set_message(format!("Syncing {label}"));
pb.enable_steady_tick(Duration::from_millis(100));
Some(pb)
} else {
eprintln!("{}", color::success(color_mode, format!("Synced {label}")));
None
};
let worktree_path = Path::new(path);
if !worktree_path.exists() {
if let Some(pb) = header_pb {
pb.set_style(ProgressStyle::with_template("{msg}").unwrap());
pb.finish_with_message(format!(
"{}",
color::success(color_mode, format!("Synced {label}"))
));
}
hooks::emit_line(
&mp,
is_tty,
format!(
" {}",
color::warn(
color_mode,
format!("Worktree directory not found, skipping: {path}")
)
),
);
continue;
}
if let Err(e) =
hooks::execute_hooks_with_mp(&actions, worktree_path, &repo_root, color_mode, " ", &mp)
{
errors.push(format!("{path}: {e}"));
}
if let Some(pb) = header_pb {
pb.set_style(ProgressStyle::with_template("{msg}").unwrap());
pb.finish_with_message(format!(
"{}",
color::success(color_mode, format!("Synced {label}"))
));
}
}
if !errors.is_empty() {
let n = errors.len();
for err in &errors {
hooks::emit_line(
&mp,
is_tty,
format!(" {}", color::warn(color_mode, format!("Error: {err}"))),
);
}
anyhow::bail!("Sync failed for {n} worktree(s)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::config::HookActions;
fn build_actions(run: bool, copy: bool, link: bool, create: &HookActions) -> HookActions {
let (do_run, do_copy, do_link) = if !run && !copy && !link {
(true, true, true)
} else {
(run, copy, link)
};
HookActions {
run: if do_run { create.run.clone() } else { vec![] },
copy: if do_copy { create.copy.clone() } else { vec![] },
link: if do_link { create.link.clone() } else { vec![] },
}
}
#[test]
fn test_no_flags_means_all_actions() {
let create = HookActions {
run: vec!["echo run".to_string()],
copy: vec!["file.txt".to_string()],
link: vec![".env".to_string()],
};
let actions = build_actions(false, false, false, &create);
assert_eq!(actions.run, create.run);
assert_eq!(actions.copy, create.copy);
assert_eq!(actions.link, create.link);
}
#[test]
fn test_link_only_flag() {
let create = HookActions {
run: vec!["echo run".to_string()],
copy: vec!["file.txt".to_string()],
link: vec![".env".to_string()],
};
let actions = build_actions(false, false, true, &create);
assert!(actions.run.is_empty());
assert!(actions.copy.is_empty());
assert_eq!(actions.link, create.link);
}
#[test]
fn test_run_copy_flags() {
let create = HookActions {
run: vec!["echo run".to_string()],
copy: vec!["file.txt".to_string()],
link: vec![".env".to_string()],
};
let actions = build_actions(true, true, false, &create);
assert_eq!(actions.run, create.run);
assert_eq!(actions.copy, create.copy);
assert!(actions.link.is_empty());
}
#[test]
fn test_all_flags_same_as_no_flags() {
let create = HookActions {
run: vec!["echo run".to_string()],
copy: vec!["file.txt".to_string()],
link: vec![".env".to_string()],
};
let all_flags = build_actions(true, true, true, &create);
let no_flags = build_actions(false, false, false, &create);
assert_eq!(all_flags.run, no_flags.run);
assert_eq!(all_flags.copy, no_flags.copy);
assert_eq!(all_flags.link, no_flags.link);
}
#[test]
fn test_empty_config_no_flag() {
let create = HookActions::default();
let actions = build_actions(false, false, false, &create);
assert!(actions.run.is_empty());
assert!(actions.copy.is_empty());
assert!(actions.link.is_empty());
}
#[test]
fn test_run_only_config_with_link_flag_yields_empty() {
let create = HookActions {
run: vec!["echo run".to_string()],
copy: vec![],
link: vec![],
};
let actions = build_actions(false, false, true, &create);
assert!(actions.run.is_empty());
assert!(actions.copy.is_empty());
assert!(actions.link.is_empty());
}
}