1use std::path::Path;
9
10use anyhow::{Context, Result};
11use git2::{Delta, Repository};
12
13#[derive(Debug, Default, Clone)]
15pub struct ChangeSet {
16 pub added: Vec<String>,
18 pub modified: Vec<String>,
20 pub deleted: Vec<String>,
22}
23
24impl ChangeSet {
25 pub fn total(&self) -> usize {
26 self.added.len() + self.modified.len() + self.deleted.len()
27 }
28
29 pub fn to_parse(&self) -> impl Iterator<Item = &String> {
31 self.added.iter().chain(self.modified.iter())
32 }
33
34 pub fn to_drop(&self) -> impl Iterator<Item = &String> {
36 self.modified.iter().chain(self.deleted.iter())
37 }
38}
39
40fn path_string(p: Option<&Path>) -> Option<String> {
41 p.map(|p| p.to_string_lossy().replace('\\', "/"))
42}
43
44pub fn head_commit(repo_path: &Path) -> Result<String> {
47 let repo = Repository::open(repo_path)
48 .with_context(|| format!("open git repo at {}", repo_path.display()))?;
49 let commit = repo.head()?.peel_to_commit()?;
50 Ok(commit.id().to_string())
51}
52
53pub fn changed_files(repo_path: &Path, base_sha: &str) -> Result<ChangeSet> {
56 let repo = Repository::open(repo_path)
57 .with_context(|| format!("open git repo at {}", repo_path.display()))?;
58 let base_tree = repo
59 .revparse_single(base_sha)
60 .with_context(|| format!("resolve base commit {base_sha}"))?
61 .peel_to_commit()?
62 .tree()?;
63 let head_tree = repo.head()?.peel_to_commit()?.tree()?;
64
65 let mut opts = git2::DiffOptions::new();
66 let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
67
68 let mut cs = ChangeSet::default();
69 for d in diff.deltas() {
70 let new_p = path_string(d.new_file().path());
71 let old_p = path_string(d.old_file().path());
72 match d.status() {
73 Delta::Added | Delta::Copied => cs.added.extend(new_p),
74 Delta::Deleted => cs.deleted.extend(old_p),
75 Delta::Modified | Delta::Typechange => cs.modified.extend(new_p),
76 Delta::Renamed => {
77 cs.deleted.extend(old_p);
78 cs.added.extend(new_p);
79 }
80 _ => {}
81 }
82 }
83 Ok(cs)
84}
85
86#[derive(Debug, Clone)]
88pub struct CommitFiles {
89 pub sha: String,
90 pub subject: String,
91 pub time: i64,
93 pub mr: Option<u64>,
95 pub added: Vec<String>,
96 pub changed: Vec<String>,
97}
98
99pub fn history(repo_path: &Path, limit: usize) -> Result<Vec<CommitFiles>> {
101 let repo = Repository::open(repo_path)
102 .with_context(|| format!("open git repo at {}", repo_path.display()))?;
103 let mut walk = repo.revwalk()?;
104 walk.push_head()?;
105 walk.simplify_first_parent()?;
106 let mut out = Vec::new();
107 for oid in walk.take(limit) {
108 let commit = repo.find_commit(oid?)?;
109 let tree = commit.tree()?;
110 let parent = commit.parents().next().map(|p| p.tree()).transpose()?;
111 let diff = repo.diff_tree_to_tree(parent.as_ref(), Some(&tree), None)?;
112 let mut c = CommitFiles {
113 sha: commit.id().to_string(),
114 subject: commit
115 .summary()
116 .ok()
117 .flatten()
118 .unwrap_or_default()
119 .to_string(),
120 time: commit.time().seconds(),
121 mr: commit.message().ok().and_then(merge_request),
122 added: Vec::new(),
123 changed: Vec::new(),
124 };
125 for d in diff.deltas() {
126 let Some(p) = path_string(d.new_file().path()) else {
127 continue;
128 };
129 if matches!(d.status(), Delta::Added | Delta::Copied | Delta::Renamed) {
130 c.added.push(p.clone());
131 }
132 if !matches!(d.status(), Delta::Deleted) {
133 c.changed.push(p);
134 }
135 }
136 out.push(c);
137 }
138 Ok(out)
139}
140
141fn merge_request(msg: &str) -> Option<u64> {
144 if let Some(line) = msg.lines().find(|l| l.starts_with("See merge request ")) {
145 return line.rsplit_once('!')?.1.trim().parse().ok();
146 }
147 let first = msg.lines().next()?.trim_end();
148 if let Some(rest) = first.strip_prefix("Merge pull request #") {
149 return rest.split_whitespace().next()?.parse().ok();
150 }
151 first.strip_suffix(')')?.rsplit_once("(#")?.1.parse().ok()
152}
153
154pub type FileHunks = (String, Vec<(u32, u32)>);
156
157pub fn working_changes(repo_path: &Path, base: &str) -> Result<Vec<FileHunks>> {
159 let repo = Repository::open(repo_path)
160 .with_context(|| format!("open git repo at {}", repo_path.display()))?;
161 let tree = repo.revparse_single(base)?.peel_to_tree()?;
162 let mut opts = git2::DiffOptions::new();
163 opts.include_untracked(true).recurse_untracked_dirs(true);
164 let diff = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts))?;
165 let out: std::cell::RefCell<Vec<FileHunks>> = Default::default();
166 diff.foreach(
167 &mut |d, _| {
168 if let Some(p) = path_string(d.new_file().path()) {
169 out.borrow_mut().push((p, Vec::new()));
170 }
171 true
172 },
173 None,
174 Some(&mut |d, h| {
175 let mut v = out.borrow_mut();
176 if let (Some(p), Some(last)) = (path_string(d.new_file().path()), v.last_mut()) {
177 if last.0 == p {
178 let start = h.new_start().max(1);
179 last.1.push((start, start + h.new_lines().max(1) - 1));
180 }
181 }
182 true
183 }),
184 None,
185 )?;
186 Ok(out.into_inner())
187}
188
189#[cfg(test)]
190mod tests {
191 #[test]
192 fn merge_request_from_merge_commit() {
193 let msg = "Merge branch 'x' into 'main'\n\nFix it\n\nSee merge request team/orders!476";
194 assert_eq!(super::merge_request(msg), Some(476));
195 assert_eq!(
196 super::merge_request("Merge pull request #12 from a/b\n\nFix"),
197 Some(12)
198 );
199 assert_eq!(
200 super::merge_request("Fix the retry (#34)\n\nbody"),
201 Some(34)
202 );
203 assert_eq!(super::merge_request("Fix it"), None);
204 }
205}