git-bra 0.4.0

A Git worktree manager with project-aware configuration.
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    pub worktree_destination: Option<PathBuf>,
    pub branch_separator: Option<String>,
    #[serde(default)]
    pub project_prefix: bool,
    #[serde(default)]
    pub scripts: HashMap<String, Vec<ScriptEntry>>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScriptEntry {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
}

impl Config {
    pub fn load() -> Result<Self> {
        let path = config_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }

        let raw = fs::read_to_string(&path)
            .with_context(|| format!("failed to read config at {}", path.display()))?;
        let config: Self = toml::from_str(&raw)
            .with_context(|| format!("failed to parse config at {}", path.display()))?;
        config.validate()?;
        Ok(config)
    }

    pub fn save(&self) -> Result<()> {
        self.validate()?;

        let path = config_path()?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!("failed to create config directory {}", parent.display())
            })?;
        }

        let serialized = toml::to_string_pretty(self).context("failed to serialize config")?;
        let parent = path
            .parent()
            .ok_or_else(|| anyhow!("invalid config path {}", path.display()))?;

        let mut temp = NamedTempFile::new_in(parent)
            .with_context(|| format!("failed to create temp file in {}", parent.display()))?;
        use std::io::Write;
        temp.write_all(serialized.as_bytes())
            .context("failed to write temporary config file")?;
        temp.persist(&path)
            .map_err(|err| err.error)
            .with_context(|| format!("failed to persist config to {}", path.display()))?;
        Ok(())
    }

    pub fn validate(&self) -> Result<()> {
        if let Some(separator) = &self.branch_separator
            && separator.is_empty()
        {
            bail!("branch_separator cannot be empty")
        }

        if let Some(destination) = &self.worktree_destination {
            let expanded = expand_path(destination);
            if !expanded.is_absolute() {
                bail!("worktree_destination must resolve to an absolute path")
            }
        }

        for (project, scripts) in &self.scripts {
            let mut seen = std::collections::HashSet::new();
            for script in scripts {
                if script.name.trim().is_empty() {
                    bail!("script name for project '{project}' cannot be empty")
                }
                if !seen.insert(script.name.clone()) {
                    bail!("duplicate script '{}' for project '{project}'", script.name)
                }
                match (&script.path, &script.text) {
                    (Some(_), Some(_)) => bail!(
                        "script '{}' for project '{project}' cannot have both path and text",
                        script.name
                    ),
                    (None, None) => bail!(
                        "script '{}' for project '{project}' must have either a path or text",
                        script.name
                    ),
                    (None, Some(text)) if text.trim().is_empty() => bail!(
                        "script '{}' for project '{project}' text cannot be empty",
                        script.name
                    ),
                    _ => {}
                }
            }
        }

        Ok(())
    }

    pub fn worktree_destination(&self) -> Result<PathBuf> {
        let path = self
            .worktree_destination
            .as_ref()
            .ok_or_else(|| anyhow!("worktree_destination is not configured"))?;
        Ok(expand_path(path))
    }

    pub fn scripts_for_project(&self, project: &str) -> &[ScriptEntry] {
        self.scripts.get(project).map(Vec::as_slice).unwrap_or(&[])
    }

    pub fn add_script(&mut self, project: &str, script: ScriptEntry) -> Result<()> {
        let scripts = self.scripts.entry(project.to_owned()).or_default();
        if scripts.iter().any(|existing| existing.name == script.name) {
            bail!(
                "script '{}' already exists for project '{project}'",
                script.name
            );
        }
        scripts.push(script);
        scripts.sort_by(|a, b| a.name.cmp(&b.name));
        Ok(())
    }

    pub fn remove_script(&mut self, project: &str, name: &str) -> Result<()> {
        let scripts = self
            .scripts
            .get_mut(project)
            .ok_or_else(|| anyhow!("project '{project}' has no configured scripts"))?;
        let before = scripts.len();
        scripts.retain(|script| script.name != name);
        if scripts.len() == before {
            bail!("script '{name}' does not exist for project '{project}'")
        }
        if scripts.is_empty() {
            self.scripts.remove(project);
        }
        Ok(())
    }
}

pub fn default_config_template() -> &'static str {
    r#"# bra configuration

# Base directory for project worktrees.
# worktree_destination = "/home/you/worktrees"

# Nest worktrees under a per-project alias directory.
# project_prefix = true

# Optional separator used to flatten branch names.
# branch_separator = "_"

# Project scripts keyed by project alias.
#
# [[scripts.my-project]]
# name = "bootstrap"
# path = "~/bin/bootstrap-my-project.sh"
#
# [[scripts.my-project]]
# name = "install"
# text = "bun install"
"#
}

pub fn config_path() -> Result<PathBuf> {
    if let Ok(path) = std::env::var("BRA_CONFIG") {
        return Ok(PathBuf::from(path));
    }

    let dirs =
        BaseDirs::new().ok_or_else(|| anyhow!("failed to determine XDG config directory"))?;
    Ok(dirs.config_dir().join("bra").join("config.toml"))
}

