1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "snake_case", tag = "outcome")]
56pub enum HooksPath {
57 NotConfigured,
61 Own {
63 path: String,
65 },
66 Set {
68 path: String,
70 enabled: Vec<String>,
73 },
74 Shared {
77 path: String,
79 },
80 Untracked {
84 missing: String,
86 runs: String,
88 },
89 Failed {
91 message: String,
93 },
94}
95
96impl HooksPath {
97 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
135pub 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 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 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
238fn 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 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
286fn 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
300fn 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
309fn 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
319fn 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 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 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 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 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 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 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 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 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 let gone = f.root.join("gone");
653 let message = failed(carry_hooks_path(&gone, &gone));
654 assert!(message.contains("--show-scope"), "{message}");
655
656 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 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 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 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 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 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 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}