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}