Skip to main content

jj_hooks/
lib.rs

1//! Library entrypoint shared by the `jj-hooks` and `jj-hp` binaries.
2//!
3//! Both binaries are identical — `jj-hp` is just a shorter name that's
4//! easier to type and that we route the `jj push` alias through.
5
6pub mod bookmark_updates;
7pub mod cli;
8pub mod completions;
9pub mod error;
10pub mod hooks;
11pub mod init;
12pub mod jj;
13pub mod push;
14pub mod push_tags;
15pub mod runner;
16pub mod setup;
17pub mod worktree;
18
19use std::process::ExitCode;
20
21use clap::FromArgMatches;
22use tracing_subscriber::EnvFilter;
23
24use crate::cli::{Cli, Command};
25use crate::error::JjHooksError;
26use crate::init::InteractivePrompter;
27use crate::jj::JjCli;
28use crate::push::{execute_push, maybe_advance_bookmarks, run_checks};
29use crate::runner::{Runner, Stage};
30
31/// Parse CLI args, dispatch to a subcommand, and return the process exit
32/// code. Both `bin/jj-hooks` and `bin/jj-hp` are trivial wrappers around
33/// this function.
34pub fn run() -> ExitCode {
35    // Handle dynamic completion requests *before* anything else. When the
36    // shell calls us back with `COMPLETE=<shell>` set (via the script
37    // emitted by the `completions` subcommand), CompleteEnv runs the
38    // ArgValueCompleter callbacks and exits — we never reach `Cli::parse`.
39    use clap::CommandFactory;
40    clap_complete::CompleteEnv::with_factory(Cli::command).complete();
41
42    // Dispatch CLI parsing through a command whose `name` matches the
43    // invoked binary name (argv[0]'s file_name). Both `jj-hooks` and
44    // `jj-hp` share this entrypoint, so without this swap clap's
45    // `#[command(name = "jj-hooks")]` would make `jj-hp --version` print
46    // `jj-hooks 0.3.x` — wrong identifier, and the homebrew tap formula
47    // test catches it. Bonus: `--help` headers are also self-correct.
48    let bin_name = std::env::args()
49        .next()
50        .and_then(|arg0| {
51            std::path::Path::new(&arg0)
52                .file_name()
53                .map(|s| s.to_string_lossy().into_owned())
54        })
55        .unwrap_or_else(|| "jj-hooks".into());
56    // clap's `Command::name`/`bin_name` require `Into<Str>` which only
57    // accepts `&'static str` (not `&str` with a shorter lifetime). The
58    // `bin_name` String is built from argv[0] at runtime; leak it once
59    // so the slice satisfies the lifetime bound. The leak is process-
60    // lifetime (one allocation per `run()` call, which is at most one
61    // per process), so it's effectively free.
62    let bin_name_static: &'static str = Box::leak(bin_name.into_boxed_str());
63    let cmd = Cli::command()
64        .name(bin_name_static)
65        .bin_name(bin_name_static);
66    let cli = Cli::from_arg_matches(&cmd.get_matches()).unwrap_or_else(|e| e.exit());
67
68    let _ = tracing_subscriber::fmt()
69        .with_env_filter(
70            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&cli.log_level)),
71        )
72        .with_target(false)
73        .without_time()
74        .try_init();
75
76    match dispatch(cli) {
77        Ok(code) => code,
78        Err(e) => {
79            eprintln!("jj-hooks: {e}");
80            ExitCode::from(1)
81        }
82    }
83}
84
85fn dispatch(cli: Cli) -> Result<ExitCode, JjHooksError> {
86    let jj = JjCli::new(std::env::current_dir()?);
87
88    match cli.command {
89        Command::Push {
90            advance_bookmarks,
91            stage,
92            push,
93            dry_run,
94            no_retry_after_fixup,
95        } => {
96            let workspace_root = jj.workspace_root()?;
97            // Argv that's just the bookmark selection (no --dry-run) — used
98            // for the dry-run probe that figures out which bookmarks would
99            // change. Adding --dry-run here would double up since the probe
100            // already adds it.
101            let select_argv = crate::cli::push_argv(&push, false);
102            // Argv used to actually push (includes --dry-run if requested).
103            let push_argv = crate::cli::push_argv(&push, dry_run);
104
105            // Resolve the runner per-update inside `run_checks` so a
106            // runner-migration commit (e.g. one that deletes lefthook.yml
107            // and adds hk.pkl) is gated by the runner the *target* commit
108            // commits to, not the runner the primary workspace happens
109            // to have on disk right now. The `--runner` CLI flag still
110            // overrides this for users who need to force a specific runner.
111            let cli_runner: Option<Runner> = cli.runner.map(Into::into);
112
113            let run_opts = crate::hooks::RunOpts {
114                retry_after_fixup: !no_retry_after_fixup,
115                // push always uses the diff range — the bookmark's ref
116                // bounds are the whole point.
117                all_files: false,
118                // jj-hp push is a single-bookmark CLI invocation; the
119                // user wants the runner's live progress bar in their
120                // terminal. Capture is only needed by the multi-update
121                // parallel batch API used by jj-gt.
122                capture_output: false,
123            };
124
125            let report = run_checks(
126                &jj,
127                &workspace_root,
128                cli_runner,
129                stage.into(),
130                &select_argv,
131                run_opts,
132            )?;
133
134            if report.skipped {
135                execute_push(&jj, &push_argv, false)?;
136                return Ok(ExitCode::SUCCESS);
137            }
138
139            for (update, outcome) in &report.per_bookmark {
140                if !outcome.success {
141                    eprintln!("jj-hooks: {update}: hook failed");
142                    // Setup-step failures synthesize a `captured_output`
143                    // buffer (the captured stdout/stderr plus a
144                    // trailing line naming the failing step) so the
145                    // user has the context they need to fix it.
146                    // Regular hook failures in live (non-capture) mode
147                    // have already streamed their output to the
148                    // terminal, so they have `captured_output: None`
149                    // here. Either way: dump the buffer when present.
150                    if let Some(captured) = &outcome.captured_output {
151                        for line in captured.lines() {
152                            eprintln!("│ {line}");
153                        }
154                    }
155                }
156                if let Some(commit) = &outcome.fixup_commit {
157                    if outcome.success && outcome.retried {
158                        // Final state is good — the retry on the fixup
159                        // was clean — but the initial run failed, so
160                        // warn the user about the racy step.
161                        eprintln!(
162                            "jj-hooks: {update}: hooks modified files; re-run on fixup commit \
163                             was clean (fixup {commit})"
164                        );
165                    } else {
166                        eprintln!(
167                            "jj-hooks: {update}: hooks modified files (fixup commit {commit})"
168                        );
169                    }
170                } else if outcome.success && outcome.initial_failure {
171                    // Edge case: initial run failed without producing a
172                    // fixup, retry-after-fixup never triggered. Surface
173                    // the initial failure for context.
174                    eprintln!("jj-hooks: {update}: initial hook run reported a failure");
175                }
176            }
177
178            let advance = advance_bookmarks || advance_bookmarks_from_config(&jj);
179            let advanced = maybe_advance_bookmarks(&jj, &report, advance)?;
180            for name in advanced {
181                eprintln!("jj-hooks: advanced bookmark {name} to fixup commit");
182            }
183
184            // Abort when any bookmark either fails outright or has a
185            // fixup commit the user hasn't squashed in yet. A successful
186            // retry-after-fixup still produces a fixup_commit (the user
187            // needs to advance the bookmark to it before re-pushing), so
188            // it correctly aborts here.
189            if report.any_failure() || report.any_fixup() {
190                eprintln!("jj-hooks: aborting push");
191                return Ok(ExitCode::from(1));
192            }
193
194            execute_push(&jj, &push_argv, false)?;
195            Ok(ExitCode::SUCCESS)
196        }
197
198        Command::Run {
199            stage,
200            revset,
201            no_retry_after_fixup,
202            all_files,
203        } => {
204            let workspace_root = jj.workspace_root()?;
205            // Same per-worktree autodetect contract as the push path: the
206            // runner is picked from the target commit's own tree, not from
207            // the primary workspace. `--runner` overrides.
208            let cli_runner: Option<Runner> = cli.runner.map(Into::into);
209
210            let run_opts = crate::hooks::RunOpts {
211                retry_after_fixup: !no_retry_after_fixup,
212                all_files,
213                capture_output: false,
214            };
215
216            run_for_revset(
217                &jj,
218                &workspace_root,
219                cli_runner,
220                stage.into(),
221                &revset,
222                run_opts,
223            )
224        }
225
226        Command::PushTags {
227            tags,
228            all,
229            force,
230            dry_run,
231            remote,
232        } => {
233            push_tags::run(
234                &jj,
235                push_tags::PushTagsOpts {
236                    remote: &remote,
237                    tags,
238                    all,
239                    force,
240                    dry_run,
241                },
242            )?;
243            Ok(ExitCode::SUCCESS)
244        }
245
246        Command::Init => {
247            let detected = jj
248                .workspace_root()
249                .ok()
250                .and_then(|root| Runner::autodetect(&root).ok().flatten());
251            let mut prompter = InteractivePrompter;
252            let plan = init::plan(detected, &mut prompter)?;
253            let outcome = init::apply(&plan, None, None)?;
254            if outcome.alias_set {
255                eprintln!("jj-hooks: installed `aliases.push` = jj-hp push");
256            }
257            if outcome.advance_bookmarks_set {
258                eprintln!("jj-hooks: set `jj-hooks.advance-bookmarks = true`");
259            }
260            let jjui = outcome.jjui_actions_added;
261            if jjui.added_jj_push
262                || jjui.added_jj_push_selected
263                || jjui.added_binding_x_p
264                || jjui.added_binding_x_p_caps
265            {
266                eprintln!("jj-hooks: merged jjui actions/bindings into jjui config");
267            }
268            Ok(ExitCode::SUCCESS)
269        }
270
271        Command::Completions { shell } => {
272            use clap::CommandFactory;
273            use clap_complete::env::EnvCompleter;
274            use clap_complete::env::{Bash, Elvish, Fish, Powershell, Zsh};
275
276            let cmd = Cli::command();
277            // Pick the binary name dynamically from argv[0] so the script
278            // targets whichever name the user invoked (`jj-hooks` vs `jj-hp`).
279            let bin_name = std::env::args()
280                .next()
281                .and_then(|arg0| {
282                    std::path::Path::new(&arg0)
283                        .file_name()
284                        .map(|s| s.to_string_lossy().into_owned())
285                })
286                .unwrap_or_else(|| "jj-hp".into());
287
288            // Write the env-driven registration script (NOT the static
289            // completion script). Static scripts can't fire ArgValueCompleter
290            // callbacks, so bookmark / remote completion would silently fall
291            // through to file completion. The env-driven script makes the
292            // shell call us back with `COMPLETE=<shell>` set, which the
293            // CompleteEnv::complete() call at the top of run() handles.
294            let mut out = std::io::stdout();
295            let result =
296                match shell {
297                    clap_complete::Shell::Bash => Bash
298                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
299                    clap_complete::Shell::Zsh => Zsh
300                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
301                    clap_complete::Shell::Fish => Fish
302                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
303                    clap_complete::Shell::PowerShell => Powershell
304                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
305                    clap_complete::Shell::Elvish => Elvish
306                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
307                    _ => {
308                        eprintln!("jj-hooks: unsupported shell for dynamic completion");
309                        return Ok(ExitCode::from(2));
310                    }
311                };
312            // Use cmd to satisfy the unused warning. The script writers
313            // above don't need it — they reference the binary by name only.
314            let _ = cmd;
315            result.map_err(JjHooksError::Io)?;
316            Ok(ExitCode::SUCCESS)
317        }
318    }
319}
320
321fn advance_bookmarks_from_config(jj: &JjCli) -> bool {
322    matches!(
323        jj.run(&["config", "get", "jj-hooks.advance-bookmarks"])
324            .ok()
325            .map(|s| s.trim().to_owned()),
326        Some(ref v) if v == "true"
327    )
328}
329
330/// Run the configured hook runner against a jj revset, the same way
331/// `jj-hp run [REVSET]` does. Exposed as a library entrypoint so other
332/// tools (e.g. `jj-gt`) can gate their own pipelines on the same hook
333/// machinery without shelling out to the `jj-hp` binary.
334///
335/// Resolves the latest commit in `revset` as the "to" target and uses
336/// its parent as the "from" diff base. The hook backend is picked from
337/// the target commit's tree (so a runner-migration commit is gated by
338/// the runner the *target* commits to), unless `cli_runner` overrides.
339///
340/// Returns `ExitCode::SUCCESS` only when every hook step exits 0 *and*
341/// no fixup commit was produced (i.e. hooks didn't modify any files).
342/// Otherwise returns a non-zero exit code suitable for propagating from
343/// a binary's `main`.
344pub fn run_for_revset(
345    jj: &JjCli,
346    workspace_root: &std::path::Path,
347    cli_runner: Option<Runner>,
348    stage: Stage,
349    revset: &str,
350    opts: hooks::RunOpts,
351) -> Result<ExitCode, JjHooksError> {
352    match run_for_revset_outcome(jj, workspace_root, cli_runner, stage, revset, opts)? {
353        None => {
354            eprintln!("jj-hooks: revset `{revset}` is empty");
355            Ok(ExitCode::from(2))
356        }
357        Some(outcome) => {
358            if let Some(commit) = &outcome.fixup_commit {
359                if outcome.success && outcome.retried {
360                    eprintln!(
361                        "jj-hooks: hooks modified files; re-run on fixup commit was clean \
362                         (fixup {commit})"
363                    );
364                } else {
365                    eprintln!("jj-hooks: hooks modified files (fixup commit {commit})");
366                }
367            } else if outcome.success && outcome.initial_failure {
368                eprintln!("jj-hooks: initial hook run reported a failure");
369            }
370            if outcome.success && outcome.fixup_commit.is_none() {
371                Ok(ExitCode::SUCCESS)
372            } else {
373                Ok(ExitCode::from(1))
374            }
375        }
376    }
377}
378
379/// Structured variant of [`run_for_revset`] — returns `Ok(None)` for
380/// an empty revset, otherwise the per-update [`hooks::HookOutcome`].
381///
382/// Callers (other binaries that compose jj-hooks into their own
383/// pipelines) typically want to branch on `outcome.success` and
384/// `outcome.fixup_commit` rather than parse an exit code.
385///
386/// The synthesized [`bookmark_updates::BookmarkUpdate`] uses the
387/// *full revset* as the diff range:
388///
389/// - `new_commit` (the "to" / target tree the hooks see) is the
390///   single head of the revset (`heads(<revset>)`). A multi-head
391///   revset is rejected upstream — the worktree we materialise to
392///   run hooks against can only be one commit.
393/// - `old_commit` (the "from" / diff base the hooks compare
394///   against) is the parent of the lowest commit in the revset
395///   (`roots(<revset>)-`). For `main..tip` this is `main` itself,
396///   so hooks see the entire stack diff `main..tip` — same as what
397///   `git push origin tip` would push.
398///
399/// For single-commit revsets like `@` or `<sha>` this reduces to
400/// `parent → target`, the same shape the old per-tip implementation
401/// produced.
402pub fn run_for_revset_outcome(
403    jj: &JjCli,
404    workspace_root: &std::path::Path,
405    cli_runner: Option<Runner>,
406    stage: Stage,
407    revset: &str,
408    opts: hooks::RunOpts,
409) -> Result<Option<hooks::HookOutcome>, JjHooksError> {
410    // Head of the revset = the tip commit. `heads(...)` returns the
411    // unique commit in the set that no other commit in the set is
412    // an ancestor of; for a linear chain this is the topmost
413    // commit. For a multi-head revset jj will return multiple
414    // results; we limit to 1 and let the caller surface a
415    // confusing-but-not-wrong outcome rather than failing here
416    // (multi-head pre-push checks aren't a workflow this library
417    // tries to support).
418    let target = jj.run(&[
419        "log",
420        "--no-graph",
421        "-r",
422        &format!("heads({revset})"),
423        "-T",
424        "commit_id",
425        "--limit",
426        "1",
427        "--ignore-working-copy",
428    ])?;
429    let target = target.trim();
430    if target.is_empty() {
431        return Ok(None);
432    }
433
434    // From-ref = parent of the lowest commit in the revset. For
435    // `main..tip` this resolves to `main` itself, so hooks see the
436    // entire stack range. For single-commit revsets like `@`,
437    // `roots(@)-` reduces to `@-` — same shape the old code
438    // produced.
439    let parent = jj.run(&[
440        "log",
441        "--no-graph",
442        "-r",
443        &format!("roots({revset})-"),
444        "-T",
445        "commit_id",
446        "--limit",
447        "1",
448        "--ignore-working-copy",
449    ])?;
450    let parent = parent.trim().to_owned();
451
452    let update = bookmark_updates::BookmarkUpdate {
453        remote: "<local>".into(),
454        bookmark: format!("revset:{revset}"),
455        update_type: bookmark_updates::UpdateType::MoveForward,
456        old_commit: Some(parent),
457        new_commit: Some(target.to_owned()),
458    };
459
460    let primary_git_dir = jj::primary_git_dir(workspace_root)?;
461    let outcome = hooks::run_for_update(
462        jj,
463        &primary_git_dir,
464        workspace_root,
465        cli_runner,
466        stage,
467        &update,
468        opts,
469    )?;
470    Ok(Some(outcome))
471}