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}