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}