pub fn expand_path(path: &Path) -> PathBuf {
    let raw = path.to_string_lossy();
    if raw == "~"
        && let Ok(home) = std::env::var("HOME")
    {
        return PathBuf::from(home);
    }
    if let Some(stripped) = raw.strip_prefix("~/")
        && let Ok(home) = std::env::var("HOME")
    {
        return PathBuf::from(home).join(stripped);
    }
    path.to_path_buf()
}

pub fn flatten_branch(branch: &str, separator: Option<&str>) -> String {
    match separator {
        Some(separator) => branch.replace('/', separator),
        None => branch.to_owned(),
    }
}

pub fn worktree_path(config: &Config, alias: &str, branch: &str) -> Result<PathBuf> {
    let destination = config.worktree_destination()?;
    let flattened = flatten_branch(branch, config.branch_separator.as_deref());
    let base = if config.project_prefix {
        destination.join(alias)
    } else {
        destination
    };
    Ok(base.join(flattened))
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    #[test]
    fn flattens_branch_name_when_separator_is_set() {
        assert_eq!(flatten_branch("feature/test", Some("-")), "feature-test");
    }

    #[test]
    fn keeps_branch_name_nested_without_separator() {
        assert_eq!(flatten_branch("feature/test", None), "feature/test");
    }

    #[test]
    fn expands_home_prefix() {
        let home = std::env::var("HOME").expect("HOME should be set in tests");
        let probe = PathBuf::from(home).join("bra-test-home-child");
        assert_eq!(expand_path(Path::new("~/bra-test-home-child")), probe);
    }

    #[test]
    fn worktree_path_flattens_branch_component() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("worktrees-root");
        let config = Config {
            worktree_destination: Some(root.clone()),
            branch_separator: Some("-".to_owned()),
            project_prefix: false,
            scripts: HashMap::new(),
        };

        assert_eq!(
            worktree_path(&config, "my-project", "feature/test").unwrap(),
            root.join("feature-test")
        );
    }

    #[test]
    fn worktree_path_can_prefix_project_alias() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("worktrees-root");
        let config = Config {
            worktree_destination: Some(root.clone()),
            branch_separator: Some("-".to_owned()),
            project_prefix: true,
            scripts: HashMap::new(),
        };

        assert_eq!(
            worktree_path(&config, "my-project", "feature/test").unwrap(),
            root.join("my-project").join("feature-test")
        );
    }

    #[test]
    fn rejects_duplicate_script_names() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("worktrees-root");
        let script_a = temp.path().join("script-a");
        let script_b = temp.path().join("script-b");
        let config = Config {
            worktree_destination: Some(root),
            branch_separator: None,
            project_prefix: false,
            scripts: HashMap::from([(
                "my-project".to_owned(),
                vec![
                    ScriptEntry {
                        name: "setup".to_owned(),
                        path: Some(script_a),
                        text: None,
                    },
                    ScriptEntry {
                        name: "setup".to_owned(),
                        path: Some(script_b),
                        text: None,
                    },
                ],
            )]),
        };

        assert!(config.validate().is_err());
    }

    #[test]
    fn rejects_script_with_path_and_text() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("worktrees-root");
        let script = temp.path().join("script");
        let config = Config {
            worktree_destination: Some(root),
            branch_separator: None,
            project_prefix: false,
            scripts: HashMap::from([(
                "my-project".to_owned(),
                vec![ScriptEntry {
                    name: "setup".to_owned(),
                    path: Some(script),
                    text: Some("echo setup".to_owned()),
                }],
            )]),
        };

        assert!(config.validate().is_err());
    }

    #[test]
    fn rejects_script_without_path_or_text() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("worktrees-root");
        let config = Config {
            worktree_destination: Some(root),
            branch_separator: None,
            project_prefix: false,
            scripts: HashMap::from([(
                "my-project".to_owned(),
                vec![ScriptEntry {
                    name: "setup".to_owned(),
                    path: None,
                    text: None,
                }],
            )]),
        };

        assert!(config.validate().is_err());
    }

    #[test]
    fn rejects_blank_inline_script_text() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("worktrees-root");
        let config = Config {
            worktree_destination: Some(root),
            branch_separator: None,
            project_prefix: false,
            scripts: HashMap::from([(
                "my-project".to_owned(),
                vec![ScriptEntry {
                    name: "setup".to_owned(),
                    path: None,
                    text: Some(" \n\t".to_owned()),
                }],
            )]),
        };

        assert!(config.validate().is_err());
    }

    #[test]
    fn default_config_path_uses_xdg_location() {
        let path = config_path().unwrap();
        assert_eq!(
            path.file_name().and_then(|name| name.to_str()),
            Some("config.toml")
        );
        assert_eq!(
            path.parent()
                .and_then(|parent| parent.file_name())
                .and_then(|name| name.to_str()),
            Some("bra")
        );
    }
}