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%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 (sha, summary) = line.split_once('\t').unwrap_or((line, ""));
143 let short_sha = sha[..7.min(sha.len())].to_string();
144 commits.push(CommitEntry {
145 sha: sha.to_string(),
146 short_sha,
147 summary: summary.to_string(),
148 });
149 }
150 Ok(UnpushedCommitsOutput {
151 branch: branch.to_string(),
152 remote: remote.to_string(),
153 remote_head,
154 count: commits.len(),
155 commits,
156 })
157 }
158
159 pub fn is_pushed(&self, commit: &str, remote: &str) -> Result<IsPushedOutput> {
162 let refspace = format!("refs/remotes/{remote}/");
163 let raw = git_cmd(
164 self.session().root(),
165 &[
166 "for-each-ref",
167 "--contains",
168 commit,
169 "--format=%(refname)",
170 &refspace,
171 ],
172 )?;
173 let refs: Vec<String> = raw
174 .lines()
175 .filter(|l| !l.is_empty())
176 .map(|s| s.to_string())
177 .collect();
178 let pushed = !refs.is_empty();
179 Ok(IsPushedOutput {
180 commit: commit.to_string(),
181 remote: remote.to_string(),
182 pushed,
183 refs,
184 })
185 }
186
187 pub fn tag_pushed(&self, tag: &str, remote: &str) -> Result<TagPushedOutput> {
189 let refspec = format!("refs/tags/{tag}");
190 let raw = git_cmd(
191 self.session().root(),
192 &["ls-remote", "--tags", remote, &refspec],
193 )?;
194 let remote_refs: Vec<String> = raw
195 .lines()
196 .filter(|l| !l.is_empty())
197 .map(|s| s.to_string())
198 .collect();
199 let pushed = !remote_refs.is_empty();
200 Ok(TagPushedOutput {
201 tag: tag.to_string(),
202 remote: remote.to_string(),
203 pushed,
204 remote_refs,
205 })
206 }
207
208 pub fn worktree_state(&self, branch: Option<&str>) -> Result<WorktreeStateOutput> {
210 let root = self.session().root();
211 let resolved_branch = match branch {
212 Some(b) => b.to_string(),
213 None => git_cmd(root, &["branch", "--show-current"])?,
214 };
215
216 let tracking = resolve_upstream(root)?;
217
218 let (ahead, behind) = if let Some(ref upstream) = tracking {
219 let repo = Repository::open(root)?;
220 let branch_oid = repo.revparse_single(&resolved_branch)?.id();
221 let upstream_oid = repo.revparse_single(upstream)?.id();
222 let (a, b) = repo
223 .graph_ahead_behind(branch_oid, upstream_oid)
224 .unwrap_or((0, 0));
225 (a as u32, b as u32)
226 } else {
227 (0, 0)
228 };
229
230 let porcelain = git_cmd(root, &["status", "--porcelain"]).unwrap_or_default();
231 let uncommitted = porcelain.lines().filter(|l| !l.is_empty()).count();
232 let clean = uncommitted == 0;
233 let sync = behind == 0;
234
235 Ok(WorktreeStateOutput {
236 branch: resolved_branch,
237 tracking,
238 ahead,
239 behind,
240 uncommitted,
241 clean,
242 sync,
243 })
244 }
245}
246
247fn resolve_upstream(root: &Path) -> Result<Option<String>> {
250 let output = Command::new("git")
251 .args(["rev-parse", "--abbrev-ref", "@{upstream}"])
252 .current_dir(root)
253 .output()
254 .context("failed to spawn git rev-parse @{upstream}")?;
255 if output.status.success() {
256 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
257 return Ok(if raw.is_empty() { None } else { Some(raw) });
258 }
259 match output.status.code() {
260 Some(128) => Ok(None),
261 _ => {
262 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
263 bail!("git rev-parse @{{upstream}}: {stderr}");
264 }
265 }
266}