1use std::path::{Path, PathBuf};
57
58use anyhow::Result;
59use git2::{Oid, Repository};
60use serde::Serialize;
61
62use crate::git::remote::RemoteInfo;
63use crate::git::resolve_git_binary;
64use crate::git::worktree_batch::{
65 head_branch, is_false, resolve_selection, run_git_in, trimmed_stderr,
66};
67
68pub use crate::git::worktree_batch::Selection;
69
70const DEFAULT_REMOTE: &str = "origin";
74
75#[derive(Debug, Clone, Default)]
81pub struct PushOptions {
82 pub git_bin: Option<PathBuf>,
87}
88
89impl PushOptions {
90 fn git_bin(&self) -> PathBuf {
93 self.git_bin.clone().unwrap_or_else(resolve_git_binary)
94 }
95}
96
97#[derive(Debug, Clone, Serialize)]
102pub struct Plan {
103 pub worktrees: Vec<WorktreeOutcome>,
105}
106
107impl Plan {
108 #[must_use]
111 pub fn has_pending_pushes(&self) -> bool {
112 self.worktrees.iter().any(|w| w.result.is_pending())
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct WorktreeOutcome {
119 pub path: PathBuf,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub branch: Option<String>,
125 pub remote: String,
127 pub remote_branch: String,
130 #[serde(flatten)]
132 pub result: PushResult,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141#[serde(tag = "status", rename_all = "kebab-case")]
142pub enum PushResult {
143 UpToDate,
145 WouldFastForward {
148 ahead: usize,
150 },
151 WouldForce {
155 ahead: usize,
157 behind: usize,
159 },
160 WouldCreate,
165 Pushed {
167 forced: bool,
170 },
171 Created,
173 Rejected {
175 detail: String,
178 #[serde(skip_serializing_if = "is_false")]
183 stale: bool,
184 },
185 Skipped {
187 reason: SkipReason,
189 },
190}
191
192impl PushResult {
193 #[must_use]
196 pub const fn is_pending(&self) -> bool {
197 self.pending_kind().is_some()
198 }
199
200 const fn pending_kind(&self) -> Option<PushKind> {
203 match self {
204 Self::WouldFastForward { .. } => Some(PushKind::FastForward),
205 Self::WouldForce { .. } => Some(PushKind::Force),
206 Self::WouldCreate => Some(PushKind::Create),
207 _ => None,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
217#[serde(rename_all = "kebab-case")]
218pub enum SkipReason {
219 DetachedHead,
222 NotAWorktree,
224 NoRemote,
227 DefaultBranchForcePush,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum PushKind {
237 FastForward,
239 Force,
241 Create,
243}
244
245pub fn plan(selection: &Selection) -> Result<Plan> {
259 let paths = resolve_selection(selection)?;
260 let worktrees = paths.iter().map(|path| classify(path)).collect();
261 Ok(Plan { worktrees })
262}
263
264#[must_use]
271pub fn execute(plan: Plan, opts: &PushOptions) -> Vec<WorktreeOutcome> {
272 let git = opts.git_bin();
273 plan.worktrees
274 .into_iter()
275 .map(|mut outcome| {
276 if let Some(kind) = outcome.result.pending_kind() {
277 outcome.result = push_worktree(&git, &outcome, kind);
278 }
279 outcome
280 })
281 .collect()
282}
283
284fn classify(path: &Path) -> WorktreeOutcome {
292 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
293 let Ok(repo) = Repository::discover(&canon) else {
294 return WorktreeOutcome::skipped(canon, None, SkipReason::NotAWorktree);
295 };
296
297 let (branch, head_oid) = head_branch(&repo);
299 let (Some(branch), Some(head)) = (branch, head_oid) else {
300 return WorktreeOutcome::skipped(canon, None, SkipReason::DetachedHead);
301 };
302
303 let Some(target) = resolve_target(&repo, &branch) else {
304 return WorktreeOutcome::skipped(canon, Some(branch), SkipReason::NoRemote);
305 };
306
307 let outcome = |result| WorktreeOutcome {
308 path: canon.clone(),
309 branch: Some(branch.clone()),
310 remote: target.remote.clone(),
311 remote_branch: target.remote_branch.clone(),
312 result,
313 };
314
315 let Some(upstream_oid) = target.upstream_oid else {
319 return outcome(PushResult::WouldCreate);
320 };
321
322 let Some((ahead, behind)) = repo.graph_ahead_behind(head, upstream_oid).ok() else {
323 return outcome(PushResult::UpToDate);
326 };
327
328 match (ahead, behind) {
329 (0, _) => outcome(PushResult::UpToDate),
330 (ahead, 0) => outcome(PushResult::WouldFastForward { ahead }),
331 (ahead, behind) => {
332 if is_default_branch(&repo, &target) {
336 return outcome(PushResult::Skipped {
337 reason: SkipReason::DefaultBranchForcePush,
338 });
339 }
340 outcome(PushResult::WouldForce { ahead, behind })
341 }
342 }
343}
344
345struct Target {
347 remote: String,
349 remote_branch: String,
352 upstream_oid: Option<Oid>,
355}
356
357fn resolve_target(repo: &Repository, branch: &str) -> Option<Target> {
366 let refname = format!("refs/heads/{branch}");
367
368 if let Some(remote) = buf_string(repo.branch_upstream_remote(&refname)) {
369 let upstream_oid = buf_string(repo.branch_upstream_name(&refname))
370 .and_then(|name| repo.refname_to_id(&name).ok());
371 return Some(Target {
372 remote_branch: merge_ref_branch(repo, branch).unwrap_or_else(|| branch.to_string()),
373 remote,
374 upstream_oid,
375 });
376 }
377
378 Some(Target {
379 remote: fallback_remote(repo)?,
380 remote_branch: branch.to_string(),
381 upstream_oid: None,
382 })
383}
384
385fn buf_string(buf: std::result::Result<git2::Buf, git2::Error>) -> Option<String> {
388 buf.ok()?.as_str().ok().map(ToString::to_string)
389}
390
391fn merge_ref_branch(repo: &Repository, branch: &str) -> Option<String> {
395 let merge = repo
396 .config()
397 .ok()?
398 .get_string(&format!("branch.{branch}.merge"))
399 .ok()?;
400 merge.strip_prefix("refs/heads/").map(ToString::to_string)
401}
402
403fn fallback_remote(repo: &Repository) -> Option<String> {
408 let remotes = repo.remotes().ok()?;
409 let names: Vec<String> = remotes
413 .iter()
414 .flatten()
415 .flatten()
416 .map(String::from)
417 .collect();
418 if names.iter().any(|name| name == DEFAULT_REMOTE) {
419 return Some(DEFAULT_REMOTE.to_string());
420 }
421 match names.as_slice() {
422 [only] => Some(only.clone()),
423 _ => None,
424 }
425}
426
427fn is_default_branch(repo: &Repository, target: &Target) -> bool {
431 RemoteInfo::detect_main_branch_local(repo, &target.remote)
432 .is_some_and(|default| default == target.remote_branch)
433}
434
435impl WorktreeOutcome {
436 fn skipped(path: PathBuf, branch: Option<String>, reason: SkipReason) -> Self {
438 Self {
439 path,
440 branch,
441 remote: String::new(),
442 remote_branch: String::new(),
443 result: PushResult::Skipped { reason },
444 }
445 }
446}
447
448fn push_worktree(git: &Path, outcome: &WorktreeOutcome, kind: PushKind) -> PushResult {
453 let branch = outcome.branch.as_deref().unwrap_or_default();
454 let args = push_args(kind, &outcome.remote, branch, &outcome.remote_branch);
455 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
456
457 let output = match run_git_in(git, &outcome.path, &argv) {
458 Ok(output) => output,
459 Err(err) => {
460 return PushResult::Rejected {
466 detail: format!("{err:#}"),
467 stale: false,
468 };
469 }
470 };
471
472 let stdout = String::from_utf8_lossy(&output.stdout);
473 if let Some(result) = parse_porcelain(&stdout).into_iter().find_map(result_of) {
474 return result;
475 }
476 if output.status.success() {
479 return match kind {
480 PushKind::Create => PushResult::Created,
481 PushKind::Force => PushResult::Pushed { forced: true },
482 PushKind::FastForward => PushResult::Pushed { forced: false },
483 };
484 }
485 PushResult::Rejected {
486 detail: trimmed_stderr(&output),
487 stale: false,
488 }
489}
490
491fn push_args(kind: PushKind, remote: &str, local: &str, remote_branch: &str) -> Vec<String> {
502 let mut args = vec!["push".to_string(), "--porcelain".to_string()];
503 match kind {
504 PushKind::FastForward => {}
505 PushKind::Force => {
506 args.push("--force-with-lease".to_string());
507 args.push("--force-if-includes".to_string());
508 }
509 PushKind::Create => args.push("--set-upstream".to_string()),
510 }
511 args.push(remote.to_string());
512 args.push(format!("refs/heads/{local}:refs/heads/{remote_branch}"));
513 args
514}
515
516#[derive(Debug, Clone, PartialEq, Eq)]
518struct PorcelainLine {
519 flag: char,
521 summary: String,
523 reason: Option<String>,
525}
526
527fn parse_porcelain(stdout: &str) -> Vec<PorcelainLine> {
531 stdout.lines().filter_map(parse_porcelain_line).collect()
532}
533
534fn parse_porcelain_line(line: &str) -> Option<PorcelainLine> {
536 let mut fields = line.split('\t');
537 let flag_field = fields.next()?;
538 let _refs = fields.next()?;
539 let rest = fields.next()?;
540
541 let mut chars = flag_field.chars();
544 let flag = chars.next()?;
545 if chars.next().is_some() {
546 return None;
547 }
548
549 let (summary, reason) = split_reason(rest);
550 Some(PorcelainLine {
551 flag,
552 summary,
553 reason,
554 })
555}
556
557fn split_reason(rest: &str) -> (String, Option<String>) {
559 let rest = rest.trim();
560 if let Some(stripped) = rest.strip_suffix(')') {
561 if let Some(open) = stripped.rfind('(') {
562 return (
563 stripped[..open].trim().to_string(),
564 Some(stripped[open + 1..].trim().to_string()),
565 );
566 }
567 }
568 (rest.to_string(), None)
569}
570
571fn result_of(line: PorcelainLine) -> Option<PushResult> {
577 match line.flag {
578 '=' => Some(PushResult::UpToDate),
579 ' ' => Some(PushResult::Pushed { forced: false }),
580 '+' => Some(PushResult::Pushed { forced: true }),
581 '*' => Some(PushResult::Created),
582 '!' => {
583 let reason = line.reason.unwrap_or(line.summary);
584 Some(PushResult::Rejected {
585 stale: is_lease_refusal(&reason),
586 detail: reason,
587 })
588 }
589 _ => None,
590 }
591}
592
593fn is_lease_refusal(reason: &str) -> bool {
601 let reason = reason.to_ascii_lowercase();
602 reason.contains("stale info") || reason.contains("remote ref updated since checkout")
603}
604
605#[cfg(test)]
606#[allow(clippy::unwrap_used, clippy::expect_used)]
607mod tests {
608 use super::*;
609
610 use crate::git::worktree_batch::test_serial_lock;
611
612 fn serial() -> std::sync::MutexGuard<'static, ()> {
615 test_serial_lock()
616 }
617
618 #[test]
621 fn a_fast_forward_push_uses_no_force_flag_at_all() {
622 assert_eq!(
623 push_args(PushKind::FastForward, "origin", "feat", "feat"),
624 vec![
625 "push",
626 "--porcelain",
627 "origin",
628 "refs/heads/feat:refs/heads/feat"
629 ],
630 );
631 }
632
633 #[test]
634 fn a_forced_push_pairs_the_lease_with_force_if_includes() {
635 let args = push_args(PushKind::Force, "origin", "feat", "feat");
636 assert_eq!(
637 args,
638 vec![
639 "push",
640 "--porcelain",
641 "--force-with-lease",
642 "--force-if-includes",
643 "origin",
644 "refs/heads/feat:refs/heads/feat"
645 ],
646 );
647 assert!(
651 args.iter().all(|a| !a.starts_with("--force-with-lease=")),
652 "the lease must stay valueless or --force-if-includes is a no-op"
653 );
654 }
655
656 #[test]
657 fn no_push_flavour_ever_emits_bare_force() {
658 for kind in [PushKind::FastForward, PushKind::Force, PushKind::Create] {
659 let args = push_args(kind, "origin", "feat", "feat");
660 assert!(
661 !args.iter().any(|a| a == "--force" || a == "-f"),
662 "{kind:?} must never force without a lease"
663 );
664 }
665 }
666
667 #[test]
668 fn creating_a_branch_sets_its_upstream() {
669 assert_eq!(
670 push_args(PushKind::Create, "upstream", "feat", "feat"),
671 vec![
672 "push",
673 "--porcelain",
674 "--set-upstream",
675 "upstream",
676 "refs/heads/feat:refs/heads/feat"
677 ],
678 );
679 }
680
681 #[test]
682 fn the_refspec_honours_a_differing_remote_branch_name() {
683 let args = push_args(PushKind::Force, "origin", "local-name", "remote-name");
684 assert_eq!(
685 args.last().unwrap(),
686 "refs/heads/local-name:refs/heads/remote-name",
687 "a `branch.<n>.merge` pointing elsewhere must not be pushed over the local name"
688 );
689 }
690
691 #[test]
694 fn porcelain_parsing_maps_every_documented_flag() {
695 let cases = [
696 (
697 "=\trefs/heads/a:refs/heads/a\t[up to date]",
698 PushResult::UpToDate,
699 ),
700 (
701 " \trefs/heads/a:refs/heads/a\tabc123..def456",
702 PushResult::Pushed { forced: false },
703 ),
704 (
705 "+\trefs/heads/a:refs/heads/a\tabc123...def456 (forced update)",
706 PushResult::Pushed { forced: true },
707 ),
708 (
709 "*\trefs/heads/a:refs/heads/a\t[new branch]",
710 PushResult::Created,
711 ),
712 ];
713 for (line, expected) in cases {
714 let parsed = parse_porcelain(line);
715 assert_eq!(parsed.len(), 1, "failed to parse {line:?}");
716 assert_eq!(result_of(parsed[0].clone()), Some(expected), "for {line:?}");
717 }
718 }
719
720 #[test]
721 fn porcelain_parsing_skips_the_header_and_trailer() {
722 let stdout = "To git@github.com:o/r.git\n\
723 =\trefs/heads/a:refs/heads/a\t[up to date]\n\
724 Done\n";
725 let parsed = parse_porcelain(stdout);
726 assert_eq!(parsed.len(), 1, "only the status line is a status line");
727 assert_eq!(parsed[0].flag, '=');
728 }
729
730 #[test]
731 fn a_stale_lease_rejection_is_distinguished_from_an_ordinary_one() {
732 let stale = parse_porcelain("!\trefs/heads/a:refs/heads/a\t[rejected] (stale info)");
733 assert_eq!(
734 result_of(stale[0].clone()),
735 Some(PushResult::Rejected {
736 detail: "stale info".to_string(),
737 stale: true,
738 }),
739 );
740
741 let includes = parse_porcelain(
742 "!\trefs/heads/a:refs/heads/a\t[rejected] (remote ref updated since checkout)",
743 );
744 assert!(
745 matches!(result_of(includes[0].clone()), Some(PushResult::Rejected { stale, .. }) if stale),
746 "--force-if-includes wording is a lease refusal too"
747 );
748
749 let hook = parse_porcelain(
750 "!\trefs/heads/a:refs/heads/a\t[remote rejected] (pre-receive hook declined)",
751 );
752 assert!(
753 matches!(result_of(hook[0].clone()), Some(PushResult::Rejected { stale, .. }) if !stale),
754 "a server-side hook refusal is not a lease refusal"
755 );
756 }
757
758 #[test]
759 fn a_rejection_without_a_reason_falls_back_to_the_summary() {
760 let parsed = parse_porcelain("!\trefs/heads/a:refs/heads/a\t[rejected]");
761 assert_eq!(
762 result_of(parsed[0].clone()),
763 Some(PushResult::Rejected {
764 detail: "[rejected]".to_string(),
765 stale: false,
766 }),
767 );
768 }
769
770 #[test]
771 fn split_reason_leaves_a_parenthesis_free_summary_alone() {
772 assert_eq!(
773 split_reason("abc123..def456"),
774 ("abc123..def456".to_string(), None)
775 );
776 assert_eq!(
777 split_reason("[rejected] (non-fast-forward)"),
778 (
779 "[rejected]".to_string(),
780 Some("non-fast-forward".to_string())
781 )
782 );
783 assert_eq!(
784 split_reason("weird)"),
785 ("weird)".to_string(), None),
786 "a trailing `)` with no opening `(` is part of the summary, not a reason"
787 );
788 }
789
790 #[test]
791 fn a_multi_character_first_field_is_not_a_status_line() {
792 assert!(parse_porcelain("To\tgit@host:o/r.git\tsomething").is_empty());
796 assert!(parse_porcelain("\trefs/heads/a:refs/heads/a\tsummary").is_empty());
797 }
798
799 #[test]
800 fn an_unknown_status_flag_yields_no_result() {
801 let parsed = parse_porcelain("-\t:refs/heads/a\t[deleted]");
805 assert_eq!(parsed.len(), 1, "the line still parses structurally");
806 assert_eq!(result_of(parsed[0].clone()), None);
807 }
808
809 #[test]
812 fn an_unpushed_branch_would_be_created() {
813 let _guard = serial();
814 let scenario = Scenario::new();
815 let wt = scenario.add_worktree("feature-a");
816
817 let outcome = classify(&wt);
818 assert_eq!(outcome.result, PushResult::WouldCreate);
819 assert_eq!(
820 outcome.remote, "origin",
821 "an upstream-less branch falls back to origin"
822 );
823 assert_eq!(outcome.remote_branch, "feature-a");
824 }
825
826 #[test]
827 fn a_branch_ahead_of_its_upstream_would_fast_forward() {
828 let _guard = serial();
829 let scenario = Scenario::new();
830 let wt = scenario.add_worktree("feature-a");
831 scenario.publish(&wt, "feature-a");
832 scenario.commit_in(&wt, "file.txt", "local\n", "local work");
833
834 assert_eq!(
835 classify(&wt).result,
836 PushResult::WouldFastForward { ahead: 1 },
837 "ahead-only needs no lease"
838 );
839 }
840
841 #[test]
842 fn a_published_branch_with_nothing_new_is_up_to_date() {
843 let _guard = serial();
844 let scenario = Scenario::new();
845 let wt = scenario.add_worktree("feature-a");
846 scenario.publish(&wt, "feature-a");
847
848 assert_eq!(classify(&wt).result, PushResult::UpToDate);
849 }
850
851 #[test]
852 fn a_rewritten_branch_would_force() {
853 let _guard = serial();
854 let scenario = Scenario::new();
855 let wt = scenario.add_worktree("feature-a");
856 scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
857 scenario.publish(&wt, "feature-a");
858 scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
860
861 assert!(
862 matches!(
863 classify(&wt).result,
864 PushResult::WouldForce {
865 ahead: 1,
866 behind: 1
867 }
868 ),
869 "a rewritten tip diverges and needs the lease"
870 );
871 }
872
873 #[test]
874 fn a_dangling_upstream_ref_is_not_guessed_at_as_a_force() {
875 let _guard = serial();
881 let scenario = Scenario::new();
882 let wt = scenario.add_worktree("feature-a");
883 scenario.publish(&wt, "feature-a");
884 scenario.commit_in(&wt, "file.txt", "local\n", "local work");
885 assert_eq!(
886 classify(&wt).result,
887 PushResult::WouldFastForward { ahead: 1 },
888 "precondition: an intact upstream classifies normally"
889 );
890
891 let tracking = scenario.local.join(".git/refs/remotes/origin/feature-a");
894 std::fs::create_dir_all(tracking.parent().unwrap()).unwrap();
895 std::fs::write(&tracking, "0123456789abcdef0123456789abcdef01234567\n").unwrap();
896
897 assert_eq!(classify(&wt).result, PushResult::UpToDate);
898 }
899
900 #[test]
901 fn a_detached_head_is_skipped() {
902 let _guard = serial();
903 let scenario = Scenario::new();
904 let wt = scenario.add_worktree("feature-a");
905 scenario.git_in(&wt, &["checkout", "--detach"]);
906
907 assert_eq!(
908 classify(&wt).result,
909 PushResult::Skipped {
910 reason: SkipReason::DetachedHead
911 },
912 );
913 }
914
915 #[test]
916 fn a_non_worktree_path_is_skipped_rather_than_failing_the_batch() {
917 let dir = tempfile::tempdir().unwrap();
918 assert_eq!(
919 classify(dir.path()).result,
920 PushResult::Skipped {
921 reason: SkipReason::NotAWorktree
922 },
923 );
924 }
925
926 #[test]
927 fn a_dirty_worktree_is_not_a_skip() {
928 let _guard = serial();
931 let scenario = Scenario::new();
932 let wt = scenario.add_worktree("feature-a");
933 scenario.publish(&wt, "feature-a");
934 scenario.commit_in(&wt, "file.txt", "committed\n", "work");
935 std::fs::write(wt.join("keep.txt"), "uncommitted\n").unwrap();
936
937 assert_eq!(
938 classify(&wt).result,
939 PushResult::WouldFastForward { ahead: 1 },
940 "a dirty tree must not suppress a push"
941 );
942 }
943
944 #[test]
947 fn force_pushing_the_remote_default_branch_is_refused() {
948 let _guard = serial();
949 let scenario = Scenario::new();
950 scenario.git_in(&scenario.local, &["commit", "--amend", "-m", "rewritten"]);
952
953 let outcome = classify(&scenario.local);
954 assert_eq!(
955 outcome.result,
956 PushResult::Skipped {
957 reason: SkipReason::DefaultBranchForcePush
958 },
959 "a force-push to the default branch publishes a rewrite to everyone",
960 );
961 }
962
963 #[test]
964 fn fast_forwarding_the_remote_default_branch_stays_allowed() {
965 let _guard = serial();
966 let scenario = Scenario::new();
967 scenario.commit_in(&scenario.local, "file.txt", "more\n", "more");
968
969 assert_eq!(
970 classify(&scenario.local).result,
971 PushResult::WouldFastForward { ahead: 1 },
972 "an ordinary push to the default branch destroys nothing",
973 );
974 }
975
976 #[test]
979 fn a_rewritten_branch_is_force_pushed_with_the_lease() {
980 let _guard = serial();
981 let scenario = Scenario::new();
982 let wt = scenario.add_worktree("feature-a");
983 scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
984 scenario.publish(&wt, "feature-a");
985 scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
986 let rewritten = scenario.head_oid(&wt);
987
988 let plan = plan(&Selection::Paths(vec![wt])).unwrap();
989 assert!(plan.has_pending_pushes());
990 let outcomes = execute(plan, &PushOptions::default());
991
992 assert_eq!(outcomes.len(), 1);
993 assert_eq!(
994 outcomes[0].result,
995 PushResult::Pushed { forced: true },
996 "unexpected outcome: {:?}",
997 outcomes[0],
998 );
999 assert_eq!(
1000 scenario.origin_oid("refs/heads/feature-a"),
1001 Some(rewritten),
1002 "the remote must carry the rewritten tip",
1003 );
1004 }
1005
1006 #[test]
1007 fn an_unpublished_branch_is_created_with_its_upstream() {
1008 let _guard = serial();
1009 let scenario = Scenario::new();
1010 let wt = scenario.add_worktree("feature-a");
1011 scenario.commit_in(&wt, "file.txt", "new\n", "new work");
1012 let tip = scenario.head_oid(&wt);
1013
1014 let plan = plan(&Selection::Paths(vec![wt.clone()])).unwrap();
1015 let outcomes = execute(plan, &PushOptions::default());
1016
1017 assert_eq!(outcomes[0].result, PushResult::Created);
1018 assert_eq!(scenario.origin_oid("refs/heads/feature-a"), Some(tip));
1019 assert_eq!(classify(&wt).result, PushResult::UpToDate);
1021 }
1022
1023 #[test]
1024 fn a_lease_refusal_is_reported_rather_than_forced_through() {
1025 let _guard = serial();
1026 let scenario = Scenario::new();
1027 let wt = scenario.add_worktree("feature-a");
1028 scenario.commit_in(&wt, "file.txt", "worktree first\n", "first");
1029 scenario.publish(&wt, "feature-a");
1030 let before = scenario.origin_oid("refs/heads/feature-a");
1031
1032 scenario.advance_origin("refs/heads/feature-a", "theirs\n");
1035 let theirs = scenario.origin_oid("refs/heads/feature-a");
1036 assert_ne!(before, theirs);
1037
1038 scenario.git_in(&wt, &["commit", "--amend", "-m", "rewritten"]);
1039 let plan = plan(&Selection::Paths(vec![wt])).unwrap();
1040 let outcomes = execute(plan, &PushOptions::default());
1041
1042 match &outcomes[0].result {
1043 PushResult::Rejected { stale, detail } => assert!(
1044 *stale,
1045 "a lease refusal must be flagged so the report can name `git fetch`; got {detail:?}"
1046 ),
1047 other => panic!("expected the lease to refuse, got {other:?}"),
1048 }
1049 assert_eq!(
1050 scenario.origin_oid("refs/heads/feature-a"),
1051 theirs,
1052 "their commit must survive",
1053 );
1054 }
1055
1056 #[test]
1057 fn a_batch_continues_past_a_skipped_worktree() {
1058 let _guard = serial();
1059 let scenario = Scenario::new();
1060 let pushable = scenario.add_worktree("feature-a");
1061 scenario.commit_in(&pushable, "file.txt", "new\n", "new work");
1062 let detached = scenario.add_worktree("feature-b");
1063 scenario.git_in(&detached, &["checkout", "--detach"]);
1064
1065 let plan = plan(&Selection::Paths(vec![detached, pushable])).unwrap();
1066 let outcomes = execute(plan, &PushOptions::default());
1067
1068 assert_eq!(outcomes.len(), 2, "selection order is preserved");
1069 assert_eq!(
1070 outcomes[0].result,
1071 PushResult::Skipped {
1072 reason: SkipReason::DetachedHead
1073 },
1074 );
1075 assert_eq!(outcomes[1].result, PushResult::Created);
1076 }
1077
1078 #[test]
1079 fn all_selects_every_worktree_of_the_repository() {
1080 let _guard = serial();
1081 let scenario = Scenario::new();
1082 scenario.add_worktree("feature-a");
1083 scenario.add_worktree("feature-b");
1084
1085 let plan = plan(&Selection::All {
1086 base: scenario.local,
1087 })
1088 .unwrap();
1089 assert_eq!(
1090 plan.worktrees.len(),
1091 3,
1092 "the main working tree is a target like any other (ADR-0060)"
1093 );
1094 }
1095
1096 #[test]
1099 fn a_repository_with_no_remote_has_nowhere_to_publish() {
1100 let _guard = serial();
1101 let dir = tempfile::tempdir().unwrap();
1102 let local = dir.path().join("solo");
1103 std::fs::create_dir_all(&local).unwrap();
1104 git_at(&local, &["init", "-b", "main"]);
1105 config_repo(&local, "Test", "test@example.com");
1106 std::fs::write(local.join("file.txt"), "x\n").unwrap();
1107 git_at(&local, &["add", "file.txt"]);
1108 git_at(&local, &["commit", "-m", "first"]);
1109
1110 assert_eq!(
1111 classify(&local).result,
1112 PushResult::Skipped {
1113 reason: SkipReason::NoRemote
1114 },
1115 );
1116 }
1117
1118 #[test]
1119 fn a_sole_non_origin_remote_is_the_fallback_destination() {
1120 let _guard = serial();
1123 let dir = tempfile::tempdir().unwrap();
1124 let local = dir.path().join("solo");
1125 std::fs::create_dir_all(&local).unwrap();
1126 git_at(&local, &["init", "-b", "main"]);
1127 config_repo(&local, "Test", "test@example.com");
1128 std::fs::write(local.join("file.txt"), "x\n").unwrap();
1129 git_at(&local, &["add", "file.txt"]);
1130 git_at(&local, &["commit", "-m", "first"]);
1131 git_at(&local, &["remote", "add", "upstream", "/nonexistent.git"]);
1132
1133 let outcome = classify(&local);
1134 assert_eq!(outcome.result, PushResult::WouldCreate);
1135 assert_eq!(outcome.remote, "upstream");
1136 }
1137
1138 #[test]
1139 fn several_remotes_without_origin_are_too_ambiguous_to_guess() {
1140 let _guard = serial();
1141 let dir = tempfile::tempdir().unwrap();
1142 let local = dir.path().join("solo");
1143 std::fs::create_dir_all(&local).unwrap();
1144 git_at(&local, &["init", "-b", "main"]);
1145 config_repo(&local, "Test", "test@example.com");
1146 std::fs::write(local.join("file.txt"), "x\n").unwrap();
1147 git_at(&local, &["add", "file.txt"]);
1148 git_at(&local, &["commit", "-m", "first"]);
1149 git_at(&local, &["remote", "add", "upstream", "/a.git"]);
1150 git_at(&local, &["remote", "add", "fork", "/b.git"]);
1151
1152 assert_eq!(
1153 classify(&local).result,
1154 PushResult::Skipped {
1155 reason: SkipReason::NoRemote
1156 },
1157 "publishing to an arbitrary one of several remotes would be a guess",
1158 );
1159 }
1160
1161 fn outcome_at(path: &Path, result: PushResult) -> WorktreeOutcome {
1166 WorktreeOutcome {
1167 path: path.to_path_buf(),
1168 branch: Some("feat".to_string()),
1169 remote: "origin".to_string(),
1170 remote_branch: "feat".to_string(),
1171 result,
1172 }
1173 }
1174
1175 fn execute_against_stub(script: &str, result: PushResult) -> PushResult {
1181 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
1182 let _guard = shim_lock();
1183 let dir = tempfile::tempdir().unwrap();
1184 let stub = dir.path().join("git-stub");
1185 write_exec_script(&stub, script);
1186
1187 let opts = PushOptions {
1188 git_bin: Some(stub),
1189 };
1190 let plan = Plan {
1191 worktrees: vec![outcome_at(dir.path(), result)],
1192 };
1193 retry_on_etxtbsy(|| {
1195 let outcomes = execute(plan.clone(), &opts);
1196 let result = outcomes.into_iter().next().unwrap().result;
1197 match &result {
1198 PushResult::Rejected { detail, .. } if detail.contains("Text file busy") => {
1199 Err(anyhow::anyhow!(std::io::Error::from_raw_os_error(26)))
1200 }
1201 _ => Ok(result),
1202 }
1203 })
1204 .unwrap()
1205 }
1206
1207 #[test]
1208 fn a_git_that_cannot_be_spawned_is_reported_not_panicked() {
1209 let dir = tempfile::tempdir().unwrap();
1212 let opts = PushOptions {
1213 git_bin: Some(dir.path().join("no-such-git")),
1214 };
1215 let plan = Plan {
1216 worktrees: vec![outcome_at(dir.path(), PushResult::WouldCreate)],
1217 };
1218
1219 let outcomes = execute(plan, &opts);
1220 assert!(
1221 matches!(
1222 &outcomes[0].result,
1223 PushResult::Rejected { stale: false, detail } if detail.contains("failed to execute")
1224 ),
1225 "unexpected outcome: {:?}",
1226 outcomes[0].result,
1227 );
1228 }
1229
1230 #[test]
1231 fn a_silent_successful_git_is_trusted_by_its_exit_status() {
1232 let cases = [
1236 (PushResult::WouldCreate, PushResult::Created),
1237 (
1238 PushResult::WouldForce {
1239 ahead: 1,
1240 behind: 1,
1241 },
1242 PushResult::Pushed { forced: true },
1243 ),
1244 (
1245 PushResult::WouldFastForward { ahead: 1 },
1246 PushResult::Pushed { forced: false },
1247 ),
1248 ];
1249 for (planned, expected) in cases {
1250 assert_eq!(
1251 execute_against_stub("#!/bin/sh\nexit 0\n", planned.clone()),
1252 expected,
1253 "for a planned {planned:?}",
1254 );
1255 }
1256 }
1257
1258 #[test]
1259 fn a_silent_failing_git_is_reported_with_its_stderr() {
1260 let result = execute_against_stub(
1261 "#!/bin/sh\necho 'fatal: could not read from remote' >&2\nexit 128\n",
1262 PushResult::WouldFastForward { ahead: 1 },
1263 );
1264 assert_eq!(
1265 result,
1266 PushResult::Rejected {
1267 detail: "fatal: could not read from remote".to_string(),
1268 stale: false,
1269 },
1270 );
1271 }
1272
1273 #[test]
1274 fn a_porcelain_status_line_wins_over_the_exit_status() {
1275 let result = execute_against_stub(
1278 "#!/bin/sh\n\
1279 echo 'To /origin.git'\n\
1280 printf '!\\trefs/heads/feat:refs/heads/feat\\t[rejected] (stale info)\\n'\n\
1281 echo 'Done'\n\
1282 exit 1\n",
1283 PushResult::WouldForce {
1284 ahead: 1,
1285 behind: 1,
1286 },
1287 );
1288 assert_eq!(
1289 result,
1290 PushResult::Rejected {
1291 detail: "stale info".to_string(),
1292 stale: true,
1293 },
1294 );
1295 }
1296
1297 struct Scenario {
1303 root: tempfile::TempDir,
1304 origin: PathBuf,
1305 local: PathBuf,
1306 }
1307
1308 impl Scenario {
1309 fn new() -> Self {
1310 let root = tempfile::tempdir().unwrap();
1311 let origin = root.path().join("origin.git");
1312 let local = root.path().join("local");
1313 std::fs::create_dir_all(&origin).unwrap();
1314 std::fs::create_dir_all(&local).unwrap();
1315 git_at(&origin, &["init", "--bare", "-b", "main"]);
1316 git_at(&local, &["init", "-b", "main"]);
1317 config_repo(&local, "Test", "test@example.com");
1318 std::fs::write(local.join("file.txt"), "first\n").unwrap();
1319 std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
1320 git_at(&local, &["add", "file.txt", "keep.txt"]);
1321 git_at(&local, &["commit", "-m", "first"]);
1322 git_at(
1323 &local,
1324 &["remote", "add", "origin", origin.to_str().unwrap()],
1325 );
1326 git_at(&local, &["push", "-u", "origin", "main"]);
1327 Self {
1328 root,
1329 origin,
1330 local,
1331 }
1332 }
1333
1334 fn add_worktree(&self, name: &str) -> PathBuf {
1336 let path = self.root.path().join(name);
1337 git_at(
1338 &self.local,
1339 &[
1340 "worktree",
1341 "add",
1342 "-b",
1343 name,
1344 path.to_str().unwrap(),
1345 "main",
1346 ],
1347 );
1348 std::fs::canonicalize(&path).unwrap()
1349 }
1350
1351 fn publish(&self, wt: &Path, branch: &str) {
1353 git_at(wt, &["push", "-u", "origin", branch]);
1354 }
1355
1356 fn commit_in(&self, wt: &Path, file: &str, content: &str, msg: &str) {
1358 std::fs::write(wt.join(file), content).unwrap();
1359 git_at(wt, &["add", file]);
1360 git_at(wt, &["commit", "-m", msg]);
1361 }
1362
1363 fn git_in(&self, wt: &Path, args: &[&str]) {
1365 git_at(wt, args);
1366 }
1367
1368 fn advance_origin(&self, refname: &str, content: &str) {
1372 let repo = Repository::open_bare(&self.origin).unwrap();
1373 let parent = repo
1374 .find_commit(repo.refname_to_id(refname).unwrap())
1375 .unwrap();
1376 let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
1377 let blob = repo.blob(content.as_bytes()).unwrap();
1378 builder.insert("file.txt", blob, 0o100_644).unwrap();
1379 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
1380 let sig = git2::Signature::now("Other", "other@example.com").unwrap();
1381 repo.commit(Some(refname), &sig, &sig, "theirs", &tree, &[&parent])
1382 .unwrap();
1383 }
1384
1385 fn origin_oid(&self, refname: &str) -> Option<Oid> {
1387 Repository::open_bare(&self.origin)
1388 .unwrap()
1389 .refname_to_id(refname)
1390 .ok()
1391 }
1392
1393 fn head_oid(&self, wt: &Path) -> Oid {
1395 Repository::open(wt)
1396 .unwrap()
1397 .head()
1398 .unwrap()
1399 .target()
1400 .unwrap()
1401 }
1402 }
1403
1404 fn config_repo(dir: &Path, name: &str, email: &str) {
1405 git_at(dir, &["config", "user.name", name]);
1406 git_at(dir, &["config", "user.email", email]);
1407 git_at(dir, &["config", "commit.gpgsign", "false"]);
1408 }
1409
1410 fn git_at(dir: &Path, args: &[&str]) {
1411 let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
1412 assert!(
1413 output.status.success(),
1414 "git {args:?} in {} failed: {}",
1415 dir.display(),
1416 String::from_utf8_lossy(&output.stderr)
1417 );
1418 }
1419}