Skip to main content

jj_hooks/
push.rs

1//! Push pipeline: dry-run parse → per-bookmark hook → push or abort.
2
3use std::path::Path;
4use std::process::Command;
5
6use crate::bookmark_updates::{BookmarkUpdate, UpdateType, parse_git_push_dry_run};
7use crate::error::{JjHooksError, Result};
8use crate::hooks::{HookOutcome, RunOpts, run_for_update};
9use crate::jj::{self, JjCli};
10use crate::runner::{Runner, Stage};
11
12#[derive(Debug, Clone)]
13pub struct PushReport {
14    /// Per-bookmark hook outcomes.
15    pub per_bookmark: Vec<(BookmarkUpdate, HookOutcome)>,
16    /// Set true if there was nothing to do (no config, no updates, or only
17    /// deletes). The caller falls through to a plain `jj git push`.
18    pub skipped: bool,
19}
20
21impl PushReport {
22    pub fn any_failure(&self) -> bool {
23        self.per_bookmark.iter().any(|(_, o)| !o.success)
24    }
25
26    pub fn any_fixup(&self) -> bool {
27        self.per_bookmark
28            .iter()
29            .any(|(_, o)| o.fixup_commit.is_some())
30    }
31}
32
33/// Run hooks for every bookmark that would be pushed by `jj git push <push_args>`.
34///
35/// `cli_runner` is `None` when the user did not pass `--runner`. In that
36/// case `run_for_update` autodetects the runner from each target commit's
37/// own tree (so a runner-migration commit picks the new runner). When
38/// `Some(r)`, the user's choice is honored as-is for every update.
39pub fn run_checks(
40    jj: &JjCli,
41    workspace_root: &Path,
42    cli_runner: Option<Runner>,
43    stage: Stage,
44    push_args: &[String],
45    run_opts: RunOpts,
46) -> Result<PushReport> {
47    let updates = dry_run_updates(jj, push_args)?;
48
49    if updates.is_empty() {
50        tracing::info!("nothing to push, skipping hooks");
51        return Ok(PushReport {
52            per_bookmark: vec![],
53            skipped: true,
54        });
55    }
56
57    let non_deletes: Vec<_> = updates
58        .into_iter()
59        .filter(|u| u.update_type != UpdateType::Delete)
60        .collect();
61
62    if non_deletes.is_empty() {
63        tracing::info!("only deletions to push, skipping hooks");
64        return Ok(PushReport {
65            per_bookmark: vec![],
66            skipped: true,
67        });
68    }
69
70    let primary_git_dir = jj::primary_git_dir(workspace_root)?;
71
72    let mut per_bookmark = Vec::with_capacity(non_deletes.len());
73    for update in non_deletes {
74        match cli_runner {
75            Some(r) => tracing::info!("{update}: running {} hooks", r.bin()),
76            None => tracing::info!("{update}: autodetecting runner inside target worktree"),
77        }
78        let outcome = run_for_update(
79            jj,
80            &primary_git_dir,
81            workspace_root,
82            cli_runner,
83            stage,
84            &update,
85            run_opts,
86        )?;
87        per_bookmark.push((update, outcome));
88    }
89
90    Ok(PushReport {
91        per_bookmark,
92        skipped: false,
93    })
94}
95
96/// If `advance_bookmarks` is true, point each local bookmark with a fixup
97/// commit at that fixup commit, and forget the temporary
98/// `jj-hooks-fixup/<name>` bookmark that `jj git import` created when it
99/// picked up our `refs/heads/jj-hooks-fixup/<name>` ref. Returns the list
100/// of bookmarks that were advanced.
101pub fn maybe_advance_bookmarks(
102    jj: &JjCli,
103    report: &PushReport,
104    advance_bookmarks: bool,
105) -> Result<Vec<String>> {
106    if !advance_bookmarks {
107        return Ok(vec![]);
108    }
109    let mut advanced = vec![];
110    for (update, outcome) in &report.per_bookmark {
111        if let Some(commit) = &outcome.fixup_commit {
112            let argv = advance_bookmark_argv(&update.bookmark, commit);
113            let argv: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
114            jj.run(&argv)?;
115            // The temp jj-hooks-fixup bookmark was already forgotten by
116            // `hooks::run_for_update` after `jj git import` — nothing to
117            // clean up here.
118            advanced.push(update.bookmark.clone());
119        }
120    }
121    Ok(advanced)
122}
123
124/// Build the argv for `jj bookmark set` to advance a local bookmark to its
125/// fixup commit. `--ignore-working-copy` keeps this from snapshotting the
126/// user's working copy and racing against any other `jj` process they might
127/// be running in parallel — bookmark targets come from `commit` (the fixup
128/// hash), not from the working copy state.
129pub(crate) fn advance_bookmark_argv(bookmark: &str, commit: &str) -> Vec<String> {
130    vec![
131        "bookmark".into(),
132        "set".into(),
133        bookmark.into(),
134        "-r".into(),
135        commit.into(),
136        "--allow-backwards".into(),
137        "--ignore-working-copy".into(),
138    ]
139}
140
141fn dry_run_updates(
142    jj: &JjCli,
143    push_args: &[String],
144) -> Result<std::collections::HashSet<BookmarkUpdate>> {
145    let args = dry_run_argv(push_args);
146    let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
147    let output = jj.run_capture_stderr(&argv)?;
148    tracing::debug!("dry-run output:\n{output}");
149    parse_git_push_dry_run(&output)
150}
151
152/// Build the argv for the `jj git push --dry-run` probe that resolves which
153/// bookmarks would be updated. `--ignore-working-copy` avoids contending for
154/// the op lock with any concurrent `jj` invocation — the probe only reads
155/// bookmark state, not the user's working copy.
156pub(crate) fn dry_run_argv(push_args: &[String]) -> Vec<String> {
157    let mut args = vec![
158        "git".into(),
159        "push".into(),
160        "--dry-run".into(),
161        "--ignore-working-copy".into(),
162    ];
163    args.extend(push_args.iter().cloned());
164    args
165}
166
167/// Run the actual `jj git push` after hooks succeeded.
168pub fn execute_push(jj: &JjCli, push_args: &[String], dry_run: bool) -> Result<()> {
169    let args = execute_push_argv(push_args, dry_run);
170    let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
171    let status = Command::new("jj")
172        .args(&argv)
173        .current_dir(jj.cwd())
174        .status()?;
175    if !status.success() {
176        return Err(JjHooksError::JjFailed {
177            status: status.code().unwrap_or(-1),
178            stderr: "jj git push failed".into(),
179        });
180    }
181    Ok(())
182}
183
184/// Build the argv for the final `jj git push`. `--ignore-working-copy` is
185/// what makes `jj-hp push` safe to run while the user is doing unrelated
186/// `jj` work in another shell: hooks have already run against the target
187/// commits in an ephemeral worktree, so the push doesn't need the user's
188/// working copy. Without this flag, jj snapshots/updates the working copy
189/// around the push and bails with "Concurrent checkout" when another `jj`
190/// process held the op lock.
191pub(crate) fn execute_push_argv(push_args: &[String], dry_run: bool) -> Vec<String> {
192    let mut args = vec!["git".into(), "push".into(), "--ignore-working-copy".into()];
193    args.extend(push_args.iter().cloned());
194    if dry_run {
195        args.push("--dry-run".into());
196    }
197    args
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn execute_push_argv_includes_ignore_working_copy() {
206        // Regression for issue #3: `jj git push` must run with
207        // `--ignore-working-copy` so it doesn't fight for the op lock with
208        // a concurrent `jj` process (`jj new`, `jj edit`, etc.) in another
209        // shell. Without this, the push aborts with "Concurrent checkout".
210        let argv = execute_push_argv(&["-b".into(), "main".into()], false);
211        assert!(
212            argv.iter().any(|a| a == "--ignore-working-copy"),
213            "execute_push must pass --ignore-working-copy: {argv:?}"
214        );
215        assert_eq!(argv[0], "git");
216        assert_eq!(argv[1], "push");
217    }
218
219    #[test]
220    fn execute_push_argv_appends_dry_run_when_set() {
221        let argv = execute_push_argv(&[], true);
222        assert!(argv.iter().any(|a| a == "--dry-run"));
223        assert!(argv.iter().any(|a| a == "--ignore-working-copy"));
224    }
225
226    #[test]
227    fn execute_push_argv_passes_through_caller_args() {
228        let argv = execute_push_argv(
229            &[
230                "-b".into(),
231                "feature".into(),
232                "--allow-new".into(),
233                "--remote".into(),
234                "origin".into(),
235            ],
236            false,
237        );
238        for needle in ["-b", "feature", "--allow-new", "--remote", "origin"] {
239            assert!(
240                argv.iter().any(|a| a == needle),
241                "expected `{needle}` in argv: {argv:?}"
242            );
243        }
244    }
245
246    #[test]
247    fn dry_run_argv_includes_ignore_working_copy() {
248        // The dry-run probe runs first and is the most common race victim:
249        // the bookmark-resolution step would fail before hooks ever start.
250        let argv = dry_run_argv(&["-b".into(), "main".into()]);
251        assert!(
252            argv.iter().any(|a| a == "--ignore-working-copy"),
253            "dry-run argv must include --ignore-working-copy: {argv:?}"
254        );
255        assert!(argv.iter().any(|a| a == "--dry-run"));
256    }
257
258    #[test]
259    fn advance_bookmark_argv_includes_ignore_working_copy() {
260        // Post-autofix `jj bookmark set` runs after hooks completed — the
261        // user may already have moved to another change, so this is the
262        // most likely site for a working-copy snapshot race.
263        let argv = advance_bookmark_argv("main", "deadbeef");
264        assert!(
265            argv.iter().any(|a| a == "--ignore-working-copy"),
266            "advance-bookmark argv must include --ignore-working-copy: {argv:?}"
267        );
268        assert_eq!(argv[0], "bookmark");
269        assert_eq!(argv[1], "set");
270        assert_eq!(argv[2], "main");
271        assert!(argv.iter().any(|a| a == "--allow-backwards"));
272    }
273}