1use std::path::Path;
26use std::process::Stdio;
27
28use anyhow::{Context as _, Result};
29use serde::{Deserialize, Serialize};
30use tokio::io::AsyncWriteExt as _;
31use tokio::process::Command;
32
33use crate::git;
34use crate::proc::Quiet as _;
35
36const PAIRING_LIMIT: usize = 1000;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Proof {
44 PatchId,
46 Tree,
48 Ancestry,
50}
51
52impl Proof {
53 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::PatchId => "patch-id",
57 Self::Tree => "tree",
58 Self::Ancestry => "ancestry",
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum AlreadyIn {
66 Yes(Proof),
68 No,
70}
71
72pub fn already_in(
77 added: &[String],
78 unmatched: &[String],
79 matched: &[String],
80 tree_unchanged: bool,
81) -> AlreadyIn {
82 if added.is_empty() {
83 return AlreadyIn::No;
84 }
85 let untested = added
88 .iter()
89 .any(|c| !unmatched.contains(c) && !matched.contains(c));
90 if !tree_unchanged {
94 return AlreadyIn::No;
95 }
96 if unmatched.is_empty() && !matched.is_empty() && !untested {
97 return AlreadyIn::Yes(Proof::PatchId);
98 }
99 AlreadyIn::Yes(Proof::Tree)
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct Evidence {
105 pub proof: Proof,
107 pub tip: String,
109 #[serde(default)]
112 pub commits: Vec<String>,
113}
114
115impl Evidence {
116 pub fn commit(&self) -> &str {
118 self.commits
119 .first()
120 .map_or(self.tip.as_str(), String::as_str)
121 }
122
123 pub fn names(&self) -> String {
125 if self.commits.is_empty() {
126 return short_sha(&self.tip).to_owned();
127 }
128 self.commits
129 .iter()
130 .map(|c| short_sha(c))
131 .collect::<Vec<_>>()
132 .join(", ")
133 }
134}
135
136pub fn short_sha(sha: &str) -> &str {
138 sha.get(..7).unwrap_or(sha)
139}
140
141pub async fn classify(
148 repo: &Path,
149 base: &str,
150 head: &str,
151 start: Option<&str>,
152) -> Result<Option<Evidence>> {
153 let base = git::rev_parse(repo, base).await?;
154 let head = git::rev_parse(repo, head).await?;
155 if start.is_some_and(|s| s == head) {
156 return Ok(None);
157 }
158 if git::is_ancestor(repo, &head, &base).await {
159 return Ok(Some(Evidence {
162 proof: Proof::Ancestry,
163 tip: base,
164 commits: vec![head],
165 }));
166 }
167 let merge_base = git::git(repo, &["merge-base", &base, &head]).await?;
168 let range = format!("{merge_base}..{head}");
169 let added: Vec<String> = git::git(repo, &["rev-list", &range])
170 .await?
171 .lines()
172 .map(str::to_owned)
173 .collect();
174 if added.is_empty() {
175 return Ok(None);
176 }
177 let (unmatched, matched) = git::cherry(repo, &base, &head).await?;
178 let tree_unchanged = tree_unchanged(repo, &base, &head).await;
179 let proof = match already_in(&added, &unmatched, &matched, tree_unchanged) {
180 AlreadyIn::Yes(p) => p,
181 AlreadyIn::No => return Ok(None),
182 };
183 let commits = match proof {
184 Proof::PatchId => twins(repo, &merge_base, &base, &head, &matched)
185 .await
186 .unwrap_or_default(),
187 Proof::Tree | Proof::Ancestry => Vec::new(),
188 };
189 Ok(Some(Evidence {
190 proof,
191 tip: base,
192 commits,
193 }))
194}
195
196async fn tree_unchanged(repo: &Path, base: &str, head: &str) -> bool {
199 let Ok(merged) = git::git_raw(repo, &["merge-tree", "--write-tree", base, head]).await else {
200 return false;
201 };
202 if !merged.ok() {
203 return false;
204 }
205 let Some(tree) = merged.stdout.lines().next() else {
206 return false;
207 };
208 let base_tree = format!("{base}^{{tree}}");
209 git::rev_parse(repo, &base_tree)
210 .await
211 .is_ok_and(|t| t == tree.trim())
212}
213
214async fn patch_ids(repo: &Path, range: &str) -> Result<Vec<(String, String)>> {
216 let log = git::git(repo, &["log", "-p", "--no-merges", "--no-color", range]).await?;
217 let mut child = Command::new("git")
218 .args(["patch-id", "--stable"])
219 .current_dir(repo)
220 .quiet()
221 .stdin(Stdio::piped())
222 .stdout(Stdio::piped())
223 .stderr(Stdio::null())
224 .spawn()
225 .context("spawn git patch-id")?;
226 let mut stdin = child.stdin.take().context("git patch-id stdin")?;
227 let feed = tokio::spawn(async move {
228 let _ = stdin.write_all(log.as_bytes()).await;
229 let _ = stdin.write_all(b"\n").await;
230 });
231 let out = child.wait_with_output().await.context("git patch-id")?;
232 let _ = feed.await;
233 Ok(String::from_utf8_lossy(&out.stdout)
234 .lines()
235 .filter_map(|l| {
236 let (p, c) = l.split_once(' ')?;
237 Some((p.to_owned(), c.trim().to_owned()))
238 })
239 .collect())
240}
241
242async fn twins(
244 repo: &Path,
245 merge_base: &str,
246 base: &str,
247 head: &str,
248 matched: &[String],
249) -> Result<Vec<String>> {
250 let count = git::commits_ahead(repo, merge_base, base).await?;
251 if count > PAIRING_LIMIT {
252 return Ok(Vec::new());
253 }
254 let ours = patch_ids(repo, &format!("{merge_base}..{head}")).await?;
255 let theirs = patch_ids(repo, &format!("{merge_base}..{base}")).await?;
256 let mut out: Vec<String> = Vec::new();
257 for (pid, commit) in &ours {
258 if !matched.contains(commit) {
259 continue;
260 }
261 if let Some((_, twin)) = theirs.iter().find(|(p, _)| p == pid)
262 && !out.contains(twin)
263 {
264 out.push(twin.clone());
265 }
266 }
267 Ok(out)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn v(items: &[&str]) -> Vec<String> {
275 items.iter().map(|s| (*s).to_owned()).collect()
276 }
277
278 #[test]
279 fn every_commit_matched_is_already_in() {
280 assert_eq!(
281 already_in(&v(&["a", "b"]), &[], &v(&["a", "b"]), true),
282 AlreadyIn::Yes(Proof::PatchId)
283 );
284 }
285
286 #[test]
287 fn a_reverted_twin_is_not_already_in() {
288 assert_eq!(
289 already_in(&v(&["a", "b"]), &[], &v(&["a", "b"]), false),
290 AlreadyIn::No
291 );
292 }
293
294 #[test]
295 fn a_partial_overlap_is_not_already_in() {
296 assert_eq!(
297 already_in(&v(&["a", "b"]), &v(&["b"]), &v(&["a"]), false),
298 AlreadyIn::No
299 );
300 }
301
302 #[test]
303 fn a_merge_commit_cherry_never_saw_blocks_the_patch_id_proof() {
304 assert_eq!(
305 already_in(&v(&["a", "m"]), &[], &v(&["a"]), false),
306 AlreadyIn::No
307 );
308 assert_eq!(
309 already_in(&v(&["a", "m"]), &[], &v(&["a"]), true),
310 AlreadyIn::Yes(Proof::Tree)
311 );
312 }
313
314 #[test]
315 fn a_squash_is_proven_by_the_tree() {
316 assert_eq!(
317 already_in(&v(&["a", "b"]), &v(&["a", "b"]), &[], true),
318 AlreadyIn::Yes(Proof::Tree)
319 );
320 }
321
322 #[test]
323 fn nothing_added_is_never_already_in() {
324 assert_eq!(already_in(&[], &[], &[], true), AlreadyIn::No);
325 }
326
327 #[test]
328 fn evidence_names_the_twin_or_else_the_tip() {
329 let e = Evidence {
330 proof: Proof::PatchId,
331 tip: "1234567890".into(),
332 commits: v(&["abcdef0123"]),
333 };
334 assert_eq!(e.commit(), "abcdef0123");
335 assert_eq!(e.names(), "abcdef0");
336 let t = Evidence {
337 proof: Proof::Tree,
338 tip: "1234567890".into(),
339 commits: Vec::new(),
340 };
341 assert_eq!(t.commit(), "1234567890");
342 assert_eq!(t.names(), "1234567");
343 }
344
345 fn sh(dir: &Path, args: &[&str]) -> String {
346 let out = std::process::Command::new("git")
347 .args(args)
348 .current_dir(dir)
349 .quiet()
350 .output()
351 .unwrap();
352 assert!(
353 out.status.success(),
354 "git {args:?}: {}",
355 String::from_utf8_lossy(&out.stderr)
356 );
357 String::from_utf8_lossy(&out.stdout).trim().to_owned()
358 }
359
360 fn commit(dir: &Path, file: &str, body: &str, msg: &str) -> String {
361 std::fs::write(dir.join(file), body).unwrap();
362 sh(dir, &["add", "-A"]);
363 sh(dir, &["commit", "-q", "-m", msg]);
364 sh(dir, &["rev-parse", "HEAD"])
365 }
366
367 fn repo() -> (tempfile::TempDir, std::path::PathBuf) {
368 let dir = tempfile::tempdir().unwrap();
369 let repo = dir.path().to_path_buf();
370 sh(&repo, &["init", "-q", "-b", "main"]);
371 sh(&repo, &["config", "user.name", "t"]);
372 sh(&repo, &["config", "user.email", "t@example.com"]);
373 commit(&repo, "base.txt", "base\n", "base");
374 (dir, repo)
375 }
376
377 #[tokio::test]
378 async fn a_cherry_picked_reland_is_detected_and_names_the_twin() {
379 let (_d, repo) = repo();
380 sh(&repo, &["switch", "-q", "-c", "work"]);
381 let orig = commit(&repo, "feat.txt", "feature\n", "feature");
382 sh(&repo, &["switch", "-q", "main"]);
383 commit(&repo, "other.txt", "other\n", "unrelated");
384 sh(&repo, &["cherry-pick", &orig]);
385 let twin = sh(&repo, &["rev-parse", "HEAD"]);
386 assert_ne!(twin, orig);
387 let e = classify(&repo, "main", "work", None)
388 .await
389 .unwrap()
390 .expect("already in");
391 assert_eq!(e.proof, Proof::PatchId);
392 assert_eq!(e.commits, vec![twin]);
393 }
394
395 #[tokio::test]
396 async fn a_squashed_reland_is_detected_by_tree() {
397 let (_d, repo) = repo();
398 sh(&repo, &["switch", "-q", "-c", "work"]);
399 commit(&repo, "a.txt", "a\n", "one");
400 commit(&repo, "b.txt", "b\n", "two");
401 sh(&repo, &["switch", "-q", "main"]);
402 sh(&repo, &["merge", "--squash", "work"]);
403 sh(&repo, &["commit", "-q", "-m", "squashed"]);
404 commit(&repo, "later.txt", "later\n", "later");
405 let e = classify(&repo, "main", "work", None)
406 .await
407 .unwrap()
408 .expect("already in");
409 assert_eq!(e.proof, Proof::Tree);
410 assert!(e.commits.is_empty());
411 }
412
413 #[tokio::test]
414 async fn a_partly_present_branch_is_not_already_in() {
415 let (_d, repo) = repo();
416 sh(&repo, &["switch", "-q", "-c", "work"]);
417 let first = commit(&repo, "a.txt", "a\n", "one");
418 commit(&repo, "b.txt", "b\n", "two");
419 sh(&repo, &["switch", "-q", "main"]);
420 sh(&repo, &["cherry-pick", &first]);
421 assert!(
422 classify(&repo, "main", "work", None)
423 .await
424 .unwrap()
425 .is_none()
426 );
427 }
428
429 #[tokio::test]
430 async fn a_conflicting_branch_is_not_already_in() {
431 let (_d, repo) = repo();
432 sh(&repo, &["switch", "-q", "-c", "work"]);
433 commit(&repo, "base.txt", "mine\n", "mine");
434 sh(&repo, &["switch", "-q", "main"]);
435 commit(&repo, "base.txt", "theirs\n", "theirs");
436 assert!(
437 classify(&repo, "main", "work", None)
438 .await
439 .unwrap()
440 .is_none()
441 );
442 }
443
444 #[tokio::test]
445 async fn a_branch_already_in_the_base_history_is_detected_unless_it_is_the_start() {
446 let (_d, repo) = repo();
447 let start = sh(&repo, &["rev-parse", "HEAD"]);
448 sh(&repo, &["switch", "-q", "-c", "work"]);
449 let tip = commit(&repo, "a.txt", "a\n", "one");
450 sh(&repo, &["switch", "-q", "main"]);
451 sh(&repo, &["merge", "-q", "--ff-only", "work"]);
452 commit(&repo, "later.txt", "later\n", "later");
453 let e = classify(&repo, "main", "work", Some(&start))
454 .await
455 .unwrap()
456 .expect("already in");
457 assert_eq!(e.proof, Proof::Ancestry);
458 assert_eq!(e.commits, vec![tip]);
459 sh(&repo, &["branch", "idle", &start]);
461 assert!(
462 classify(&repo, "main", "idle", Some(&start))
463 .await
464 .unwrap()
465 .is_none()
466 );
467 }
468}