1use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use crate::git;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SeedKind {
24 Unmerged,
26 AlreadyMerged,
28 Unresolved,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Seed {
35 pub token: String,
37 pub kind: SeedKind,
39 #[serde(default)]
41 pub sha: String,
42 #[serde(default)]
44 pub branch: bool,
45 #[serde(default)]
47 pub detail: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Token {
53 pub text: String,
55 pub branch: bool,
57}
58
59fn is_word(c: char) -> bool {
60 c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')
61}
62
63pub fn scan(text: &str) -> Vec<Token> {
68 let mut out: Vec<Token> = Vec::new();
69 let mut push = |t: Token| {
70 if !out.iter().any(|o| o.text == t.text) {
71 out.push(t);
72 }
73 };
74 let mut word = String::new();
75 let flush = |word: &mut String, hash: bool, push: &mut dyn FnMut(Token)| {
76 let w = word.trim_matches(|c| matches!(c, '.' | '-' | '/' | '_'));
77 if let Some(rest) = w.strip_prefix("magi/") {
78 let parts: Vec<&str> = rest.split('/').collect();
79 if parts.len() == 2 && parts.iter().all(|p| !p.is_empty()) {
80 push(Token {
81 text: w.to_owned(),
82 branch: true,
83 });
84 }
85 } else if !hash
86 && (7..=40).contains(&w.len())
87 && w.chars().all(|c| c.is_ascii_hexdigit())
88 && w.chars().any(|c| c.is_ascii_digit())
89 {
90 push(Token {
91 text: w.to_ascii_lowercase(),
92 branch: false,
93 });
94 }
95 word.clear();
96 };
97 let mut hash = false;
99 let mut prev = ' ';
100 for c in text.chars() {
101 if is_word(c) {
102 if word.is_empty() {
103 hash = prev == '#';
104 }
105 word.push(c);
106 } else {
107 flush(&mut word, hash, &mut push);
108 }
109 prev = c;
110 }
111 flush(&mut word, hash, &mut push);
112 out.sort_by_key(|t| !t.branch);
115 out
116}
117
118pub async fn resolve(repo: &Path, base_commit: &str, remote: &str, text: &str) -> Vec<Seed> {
125 let mut seeds: Vec<Seed> = Vec::new();
126 for token in scan(text) {
127 let found = match commit_for(repo, remote, &token).await {
128 Some(sha) => sha,
129 None => {
130 if token.branch || token.text.chars().any(|c| c.is_ascii_alphabetic()) {
131 seeds.push(Seed {
132 token: token.text.clone(),
133 kind: SeedKind::Unresolved,
134 sha: String::new(),
135 branch: token.branch,
136 detail: format!("no branch or commit named {}", token.text),
137 });
138 }
139 continue;
140 }
141 };
142 if seeds.iter().any(|s| s.sha == found) {
144 continue;
145 }
146 let seed = match git::ancestry(repo, &found, base_commit).await {
147 Ok(true) => {
148 let holders = git::branches_containing(repo, &found)
149 .await
150 .map(|b| b.join(", "))
151 .unwrap_or_default();
152 Seed {
153 token: token.text,
154 kind: SeedKind::AlreadyMerged,
155 sha: found,
156 branch: token.branch,
157 detail: holders,
158 }
159 }
160 Ok(false) => {
161 let holders = git::branches_containing(repo, &found)
162 .await
163 .map(|b| b.join(", "))
164 .unwrap_or_default();
165 Seed {
166 token: token.text,
167 kind: SeedKind::Unmerged,
168 sha: found,
169 branch: token.branch,
170 detail: holders,
171 }
172 }
173 Err(e) => Seed {
174 token: token.text,
175 kind: SeedKind::Unresolved,
176 sha: found,
177 branch: token.branch,
178 detail: format!("could not tell whether the base contains it: {e}"),
179 },
180 };
181 seeds.push(seed);
182 }
183 seeds
184}
185
186async fn commit_for(repo: &Path, remote: &str, token: &Token) -> Option<String> {
187 let fetched = token.branch
192 && git::fetch(repo, remote, &token.text)
193 .await
194 .is_ok_and(|o| o.ok());
195 let local = match git::commit_of(repo, &token.text).await {
196 Some(sha)
198 if !token.branch || git::branch_exists(repo, &token.text).await.unwrap_or(false) =>
199 {
200 Some(sha)
201 }
202 _ => None,
203 };
204 if !token.branch {
205 return local;
206 }
207 let tracked = if fetched {
208 git::commit_of(repo, &format!("refs/remotes/{remote}/{}", token.text)).await
209 } else {
210 None
211 };
212 match (local, tracked) {
213 (Some(l), Some(t)) if l != t && git::is_ancestor(repo, &l, &t).await => Some(t),
216 (Some(l), _) => Some(l),
217 (None, t) => t,
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Default)]
223pub struct Plan {
224 pub start: Option<String>,
226 pub picks: Vec<String>,
228}
229
230pub async fn plan(repo: &Path, seeds: &[Seed]) -> anyhow::Result<Plan> {
241 let mut start: Option<&Seed> = None;
242 for s in seeds
243 .iter()
244 .filter(|s| s.kind == SeedKind::Unmerged && s.branch)
245 {
246 match start {
247 None => start = Some(s),
248 Some(cur) => {
249 if git::ancestry(repo, &cur.sha, &s.sha).await? {
250 start = Some(s);
251 } else if !git::ancestry(repo, &s.sha, &cur.sha).await? {
252 anyhow::bail!(
253 "the task names {} and {}, which have diverged; name one, \
254 or the commits to take, so magi does not have to guess",
255 cur.token,
256 s.token
257 );
258 }
259 }
260 }
261 }
262 let mut picks = Vec::new();
263 for s in seeds
264 .iter()
265 .filter(|s| s.kind == SeedKind::Unmerged && !s.branch)
266 {
267 if let Some(st) = start
268 && git::ancestry(repo, &s.sha, &st.sha).await?
269 {
270 continue;
271 }
272 if picks.contains(&s.sha) {
273 continue;
274 }
275 let mut at = picks.len();
278 for (i, p) in picks.iter().enumerate() {
279 if git::ancestry(repo, &s.sha, p).await? {
280 at = i;
281 break;
282 }
283 }
284 picks.insert(at, s.sha.clone());
285 }
286 Ok(Plan {
287 start: start.map(|s| s.sha.clone()),
288 picks,
289 })
290}
291
292fn short(sha: &str) -> &str {
293 &sha[..sha.len().min(8)]
294}
295
296pub fn describe(seeds: &[Seed]) -> Option<String> {
298 if seeds.is_empty() {
299 return None;
300 }
301 let lines: Vec<String> = seeds
302 .iter()
303 .map(|s| match s.kind {
304 SeedKind::Unmerged => format!(
305 "- `{}` ({}) is NOT on the base branch{}",
306 s.token,
307 short(&s.sha),
308 if s.detail.is_empty() {
309 String::new()
310 } else {
311 format!("; contained in: {}", s.detail)
312 }
313 ),
314 SeedKind::AlreadyMerged => format!(
315 "- `{}` ({}) is already contained in the base branch{}",
316 s.token,
317 short(&s.sha),
318 if s.detail.is_empty() {
319 String::new()
320 } else {
321 format!("; contained in: {}", s.detail)
322 }
323 ),
324 SeedKind::Unresolved => format!("- `{}` could not be resolved: {}", s.token, s.detail),
325 })
326 .collect();
327 Some(lines.join("\n"))
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::proc::Quiet as _;
334
335 #[test]
336 fn scan_finds_branches_and_shas() {
337 let t = scan(
338 "Land the fix dc2e888 from `magi/27b2/A`, see also magi/27b2/A. \
339 Not facade, not defaced, not magi/x.",
340 );
341 let texts: Vec<&str> = t.iter().map(|t| t.text.as_str()).collect();
342 assert_eq!(texts, vec!["magi/27b2/A", "dc2e888"]);
343 assert!(t[0].branch && !t[1].branch);
344 }
345
346 #[test]
347 fn scan_ignores_hex_colours() {
348 assert!(scan("Change the color to #deadbeef and #0a1b2c3d").is_empty());
349 }
350
351 #[test]
352 fn scan_ignores_short_or_long_hex() {
353 assert!(scan("abc123 and 0123456789abcdef0123456789abcdef012345678").is_empty());
354 }
355
356 fn sh(dir: &Path, args: &[&str]) {
357 let out = std::process::Command::new("git")
358 .quiet()
359 .args(args)
360 .current_dir(dir)
361 .output()
362 .expect("spawn git");
363 assert!(
364 out.status.success(),
365 "git {args:?}: {}",
366 String::from_utf8_lossy(&out.stderr)
367 );
368 }
369
370 fn commit_file(dir: &Path, name: &str, body: &str) -> String {
371 std::fs::write(dir.join(name), body).unwrap();
372 sh(dir, &["add", "-A"]);
373 sh(
374 dir,
375 &[
376 "-c",
377 "user.name=t",
378 "-c",
379 "user.email=t@localhost",
380 "commit",
381 "-q",
382 "-m",
383 name,
384 ],
385 );
386 let out = std::process::Command::new("git")
387 .quiet()
388 .args(["rev-parse", "HEAD"])
389 .current_dir(dir)
390 .output()
391 .unwrap();
392 String::from_utf8_lossy(&out.stdout).trim().to_owned()
393 }
394
395 fn repo_with_side_branch() -> (
396 tempfile::TempDir,
397 std::path::PathBuf,
398 String,
399 String,
400 String,
401 ) {
402 let tmp = tempfile::tempdir().unwrap();
403 let repo = tmp.path().join("repo");
404 std::fs::create_dir_all(&repo).unwrap();
405 sh(&repo, &["init", "-q", "-b", "main"]);
406 let merged = commit_file(&repo, "a.txt", "a\n");
407 sh(&repo, &["checkout", "-q", "-b", "magi/27b2/A"]);
408 let fix = commit_file(&repo, "fix.txt", "fix\n");
409 sh(&repo, &["checkout", "-q", "main"]);
410 let base = merged.clone();
411 (tmp, repo, base, fix, merged)
412 }
413
414 #[tokio::test]
415 async fn an_unmerged_branch_is_the_start_and_a_merged_sha_is_reported_as_such() {
416 let (_g, repo, base, fix, merged) = repo_with_side_branch();
417 let text = format!(
418 "Land the fix {} from magi/27b2/A; {} is old",
419 &fix[..8],
420 &merged[..8]
421 );
422 let seeds = resolve(&repo, &base, "origin", &text).await;
423
424 assert_eq!(seeds.len(), 2, "{seeds:?}");
426 let branch = &seeds[0];
427 assert_eq!(branch.token, "magi/27b2/A");
428 assert_eq!(branch.kind, SeedKind::Unmerged);
429 assert_eq!(branch.sha, fix);
430 assert!(branch.detail.contains("magi/27b2/A"), "{}", branch.detail);
431 assert_eq!(seeds[1].kind, SeedKind::AlreadyMerged);
432 assert_eq!(seeds[1].sha, merged);
433
434 let plan = plan(&repo, &seeds).await.unwrap();
435 assert_eq!(plan.start.as_deref(), Some(fix.as_str()));
436 assert!(plan.picks.is_empty());
437 let facts = describe(&seeds).unwrap();
438 assert!(facts.contains("NOT on the base branch"), "{facts}");
439 assert!(facts.contains("already contained"), "{facts}");
440 }
441
442 #[tokio::test]
443 async fn a_bare_unmerged_sha_is_cherry_picked_and_an_unknown_branch_is_reported() {
444 let (_g, repo, base, fix, _) = repo_with_side_branch();
445 let seeds = resolve(
446 &repo,
447 &base,
448 "origin",
449 &format!("take {} and magi/zzzz/A", &fix[..10]),
450 )
451 .await;
452 assert_eq!(seeds.len(), 2, "{seeds:?}");
453 assert_eq!(seeds[0].kind, SeedKind::Unresolved);
454 assert_eq!(seeds[1].kind, SeedKind::Unmerged);
455 let ghost = resolve(&repo, &base, "origin", "cherry-pick abc1234f").await;
456 assert_eq!(ghost.len(), 1, "{ghost:?}");
457 assert_eq!(ghost[0].kind, SeedKind::Unresolved);
458 assert!(
459 resolve(&repo, &base, "origin", "on 20260930")
460 .await
461 .is_empty()
462 );
463 assert!(!seeds[1].branch);
464
465 let plan = plan(&repo, &seeds).await.unwrap();
466 assert_eq!(plan.start, None);
467 assert_eq!(plan.picks, vec![fix.clone()]);
468
469 sh(&repo, &["checkout", "-q", "-b", "cand"]);
470 git::cherry_pick(&repo, &fix).await.unwrap();
471 assert!(repo.join("fix.txt").is_file());
472 }
473
474 #[tokio::test]
475 async fn a_task_that_names_nothing_resolves_to_nothing() {
476 let (_g, repo, base, _, _) = repo_with_side_branch();
477 assert!(
478 resolve(&repo, &base, "origin", "just fix the bug")
479 .await
480 .is_empty()
481 );
482 }
483}