1use colored::Colorize;
2use dialoguer::{theme::ColorfulTheme, Confirm, Input};
3use std::collections::BTreeSet;
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6use tokio::process::Command;
7
8use crate::config::SshConfig;
9use crate::utils::command_exists;
10
11pub struct AutoCommitRequest<'a> {
12 pub project_root: &'a Path,
13 pub paths: Vec<PathBuf>,
14 pub message: String,
15 pub action_label: &'a str,
16}
17
18pub enum AutoCommitResult {
19 Committed(AutoCommitOutcome),
20 Skipped(String),
21}
22
23pub struct AutoCommitOutcome {
24 pub repo_name: String,
25 pub repo_root: PathBuf,
26 pub branch: Option<String>,
27 pub commit_sha: String,
28 pub short_sha: String,
29 pub message: String,
30 pub committed_files: Vec<String>,
31}
32
33pub struct PushOutcome {
34 pub remote: String,
35 pub branch: String,
36 pub rebased: bool,
37}
38
39pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
40 if !command_exists("git") {
41 return Ok(AutoCommitResult::Skipped(
42 "Git is not installed on this machine.".to_string(),
43 ));
44 }
45
46 let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
47 {
48 Ok(root) => PathBuf::from(root),
49 Err(_) => {
50 return Ok(AutoCommitResult::Skipped(
51 "Current project is not inside a git repository.".to_string(),
52 ));
53 }
54 };
55
56 let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
57 if normalized_paths.is_empty() {
58 return Ok(AutoCommitResult::Skipped(
59 "No generated or updated files were provided for auto-commit.".to_string(),
60 ));
61 }
62
63 let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
64 add_args.extend(normalized_paths.iter().cloned());
65 git_output_owned(request.project_root, add_args).await?;
66
67 let mut diff_args = vec![
68 "diff".to_string(),
69 "--cached".to_string(),
70 "--name-only".to_string(),
71 "--".to_string(),
72 ];
73 diff_args.extend(normalized_paths.iter().cloned());
74 let committed_files = git_output_owned(request.project_root, diff_args)
75 .await?
76 .lines()
77 .map(str::trim)
78 .filter(|line| !line.is_empty())
79 .map(|line| line.replace('\\', "/"))
80 .collect::<Vec<_>>();
81
82 if committed_files.is_empty() {
83 return Ok(AutoCommitResult::Skipped(
84 "Target files did not produce any staged git diff.".to_string(),
85 ));
86 }
87
88 ensure_git_commit_identity(request.project_root).await?;
89
90 if let Err(error) =
91 commit_with_optional_hook_retry(request.project_root, &request.message).await
92 {
93 return Err(format_git_commit_failure(request.project_root, &error).await);
94 }
95
96 let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
97 let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
98 let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
99 .await
100 .ok()
101 .filter(|value| !value.is_empty() && value != "HEAD");
102
103 let outcome = AutoCommitOutcome {
104 repo_name: repo_name(&repo_root),
105 repo_root,
106 branch,
107 commit_sha,
108 short_sha,
109 message: request.message,
110 committed_files,
111 };
112
113 print_commit_summary(request.action_label, &outcome);
114
115 Ok(AutoCommitResult::Committed(outcome))
116}
117
118pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
119 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
120 .await
121 .ok()
122 .filter(|value| !value.is_empty() && value != "HEAD");
123 push_current_branch_with_name(project_root, branch.as_deref()).await
124}
125
126pub async fn push_current_branch_with_name(
127 project_root: &Path,
128 branch: Option<&str>,
129) -> Result<Option<PushOutcome>, String> {
130 if !command_exists("git") {
131 return Ok(None);
132 }
133
134 let Some(branch) = branch
135 .map(str::trim)
136 .filter(|value| !value.is_empty() && *value != "HEAD")
137 .map(ToOwned::to_owned)
138 else {
139 return Ok(None);
140 };
141
142 let remote = resolve_push_remote(project_root, &branch).await?;
143 let mut rebased = false;
144
145 match attempt_push(project_root, &remote, &branch).await {
146 Ok(()) => {}
147 Err(push_error) if is_non_fast_forward_push_error(&push_error) => {
148 println!(
149 "{} {}",
150 "Branch behind remote".bright_yellow().bold(),
151 format!("rebasing onto {}/{} then retrying push...", remote, branch)
152 .bright_white()
153 );
154 pull_rebase(project_root, &remote, &branch)
155 .await
156 .map_err(|rebase_error| {
157 format!(
158 "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
159 )
160 })?;
161 rebased = true;
162 attempt_push(project_root, &remote, &branch)
163 .await
164 .map_err(|retry_error| {
165 summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
166 })?;
167 }
168 Err(push_error) => {
169 return Err(summarize_git_push_error(
170 &remote,
171 &branch,
172 &push_error,
173 &push_error,
174 ));
175 }
176 }
177
178 Ok(Some(PushOutcome {
179 remote,
180 branch,
181 rebased,
182 }))
183}
184
185pub async fn pull_rebase_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
187 if !command_exists("git") {
188 return Ok(None);
189 }
190
191 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
192 .await
193 .ok()
194 .filter(|value| !value.is_empty() && value != "HEAD");
195 let Some(branch) = branch else {
196 return Ok(None);
197 };
198
199 let remote = resolve_push_remote(project_root, &branch).await?;
200 println!(
201 "{} {}",
202 "Rebasing".bright_cyan().bold(),
203 format!("onto {}/{}...", remote, branch).bright_white()
204 );
205 pull_rebase(project_root, &remote, &branch).await?;
206 Ok(Some(PushOutcome {
207 remote,
208 branch,
209 rebased: true,
210 }))
211}
212
213pub fn print_skip(action_label: &str, reason: &str) {
214 println!(
215 "{} {} {}",
216 "Auto-commit".bright_yellow().bold(),
217 format!("skipped for {}", action_label).bright_white(),
218 format!("({})", reason).dimmed()
219 );
220}
221
222pub fn print_push_summary(outcome: &PushOutcome) {
223 let target = format!("{}/{}", outcome.remote, outcome.branch);
224 if outcome.rebased {
225 println!(
226 "{} {} {}",
227 "Pushed".bright_green().bold(),
228 target.bright_white(),
229 "(after auto-rebase)".dimmed()
230 );
231 } else {
232 println!(
233 "{} {}",
234 "Pushed".bright_green().bold(),
235 target.bright_white()
236 );
237 }
238}
239
240async fn attempt_push(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
241 match git_output(project_root, &["push"]).await {
242 Ok(_) => Ok(()),
243 Err(push_error) => {
244 git_output(project_root, &["push", "-u", remote, branch])
246 .await
247 .map(|_| ())
248 .map_err(|fallback_error| format!("{push_error}\n{fallback_error}"))
249 }
250 }
251}
252
253async fn pull_rebase(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
254 let _ = git_output(project_root, &["fetch", remote, branch]).await;
256 git_output(project_root, &["pull", "--rebase", remote, branch]).await?;
257 Ok(())
258}
259
260pub async fn resolve_push_remote(project_root: &Path, branch: &str) -> Result<String, String> {
264 if let Ok(configured) = git_output(
265 project_root,
266 &["config", "--get", &format!("branch.{branch}.remote")],
267 )
268 .await
269 {
270 let trimmed = configured.trim();
271 if !trimmed.is_empty() {
272 return Ok(trimmed.to_string());
273 }
274 }
275
276 if let Ok(upstream) = git_output(
277 project_root,
278 &[
279 "rev-parse",
280 "--abbrev-ref",
281 "--symbolic-full-name",
282 "@{upstream}",
283 ],
284 )
285 .await
286 {
287 if let Some(remote) = split_remote_branch(upstream.trim()).map(|(remote, _)| remote) {
288 return Ok(remote);
289 }
290 }
291
292 let remotes = git_output(project_root, &["remote"]).await.unwrap_or_default();
293 let remote_names = remotes
294 .lines()
295 .map(str::trim)
296 .filter(|line| !line.is_empty())
297 .map(ToOwned::to_owned)
298 .collect::<Vec<_>>();
299
300 if remote_names.iter().any(|name| name == "origin") {
301 return Ok("origin".to_string());
302 }
303
304 remote_names.into_iter().next().ok_or_else(|| {
305 "No git remotes are configured for this repository. Add one with `git remote add <name> <url>`."
306 .to_string()
307 })
308}
309
310fn split_remote_branch(upstream: &str) -> Option<(String, String)> {
311 let (remote, branch) = upstream.split_once('/')?;
312 let remote = remote.trim();
313 let branch = branch.trim();
314 if remote.is_empty() || branch.is_empty() {
315 return None;
316 }
317 Some((remote.to_string(), branch.to_string()))
318}
319
320fn is_non_fast_forward_push_error(error: &str) -> bool {
321 let corpus = error.to_ascii_lowercase();
322 corpus.contains("non-fast-forward")
323 || corpus.contains("tip of your current branch is behind")
324 || (corpus.contains("failed to push some refs")
325 && (corpus.contains("behind")
326 || corpus.contains("rejected")
327 || corpus.contains("fetch first")))
328}
329
330fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
331 let branch = outcome
332 .branch
333 .as_deref()
334 .map(|value| format!(" on {}", value.bright_blue()))
335 .unwrap_or_default();
336 let files = if outcome.committed_files.is_empty() {
337 "(none)".dimmed().to_string()
338 } else {
339 outcome
340 .committed_files
341 .iter()
342 .map(|value| value.bright_white().to_string())
343 .collect::<Vec<_>>()
344 .join(", ")
345 };
346
347 println!(
348 "{} {}{}",
349 "Auto-commit".bright_green().bold(),
350 format!("created for {}", action_label).bright_white(),
351 branch
352 );
353 println!(
354 " {} {} {}",
355 "Repo".bright_cyan().bold(),
356 outcome.repo_name.bright_white().bold(),
357 format!("({})", outcome.repo_root.display()).dimmed()
358 );
359 println!(
360 " {} {} {}",
361 "Commit".bright_cyan().bold(),
362 outcome.short_sha.bright_green().bold(),
363 format!("({})", outcome.commit_sha).dimmed()
364 );
365 println!(
366 " {} {}",
367 "Message".bright_cyan().bold(),
368 outcome.message.bright_magenta()
369 );
370 println!(" {} {}", "Files".bright_cyan().bold(), files);
371}
372
373async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
374 let owned_args = args
375 .iter()
376 .map(|value| value.to_string())
377 .collect::<Vec<_>>();
378 git_output_owned(project_root, owned_args).await
379}
380
381async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
382 let output = Command::new("git")
383 .current_dir(project_root)
384 .args(&args)
385 .output()
386 .await
387 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
388
389 if !output.status.success() {
390 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
391 if stderr.is_empty() {
392 return Err(format!(
393 "`git {}` failed with status {}",
394 args.join(" "),
395 output.status
396 ));
397 }
398 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
399 }
400
401 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
402}
403
404async fn commit_with_optional_hook_retry(project_root: &Path, message: &str) -> Result<(), String> {
405 match git_output(project_root, &["commit", "-m", message]).await {
406 Ok(_) => Ok(()),
407 Err(error) if is_missing_lefthook_error(&error) => {
408 if std::io::stdin().is_terminal() {
409 let retry = Confirm::with_theme(&ColorfulTheme::default())
410 .with_prompt(
411 "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?",
412 )
413 .default(true)
414 .interact()
415 .map_err(|e| format!("Failed to read retry choice: {}", e))?;
416 if retry {
417 run_commit_with_hooks_disabled(project_root, message).await
418 } else {
419 Err(error)
420 }
421 } else {
422 Err(error)
423 }
424 }
425 Err(error) => Err(error),
426 }
427}
428
429async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
430 let output = Command::new("git")
431 .current_dir(project_root)
432 .env("LEFTHOOK", "0")
433 .args(["commit", "-m", message])
434 .output()
435 .await
436 .map_err(|e| format!("Failed to run `git commit -m {}`: {}", message, e))?;
437
438 if !output.status.success() {
439 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
440 if stderr.is_empty() {
441 return Err(format!(
442 "`git commit -m {}` failed with status {}",
443 message, output.status
444 ));
445 }
446 return Err(format!("`git commit -m {}` failed: {}", message, stderr));
447 }
448
449 Ok(())
450}
451
452async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
453 let current_name = git_output(project_root, &["config", "--get", "user.name"])
454 .await
455 .ok()
456 .map(|value| value.trim().to_string())
457 .filter(|value| !value.is_empty());
458 let current_email = git_output(project_root, &["config", "--get", "user.email"])
459 .await
460 .ok()
461 .map(|value| value.trim().to_string())
462 .filter(|value| !value.is_empty());
463
464 if current_name.is_some() && current_email.is_some() {
465 return Ok(());
466 }
467
468 let suggested = SshConfig::load()
469 .ok()
470 .and_then(|config| config.cli_auth)
471 .and_then(|auth| match (auth.user_name, auth.user_email) {
472 (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
473 Some((name, email))
474 }
475 _ => None,
476 });
477
478 if !std::io::stdin().is_terminal() {
479 return Err(identity_setup_hint(
480 current_name.as_deref(),
481 current_email.as_deref(),
482 suggested
483 .as_ref()
484 .map(|(name, email)| (name.as_str(), email.as_str())),
485 ));
486 }
487
488 let prefill_name = suggested
489 .as_ref()
490 .map(|(name, _)| name.clone())
491 .or_else(|| current_name.clone())
492 .unwrap_or_default();
493 let prefill_email = suggested
494 .as_ref()
495 .map(|(_, email)| email.clone())
496 .or_else(|| current_email.clone())
497 .unwrap_or_default();
498
499 let mut configured_name = current_name;
500 let mut configured_email = current_email;
501
502 if configured_name.is_none() {
503 let name: String = Input::with_theme(&ColorfulTheme::default())
504 .with_prompt("Git commit author name")
505 .with_initial_text(prefill_name)
506 .interact_text()
507 .map_err(|e| format!("Failed to read git author name: {}", e))?;
508 let trimmed = name.trim().to_string();
509 if trimmed.is_empty() {
510 return Err("Git commit author name cannot be empty.".to_string());
511 }
512 git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
513 configured_name = Some(trimmed);
514 }
515
516 if configured_email.is_none() {
517 let email: String = Input::with_theme(&ColorfulTheme::default())
518 .with_prompt("Git commit author email")
519 .with_initial_text(prefill_email)
520 .interact_text()
521 .map_err(|e| format!("Failed to read git author email: {}", e))?;
522 let trimmed = email.trim().to_string();
523 if trimmed.is_empty() {
524 return Err("Git commit author email cannot be empty.".to_string());
525 }
526 git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
527 configured_email = Some(trimmed);
528 }
529
530 if configured_name.is_some() && configured_email.is_some() {
531 Ok(())
532 } else {
533 Err("Git commit identity is still incomplete after prompting.".to_string())
534 }
535}
536
537async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
538 if is_missing_git_identity_error(error) {
539 let suggested = SshConfig::load()
540 .ok()
541 .and_then(|config| config.cli_auth)
542 .and_then(|auth| match (auth.user_name, auth.user_email) {
543 (Some(name), Some(email))
544 if !name.trim().is_empty() && !email.trim().is_empty() =>
545 {
546 Some((name, email))
547 }
548 _ => None,
549 });
550
551 return identity_setup_hint(
552 git_output(project_root, &["config", "--get", "user.name"])
553 .await
554 .ok()
555 .as_deref(),
556 git_output(project_root, &["config", "--get", "user.email"])
557 .await
558 .ok()
559 .as_deref(),
560 suggested
561 .as_ref()
562 .map(|(name, email)| (name.as_str(), email.as_str())),
563 );
564 }
565
566 if is_missing_lefthook_error(error) {
567 return format!(
568 "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
569Run `pnpm install` or `npm install` in the repo that owns the hook, or fix the hook path so it resolves from the current checkout.\n\
570If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0`.\n\
571Raw error: {error}"
572 );
573 }
574
575 format!("Git commit failed: {}", error)
576}
577
578fn identity_setup_hint(
579 current_name: Option<&str>,
580 current_email: Option<&str>,
581 suggested: Option<(&str, &str)>,
582) -> String {
583 let mut lines = vec![
584 "Git blocked the commit because your author identity is not configured in this environment."
585 .to_string(),
586 ];
587 if current_name.is_none() {
588 lines.push("Missing `user.name`.".to_string());
589 }
590 if current_email.is_none() {
591 lines.push("Missing `user.email`.".to_string());
592 }
593 if let Some((name, email)) = suggested {
594 lines.push(format!(
595 "XBP found a likely identity to prefill: {} <{}>",
596 name, email
597 ));
598 lines.push(format!("Run `git config --local user.name \"{}\"`", name));
599 lines.push(format!("Run `git config --local user.email \"{}\"`", email));
600 } else {
601 lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
602 .to_string());
603 lines.push(
604 "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
605 );
606 }
607 lines.join("\n")
608}
609
610fn is_missing_git_identity_error(error: &str) -> bool {
611 error.contains("Author identity unknown")
612 || error.contains("empty ident name")
613 || error.contains("Please tell me who you are")
614}
615
616fn is_missing_lefthook_error(error: &str) -> bool {
617 let lower = error.to_ascii_lowercase();
618 (lower.contains("lefthook") && lower.contains("module not found"))
619 || (lower.contains("lefthook") && lower.contains("cannot find module"))
620}
621
622fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
623 let mut deduped = BTreeSet::new();
624
625 for path in paths {
626 if path.as_os_str().is_empty() {
627 continue;
628 }
629
630 let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
631 relative.to_path_buf()
632 } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
633 relative
634 } else {
635 path.to_path_buf()
636 };
637
638 let rendered = normalized.to_string_lossy().replace('\\', "/");
639 let trimmed = rendered.trim();
640 if !trimmed.is_empty() && trimmed != "." {
641 deduped.insert(trimmed.to_string());
642 }
643 }
644
645 deduped.into_iter().collect()
646}
647
648fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
649 let root = project_root
650 .to_string_lossy()
651 .replace('\\', "/")
652 .trim_end_matches('/')
653 .to_string();
654 let candidate = path.to_string_lossy().replace('\\', "/");
655
656 if candidate.len() <= root.len() {
657 return None;
658 }
659
660 let (prefix, suffix) = candidate.split_at(root.len());
661 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
662 return Some(PathBuf::from(suffix.trim_start_matches('/')));
663 }
664
665 None
666}
667
668pub fn summarize_git_push_error(
669 remote: &str,
670 branch: &str,
671 primary_error: &str,
672 fallback_error: &str,
673) -> String {
674 let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
675
676 if is_non_fast_forward_push_error(&corpus) {
677 return format!(
678 "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
679 );
680 }
681
682 if corpus.contains("authentication failed")
683 || corpus.contains("could not read username")
684 || corpus.contains("403")
685 || corpus.contains("401")
686 {
687 return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
688 .to_string();
689 }
690
691 let lines = dedupe_git_error_lines(primary_error, fallback_error);
692 lines
693 .into_iter()
694 .last()
695 .unwrap_or_else(|| "git push failed".to_string())
696}
697
698fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
699 let mut seen = BTreeSet::new();
700 let mut lines = Vec::new();
701
702 for line in primary_error.lines().chain(fallback_error.lines()) {
703 let trimmed = line.trim();
704 if trimmed.is_empty() || trimmed.starts_with("hint:") {
705 continue;
706 }
707 let key = trimmed.to_ascii_lowercase();
708 if seen.insert(key) {
709 lines.push(trimmed.to_string());
710 }
711 }
712
713 lines
714}
715
716fn repo_name(path: &Path) -> String {
717 path.file_name()
718 .and_then(|value| value.to_str())
719 .filter(|value| !value.trim().is_empty())
720 .unwrap_or("repository")
721 .to_string()
722}
723
724#[cfg(test)]
725mod tests {
726 use super::{
727 is_missing_git_identity_error, is_missing_lefthook_error, is_non_fast_forward_push_error,
728 normalize_commit_paths, split_remote_branch, summarize_git_push_error,
729 };
730 use std::path::{Path, PathBuf};
731
732 #[test]
733 fn normalizes_commit_paths_relative_to_project_root() {
734 let project_root = Path::new("C:/repo");
735 let paths = vec![
736 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
737 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
738 PathBuf::from("CHANGELOG.md"),
739 ];
740
741 let normalized = normalize_commit_paths(project_root, &paths);
742
743 assert_eq!(
744 normalized,
745 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
746 );
747 }
748
749 #[test]
750 fn detects_missing_git_identity_errors() {
751 assert!(is_missing_git_identity_error(
752 "Author identity unknown\n*** Please tell me who you are."
753 ));
754 }
755
756 #[test]
757 fn detects_lefthook_module_errors() {
758 assert!(is_missing_lefthook_error(
759 "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
760 ));
761 }
762
763 #[test]
764 fn summarizes_non_fast_forward_push_errors_without_git_hints() {
765 let primary = "! [rejected] main -> main (non-fast-forward)";
766 let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
767hint: Updates were rejected because the tip of your current branch is behind
768hint: its remote counterpart. If you want to integrate the remote changes,
769hint: use 'git pull' before pushing again."#;
770
771 let summary = summarize_git_push_error("origin", "main", primary, fallback);
772
773 assert!(summary.contains("behind `origin/main`"));
774 assert!(summary.contains("git pull --rebase origin main"));
775 assert!(!summary.contains("hint:"));
776 assert!(!summary.contains("git push -u origin main"));
777 }
778
779 #[test]
780 fn summarizes_behind_errors_with_detected_remote_name() {
781 let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
782 let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
783
784 let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
785
786 assert!(summary.contains("cleanup-sql"));
787 assert!(summary.contains("git pull --rebase origin cleanup-sql"));
788 }
789
790 #[test]
791 fn detects_non_fast_forward_push_errors() {
792 assert!(is_non_fast_forward_push_error(
793 "! [rejected] main -> main (non-fast-forward)"
794 ));
795 assert!(is_non_fast_forward_push_error(
796 "failed to push some refs\ntip of your current branch is behind"
797 ));
798 assert!(!is_non_fast_forward_push_error(
799 "authentication failed for 'https://github.com/acme/repo.git'"
800 ));
801 }
802
803 #[test]
804 fn splits_upstream_into_remote_and_branch() {
805 assert_eq!(
806 split_remote_branch("origin/cleanup-sql"),
807 Some(("origin".to_string(), "cleanup-sql".to_string()))
808 );
809 assert_eq!(
810 split_remote_branch("upstream/feature/foo"),
811 Some(("upstream".to_string(), "feature/foo".to_string()))
812 );
813 assert_eq!(split_remote_branch("origin"), None);
814 }
815}