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;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use crate::bookmark_updates::BookmarkUpdate;
23use crate::error::{JjHooksError, Result};
24use crate::jj::JjCli;
25use crate::runner::{
26    Runner, Stage, hook_command, hook_command_all_files, lefthook_command,
27    lefthook_command_all_files,
28};
29use crate::setup::{self, SetupStep};
30use crate::worktree::Worktree;
31
32/// Cooperative cancellation handle for parallel hook runs.
33///
34/// `run_once` checks this between each hook-runner subprocess
35/// invocation (per-from-ref iteration in the diff-range path, the
36/// single call in the all-files path) and between the runner and the
37/// fixup-commit step. If cancellation has been requested, the
38/// outcome short-circuits with `success: true` and no captured
39/// output — the caller knows the run was cancelled because it
40/// requested it, and the no-op return keeps the result-collection
41/// loop in `run_for_partitioned_updates_parallel` simple.
42///
43/// `Cancel::never()` produces a no-op token for callers that never
44/// want to cancel (the per-bookmark `jj-hp push` CLI path). The
45/// `Default` impl gives the same.
46#[derive(Debug, Clone, Default)]
47pub struct Cancel(Arc<AtomicBool>);
48
49impl Cancel {
50    /// A fresh cancellation token in the un-cancelled state.
51    pub fn new() -> Self {
52        Self(Arc::new(AtomicBool::new(false)))
53    }
54
55    /// A token that never fires. Cheaper than `new()` only in
56    /// readability — both allocate one `AtomicBool`.
57    pub fn never() -> Self {
58        Self::new()
59    }
60
61    /// Mark this token as cancelled. Idempotent.
62    pub fn cancel(&self) {
63        self.0.store(true, Ordering::Relaxed);
64    }
65
66    /// Has cancellation been requested?
67    pub fn is_cancelled(&self) -> bool {
68        self.0.load(Ordering::Relaxed)
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct HookOutcome {
74    /// Final success for this bookmark — `true` iff every hook run we
75    /// took into account exited 0. When `retry_after_fixup` is enabled
76    /// and a retry on the fixup commit was clean, this reports `true`
77    /// even though the initial run failed.
78    pub success: bool,
79    /// Commit id of the fixup commit if the hook(s) modified files.
80    /// `Some(_)` means the caller's tree is stale relative to what the
81    /// hooks want.
82    pub fixup_commit: Option<String>,
83    /// `true` iff we re-ran hooks against the fixup commit after the
84    /// initial run reported failure-with-fixup.
85    pub retried: bool,
86    /// `true` iff the initial hook run exited non-zero, regardless of
87    /// whether a subsequent retry healed the outcome. CLI uses this to
88    /// warn the user that something was racy even when the final state
89    /// is OK.
90    pub initial_failure: bool,
91    /// Captured stdout/stderr from every hook subprocess invoked for
92    /// this update, in order. `None` when [`RunOpts::capture_output`]
93    /// is false (the default — hook output streams straight to the
94    /// parent's terminal so the user sees runner progress live).
95    /// `Some(buf)` when the caller asked for capture so it can
96    /// multiplex N parallel runs into ordered output blocks. See
97    /// [`run_for_updates_parallel`] for the canonical consumer.
98    pub captured_output: Option<String>,
99    /// `true` iff the pipeline observed cancellation between
100    /// subprocess invocations and short-circuited the remaining
101    /// runs. The partitioned-parallel entrypoint flips its
102    /// partition's `Cancel` when any sibling fails; the user sees a
103    /// "cancelled" annotation in the output rather than treating
104    /// this as a normal success/failure.
105    pub cancelled: bool,
106}
107
108/// Inputs that control how [`run_for_update`] behaves. Defaults match
109/// pre-0.3.0 behavior (no retry).
110#[derive(Debug, Clone, Copy, Default)]
111pub struct RunOpts {
112    /// When the initial hook run produces a fixup commit AND reports
113    /// failure, re-run the hooks against the fixup commit. If the
114    /// re-run is clean, the overall outcome is reported as success
115    /// with `initial_failure = true`. Use this to recover from
116    /// transient races (e.g. hk's intra-bookmark step parallelism
117    /// fighting for `.git/index.lock` while one step legitimately
118    /// auto-fixes files).
119    pub retry_after_fixup: bool,
120    /// Run hooks against every tracked file in the worktree rather
121    /// than the diff range. Each runner gets its own all-files flag
122    /// (see [`crate::runner::hook_command_all_files`]). Currently
123    /// surfaced via `jj-hp run --all-files`; `push` always uses the
124    /// diff range since the bookmark's ref bounds are the whole
125    /// point.
126    pub all_files: bool,
127    /// Capture hook subprocess stdout/stderr into the returned
128    /// [`HookOutcome::captured_output`] instead of letting it stream
129    /// straight to the parent's terminal. Required for parallel
130    /// per-bookmark hook runs (see [`run_for_updates_parallel`]) so
131    /// N concurrent runs don't garble the terminal; the caller
132    /// replays the captured blocks in completion order.
133    ///
134    /// Default is `false` — sequential single-bookmark runs (the
135    /// `jj-hp push` path) want the live runner progress bar.
136    pub capture_output: bool,
137}
138
139/// Run hooks for one bookmark update. Returns the outcome (success +
140/// optional fixup commit + retry metadata).
141///
142/// `cli_runner` is the user's `--runner` override (or `None` for autodetect).
143/// When `None`, runner detection happens inside the ephemeral worktree at the
144/// target commit — so a commit that migrated runners (e.g. `lefthook → hk`)
145/// is gated by the runner the *target* commits to, not the runner the user's
146/// primary workspace currently has on disk.
147pub fn run_for_update(
148    jj: &JjCli,
149    primary_git_dir: &Path,
150    workspace_root: &Path,
151    cli_runner: Option<Runner>,
152    stage: Stage,
153    update: &BookmarkUpdate,
154    opts: RunOpts,
155) -> Result<HookOutcome> {
156    run_for_update_with_cancel(
157        jj,
158        primary_git_dir,
159        workspace_root,
160        cli_runner,
161        stage,
162        update,
163        opts,
164        &Cancel::never(),
165    )
166}
167
168/// Like [`run_for_update`] but takes a cancellation token so callers
169/// running multiple updates in parallel can short-circuit siblings
170/// when one fails.
171///
172/// Set the token (`Cancel::cancel`) from a progress callback when
173/// any earlier sibling reports `success: false`; the remaining
174/// `run_for_update_with_cancel` calls in the same scope will check
175/// the token before each subprocess and skip the rest of their
176/// pipeline. The function never *kills* an in-flight subprocess —
177/// it skips the next one. For an hk config with N steps that
178/// translates to "save the (N-1) remaining steps".
179#[allow(clippy::too_many_arguments)]
180pub fn run_for_update_with_cancel(
181    jj: &JjCli,
182    primary_git_dir: &Path,
183    workspace_root: &Path,
184    cli_runner: Option<Runner>,
185    stage: Stage,
186    update: &BookmarkUpdate,
187    opts: RunOpts,
188    cancel: &Cancel,
189) -> Result<HookOutcome> {
190    let Some(new_commit) = update.new_commit.as_ref() else {
191        // Pure delete — nothing to check.
192        return Ok(HookOutcome {
193            success: true,
194            fixup_commit: None,
195            retried: false,
196            initial_failure: false,
197            captured_output: None,
198            cancelled: false,
199        });
200    };
201
202    let from_refs = resolve_from_refs(jj, update)?;
203    let setup_steps = setup::load_steps(jj)?;
204
205    let initial = run_once(
206        jj,
207        primary_git_dir,
208        workspace_root,
209        cli_runner,
210        stage,
211        update,
212        new_commit,
213        &from_refs,
214        &setup_steps,
215        opts.all_files,
216        opts.capture_output,
217        cancel,
218    )?;
219
220    // Initial run was clean OR caller opted out of retry OR there's nothing
221    // to retry against — return as-is. (No fixup means the caller's tree is
222    // already what the hooks would produce; nothing to re-check.)
223    if !opts.retry_after_fixup || initial.success || initial.fixup_commit.is_none() {
224        return Ok(HookOutcome {
225            success: initial.success,
226            fixup_commit: initial.fixup_commit,
227            retried: false,
228            initial_failure: !initial.success,
229            captured_output: initial.captured_output,
230            cancelled: initial.cancelled,
231        });
232    }
233
234    let fixup = initial.fixup_commit.as_ref().expect("checked Some above");
235    tracing::info!(
236        "{update}: re-running hooks against fixup commit {fixup} to check for transient failure"
237    );
238    let retry = run_once(
239        jj,
240        primary_git_dir,
241        workspace_root,
242        cli_runner,
243        stage,
244        update,
245        fixup,
246        &from_refs,
247        &setup_steps,
248        opts.all_files,
249        opts.capture_output,
250        cancel,
251    )?;
252
253    // The retry should be clean (no failure, no new fixup) for the
254    // "healed by retry" verdict. Any further fixup means the tree is
255    // still drifting; bail with the original failure semantics.
256    let healed = retry.success && retry.fixup_commit.is_none();
257    // Concatenate initial + retry captured output so the caller sees
258    // both passes in order. Only relevant when capture_output is on;
259    // when off, both are None.
260    let captured_output = match (initial.captured_output, retry.captured_output) {
261        (Some(mut a), Some(b)) => {
262            a.push_str(&b);
263            Some(a)
264        }
265        (Some(a), None) => Some(a),
266        (None, Some(b)) => Some(b),
267        (None, None) => None,
268    };
269    Ok(HookOutcome {
270        // If the retry healed it, report success and surface the fixup
271        // so the user knows to advance their bookmark. If the retry
272        // *also* failed, success is whatever the retry reported and
273        // the fixup is whichever one the retry produced (which may
274        // differ from the initial one).
275        success: if healed { true } else { retry.success },
276        fixup_commit: if healed {
277            initial.fixup_commit
278        } else {
279            // The retry pass either produced a fresh fixup (chain of
280            // autofixes) or none at all (just a hard failure). Prefer
281            // the retry's fixup when it has one so the user advances
282            // their bookmark to the most recent good state; fall back
283            // to the initial fixup so we don't drop information.
284            retry.fixup_commit.or(initial.fixup_commit)
285        },
286        retried: true,
287        initial_failure: true,
288        captured_output,
289        cancelled: initial.cancelled || retry.cancelled,
290    })
291}
292
293/// Batch entrypoint: run hooks for N bookmark updates in parallel,
294/// with fail-fast cancellation across siblings.
295///
296/// One thread per update. Each thread runs the full
297/// [`run_for_update_with_cancel`] pipeline against its own
298/// ephemeral worktree — the worktrees are filesystem-isolated and
299/// don't share index locks, so per-bookmark hook backends (cargo,
300/// hk, etc.) can run truly concurrently. The shared `.git/objects/`
301/// directory is read-mostly during hook execution; the per-bookmark
302/// `jj git import` invoked at the end of `run_for_update_with_cancel`
303/// (if a fixup was produced) relies on jj's own concurrent-op
304/// reconciliation.
305///
306/// Fail-fast: every update in the batch shares one `Cancel` token.
307/// As soon as any thread observes `outcome.success == false`, it
308/// flips the token; siblings still in the middle of a multi-step
309/// hk pipeline check the token between subprocess invocations and
310/// short-circuit the rest. For an N-bookmark batch where bookmark
311/// 1 fails fmt while bookmarks 2 and 3 are doing clippy, this
312/// converts a "wait for two slow clippy runs to finish" symptom
313/// into "skip them as soon as the current step exits."
314///
315/// Use [`run_for_partitioned_updates_parallel`] when the batch
316/// represents multiple independent stacks — each stack gets its
317/// own Cancel scope so a failure in stack A doesn't cancel stack B.
318///
319/// Mandatory call-site invariant: `opts.capture_output` MUST be true.
320/// Letting N hook backends stream live to the same terminal garbles
321/// the user's view. The function asserts on this — passing
322/// `capture_output: false` is a programmer error.
323///
324/// Returns results in the same order as `updates` (not completion
325/// order). `progress_start` is invoked once per update on the thread
326/// that picks it up, right before any actual work happens (worktree
327/// creation, setup steps, hook runner); `progress` is invoked once
328/// per update on the thread that finished it. The pair lets the
329/// caller render a live spinner / "running" state per bookmark
330/// instead of just a post-hoc "passed/failed" line.
331///
332/// First subprocess error (spawn failure, etc.) aborts before
333/// returning; per-update non-zero exits are reported via
334/// [`HookOutcome::success`], not as `Err`.
335#[allow(clippy::too_many_arguments)]
336pub fn run_for_updates_parallel<S, F>(
337    jj: &JjCli,
338    primary_git_dir: &Path,
339    workspace_root: &Path,
340    cli_runner: Option<Runner>,
341    stage: Stage,
342    updates: &[BookmarkUpdate],
343    opts: RunOpts,
344    progress_start: S,
345    progress: F,
346) -> Result<Vec<HookOutcome>>
347where
348    S: Fn(usize, &BookmarkUpdate) + Send + Sync,
349    F: Fn(usize, &BookmarkUpdate, &HookOutcome) + Send + Sync,
350{
351    assert!(
352        opts.capture_output,
353        "run_for_updates_parallel requires capture_output=true; parallel runs without capture garble the terminal",
354    );
355
356    use std::sync::Mutex;
357    let progress_start = &progress_start;
358    let progress = &progress;
359    let results: Vec<Mutex<Option<Result<HookOutcome>>>> =
360        (0..updates.len()).map(|_| Mutex::new(None)).collect();
361    let results_ref = &results;
362    let cancel = Cancel::new();
363    let cancel_ref = &cancel;
364
365    std::thread::scope(|s| {
366        for (idx, update) in updates.iter().enumerate() {
367            s.spawn(move || {
368                // Fire `progress_start` before doing any work so the
369                // caller's tracker UI can flip this bookmark from
370                // pending → running while we're still inside
371                // worktree setup / setup-step / hook runner. The
372                // `cancelled` short-circuit inside `run_once` may
373                // mean the bookmark never actually executes a hook,
374                // but the start callback still fires — the tracker
375                // resolves that into a "cancelled" final state via
376                // the completion callback below.
377                progress_start(idx, update);
378                let outcome = run_for_update_with_cancel(
379                    jj,
380                    primary_git_dir,
381                    workspace_root,
382                    cli_runner,
383                    stage,
384                    update,
385                    opts,
386                    cancel_ref,
387                );
388                if let Ok(o) = &outcome {
389                    // Trip the cancellation token on any failure
390                    // (including initial-failure that ended up
391                    // healing via retry — the user wants the
392                    // siblings to stop). Cancelled outcomes don't
393                    // count as failures; they just bail out
394                    // because someone else already did.
395                    if !o.success && !o.cancelled {
396                        cancel_ref.cancel();
397                    }
398                    progress(idx, update, o);
399                }
400                *results_ref[idx].lock().unwrap() = Some(outcome);
401            });
402        }
403    });
404
405    let mut out = Vec::with_capacity(updates.len());
406    for slot in results {
407        let result = slot
408            .into_inner()
409            .unwrap()
410            .expect("thread::scope joined all threads but a slot is still None");
411        out.push(result?);
412    }
413    Ok(out)
414}
415
416/// Partitioned variant of [`run_for_updates_parallel`]. Each
417/// partition runs as an atomic fail-fast unit (siblings cancel each
418/// other within the partition); partitions are independent (a
419/// failure in one partition does NOT cancel any other).
420///
421/// Use this when the user passed `-b X -b Y` for two unrelated
422/// tips: stack X's bookmarks share a Cancel, stack Y's share a
423/// different Cancel, and stack Y keeps going to completion even
424/// if stack X fails out on its first bookmark.
425///
426/// Partitions run concurrently with each other (each partition is
427/// its own `run_for_updates_parallel` call inside a `thread::scope`).
428/// Outcomes are returned in the same shape as the input partitions
429/// (`Vec<Vec<HookOutcome>>`), in the same order.
430///
431/// `progress_start` is called as `(partition_idx, update_idx_in_partition,
432/// update)` when the thread begins work on a bookmark; `progress` is
433/// called as `(partition_idx, update_idx_in_partition, update, outcome)`
434/// when the thread finishes one update.
435#[allow(clippy::too_many_arguments)]
436pub fn run_for_partitioned_updates_parallel<S, F>(
437    jj: &JjCli,
438    primary_git_dir: &Path,
439    workspace_root: &Path,
440    cli_runner: Option<Runner>,
441    stage: Stage,
442    partitions: &[Vec<BookmarkUpdate>],
443    opts: RunOpts,
444    progress_start: S,
445    progress: F,
446) -> Result<Vec<Vec<HookOutcome>>>
447where
448    S: Fn(usize, usize, &BookmarkUpdate) + Send + Sync,
449    F: Fn(usize, usize, &BookmarkUpdate, &HookOutcome) + Send + Sync,
450{
451    assert!(
452        opts.capture_output,
453        "run_for_partitioned_updates_parallel requires capture_output=true",
454    );
455
456    use std::sync::Mutex;
457    let progress_start = &progress_start;
458    let progress = &progress;
459    // Pre-allocate the result shape. Outer index = partition;
460    // inner index = position in partition.
461    let results: Vec<Vec<Mutex<Option<Result<HookOutcome>>>>> = partitions
462        .iter()
463        .map(|p| (0..p.len()).map(|_| Mutex::new(None)).collect())
464        .collect();
465    let results_ref = &results;
466
467    std::thread::scope(|s| {
468        for (p_idx, partition) in partitions.iter().enumerate() {
469            // One Cancel token per partition — the fail-fast scope
470            // is exactly the partition.
471            let cancel = Cancel::new();
472            for (u_idx, update) in partition.iter().enumerate() {
473                let cancel = cancel.clone();
474                s.spawn(move || {
475                    // Fire start before any work so the tracker UI
476                    // can render "running" with elapsed time while
477                    // the hook subprocess is still going.
478                    progress_start(p_idx, u_idx, update);
479                    let outcome = run_for_update_with_cancel(
480                        jj,
481                        primary_git_dir,
482                        workspace_root,
483                        cli_runner,
484                        stage,
485                        update,
486                        opts,
487                        &cancel,
488                    );
489                    if let Ok(o) = &outcome {
490                        if !o.success && !o.cancelled {
491                            cancel.cancel();
492                        }
493                        progress(p_idx, u_idx, update, o);
494                    }
495                    *results_ref[p_idx][u_idx].lock().unwrap() = Some(outcome);
496                });
497            }
498        }
499    });
500
501    let mut out = Vec::with_capacity(partitions.len());
502    for partition_slots in results {
503        let mut partition_out = Vec::with_capacity(partition_slots.len());
504        for slot in partition_slots {
505            let result = slot
506                .into_inner()
507                .unwrap()
508                .expect("thread::scope joined but a slot is still None");
509            partition_out.push(result?);
510        }
511        out.push(partition_out);
512    }
513    Ok(out)
514}
515
516/// Sequential counterpart to [`run_for_updates_parallel`] for the
517/// `--hooks-sequential` opt-out path. Same contract (per-bookmark
518/// `run_for_update`, in-order results) but no thread fan-out. Output
519/// streams live by default — `opts.capture_output` is honored if
520/// set, but unlike the parallel variant it isn't required.
521#[allow(clippy::too_many_arguments)]
522pub fn run_for_updates_sequential<F>(
523    jj: &JjCli,
524    primary_git_dir: &Path,
525    workspace_root: &Path,
526    cli_runner: Option<Runner>,
527    stage: Stage,
528    updates: &[BookmarkUpdate],
529    opts: RunOpts,
530    progress: F,
531) -> Result<Vec<HookOutcome>>
532where
533    F: Fn(usize, &BookmarkUpdate, &HookOutcome),
534{
535    let mut out = Vec::with_capacity(updates.len());
536    for (idx, update) in updates.iter().enumerate() {
537        let outcome = run_for_update(
538            jj,
539            primary_git_dir,
540            workspace_root,
541            cli_runner,
542            stage,
543            update,
544            opts,
545        )?;
546        progress(idx, update, &outcome);
547        out.push(outcome);
548    }
549    Ok(out)
550}
551
552/// Internal shape returned by [`run_once`]: a single hook run plus the
553/// fixup commit (if any) it produced. This is the per-attempt building
554/// block used by [`run_for_update`] to layer retry-after-fixup logic.
555struct OnceOutcome {
556    success: bool,
557    fixup_commit: Option<String>,
558    /// `Some(buf)` iff the caller asked for capture (see
559    /// [`RunOpts::capture_output`]). Carries the concatenated
560    /// stdout+stderr of every subprocess invoked during this pass.
561    captured_output: Option<String>,
562    /// `true` iff this pass short-circuited because the cancellation
563    /// token was already set when the pass started. Distinguishes
564    /// "cancelled before doing real work" from "ran to completion
565    /// and happened to succeed" so the result-collection layer can
566    /// filter cancelled outcomes out of progress callbacks.
567    cancelled: bool,
568}
569
570/// Replace element 0 of `command_argv` (the bare runner binary name
571/// produced by `hook_command{,_all_files}` / `lefthook_command{,_all_files}`)
572/// with the resolved argv prefix from [`crate::runner::resolve_runner_argv`].
573///
574/// For the common case the prefix is a single element (an absolute path
575/// or just the bare name found on $PATH), so this is a near-no-op. For
576/// the `uv run --` wrapper case the prefix is multiple elements; we
577/// drop the placeholder name and splice in the wrapper.
578fn splice_runner_prefix(prefix: &[String], command_argv: &[String]) -> Vec<String> {
579    let mut out = Vec::with_capacity(prefix.len() + command_argv.len().saturating_sub(1));
580    out.extend(prefix.iter().cloned());
581    if command_argv.len() > 1 {
582        out.extend(command_argv[1..].iter().cloned());
583    }
584    out
585}
586
587/// One pass through the hook pipeline against a specific target commit.
588///
589/// Builds a fresh worktree at `target_commit`, runs the hook backend
590/// against each entry in `from_refs`, and, if the worktree's tree
591/// differs from `target_commit`'s tree at the end, builds a fixup
592/// commit + cleans up the temp ref / bookmark that `jj git import`
593/// creates.
594///
595/// When `capture_output` is true, every subprocess's stdout+stderr
596/// gets folded into the returned `OnceOutcome::captured_output`
597/// instead of streaming to the parent terminal. The trade is no live
598/// progress bar — the caller (typically [`run_for_updates_parallel`])
599/// replays the captured block when the pass finishes.
600///
601/// Callers (currently [`run_for_update`] and the batch entrypoint)
602/// decide whether to retry based on the returned `success` /
603/// `fixup_commit`.
604#[allow(clippy::too_many_arguments)]
605fn run_once(
606    jj: &JjCli,
607    primary_git_dir: &Path,
608    workspace_root: &Path,
609    cli_runner: Option<Runner>,
610    stage: Stage,
611    update: &BookmarkUpdate,
612    target_commit: &str,
613    from_refs: &[String],
614    setup_steps: &[SetupStep],
615    all_files: bool,
616    capture_output: bool,
617    cancel: &Cancel,
618) -> Result<OnceOutcome> {
619    if cancel.is_cancelled() {
620        return Ok(OnceOutcome {
621            success: true,
622            fixup_commit: None,
623            captured_output: None,
624            cancelled: true,
625        });
626    }
627    let wt = Worktree::create(primary_git_dir, target_commit)?;
628
629    // User-declared setup commands (e.g. `bun install`) run inside
630    // the worktree before the runner so hooks have install-time
631    // resources (`node_modules`, `.venv`, etc.) available. A
632    // non-zero exit aborts before the runner is invoked — the
633    // worktree is unhealthy and there's no point asking the
634    // runner to grade it.
635    //
636    // Output is always captured: silent on success (the daily
637    // case: `bun install` chattering about which packages it
638    // installed is noise nobody wants), included in the captured
639    // buffer on failure or when `capture_output` is on for the
640    // whole pass.
641    //
642    // A failure is converted into a `success: false` OnceOutcome
643    // rather than propagated as a hard error so the parallel
644    // runner can still classify other bookmarks per-partition.
645    // The captured setup output rides along on the `captured_output`
646    // field so it shows up in the same dump the user already sees
647    // for a hook failure.
648    let setup_captured = match setup::run_steps(setup_steps, wt.path(), workspace_root) {
649        Ok(captured) => captured,
650        Err(JjHooksError::SetupFailed {
651            name,
652            status,
653            captured,
654        }) => {
655            // Same buffer shape the hook-failure path produces: the
656            // captured stdout/stderr plus a trailing line explaining
657            // *why* the buffer ends here.
658            let mut buf = captured;
659            if !buf.ends_with('\n') {
660                buf.push('\n');
661            }
662            buf.push_str(&format!(
663                "setup step `{name}` exited with status {status}; \
664                 skipping hook runner for this bookmark\n",
665            ));
666            return Ok(OnceOutcome {
667                success: false,
668                fixup_commit: None,
669                captured_output: Some(buf),
670                cancelled: false,
671            });
672        }
673        Err(other) => return Err(other),
674    };
675
676    // Resolve the runner from the target commit's tree, not the primary
677    // workspace. `--runner` overrides; otherwise autodetect against the
678    // worktree we just checked out. If autodetect comes up empty, the
679    // commit doesn't have a hook config — silent-skip with an info log.
680    let runner = match cli_runner {
681        Some(r) => r,
682        None => {
683            let Some(r) = Runner::autodetect(wt.path())? else {
684                eprintln!(
685                    "jj-hooks: {update}: no hook-runner config in target commit; skipping hooks"
686                );
687                return Ok(OnceOutcome {
688                    success: true,
689                    fixup_commit: None,
690                    captured_output: None,
691                    cancelled: false,
692                });
693            };
694            // prek is a faster drop-in for pre-commit; prefer it when
695            // present. The override path already skips this so an explicit
696            // `--runner pre-commit` keeps the slower binary.
697            //
698            // "Present" here means resolvable through any of the layers
699            // in [`resolve_runner_argv`], not just $PATH — a prek
700            // installed only inside a venv (the issue #17 scenario) is
701            // still preferable to the pre-commit on $PATH if the user
702            // bothered to `prek install` the shim or set the config.
703            let prek_present = crate::runner::resolve_runner_argv(
704                Runner::Prek,
705                jj,
706                workspace_root,
707                primary_git_dir,
708                stage,
709            )
710            .is_ok();
711            crate::runner::prefer_prek_when_available(r, prek_present)
712        }
713    };
714
715    // Pre-check that the runner binary is on PATH. Without this, the
716    // `Command::status()` call below surfaces a libc-level
717    // `posix_spawn: No such file or directory (os error 2)` with no
718    // indication of *which* binary couldn't be found. The common case
719    // for prek users is that prek is installed only inside a Python
720    // venv — jj-hooks runs in a clean ephemeral worktree and doesn't
721    // inherit the venv's PATH, so the user sees the cryptic error
722    // and has no idea it was prek that was missing.
723    //
724    // Resolution order is (1) explicit config, (2) the path baked into
725    // the `.git/hooks/<stage>` shim by `prek install` / `pre-commit
726    // install`, (3) `uv run` when uv.lock + uv are both present,
727    // (4) plain $PATH. See `resolve_runner_argv` for details.
728    let runner_argv =
729        crate::runner::resolve_runner_argv(runner, jj, workspace_root, primary_git_dir, stage)?;
730
731    // all_files: ignore the diff range and run each runner's
732    // "lint every tracked file" command exactly once. from_refs
733    // is meaningless here — the runner sees no --from-ref/--to-ref.
734    //
735    // Default path: iterate from_refs (one per ancestor on the
736    // remote) so multi-ancestor pushes still get the full set of
737    // diff bases. Each iteration accumulates modifications in the
738    // shared worktree, mirroring how the standard pre-push pipeline
739    // builds up its fixup.
740    let mut success = true;
741    // Seed the captured buffer with the setup-step output when the
742    // caller asked us to capture. Setup output is always captured
743    // inside `run_steps` (it has to be, to attach it to a
744    // `SetupFailed` error), so it's already in hand here — we just
745    // decide whether to fold it into the per-bookmark buffer the
746    // caller will see on `--verbose` / failure.
747    let mut captured = if capture_output {
748        Some(setup_captured)
749    } else {
750        None
751    };
752    let mut cancelled = false;
753    if all_files {
754        if cancel.is_cancelled() {
755            cancelled = true;
756        } else {
757            let argv = match runner {
758                Runner::Lefthook => lefthook_command_all_files(stage),
759                _ => hook_command_all_files(runner, stage),
760            };
761            let argv = splice_runner_prefix(&runner_argv, &argv);
762            tracing::info!("running (--all-files): {:?}", argv);
763            let ok = run_subprocess(&argv, wt.path(), workspace_root, captured.as_mut())?;
764            if !ok {
765                success = false;
766            }
767        }
768    } else {
769        for from_ref in from_refs {
770            // Cancellation check between subprocess invocations. The
771            // hk/cargo subprocess itself isn't cancellable from
772            // outside, but skipping the *next* one short-circuits
773            // the rest of this bookmark's pipeline. For a typical
774            // hk config (fmt → clippy-native → clippy-wasm) this
775            // saves ~30-60s on cold caches when a parallel sibling
776            // bookmark already failed.
777            if cancel.is_cancelled() {
778                cancelled = true;
779                break;
780            }
781            let argv = match runner {
782                Runner::Lefthook => {
783                    let files = changed_files(wt.path(), from_ref, target_commit)?;
784                    lefthook_command(stage, &files)
785                }
786                _ => hook_command(runner, stage, from_ref, target_commit),
787            };
788            let argv = splice_runner_prefix(&runner_argv, &argv);
789
790            tracing::info!("running: {:?}", argv);
791            let ok = run_subprocess(&argv, wt.path(), workspace_root, captured.as_mut())?;
792            if !ok {
793                success = false;
794            }
795        }
796    }
797
798    let fixup_commit =
799        maybe_build_fixup_commit(primary_git_dir, wt.path(), target_commit, &update.bookmark)?;
800
801    if fixup_commit.is_some() {
802        // Make jj aware of the new commit. --ignore-working-copy keeps
803        // this import from racing against any concurrent `jj` process
804        // (same lock-contention rationale as in push.rs).
805        jj.run(&["git", "import", "--ignore-working-copy"])?;
806
807        // jj git import created a `jj-hooks-fixup/<bookmark>` jj bookmark
808        // from the underlying refs/heads/jj-hooks-fixup/<bookmark> ref.
809        // Clean both up immediately — the user almost always wants to
810        // either squash the fixup into the parent or move their bookmark
811        // forward themselves, not have a stale temp bookmark lying
812        // around. The commit stays addressable by hash via `jj log`,
813        // `jj show`, `jj squash --from <hash>` etc. since jj tracks it
814        // in its own commit graph independent of the ref.
815        let temp_bookmark = fixup_bookmark(&update.bookmark);
816        // `jj bookmark forget` removes the jj bookmark, but in a
817        // secondary workspace it leaves the underlying refs/heads/<name>
818        // ref alive in the primary's git dir. Explicitly delete the
819        // git ref ourselves so the cleanup is uniform.
820        let _ = jj.run(&[
821            "bookmark",
822            "forget",
823            &temp_bookmark,
824            "--ignore-working-copy",
825        ]);
826        let _ = delete_git_ref(primary_git_dir, &fixup_ref(&update.bookmark));
827    }
828
829    Ok(OnceOutcome {
830        success,
831        fixup_commit,
832        captured_output: captured,
833        cancelled,
834    })
835}
836
837/// Run a hook subprocess. When `capture` is `Some`, the child's
838/// stdout+stderr are captured into the buffer (chronological order is
839/// approximated by concatenating stdout then stderr — the runner CLIs
840/// we wrap mostly print failures to stderr so this preserves the
841/// signal even though it's not a true byte-level interleave). When
842/// `capture` is `None`, the child inherits stdio so the user sees the
843/// runner's progress bar live.
844///
845/// Returns `Ok(true)` on a zero exit, `Ok(false)` on any non-zero
846/// exit. IO errors (spawn failure, etc.) propagate as `Err`.
847fn run_subprocess(
848    argv: &[String],
849    cwd: &Path,
850    workspace_root: &Path,
851    capture: Option<&mut String>,
852) -> Result<bool> {
853    let mut cmd = Command::new(&argv[0]);
854    cmd.args(&argv[1..])
855        .current_dir(cwd)
856        .env("JJ_HOOKS_WORKSPACE", workspace_root);
857    match capture {
858        None => {
859            let status = cmd.status()?;
860            Ok(status.success())
861        }
862        Some(buf) => {
863            let output = cmd.output()?;
864            // Tag the captured block with the argv so the user can
865            // see which subprocess produced each chunk when N hook
866            // backends are multiplexed.
867            buf.push_str(&format!("$ {}\n", argv.join(" ")));
868            buf.push_str(&String::from_utf8_lossy(&output.stdout));
869            if !output.stderr.is_empty() {
870                buf.push_str(&String::from_utf8_lossy(&output.stderr));
871            }
872            if !buf.ends_with('\n') {
873                buf.push('\n');
874            }
875            Ok(output.status.success())
876        }
877    }
878}
879
880/// Resolve the `from_ref` commits to diff against. For an existing
881/// bookmark update we just use the old commit; for a new bookmark we
882/// find the heads of `::new & ::remote_bookmarks(remote)` so each
883/// already-on-remote ancestor becomes its own diff base.
884fn resolve_from_refs(jj: &JjCli, update: &BookmarkUpdate) -> Result<Vec<String>> {
885    if let Some(old) = update.old_commit.as_ref() {
886        return Ok(vec![old.clone()]);
887    }
888
889    let new = update.new_commit.as_ref().expect("not a delete here");
890    let revset = format!(
891        "heads(::{new} & ::remote_bookmarks(remote=exact:{}))",
892        update.remote
893    );
894
895    let template = r#"commit_id ++ "\n""#;
896    let out = jj.run(&[
897        "log",
898        "--no-graph",
899        "-r",
900        &revset,
901        "-T",
902        template,
903        "--ignore-working-copy",
904    ])?;
905
906    let refs: Vec<String> = out
907        .lines()
908        .map(|l| l.trim().to_owned())
909        .filter(|l| !l.is_empty())
910        .collect();
911
912    if refs.is_empty() {
913        // New bookmark on a totally fresh remote — no ancestors on the
914        // remote at all. Use the parent of new as the diff base.
915        return Ok(vec![format!("{new}^")]);
916    }
917
918    Ok(refs)
919}
920
921fn changed_files(worktree: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
922    let out = Command::new("git")
923        .args(["diff", "--name-only", "--diff-filter=ACMR"])
924        .arg(format!("{from}..{to}"))
925        .current_dir(worktree)
926        .output()?;
927    if !out.status.success() {
928        return Err(JjHooksError::JjFailed {
929            status: out.status.code().unwrap_or(-1),
930            stderr: format!(
931                "git diff --name-only failed: {}",
932                String::from_utf8_lossy(&out.stderr)
933            ),
934        });
935    }
936    Ok(String::from_utf8_lossy(&out.stdout)
937        .lines()
938        .map(|l| PathBuf::from(l.trim()))
939        .filter(|p| !p.as_os_str().is_empty())
940        .collect())
941}
942
943/// Stage everything in the worktree, hash the resulting tree, and
944/// compare against the parent commit's tree. Returns a fixup commit
945/// only when the trees actually differ — `git status --porcelain`
946/// can report a worktree as dirty (e.g. when a hook runner touched
947/// the index without changing file content; hk's auto-stage path
948/// does this even on check-only steps), but the resulting tree is
949/// often identical to the parent and an empty fixup commit is just
950/// noise that pins the bookmark to a content-equivalent revision
951/// and aborts the push.
952///
953/// Content-addressed gating eliminates the false positive: if the
954/// hooks didn't actually change any file, the write-tree OID equals
955/// the parent's tree OID and we return `None`.
956fn maybe_build_fixup_commit(
957    primary_git_dir: &Path,
958    worktree: &Path,
959    parent: &str,
960    bookmark: &str,
961) -> Result<Option<String>> {
962    // Stage everything (tracked + untracked) and hash the tree.
963    // Both are cheap on a clean checkout — `git add -A` is a no-op
964    // when nothing changed; `git write-tree` is hashing-only.
965    run_git(worktree, &["add", "-A"])?;
966    let tree = run_git_capture(worktree, &["write-tree"])?;
967
968    // Parent's tree as a content reference. `<commit>^{tree}` is
969    // the standard rev-parse spelling.
970    let parent_tree_spec = format!("{parent}^{{tree}}");
971    let parent_tree = run_git_capture(worktree, &["rev-parse", &parent_tree_spec])?;
972
973    if tree == parent_tree {
974        return Ok(None);
975    }
976
977    // Build the commit object via the *primary* git dir so the resulting
978    // commit lives in the shared object database.
979    let message = format!("jj-hooks: autofixes for {bookmark}");
980    let commit = run_git_capture_with_git_dir(
981        primary_git_dir,
982        worktree,
983        &["commit-tree", &tree, "-p", parent, "-m", &message],
984    )?;
985
986    // Anchor under refs/heads/ so `jj git import` will pick it up as a
987    // bookmark. (Refs outside refs/heads/ and refs/remotes/ are invisible
988    // to jj's git import logic.)
989    let ref_name = fixup_ref(bookmark);
990    run_git_capture_with_git_dir(
991        primary_git_dir,
992        worktree,
993        &["update-ref", &ref_name, &commit],
994    )?;
995
996    Ok(Some(commit))
997}
998
999/// The git ref where a fixup commit gets anchored for a given bookmark.
1000/// Lives under `refs/heads/` so `jj git import` picks it up as a bookmark.
1001pub fn fixup_ref(bookmark: &str) -> String {
1002    format!("refs/heads/jj-hooks-fixup/{}", sanitize_for_ref(bookmark))
1003}
1004
1005/// The jj bookmark name corresponding to `fixup_ref`.
1006pub fn fixup_bookmark(bookmark: &str) -> String {
1007    format!("jj-hooks-fixup/{}", sanitize_for_ref(bookmark))
1008}
1009
1010/// Replace characters that git rejects in ref names (per git-check-ref-format)
1011/// with `_`. Real bookmark names like `main` or `feature/foo` pass through
1012/// unchanged; synthesized names like `revset:@` (used by `jj-hp run @`) get
1013/// scrubbed so the resulting `refs/heads/jj-hooks-fixup/<name>` is valid.
1014fn sanitize_for_ref(s: &str) -> String {
1015    // Per-character offenders first; then collapse multi-char sequences
1016    // and trim the position-sensitive ones (leading `-`/`.`, trailing
1017    // `.`/`.lock`/`/`, internal `//`).
1018    let mut out: String = s
1019        .chars()
1020        .map(|c| match c {
1021            ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' | '\x7f' => '_',
1022            c if (c as u32) < 0x20 => '_',
1023            c => c,
1024        })
1025        .collect();
1026
1027    while out.contains("..") {
1028        out = out.replace("..", "__");
1029    }
1030    while out.contains("@{") {
1031        out = out.replace("@{", "@_");
1032    }
1033    if out.starts_with('-') {
1034        out.replace_range(0..1, "_");
1035    }
1036    if out.starts_with('.') {
1037        out.replace_range(0..1, "_");
1038    }
1039    if out.ends_with('.') {
1040        let n = out.len();
1041        out.replace_range(n - 1..n, "_");
1042    }
1043    if out.ends_with(".lock") {
1044        let n = out.len();
1045        out.replace_range(n - 5..n - 4, "_");
1046    }
1047    if out.ends_with('/') {
1048        let n = out.len();
1049        out.replace_range(n - 1..n, "_");
1050    }
1051    while out.contains("//") {
1052        out = out.replace("//", "/_");
1053    }
1054    if out.is_empty() {
1055        return "_".into();
1056    }
1057    out
1058}
1059
1060/// Delete a git ref in the given git dir, ignoring "ref doesn't exist"
1061/// failures. Used to clean up the temp `refs/heads/jj-hooks-fixup/<name>`
1062/// after `jj git import` + `jj bookmark forget` from a secondary
1063/// workspace (where forget leaves the underlying ref alive).
1064fn delete_git_ref(git_dir: &Path, ref_name: &str) -> Result<()> {
1065    let out = Command::new("git")
1066        .arg(format!("--git-dir={}", git_dir.display()))
1067        .args(["update-ref", "-d", ref_name])
1068        .output()?;
1069    if !out.status.success() {
1070        // Treat any failure as best-effort: if the ref didn't exist,
1071        // that's the desired state already.
1072        tracing::debug!(
1073            "git update-ref -d {ref_name} failed: {}",
1074            String::from_utf8_lossy(&out.stderr)
1075        );
1076    }
1077    Ok(())
1078}
1079
1080fn run_git(cwd: &Path, args: &[&str]) -> Result<()> {
1081    let out = Command::new("git").args(args).current_dir(cwd).output()?;
1082    if !out.status.success() {
1083        return Err(JjHooksError::JjFailed {
1084            status: out.status.code().unwrap_or(-1),
1085            stderr: format!(
1086                "git {args:?} failed: {}",
1087                String::from_utf8_lossy(&out.stderr)
1088            ),
1089        });
1090    }
1091    Ok(())
1092}
1093
1094fn run_git_capture(cwd: &Path, args: &[&str]) -> Result<String> {
1095    let out = Command::new("git").args(args).current_dir(cwd).output()?;
1096    if !out.status.success() {
1097        return Err(JjHooksError::JjFailed {
1098            status: out.status.code().unwrap_or(-1),
1099            stderr: format!(
1100                "git {args:?} failed: {}",
1101                String::from_utf8_lossy(&out.stderr)
1102            ),
1103        });
1104    }
1105    Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1106}
1107
1108fn run_git_capture_with_git_dir(git_dir: &Path, cwd: &Path, args: &[&str]) -> Result<String> {
1109    let out = Command::new("git")
1110        .arg(format!("--git-dir={}", git_dir.display()))
1111        .args(args)
1112        .current_dir(cwd)
1113        .output()?;
1114    if !out.status.success() {
1115        return Err(JjHooksError::JjFailed {
1116            status: out.status.code().unwrap_or(-1),
1117            stderr: format!(
1118                "git --git-dir={} {args:?} failed: {}",
1119                git_dir.display(),
1120                String::from_utf8_lossy(&out.stderr)
1121            ),
1122        });
1123    }
1124    Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    #[test]
1132    fn fixup_ref_for_plain_bookmark() {
1133        assert_eq!(fixup_ref("main"), "refs/heads/jj-hooks-fixup/main");
1134    }
1135
1136    #[test]
1137    fn fixup_ref_keeps_internal_slash() {
1138        // jj bookmark names commonly contain `/` (e.g. `feature/foo`) and
1139        // git accepts them as path separators inside a ref.
1140        assert_eq!(
1141            fixup_ref("feature/foo"),
1142            "refs/heads/jj-hooks-fixup/feature/foo"
1143        );
1144    }
1145
1146    #[test]
1147    fn fixup_ref_scrubs_colon() {
1148        // The bug from issue #1: `jj-hp run @` synthesizes `revset:@`.
1149        // Without sanitization, git rejects the ref with "bad name".
1150        assert_eq!(fixup_ref("revset:@"), "refs/heads/jj-hooks-fixup/revset_@");
1151    }
1152
1153    #[test]
1154    fn sanitize_replaces_each_invalid_char() {
1155        // One probe per character class git-check-ref-format rejects.
1156        assert_eq!(sanitize_for_ref("a:b"), "a_b");
1157        assert_eq!(sanitize_for_ref("a~b"), "a_b");
1158        assert_eq!(sanitize_for_ref("a^b"), "a_b");
1159        assert_eq!(sanitize_for_ref("a?b"), "a_b");
1160        assert_eq!(sanitize_for_ref("a*b"), "a_b");
1161        assert_eq!(sanitize_for_ref("a[b"), "a_b");
1162        assert_eq!(sanitize_for_ref("a\\b"), "a_b");
1163        assert_eq!(sanitize_for_ref("a b"), "a_b");
1164        assert_eq!(sanitize_for_ref("a\tb"), "a_b");
1165        assert_eq!(sanitize_for_ref("a\x7fb"), "a_b");
1166    }
1167
1168    #[test]
1169    fn sanitize_collapses_double_dot() {
1170        assert_eq!(sanitize_for_ref("a..b"), "a__b");
1171        // `..` replacement is non-overlapping: `a...b` becomes `a__.b`
1172        // (first `..` matches at positions 1-2 and gets replaced; the
1173        // remaining `.` is harmless mid-string).
1174        assert_eq!(sanitize_for_ref("a...b"), "a__.b");
1175        assert!(!sanitize_for_ref("a....b").contains(".."));
1176    }
1177
1178    #[test]
1179    fn sanitize_collapses_at_brace() {
1180        assert_eq!(sanitize_for_ref("a@{b"), "a@_b");
1181    }
1182
1183    #[test]
1184    fn sanitize_strips_leading_dash() {
1185        assert_eq!(sanitize_for_ref("-foo"), "_foo");
1186    }
1187
1188    #[test]
1189    fn sanitize_strips_leading_dot() {
1190        assert_eq!(sanitize_for_ref(".foo"), "_foo");
1191    }
1192
1193    #[test]
1194    fn sanitize_strips_trailing_dot() {
1195        assert_eq!(sanitize_for_ref("foo."), "foo_");
1196    }
1197
1198    #[test]
1199    fn sanitize_strips_trailing_dot_lock() {
1200        assert_eq!(sanitize_for_ref("foo.lock"), "foo_lock");
1201    }
1202
1203    #[test]
1204    fn sanitize_strips_trailing_slash() {
1205        assert_eq!(sanitize_for_ref("foo/"), "foo_");
1206    }
1207
1208    #[test]
1209    fn sanitize_collapses_double_slash() {
1210        assert_eq!(sanitize_for_ref("a//b"), "a/_b");
1211    }
1212
1213    #[test]
1214    fn sanitize_empty_becomes_underscore() {
1215        // Defensive: if the input is empty after some external transform,
1216        // emit a single underscore so the joined ref isn't dangling.
1217        assert_eq!(sanitize_for_ref(""), "_");
1218    }
1219
1220    #[test]
1221    fn fixup_bookmark_uses_same_sanitizer() {
1222        // fixup_bookmark feeds `jj bookmark forget` which is also strict
1223        // about colon (jj rejects bookmark names with `:` in them).
1224        assert_eq!(fixup_bookmark("revset:@"), "jj-hooks-fixup/revset_@");
1225    }
1226}