jj_hooks/hooks.rs
1//! Per-bookmark hook execution pipeline.
2//!
3//! For each bookmark update being pushed:
4//! 1. Resolve one or more `from_ref` commits (the ancestors on the remote).
5//! 2. Create an ephemeral detached worktree at the new commit.
6//! 3. Run the configured hook backend against each `from_ref` in turn.
7//! Modifications accumulate in the same worktree.
8//! 4. If the worktree ended up with modifications, build a fixup commit
9//! via `git commit-tree`, anchor it under `refs/jj-hooks/fixup/<bookmark>`,
10//! and `jj git import` so jj sees it.
11//! 5. Optionally re-run the hook backend against the fixup commit; if
12//! the re-run is clean, the overall outcome is reported as success
13//! with `initial_failure = true` so callers can surface the
14//! transient failure. See [`RunOpts::retry_after_fixup`].
15//! 6. Optionally advance the bookmark to the fixup commit.
16
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use crate::bookmark_updates::BookmarkUpdate;
21use crate::error::{JjHooksError, Result};
22use crate::jj::JjCli;
23use crate::runner::{
24 Runner, Stage, hook_command, hook_command_all_files, lefthook_command,
25 lefthook_command_all_files,
26};
27use crate::setup::{self, SetupStep};
28use crate::worktree::Worktree;
29
30#[derive(Debug, Clone)]
31pub struct HookOutcome {
32 /// Final success for this bookmark — `true` iff every hook run we
33 /// took into account exited 0. When `retry_after_fixup` is enabled
34 /// and a retry on the fixup commit was clean, this reports `true`
35 /// even though the initial run failed.
36 pub success: bool,
37 /// Commit id of the fixup commit if the hook(s) modified files.
38 /// `Some(_)` means the caller's tree is stale relative to what the
39 /// hooks want.
40 pub fixup_commit: Option<String>,
41 /// `true` iff we re-ran hooks against the fixup commit after the
42 /// initial run reported failure-with-fixup.
43 pub retried: bool,
44 /// `true` iff the initial hook run exited non-zero, regardless of
45 /// whether a subsequent retry healed the outcome. CLI uses this to
46 /// warn the user that something was racy even when the final state
47 /// is OK.
48 pub initial_failure: bool,
49}
50
51/// Inputs that control how [`run_for_update`] behaves. Defaults match
52/// pre-0.3.0 behavior (no retry).
53#[derive(Debug, Clone, Copy, Default)]
54pub struct RunOpts {
55 /// When the initial hook run produces a fixup commit AND reports
56 /// failure, re-run the hooks against the fixup commit. If the
57 /// re-run is clean, the overall outcome is reported as success
58 /// with `initial_failure = true`. Use this to recover from
59 /// transient races (e.g. hk's intra-bookmark step parallelism
60 /// fighting for `.git/index.lock` while one step legitimately
61 /// auto-fixes files).
62 pub retry_after_fixup: bool,
63 /// Run hooks against every tracked file in the worktree rather
64 /// than the diff range. Each runner gets its own all-files flag
65 /// (see [`crate::runner::hook_command_all_files`]). Currently
66 /// surfaced via `jj-hp run --all-files`; `push` always uses the
67 /// diff range since the bookmark's ref bounds are the whole
68 /// point.
69 pub all_files: bool,
70}
71
72/// Run hooks for one bookmark update. Returns the outcome (success +
73/// optional fixup commit + retry metadata).
74///
75/// `cli_runner` is the user's `--runner` override (or `None` for autodetect).
76/// When `None`, runner detection happens inside the ephemeral worktree at the
77/// target commit — so a commit that migrated runners (e.g. `lefthook → hk`)
78/// is gated by the runner the *target* commits to, not the runner the user's
79/// primary workspace currently has on disk.
80pub fn run_for_update(
81 jj: &JjCli,
82 primary_git_dir: &Path,
83 workspace_root: &Path,
84 cli_runner: Option<Runner>,
85 stage: Stage,
86 update: &BookmarkUpdate,
87 opts: RunOpts,
88) -> Result<HookOutcome> {
89 let Some(new_commit) = update.new_commit.as_ref() else {
90 // Pure delete — nothing to check.
91 return Ok(HookOutcome {
92 success: true,
93 fixup_commit: None,
94 retried: false,
95 initial_failure: false,
96 });
97 };
98
99 let from_refs = resolve_from_refs(jj, update)?;
100 let setup_steps = setup::load_steps(jj)?;
101
102 let initial = run_once(
103 jj,
104 primary_git_dir,
105 workspace_root,
106 cli_runner,
107 stage,
108 update,
109 new_commit,
110 &from_refs,
111 &setup_steps,
112 opts.all_files,
113 )?;
114
115 // Initial run was clean OR caller opted out of retry OR there's nothing
116 // to retry against — return as-is. (No fixup means the caller's tree is
117 // already what the hooks would produce; nothing to re-check.)
118 if !opts.retry_after_fixup || initial.success || initial.fixup_commit.is_none() {
119 return Ok(HookOutcome {
120 success: initial.success,
121 fixup_commit: initial.fixup_commit,
122 retried: false,
123 initial_failure: !initial.success,
124 });
125 }
126
127 let fixup = initial.fixup_commit.as_ref().expect("checked Some above");
128 tracing::info!(
129 "{update}: re-running hooks against fixup commit {fixup} to check for transient failure"
130 );
131 let retry = run_once(
132 jj,
133 primary_git_dir,
134 workspace_root,
135 cli_runner,
136 stage,
137 update,
138 fixup,
139 &from_refs,
140 &setup_steps,
141 opts.all_files,
142 )?;
143
144 // The retry should be clean (no failure, no new fixup) for the
145 // "healed by retry" verdict. Any further fixup means the tree is
146 // still drifting; bail with the original failure semantics.
147 let healed = retry.success && retry.fixup_commit.is_none();
148 Ok(HookOutcome {
149 // If the retry healed it, report success and surface the fixup
150 // so the user knows to advance their bookmark. If the retry
151 // *also* failed, success is whatever the retry reported and
152 // the fixup is whichever one the retry produced (which may
153 // differ from the initial one).
154 success: if healed { true } else { retry.success },
155 fixup_commit: if healed {
156 initial.fixup_commit
157 } else {
158 // The retry pass either produced a fresh fixup (chain of
159 // autofixes) or none at all (just a hard failure). Prefer
160 // the retry's fixup when it has one so the user advances
161 // their bookmark to the most recent good state; fall back
162 // to the initial fixup so we don't drop information.
163 retry.fixup_commit.or(initial.fixup_commit)
164 },
165 retried: true,
166 initial_failure: true,
167 })
168}
169
170/// Internal shape returned by [`run_once`]: a single hook run plus the
171/// fixup commit (if any) it produced. This is the per-attempt building
172/// block used by [`run_for_update`] to layer retry-after-fixup logic.
173struct OnceOutcome {
174 success: bool,
175 fixup_commit: Option<String>,
176}
177
178/// Replace element 0 of `command_argv` (the bare runner binary name
179/// produced by `hook_command{,_all_files}` / `lefthook_command{,_all_files}`)
180/// with the resolved argv prefix from [`crate::runner::resolve_runner_argv`].
181///
182/// For the common case the prefix is a single element (an absolute path
183/// or just the bare name found on $PATH), so this is a near-no-op. For
184/// the `uv run --` wrapper case the prefix is multiple elements; we
185/// drop the placeholder name and splice in the wrapper.
186fn splice_runner_prefix(prefix: &[String], command_argv: &[String]) -> Vec<String> {
187 let mut out = Vec::with_capacity(prefix.len() + command_argv.len().saturating_sub(1));
188 out.extend(prefix.iter().cloned());
189 if command_argv.len() > 1 {
190 out.extend(command_argv[1..].iter().cloned());
191 }
192 out
193}
194
195/// One pass through the hook pipeline against a specific target commit.
196///
197/// Builds a fresh worktree at `target_commit`, runs the hook backend
198/// against each entry in `from_refs`, and, if the worktree's tree
199/// differs from `target_commit`'s tree at the end, builds a fixup
200/// commit + cleans up the temp ref / bookmark that `jj git import`
201/// creates.
202///
203/// Callers (currently just [`run_for_update`]) decide whether to retry
204/// based on the returned `success` / `fixup_commit`.
205#[allow(clippy::too_many_arguments)]
206fn run_once(
207 jj: &JjCli,
208 primary_git_dir: &Path,
209 workspace_root: &Path,
210 cli_runner: Option<Runner>,
211 stage: Stage,
212 update: &BookmarkUpdate,
213 target_commit: &str,
214 from_refs: &[String],
215 setup_steps: &[SetupStep],
216 all_files: bool,
217) -> Result<OnceOutcome> {
218 let wt = Worktree::create(primary_git_dir, target_commit)?;
219
220 // User-declared setup commands (e.g. `bun install`) run inside
221 // the worktree before the runner so hooks have install-time
222 // resources (`node_modules`, `.venv`, etc.) available. A
223 // non-zero exit aborts before the runner is invoked — the
224 // worktree is unhealthy and there's no point asking the
225 // runner to grade it.
226 setup::run_steps(setup_steps, wt.path(), workspace_root)?;
227
228 // Resolve the runner from the target commit's tree, not the primary
229 // workspace. `--runner` overrides; otherwise autodetect against the
230 // worktree we just checked out. If autodetect comes up empty, the
231 // commit doesn't have a hook config — silent-skip with an info log.
232 let runner = match cli_runner {
233 Some(r) => r,
234 None => {
235 let Some(r) = Runner::autodetect(wt.path())? else {
236 eprintln!(
237 "jj-hooks: {update}: no hook-runner config in target commit; skipping hooks"
238 );
239 return Ok(OnceOutcome {
240 success: true,
241 fixup_commit: None,
242 });
243 };
244 // prek is a faster drop-in for pre-commit; prefer it when
245 // present. The override path already skips this so an explicit
246 // `--runner pre-commit` keeps the slower binary.
247 //
248 // "Present" here means resolvable through any of the layers
249 // in [`resolve_runner_argv`], not just $PATH — a prek
250 // installed only inside a venv (the issue #17 scenario) is
251 // still preferable to the pre-commit on $PATH if the user
252 // bothered to `prek install` the shim or set the config.
253 let prek_present = crate::runner::resolve_runner_argv(
254 Runner::Prek,
255 jj,
256 workspace_root,
257 primary_git_dir,
258 stage,
259 )
260 .is_ok();
261 crate::runner::prefer_prek_when_available(r, prek_present)
262 }
263 };
264
265 // Pre-check that the runner binary is on PATH. Without this, the
266 // `Command::status()` call below surfaces a libc-level
267 // `posix_spawn: No such file or directory (os error 2)` with no
268 // indication of *which* binary couldn't be found. The common case
269 // for prek users is that prek is installed only inside a Python
270 // venv — jj-hooks runs in a clean ephemeral worktree and doesn't
271 // inherit the venv's PATH, so the user sees the cryptic error
272 // and has no idea it was prek that was missing.
273 //
274 // Resolution order is (1) explicit config, (2) the path baked into
275 // the `.git/hooks/<stage>` shim by `prek install` / `pre-commit
276 // install`, (3) `uv run` when uv.lock + uv are both present,
277 // (4) plain $PATH. See `resolve_runner_argv` for details.
278 let runner_argv =
279 crate::runner::resolve_runner_argv(runner, jj, workspace_root, primary_git_dir, stage)?;
280
281 // all_files: ignore the diff range and run each runner's
282 // "lint every tracked file" command exactly once. from_refs
283 // is meaningless here — the runner sees no --from-ref/--to-ref.
284 //
285 // Default path: iterate from_refs (one per ancestor on the
286 // remote) so multi-ancestor pushes still get the full set of
287 // diff bases. Each iteration accumulates modifications in the
288 // shared worktree, mirroring how the standard pre-push pipeline
289 // builds up its fixup.
290 let mut success = true;
291 if all_files {
292 let argv = match runner {
293 Runner::Lefthook => lefthook_command_all_files(stage),
294 _ => hook_command_all_files(runner, stage),
295 };
296 let argv = splice_runner_prefix(&runner_argv, &argv);
297 tracing::info!("running (--all-files): {:?}", argv);
298 let status = Command::new(&argv[0])
299 .args(&argv[1..])
300 .current_dir(wt.path())
301 .env("JJ_HOOKS_WORKSPACE", workspace_root)
302 .status()?;
303 if !status.success() {
304 success = false;
305 }
306 } else {
307 for from_ref in from_refs {
308 let argv = match runner {
309 Runner::Lefthook => {
310 let files = changed_files(wt.path(), from_ref, target_commit)?;
311 lefthook_command(stage, &files)
312 }
313 _ => hook_command(runner, stage, from_ref, target_commit),
314 };
315 let argv = splice_runner_prefix(&runner_argv, &argv);
316
317 tracing::info!("running: {:?}", argv);
318 let status = Command::new(&argv[0])
319 .args(&argv[1..])
320 .current_dir(wt.path())
321 .env("JJ_HOOKS_WORKSPACE", workspace_root)
322 .status()?;
323
324 if !status.success() {
325 success = false;
326 }
327 }
328 }
329
330 let fixup_commit =
331 maybe_build_fixup_commit(primary_git_dir, wt.path(), target_commit, &update.bookmark)?;
332
333 if fixup_commit.is_some() {
334 // Make jj aware of the new commit. --ignore-working-copy keeps
335 // this import from racing against any concurrent `jj` process
336 // (same lock-contention rationale as in push.rs).
337 jj.run(&["git", "import", "--ignore-working-copy"])?;
338
339 // jj git import created a `jj-hooks-fixup/<bookmark>` jj bookmark
340 // from the underlying refs/heads/jj-hooks-fixup/<bookmark> ref.
341 // Clean both up immediately — the user almost always wants to
342 // either squash the fixup into the parent or move their bookmark
343 // forward themselves, not have a stale temp bookmark lying
344 // around. The commit stays addressable by hash via `jj log`,
345 // `jj show`, `jj squash --from <hash>` etc. since jj tracks it
346 // in its own commit graph independent of the ref.
347 let temp_bookmark = fixup_bookmark(&update.bookmark);
348 // `jj bookmark forget` removes the jj bookmark, but in a
349 // secondary workspace it leaves the underlying refs/heads/<name>
350 // ref alive in the primary's git dir. Explicitly delete the
351 // git ref ourselves so the cleanup is uniform.
352 let _ = jj.run(&[
353 "bookmark",
354 "forget",
355 &temp_bookmark,
356 "--ignore-working-copy",
357 ]);
358 let _ = delete_git_ref(primary_git_dir, &fixup_ref(&update.bookmark));
359 }
360
361 Ok(OnceOutcome {
362 success,
363 fixup_commit,
364 })
365}
366
367/// Resolve the `from_ref` commits to diff against. For an existing
368/// bookmark update we just use the old commit; for a new bookmark we
369/// find the heads of `::new & ::remote_bookmarks(remote)` so each
370/// already-on-remote ancestor becomes its own diff base.
371fn resolve_from_refs(jj: &JjCli, update: &BookmarkUpdate) -> Result<Vec<String>> {
372 if let Some(old) = update.old_commit.as_ref() {
373 return Ok(vec![old.clone()]);
374 }
375
376 let new = update.new_commit.as_ref().expect("not a delete here");
377 let revset = format!(
378 "heads(::{new} & ::remote_bookmarks(remote=exact:{}))",
379 update.remote
380 );
381
382 let template = r#"commit_id ++ "\n""#;
383 let out = jj.run(&[
384 "log",
385 "--no-graph",
386 "-r",
387 &revset,
388 "-T",
389 template,
390 "--ignore-working-copy",
391 ])?;
392
393 let refs: Vec<String> = out
394 .lines()
395 .map(|l| l.trim().to_owned())
396 .filter(|l| !l.is_empty())
397 .collect();
398
399 if refs.is_empty() {
400 // New bookmark on a totally fresh remote — no ancestors on the
401 // remote at all. Use the parent of new as the diff base.
402 return Ok(vec![format!("{new}^")]);
403 }
404
405 Ok(refs)
406}
407
408fn changed_files(worktree: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
409 let out = Command::new("git")
410 .args(["diff", "--name-only", "--diff-filter=ACMR"])
411 .arg(format!("{from}..{to}"))
412 .current_dir(worktree)
413 .output()?;
414 if !out.status.success() {
415 return Err(JjHooksError::JjFailed {
416 status: out.status.code().unwrap_or(-1),
417 stderr: format!(
418 "git diff --name-only failed: {}",
419 String::from_utf8_lossy(&out.stderr)
420 ),
421 });
422 }
423 Ok(String::from_utf8_lossy(&out.stdout)
424 .lines()
425 .map(|l| PathBuf::from(l.trim()))
426 .filter(|p| !p.as_os_str().is_empty())
427 .collect())
428}
429
430/// Stage everything in the worktree, hash the resulting tree, and
431/// compare against the parent commit's tree. Returns a fixup commit
432/// only when the trees actually differ — `git status --porcelain`
433/// can report a worktree as dirty (e.g. when a hook runner touched
434/// the index without changing file content; hk's auto-stage path
435/// does this even on check-only steps), but the resulting tree is
436/// often identical to the parent and an empty fixup commit is just
437/// noise that pins the bookmark to a content-equivalent revision
438/// and aborts the push.
439///
440/// Content-addressed gating eliminates the false positive: if the
441/// hooks didn't actually change any file, the write-tree OID equals
442/// the parent's tree OID and we return `None`.
443fn maybe_build_fixup_commit(
444 primary_git_dir: &Path,
445 worktree: &Path,
446 parent: &str,
447 bookmark: &str,
448) -> Result<Option<String>> {
449 // Stage everything (tracked + untracked) and hash the tree.
450 // Both are cheap on a clean checkout — `git add -A` is a no-op
451 // when nothing changed; `git write-tree` is hashing-only.
452 run_git(worktree, &["add", "-A"])?;
453 let tree = run_git_capture(worktree, &["write-tree"])?;
454
455 // Parent's tree as a content reference. `<commit>^{tree}` is
456 // the standard rev-parse spelling.
457 let parent_tree_spec = format!("{parent}^{{tree}}");
458 let parent_tree = run_git_capture(worktree, &["rev-parse", &parent_tree_spec])?;
459
460 if tree == parent_tree {
461 return Ok(None);
462 }
463
464 // Build the commit object via the *primary* git dir so the resulting
465 // commit lives in the shared object database.
466 let message = format!("jj-hooks: autofixes for {bookmark}");
467 let commit = run_git_capture_with_git_dir(
468 primary_git_dir,
469 worktree,
470 &["commit-tree", &tree, "-p", parent, "-m", &message],
471 )?;
472
473 // Anchor under refs/heads/ so `jj git import` will pick it up as a
474 // bookmark. (Refs outside refs/heads/ and refs/remotes/ are invisible
475 // to jj's git import logic.)
476 let ref_name = fixup_ref(bookmark);
477 run_git_capture_with_git_dir(
478 primary_git_dir,
479 worktree,
480 &["update-ref", &ref_name, &commit],
481 )?;
482
483 Ok(Some(commit))
484}
485
486/// The git ref where a fixup commit gets anchored for a given bookmark.
487/// Lives under `refs/heads/` so `jj git import` picks it up as a bookmark.
488pub fn fixup_ref(bookmark: &str) -> String {
489 format!("refs/heads/jj-hooks-fixup/{}", sanitize_for_ref(bookmark))
490}
491
492/// The jj bookmark name corresponding to `fixup_ref`.
493pub fn fixup_bookmark(bookmark: &str) -> String {
494 format!("jj-hooks-fixup/{}", sanitize_for_ref(bookmark))
495}
496
497/// Replace characters that git rejects in ref names (per git-check-ref-format)
498/// with `_`. Real bookmark names like `main` or `feature/foo` pass through
499/// unchanged; synthesized names like `revset:@` (used by `jj-hp run @`) get
500/// scrubbed so the resulting `refs/heads/jj-hooks-fixup/<name>` is valid.
501fn sanitize_for_ref(s: &str) -> String {
502 // Per-character offenders first; then collapse multi-char sequences
503 // and trim the position-sensitive ones (leading `-`/`.`, trailing
504 // `.`/`.lock`/`/`, internal `//`).
505 let mut out: String = s
506 .chars()
507 .map(|c| match c {
508 ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' | '\x7f' => '_',
509 c if (c as u32) < 0x20 => '_',
510 c => c,
511 })
512 .collect();
513
514 while out.contains("..") {
515 out = out.replace("..", "__");
516 }
517 while out.contains("@{") {
518 out = out.replace("@{", "@_");
519 }
520 if out.starts_with('-') {
521 out.replace_range(0..1, "_");
522 }
523 if out.starts_with('.') {
524 out.replace_range(0..1, "_");
525 }
526 if out.ends_with('.') {
527 let n = out.len();
528 out.replace_range(n - 1..n, "_");
529 }
530 if out.ends_with(".lock") {
531 let n = out.len();
532 out.replace_range(n - 5..n - 4, "_");
533 }
534 if out.ends_with('/') {
535 let n = out.len();
536 out.replace_range(n - 1..n, "_");
537 }
538 while out.contains("//") {
539 out = out.replace("//", "/_");
540 }
541 if out.is_empty() {
542 return "_".into();
543 }
544 out
545}
546
547/// Delete a git ref in the given git dir, ignoring "ref doesn't exist"
548/// failures. Used to clean up the temp `refs/heads/jj-hooks-fixup/<name>`
549/// after `jj git import` + `jj bookmark forget` from a secondary
550/// workspace (where forget leaves the underlying ref alive).
551fn delete_git_ref(git_dir: &Path, ref_name: &str) -> Result<()> {
552 let out = Command::new("git")
553 .arg(format!("--git-dir={}", git_dir.display()))
554 .args(["update-ref", "-d", ref_name])
555 .output()?;
556 if !out.status.success() {
557 // Treat any failure as best-effort: if the ref didn't exist,
558 // that's the desired state already.
559 tracing::debug!(
560 "git update-ref -d {ref_name} failed: {}",
561 String::from_utf8_lossy(&out.stderr)
562 );
563 }
564 Ok(())
565}
566
567fn run_git(cwd: &Path, args: &[&str]) -> Result<()> {
568 let out = Command::new("git").args(args).current_dir(cwd).output()?;
569 if !out.status.success() {
570 return Err(JjHooksError::JjFailed {
571 status: out.status.code().unwrap_or(-1),
572 stderr: format!(
573 "git {args:?} failed: {}",
574 String::from_utf8_lossy(&out.stderr)
575 ),
576 });
577 }
578 Ok(())
579}
580
581fn run_git_capture(cwd: &Path, args: &[&str]) -> Result<String> {
582 let out = Command::new("git").args(args).current_dir(cwd).output()?;
583 if !out.status.success() {
584 return Err(JjHooksError::JjFailed {
585 status: out.status.code().unwrap_or(-1),
586 stderr: format!(
587 "git {args:?} failed: {}",
588 String::from_utf8_lossy(&out.stderr)
589 ),
590 });
591 }
592 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
593}
594
595fn run_git_capture_with_git_dir(git_dir: &Path, cwd: &Path, args: &[&str]) -> Result<String> {
596 let out = Command::new("git")
597 .arg(format!("--git-dir={}", git_dir.display()))
598 .args(args)
599 .current_dir(cwd)
600 .output()?;
601 if !out.status.success() {
602 return Err(JjHooksError::JjFailed {
603 status: out.status.code().unwrap_or(-1),
604 stderr: format!(
605 "git --git-dir={} {args:?} failed: {}",
606 git_dir.display(),
607 String::from_utf8_lossy(&out.stderr)
608 ),
609 });
610 }
611 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn fixup_ref_for_plain_bookmark() {
620 assert_eq!(fixup_ref("main"), "refs/heads/jj-hooks-fixup/main");
621 }
622
623 #[test]
624 fn fixup_ref_keeps_internal_slash() {
625 // jj bookmark names commonly contain `/` (e.g. `feature/foo`) and
626 // git accepts them as path separators inside a ref.
627 assert_eq!(
628 fixup_ref("feature/foo"),
629 "refs/heads/jj-hooks-fixup/feature/foo"
630 );
631 }
632
633 #[test]
634 fn fixup_ref_scrubs_colon() {
635 // The bug from issue #1: `jj-hp run @` synthesizes `revset:@`.
636 // Without sanitization, git rejects the ref with "bad name".
637 assert_eq!(fixup_ref("revset:@"), "refs/heads/jj-hooks-fixup/revset_@");
638 }
639
640 #[test]
641 fn sanitize_replaces_each_invalid_char() {
642 // One probe per character class git-check-ref-format rejects.
643 assert_eq!(sanitize_for_ref("a:b"), "a_b");
644 assert_eq!(sanitize_for_ref("a~b"), "a_b");
645 assert_eq!(sanitize_for_ref("a^b"), "a_b");
646 assert_eq!(sanitize_for_ref("a?b"), "a_b");
647 assert_eq!(sanitize_for_ref("a*b"), "a_b");
648 assert_eq!(sanitize_for_ref("a[b"), "a_b");
649 assert_eq!(sanitize_for_ref("a\\b"), "a_b");
650 assert_eq!(sanitize_for_ref("a b"), "a_b");
651 assert_eq!(sanitize_for_ref("a\tb"), "a_b");
652 assert_eq!(sanitize_for_ref("a\x7fb"), "a_b");
653 }
654
655 #[test]
656 fn sanitize_collapses_double_dot() {
657 assert_eq!(sanitize_for_ref("a..b"), "a__b");
658 // `..` replacement is non-overlapping: `a...b` becomes `a__.b`
659 // (first `..` matches at positions 1-2 and gets replaced; the
660 // remaining `.` is harmless mid-string).
661 assert_eq!(sanitize_for_ref("a...b"), "a__.b");
662 assert!(!sanitize_for_ref("a....b").contains(".."));
663 }
664
665 #[test]
666 fn sanitize_collapses_at_brace() {
667 assert_eq!(sanitize_for_ref("a@{b"), "a@_b");
668 }
669
670 #[test]
671 fn sanitize_strips_leading_dash() {
672 assert_eq!(sanitize_for_ref("-foo"), "_foo");
673 }
674
675 #[test]
676 fn sanitize_strips_leading_dot() {
677 assert_eq!(sanitize_for_ref(".foo"), "_foo");
678 }
679
680 #[test]
681 fn sanitize_strips_trailing_dot() {
682 assert_eq!(sanitize_for_ref("foo."), "foo_");
683 }
684
685 #[test]
686 fn sanitize_strips_trailing_dot_lock() {
687 assert_eq!(sanitize_for_ref("foo.lock"), "foo_lock");
688 }
689
690 #[test]
691 fn sanitize_strips_trailing_slash() {
692 assert_eq!(sanitize_for_ref("foo/"), "foo_");
693 }
694
695 #[test]
696 fn sanitize_collapses_double_slash() {
697 assert_eq!(sanitize_for_ref("a//b"), "a/_b");
698 }
699
700 #[test]
701 fn sanitize_empty_becomes_underscore() {
702 // Defensive: if the input is empty after some external transform,
703 // emit a single underscore so the joined ref isn't dangling.
704 assert_eq!(sanitize_for_ref(""), "_");
705 }
706
707 #[test]
708 fn fixup_bookmark_uses_same_sanitizer() {
709 // fixup_bookmark feeds `jj bookmark forget` which is also strict
710 // about colon (jj rejects bookmark names with `:` in them).
711 assert_eq!(fixup_bookmark("revset:@"), "jj-hooks-fixup/revset_@");
712 }
713}