1use std::path::Path;
5
6use crate::config::{parse_git_config_maybe_bool, ConfigSet};
7use crate::environment::Environment;
8use crate::error::Result;
9use crate::fetch_head::merge_object_ids_hex;
10use crate::merge_base::ahead_behind_of_target_commits;
11use crate::objects::ObjectId;
12use crate::push_report::{PushRefResult, PushRefStatus};
13use crate::refs;
14use crate::refs::store::{Expected, RawRef, RefUpdate, RefUpdateFlags};
15use crate::refspec::{parse_fetch_refspec, RefspecItem};
16use crate::repo::Repository;
17use crate::rev_parse::abbreviate_ref_name;
18use crate::rev_parse::{peel_to_commit_for_merge_base, resolve_revision_for_range_end};
19use crate::state::{resolve_head, HeadState, InProgressOperation, WtStatusState};
20use crate::transfer::PushRefSpec;
21use crate::transfer::{match_positive, ref_excluded};
22
23struct FetchRefspecs {
25 positive: Vec<RefspecItem>,
26 negative: Vec<RefspecItem>,
27}
28
29pub fn remote_tracking_ref_is_mapped(repo: &Repository, tracking_ref: &str) -> Result<bool> {
33 let Some(rest) = tracking_ref.strip_prefix("refs/remotes/") else {
34 return Ok(false);
35 };
36 let Some((remote, branch)) = rest.split_once('/') else {
37 return Ok(false);
38 };
39 let config = repo.config()?;
40 let refspecs = FetchRefspecs::load(&config, remote);
41 let remote_ref = format!("refs/heads/{branch}");
42 Ok(refspecs.tracking_ref(remote, &remote_ref).as_deref() == Some(tracking_ref))
43}
44
45#[must_use]
47pub fn upstream_config_for_branch_ref(upstream_full: &str) -> Option<(String, String)> {
48 if let Some(rest) = upstream_full.strip_prefix("refs/remotes/") {
49 let (remote, branch) = rest.split_once('/')?;
50 return Some((remote.to_owned(), format!("refs/heads/{branch}")));
51 }
52 upstream_full
53 .strip_prefix("refs/heads/")
54 .map(|branch| (".".to_owned(), format!("refs/heads/{branch}")))
55}
56
57#[must_use]
62pub fn remote_tracking_ref_for_remote_ref(
63 config: &ConfigSet,
64 remote_name: &str,
65 remote_ref: &str,
66) -> Option<String> {
67 FetchRefspecs::load(config, remote_name).tracking_ref(remote_name, remote_ref)
68}
69
70impl FetchRefspecs {
71 fn load(config: &ConfigSet, remote_name: &str) -> Self {
72 let (negative, positive) = config
73 .get_all(&format!("remote.{remote_name}.fetch"))
74 .iter()
75 .filter_map(|spec| parse_fetch_refspec(spec).ok())
76 .partition(|item| item.negative);
77 Self { positive, negative }
78 }
79
80 fn tracking_ref(&self, remote_name: &str, remote_ref: &str) -> Option<String> {
84 if ref_excluded(remote_ref, &self.negative) {
85 return None;
86 }
87 match match_positive(remote_ref, &self.positive) {
88 Some(dst) => dst,
89 None if self.positive.is_empty() => remote_ref
90 .strip_prefix("refs/heads/")
91 .map(|branch| format!("refs/remotes/{remote_name}/{branch}")),
92 None => None,
93 }
94 }
95}
96
97pub fn write_branch_upstream_config(
101 repo: &Repository,
102 branch_short: &str,
103 remote: &str,
104 merge_ref: &str,
105) -> Result<()> {
106 let handle = repo.config_handle();
107 handle.set_local(&format!("branch.{branch_short}.remote"), remote)?;
108 handle.set_local(&format!("branch.{branch_short}.merge"), merge_ref)?;
109 Ok(())
110}
111
112pub fn apply_push_set_upstream(
118 repo: &Repository,
119 remote: &str,
120 specs: &[PushRefSpec],
121 results: &[PushRefResult],
122) -> Result<Vec<String>> {
123 let spec_by_dst: std::collections::HashMap<&str, &PushRefSpec> =
124 specs.iter().map(|s| (s.dst.as_str(), s)).collect();
125 let mut updated = Vec::new();
126 for result in results {
127 if !matches!(result.status, PushRefStatus::Ok | PushRefStatus::UpToDate) {
128 continue;
129 }
130 let Some(spec) = spec_by_dst.get(result.remote_ref.as_str()) else {
131 continue;
132 };
133 let Some(src_ref) = spec.src_ref.as_deref() else {
134 continue;
135 };
136 let Some(branch_short) = src_ref.strip_prefix("refs/heads/") else {
137 continue;
138 };
139 if !result.remote_ref.starts_with("refs/heads/") {
140 continue;
141 }
142 write_branch_upstream_config(repo, branch_short, remote, &result.remote_ref)?;
143 updated.push(branch_short.to_owned());
144 }
145 Ok(updated)
146}
147
148pub fn apply_push_remote_tracking_updates(
166 git_dir: &Path,
167 remote_name: &str,
168 results: &[PushRefResult],
169) -> Result<()> {
170 if !tracks_pushes_to(remote_name) {
171 return Ok(());
172 }
173 let Ok(config) = ConfigSet::load(&Environment::empty(), Some(git_dir), true) else {
174 return Ok(());
175 };
176 let store = refs::store::open_ref_store(git_dir)?;
177 commit_push_tracking_updates(store.as_ref(), &config, remote_name, results)
178}
179
180pub(crate) fn apply_push_remote_tracking_updates_in(
188 repo: &Repository,
189 remote_name: &str,
190 results: &[PushRefResult],
191) -> Result<()> {
192 if !tracks_pushes_to(remote_name) {
193 return Ok(());
194 }
195 let Ok(config) = repo.config() else {
196 return Ok(());
197 };
198 commit_push_tracking_updates(repo.refs(), &config, remote_name, results)
199}
200
201fn tracks_pushes_to(remote_name: &str) -> bool {
203 !(remote_name.contains('/') || remote_name.starts_with('.'))
204}
205
206fn commit_push_tracking_updates(
207 store: &dyn refs::store::RefStore,
208 config: &ConfigSet,
209 remote_name: &str,
210 results: &[PushRefResult],
211) -> Result<()> {
212 let refspecs = FetchRefspecs::load(config, remote_name);
213 let batch: Vec<RefUpdate> = results
214 .iter()
215 .filter(|result| matches!(result.status, PushRefStatus::Ok | PushRefStatus::UpToDate))
216 .filter_map(|result| {
217 let name = refspecs.tracking_ref(remote_name, &result.remote_ref)?;
218 let current = store.resolve(&name).ok();
219 let new_value = match result.new_oid {
220 Some(oid) if current == Some(oid) => return None,
221 Some(oid) => Some(RawRef::Direct(oid)),
222 None if current.is_none() => return None,
223 None => None,
224 };
225 Some(RefUpdate {
226 name,
227 new_value,
228 expected: Expected::Any,
229 reflog: None,
230 flags: RefUpdateFlags::default(),
231 })
232 })
233 .collect();
234 refs::commit_owned_ref_updates_on_store(store, batch)
235}
236
237#[must_use]
244pub fn upstream_tracking_full_ref(repo: &Repository, branch_short: &str) -> Option<String> {
245 let config = repo.config().ok()?;
246 let remote = config.get(&format!("branch.{branch_short}.remote"))?;
247 let merge = config.get(&format!("branch.{branch_short}.merge"))?;
248 upstream_ref_for_merge(&config, remote.trim(), &merge)
249}
250
251#[must_use]
253pub(crate) fn branch_upstream_full_ref(config: &ConfigSet, branch_short: &str) -> Option<String> {
254 let remote = config.get(&format!("branch.{branch_short}.remote"))?;
255 let merge = config.get(&format!("branch.{branch_short}.merge"))?;
256 upstream_ref_for_merge(config, remote.trim(), &merge)
257}
258
259pub(crate) fn upstream_ref_for_merge(
267 config: &ConfigSet,
268 remote: &str,
269 merge: &str,
270) -> Option<String> {
271 let merge = merge.trim();
272 let merge_ref = if merge.starts_with("refs/") {
273 merge.to_owned()
274 } else {
275 format!("refs/heads/{merge}")
276 };
277 if remote == "." {
278 return Some(merge_ref);
279 }
280 FetchRefspecs::load(config, remote).tracking_ref(remote, &merge_ref)
281}
282
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub enum BaseSource {
286 Upstream,
288 Explicit,
290 ConfiguredTarget,
292 DefaultTarget,
294 Target,
296}
297
298#[derive(Clone, Debug, PartialEq, Eq)]
300pub struct ComparisonBase {
301 pub name: String,
304 pub full_ref: String,
306 pub oid: ObjectId,
308 pub source: BaseSource,
310}
311
312pub const DEFAULT_TARGET_CANDIDATES: [&str; 4] = ["origin/master", "origin/main", "master", "main"];
314
315#[derive(Clone, Debug, PartialEq, Eq)]
317pub struct TargetSpec(pub String);
318
319#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct TargetComparisonOptions {
322 pub target: Option<TargetSpec>,
324 pub list_limit: usize,
326 pub include_behind: bool,
328}
329
330#[derive(Clone, Debug, PartialEq, Eq)]
332pub struct TargetComparison {
333 pub branch: String,
335 pub base: ComparisonBase,
337 pub ahead: usize,
339 pub behind: usize,
341 pub newest_ahead: Vec<ObjectId>,
343 pub newest_behind: Vec<ObjectId>,
345}
346
347fn resolve_optional(repo: &Repository, refname: &str) -> Option<ObjectId> {
350 repo.refs().resolve(refname).ok()
351}
352
353fn ref_tip_as_commit(repo: &Repository, oid: ObjectId) -> Result<ObjectId> {
354 if !repo.odb().exists(&oid) {
355 return Ok(oid);
356 }
357 match peel_to_commit_for_merge_base(repo, oid) {
358 Ok(commit) => Ok(commit),
359 Err(crate::error::Error::ObjectNotFound(_)) => Ok(oid),
360 Err(err) => Err(err),
361 }
362}
363
364fn comparison_base_from_candidate(
365 repo: &Repository,
366 candidate: &str,
367 full_ref: &str,
368 oid: ObjectId,
369 source: BaseSource,
370) -> Result<ComparisonBase> {
371 Ok(ComparisonBase {
372 name: candidate.to_owned(),
373 full_ref: full_ref.to_owned(),
374 oid: ref_tip_as_commit(repo, oid)?,
375 source,
376 })
377}
378
379fn try_resolve_target_candidate(
380 repo: &Repository,
381 candidate: &str,
382 source: BaseSource,
383) -> Result<Option<ComparisonBase>> {
384 for full_ref in target_candidate_refnames(candidate) {
385 if let Some(oid) = resolve_optional(repo, &full_ref) {
386 return Ok(Some(comparison_base_from_candidate(
387 repo, candidate, &full_ref, oid, source,
388 )?));
389 }
390 }
391 Ok(None)
392}
393
394fn discover_target_branch(repo: &Repository) -> Result<Option<ComparisonBase>> {
395 let config = repo.config()?;
396 let configured = config
397 .get("target.branch")
398 .map(|value| value.trim().to_owned())
399 .filter(|value| !value.is_empty());
400 if let Some(candidate) = configured {
401 if let Some(base) =
402 try_resolve_target_candidate(repo, &candidate, BaseSource::ConfiguredTarget)?
403 {
404 return Ok(Some(base));
405 }
406 }
407 for candidate in DEFAULT_TARGET_CANDIDATES {
408 if let Some(base) =
409 try_resolve_target_candidate(repo, candidate, BaseSource::DefaultTarget)?
410 {
411 return Ok(Some(base));
412 }
413 }
414 Ok(None)
415}
416
417fn resolve_explicit_target(repo: &Repository, spec: &str) -> Result<ComparisonBase> {
418 let spec = spec.trim();
419 if spec.is_empty() {
420 return Err(crate::error::Error::InvalidRef(
421 "empty target revision".into(),
422 ));
423 }
424 if let Some(base) = try_resolve_target_candidate(repo, spec, BaseSource::Explicit)? {
425 return Ok(ComparisonBase {
426 name: spec.to_owned(),
427 ..base
428 });
429 }
430 let oid = resolve_revision_for_range_end(repo, spec)?;
431 let commit = ref_tip_as_commit(repo, oid)?;
432 Ok(ComparisonBase {
433 name: spec.to_owned(),
434 full_ref: spec.to_owned(),
435 oid: commit,
436 source: BaseSource::Explicit,
437 })
438}
439
440pub fn compare_with_target(
454 repo: &Repository,
455 opts: &TargetComparisonOptions,
456) -> Result<TargetComparison> {
457 compare_with_target_optional(repo, opts)?
458 .ok_or_else(|| crate::error::Error::InvalidRef("no target branch".into()))
459}
460
461pub fn compare_with_target_optional(
469 repo: &Repository,
470 opts: &TargetComparisonOptions,
471) -> Result<Option<TargetComparison>> {
472 let head = resolve_head(repo.git_dir())?;
473 let (branch_name, head_oid) = match &head {
474 HeadState::Branch {
475 short_name,
476 oid: Some(oid),
477 ..
478 } => (short_name.clone(), *oid),
479 HeadState::Branch { .. } => return Err(crate::error::Error::UnbornHead),
480 HeadState::Detached { .. } => return Err(crate::error::Error::DetachedHead),
481 HeadState::Invalid => return Err(crate::error::Error::InvalidRef("invalid HEAD".into())),
482 };
483
484 let base = match &opts.target {
485 Some(TargetSpec(spec)) => Some(resolve_explicit_target(repo, spec)?),
486 None => discover_target_branch(repo)?,
487 };
488 let Some(base) = base else {
489 return Ok(None);
490 };
491
492 let retain_behind = if opts.include_behind {
493 opts.list_limit
494 } else {
495 0
496 };
497 let (ahead, behind, newest_ahead, newest_behind) =
498 ahead_behind_of_target_commits(repo, head_oid, base.oid, opts.list_limit, retain_behind)?;
499 Ok(Some(TargetComparison {
500 branch: branch_name,
501 base,
502 ahead,
503 behind,
504 newest_ahead,
505 newest_behind,
506 }))
507}
508
509fn target_candidate_refnames(candidate: &str) -> Vec<String> {
511 if candidate.starts_with("refs/") || candidate == "HEAD" {
512 return vec![candidate.to_owned()];
513 }
514 if let Some(remote_branch) = candidate.strip_prefix("origin/") {
515 return vec![
516 format!("refs/remotes/origin/{remote_branch}"),
517 format!("refs/heads/{candidate}"),
518 ];
519 }
520 vec![
521 format!("refs/heads/{candidate}"),
522 format!("refs/remotes/{candidate}"),
523 ]
524}
525
526pub fn target_branch(repo: &Repository) -> Result<Option<ComparisonBase>> {
546 Ok(discover_target_branch(repo)?.map(|mut base| {
547 base.source = BaseSource::Target;
548 base
549 }))
550}
551
552#[must_use]
567pub fn upstream_branch(repo: &Repository, branch_short: &str) -> Option<ComparisonBase> {
568 let full_ref = upstream_tracking_full_ref(repo, branch_short)?;
569 comparison_base_from_full_ref(repo, &full_ref, BaseSource::Upstream)
570}
571
572fn fetch_head_integration_name(content: &str, remote_name: &str) -> String {
573 for line in content.lines() {
574 if let Some(start) = line.find("branch '") {
575 let rest = &line[start + 8..];
576 if let Some(end) = rest.find('\'') {
577 let branch = &rest[..end];
578 return format!("{remote_name}/{branch}");
579 }
580 }
581 }
582 format!("{remote_name} (FETCH_HEAD)")
583}
584
585#[must_use]
587pub fn pull_upstream_from_fetch_head(
588 repo: &Repository,
589 remote_name: &str,
590) -> Option<ComparisonBase> {
591 let content = std::fs::read_to_string(repo.git_dir().join("FETCH_HEAD")).ok()?;
592 let hex_ids = merge_object_ids_hex(&content);
593 let first = hex_ids.first()?;
594 let oid = ObjectId::from_hex(first).ok()?;
595 let name = fetch_head_integration_name(&content, remote_name);
596 Some(ComparisonBase {
597 name,
598 full_ref: "FETCH_HEAD".to_owned(),
599 oid,
600 source: BaseSource::Upstream,
601 })
602}
603
604#[must_use]
609pub fn resolve_pull_integration_base(
610 repo: &Repository,
611 branch_short: &str,
612 remote_name: &str,
613 explicit_fetch: bool,
614) -> Option<ComparisonBase> {
615 if explicit_fetch {
616 return pull_upstream_from_fetch_head(repo, remote_name);
617 }
618 pull_upstream_branch(repo, branch_short, remote_name)
619}
620
621#[must_use]
627pub fn pull_upstream_branch(
628 repo: &Repository,
629 branch_short: &str,
630 remote_name: &str,
631) -> Option<ComparisonBase> {
632 let config = repo.config().ok()?;
633 let merge_key = format!("branch.{branch_short}.merge");
634 if config.get(&merge_key).is_some() {
635 let full_ref = upstream_tracking_full_ref(repo, branch_short)?;
636 return comparison_base_from_full_ref(repo, &full_ref, BaseSource::Upstream);
637 }
638 let full_ref = format!("refs/remotes/{remote_name}/{branch_short}");
639 comparison_base_from_full_ref(repo, &full_ref, BaseSource::Upstream)
640}
641
642fn comparison_base_from_full_ref(
643 repo: &Repository,
644 full_ref: &str,
645 source: BaseSource,
646) -> Option<ComparisonBase> {
647 let oid = resolve_optional(repo, full_ref)?;
648 Some(ComparisonBase {
649 name: abbreviate_ref_name(full_ref),
650 full_ref: full_ref.to_owned(),
651 oid,
652 source,
653 })
654}
655
656pub fn comparison_base(repo: &Repository, branch_short: &str) -> Result<Option<ComparisonBase>> {
668 match upstream_branch(repo, branch_short) {
669 Some(upstream) => Ok(Some(upstream)),
670 None => target_branch(repo),
671 }
672}
673
674#[derive(Clone, Copy, Debug, PartialEq, Eq)]
676pub struct StandingOptions {
677 pub newest_ahead_limit: usize,
679 pub counts: bool,
682}
683
684#[derive(Clone, Debug, PartialEq, Eq)]
686pub struct TrackedComparison {
687 pub name: String,
689 pub full_ref: String,
691 pub oid: ObjectId,
693 pub ahead: Option<usize>,
695 pub behind: Option<usize>,
697 pub newest_ahead: Vec<ObjectId>,
699}
700
701#[derive(Clone, Copy, Debug, PartialEq, Eq)]
703pub enum StandingHeader {
704 Unborn,
706 Detached,
708 Invalid,
710 NoTarget,
712 Compared(Standing),
714 ComparedNoCounts,
716}
717
718#[derive(Clone, Debug, PartialEq, Eq)]
720pub struct BranchStanding {
721 pub branch: Option<String>,
723 pub target: Option<TrackedComparison>,
725 pub upstream: Option<TrackedComparison>,
727 pub header: StandingHeader,
729}
730
731#[derive(Clone, Debug, PartialEq, Eq)]
733pub enum StatusHeader {
734 Branch(StandingHeader),
736 MergeInProgress,
738 RebaseInProgress {
743 branch: Option<String>,
745 onto: Option<String>,
747 },
748 OtherInProgress,
750}
751
752impl StatusHeader {
753 #[must_use]
761 pub fn resolve(
762 standing: &BranchStanding,
763 state: &WtStatusState,
764 operations: &[InProgressOperation],
765 ) -> Self {
766 if state.merge_in_progress || operations.contains(&InProgressOperation::Merge) {
767 return Self::MergeInProgress;
768 }
769 if state.rebase_in_progress || state.rebase_interactive_in_progress {
770 return Self::RebaseInProgress {
771 branch: state.rebase_branch.clone(),
772 onto: state.rebase_onto.clone(),
773 };
774 }
775 if !operations.is_empty() {
776 return Self::OtherInProgress;
777 }
778 Self::Branch(standing.header)
779 }
780
781 #[must_use]
783 pub fn primary_comparison_name(standing: &BranchStanding) -> Option<&str> {
784 standing
785 .target
786 .as_ref()
787 .map(|t| t.name.as_str())
788 .or_else(|| standing.upstream.as_ref().map(|u| u.name.as_str()))
789 }
790
791 #[must_use]
795 pub fn pull_integration_ref(standing: &BranchStanding) -> Option<&str> {
796 if let Some(up) = &standing.upstream {
797 if up.ahead == Some(0) && up.behind.is_some_and(|b| b > 0) {
798 return Some(up.name.as_str());
799 }
800 return None;
801 }
802 standing.target.as_ref().and_then(|t| {
803 if t.ahead == Some(0) && t.behind.is_some_and(|b| b > 0) {
804 Some(t.name.as_str())
805 } else {
806 None
807 }
808 })
809 }
810}
811
812#[derive(Clone, Copy, Debug, PartialEq, Eq)]
814pub enum StatusIntegrationAction {
815 Pull,
817 Merge,
819}
820
821#[derive(Clone, Debug, PartialEq, Eq)]
823pub struct StatusIntegrationHint {
824 pub action: StatusIntegrationAction,
826 pub ref_name: String,
828}
829
830#[must_use]
832pub fn integration_comparison_behind_only(standing: &BranchStanding) -> Option<&TrackedComparison> {
833 if let Some(up) = &standing.upstream {
834 if up.ahead == Some(0) && up.behind.is_some_and(|b| b > 0) {
835 return Some(up);
836 }
837 return None;
838 }
839 standing
840 .target
841 .as_ref()
842 .filter(|t| t.ahead == Some(0) && t.behind.is_some_and(|b| b > 0))
843}
844
845#[must_use]
851pub fn status_integration_hint(
852 standing: &BranchStanding,
853 pull_context: Option<(&Repository, &str)>,
854) -> Option<StatusIntegrationHint> {
855 let cmp = integration_comparison_behind_only(standing)?;
856 let action = match pull_context {
857 Some((repo, branch_short)) => {
858 let config = repo.config().ok()?;
859 let remote =
860 crate::fetch_request::configured_remote_for_pull(&config, Some(branch_short));
861 match resolve_pull_integration_base(repo, branch_short, &remote, false) {
862 Some(base) if base.full_ref == cmp.full_ref => StatusIntegrationAction::Pull,
863 _ => StatusIntegrationAction::Merge,
864 }
865 }
866 None => {
867 if standing.upstream.is_some() {
868 StatusIntegrationAction::Pull
869 } else {
870 StatusIntegrationAction::Merge
871 }
872 }
873 };
874 Some(StatusIntegrationHint {
875 action,
876 ref_name: cmp.name.clone(),
877 })
878}
879
880impl BranchStanding {
881 pub fn compute(repo: &Repository, head: &HeadState, opts: StandingOptions) -> Result<Self> {
891 match head {
892 HeadState::Invalid => Ok(Self {
893 branch: None,
894 target: None,
895 upstream: None,
896 header: StandingHeader::Invalid,
897 }),
898 HeadState::Detached { .. } => Ok(Self {
899 branch: None,
900 target: None,
901 upstream: None,
902 header: StandingHeader::Detached,
903 }),
904 HeadState::Branch {
905 short_name,
906 oid: None,
907 ..
908 } => Ok(Self {
909 branch: Some(short_name.clone()),
910 target: None,
911 upstream: None,
912 header: StandingHeader::Unborn,
913 }),
914 HeadState::Branch {
915 short_name, oid, ..
916 } => {
917 let target_base = target_branch(repo)?;
918 let upstream_base = upstream_branch(repo, short_name);
919 let tip = *oid;
920 let (target, upstream) =
921 comparisons_for_branch(repo, tip, target_base, upstream_base, opts)?;
922 let header = primary_standing_header(tip, target.as_ref(), upstream.as_ref(), opts);
923 Ok(Self {
924 branch: Some(short_name.clone()),
925 target,
926 upstream,
927 header,
928 })
929 }
930 }
931 }
932}
933
934fn primary_standing_header(
935 tip: Option<ObjectId>,
936 target: Option<&TrackedComparison>,
937 upstream: Option<&TrackedComparison>,
938 opts: StandingOptions,
939) -> StandingHeader {
940 let Some(tracked) = target.or(upstream) else {
941 return StandingHeader::NoTarget;
942 };
943 standing_header_for_tracked(tip, tracked, opts)
944}
945
946fn standing_header_for_tracked(
947 tip: Option<ObjectId>,
948 tracked: &TrackedComparison,
949 opts: StandingOptions,
950) -> StandingHeader {
951 if !opts.counts {
952 return if tip == Some(tracked.oid) {
953 StandingHeader::Compared(Standing::Even)
954 } else {
955 StandingHeader::ComparedNoCounts
956 };
957 }
958 StandingHeader::Compared(Standing::from_counts(
959 tracked.ahead.unwrap_or(0),
960 tracked.behind.unwrap_or(0),
961 ))
962}
963
964fn comparisons_for_branch(
965 repo: &Repository,
966 tip: Option<ObjectId>,
967 target_base: Option<ComparisonBase>,
968 upstream_base: Option<ComparisonBase>,
969 opts: StandingOptions,
970) -> Result<(Option<TrackedComparison>, Option<TrackedComparison>)> {
971 let Some(head_oid) = tip else {
972 let target = target_base
973 .map(|base| tracked_comparison(repo, tip, base, opts))
974 .transpose()?;
975 let upstream = upstream_base
976 .filter(|base| target.as_ref().is_none_or(|t| t.full_ref != base.full_ref))
977 .map(|base| tracked_comparison(repo, tip, base, opts))
978 .transpose()?;
979 return Ok((target, upstream));
980 };
981
982 let same_upstream_as_target = matches!(
983 (target_base.as_ref(), upstream_base.as_ref()),
984 (Some(t), Some(u)) if t.full_ref == u.full_ref
985 );
986
987 if same_upstream_as_target {
988 let target = target_base
989 .map(|base| tracked_comparison(repo, tip, base, opts))
990 .transpose()?;
991 return Ok((target, None));
992 }
993
994 if opts.counts {
995 if let (Some(t_base), Some(u_base)) = (&target_base, &upstream_base) {
996 let (target, upstream) = dual_tracked_comparisons(
997 repo,
998 head_oid,
999 t_base.clone(),
1000 u_base.clone(),
1001 opts.newest_ahead_limit,
1002 )?;
1003 return Ok((Some(target), Some(upstream)));
1004 }
1005 }
1006
1007 let target = target_base
1008 .map(|base| tracked_comparison(repo, tip, base, opts))
1009 .transpose()?;
1010 let upstream = upstream_base
1011 .filter(|base| target.as_ref().is_none_or(|t| t.full_ref != base.full_ref))
1012 .map(|base| tracked_comparison(repo, tip, base, opts))
1013 .transpose()?;
1014 Ok((target, upstream))
1015}
1016
1017fn dual_tracked_comparisons(
1018 repo: &Repository,
1019 head: ObjectId,
1020 target_base: ComparisonBase,
1021 upstream_base: ComparisonBase,
1022 retain_newest_ahead: usize,
1023) -> Result<(TrackedComparison, TrackedComparison)> {
1024 let target_cmp = TipComparison::compute(repo, head, target_base.oid, retain_newest_ahead)?;
1025 let upstream_cmp = TipComparison::compute(repo, head, upstream_base.oid, 0)?;
1026 Ok((
1027 TrackedComparison {
1028 name: target_base.name,
1029 full_ref: target_base.full_ref,
1030 oid: target_base.oid,
1031 ahead: Some(target_cmp.ahead),
1032 behind: Some(target_cmp.behind),
1033 newest_ahead: target_cmp.newest_ahead,
1034 },
1035 TrackedComparison {
1036 name: upstream_base.name,
1037 full_ref: upstream_base.full_ref,
1038 oid: upstream_base.oid,
1039 ahead: Some(upstream_cmp.ahead),
1040 behind: Some(upstream_cmp.behind),
1041 newest_ahead: Vec::new(),
1042 },
1043 ))
1044}
1045
1046fn tracked_comparison(
1047 repo: &Repository,
1048 tip: Option<ObjectId>,
1049 base: ComparisonBase,
1050 opts: StandingOptions,
1051) -> Result<TrackedComparison> {
1052 let ComparisonBase {
1053 name,
1054 full_ref,
1055 oid: base_oid,
1056 source: _,
1057 } = base;
1058 let (ahead, behind, newest_ahead) = match tip {
1059 None => (None, None, Vec::new()),
1060 Some(_head_oid) if !opts.counts => (None, None, Vec::new()),
1061 Some(head_oid) => {
1062 let comparison =
1063 TipComparison::compute(repo, head_oid, base_oid, opts.newest_ahead_limit)?;
1064 (
1065 Some(comparison.ahead),
1066 Some(comparison.behind),
1067 comparison.newest_ahead,
1068 )
1069 }
1070 };
1071 Ok(TrackedComparison {
1072 name,
1073 full_ref,
1074 oid: base_oid,
1075 ahead,
1076 behind,
1077 newest_ahead,
1078 })
1079}
1080
1081#[must_use]
1083pub fn status_ahead_behind_enabled(config: &ConfigSet) -> bool {
1084 let raw = config
1085 .get("status.aheadBehind")
1086 .or_else(|| config.get("status.aheadbehind"));
1087 match raw {
1088 None => true,
1089 Some(value) => parse_git_config_maybe_bool(&value).unwrap_or(true),
1090 }
1091}
1092
1093#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1095pub enum Standing {
1096 Even,
1098 Ahead,
1100 Behind,
1102 Diverged,
1104}
1105
1106impl Standing {
1107 #[must_use]
1109 pub fn from_counts(ahead: usize, behind: usize) -> Self {
1110 match (ahead, behind) {
1111 (0, 0) => Self::Even,
1112 (_, 0) => Self::Ahead,
1113 (0, _) => Self::Behind,
1114 _ => Self::Diverged,
1115 }
1116 }
1117}
1118
1119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1121pub struct TargetTipListOptions {
1122 pub list_limit: usize,
1124 pub include_behind: bool,
1126}
1127
1128impl Default for TargetTipListOptions {
1129 fn default() -> Self {
1130 Self {
1131 list_limit: 100,
1132 include_behind: false,
1133 }
1134 }
1135}
1136
1137#[derive(Clone, Debug, PartialEq, Eq)]
1139pub struct TargetTipLists {
1140 pub ahead: usize,
1142 pub behind: usize,
1144 pub ahead_oids: Vec<ObjectId>,
1146 pub behind_oids: Vec<ObjectId>,
1148}
1149
1150impl TargetTipLists {
1151 pub fn compute(
1157 repo: &Repository,
1158 tip: ObjectId,
1159 target: ObjectId,
1160 opts: TargetTipListOptions,
1161 ) -> Result<Self> {
1162 let retain_behind = if opts.include_behind {
1163 opts.list_limit
1164 } else {
1165 0
1166 };
1167 let (ahead, behind, ahead_oids, behind_oids) =
1168 ahead_behind_of_target_commits(repo, tip, target, opts.list_limit, retain_behind)?;
1169 Ok(Self {
1170 ahead,
1171 behind,
1172 ahead_oids,
1173 behind_oids,
1174 })
1175 }
1176}
1177
1178#[derive(Clone, Debug, PartialEq, Eq)]
1180pub struct TipComparison {
1181 pub ahead: usize,
1183 pub behind: usize,
1185 pub newest_ahead: Vec<ObjectId>,
1187}
1188
1189impl TipComparison {
1190 pub fn compute(
1207 repo: &Repository,
1208 tip: ObjectId,
1209 base: ObjectId,
1210 retain_newest_ahead: usize,
1211 ) -> Result<Self> {
1212 let (ahead, behind, newest_ahead, _) =
1213 ahead_behind_of_target_commits(repo, tip, base, retain_newest_ahead, 0)?;
1214 Ok(Self {
1215 ahead,
1216 behind,
1217 newest_ahead,
1218 })
1219 }
1220
1221 #[must_use]
1223 pub fn standing(&self) -> Standing {
1224 Standing::from_counts(self.ahead, self.behind)
1225 }
1226}
1227
1228pub use crate::branch_inventory::{
1229 list_branches, upstream_status, BranchDetail, BranchListQuery, BranchScope, BranchSummary,
1230 UpstreamStatus,
1231};
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236 use crate::refspec::RefspecError;
1237
1238 #[cfg_attr(
1239 not(feature = "full-tests"),
1240 ignore = "starts system git or is heavy; run with --features full-tests"
1241 )]
1242 #[test]
1243 fn target_comparison_missing_target_is_optional_only_for_discovery() {
1244 let tmp = TempDir::new().expect("tempdir");
1245 git(tmp.path(), &["init", "-q", "-b", "trunk"]);
1246 commit(tmp.path(), "f", "base");
1247 let repo = open(tmp.path());
1248 let mut opts = TargetComparisonOptions {
1249 target: None,
1250 list_limit: 0,
1251 include_behind: false,
1252 };
1253 assert_eq!(compare_with_target_optional(&repo, &opts).unwrap(), None);
1254 assert!(matches!(compare_with_target(&repo, &opts),
1255 Err(crate::error::Error::InvalidRef(reason)) if reason == "no target branch"));
1256
1257 opts.target = Some(TargetSpec("".into()));
1258 for result in [
1259 compare_with_target(&repo, &opts).map(Some),
1260 compare_with_target_optional(&repo, &opts),
1261 ] {
1262 assert!(matches!(result,
1263 Err(crate::error::Error::InvalidRef(reason)) if reason == "empty target revision"));
1264 }
1265 }
1266
1267 #[cfg_attr(
1268 not(feature = "full-tests"),
1269 ignore = "starts system git or is heavy; run with --features full-tests"
1270 )]
1271 #[test]
1272 fn target_comparison_rejects_head_before_resolving_target() {
1273 let tmp = TempDir::new().expect("tempdir");
1274 git(tmp.path(), &["init", "-q", "-b", "trunk"]);
1275 let repo = open(tmp.path());
1276 let opts = TargetComparisonOptions {
1277 target: Some(TargetSpec("".into())),
1278 list_limit: 1,
1279 include_behind: true,
1280 };
1281 for result in [
1282 compare_with_target(&repo, &opts).map(Some),
1283 compare_with_target_optional(&repo, &opts),
1284 ] {
1285 assert!(matches!(result, Err(crate::error::Error::UnbornHead)));
1286 }
1287 std::fs::write(
1288 repo.git_dir().join("HEAD"),
1289 "1111111111111111111111111111111111111111\n",
1290 )
1291 .unwrap();
1292 for result in [
1293 compare_with_target(&repo, &opts).map(Some),
1294 compare_with_target_optional(&repo, &opts),
1295 ] {
1296 assert!(matches!(result, Err(crate::error::Error::DetachedHead)));
1297 }
1298 std::fs::write(repo.git_dir().join("HEAD"), "invalid\n").unwrap();
1299 for result in [
1300 compare_with_target(&repo, &opts).map(Some),
1301 compare_with_target_optional(&repo, &opts),
1302 ] {
1303 assert!(matches!(result,
1304 Err(crate::error::Error::InvalidRef(reason)) if reason == "invalid HEAD"));
1305 }
1306 }
1307
1308 #[test]
1309 fn standing_from_counts_covers_every_quadrant() {
1310 assert_eq!(Standing::from_counts(0, 0), Standing::Even);
1311 assert_eq!(Standing::from_counts(3, 0), Standing::Ahead);
1312 assert_eq!(Standing::from_counts(0, 2), Standing::Behind);
1313 assert_eq!(Standing::from_counts(1, 1), Standing::Diverged);
1314 }
1315
1316 use crate::config::environment::RepositoryOptions;
1317
1318 use crate::repo::Repository;
1319 use std::path::Path;
1320 use std::process::Command;
1321 use tempfile::TempDir;
1322
1323 fn git(dir: &Path, args: &[&str]) {
1324 let status = Command::new("git")
1325 .current_dir(dir)
1326 .args(args)
1327 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1328 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1329 .env("GIT_AUTHOR_NAME", "T")
1330 .env("GIT_AUTHOR_EMAIL", "t@e.com")
1331 .env("GIT_COMMITTER_NAME", "T")
1332 .env("GIT_COMMITTER_EMAIL", "t@e.com")
1333 .status()
1334 .expect("git");
1335 assert!(status.success(), "git {args:?} failed");
1336 }
1337
1338 fn open(dir: &Path) -> Repository {
1339 Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("open")
1340 }
1341
1342 fn head_state(repo: &Repository) -> HeadState {
1343 crate::state::resolve_head(repo.git_dir()).expect("head")
1344 }
1345
1346 fn commit(dir: &Path, file: &str, msg: &str) {
1347 std::fs::write(dir.join(file), msg.as_bytes()).expect("write");
1348 git(dir, &["add", file]);
1349 git(dir, &["commit", "-qm", msg]);
1350 }
1351
1352 #[cfg_attr(
1353 not(feature = "full-tests"),
1354 ignore = "starts system git or is heavy; run with --features full-tests"
1355 )]
1356 #[test]
1357 fn standing_target_and_upstream_distinct() {
1358 let tmp = TempDir::new().expect("tempdir");
1359 let origin = tmp.path().join("origin");
1360 std::fs::create_dir_all(&origin).expect("mkdir");
1361 git(&origin, &["init", "-q", "-b", "main"]);
1362 commit(&origin, "a", "m1");
1363 git(&origin, &["checkout", "-qb", "topic"]);
1364 commit(&origin, "b", "topic");
1365 git(
1366 tmp.path(),
1367 &["clone", "-q", "-b", "main", "origin", "clone"],
1368 );
1369 let clone = tmp.path().join("clone");
1370 git(&clone, &["checkout", "-q", "topic"]);
1371 git(&clone, &["branch", "-q", "develop", "main"]);
1372 git(
1373 &clone,
1374 &[
1375 "branch",
1376 "--set-upstream-to",
1377 "refs/remotes/origin/topic",
1378 "topic",
1379 ],
1380 );
1381 git(&clone, &["config", "target.branch", "develop"]);
1382
1383 let repo = open(&clone);
1384 let standing = BranchStanding::compute(
1385 &repo,
1386 &head_state(&repo),
1387 StandingOptions {
1388 newest_ahead_limit: 10,
1389 counts: true,
1390 },
1391 )
1392 .expect("standing");
1393 assert_eq!(standing.branch.as_deref(), Some("topic"));
1394 let target = standing.target.as_ref().expect("target");
1395 let upstream = standing.upstream.as_ref().expect("upstream");
1396 assert_eq!(target.name, "develop");
1397 assert_eq!(upstream.name, "origin/topic");
1398 assert_ne!(target.full_ref, upstream.full_ref);
1399 }
1400
1401 #[cfg_attr(
1402 not(feature = "full-tests"),
1403 ignore = "starts system git or is heavy; run with --features full-tests"
1404 )]
1405 #[test]
1406 fn standing_upstream_equals_target_computed_once() {
1407 let tmp = TempDir::new().expect("tempdir");
1408 git(tmp.path(), &["init", "-q", "-b", "main"]);
1409 commit(tmp.path(), "f", "c1");
1410 git(
1411 tmp.path(),
1412 &["branch", "--set-upstream-to", "refs/heads/main", "main"],
1413 );
1414
1415 let repo = open(tmp.path());
1416 let standing = BranchStanding::compute(
1417 &repo,
1418 &head_state(&repo),
1419 StandingOptions {
1420 newest_ahead_limit: 10,
1421 counts: true,
1422 },
1423 )
1424 .expect("standing");
1425 assert!(standing.upstream.is_none());
1426 let target = standing.target.as_ref().expect("target");
1427 assert_eq!(target.name, "main");
1428 assert_eq!(target.ahead, Some(0));
1429 assert_eq!(target.behind, Some(0));
1430 }
1431
1432 #[cfg_attr(
1433 not(feature = "full-tests"),
1434 ignore = "starts system git or is heavy; run with --features full-tests"
1435 )]
1436 #[test]
1437 fn standing_no_counts_skips_walk() {
1438 let tmp = TempDir::new().expect("tempdir");
1439 git(tmp.path(), &["init", "-q", "-b", "main"]);
1440 commit(tmp.path(), "f", "c1");
1441 git(tmp.path(), &["branch", "topic"]);
1442 git(tmp.path(), &["checkout", "-q", "topic"]);
1443 commit(tmp.path(), "f", "c2");
1444
1445 let repo = open(tmp.path());
1446 let standing = BranchStanding::compute(
1447 &repo,
1448 &head_state(&repo),
1449 StandingOptions {
1450 newest_ahead_limit: 10,
1451 counts: false,
1452 },
1453 )
1454 .expect("standing");
1455 let target = standing.target.as_ref().expect("target");
1456 assert_eq!(target.ahead, None);
1457 assert_eq!(target.behind, None);
1458 assert!(target.newest_ahead.is_empty());
1459 }
1460
1461 #[cfg_attr(
1462 not(feature = "full-tests"),
1463 ignore = "starts system git or is heavy; run with --features full-tests"
1464 )]
1465 #[test]
1466 fn standing_unborn_and_detached() {
1467 let tmp = TempDir::new().expect("tempdir");
1468 git(tmp.path(), &["init", "-q", "-b", "empty"]);
1469 let repo = open(tmp.path());
1470 let unborn = BranchStanding::compute(
1471 &repo,
1472 &head_state(&repo),
1473 StandingOptions {
1474 newest_ahead_limit: 10,
1475 counts: true,
1476 },
1477 )
1478 .expect("standing");
1479 assert_eq!(unborn.branch.as_deref(), Some("empty"));
1480 assert!(unborn.target.is_none());
1481
1482 commit(tmp.path(), "f", "c1");
1483 let oid = Command::new("git")
1484 .current_dir(tmp.path())
1485 .args(["rev-parse", "HEAD"])
1486 .output()
1487 .expect("rev-parse");
1488 let hex = String::from_utf8_lossy(&oid.stdout);
1489 let hex = hex.trim();
1490 git(tmp.path(), &["checkout", "--detach", hex]);
1491 let repo = open(tmp.path());
1492 let detached = BranchStanding::compute(
1493 &repo,
1494 &head_state(&repo),
1495 StandingOptions {
1496 newest_ahead_limit: 10,
1497 counts: true,
1498 },
1499 )
1500 .expect("standing");
1501 assert!(detached.branch.is_none());
1502 assert!(detached.target.is_none());
1503 assert!(detached.upstream.is_none());
1504 }
1505
1506 #[cfg_attr(
1507 not(feature = "full-tests"),
1508 ignore = "starts system git or is heavy; run with --features full-tests"
1509 )]
1510 #[test]
1511 fn standing_upstream_counts_match_git() {
1512 let tmp = TempDir::new().expect("tempdir");
1513 let origin = tmp.path().join("origin");
1514 std::fs::create_dir_all(&origin).expect("mkdir");
1515 git(&origin, &["init", "-q", "-b", "main"]);
1516 commit(&origin, "a", "m1");
1517 commit(&origin, "a", "m2");
1518 git(tmp.path(), &["clone", "-q", "origin", "clone"]);
1519 let clone = tmp.path().join("clone");
1520 git(&clone, &["config", "user.name", "T"]);
1521 git(&clone, &["config", "user.email", "t@e.com"]);
1522 commit(&clone, "b", "local");
1523 commit(&origin, "a", "remote");
1524 git(&clone, &["fetch", "-q"]);
1525
1526 let git_out = Command::new("git")
1527 .current_dir(&clone)
1528 .args(["rev-list", "--left-right", "--count", "HEAD...@{u}"])
1529 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1530 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1531 .output()
1532 .expect("git rev-list");
1533 assert!(git_out.status.success());
1534 let text = String::from_utf8_lossy(&git_out.stdout);
1535 let mut parts = text.split_whitespace();
1536 let git_ahead: usize = parts.next().unwrap().parse().unwrap();
1537 let git_behind: usize = parts.next().unwrap().parse().unwrap();
1538
1539 let repo = open(&clone);
1540 let standing = BranchStanding::compute(
1541 &repo,
1542 &head_state(&repo),
1543 StandingOptions {
1544 newest_ahead_limit: 10,
1545 counts: true,
1546 },
1547 )
1548 .expect("standing");
1549 let upstream = standing
1550 .upstream
1551 .as_ref()
1552 .or(standing.target.as_ref())
1553 .expect("upstream or target");
1554 assert_eq!(upstream.ahead, Some(git_ahead));
1555 assert_eq!(upstream.behind, Some(git_behind));
1556 }
1557
1558 #[test]
1559 fn target_candidates_expand_to_full_refs() {
1560 assert_eq!(
1561 target_candidate_refnames("refs/heads/dev"),
1562 vec!["refs/heads/dev".to_owned()]
1563 );
1564 assert_eq!(target_candidate_refnames("HEAD"), vec!["HEAD".to_owned()]);
1565 assert_eq!(
1566 target_candidate_refnames("origin/main"),
1567 vec![
1568 "refs/remotes/origin/main".to_owned(),
1569 "refs/heads/origin/main".to_owned(),
1570 ]
1571 );
1572 assert_eq!(
1573 target_candidate_refnames("upstream/dev"),
1574 vec![
1575 "refs/heads/upstream/dev".to_owned(),
1576 "refs/remotes/upstream/dev".to_owned(),
1577 ]
1578 );
1579 }
1580
1581 type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
1582
1583 fn refspecs(specs: &[&str]) -> std::result::Result<FetchRefspecs, RefspecError> {
1584 let mut positive = Vec::new();
1585 let mut negative = Vec::new();
1586 for spec in specs {
1587 let item = parse_fetch_refspec(spec)?;
1588 if item.negative {
1589 negative.push(item);
1590 } else {
1591 positive.push(item);
1592 }
1593 }
1594 Ok(FetchRefspecs { positive, negative })
1595 }
1596
1597 #[test]
1598 fn tracking_ref_follows_fetch_refspecs() -> TestResult {
1599 let specs = refspecs(&[
1600 "+refs/heads/*:refs/remotes/upstream/*",
1601 "^refs/heads/secret",
1602 ])?;
1603 assert_eq!(
1604 specs.tracking_ref("origin", "refs/heads/main").as_deref(),
1605 Some("refs/remotes/upstream/main")
1606 );
1607 assert_eq!(specs.tracking_ref("origin", "refs/heads/secret"), None);
1608 assert_eq!(specs.tracking_ref("origin", "refs/tags/v1"), None);
1609 Ok(())
1610 }
1611
1612 #[test]
1613 fn tracking_ref_without_fetch_refspecs_maps_branches_only() -> TestResult {
1614 let specs = refspecs(&[])?;
1615 assert_eq!(
1616 specs.tracking_ref("origin", "refs/heads/topic").as_deref(),
1617 Some("refs/remotes/origin/topic")
1618 );
1619 assert_eq!(specs.tracking_ref("origin", "refs/tags/v1"), None);
1620
1621 let only_negative = refspecs(&["^refs/heads/wip"])?;
1622 assert_eq!(only_negative.tracking_ref("origin", "refs/heads/wip"), None);
1623 assert_eq!(
1624 only_negative
1625 .tracking_ref("origin", "refs/heads/main")
1626 .as_deref(),
1627 Some("refs/remotes/origin/main")
1628 );
1629 Ok(())
1630 }
1631
1632 #[test]
1633 fn fetch_refspecs_load_reads_one_remote() -> TestResult {
1634 let mut config = ConfigSet::new();
1635 config
1636 .add_command_override("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
1637 config.add_command_override("remote.origin.fetch", "^refs/heads/wip")?;
1638 config.add_command_override("remote.other.fetch", "+refs/heads/*:refs/remotes/other/*")?;
1639 let specs = FetchRefspecs::load(&config, "origin");
1640 assert_eq!(specs.positive.len(), 1);
1641 assert_eq!(specs.negative.len(), 1);
1642 assert_eq!(
1643 specs.tracking_ref("origin", "refs/heads/main").as_deref(),
1644 Some("refs/remotes/origin/main")
1645 );
1646 Ok(())
1647 }
1648
1649 #[test]
1650 fn upstream_ref_for_merge_maps_through_fetch_refspecs() -> TestResult {
1651 let mut config = ConfigSet::new();
1652 config
1653 .add_command_override("remote.origin.fetch", "+refs/heads/*:refs/remotes/mirror/*")?;
1654 config.add_command_override("remote.origin.fetch", "^refs/heads/wip")?;
1655 assert_eq!(
1656 upstream_ref_for_merge(&config, "origin", "refs/heads/main").as_deref(),
1657 Some("refs/remotes/mirror/main")
1658 );
1659 assert_eq!(
1660 upstream_ref_for_merge(&config, "origin", " main ").as_deref(),
1661 Some("refs/remotes/mirror/main")
1662 );
1663 assert_eq!(upstream_ref_for_merge(&config, "origin", "wip"), None);
1664 assert_eq!(
1665 upstream_ref_for_merge(&config, "origin", "refs/tags/v1"),
1666 None
1667 );
1668 assert_eq!(
1669 upstream_ref_for_merge(&config, "unconfigured", "refs/heads/dev").as_deref(),
1670 Some("refs/remotes/unconfigured/dev")
1671 );
1672 Ok(())
1673 }
1674
1675 #[test]
1676 fn upstream_ref_for_merge_local_remote_is_a_local_ref() {
1677 let config = ConfigSet::new();
1678 assert_eq!(
1679 upstream_ref_for_merge(&config, ".", "main").as_deref(),
1680 Some("refs/heads/main")
1681 );
1682 assert_eq!(
1683 upstream_ref_for_merge(&config, ".", "refs/heads/dev").as_deref(),
1684 Some("refs/heads/dev")
1685 );
1686 }
1687
1688 #[cfg_attr(
1689 not(feature = "full-tests"),
1690 ignore = "starts system git or is heavy; run with --features full-tests"
1691 )]
1692 #[test]
1693 fn status_integration_hint_merge_for_local_target() {
1694 let tmp = TempDir::new().expect("tempdir");
1695 git(tmp.path(), &["init", "-q", "-b", "main"]);
1696 commit(tmp.path(), "a", "c1");
1697 git(tmp.path(), &["switch", "-c", "old"]);
1698 git(tmp.path(), &["switch", "main"]);
1699 commit(tmp.path(), "b", "c2");
1700 git(tmp.path(), &["switch", "old"]);
1701
1702 let repo = open(tmp.path());
1703 let standing = BranchStanding::compute(
1704 &repo,
1705 &head_state(&repo),
1706 StandingOptions {
1707 newest_ahead_limit: 10,
1708 counts: true,
1709 },
1710 )
1711 .expect("standing");
1712 let hint = status_integration_hint(&standing, Some((&repo, "old"))).expect("hint");
1713 assert_eq!(hint.action, StatusIntegrationAction::Merge);
1714 assert_eq!(hint.ref_name, "main");
1715 }
1716
1717 #[cfg_attr(
1718 not(feature = "full-tests"),
1719 ignore = "starts system git or is heavy; run with --features full-tests"
1720 )]
1721 #[test]
1722 fn status_integration_hint_pull_when_upstream_matches() {
1723 let tmp = TempDir::new().expect("tempdir");
1724 let origin = tmp.path().join("origin");
1725 std::fs::create_dir_all(&origin).expect("mkdir");
1726 git(&origin, &["init", "-q", "-b", "main"]);
1727 commit(&origin, "a", "m1");
1728 git(tmp.path(), &["clone", "-q", "origin", "clone"]);
1729 let clone = tmp.path().join("clone");
1730 commit(&origin, "a", "m2");
1731 git(&clone, &["fetch", "-q"]);
1732
1733 let repo = open(&clone);
1734 let standing = BranchStanding::compute(
1735 &repo,
1736 &head_state(&repo),
1737 StandingOptions {
1738 newest_ahead_limit: 10,
1739 counts: true,
1740 },
1741 )
1742 .expect("standing");
1743 let hint = status_integration_hint(&standing, Some((&repo, "main"))).expect("hint");
1744 assert_eq!(hint.action, StatusIntegrationAction::Pull);
1745 assert_eq!(hint.ref_name, "origin/main");
1746 }
1747
1748 #[cfg_attr(
1749 not(feature = "full-tests"),
1750 ignore = "starts system git or is heavy; run with --features full-tests"
1751 )]
1752 #[test]
1753 fn upstream_tracking_full_ref_honours_custom_refspec() {
1754 let tmp = TempDir::new().expect("tempdir");
1755 let dir = tmp.path();
1756 git(dir, &["init", "-q", "-b", "main"]);
1757 git(dir, &["config", "remote.origin.url", "/nonexistent"]);
1758 git(
1759 dir,
1760 &[
1761 "config",
1762 "remote.origin.fetch",
1763 "+refs/heads/main:refs/remotes/upstream-main",
1764 ],
1765 );
1766 git(dir, &["config", "branch.main.remote", "origin"]);
1767 git(dir, &["config", "branch.main.merge", "refs/heads/main"]);
1768 git(dir, &["config", "branch.dev.remote", "origin"]);
1769 git(dir, &["config", "branch.dev.merge", "refs/heads/dev"]);
1770 let repo = open(dir);
1771 assert_eq!(
1772 upstream_tracking_full_ref(&repo, "main").as_deref(),
1773 Some("refs/remotes/upstream-main")
1774 );
1775 assert_eq!(upstream_tracking_full_ref(&repo, "dev"), None);
1776 assert_eq!(upstream_tracking_full_ref(&repo, "unset"), None);
1777 }
1778}