Skip to main content

majordomus_cli/worktree/
hooks.rs

1//! Each linked worktree runs the hooks of its own tree.
2//!
3//! A repository that tracks its hooks (`.githooks/`, named by `core.hooksPath`) means every
4//! commit to be judged by the hooks of the commit's own tree. Git does not guarantee that
5//! across worktrees: `core.hooksPath` lives in the configuration every worktree shares, and
6//! once it holds an absolute path — `<primary>/.githooks`, written by hand or by a tool that
7//! made the value absolute — every linked worktree runs the primary checkout's hooks. When
8//! the primary checkout is days behind, those hooks are days behind, and on 2026-10-14 the
9//! stale `doctor` they ran refused every commit of current master in every worktree, saying
10//! that a gate was "not invoked by `<primary>/.githooks/pre-commit`".
11//!
12//! The remedy is git's own: per-worktree configuration. With `extensions.worktreeConfig`
13//! on, `git config --worktree core.hooksPath <worktree>/.githooks` binds one worktree to its
14//! own directory, and nothing else moves. [`carry_hooks_path`] does that for a worktree this module
15//! creates, finds or moves, and only when it is needed: the policy is read from the primary
16//! checkout (its effective `core.hooksPath`), the directory it names inside the primary is
17//! looked for inside the worktree, and git's own resolution (`rev-parse --git-path hooks`)
18//! decides whether the worktree already runs it. A repository with no `core.hooksPath` keeps
19//! git's default hooks directory and gets nothing invented; a hooks directory outside the
20//! primary checkout is a shared one on purpose and is left alone; a worktree whose tree does
21//! not carry the directory is left as it is, because pointing it at nothing would switch its
22//! hooks off.
23//!
24//! Every outcome is reported and none is fatal: a worktree that exists is still a worktree,
25//! and the report says, before the first commit does, which hooks it runs.
26
27use std::path::{Path, PathBuf};
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use super::git;
33use super::identity::ResolvedPath;
34use super::service::display;
35
36/// What became of the hooks directory of a worktree that was just created, found or moved.
37///
38/// It travels in the JSON of `worktree create`, `ensure`, `migrate` and `repair`, tagged by
39/// `outcome`, so a reader branches on one word rather than parsing the line:
40///
41/// ```
42/// use majordomus_cli::worktree::HooksPath;
43///
44/// let set = HooksPath::Set {
45///     path: "/r-wt/fix/x/.githooks".into(),
46///     enabled: vec![],
47/// };
48/// let json = serde_json::to_value(&set).unwrap();
49/// assert_eq!(json["outcome"], "set");
50/// assert_eq!(json["path"], "/r-wt/fix/x/.githooks");
51/// let back: HooksPath = serde_json::from_value(json).unwrap();
52/// assert_eq!(back, set);
53/// ```
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "snake_case", tag = "outcome")]
56pub enum HooksPath {
57    /// The repository's configuration sets no `core.hooksPath`: git's own hooks directory
58    /// in the common directory (or a value of the user's) serves every worktree, and there
59    /// is no tracked directory to follow.
60    NotConfigured,
61    /// Git already resolves this worktree's hooks to its own directory; nothing was set.
62    Own {
63        /// The directory git runs the hooks from.
64        path: String,
65    },
66    /// This worktree's `core.hooksPath` was set, per worktree, to its own directory.
67    Set {
68        /// The directory it now runs the hooks from.
69        path: String,
70        /// Repository settings switched on so that a per-worktree value is honoured, as
71        /// `key=value`; empty when they were on already.
72        enabled: Vec<String>,
73    },
74    /// The hooks directory is outside the primary checkout — one directory shared on
75    /// purpose — and every worktree runs it; nothing was set.
76    Shared {
77        /// The directory every worktree runs the hooks from.
78        path: String,
79    },
80    /// The primary checkout's hooks directory has no counterpart in this worktree's tree,
81    /// so the worktree keeps running the primary's: pointing it at a directory that does
82    /// not exist would switch its hooks off.
83    Untracked {
84        /// The directory that would be the worktree's own, and is not there.
85        missing: String,
86        /// The directory the worktree runs the hooks from instead.
87        runs: String,
88    },
89    /// Git was asked and refused, or could not be run; the message is its own.
90    Failed {
91        /// What went wrong.
92        message: String,
93    },
94}
95
96impl HooksPath {
97    /// One line for a person, after the path the worktree is at.
98    ///
99    /// ```
100    /// use majordomus_cli::worktree::HooksPath;
101    ///
102    /// let set = HooksPath::Set {
103    ///     path: "/r-wt/fix/x/.githooks".into(),
104    ///     enabled: vec!["extensions.worktreeConfig=true".into()],
105    /// };
106    /// assert_eq!(
107    ///     set.describe(),
108    ///     "hooks   /r-wt/fix/x/.githooks, its own (git config --worktree core.hooksPath; \
109    ///      enabled extensions.worktreeConfig=true)"
110    /// );
111    /// assert!(HooksPath::NotConfigured.describe().contains("no core.hooksPath"));
112    /// ```
113    pub fn describe(&self) -> String {
114        match self {
115            Self::NotConfigured => {
116                "hooks   no core.hooksPath in this repository; nothing tracked to follow".into()
117            }
118            Self::Own { path } => format!("hooks   {path}, its own"),
119            Self::Set { path, enabled } if enabled.is_empty() => {
120                format!("hooks   {path}, its own (git config --worktree core.hooksPath)")
121            }
122            Self::Set { path, enabled } => format!(
123                "hooks   {path}, its own (git config --worktree core.hooksPath; enabled {})",
124                enabled.join(", ")
125            ),
126            Self::Shared { path } => format!("hooks   {path}, shared outside the checkouts"),
127            Self::Untracked { missing, runs } => {
128                format!("hooks   {runs}, the primary checkout's: {missing} is not in this tree")
129            }
130            Self::Failed { message } => format!("hooks   not set to its own: {message}"),
131        }
132    }
133}
134
135/// Bind `worktree` to the hooks directory of its own tree, when the repository tracks one
136/// and git would otherwise run another worktree's.
137///
138/// `primary` is the primary checkout, whose effective `core.hooksPath` is the repository's
139/// policy. Never fails: the worst outcome is a worktree left as git left it, reported. The
140/// primary checkout itself is answered with what it runs and never changed.
141///
142/// ```
143/// use std::process::Command;
144/// use majordomus_cli::worktree::{carry_hooks_path, HooksPath};
145///
146/// let dir = tempfile::tempdir().unwrap();
147/// let r = dir.path().join("r");
148/// let wt = dir.path().join("r-wt").join("x");
149/// let git = |cwd: &std::path::Path, args: &[&str]| {
150///     assert!(Command::new("git").arg("-C").arg(cwd).args(args).status().unwrap().success());
151/// };
152/// std::fs::create_dir_all(r.join(".githooks")).unwrap();
153/// std::fs::write(r.join(".githooks/pre-commit"), "#!/bin/sh\n").unwrap();
154/// git(dir.path(), &["init", "-q", r.to_str().unwrap()]);
155/// git(&r, &["add", "."]);
156/// git(&r, &["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "hooks"]);
157/// // The value that sent every worktree to the primary's hooks: absolute, shared.
158/// let primary_hooks = r.join(".githooks");
159/// git(&r, &["config", "core.hooksPath", primary_hooks.to_str().unwrap()]);
160/// git(&r, &["worktree", "add", "-q", "-b", "x", wt.to_str().unwrap()]);
161///
162/// match carry_hooks_path(&r, &wt) {
163///     HooksPath::Set { path, .. } => assert!(path.ends_with("r-wt/x/.githooks")),
164///     other => panic!("expected the worktree's own hooks to be set, got {other:?}"),
165/// }
166/// // A second call finds it done.
167/// assert!(matches!(carry_hooks_path(&r, &wt), HooksPath::Own { .. }));
168/// ```
169pub fn carry_hooks_path(primary: &Path, worktree: &Path) -> HooksPath {
170    match carry_inner(primary, worktree) {
171        Ok(outcome) => outcome,
172        Err(message) => HooksPath::Failed { message },
173    }
174}
175
176fn carry_inner(primary: &Path, worktree: &Path) -> Result<HooksPath, String> {
177    let resolved_primary = ResolvedPath::of(primary);
178    let resolved_worktree = ResolvedPath::of(worktree);
179    let Some(policy) = repository_policy(primary)? else {
180        return Ok(HooksPath::NotConfigured);
181    };
182    let runs = hooks_dir(worktree)?;
183    if resolved_worktree.same_as(&resolved_primary) {
184        return Ok(HooksPath::Own {
185            path: display(&runs),
186        });
187    }
188    // The tracked directory, relative to the checkout that holds it. A relative policy is
189    // that already; an absolute one counts only when it names a directory of the primary
190    // checkout — anything else is a hooks directory shared on purpose.
191    let relative = match PathBuf::from(&policy) {
192        p if p.is_relative() => p,
193        p => match ResolvedPath::of(&p)
194            .real
195            .strip_prefix(&resolved_primary.real)
196        {
197            Ok(rest) if ResolvedPath::of(&p).is_inside(&resolved_primary) => rest.to_path_buf(),
198            _ => {
199                return Ok(HooksPath::Shared {
200                    path: display(&runs),
201                })
202            }
203        },
204    };
205    let own = worktree.join(&relative);
206    if ResolvedPath::of(&runs).same_as(&ResolvedPath::of(&own)) {
207        return Ok(HooksPath::Own {
208            path: display(&own),
209        });
210    }
211    if !own.is_dir() {
212        return Ok(HooksPath::Untracked {
213            missing: display(&own),
214            runs: display(&runs),
215        });
216    }
217    let enabled = enable_worktree_config(primary)?;
218    let own_text = display(&own);
219    git::run(
220        worktree,
221        &["config", "--worktree", "core.hooksPath", own_text.as_str()],
222    )
223    .map_err(|e| e.to_string())?;
224    // Verify rather than assume: the answer is what git now resolves.
225    let now = hooks_dir(worktree)?;
226    if !ResolvedPath::of(&now).same_as(&ResolvedPath::of(&own)) {
227        return Err(format!(
228            "set core.hooksPath to {own_text} for this worktree and git still resolves {}",
229            display(&now)
230        ));
231    }
232    Ok(HooksPath::Set {
233        path: own_text,
234        enabled,
235    })
236}
237
238/// Switch on what a per-worktree value needs, and answer what was switched on. Version 1
239/// first: extensions are a version-1 repository's, and a tool that reads a version-0
240/// repository is entitled to ignore them.
241fn enable_worktree_config(primary: &Path) -> Result<Vec<String>, String> {
242    let mut enabled = Vec::new();
243    let on = config_value(
244        primary,
245        &[
246            "config",
247            "--local",
248            "--type=bool",
249            "--get",
250            "extensions.worktreeConfig",
251        ],
252    )?;
253    if on.as_deref() == Some("true") {
254        return Ok(enabled);
255    }
256    // `core.worktree` in the shared file would, once per-worktree files are read, be read
257    // by every worktree; git's own documentation says to move it first, and that is a
258    // person's decision, not this command's.
259    if let Some(value) = config_value(primary, &["config", "--local", "--get", "core.worktree"])? {
260        return Err(format!(
261            "core.worktree is set in the shared configuration ({value}); enabling \
262             extensions.worktreeConfig would apply it to every worktree, so nothing was changed"
263        ));
264    }
265    let version = config_value(
266        primary,
267        &["config", "--local", "--get", "core.repositoryformatversion"],
268    )?;
269    if matches!(version.as_deref(), None | Some("0")) {
270        git::run(
271            primary,
272            &["config", "--local", "core.repositoryformatversion", "1"],
273        )
274        .map_err(|e| e.to_string())?;
275        enabled.push("core.repositoryformatversion=1".into());
276    }
277    git::run(
278        primary,
279        &["config", "--local", "extensions.worktreeConfig", "true"],
280    )
281    .map_err(|e| e.to_string())?;
282    enabled.push("extensions.worktreeConfig=true".into());
283    Ok(enabled)
284}
285
286/// The primary checkout's effective `core.hooksPath`, when the repository sets it: its own
287/// per-worktree value, else the shared one. A value from the user's or the system's
288/// configuration is not the repository's policy and is answered as none.
289fn repository_policy(primary: &Path) -> Result<Option<String>, String> {
290    let Some(line) = config_value(
291        primary,
292        &["config", "--show-scope", "--get", "core.hooksPath"],
293    )?
294    else {
295        return Ok(None);
296    };
297    Ok(policy_of(&line))
298}
299
300/// The repository's policy in one `--show-scope` line (`<scope>\t<value>`): the value when
301/// the repository's own file or the worktree's set it, and non-empty; none otherwise.
302fn policy_of(line: &str) -> Option<String> {
303    match line.split_once('\t') {
304        Some(("local" | "worktree", value)) if !value.is_empty() => Some(value.to_string()),
305        _ => None,
306    }
307}
308
309/// The hooks directory git runs for `worktree`, as an absolute path.
310fn hooks_dir(worktree: &Path) -> Result<PathBuf, String> {
311    let out = git::run(
312        worktree,
313        &["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
314    )
315    .map_err(|e| e.to_string())?;
316    Ok(PathBuf::from(out.text().map_err(|e| e.to_string())?))
317}
318
319/// A configuration value, `None` when it is not set (git's exit 1), an error otherwise.
320fn config_value(cwd: &Path, args: &[&str]) -> Result<Option<String>, String> {
321    let out = git::try_run(cwd, args).map_err(|e| e.to_string())?;
322    match out.status {
323        Some(0) => {
324            let v = out.text().map_err(|e| e.to_string())?;
325            Ok((!v.is_empty()).then_some(v))
326        }
327        Some(1) => Ok(None),
328        _ => Err(format!(
329            "git {} failed: {}",
330            args.join(" "),
331            if out.stderr.is_empty() {
332                "git said nothing"
333            } else {
334                &out.stderr
335            }
336        )),
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    struct Fixture {
345        _dir: tempfile::TempDir,
346        primary: PathBuf,
347        root: PathBuf,
348    }
349
350    fn git(cwd: &Path, args: &[&str]) -> String {
351        let out = std::process::Command::new("git")
352            .arg("-C")
353            .arg(cwd)
354            .args(args)
355            .env("GIT_CONFIG_GLOBAL", "/dev/null")
356            .env("GIT_CONFIG_NOSYSTEM", "1")
357            .output()
358            .unwrap();
359        assert!(
360            out.status.success(),
361            "git {args:?}: {}",
362            String::from_utf8_lossy(&out.stderr)
363        );
364        String::from_utf8_lossy(&out.stdout).trim().to_string()
365    }
366
367    /// A repository with a tracked `.githooks/`, its hooks path set as `hooks_path` names it
368    /// (`None` leaves it unset), and one linked worktree per name.
369    fn fixture(hooks_path: Option<&str>) -> Fixture {
370        let dir = tempfile::tempdir().unwrap();
371        let root = std::fs::canonicalize(dir.path()).unwrap();
372        let primary = root.join("r");
373        std::fs::create_dir_all(primary.join(".githooks")).unwrap();
374        std::fs::write(primary.join(".githooks/pre-commit"), "#!/bin/sh\n").unwrap();
375        git(
376            &root,
377            &["init", "-q", "-b", "master", primary.to_str().unwrap()],
378        );
379        git(&primary, &["add", "."]);
380        git(
381            &primary,
382            &[
383                "-c",
384                "user.name=t",
385                "-c",
386                "user.email=t@t",
387                "commit",
388                "-qm",
389                "hooks",
390            ],
391        );
392        if let Some(v) = hooks_path {
393            let v = v.replace("{primary}", primary.to_str().unwrap());
394            git(&primary, &["config", "core.hooksPath", &v]);
395        }
396        Fixture {
397            _dir: dir,
398            primary,
399            root,
400        }
401    }
402
403    fn add(f: &Fixture, branch: &str) -> PathBuf {
404        let wt = f.root.join("r-wt").join(branch);
405        git(
406            &f.primary,
407            &["worktree", "add", "-q", "-b", branch, wt.to_str().unwrap()],
408        );
409        wt
410    }
411
412    #[test]
413    fn a_worktree_sent_to_the_primarys_hooks_is_bound_to_its_own_and_the_primary_is_not() {
414        let f = fixture(Some("{primary}/.githooks"));
415        let wt = add(&f, "x");
416        let outcome = carry_hooks_path(&f.primary, &wt);
417        let own = display(&wt.join(".githooks"));
418        assert_eq!(
419            outcome,
420            HooksPath::Set {
421                path: own.clone(),
422                enabled: vec![
423                    "core.repositoryformatversion=1".into(),
424                    "extensions.worktreeConfig=true".into()
425                ],
426            }
427        );
428        assert_eq!(
429            git(
430                &wt,
431                &["rev-parse", "--path-format=absolute", "--git-path", "hooks"]
432            ),
433            own
434        );
435        // the primary checkout still runs its own, and the shared value is untouched
436        assert_eq!(
437            git(
438                &f.primary,
439                &["rev-parse", "--path-format=absolute", "--git-path", "hooks"]
440            ),
441            display(&f.primary.join(".githooks"))
442        );
443        assert_eq!(
444            git(
445                &f.primary,
446                &["config", "--local", "--get", "core.hooksPath"]
447            ),
448            display(&f.primary.join(".githooks"))
449        );
450        // asked again, nothing more is done; a second worktree finds the extension on
451        assert_eq!(
452            carry_hooks_path(&f.primary, &wt),
453            HooksPath::Own { path: own }
454        );
455        let second = add(&f, "y");
456        assert!(
457            matches!(carry_hooks_path(&f.primary, &second), HooksPath::Set { ref enabled, .. } if enabled.is_empty())
458        );
459    }
460
461    #[test]
462    fn a_moved_worktree_is_rebound_to_the_directory_at_its_new_path() {
463        let f = fixture(Some("{primary}/.githooks"));
464        let wt = add(&f, "x");
465        assert!(matches!(
466            carry_hooks_path(&f.primary, &wt),
467            HooksPath::Set { .. }
468        ));
469        let moved = f.root.join("r-wt").join("moved");
470        git(
471            &f.primary,
472            &[
473                "worktree",
474                "move",
475                wt.to_str().unwrap(),
476                moved.to_str().unwrap(),
477            ],
478        );
479        let own = display(&moved.join(".githooks"));
480        assert!(
481            matches!(carry_hooks_path(&f.primary, &moved), HooksPath::Set { ref path, .. } if *path == own)
482        );
483    }
484
485    #[test]
486    fn nothing_is_invented_set_or_shared_where_the_repository_does_not_track_its_hooks() {
487        // no hooksPath at all: git's own directory, and no configuration written
488        let f = fixture(None);
489        let wt = add(&f, "x");
490        assert_eq!(carry_hooks_path(&f.primary, &wt), HooksPath::NotConfigured);
491        assert!(
492            git::try_run(
493                &f.primary,
494                &["config", "--get", "extensions.worktreeConfig"]
495            )
496            .unwrap()
497            .status
498                != Some(0)
499        );
500
501        // a relative value already resolves per worktree: reported, not rewritten
502        let f = fixture(Some(".githooks"));
503        let wt = add(&f, "x");
504        assert_eq!(
505            carry_hooks_path(&f.primary, &wt),
506            HooksPath::Own {
507                path: display(&wt.join(".githooks"))
508            }
509        );
510
511        // a directory outside every checkout is shared on purpose
512        let f = fixture(Some("/nonexistent-shared-hooks"));
513        let wt = add(&f, "x");
514        assert!(matches!(
515            carry_hooks_path(&f.primary, &wt),
516            HooksPath::Shared { .. }
517        ));
518
519        // the primary checkout is never changed
520        let f = fixture(Some("{primary}/.githooks"));
521        assert!(matches!(
522            carry_hooks_path(&f.primary, &f.primary),
523            HooksPath::Own { .. }
524        ));
525    }
526
527    #[test]
528    fn a_tree_without_the_hooks_directory_keeps_the_primarys_rather_than_none() {
529        let f = fixture(Some("{primary}/.githooks"));
530        let wt = add(&f, "x");
531        std::fs::remove_dir_all(wt.join(".githooks")).unwrap();
532        assert!(matches!(
533            carry_hooks_path(&f.primary, &wt),
534            HooksPath::Untracked { .. }
535        ));
536        assert!(
537            git::try_run(&wt, &["config", "--worktree", "--get", "core.hooksPath"])
538                .unwrap()
539                .status
540                != Some(0)
541        );
542    }
543
544    #[test]
545    fn every_outcome_reads_as_one_line_naming_the_directory() {
546        for (outcome, needle) in [
547            (HooksPath::Own { path: "/a".into() }, "/a, its own"),
548            (HooksPath::Shared { path: "/s".into() }, "/s, shared"),
549            (
550                HooksPath::Untracked {
551                    missing: "/w/.githooks".into(),
552                    runs: "/p/.githooks".into(),
553                },
554                "/p/.githooks, the primary checkout's: /w/.githooks",
555            ),
556            (
557                HooksPath::Failed {
558                    message: "no".into(),
559                },
560                "not set to its own: no",
561            ),
562        ] {
563            let line = outcome.describe();
564            assert!(
565                line.starts_with("hooks   ") && line.contains(needle),
566                "{line}"
567            );
568            assert!(!line.contains('\n'));
569        }
570    }
571
572    #[test]
573    fn a_set_outcome_names_what_it_switched_on_only_when_it_switched_something_on() {
574        let quiet = HooksPath::Set {
575            path: "/w/.githooks".into(),
576            enabled: Vec::new(),
577        };
578        assert_eq!(
579            quiet.describe(),
580            "hooks   /w/.githooks, its own (git config --worktree core.hooksPath)"
581        );
582        let loud = HooksPath::Set {
583            path: "/w/.githooks".into(),
584            enabled: vec!["a=1".into(), "b=true".into()],
585        };
586        assert!(loud.describe().ends_with("; enabled a=1, b=true)"));
587        assert!(HooksPath::NotConfigured
588            .describe()
589            .starts_with("hooks   no core.hooksPath"));
590    }
591
592    /// Switch per-worktree configuration on in the primary, as a person (or an earlier run)
593    /// would have, and answer the administrative directory of `wt`.
594    fn per_worktree(f: &Fixture, wt: &Path) -> PathBuf {
595        git(&f.primary, &["config", "core.repositoryformatversion", "1"]);
596        git(&f.primary, &["config", "extensions.worktreeConfig", "true"]);
597        PathBuf::from(git(wt, &["rev-parse", "--absolute-git-dir"]))
598    }
599
600    fn failed(outcome: HooksPath) -> String {
601        match outcome {
602            HooksPath::Failed { message } => message,
603            other => panic!("expected a failure, got {other:?}"),
604        }
605    }
606
607    #[test]
608    fn a_version_one_repository_is_switched_on_without_its_version_being_written() {
609        let f = fixture(Some("{primary}/.githooks"));
610        let wt = add(&f, "x");
611        git(&f.primary, &["config", "core.repositoryformatversion", "1"]);
612        assert_eq!(
613            carry_hooks_path(&f.primary, &wt),
614            HooksPath::Set {
615                path: display(&wt.join(".githooks")),
616                enabled: vec!["extensions.worktreeConfig=true".into()],
617            }
618        );
619    }
620
621    #[test]
622    fn only_a_non_empty_value_of_the_repositorys_own_files_is_its_policy() {
623        assert_eq!(
624            policy_of("local\t/r/.githooks"),
625            Some("/r/.githooks".into())
626        );
627        assert_eq!(policy_of("worktree\t.githooks"), Some(".githooks".into()));
628        assert_eq!(policy_of("local\t"), None, "an empty value");
629        assert_eq!(policy_of("worktree\t"), None, "an empty value");
630        assert_eq!(
631            policy_of("global\t/home/h"),
632            None,
633            "the user's, not the repository's"
634        );
635        assert_eq!(policy_of("command\t/c"), None);
636        assert_eq!(policy_of("no tab at all"), None);
637    }
638
639    #[test]
640    fn an_empty_value_is_no_policy() {
641        let f = fixture(Some(""));
642        let wt = add(&f, "x");
643        assert_eq!(repository_policy(&f.primary), Ok(None));
644        assert_eq!(carry_hooks_path(&f.primary, &wt), HooksPath::NotConfigured);
645    }
646
647    #[test]
648    fn what_git_refuses_is_reported_and_nothing_is_changed() {
649        let f = fixture(Some("{primary}/.githooks"));
650
651        // a primary checkout that is not there: git's own words
652        let gone = f.root.join("gone");
653        let message = failed(carry_hooks_path(&gone, &gone));
654        assert!(message.contains("--show-scope"), "{message}");
655
656        // a "worktree" that is no repository: the hooks directory cannot be asked for
657        let plain = f.root.join("plain");
658        std::fs::create_dir_all(&plain).unwrap();
659        let message = failed(carry_hooks_path(&f.primary, &plain));
660        assert!(message.contains("rev-parse"), "{message}");
661
662        // core.worktree in the shared file: a person's decision, refused before any write
663        let wt = add(&f, "x");
664        git(
665            &f.primary,
666            &["config", "core.worktree", f.primary.to_str().unwrap()],
667        );
668        let message = failed(carry_hooks_path(&f.primary, &wt));
669        assert!(message.contains("core.worktree is set"), "{message}");
670        assert!(
671            git::try_run(
672                &f.primary,
673                &["config", "--get", "extensions.worktreeConfig"]
674            )
675            .unwrap()
676            .status
677                != Some(0)
678        );
679    }
680
681    #[test]
682    fn a_value_git_will_not_overwrite_or_still_overrides_is_a_failure() {
683        let f = fixture(Some("{primary}/.githooks"));
684
685        // two values in the worktree's own file: git refuses to overwrite them
686        let wt = add(&f, "x");
687        let admin = per_worktree(&f, &wt);
688        std::fs::write(
689            admin.join("config.worktree"),
690            "[core]\n\thooksPath = /a\n\thooksPath = /b\n",
691        )
692        .unwrap();
693        let message = failed(carry_hooks_path(&f.primary, &wt));
694        assert!(message.contains("core.hooksPath"), "{message}");
695
696        // an include read after the value: set, and git still resolves another directory
697        let wt = add(&f, "y");
698        let admin = PathBuf::from(git(&wt, &["rev-parse", "--absolute-git-dir"]));
699        let extra = f.root.join("extra.conf");
700        std::fs::write(&extra, "[core]\n\thooksPath = /elsewhere\n").unwrap();
701        std::fs::write(
702            admin.join("config.worktree"),
703            format!(
704                "[core]\n\thooksPath = /a\n[include]\n\tpath = {}\n",
705                extra.display()
706            ),
707        )
708        .unwrap();
709        let message = failed(carry_hooks_path(&f.primary, &wt));
710        assert!(
711            message.contains("git still resolves /elsewhere"),
712            "{message}"
713        );
714    }
715
716    #[test]
717    fn a_path_git_answers_that_is_not_utf8_is_a_failure_not_a_lossy_name() {
718        // the worktree resolves a directory this tool cannot name
719        let f = fixture(Some("{primary}/.githooks"));
720        let wt = add(&f, "x");
721        let admin = per_worktree(&f, &wt);
722        std::fs::write(
723            admin.join("config.worktree"),
724            b"[core]\n\thooksPath = /\xff\n",
725        )
726        .unwrap();
727        let message = failed(carry_hooks_path(&f.primary, &wt));
728        assert!(message.contains("UTF-8"), "{message}");
729
730        // the policy itself cannot be named
731        let f = fixture(None);
732        let mut config = std::fs::read(f.primary.join(".git/config")).unwrap();
733        config.extend_from_slice(b"[core]\n\thooksPath = /\xff\n");
734        std::fs::write(f.primary.join(".git/config"), config).unwrap();
735        let message = failed(carry_hooks_path(&f.primary, &f.primary));
736        assert!(message.contains("UTF-8"), "{message}");
737    }
738
739    #[test]
740    fn a_configuration_read_git_fails_without_a_word_says_so() {
741        let f = fixture(None);
742        // `ls-remote --exit-code` exits 2, silently, when nothing matches
743        let err = config_value(&f.primary, &["ls-remote", "--exit-code", ".", "refs/none"])
744            .expect_err("exit 2 is neither a value nor its absence");
745        assert!(err.ends_with("failed: git said nothing"), "{err}");
746    }
747}