use anyhow::Result;
use crate::git::Git;
use crate::ui::Ui;
const SECTION: &str = "sync";
#[derive(Debug, Clone)]
pub struct Config {
pub protected: Vec<String>,
pub remotes: Option<Vec<String>>,
pub worktrunk: Option<bool>,
}
impl Default for Config {
fn default() -> Self {
Self {
protected: vec!["main".to_string(), "master".to_string()],
remotes: None,
worktrunk: None,
}
}
}
impl Config {
pub fn load(git: &Git) -> Result<Option<Self>> {
if !git.config_section_exists(SECTION)? {
return Ok(None);
}
let protected = git.config_get_all(&format!("{SECTION}.protected"))?;
let remotes = {
let vals = git.config_get_all(&format!("{SECTION}.remote"))?;
if vals.is_empty() { None } else { Some(vals) }
};
let worktrunk = git
.config_get(&format!("{SECTION}.worktrunk"))?
.map(|v| v.eq_ignore_ascii_case("true"));
Ok(Some(Self {
protected,
remotes,
worktrunk,
}))
}
pub fn save(&self, git: &Git) -> Result<()> {
git.config_unset_all(&format!("{SECTION}.protected"))?;
for pattern in &self.protected {
git.config_add(&format!("{SECTION}.protected"), pattern)?;
}
git.config_unset_all(&format!("{SECTION}.remote"))?;
if let Some(ref remotes) = self.remotes {
for remote in remotes {
git.config_add(&format!("{SECTION}.remote"), remote)?;
}
}
match self.worktrunk {
Some(val) => {
git.config_set(
&format!("{SECTION}.worktrunk"),
if val { "true" } else { "false" },
)?;
}
None => {
git.config_unset_all(&format!("{SECTION}.worktrunk"))?;
}
}
Ok(())
}
pub fn interactive_setup(git: &Git, ui: &Ui) -> Result<Self> {
ui.heading("No configuration found. Let's set up git-sync.");
ui.blank();
let branches = git.local_branches()?;
let well_known = ["main", "master", "develop", "development"];
if branches.is_empty() {
ui.warning("No local branches found.");
}
let defaults: Vec<bool> = branches
.iter()
.map(|b| well_known.contains(&b.as_str()))
.collect();
let mut protected: Vec<String> = if branches.is_empty() {
vec!["main".to_string()]
} else {
ui.multi_select(
"Which branches should be protected from deletion?",
&branches,
&branches,
&defaults,
)?
};
let extra = ui.input(
"Additional patterns to protect (comma-separated, e.g. release/*)",
"",
)?;
for pattern in extra.split(',').map(|s| s.trim()) {
if !pattern.is_empty() {
protected.push(pattern.to_string());
}
}
if protected.is_empty() {
protected.push("main".to_string());
ui.muted(" Defaulting to protecting 'main'.");
}
ui.blank();
let available_remotes = git.remotes()?;
let remotes = if available_remotes.is_empty() {
ui.muted("No remotes configured.");
None
} else {
let defaults: Vec<bool> = available_remotes.iter().map(|r| r == "origin").collect();
let selected = ui.multi_select(
"Which remotes should merged branches be deleted from?",
&available_remotes,
&available_remotes,
&defaults,
)?;
if selected.is_empty() {
None
} else {
Some(selected)
}
};
ui.blank();
let worktrunk = if crate::git::worktrunk_available() {
ui.blank();
let use_wt = ui.confirm(
"Worktrunk (wt) detected. Use it for worktree removal (triggers pre/post-remove hooks)?",
true,
)?;
Some(use_wt)
} else {
None
};
let config = Self {
protected,
remotes,
worktrunk,
};
config.save(git)?;
ui.success("Configuration saved to git config [sync] section.");
ui.blank();
Ok(config)
}
}
pub fn load_or_setup(git: &Git, ui: &Ui) -> Result<Config> {
match Config::load(git)? {
Some(config) => Ok(config),
None => Config::interactive_setup(git, ui),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
fn init_test_repo() -> (tempfile::TempDir, Git) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
StdCommand::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.unwrap();
std::fs::write(path.join("README.md"), "# test").unwrap();
StdCommand::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()
.unwrap();
let git = Git::with_workdir(false, path);
(dir, git)
}
#[test]
fn test_config_load_returns_none_when_not_configured() {
let (_dir, git) = init_test_repo();
let config = Config::load(&git).unwrap();
assert!(config.is_none());
}
#[test]
fn test_config_save_and_load_roundtrip() {
let (_dir, git) = init_test_repo();
let config = Config {
protected: vec!["main".to_string(), "release/*".to_string()],
remotes: Some(vec!["origin".to_string()]),
worktrunk: None,
};
config.save(&git).unwrap();
let loaded = Config::load(&git).unwrap().expect("config should exist");
assert_eq!(loaded.protected, config.protected);
assert_eq!(loaded.remotes, config.remotes);
assert_eq!(loaded.worktrunk, config.worktrunk);
}
#[test]
fn test_config_save_without_remotes() {
let (_dir, git) = init_test_repo();
let config = Config {
protected: vec!["main".to_string()],
remotes: None,
worktrunk: None,
};
config.save(&git).unwrap();
let loaded = Config::load(&git).unwrap().expect("config should exist");
assert!(loaded.remotes.is_none());
}
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.protected, vec!["main", "master"]);
assert!(config.remotes.is_none());
assert!(config.worktrunk.is_none());
}
#[test]
fn test_config_save_overwrites_previous() {
let (_dir, git) = init_test_repo();
let config1 = Config {
protected: vec!["main".to_string()],
remotes: Some(vec!["origin".to_string()]),
worktrunk: Some(true),
};
config1.save(&git).unwrap();
let config2 = Config {
protected: vec!["develop".to_string(), "release/*".to_string()],
remotes: Some(vec!["upstream".to_string()]),
worktrunk: Some(false),
};
config2.save(&git).unwrap();
let loaded = Config::load(&git).unwrap().expect("config should exist");
assert_eq!(loaded.protected, vec!["develop", "release/*"]);
assert_eq!(loaded.remotes, Some(vec!["upstream".to_string()]));
assert_eq!(loaded.worktrunk, Some(false));
}
#[test]
fn test_config_worktrunk_roundtrip() {
let (_dir, git) = init_test_repo();
let config = Config {
protected: vec!["main".to_string()],
remotes: None,
worktrunk: Some(true),
};
config.save(&git).unwrap();
let loaded = Config::load(&git).unwrap().expect("config should exist");
assert_eq!(loaded.worktrunk, Some(true));
let config2 = Config {
protected: vec!["main".to_string()],
remotes: None,
worktrunk: Some(false),
};
config2.save(&git).unwrap();
let loaded = Config::load(&git).unwrap().expect("config should exist");
assert_eq!(loaded.worktrunk, Some(false));
let config3 = Config {
protected: vec!["main".to_string()],
remotes: None,
worktrunk: None,
};
config3.save(&git).unwrap();
let loaded = Config::load(&git).unwrap().expect("config should exist");
assert!(loaded.worktrunk.is_none());
}
#[test]
fn test_load_or_setup_returns_existing_config() {
let (_dir, git) = init_test_repo();
let config = Config {
protected: vec!["main".to_string()],
remotes: None,
worktrunk: None,
};
config.save(&git).unwrap();
let ui = Ui::new();
let loaded = load_or_setup(&git, &ui).unwrap();
assert_eq!(loaded.protected, vec!["main"]);
assert!(loaded.remotes.is_none());
}
}