Skip to main content

worktree_io/
repo_hooks.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5/// Declares how a per-repo hook relates to the matching global hook.
6#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum HookOrder {
9    /// Run the per-repo hook first, then the global hook.
10    #[default]
11    Before,
12    /// Run the global hook first, then the per-repo hook.
13    After,
14    /// Disable the global hook entirely; run only the per-repo hook.
15    Replace,
16}
17
18/// A single per-repo hook with its script and its ordering relationship to the
19/// global hook.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RepoHookEntry {
22    /// The hook script. Supports the same Mustache-style placeholders as
23    /// global hooks: `{{owner}}`, `{{repo}}`, `{{issue}}`, `{{branch}}`,
24    /// `{{worktree_path}}`.
25    pub script: String,
26    /// How this hook relates to the global hook. Defaults to `before`.
27    #[serde(default)]
28    pub order: HookOrder,
29}
30
31/// Per-repo hooks configuration from `.worktree.toml`.
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct RepoHooksConfig {
34    /// Script run before the workspace is opened.
35    #[serde(rename = "pre:open", skip_serializing_if = "Option::is_none", default)]
36    pub pre_open: Option<RepoHookEntry>,
37    /// Script run after the workspace is opened.
38    #[serde(rename = "post:open", skip_serializing_if = "Option::is_none", default)]
39    pub post_open: Option<RepoHookEntry>,
40}
41
42/// Per-repository configuration loaded from `.worktree.toml` in the worktree
43/// root.
44///
45/// The file is version-controlled alongside the repo so that every developer
46/// who uses `worktree-io` gets the same lifecycle hooks automatically.
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48#[serde(default)]
49pub struct RepoConfig {
50    /// Lifecycle hooks scoped to this repository.
51    pub hooks: RepoHooksConfig,
52}
53
54impl RepoConfig {
55    /// Load `.worktree.toml` from `worktree_path`.
56    ///
57    /// Returns `None` if the file does not exist or cannot be parsed, so the
58    /// caller can safely fall back to global-only behavior.
59    #[must_use]
60    pub fn load_from(worktree_path: &Path) -> Option<Self> {
61        let path = worktree_path.join(".worktree.toml");
62        let contents = std::fs::read_to_string(path).ok()?;
63        toml::from_str(&contents).ok()
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;