Skip to main content

worktree_io/
repo_hooks.rs

1use std::path::Path;
2
3/// Declares how a per-repo hook relates to the matching global hook.
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
5pub enum HookOrder {
6    /// Run the per-repo hook first, then the global hook.
7    #[default]
8    Before,
9    /// Run the global hook first, then the per-repo hook.
10    After,
11    /// Disable the global hook entirely; run only the per-repo hook.
12    Replace,
13}
14
15/// A single per-repo hook with its script and ordering relationship to the
16/// global hook.
17#[derive(Debug, Clone)]
18pub struct RepoHookEntry {
19    /// The hook script. Supports the same Mustache-style placeholders as
20    /// global hooks: `{{owner}}`, `{{repo}}`, `{{issue}}`, `{{branch}}`,
21    /// `{{worktree_path}}`.
22    pub script: String,
23    /// How this hook relates to the global hook. Defaults to [`HookOrder::Before`].
24    pub order: HookOrder,
25}
26
27/// Per-repo hooks configuration parsed from `.worktree.toml`.
28#[derive(Debug, Clone, Default)]
29pub struct RepoHooksConfig {
30    /// Script run before the workspace is opened.
31    pub pre_open: Option<RepoHookEntry>,
32    /// Script run after the workspace is opened.
33    pub post_open: Option<RepoHookEntry>,
34}
35
36/// Per-repository configuration loaded from `.worktree.toml` in the worktree
37/// root.
38///
39/// The file is version-controlled alongside the repo so that every developer
40/// who uses `worktree-io` gets the same lifecycle hooks automatically.
41#[derive(Debug, Clone, Default)]
42pub struct RepoConfig {
43    /// Lifecycle hooks scoped to this repository.
44    pub hooks: RepoHooksConfig,
45}
46
47impl RepoConfig {
48    /// Load `.worktree.toml` from `worktree_path`.
49    ///
50    /// Returns `None` when the file is missing. When the file exists but
51    /// cannot be parsed, prints a warning to stderr and also returns `None`
52    /// so the caller falls back to global-only behavior.
53    #[must_use]
54    pub fn load_from(worktree_path: &Path) -> Option<Self> {
55        let path = worktree_path.join(".worktree.toml");
56        let contents = std::fs::read_to_string(&path).ok()?;
57        match crate::repo_hooks_parse::parse(&contents) {
58            Ok(cfg) => Some(cfg),
59            Err(e) => {
60                eprintln!("warning: ignoring {}: {e}", path.display());
61                None
62            }
63        }
64    }
65}
66
67/// Combine a global hook script with an optional per-repo hook entry into a
68/// single effective script.
69///
70/// | global  | repo entry | result                              |
71/// |---------|------------|-------------------------------------|
72/// | `None`  | `None`     | `None`                              |
73/// | `Some`  | `None`     | global only                         |
74/// | `None`  | `Some`     | repo script only                    |
75/// | `Some`  | `Some`     | ordered per `entry.order`           |
76///
77/// When both sides are present the ordering rules apply:
78/// * `before` — repo script, newline, global script
79/// * `after`  — global script, newline, repo script
80/// * `replace` — repo script only (global is suppressed)
81#[must_use]
82pub fn combined_script(global: Option<&str>, repo_entry: Option<&RepoHookEntry>) -> Option<String> {
83    match (global, repo_entry) {
84        (None, None) => None,
85        (Some(g), None) => Some(g.to_owned()),
86        (None, Some(r)) => Some(r.script.clone()),
87        (Some(g), Some(r)) => Some(match r.order {
88            HookOrder::Before => format!("{}\n{}", r.script, g),
89            HookOrder::After => format!("{}\n{}", g, r.script),
90            HookOrder::Replace => r.script.clone(),
91        }),
92    }
93}
94
95#[cfg(test)]
96#[path = "repo_hooks_load_tests.rs"]
97mod load_tests;
98#[cfg(test)]
99#[path = "repo_hooks_tests.rs"]
100mod tests;