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