Skip to main content

grit_lib/
branch_tracking.rs

1//! Comparison bases for a branch (its upstream or the repository's target branch) and
2//! remote-tracking ref updates after a push.
3
4use 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
23/// The `remote.<name>.fetch` refspecs of one remote, split by polarity.
24struct FetchRefspecs {
25    positive: Vec<RefspecItem>,
26    negative: Vec<RefspecItem>,
27}
28
29/// Whether `tracking_ref` is the destination of a positive fetch refspec for its remote.
30///
31/// Returns `false` when `tracking_ref` is not under `refs/remotes/`.
32pub 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/// Map a resolved upstream ref to `branch.<name>.remote` / `.merge` values.
46#[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/// Local remote-tracking ref that corresponds to `remote_ref` on `remote_name`.
58///
59/// Uses `remote.<name>.fetch` refspecs; when none are configured, branch refs map to
60/// `refs/remotes/<remote>/<branch>`.
61#[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    /// The local tracking ref `remote_ref` maps to, or `None` when no positive refspec maps it
81    /// or a negative one excludes it. With no positive refspecs at all, `refs/heads/<branch>`
82    /// falls back to `refs/remotes/<remote>/<branch>`.
83    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
97/// Write `branch.<branch>.remote` and `branch.<branch>.merge` in the repository config.
98///
99/// Used by `git push -u`, branch creation with tracking, and [`apply_push_set_upstream`].
100pub 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
112/// Configure upstream for each local branch that was pushed successfully (`-u` / `--set-upstream`).
113///
114/// Only branch updates (`refs/heads/*` → `refs/heads/*`) with status [`PushRefStatus::Ok`] or
115/// [`PushRefStatus::UpToDate`] are considered. Returns the short names of branches whose upstream
116/// config was written.
117pub 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
148/// Apply remote-tracking ref updates for every successfully pushed ref in `results`.
149///
150/// Refs reported as [`PushRefStatus::Ok`] or [`PushRefStatus::UpToDate`] are mapped through
151/// the remote's `remote.<name>.fetch` refspecs and updated in one ref-store batch (matching
152/// Git `transport_update_tracking_ref`); a pushed deletion removes the tracking ref. Path-like
153/// remote names (containing `/` or starting with `.`) and an unreadable config skip tracking
154/// updates entirely.
155///
156/// # Parameters
157///
158/// - `git_dir`: local repository `.git` directory.
159/// - `remote_name`: short remote name (e.g. `origin`).
160/// - `results`: per-ref push outcomes reported by the remote.
161///
162/// # Errors
163///
164/// Propagates errors opening the ref store under `git_dir` and committing the batch.
165pub 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
180/// [`apply_push_remote_tracking_updates`] for an open repository: the remote's refspecs come
181/// from `repo`'s config, and the tracking refs are read and written through
182/// [`Repository::refs`], so an injected ref store receives them.
183///
184/// # Errors
185///
186/// Propagates ref-store errors from the batch commit.
187pub(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
201/// Path-like remote names (a URL or path given as the push destination) have no tracking refs.
202fn 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/// Full ref for the configured upstream of `branch_short` (`refs/remotes/...` or `refs/heads/...`).
238///
239/// Both `branch.<name>.remote` and `branch.<name>.merge` must be set. For the local remote `.`
240/// the merge ref is itself the upstream; otherwise it is mapped through
241/// `remote.<remote>.fetch` (with no positive refspecs, `refs/heads/<branch>` maps to
242/// `refs/remotes/<remote>/<branch>`). `None` when the refspecs do not store it locally.
243#[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/// Same as [`upstream_tracking_full_ref`] with an already-loaded config (branch inventory).
252#[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
259/// The local ref that holds `merge` (a `branch.<name>.merge` value) of `remote`.
260///
261/// For the local remote `.` the merge ref is itself a local ref. Otherwise the remote ref
262/// (`refs/heads/<merge>` when `merge` is a short name) is mapped through
263/// `remote.<remote>.fetch`; with no positive fetch refspecs, `refs/heads/<branch>` maps to
264/// `refs/remotes/<remote>/<branch>`. Returns `None` when the refspecs do not fetch the ref
265/// into a remote-tracking ref.
266pub(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/// Where a [`ComparisonBase`] came from.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub enum BaseSource {
286    /// The branch's configured upstream (`branch.<name>.remote` / `branch.<name>.merge`).
287    Upstream,
288    /// Explicit comparison ref from [`TargetSpec`] (for example `grit shortlog --target`).
289    Explicit,
290    /// The repository's target branch from the `target.branch` config value.
291    ConfiguredTarget,
292    /// A default target candidate ([`DEFAULT_TARGET_CANDIDATES`]).
293    DefaultTarget,
294    /// The repository's target branch (see [`target_branch`]); covers configured and default.
295    Target,
296}
297
298/// A resolved branch that another branch is measured against.
299#[derive(Clone, Debug, PartialEq, Eq)]
300pub struct ComparisonBase {
301    /// Short display name: the full ref abbreviated (`origin/main`) for an upstream, or the
302    /// candidate as configured or listed in [`DEFAULT_TARGET_CANDIDATES`] for a target.
303    pub name: String,
304    /// Full ref the base resolved through (for example `refs/remotes/origin/main`).
305    pub full_ref: String,
306    /// Commit the base points at.
307    pub oid: ObjectId,
308    /// Whether the base is an upstream or the target branch.
309    pub source: BaseSource,
310}
311
312/// Trunk names tried, in order, after `target.branch` when looking for the target branch.
313pub const DEFAULT_TARGET_CANDIDATES: [&str; 4] = ["origin/master", "origin/main", "master", "main"];
314
315/// User-provided ref or revision to compare the current branch against.
316#[derive(Clone, Debug, PartialEq, Eq)]
317pub struct TargetSpec(pub String);
318
319/// Options for [`compare_with_target`].
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct TargetComparisonOptions {
322    /// When set, compare against this ref or revision instead of discovering a target branch.
323    pub target: Option<TargetSpec>,
324    /// Maximum number of newest ahead commit oids to retain.
325    pub list_limit: usize,
326    /// When true, retain up to [`Self::list_limit`] newest behind commits as well as ahead.
327    pub include_behind: bool,
328}
329
330/// Ahead-of-target view of the current branch (`grit shortlog` / `grit ahead`).
331#[derive(Clone, Debug, PartialEq, Eq)]
332pub struct TargetComparison {
333    /// Current branch short name.
334    pub branch: String,
335    /// Resolved comparison base (explicit, configured, or default target).
336    pub base: ComparisonBase,
337    /// Commits reachable from the branch tip but not from the base (full count).
338    pub ahead: usize,
339    /// Commits reachable from the base but not from the branch tip (full count).
340    pub behind: usize,
341    /// Newest commits in the ahead set (newest first), up to the requested limit.
342    pub newest_ahead: Vec<ObjectId>,
343    /// Newest commits in the behind set when [`TargetComparisonOptions::include_behind`] is set.
344    pub newest_behind: Vec<ObjectId>,
345}
346
347/// Resolve `refname` through the repository's ref store; refs that are missing or cannot be
348/// resolved (dangling symrefs, unreadable files) count as absent.
349fn 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
440/// Compare the current branch tip against a target ref or the repository's default target branch.
441///
442/// When [`TargetComparisonOptions::target`] is [`None`], the target is discovered in the same
443/// order as [`target_branch`]. When it is set, the spec is resolved through the ref store and
444/// revision parser and peeled to a commit.
445///
446/// # Errors
447///
448/// Returns [`crate::error::Error::DetachedHead`] when `HEAD` is detached,
449/// [`crate::error::Error::UnbornHead`] when the current branch has no commits yet, and revision
450/// resolution errors when an explicit target does not resolve. Returns
451/// [`crate::error::Error::InvalidRef`] when no target branch can be discovered; use
452/// [`compare_with_target_optional`] to handle that case without an error.
453pub 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
461/// Like [`compare_with_target`], but returns `Ok(None)` when no target branch can be discovered
462/// (only when [`TargetComparisonOptions::target`] is [`None`]).
463///
464/// # Errors
465///
466/// Returns the same HEAD, revision-resolution, and object-read errors as
467/// [`compare_with_target`]. An explicit target that cannot be resolved is always an error.
468pub 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
509/// Full refs to try for one target-branch candidate, most specific first.
510fn 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
526/// The repository's target branch: the trunk the current branch is measured against.
527///
528/// Tries the `target.branch` config value first (when set and non-blank), then each of
529/// [`DEFAULT_TARGET_CANDIDATES`]. A candidate is a full ref (`refs/...` or `HEAD`), an
530/// `origin/<branch>` name (tried as `refs/remotes/origin/<branch>`, then as a local branch of
531/// that name), or a short name (tried as a local branch, then as a remote-tracking ref).
532/// Refs are resolved through the repository's ref store, so every ref backend is honoured.
533///
534/// # Parameters
535///
536/// - `repo`: the repository to search.
537///
538/// # Returns
539///
540/// The first candidate that resolves, with [`BaseSource::Target`], or `None` when none does.
541///
542/// # Errors
543///
544/// Returns an error when the repository configuration cannot be loaded.
545pub 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/// The configured upstream of local branch `branch_short`, when it is set and resolves.
553///
554/// The upstream ref comes from [`upstream_tracking_full_ref`] and is resolved through the
555/// repository's ref store.
556///
557/// # Parameters
558///
559/// - `repo`: the repository.
560/// - `branch_short`: local branch name without `refs/heads/`.
561///
562/// # Returns
563///
564/// The upstream with [`BaseSource::Upstream`], or `None` when no upstream is configured or
565/// its ref does not resolve (for example before the first fetch).
566#[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/// For-merge tip recorded in [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) after an explicit fetch.
586#[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/// Commit [`crate::porcelain::pull::pull`] integrates after fetch.
605///
606/// Explicit command-line refspecs use [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion);
607/// otherwise the configured upstream of `branch_short` is used.
608#[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/// Upstream ref for [`crate::porcelain::pull::pull`].
622///
623/// When `branch.<name>.merge` is set, only that configured tracking ref is used; if it
624/// does not resolve after fetch, returns `None` (no implicit fallback). When merge is
625/// unset, uses Git's default `refs/remotes/<remote>/<branch_short>`.
626#[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
656/// The base branch `branch_short` is compared against: its [`upstream_branch`] when that
657/// resolves, otherwise the repository's [`target_branch`].
658///
659/// # Parameters
660///
661/// - `repo`: the repository.
662/// - `branch_short`: local branch name without `refs/heads/`.
663///
664/// # Errors
665///
666/// Returns an error when the repository configuration cannot be loaded.
667pub 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/// Options for [`BranchStanding::compute`].
675#[derive(Clone, Copy, Debug, PartialEq, Eq)]
676pub struct StandingOptions {
677    /// Maximum number of newest ahead commits to retain on each [`TrackedComparison`].
678    pub newest_ahead_limit: usize,
679    /// When `false`, only tip oids are compared; [`TrackedComparison::ahead`] and
680    /// [`TrackedComparison::behind`] stay `None` and no rev-list walk runs.
681    pub counts: bool,
682}
683
684/// Symmetric comparison of the current branch tip against one tracked ref (target or upstream).
685#[derive(Clone, Debug, PartialEq, Eq)]
686pub struct TrackedComparison {
687    /// Short display name (`origin/main`, `main`, …).
688    pub name: String,
689    /// Full ref the comparison resolved through.
690    pub full_ref: String,
691    /// Tip oid of the tracked ref at comparison time.
692    pub oid: ObjectId,
693    /// Commits on the branch not reachable from the tracked tip; `None` when counts were skipped.
694    pub ahead: Option<usize>,
695    /// Commits on the tracked tip not reachable from the branch; `None` when counts were skipped.
696    pub behind: Option<usize>,
697    /// Newest commits among the ahead set (newest first), up to the requested limit.
698    pub newest_ahead: Vec<ObjectId>,
699}
700
701/// How the status header summarizes branch vs target/upstream (before merge/rebase overrides).
702#[derive(Clone, Copy, Debug, PartialEq, Eq)]
703pub enum StandingHeader {
704    /// On a branch that has no commits yet.
705    Unborn,
706    /// HEAD is detached.
707    Detached,
708    /// HEAD could not be resolved.
709    Invalid,
710    /// On a branch with no target or upstream to compare against.
711    NoTarget,
712    /// Symmetric ahead/behind counts vs the primary comparison ref.
713    Compared(Standing),
714    /// Primary comparison ref resolves but ahead/behind counts were skipped.
715    ComparedNoCounts,
716}
717
718/// Target and upstream standing for the current HEAD (status header / JSON).
719#[derive(Clone, Debug, PartialEq, Eq)]
720pub struct BranchStanding {
721    /// Current branch short name, or `None` when HEAD is detached or invalid.
722    pub branch: Option<String>,
723    /// Repository target branch comparison, when a target resolves.
724    pub target: Option<TrackedComparison>,
725    /// Configured upstream comparison, when it resolves and differs from [`Self::target`].
726    pub upstream: Option<TrackedComparison>,
727    /// Header classification for the primary comparison (target, else upstream).
728    pub header: StandingHeader,
729}
730
731/// Status header after branch standing and in-progress operation overrides.
732#[derive(Clone, Debug, PartialEq, Eq)]
733pub enum StatusHeader {
734    /// Normal branch vs target/upstream summary.
735    Branch(StandingHeader),
736    /// A merge is in progress (`MERGE_HEAD` exists).
737    MergeInProgress,
738    /// A rebase is in progress, started by grit or by `git` (`rebase-merge/` or `rebase-apply/`).
739    ///
740    /// `HEAD` is detached while the rebase replays; the branch being rebased comes from the
741    /// rebase state's `head-name`.
742    RebaseInProgress {
743        /// Short name of the branch being rebased; `None` when the rebase started detached.
744        branch: Option<String>,
745        /// Abbreviated commit (or ref) the branch is rebased onto, when recorded.
746        onto: Option<String>,
747    },
748    /// Another in-progress operation (cherry-pick, revert, bisect, …).
749    OtherInProgress,
750}
751
752impl StatusHeader {
753    /// Choose the header line, preferring merge/rebase overrides over branch standing.
754    ///
755    /// # Parameters
756    ///
757    /// - `standing`: branch comparison used when no operation is in progress.
758    /// - `state`: in-progress state from [`wt_status_get_state`](crate::state::wt_status_get_state).
759    /// - `operations`: sentinels from [`detect_in_progress`](crate::state::detect_in_progress).
760    #[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    /// Short ref name for the primary header comparison (target, else upstream).
782    #[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    /// Ref name for a `grit pull` hint: upstream when configured, otherwise target.
792    ///
793    /// Prefer [`status_integration_hint`] for status footers; pull is not always the right action.
794    #[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/// Whether status should suggest `grit pull` or `grit merge` to catch up.
813#[derive(Clone, Copy, Debug, PartialEq, Eq)]
814pub enum StatusIntegrationAction {
815    /// Integrate via configured upstream (`grit pull`).
816    Pull,
817    /// Integrate a local or non-upstream ref (`grit merge <ref>`).
818    Merge,
819}
820
821/// Footer hint for integrating commits the branch is behind.
822#[derive(Clone, Debug, PartialEq, Eq)]
823pub struct StatusIntegrationHint {
824    /// Suggested command kind.
825    pub action: StatusIntegrationAction,
826    /// Short ref name to pass to pull or merge (same ref shown in the status header).
827    pub ref_name: String,
828}
829
830/// Tracked comparison that is strictly behind its base (`ahead == 0`, `behind > 0`).
831#[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/// Resolve a status footer integration hint from branch standing and optional pull context.
846///
847/// When `pull_context` is `Some((repo, branch_short))`, pull is suggested only when
848/// [`resolve_pull_integration_base`] would integrate the same ref the branch is behind.
849/// Without context, pull is assumed when the behind-only comparison is upstream-only.
850#[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    /// Resolve target and upstream for `head` and compare the branch tip against each.
882    ///
883    /// When target and upstream resolve to the same ref, the symmetric walk runs once.
884    /// With [`StandingOptions::counts`] set to `false`, only tip oids are compared and
885    /// ahead/behind counts are omitted.
886    ///
887    /// # Errors
888    ///
889    /// Propagates configuration load errors and object read failures from the commit walk.
890    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/// Whether status should compute ahead/behind counts (`status.aheadBehind`, default true).
1082#[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/// How a branch tip relates to its base, from symmetric ahead/behind counts.
1094#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1095pub enum Standing {
1096    /// Neither side has commits the other lacks.
1097    Even,
1098    /// Only the branch has commits the base lacks.
1099    Ahead,
1100    /// Only the base has commits the branch lacks.
1101    Behind,
1102    /// Both sides have commits the other lacks.
1103    Diverged,
1104}
1105
1106impl Standing {
1107    /// Classify symmetric counts: `ahead` commits only on the branch, `behind` only on the base.
1108    #[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/// Options for [`TargetTipLists::compute`].
1120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1121pub struct TargetTipListOptions {
1122    /// Maximum commits to retain on each listed side (`-n` / `--max-count`; default 100).
1123    pub list_limit: usize,
1124    /// When true, retain up to [`Self::list_limit`] newest behind commits as well as ahead.
1125    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/// Symmetric comparison of two commit tips (counts plus bounded OID lists).
1138#[derive(Clone, Debug, PartialEq, Eq)]
1139pub struct TargetTipLists {
1140    /// Commits reachable from the tip but not from the target.
1141    pub ahead: usize,
1142    /// Commits reachable from the target but not from the tip.
1143    pub behind: usize,
1144    /// Newest ahead commits (newest first), at most [`TargetTipListOptions::list_limit`].
1145    pub ahead_oids: Vec<ObjectId>,
1146    /// Newest behind commits when [`TargetTipListOptions::include_behind`] is set.
1147    pub behind_oids: Vec<ObjectId>,
1148}
1149
1150impl TargetTipLists {
1151    /// Compare `tip` with `target`, matching `git rev-list --left-right --count tip...target`.
1152    ///
1153    /// # Errors
1154    ///
1155    /// Propagates object read and parse errors from the commit walk.
1156    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/// Symmetric comparison of a branch tip with a base commit.
1179#[derive(Clone, Debug, PartialEq, Eq)]
1180pub struct TipComparison {
1181    /// Commits reachable from the tip but not from the base (full count).
1182    pub ahead: usize,
1183    /// Commits reachable from the base but not from the tip (full count).
1184    pub behind: usize,
1185    /// Newest of the `ahead` commits (newest first), at most the requested limit.
1186    pub newest_ahead: Vec<ObjectId>,
1187}
1188
1189impl TipComparison {
1190    /// Compare `tip` with `base`, matching `git rev-list --left-right --count tip...base` for
1191    /// the counts and keeping at most `retain_newest_ahead` of the ahead commits.
1192    ///
1193    /// The walk streams commits instead of materializing ancestor closures, and skips a side
1194    /// entirely when one tip is an ancestor of the other.
1195    ///
1196    /// # Parameters
1197    ///
1198    /// - `repo`: the repository holding both commits.
1199    /// - `tip`: the branch tip.
1200    /// - `base`: the commit it is compared against.
1201    /// - `retain_newest_ahead`: how many of the newest ahead commits to return.
1202    ///
1203    /// # Errors
1204    ///
1205    /// Propagates object read and parse errors from the commit walk.
1206    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    /// How the tip stands relative to the base.
1222    #[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}