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%an <%ae>%x09%at%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 mut parts = line.splitn(4, '\t');
143            let sha = parts.next().unwrap_or("");
144            let author = parts.next().unwrap_or("").to_string();
145            let timestamp = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
146            let summary = parts.next().unwrap_or("").to_string();
147            let short_sha = sha[..7.min(sha.len())].to_string();
148            commits.push(CommitEntry {
149                sha: sha.to_string(),
150                short_sha,
151                summary,
152                author,
153                timestamp,
154            });
155        }
156        Ok(UnpushedCommitsOutput {
157            branch: branch.to_string(),
158            remote: remote.to_string(),
159            remote_head,
160            count: commits.len(),
161            commits,
162        })
163    }
164
165    /// Check whether `commit` is reachable from any remote-tracking ref
166    /// under `refs/remotes/<remote>/`.
167    pub fn is_pushed(&self, commit: &str, remote: &str) -> Result<IsPushedOutput> {
168        let refspace = format!("refs/remotes/{remote}/");
169        let raw = git_cmd(
170            self.session().root(),
171            &[
172                "for-each-ref",
173                "--contains",
174                commit,
175                "--format=%(refname)",
176                &refspace,
177            ],
178        )?;
179        let refs: Vec<String> = raw
180            .lines()
181            .filter(|l| !l.is_empty())
182            .map(|s| s.to_string())
183            .collect();
184        let pushed = !refs.is_empty();
185        Ok(IsPushedOutput {
186            commit: commit.to_string(),
187            remote: remote.to_string(),
188            pushed,
189            refs,
190        })
191    }
192
193    /// Check whether `tag` exists on `remote` (via `git ls-remote --tags`).
194    pub fn tag_pushed(&self, tag: &str, remote: &str) -> Result<TagPushedOutput> {
195        let refspec = format!("refs/tags/{tag}");
196        let raw = git_cmd(
197            self.session().root(),
198            &["ls-remote", "--tags", remote, &refspec],
199        )?;
200        let remote_refs: Vec<String> = raw
201            .lines()
202            .filter(|l| !l.is_empty())
203            .map(|s| s.to_string())
204            .collect();
205        let pushed = !remote_refs.is_empty();
206        Ok(TagPushedOutput {
207            tag: tag.to_string(),
208            remote: remote.to_string(),
209            pushed,
210            remote_refs,
211        })
212    }
213
214    /// Snapshot a worktree's state (branch, tracking, ahead/behind, uncommitted).
215    pub fn worktree_state(&self, branch: Option<&str>) -> Result<WorktreeStateOutput> {
216        let root = self.session().root();
217        let resolved_branch = match branch {
218            Some(b) => b.to_string(),
219            None => git_cmd(root, &["branch", "--show-current"])?,
220        };
221
222        let tracking = resolve_upstream(root)?;
223
224        let (ahead, behind) = if let Some(ref upstream) = tracking {
225            let repo = Repository::open(root)?;
226            let branch_oid = repo.revparse_single(&resolved_branch)?.id();
227            let upstream_oid = repo.revparse_single(upstream)?.id();
228            let (a, b) = repo
229                .graph_ahead_behind(branch_oid, upstream_oid)
230                .unwrap_or((0, 0));
231            (a as u32, b as u32)
232        } else {
233            (0, 0)
234        };
235
236        let porcelain = git_cmd(root, &["status", "--porcelain"]).unwrap_or_default();
237        let uncommitted = porcelain.lines().filter(|l| !l.is_empty()).count();
238        let clean = uncommitted == 0;
239        let sync = behind == 0;
240
241        Ok(WorktreeStateOutput {
242            branch: resolved_branch,
243            tracking,
244            ahead,
245            behind,
246            uncommitted,
247            clean,
248            sync,
249        })
250    }
251}
252
253/// Resolve the upstream tracking ref of the current HEAD, returning `None`
254/// when no upstream is configured.
255fn resolve_upstream(root: &Path) -> Result<Option<String>> {
256    let output = Command::new("git")
257        .args(["rev-parse", "--abbrev-ref", "@{upstream}"])
258        .current_dir(root)
259        .output()
260        .context("failed to spawn git rev-parse @{upstream}")?;
261    if output.status.success() {
262        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
263        return Ok(if raw.is_empty() { None } else { Some(raw) });
264    }
265    match output.status.code() {
266        Some(128) => Ok(None),
267        _ => {
268            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
269            bail!("git rev-parse @{{upstream}}: {stderr}");
270        }
271    }
272}