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