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}