Skip to main content

wt/git/
refs.rs

1//! Ref and branch reads via `gix` (spec §4): local branch listing, upstream
2//! resolution, ref resolution, and default-branch resolution.
3
4use crate::error::{Error, Result};
5
6/// The configured upstream of a local branch.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub(crate) struct Upstream {
9    /// Display form, e.g. `origin/feature/x`.
10    pub(crate) display: String,
11    /// The remote-tracking ref, e.g. `refs/remotes/origin/feature/x`.
12    pub(crate) tracking_ref: String,
13    /// Whether the tracking ref is gone (configured but no longer present).
14    pub(crate) is_gone: bool,
15}
16
17/// Lists local branch names (without the `refs/heads/` prefix).
18pub(crate) fn local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
19    let platform = repo
20        .references()
21        .map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
22    let iter = platform
23        .local_branches()
24        .map_err(|e| Error::operation(format!("cannot list branches: {e}")))?;
25    let mut names = Vec::new();
26    for reference in iter {
27        let reference =
28            reference.map_err(|e| Error::operation(format!("cannot read branch: {e}")))?;
29        names.push(reference.name().shorten().to_string());
30    }
31    names.sort();
32    Ok(names)
33}
34
35/// Lists remote-tracking branch names (e.g. `origin/main`), skipping the
36/// symbolic `<remote>/HEAD` pointers (which alias a real branch, not a fork
37/// candidate). Names keep their remote prefix so they read unambiguously.
38pub(crate) fn remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
39    let platform = repo
40        .references()
41        .map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
42    let iter = platform
43        .remote_branches()
44        .map_err(|e| Error::operation(format!("cannot list remote branches: {e}")))?;
45    let mut names = Vec::new();
46    for reference in iter {
47        let reference =
48            reference.map_err(|e| Error::operation(format!("cannot read remote branch: {e}")))?;
49        let name = reference.name().shorten().to_string();
50        // `origin/HEAD` is a symbolic alias for the default branch, not a branch.
51        if name.ends_with("/HEAD") {
52            continue;
53        }
54        names.push(name);
55    }
56    names.sort();
57    Ok(names)
58}
59
60/// Lists every branch a new worktree can fork from or check out: local branches
61/// first (sorted), then remote-tracking branches (sorted). Best-effort — used to
62/// populate the TUI create-prompt options dropdown (issue #25).
63pub(crate) fn all_branches(repo: &gix::Repository) -> Result<Vec<String>> {
64    let mut names = local_branches(repo)?;
65    names.extend(remote_branches(repo)?);
66    Ok(names)
67}
68
69/// The fully-qualified ref of a local branch, i.e. `refs/heads/<branch>`.
70///
71/// Centralizes the single spelling of this path that the rest of the crate
72/// builds whenever it needs a branch's full ref — for `gix` rev-parsing,
73/// `git merge-base`, `git branch -f`, and so on.
74pub(crate) fn branch_ref(branch: &str) -> String {
75    format!("refs/heads/{branch}")
76}
77
78/// Validates a user-entered branch name as a legal git branch ref
79/// (`git check-ref-format --branch` semantics), returning a human-readable
80/// reason on failure. The name is validated as the full ref `refs/heads/<name>`,
81/// so single-component lowercase names like `feature` are accepted while
82/// illegal forms (`feat..x`, `a b`, `*x`, `.hidden`, `x.lock`, `HEAD`, …) are
83/// rejected.
84pub(crate) fn validate_branch_name(name: &str) -> std::result::Result<(), String> {
85    use gix::bstr::ByteSlice;
86    let full = branch_ref(name);
87    gix::validate::reference::branch_name(full.as_bytes().as_bstr())
88        .map(|_| ())
89        .map_err(|e| format!("invalid branch name: {e}"))
90}
91
92/// Resolves a revspec to an object id (hex), or `None` if it does not resolve.
93pub(crate) fn resolve_hex(repo: &gix::Repository, spec: &str) -> Option<String> {
94    repo.rev_parse_single(spec)
95        .ok()
96        .map(|id| id.detach().to_string())
97}
98
99/// Resolves the configured upstream of `branch`, or `None` if none is set.
100pub(crate) fn upstream_of(repo: &gix::Repository, branch: &str) -> Option<Upstream> {
101    let config = repo.config_snapshot();
102    let remote = config.string(format!("branch.{branch}.remote").as_str())?;
103    let merge = config.string(format!("branch.{branch}.merge").as_str())?;
104    let remote = remote.to_string();
105    let merge = merge.to_string();
106    let merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
107    let display = format!("{remote}/{merge_branch}");
108    let tracking_ref = format!("refs/remotes/{remote}/{merge_branch}");
109    let is_gone = resolve_hex(repo, &tracking_ref).is_none();
110    Some(Upstream {
111        display,
112        tracking_ref,
113        is_gone,
114    })
115}
116
117/// Whether commit-ish `a` is an ancestor of `b` (i.e. `a` is fully merged into
118/// `b`), determined offline via `gix`. Returns `false` if either revspec does not
119/// resolve or there is no merge base (unrelated histories).
120///
121/// `a` is an ancestor of `b` exactly when their best merge base is `a` itself;
122/// this also yields `true` when `a == b`, matching `git merge-base --is-ancestor`.
123pub(crate) fn is_ancestor(repo: &gix::Repository, a: &str, b: &str) -> bool {
124    let Some(a_id) = repo.rev_parse_single(a).ok().map(|id| id.detach()) else {
125        return false;
126    };
127    let Some(b_id) = repo.rev_parse_single(b).ok().map(|id| id.detach()) else {
128        return false;
129    };
130    repo.merge_base(a_id, b_id)
131        .map(|base| base.detach() == a_id)
132        .unwrap_or(false)
133}
134
135/// Resolves the repository's default branch (spec §7): the `origin/HEAD` target,
136/// falling back to the current branch. (`init.defaultBranch` is deliberately not
137/// consulted — it governs *new* repositories, not an existing repo's default.)
138pub(crate) fn default_branch(repo: &gix::Repository) -> Option<String> {
139    origin_head_branch(repo).or_else(|| current_branch(repo))
140}
141
142/// The remote-tracking default a new worktree should fork from (issue #70): the
143/// `origin/HEAD` target in display form, e.g. `origin/main`. Forking off the
144/// remote tip keeps new branches based on the up-to-date default rather than a
145/// possibly-stale local copy. `None` when `origin/HEAD` is unset (no remote, or
146/// the repo was never cloned) — callers then fall back to their own default.
147pub(crate) fn default_base_ref(repo: &gix::Repository) -> Option<String> {
148    origin_head_tracking(repo)
149}
150
151/// The current branch name, or `None` for a detached HEAD or unborn branch.
152pub(crate) fn current_branch(repo: &gix::Repository) -> Option<String> {
153    let head = repo.head().ok()?;
154    head.referent_name().map(|name| name.shorten().to_string())
155}
156
157/// The branch that `refs/remotes/origin/HEAD` points to, if any (the short name,
158/// e.g. `main`). Drives default-branch resolution and PR trunk detection.
159pub(crate) fn origin_head_branch(repo: &gix::Repository) -> Option<String> {
160    // The tracking form is `origin/<branch>`; drop the remote to get the branch
161    // (handles slashes in branch names).
162    origin_head_tracking(repo)?
163        .split_once('/')
164        .map(|(_, branch)| branch.to_string())
165}
166
167/// The remote-tracking ref `refs/remotes/origin/HEAD` points to, in display form
168/// (e.g. `origin/main`), if it is set as a symbolic ref.
169fn origin_head_tracking(repo: &gix::Repository) -> Option<String> {
170    let reference = repo.find_reference("refs/remotes/origin/HEAD").ok()?;
171    match reference.target() {
172        gix::refs::TargetRef::Symbolic(name) => {
173            // e.g. refs/remotes/origin/main -> origin/main.
174            let full = name.as_bstr().to_string();
175            full.strip_prefix("refs/remotes/").map(str::to_string)
176        }
177        gix::refs::TargetRef::Object(_) => None,
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::git::discover::Repo;
185    use crate::testutil::TestRepo;
186
187    #[test]
188    fn lists_local_branches_sorted() {
189        let repo = TestRepo::init();
190        repo.git(&["branch", "zeta"]);
191        repo.git(&["branch", "alpha"]);
192        let r = Repo::discover(repo.root()).unwrap();
193        let branches = local_branches(r.gix()).unwrap();
194        assert_eq!(branches, vec!["alpha", "main", "zeta"]);
195    }
196
197    #[test]
198    fn lists_remote_branches_skipping_head() {
199        let repo = TestRepo::init();
200        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
201        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
202        repo.git(&["update-ref", "refs/remotes/origin/feature/x", &head]);
203        // The symbolic origin/HEAD alias must be excluded.
204        repo.git(&[
205            "symbolic-ref",
206            "refs/remotes/origin/HEAD",
207            "refs/remotes/origin/main",
208        ]);
209        let r = Repo::discover(repo.root()).unwrap();
210        let remotes = remote_branches(r.gix()).unwrap();
211        assert_eq!(remotes, vec!["origin/feature/x", "origin/main"]);
212    }
213
214    #[test]
215    fn all_branches_lists_locals_then_remotes() {
216        let repo = TestRepo::init();
217        repo.git(&["branch", "zeta"]);
218        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
219        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
220        let r = Repo::discover(repo.root()).unwrap();
221        let all = all_branches(r.gix()).unwrap();
222        // Local branches (sorted) first, then remote-tracking branches (sorted).
223        assert_eq!(all, vec!["main", "zeta", "origin/main"]);
224    }
225
226    #[test]
227    fn validates_branch_names() {
228        // Legal branch names, including slashes, dashes, underscores, digits.
229        for ok in ["feature", "feature/x", "fix-bug_123", "release/v1.2"] {
230            assert!(
231                validate_branch_name(ok).is_ok(),
232                "expected {ok:?} to be valid"
233            );
234        }
235        // Illegal forms are rejected with a reason.
236        for bad in [
237            "feat..x", "a b", "*x", ".hidden", "feature/", "x.lock", "HEAD", "",
238        ] {
239            let err = validate_branch_name(bad).unwrap_err();
240            assert!(
241                err.starts_with("invalid branch name:"),
242                "expected {bad:?} to be rejected, got {err:?}"
243            );
244        }
245    }
246
247    #[test]
248    fn branch_ref_prefixes_refs_heads() {
249        assert_eq!(branch_ref("main"), "refs/heads/main");
250        // Slashes in the branch name are preserved, not escaped.
251        assert_eq!(branch_ref("feature/login"), "refs/heads/feature/login");
252    }
253
254    #[test]
255    fn resolves_refs() {
256        let repo = TestRepo::init();
257        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
258        let r = Repo::discover(repo.root()).unwrap();
259        assert_eq!(resolve_hex(r.gix(), "HEAD").as_deref(), Some(head.as_str()));
260        assert_eq!(
261            resolve_hex(r.gix(), "refs/heads/main").as_deref(),
262            Some(head.as_str())
263        );
264        assert!(resolve_hex(r.gix(), "refs/heads/nope").is_none());
265    }
266
267    #[test]
268    fn upstream_present_absent_and_gone() {
269        let repo = TestRepo::init();
270        let r = Repo::discover(repo.root()).unwrap();
271        // No upstream configured.
272        assert!(upstream_of(r.gix(), "main").is_none());
273
274        // Configure an upstream with a present tracking ref.
275        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
276        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
277        repo.git(&["config", "branch.main.remote", "origin"]);
278        repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
279        let r = Repo::discover(repo.root()).unwrap();
280        let up = upstream_of(r.gix(), "main").unwrap();
281        assert_eq!(up.display, "origin/main");
282        assert_eq!(up.tracking_ref, "refs/remotes/origin/main");
283        assert!(!up.is_gone);
284
285        // Delete the tracking ref -> gone.
286        repo.git(&["update-ref", "-d", "refs/remotes/origin/main"]);
287        let r = Repo::discover(repo.root()).unwrap();
288        let up = upstream_of(r.gix(), "main").unwrap();
289        assert!(up.is_gone);
290    }
291
292    #[test]
293    fn default_branch_prefers_origin_head() {
294        let repo = TestRepo::init();
295        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
296        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
297        repo.git(&[
298            "symbolic-ref",
299            "refs/remotes/origin/HEAD",
300            "refs/remotes/origin/main",
301        ]);
302        let r = Repo::discover(repo.root()).unwrap();
303        assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
304    }
305
306    #[test]
307    fn default_branch_falls_back_to_current() {
308        let repo = TestRepo::init();
309        let r = Repo::discover(repo.root()).unwrap();
310        assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
311        assert_eq!(current_branch(r.gix()).as_deref(), Some("main"));
312    }
313
314    #[test]
315    fn default_base_ref_is_origin_head_tracking_form() {
316        // The default base is the remote-tracking ref (origin/main), so a new
317        // worktree forks off the up-to-date remote tip (issue #70).
318        let repo = TestRepo::init();
319        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
320        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
321        repo.git(&[
322            "symbolic-ref",
323            "refs/remotes/origin/HEAD",
324            "refs/remotes/origin/main",
325        ]);
326        let r = Repo::discover(repo.root()).unwrap();
327        assert_eq!(default_base_ref(r.gix()).as_deref(), Some("origin/main"));
328    }
329
330    #[test]
331    fn default_base_ref_none_without_origin_head() {
332        // No origin/HEAD: there is no confident remote default, so the caller
333        // falls back to its own resolution rather than guessing.
334        let repo = TestRepo::init();
335        let r = Repo::discover(repo.root()).unwrap();
336        assert_eq!(default_base_ref(r.gix()), None);
337    }
338
339    #[test]
340    fn is_ancestor_true_when_merged_false_when_divergent() {
341        let repo = TestRepo::init();
342        // `topic` branches off main with no extra commits: an ancestor of main.
343        repo.git(&["branch", "topic"]);
344        let r = Repo::discover(repo.root()).unwrap();
345        assert!(is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
346        // A ref is trivially an ancestor of itself (matches `--is-ancestor`).
347        assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/main"));
348        // Add a commit on `topic` so it diverges: no longer an ancestor of main.
349        repo.git(&["checkout", "topic"]);
350        repo.write("t.txt", "1\n");
351        repo.commit_all("topic work");
352        let r = Repo::discover(repo.root()).unwrap();
353        assert!(!is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
354        // ...but main is still an ancestor of topic.
355        assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/topic"));
356    }
357
358    #[test]
359    fn is_ancestor_false_for_missing_ref() {
360        let repo = TestRepo::init();
361        let r = Repo::discover(repo.root()).unwrap();
362        assert!(!is_ancestor(r.gix(), "refs/heads/nope", "refs/heads/main"));
363    }
364
365    #[test]
366    fn is_ancestor_false_for_unrelated_histories() {
367        // Two roots with no common ancestor: `git merge-base --is-ancestor`
368        // exits non-zero (no merge base), so this must be `false`, not a panic.
369        let repo = TestRepo::init();
370        repo.git(&["checkout", "--orphan", "unrelated"]);
371        repo.write("o.txt", "x\n");
372        repo.commit_all("orphan root");
373        let r = Repo::discover(repo.root()).unwrap();
374        assert!(!is_ancestor(
375            r.gix(),
376            "refs/heads/main",
377            "refs/heads/unrelated"
378        ));
379    }
380}