1use 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 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 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 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 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 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 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 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 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
282async 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}