1use 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 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 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 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 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 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 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 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 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
253fn 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}