1use 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 pub per_bookmark: Vec<(BookmarkUpdate, HookOutcome)>,
16 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
33pub 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
96pub 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 advanced.push(update.bookmark.clone());
119 }
120 }
121 Ok(advanced)
122}
123
124pub(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
152pub(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
167pub 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
184pub(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 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 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 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}