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            };
119
120            let report = run_checks(
121                &jj,
122                &workspace_root,
123                cli_runner,
124                stage.into(),
125                &select_argv,
126                run_opts,
127            )?;
128
129            if report.skipped {
130                execute_push(&jj, &push_argv, false)?;
131                return Ok(ExitCode::SUCCESS);
132            }
133
134            for (update, outcome) in &report.per_bookmark {
135                if !outcome.success {
136                    eprintln!("jj-hooks: {update}: hook failed");
137                }
138                if let Some(commit) = &outcome.fixup_commit {
139                    if outcome.success && outcome.retried {
140                        // Final state is good — the retry on the fixup
141                        // was clean — but the initial run failed, so
142                        // warn the user about the racy step.
143                        eprintln!(
144                            "jj-hooks: {update}: hooks modified files; re-run on fixup commit \
145                             was clean (fixup {commit})"
146                        );
147                    } else {
148                        eprintln!(
149                            "jj-hooks: {update}: hooks modified files (fixup commit {commit})"
150                        );
151                    }
152                } else if outcome.success && outcome.initial_failure {
153                    // Edge case: initial run failed without producing a
154                    // fixup, retry-after-fixup never triggered. Surface
155                    // the initial failure for context.
156                    eprintln!("jj-hooks: {update}: initial hook run reported a failure");
157                }
158            }
159
160            let advance = advance_bookmarks || advance_bookmarks_from_config(&jj);
161            let advanced = maybe_advance_bookmarks(&jj, &report, advance)?;
162            for name in advanced {
163                eprintln!("jj-hooks: advanced bookmark {name} to fixup commit");
164            }
165
166            // Abort when any bookmark either fails outright or has a
167            // fixup commit the user hasn't squashed in yet. A successful
168            // retry-after-fixup still produces a fixup_commit (the user
169            // needs to advance the bookmark to it before re-pushing), so
170            // it correctly aborts here.
171            if report.any_failure() || report.any_fixup() {
172                eprintln!("jj-hooks: aborting push");
173                return Ok(ExitCode::from(1));
174            }
175
176            execute_push(&jj, &push_argv, false)?;
177            Ok(ExitCode::SUCCESS)
178        }
179
180        Command::Run {
181            stage,
182            revset,
183            no_retry_after_fixup,
184            all_files,
185        } => {
186            let workspace_root = jj.workspace_root()?;
187            // Same per-worktree autodetect contract as the push path: the
188            // runner is picked from the target commit's own tree, not from
189            // the primary workspace. `--runner` overrides.
190            let cli_runner: Option<Runner> = cli.runner.map(Into::into);
191
192            let run_opts = crate::hooks::RunOpts {
193                retry_after_fixup: !no_retry_after_fixup,
194                all_files,
195            };
196
197            run_for_revset(
198                &jj,
199                &workspace_root,
200                cli_runner,
201                stage.into(),
202                &revset,
203                run_opts,
204            )
205        }
206
207        Command::PushTags {
208            tags,
209            all,
210            force,
211            dry_run,
212            remote,
213        } => {
214            push_tags::run(
215                &jj,
216                push_tags::PushTagsOpts {
217                    remote: &remote,
218                    tags,
219                    all,
220                    force,
221                    dry_run,
222                },
223            )?;
224            Ok(ExitCode::SUCCESS)
225        }
226
227        Command::Init => {
228            let detected = jj
229                .workspace_root()
230                .ok()
231                .and_then(|root| Runner::autodetect(&root).ok().flatten());
232            let mut prompter = InteractivePrompter;
233            let plan = init::plan(detected, &mut prompter)?;
234            let outcome = init::apply(&plan, None, None)?;
235            if outcome.alias_set {
236                eprintln!("jj-hooks: installed `aliases.push` = jj-hp push");
237            }
238            if outcome.advance_bookmarks_set {
239                eprintln!("jj-hooks: set `jj-hooks.advance-bookmarks = true`");
240            }
241            let jjui = outcome.jjui_actions_added;
242            if jjui.added_jj_push
243                || jjui.added_jj_push_selected
244                || jjui.added_binding_x_p
245                || jjui.added_binding_x_p_caps
246            {
247                eprintln!("jj-hooks: merged jjui actions/bindings into jjui config");
248            }
249            Ok(ExitCode::SUCCESS)
250        }
251
252        Command::Completions { shell } => {
253            use clap::CommandFactory;
254            use clap_complete::env::EnvCompleter;
255            use clap_complete::env::{Bash, Elvish, Fish, Powershell, Zsh};
256
257            let cmd = Cli::command();
258            // Pick the binary name dynamically from argv[0] so the script
259            // targets whichever name the user invoked (`jj-hooks` vs `jj-hp`).
260            let bin_name = std::env::args()
261                .next()
262                .and_then(|arg0| {
263                    std::path::Path::new(&arg0)
264                        .file_name()
265                        .map(|s| s.to_string_lossy().into_owned())
266                })
267                .unwrap_or_else(|| "jj-hp".into());
268
269            // Write the env-driven registration script (NOT the static
270            // completion script). Static scripts can't fire ArgValueCompleter
271            // callbacks, so bookmark / remote completion would silently fall
272            // through to file completion. The env-driven script makes the
273            // shell call us back with `COMPLETE=<shell>` set, which the
274            // CompleteEnv::complete() call at the top of run() handles.
275            let mut out = std::io::stdout();
276            let result =
277                match shell {
278                    clap_complete::Shell::Bash => Bash
279                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
280                    clap_complete::Shell::Zsh => Zsh
281                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
282                    clap_complete::Shell::Fish => Fish
283                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
284                    clap_complete::Shell::PowerShell => Powershell
285                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
286                    clap_complete::Shell::Elvish => Elvish
287                        .write_registration("COMPLETE", &bin_name, &bin_name, &bin_name, &mut out),
288                    _ => {
289                        eprintln!("jj-hooks: unsupported shell for dynamic completion");
290                        return Ok(ExitCode::from(2));
291                    }
292                };
293            // Use cmd to satisfy the unused warning. The script writers
294            // above don't need it — they reference the binary by name only.
295            let _ = cmd;
296            result.map_err(JjHooksError::Io)?;
297            Ok(ExitCode::SUCCESS)
298        }
299    }
300}
301
302fn advance_bookmarks_from_config(jj: &JjCli) -> bool {
303    matches!(
304        jj.run(&["config", "get", "jj-hooks.advance-bookmarks"])
305            .ok()
306            .map(|s| s.trim().to_owned()),
307        Some(ref v) if v == "true"
308    )
309}
310
311/// Run the configured hook runner against a jj revset, the same way
312/// `jj-hp run [REVSET]` does. Exposed as a library entrypoint so other
313/// tools (e.g. `jj-gt`) can gate their own pipelines on the same hook
314/// machinery without shelling out to the `jj-hp` binary.
315///
316/// Resolves the latest commit in `revset` as the "to" target and uses
317/// its parent as the "from" diff base. The hook backend is picked from
318/// the target commit's tree (so a runner-migration commit is gated by
319/// the runner the *target* commits to), unless `cli_runner` overrides.
320///
321/// Returns `ExitCode::SUCCESS` only when every hook step exits 0 *and*
322/// no fixup commit was produced (i.e. hooks didn't modify any files).
323/// Otherwise returns a non-zero exit code suitable for propagating from
324/// a binary's `main`.
325pub fn run_for_revset(
326    jj: &JjCli,
327    workspace_root: &std::path::Path,
328    cli_runner: Option<Runner>,
329    stage: Stage,
330    revset: &str,
331    opts: hooks::RunOpts,
332) -> Result<ExitCode, JjHooksError> {
333    match run_for_revset_outcome(jj, workspace_root, cli_runner, stage, revset, opts)? {
334        None => {
335            eprintln!("jj-hooks: revset `{revset}` is empty");
336            Ok(ExitCode::from(2))
337        }
338        Some(outcome) => {
339            if let Some(commit) = &outcome.fixup_commit {
340                if outcome.success && outcome.retried {
341                    eprintln!(
342                        "jj-hooks: hooks modified files; re-run on fixup commit was clean \
343                         (fixup {commit})"
344                    );
345                } else {
346                    eprintln!("jj-hooks: hooks modified files (fixup commit {commit})");
347                }
348            } else if outcome.success && outcome.initial_failure {
349                eprintln!("jj-hooks: initial hook run reported a failure");
350            }
351            if outcome.success && outcome.fixup_commit.is_none() {
352                Ok(ExitCode::SUCCESS)
353            } else {
354                Ok(ExitCode::from(1))
355            }
356        }
357    }
358}
359
360/// Structured variant of [`run_for_revset`] — returns `Ok(None)` for
361/// an empty revset, otherwise the per-update [`hooks::HookOutcome`].
362///
363/// Callers (other binaries that compose jj-hooks into their own
364/// pipelines) typically want to branch on `outcome.success` and
365/// `outcome.fixup_commit` rather than parse an exit code.
366///
367/// The synthesized [`bookmark_updates::BookmarkUpdate`] uses the
368/// *full revset* as the diff range:
369///
370/// - `new_commit` (the "to" / target tree the hooks see) is the
371///   single head of the revset (`heads(<revset>)`). A multi-head
372///   revset is rejected upstream — the worktree we materialise to
373///   run hooks against can only be one commit.
374/// - `old_commit` (the "from" / diff base the hooks compare
375///   against) is the parent of the lowest commit in the revset
376///   (`roots(<revset>)-`). For `main..tip` this is `main` itself,
377///   so hooks see the entire stack diff `main..tip` — same as what
378///   `git push origin tip` would push.
379///
380/// For single-commit revsets like `@` or `<sha>` this reduces to
381/// `parent → target`, the same shape the old per-tip implementation
382/// produced.
383pub fn run_for_revset_outcome(
384    jj: &JjCli,
385    workspace_root: &std::path::Path,
386    cli_runner: Option<Runner>,
387    stage: Stage,
388    revset: &str,
389    opts: hooks::RunOpts,
390) -> Result<Option<hooks::HookOutcome>, JjHooksError> {
391    // Head of the revset = the tip commit. `heads(...)` returns the
392    // unique commit in the set that no other commit in the set is
393    // an ancestor of; for a linear chain this is the topmost
394    // commit. For a multi-head revset jj will return multiple
395    // results; we limit to 1 and let the caller surface a
396    // confusing-but-not-wrong outcome rather than failing here
397    // (multi-head pre-push checks aren't a workflow this library
398    // tries to support).
399    let target = jj.run(&[
400        "log",
401        "--no-graph",
402        "-r",
403        &format!("heads({revset})"),
404        "-T",
405        "commit_id",
406        "--limit",
407        "1",
408        "--ignore-working-copy",
409    ])?;
410    let target = target.trim();
411    if target.is_empty() {
412        return Ok(None);
413    }
414
415    // From-ref = parent of the lowest commit in the revset. For
416    // `main..tip` this resolves to `main` itself, so hooks see the
417    // entire stack range. For single-commit revsets like `@`,
418    // `roots(@)-` reduces to `@-` — same shape the old code
419    // produced.
420    let parent = jj.run(&[
421        "log",
422        "--no-graph",
423        "-r",
424        &format!("roots({revset})-"),
425        "-T",
426        "commit_id",
427        "--limit",
428        "1",
429        "--ignore-working-copy",
430    ])?;
431    let parent = parent.trim().to_owned();
432
433    let update = bookmark_updates::BookmarkUpdate {
434        remote: "<local>".into(),
435        bookmark: format!("revset:{revset}"),
436        update_type: bookmark_updates::UpdateType::MoveForward,
437        old_commit: Some(parent),
438        new_commit: Some(target.to_owned()),
439    };
440
441    let primary_git_dir = jj::primary_git_dir(workspace_root)?;
442    let outcome = hooks::run_for_update(
443        jj,
444        &primary_git_dir,
445        workspace_root,
446        cli_runner,
447        stage,
448        &update,
449        opts,
450    )?;
451    Ok(Some(outcome))
452}