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;