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
151#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum Host {
155 GitHub,
156 GitLab,
157 Bitbucket,
158 Gitea,
159 Other,
161}
162
163pub struct Remote {
164 pub host: Host,
165 pub owner_repo: String, pub base: String, }
168
169pub fn normalize_remote(url: &str) -> Option<Remote> {
172 let url = url.trim().trim_end_matches(".git");
173 let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
174 let (host, path) = rest.split_once(':')?;
176 (format!("https://{host}"), path.to_string())
177 } else if let Some(rest) = url.strip_prefix("ssh://git@") {
178 let rest = rest.split('/').collect::<Vec<_>>();
180 let host = rest.first()?;
181 (format!("https://{host}"), rest[1..].join("/"))
182 } else if url.starts_with("https://") || url.starts_with("http://") {
183 let stripped = url
184 .strip_prefix("https://")
185 .or_else(|| url.strip_prefix("http://"))?;
186 let (host, path) = stripped.split_once('/')?;
187 (format!("https://{host}"), path.to_string())
188 } else {
189 return None;
190 };
191 let host = match base.as_str() {
192 "https://github.com" => Host::GitHub,
193 "https://gitlab.com" => Host::GitLab,
194 "https://bitbucket.org" => Host::Bitbucket,
195 b if b.contains("gitea") => Host::Gitea,
196 _ => Host::Other,
197 };
198 Some(Remote {
199 host,
200 owner_repo: path,
201 base,
202 })
203}
204
205pub fn pick_remote(repo: &Repo) -> Option<Remote> {
207 let remotes = repo.remotes();
208 for name in ["upstream", "origin"] {
209 if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
210 if let Some(r) = normalize_remote(url) {
211 return Some(r);
212 }
213 }
214 }
215 remotes.iter().find_map(|(_, u)| normalize_remote(u))
216}
217
218pub fn permalink(repo: &Repo, rel: &Path, start_line: usize, end_line: usize) -> Option<String> {
221 let remote = pick_remote(repo)?;
222 let sha = repo.head_sha()?;
223 let frag = if start_line == end_line {
224 format!("#L{start_line}")
225 } else {
226 format!("#L{start_line}-L{end_line}")
227 };
228 Some(format!(
229 "{}/{}/blob/{}/{}{frag}",
230 remote.base,
231 remote.owner_repo,
232 sha,
233 rel.display()
234 ))
235}
236
237fn rel_age(ts: i64) -> String {
239 let now = std::time::SystemTime::now()
240 .duration_since(std::time::UNIX_EPOCH)
241 .map(|d| d.as_secs() as i64)
242 .unwrap_or(0);
243 let age = (now - ts).max(0);
244 match age {
245 a if a < 3600 => format!("{}m", a / 60),
246 a if a < 86400 => format!("{}h", a / 3600),
247 a if a < 86400 * 30 => format!("{}d", a / 86400),
248 a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
249 a => format!("{}y", a / (86400 * 365)),
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn normalizes_ssh_and_https() {
259 let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
260 assert_eq!(
261 (r.base.as_str(), r.owner_repo.as_str()),
262 ("https://github.com", "stropdev/strop")
263 );
264 assert_eq!(r.host, Host::GitHub);
265 let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
266 assert_eq!(r.host, Host::GitLab);
267 assert_eq!(r.owner_repo, "org/proj");
268 let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
269 assert_eq!(r.host, Host::Bitbucket);
270 assert!(normalize_remote("not a url").is_none());
271 }
272}