1use std::path::Path;
7
8use serde::Serialize;
9
10use crate::git::Git;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct Commit {
15 pub id: String,
17 pub subject: String,
19 pub task: Option<String>,
21 pub verdict: Option<String>,
23 pub date: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
29pub struct File {
30 pub path: String,
32 pub add: Option<u64>,
34 pub del: Option<u64>,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct Changes {
41 pub branch: Option<String>,
43 pub base: Option<String>,
45 pub commits: Vec<Commit>,
47 pub files: Vec<File>,
49 pub patch: String,
51 pub cut: bool,
53}
54
55pub const PATCH_LIMIT: usize = 2 * 1024 * 1024;
57pub const FILE_LIMIT: usize = 512 * 1024;
59
60fn branch_of(root: &Path, feature: Option<&str>) -> Option<String> {
63 let inspected = crate::inspect(root, feature);
64 let Some(speccoder_core::CommandData::Inspect(snapshot)) = inspected.data() else {
65 return None;
66 };
67 let branch = format!("speccoder/{}", snapshot.feature.as_ref()?.feature.name);
68 Git::at(root)
69 .succeeds(&[
70 "rev-parse",
71 "--verify",
72 "-q",
73 &format!("refs/heads/{branch}"),
74 ])
75 .unwrap_or(false)
76 .then_some(branch)
77}
78
79#[must_use]
81pub fn changes(root: &Path, feature: Option<&str>) -> Changes {
82 let empty = |branch| Changes {
83 branch,
84 base: None,
85 commits: Vec::new(),
86 files: Vec::new(),
87 patch: String::new(),
88 cut: false,
89 };
90 let Some(branch) = branch_of(root, feature) else {
91 return empty(None);
92 };
93 let g = Git::at(root);
94 let Ok(head) = g.line(&["rev-parse", "HEAD"]) else {
95 return empty(Some(branch));
96 };
97 let Ok(base) = g.line(&["merge-base", &branch, &head]) else {
98 return empty(Some(branch));
99 };
100 let range = format!("{base}..{branch}");
101 let commits = g
102 .run(&[
103 "log",
104 "--no-color",
105 "--format=%H%x1f%s%x1f%(trailers:key=Spec-Task,valueonly,separator=%x2c)%x1f%(trailers:key=Verdict,valueonly,separator=%x2c)%x1f%aI%x1e",
106 &range,
107 ])
108 .map(|b| {
109 String::from_utf8_lossy(&b)
110 .split('\x1e')
111 .filter_map(|rec| {
112 let f: Vec<&str> = rec.trim().split('\x1f').collect();
113 let [id, subject, task, verdict, date] = f.as_slice() else { return None };
114 let some = |s: &str| (!s.trim().is_empty()).then(|| s.trim().to_owned());
115 Some(Commit {
116 id: (*id).to_owned(),
117 subject: (*subject).to_owned(),
118 task: some(task),
119 verdict: some(verdict),
120 date: (*date).to_owned(),
121 })
122 })
123 .collect()
124 })
125 .unwrap_or_default();
126 let files = g
129 .run(&["diff", "--numstat", "-z", "-M", &base, &branch])
130 .map(|b| numstat(&b))
131 .unwrap_or_default();
132 let mut patch = g
133 .run(&["diff", "--no-color", "-M", &base, &branch])
134 .map(|b| String::from_utf8_lossy(&b).into_owned())
135 .unwrap_or_default();
136 let cut = patch.len() > PATCH_LIMIT;
137 if cut {
138 let mut at = PATCH_LIMIT;
139 while !patch.is_char_boundary(at) {
140 at -= 1;
141 }
142 patch.truncate(at);
143 }
144 Changes {
145 branch: Some(branch),
146 base: Some(base),
147 commits,
148 files,
149 patch,
150 cut,
151 }
152}
153
154fn numstat(out: &[u8]) -> Vec<File> {
157 let text = String::from_utf8_lossy(out);
158 let mut fields = text.split('\0');
159 let mut files = Vec::new();
160 while let Some(head) = fields.next() {
161 let mut it = head.splitn(3, '\t');
162 let (Some(a), Some(d), Some(p)) = (it.next(), it.next(), it.next()) else {
163 continue;
164 };
165 let path = if p.is_empty() {
166 let _old = fields.next();
168 fields.next().unwrap_or_default().to_owned()
169 } else {
170 p.to_owned()
171 };
172 files.push(File {
173 path,
174 add: a.parse().ok(),
175 del: d.parse().ok(),
176 });
177 }
178 files
179}
180
181fn revision(root: &Path, feature: Option<&str>) -> String {
183 branch_of(root, feature).unwrap_or_else(|| "HEAD".to_owned())
184}
185
186#[must_use]
188pub fn tree(root: &Path, feature: Option<&str>) -> (String, Vec<String>) {
189 let rev = revision(root, feature);
190 let files = Git::at(root)
191 .run(&["ls-tree", "-r", "--name-only", "-z", &rev])
192 .map(|b| {
193 crate::git::nul_fields(&b)
194 .into_iter()
195 .take(20_000)
196 .collect()
197 })
198 .unwrap_or_default();
199 (rev, files)
200}
201
202pub fn file(root: &Path, feature: Option<&str>, path: &str) -> Result<String, String> {
209 let (rev, files) = tree(root, feature);
210 if !files.iter().any(|f| f == path) {
211 return Err(format!("{path} is not in {rev}"));
212 }
213 let bytes = Git::at(root)
214 .run(&["show", &format!("{rev}:./{path}")])
217 .map_err(|e| e.0)?;
218 if bytes.len() > FILE_LIMIT {
219 return Err(format!("{path} is larger than {} KB", FILE_LIMIT / 1024));
220 }
221 if bytes.contains(&0) {
222 return Err(format!("{path} is binary"));
223 }
224 Ok(String::from_utf8_lossy(&bytes).into_owned())
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn git(dir: &Path, args: &[&str]) {
232 let ok = std::process::Command::new("git")
233 .args([
234 "-c",
235 "user.name=t",
236 "-c",
237 "user.email=t@e",
238 "-c",
239 "commit.gpgsign=false",
240 ])
241 .args(args)
242 .current_dir(dir)
243 .status()
244 .is_ok_and(|s| s.success());
245 assert!(ok, "git {args:?}");
246 }
247
248 #[test]
249 fn numstat_reads_renames_and_unusual_paths() {
250 let out = b"3\t1\tsrc/lib.rs\x002\t0\t\x00src/a.rs\x00src/b.rs\x00-\t-\tlogo.png\x001\t0\tdocs/\xc3\xbcber.md\x00";
251 let files = numstat(out);
252 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
253 assert_eq!(
254 paths,
255 ["src/lib.rs", "src/b.rs", "logo.png", "docs/über.md"]
256 );
257 assert_eq!((files[2].add, files[2].del), (None, None));
258 assert_eq!(files[1].add, Some(2));
259 }
260
261 #[test]
264 fn a_nested_project_reads_its_files_and_nothing_else() {
265 let repo = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
266 let project = repo.path().join("apps/web");
267 std::fs::create_dir_all(project.join("src")).unwrap_or_else(|e| panic!("{e}"));
268 std::fs::write(project.join("src/lib.rs"), "pub fn ok() {}\n")
269 .unwrap_or_else(|e| panic!("{e}"));
270 std::fs::write(repo.path().join("secret.txt"), "outside\n")
271 .unwrap_or_else(|e| panic!("{e}"));
272 git(repo.path(), &["init", "-q"]);
273 git(repo.path(), &["add", "-A"]);
274 git(repo.path(), &["commit", "-qm", "init"]);
275 let (rev, files) = tree(&project, None);
276 assert_eq!(rev, "HEAD");
277 assert_eq!(files, ["src/lib.rs"]);
278 assert_eq!(
279 file(&project, None, "src/lib.rs").as_deref(),
280 Ok("pub fn ok() {}\n")
281 );
282 for bad in [
283 "../../secret.txt",
284 "secret.txt",
285 "src/../../../secret.txt",
286 "src",
287 ] {
288 assert!(file(&project, None, bad).is_err(), "{bad}");
289 }
290 }
291}