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