Skip to main content

lds_git/
remote.rs

1//! Remote-tracking inspection: fetch, remote list, branch / worktree state
2//! relative to upstream, push reachability.
3//!
4//! These all delegate to `git` over a subprocess (via [`git_cmd`] /
5//! [`git_cmd_combined`]) rather than the git2 C bindings — `git fetch` /
6//! `git ls-remote` need access to the user's credential helpers and refspecs,
7//! which git2 only exposes through `RemoteCallbacks` that we'd have to wire
8//! up to the surrounding session. Shelling out keeps the contract identical
9//! to what the user would type at a shell.
10
11use std::collections::BTreeMap;
12use std::path::Path;
13use std::process::Command;
14
15use anyhow::{Context, Result, bail};
16use git2::Repository;
17
18use crate::output::{
19    BranchStatusOutput, CommitEntry, FetchOutput, IsPushedOutput, RemoteEntry, RemoteListOutput,
20    TagPushedOutput, UnpushedCommitsOutput, WorktreeStateOutput,
21};
22use crate::{GitModule, git_cmd, git_cmd_combined};
23
24impl GitModule {
25    /// Fetch from a remote.
26    ///
27    /// `remote` defaults to `"origin"`. `refspec` is forwarded verbatim when
28    /// present. `prune` adds `--prune` so deleted upstream refs are removed
29    /// locally. The combined stdout + stderr is returned as `raw` for
30    /// transport diagnostics — successful fetches usually print nothing on
31    /// stdout but emit `From <url>` / `<ref> -> <ref>` lines on stderr.
32    pub fn fetch(
33        &self,
34        remote: Option<&str>,
35        refspec: Option<&str>,
36        prune: bool,
37    ) -> Result<FetchOutput> {
38        let remote = remote.unwrap_or("origin").to_string();
39        let mut args: Vec<&str> = vec!["fetch"];
40        if prune {
41            args.push("--prune");
42        }
43        args.push(&remote);
44        if let Some(rs) = refspec {
45            args.push(rs);
46        }
47        let raw = git_cmd_combined(self.session().root(), &args)?;
48        Ok(FetchOutput {
49            remote,
50            refspec: refspec.map(|s| s.to_string()),
51            prune,
52            raw,
53        })
54    }
55
56    /// Enumerate remotes with their fetch / push URLs.
57    ///
58    /// We use `git remote -v` rather than git2's `Repository::remotes` so the
59    /// output matches what `git` itself reports (and naturally handles the
60    /// case where fetch and push URLs diverge via `pushurl`).
61    pub fn remote_list(&self) -> Result<RemoteListOutput> {
62        let raw = git_cmd(self.session().root(), &["remote", "-v"])?;
63        let mut by_name: BTreeMap<String, RemoteEntry> = BTreeMap::new();
64        for line in raw.lines() {
65            // Format: "<name>\t<url> (fetch)" or "<name>\t<url> (push)"
66            let (name, rest) = match line.split_once('\t') {
67                Some(parts) => parts,
68                None => continue,
69            };
70            let (url, dir) = match rest.rsplit_once(' ') {
71                Some((u, d)) => (u.trim(), d.trim()),
72                None => continue,
73            };
74            let entry = by_name
75                .entry(name.to_string())
76                .or_insert_with(|| RemoteEntry {
77                    name: name.to_string(),
78                    fetch_url: None,
79                    push_url: None,
80                });
81            match dir {
82                "(fetch)" => entry.fetch_url = Some(url.to_string()),
83                "(push)" => entry.push_url = Some(url.to_string()),
84                _ => {}
85            }
86        }
87        Ok(RemoteListOutput {
88            remotes: by_name.into_values().collect(),
89        })
90    }
91
92    /// Compare `branch` against `base` and report ahead / behind counts plus
93    /// the merge-base, using git2's `graph_ahead_behind` for the count and a
94    /// `git merge-base` shell-out for the common-ancestor sha (git2's
95    /// `merge_base` returns an OID but we want the textual form).
96    pub fn branch_status(&self, branch: &str, base: &str) -> Result<BranchStatusOutput> {
97        let repo = Repository::open(self.session().root())?;
98        let branch_oid = repo
99            .revparse_single(branch)
100            .with_context(|| format!("revparse failed: {branch}"))?
101            .id();
102        let base_oid = repo
103            .revparse_single(base)
104            .with_context(|| format!("revparse failed: {base}"))?
105            .id();
106        let (ahead, behind) = repo
107            .graph_ahead_behind(branch_oid, base_oid)
108            .with_context(|| format!("graph_ahead_behind({branch}, {base})"))?;
109
110        let common_ancestor = repo
111            .merge_base(branch_oid, base_oid)
112            .ok()
113            .map(|oid| oid.to_string());
114
115        Ok(BranchStatusOutput {
116            branch: branch.to_string(),
117            base: base.to_string(),
118            ahead: ahead as u32,
119            behind: behind as u32,
120            up_to_date: ahead == 0 && behind == 0,
121            common_ancestor,
122        })
123    }
124
125    /// List commits that exist on `branch` but not on `<remote>/<branch>`.
126    pub fn unpushed_commits(&self, branch: &str, remote: &str) -> Result<UnpushedCommitsOutput> {
127        let remote_ref = format!("{remote}/{branch}");
128        let remote_head = git_cmd(self.session().root(), &["rev-parse", &remote_ref])?;
129        let raw = git_cmd(
130            self.session().root(),
131            &[
132                "log",
133                "--format=%H%x09%s",
134                &format!("{remote_ref}..{branch}"),
135            ],
136        )?;
137        let mut commits = Vec::new();
138        for line in raw.lines() {
139            if line.is_empty() {
140                continue;
141            }
142            let (sha, summary) = line.split_once('\t').unwrap_or((line, ""));
143            let short_sha = sha[..7.min(sha.len())].to_string();
144            commits.push(CommitEntry {
145                sha: sha.to_string(),
146                short_sha,
147                summary: summary.to_string(),
148            });
149        }
150        Ok(UnpushedCommitsOutput {
151            branch: branch.to_string(),
152            remote: remote.to_string(),
153            remote_head,
154            count: commits.len(),
155            commits,
156        })
157    }
158
159    /// Check whether `commit` is reachable from any remote-tracking ref
160    /// under `refs/remotes/<remote>/`.
161    pub fn is_pushed(&self, commit: &str, remote: &str) -> Result<IsPushedOutput> {
162        let refspace = format!("refs/remotes/{remote}/");
163        let raw = git_cmd(
164            self.session().root(),
165            &[
166                "for-each-ref",
167                "--contains",
168                commit,
169                "--format=%(refname)",
170                &refspace,
171            ],
172        )?;
173        let refs: Vec<String> = raw
174            .lines()
175            .filter(|l| !l.is_empty())
176            .map(|s| s.to_string())
177            .collect();
178        let pushed = !refs.is_empty();
179        Ok(IsPushedOutput {
180            commit: commit.to_string(),
181            remote: remote.to_string(),
182            pushed,
183            refs,
184        })
185    }
186
187    /// Check whether `tag` exists on `remote` (via `git ls-remote --tags`).
188    pub fn tag_pushed(&self, tag: &str, remote: &str) -> Result<TagPushedOutput> {
189        let refspec = format!("refs/tags/{tag}");
190        let raw = git_cmd(
191            self.session().root(),
192            &["ls-remote", "--tags", remote, &refspec],
193        )?;
194        let remote_refs: Vec<String> = raw
195            .lines()
196            .filter(|l| !l.is_empty())
197            .map(|s| s.to_string())
198            .collect();
199        let pushed = !remote_refs.is_empty();
200        Ok(TagPushedOutput {
201            tag: tag.to_string(),
202            remote: remote.to_string(),
203            pushed,
204            remote_refs,
205        })
206    }
207
208    /// Snapshot a worktree's state (branch, tracking, ahead/behind, uncommitted).
209    pub fn worktree_state(&self, branch: Option<&str>) -> Result<WorktreeStateOutput> {
210        let root = self.session().root();
211        let resolved_branch = match branch {
212            Some(b) => b.to_string(),
213            None => git_cmd(root, &["branch", "--show-current"])?,
214        };
215
216        let tracking = resolve_upstream(root)?;
217
218        let (ahead, behind) = if let Some(ref upstream) = tracking {
219            let repo = Repository::open(root)?;
220            let branch_oid = repo.revparse_single(&resolved_branch)?.id();
221            let upstream_oid = repo.revparse_single(upstream)?.id();
222            let (a, b) = repo
223                .graph_ahead_behind(branch_oid, upstream_oid)
224                .unwrap_or((0, 0));
225            (a as u32, b as u32)
226        } else {
227            (0, 0)
228        };
229
230        let porcelain = git_cmd(root, &["status", "--porcelain"]).unwrap_or_default();
231        let uncommitted = porcelain.lines().filter(|l| !l.is_empty()).count();
232        let clean = uncommitted == 0;
233        let sync = behind == 0;
234
235        Ok(WorktreeStateOutput {
236            branch: resolved_branch,
237            tracking,
238            ahead,
239            behind,
240            uncommitted,
241            clean,
242            sync,
243        })
244    }
245}
246
247/// Resolve the upstream tracking ref of the current HEAD, returning `None`
248/// when no upstream is configured.
249fn resolve_upstream(root: &Path) -> Result<Option<String>> {
250    let output = Command::new("git")
251        .args(["rev-parse", "--abbrev-ref", "@{upstream}"])
252        .current_dir(root)
253        .output()
254        .context("failed to spawn git rev-parse @{upstream}")?;
255    if output.status.success() {
256        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
257        return Ok(if raw.is_empty() { None } else { Some(raw) });
258    }
259    match output.status.code() {
260        Some(128) => Ok(None),
261        _ => {
262            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
263            bail!("git rev-parse @{{upstream}}: {stderr}");
264        }
265    }
266}