Skip to main content

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}