1use std::path::{Path, PathBuf};
6
7use crate::Repo;
8
9#[derive(Debug, Clone)]
11pub struct LogRow {
12 pub text: String,
14 pub sha: Option<String>,
16}
17
18pub fn log_graph(workdir: &Path, max: usize, file: Option<&Path>) -> Result<Vec<LogRow>, String> {
21 let mut cmd = std::process::Command::new("git");
22 cmd.args([
23 "-C",
24 &workdir.display().to_string(),
25 "log",
26 "--graph",
27 "--format=%h %an · %ar · %s%x00%H",
28 "-n",
29 &max.to_string(),
30 ]);
31 if let Some(f) = file {
32 cmd.arg("--").arg(f);
33 }
34 let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
35 if !out.status.success() {
36 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
37 }
38 let text = String::from_utf8_lossy(&out.stdout);
39 Ok(text
40 .lines()
41 .map(|line| {
42 let (vis, sha) = match line.split_once('\0') {
44 Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
45 None => (line.to_string(), None),
46 };
47 LogRow { text: vis, sha }
48 })
49 .collect())
50}
51
52#[derive(Debug, Clone)]
54pub struct BlameCard {
55 pub sha: String,
56 pub short_sha: String,
57 pub author: String,
58 pub age: String,
59 pub summary: String,
60 pub line: usize,
61}
62
63pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
65 let out = std::process::Command::new("git")
66 .args([
67 "-C",
68 &workdir.display().to_string(),
69 "blame",
70 "--line-porcelain",
71 "-L",
72 &format!("{line},{line}"),
73 "--",
74 &rel.display().to_string(),
75 ])
76 .output()
77 .map_err(|e| format!("spawn git blame: {e}"))?;
78 if !out.status.success() {
79 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
80 }
81 let text = String::from_utf8_lossy(&out.stdout);
82 let mut sha = String::new();
83 let mut author = String::new();
84 let mut summary = String::new();
85 let mut ts = 0i64;
86 for l in text.lines() {
87 if sha.is_empty()
88 && !l.starts_with('\t')
89 && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
90 {
91 sha = l.split_whitespace().next().unwrap_or("").to_string();
92 } else if let Some(a) = l.strip_prefix("author ") {
93 author = a.to_string();
94 } else if let Some(t) = l.strip_prefix("author-time ") {
95 ts = t.parse().unwrap_or(0);
96 } else if let Some(s) = l.strip_prefix("summary ") {
97 summary = s.to_string();
98 }
99 }
100 if sha.is_empty() {
101 return Err("no blame for line".into());
102 }
103 Ok(BlameCard {
104 short_sha: sha.chars().take(8).collect(),
105 sha,
106 author,
107 age: rel_age(ts),
108 summary,
109 line,
110 })
111}
112
113#[derive(Debug, Clone)]
115pub struct ChangedFile {
116 pub path: PathBuf,
117 pub added: usize,
118 pub deleted: usize,
119}
120
121pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
122 let out = std::process::Command::new("git")
123 .args([
124 "-C",
125 &workdir.display().to_string(),
126 "show",
127 "--numstat",
128 "--format=",
129 sha,
130 ])
131 .output()
132 .map_err(|e| format!("spawn git show: {e}"))?;
133 if !out.status.success() {
134 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
135 }
136 Ok(String::from_utf8_lossy(&out.stdout)
137 .lines()
138 .filter_map(|l| {
139 let mut parts = l.split('\t');
140 let added = parts.next()?.parse().ok()?;
141 let deleted = parts.next()?.parse().ok()?;
142 Some(ChangedFile {
143 path: PathBuf::from(parts.next()?),
144 added,
145 deleted,
146 })
147 })
148 .collect())
149}
150
151pub fn show_file_delta(workdir: &Path, sha: &str, file: &Path) -> Result<String, String> {
153 let out = std::process::Command::new("git")
154 .args([
155 "-C",
156 &workdir.display().to_string(),
157 "show",
158 "--format=",
159 "--patch",
160 sha,
161 "--",
162 &file.display().to_string(),
163 ])
164 .output()
165 .map_err(|e| format!("spawn git show: {e}"))?;
166 if !out.status.success() {
167 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
168 }
169 Ok(String::from_utf8_lossy(&out.stdout).to_string())
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum Host {
176 GitHub,
177 GitLab,
178 Bitbucket,
179 Gitea,
180 Other,
182}
183
184pub struct Remote {
185 pub host: Host,
186 pub owner_repo: String, pub base: String, }
189
190pub fn normalize_remote(url: &str) -> Option<Remote> {
193 let url = url.trim().trim_end_matches(".git");
194 let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
195 let (host, path) = rest.split_once(':')?;
197 (format!("https://{host}"), path.to_string())
198 } else if let Some(rest) = url.strip_prefix("ssh://git@") {
199 let rest = rest.split('/').collect::<Vec<_>>();
201 let host = rest.first()?;
202 (format!("https://{host}"), rest[1..].join("/"))
203 } else if url.starts_with("https://") || url.starts_with("http://") {
204 let stripped = url
205 .strip_prefix("https://")
206 .or_else(|| url.strip_prefix("http://"))?;
207 let (host, path) = stripped.split_once('/')?;
208 (format!("https://{host}"), path.to_string())
209 } else {
210 return None;
211 };
212 let host = match base.as_str() {
213 "https://github.com" => Host::GitHub,
214 "https://gitlab.com" => Host::GitLab,
215 "https://bitbucket.org" => Host::Bitbucket,
216 b if b.contains("gitea") => Host::Gitea,
217 _ => Host::Other,
218 };
219 Some(Remote {
220 host,
221 owner_repo: path,
222 base,
223 })
224}
225
226pub fn pick_remote(repo: &Repo) -> Option<Remote> {
228 let remotes = repo.remotes();
229 for name in ["upstream", "origin"] {
230 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
231 if let Some(r) = normalize_remote(url) {
232 return Some(r);
233 }
234 }
235 }
236 remotes.iter().find_map(|(_, u)| normalize_remote(u))
237}
238
239pub fn permalink(repo: &Repo, rel: &Path, start_line: usize, end_line: usize) -> Option<String> {
242 let remote = pick_remote(repo)?;
243 let sha = repo.head_sha()?;
244 let frag = if start_line == end_line {
245 format!("#L{start_line}")
246 } else {
247 format!("#L{start_line}-L{end_line}")
248 };
249 Some(format!(
250 "{}/{}/blob/{}/{}{frag}",
251 remote.base,
252 remote.owner_repo,
253 sha,
254 rel.display()
255 ))
256}
257
258fn rel_age(ts: i64) -> String {
260 let now = std::time::SystemTime::now()
261 .duration_since(std::time::UNIX_EPOCH)
262 .map(|d| d.as_secs() as i64)
263 .unwrap_or(0);
264 let age = (now - ts).max(0);
265 match age {
266 a if a < 3600 => format!("{}m", a / 60),
267 a if a < 86400 => format!("{}h", a / 3600),
268 a if a < 86400 * 30 => format!("{}d", a / 86400),
269 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
270 a => format!("{}y", a / (86400 * 365)),
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn normalizes_ssh_and_https() {
280 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
281 assert_eq!(
282 (r.base.as_str(), r.owner_repo.as_str()),
283 ("https://github.com", "stropdev/strop")
284 );
285 assert_eq!(r.host, Host::GitHub);
286 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
287 assert_eq!(r.host, Host::GitLab);
288 assert_eq!(r.owner_repo, "org/proj");
289 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
290 assert_eq!(r.host, Host::Bitbucket);
291 assert!(normalize_remote("not a url").is_none());
292 }
293}