Skip to main content

grit_lib/
branch_inventory.rs

1//! Branch listing with upstream and worktree metadata ([`list_branches`]).
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use time::OffsetDateTime;
7
8use crate::config::ConfigSet;
9use crate::error::{Error, Result};
10use crate::objects::{read_commit_subject_pretty, ObjectId, ObjectKind};
11use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
12use crate::refs::listing::{
13    query_refs_peeling, PeelTips, RefKind, RefNamespace, RefQuery, RefQueryError, RefQueryOptions,
14    SortKey,
15};
16use crate::repo::Repository;
17use crate::rev_parse::abbreviate_ref_name;
18use crate::state::{resolve_head_for_repository, HeadState};
19use crate::worktree::list_worktrees;
20
21/// Which branches to include in [`list_branches`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BranchScope {
24    /// Local branches only (`refs/heads/*`).
25    #[default]
26    Local,
27    /// Remote-tracking branches only (`refs/remotes/*`).
28    Remote,
29    /// Local and remote-tracking branches.
30    All,
31}
32
33/// How much object data [`list_branches`] loads per branch.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum BranchDetail {
36    /// Ref names and oids only; no commit objects read for branch fields.
37    #[default]
38    Names,
39    /// Commit subjects, committer times, and upstream ahead/behind when configured.
40    Full,
41}
42
43/// Query for [`list_branches`]: scope plus shared ref patterns, sort, and limit.
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct BranchListQuery {
46    /// Local, remote, or both.
47    pub scope: BranchScope,
48    /// Pattern, sort, and limit (namespaces come from [`Self::scope`]).
49    pub refs: RefQuery,
50}
51
52/// Upstream of a local branch when `branch.<name>.merge` / `.remote` is set.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct UpstreamStatus {
55    /// Full upstream ref (`refs/remotes/...` or `refs/heads/...`).
56    pub full_ref: String,
57    /// Abbreviated upstream name (`origin/main`).
58    pub short: String,
59    /// Configured `branch.<name>.remote`.
60    pub remote: String,
61    /// Configured `branch.<name>.merge`.
62    pub merge: String,
63    /// Upstream ref is configured but does not resolve.
64    pub gone: bool,
65    /// Commits on the branch not on the upstream tip ([`BranchDetail::Full`] only).
66    pub ahead: Option<usize>,
67    /// Commits on the upstream not on the branch ([`BranchDetail::Full`] only).
68    pub behind: Option<usize>,
69}
70
71/// One row from [`list_branches`].
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct BranchSummary {
74    /// Short display name (`main`, `origin/main`, …).
75    pub name: String,
76    /// Full ref name.
77    pub full_ref: String,
78    /// True for `refs/remotes/*`.
79    pub is_remote: bool,
80    /// Whether this branch is the current `HEAD` branch.
81    pub current: bool,
82    /// Tip object id.
83    pub oid: ObjectId,
84    /// Tip commit subject ([`BranchDetail::Full`], Git `%(contents:subject)`).
85    pub subject: Option<String>,
86    /// Tip committer time ([`BranchDetail::Full`]).
87    pub committer_time: Option<OffsetDateTime>,
88    /// Configured upstream for local branches.
89    pub upstream: Option<UpstreamStatus>,
90    /// Linked worktree path when this branch is checked out there (Git `worktreepath`).
91    pub worktree: Option<PathBuf>,
92}
93
94/// Configured upstream for local branch `branch` (short name, no `refs/heads/`).
95///
96/// Ahead/behind counts are always computed when the upstream resolves.
97///
98/// # Errors
99///
100/// Propagates configuration, ref resolution, and rev-walk errors.
101pub fn upstream_status(repo: &Repository, branch: &str) -> Result<Option<UpstreamStatus>> {
102    let tip = repo
103        .references()
104        .resolve_if_exists(&format!("refs/heads/{branch}"))?;
105    let config = repo.config()?;
106    build_upstream_status(repo, &config, branch, tip, BranchDetail::Full)
107}
108
109/// List branches matching `query` at the requested detail level.
110///
111/// [`BranchDetail::Names`] does not read commit objects for summary fields.
112/// [`BranchDetail::Full`] loads commit headers in parallel and computes upstream
113/// ahead/behind via [`crate::branch_tracking::TipComparison`]. Worktree paths are resolved once per call.
114///
115/// # Errors
116///
117/// Propagates ref listing, configuration, object read, and walk errors.
118pub fn list_branches(
119    repo: &Repository,
120    query: &BranchListQuery,
121    detail: BranchDetail,
122) -> Result<Vec<BranchSummary>> {
123    if detail == BranchDetail::Names && matches!(query.refs.sort.key, SortKey::Date) {
124        return Err(Error::BranchListNamesDateSort);
125    }
126    let config = repo.config()?;
127    let listings = query_refs_peeling(
128        repo,
129        &ref_query_for_branch_list(query),
130        &ref_query_options(&config),
131        PeelTips::WhenFiltering,
132    )
133    .map_err(ref_query_error)?;
134
135    let current = match resolve_head_for_repository(repo)? {
136        HeadState::Branch { short_name, .. } => Some(short_name),
137        _ => None,
138    };
139
140    let worktrees = branch_worktree_paths(repo)?;
141
142    let commit_meta = if detail == BranchDetail::Full {
143        let oids: Vec<ObjectId> = listings.iter().map(|l| l.oid).collect();
144        Some(load_commit_metadata(repo, &oids)?)
145    } else {
146        None
147    };
148
149    let mut out = Vec::with_capacity(listings.len());
150    for (idx, listing) in listings.into_iter().enumerate() {
151        let is_remote = listing.kind == RefKind::Remote;
152        let current = !is_remote && current.as_deref() == Some(listing.short_name.as_str());
153        let (subject, committer_time) = commit_meta
154            .as_ref()
155            .and_then(|m| m.get(idx))
156            .cloned()
157            .unwrap_or((None, None));
158
159        let upstream = if is_remote {
160            None
161        } else {
162            build_upstream_status(
163                repo,
164                &config,
165                &listing.short_name,
166                Some(listing.oid),
167                detail,
168            )?
169        };
170
171        let worktree = if is_remote {
172            None
173        } else {
174            worktrees.get(&listing.short_name).cloned()
175        };
176
177        out.push(BranchSummary {
178            name: branch_display_name(query.scope, is_remote, &listing.short_name),
179            full_ref: listing.full_name,
180            is_remote,
181            current,
182            oid: listing.oid,
183            subject,
184            committer_time,
185            upstream,
186            worktree,
187        });
188    }
189    Ok(out)
190}
191
192fn branch_display_name(scope: BranchScope, is_remote: bool, short_name: &str) -> String {
193    if is_remote && scope == BranchScope::All {
194        format!("remotes/{short_name}")
195    } else {
196        short_name.to_owned()
197    }
198}
199
200fn ref_query_for_branch_list(query: &BranchListQuery) -> RefQuery {
201    let mut refs = query.refs.clone();
202    refs.namespaces = match query.scope {
203        BranchScope::Local => vec![RefNamespace::Heads],
204        BranchScope::Remote => vec![RefNamespace::Remotes],
205        BranchScope::All => vec![RefNamespace::Heads, RefNamespace::Remotes],
206    };
207    refs
208}
209
210fn ref_query_options(config: &ConfigSet) -> RefQueryOptions {
211    RefQueryOptions {
212        versionsort_suffixes: config
213            .get_all("versionsort.suffix")
214            .iter()
215            .map(|s| s.to_owned())
216            .collect(),
217        ignore_case: config
218            .get_bool("core.ignorecase")
219            .and_then(|r| r.ok())
220            .unwrap_or(false),
221    }
222}
223
224fn ref_query_error(err: RefQueryError) -> Error {
225    match err {
226        RefQueryError::Store(e) => e,
227        RefQueryError::MissingObjectDatabase => {
228            Error::Message("creator-date sort requires commit objects".into())
229        }
230        RefQueryError::MissingObject(oid) => Error::ObjectNotFound(oid.to_string()),
231        RefQueryError::NotCommit(oid) => Error::Message(format!("not a commit: {oid}")),
232    }
233}
234
235fn branch_worktree_paths(repo: &Repository) -> Result<HashMap<String, PathBuf>> {
236    let mut map = HashMap::new();
237    for entry in list_worktrees(repo)? {
238        if let HeadState::Branch { short_name, .. } = &entry.head {
239            map.entry(short_name.clone())
240                .or_insert_with(|| entry.path.clone());
241        }
242    }
243    Ok(map)
244}
245
246fn build_upstream_status(
247    repo: &Repository,
248    config: &ConfigSet,
249    branch_short: &str,
250    branch_tip: Option<ObjectId>,
251    detail: BranchDetail,
252) -> Result<Option<UpstreamStatus>> {
253    let remote_key = format!("branch.{branch_short}.remote");
254    let merge_key = format!("branch.{branch_short}.merge");
255    let Some(full_ref) = crate::branch_tracking::branch_upstream_full_ref(config, branch_short)
256    else {
257        return Ok(None);
258    };
259    let remote = config.get(&remote_key).unwrap_or_else(|| ".".to_owned());
260    let merge = config
261        .get(&merge_key)
262        .unwrap_or_else(|| format!("refs/heads/{branch_short}"));
263    let resolved = repo.references().resolve_if_exists(&full_ref)?;
264    let gone = resolved.is_none();
265    let short = abbreviate_ref_name(&full_ref);
266    let (ahead, behind) = if detail == BranchDetail::Full && !gone {
267        if let (Some(tip), Some(up_oid)) = (branch_tip, resolved) {
268            let cmp = crate::branch_tracking::TipComparison::compute(repo, tip, up_oid, 0)?;
269            (Some(cmp.ahead), Some(cmp.behind))
270        } else {
271            (None, None)
272        }
273    } else {
274        (None, None)
275    };
276    Ok(Some(UpstreamStatus {
277        full_ref,
278        short,
279        remote,
280        merge,
281        gone,
282        ahead,
283        behind,
284    }))
285}
286
287fn load_commit_metadata(
288    repo: &Repository,
289    oids: &[ObjectId],
290) -> Result<Vec<(Option<String>, Option<OffsetDateTime>)>> {
291    if let [oid] = oids {
292        return Ok(vec![read_commit_meta(repo, oid)?]);
293    }
294    if oids.is_empty() {
295        return Ok(Vec::new());
296    }
297    let config = repo.config()?;
298    let par_opts = repo
299        .thread_policy()
300        .resolve(ParallelOp::DiffContent, &config);
301    let total_bytes = oids.len().saturating_mul(512);
302    let rows = try_par_map(oids, par_opts, total_bytes, |oid| {
303        read_commit_meta(repo, oid)
304    })
305    .map_err(|e| match e {
306        ParallelHashError::Task(err) => err,
307    })?;
308    Ok(rows)
309}
310
311fn read_commit_meta(
312    repo: &Repository,
313    oid: &ObjectId,
314) -> Result<(Option<String>, Option<OffsetDateTime>)> {
315    let obj = repo.odb().read(oid)?;
316    if obj.kind != ObjectKind::Commit {
317        return Ok((None, None));
318    }
319    let subject = read_commit_subject_pretty(&obj.data).ok();
320    let committer_time = crate::objects::CommitRef::parse(&obj.data)
321        .ok()
322        .and_then(|cref| {
323            OffsetDateTime::from_unix_timestamp(cref.committer_unix_seconds_for_ordering()).ok()
324        });
325    Ok((subject, committer_time))
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn branch_display_name_matches_git_branch_a_for_remotes() {
334        assert_eq!(
335            branch_display_name(BranchScope::All, true, "origin/main"),
336            "remotes/origin/main"
337        );
338        assert_eq!(
339            branch_display_name(BranchScope::Remote, true, "origin/main"),
340            "origin/main"
341        );
342        assert_eq!(branch_display_name(BranchScope::All, false, "main"), "main");
343    }
344}