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