use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
pub const CONFIG_FILE: &str = ".gwx.toml";
const SUPPORTED_VERSIONS: &[&str] = &["1", "1.0"];
pub const DEFAULT_BASE_DIR: &str = "../worktrees";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub hooks: Hooks,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Defaults {
#[serde(default = "default_base_dir")]
pub base_dir: String,
}
impl Default for Defaults {
fn default() -> Self {
Self {
base_dir: default_base_dir(),
}
}
}
fn default_base_dir() -> String {
DEFAULT_BASE_DIR.to_string()
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hooks {
#[serde(default)]
pub pre_create: Vec<Hook>,
#[serde(default)]
pub post_create: Vec<Hook>,
#[serde(default)]
pub pre_remove: Vec<Hook>,
#[serde(default)]
pub post_remove: Vec<Hook>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Hook {
Copy { from: String, to: Option<String> },
Symlink { from: String, to: Option<String> },
Command {
command: String,
#[serde(default)]
env: BTreeMap<String, String>,
work_dir: Option<String>,
},
}
impl Hook {
pub fn kind(&self) -> &'static str {
match self {
Hook::Copy { .. } => "copy",
Hook::Symlink { .. } => "symlink",
Hook::Command { .. } => "command",
}
}
pub fn summary(&self) -> String {
match self {
Hook::Copy { from, to } => format!("copy {from} -> {}", to.as_deref().unwrap_or(from)),
Hook::Symlink { from, to } => {
format!("symlink {from} -> {}", to.as_deref().unwrap_or(from))
}
Hook::Command { command, .. } => command.clone(),
}
}
}
impl Config {
pub fn path_in(main_worktree: &Path) -> PathBuf {
main_worktree.join(CONFIG_FILE)
}
pub fn load(main_worktree: &Path) -> Result<Self> {
let path = Self::path_in(main_worktree);
match std::fs::read_to_string(&path) {
Ok(text) => {
Self::parse(&text).with_context(|| format!("failed to parse {}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?;
if let Some(version) = &config.version {
if !SUPPORTED_VERSIONS.contains(&version.as_str()) {
bail!(
"unsupported config version `{version}` (this build understands {})",
SUPPORTED_VERSIONS.join(", ")
);
}
}
Ok(config)
}
pub fn base_dir(&self, main_worktree: &Path) -> PathBuf {
let base = Path::new(&self.defaults.base_dir);
if base.is_absolute() {
base.to_path_buf()
} else {
normalize(&main_worktree.join(base))
}
}
}
pub fn normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub const TEMPLATE: &str = r#"# gwx configuration
# Docs: https://github.com/ktakada42/gwx
version = "1"
[defaults]
# Where worktrees are created, relative to the main worktree.
base_dir = "../worktrees"
# Hooks run before the worktree is created (command hooks only, run in the
# main worktree).
# [[hooks.pre_create]]
# type = "command"
# command = "echo creating $GWX_BRANCH"
# Hooks run after the worktree is created, inside the new worktree.
# [[hooks.post_create]]
# type = "copy"
# from = ".env"
# to = ".env"
#
# `symlink` shares one directory with the main worktree; `copy` gives the
# worktree its own, keeps symlinks as symlinks, and on macOS clones the tree
# rather than duplicating it.
# [[hooks.post_create]]
# type = "symlink"
# from = "node_modules"
#
# [[hooks.post_create]]
# type = "command"
# command = "npm install"
# work_dir = "."
# env = { NODE_ENV = "development" }
# Hooks run before the worktree is removed, inside it (command hooks only).
# A failing one calls the removal off.
# [[hooks.pre_remove]]
# type = "command"
# command = "docker compose down"
# Hooks run after the worktree is gone (command hooks only, run in the main
# worktree). GWX_WORKTREE_PATH still names the directory that was removed.
# [[hooks.post_remove]]
# type = "command"
# command = "rm -rf \"$GWX_MAIN_WORKTREE/.cache/$GWX_WORKTREE_NAME\""
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_when_empty() {
let cfg = Config::parse("").unwrap();
assert_eq!(cfg.defaults.base_dir, DEFAULT_BASE_DIR);
assert!(cfg.hooks.pre_create.is_empty());
assert!(cfg.hooks.post_create.is_empty());
}
#[test]
fn parses_all_hook_types() {
let cfg = Config::parse(
r#"
version = "1"
[defaults]
base_dir = "../wt"
[[hooks.pre_create]]
type = "command"
command = "echo hi"
[[hooks.post_create]]
type = "copy"
from = ".env"
[[hooks.post_create]]
type = "symlink"
from = "node_modules"
to = "node_modules"
[[hooks.post_create]]
type = "command"
command = "npm ci"
work_dir = "app"
env = { NODE_ENV = "development" }
"#,
)
.unwrap();
assert_eq!(cfg.defaults.base_dir, "../wt");
assert_eq!(cfg.hooks.pre_create.len(), 1);
assert_eq!(cfg.hooks.post_create.len(), 3);
assert_eq!(cfg.hooks.post_create[0].kind(), "copy");
assert_eq!(cfg.hooks.post_create[1].kind(), "symlink");
match &cfg.hooks.post_create[2] {
Hook::Command { env, work_dir, .. } => {
assert_eq!(env.get("NODE_ENV").map(String::as_str), Some("development"));
assert_eq!(work_dir.as_deref(), Some("app"));
}
other => panic!("unexpected hook: {other:?}"),
}
}
#[test]
fn rejects_unknown_versions() {
assert!(Config::parse("version = \"2\"").is_err());
assert!(Config::parse("version = \"1.0\"").is_ok());
}
#[test]
fn rejects_unknown_keys() {
assert!(Config::parse("[defaults]\nbasedir = \"x\"").is_err());
}
#[test]
fn template_is_valid() {
Config::parse(TEMPLATE).unwrap();
}
#[test]
fn base_dir_is_resolved_against_the_main_worktree() {
let cfg = Config::parse("").unwrap();
assert_eq!(
cfg.base_dir(Path::new("/home/me/repo")),
PathBuf::from("/home/me/worktrees")
);
let cfg = Config::parse("[defaults]\nbase_dir = \"/tmp/wt\"").unwrap();
assert_eq!(
cfg.base_dir(Path::new("/home/me/repo")),
PathBuf::from("/tmp/wt")
);
}
#[test]
fn normalize_resolves_dots() {
assert_eq!(
normalize(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
assert_eq!(normalize(Path::new("../a")), PathBuf::from("../a"));
}
}