1use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use crate::git;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum SeedKind {
13 Unmerged,
15 AlreadyMerged,
17 Unresolved,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Seed {
24 pub token: String,
26 pub kind: SeedKind,
28 #[serde(default)]
30 pub sha: String,
31 #[serde(default)]
33 pub branch: bool,
34 #[serde(default)]
36 pub detail: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub struct Sources {
42 pub start_from: Option<Input>,
44 pub pick_commits: Vec<Input>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Input {
52 pub token: String,
54 pub sha: String,
56 pub provenance: Provenance,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Provenance {
65 ExplicitCli,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct Plan {
72 pub start: Option<String>,
74 pub picks: Vec<String>,
76}
77
78impl Sources {
79 pub fn is_empty(&self) -> bool {
83 self.start_from.is_none() && self.pick_commits.is_empty()
84 }
85
86 pub async fn resolve(
88 repo: &Path,
89 remote: &str,
90 start: Option<&str>,
91 picks: &[String],
92 ) -> anyhow::Result<Self> {
93 let start_from = match start {
94 Some(token) => Some(resolve_input(repo, remote, token, false).await?),
95 None => None,
96 };
97 let mut pick_commits = Vec::new();
98 for token in picks {
99 pick_commits.push(resolve_input(repo, remote, token, true).await?);
100 }
101 Ok(Self {
102 start_from,
103 pick_commits,
104 })
105 }
106
107 pub async fn plan(&self, repo: &Path, base: &str) -> anyhow::Result<Plan> {
110 let start = self.start_from.as_ref().map(|i| i.sha.clone());
111 let onto = start.as_deref().unwrap_or(base);
112 for input in self.start_from.iter().chain(&self.pick_commits) {
113 anyhow::ensure!(
114 input.sha.len() == 40 && input.sha.bytes().all(|b| b.is_ascii_hexdigit()),
115 "invalid pinned source SHA"
116 );
117 anyhow::ensure!(
118 git::commit_of(repo, &input.sha).await.as_deref() == Some(input.sha.as_str()),
119 "missing source commit {}",
120 input.sha
121 );
122 }
123 let mut picks = Vec::new();
124 for input in &self.pick_commits {
125 let parents =
126 git::git_raw(repo, &["rev-list", "--parents", "-n", "1", &input.sha]).await?;
127 anyhow::ensure!(
128 parents.ok() && parents.stdout.split_whitespace().count() <= 2,
129 "--pick-commit does not support merge commits"
130 );
131 anyhow::ensure!(
136 !picks.contains(&input.sha) && !git::ancestry(repo, &input.sha, onto).await?,
137 "duplicate or already contained --pick-commit {}",
138 input.token
139 );
140 picks.push(input.sha.clone());
141 }
142 Ok(Plan { start, picks })
143 }
144}
145
146async fn resolve_input(
147 repo: &Path,
148 remote: &str,
149 token: &str,
150 commit_only: bool,
151) -> anyhow::Result<Input> {
152 let hex = (7..=40).contains(&token.len()) && token.bytes().all(|b| b.is_ascii_hexdigit());
153 anyhow::ensure!(!commit_only || hex, "--pick-commit requires a commit SHA");
154 let sha = if hex {
155 let refs = git::git_raw(
158 repo,
159 &[
160 "for-each-ref",
161 "--format=%(refname)",
162 &format!("refs/heads/{token}"),
163 &format!("refs/tags/{token}"),
164 ],
165 )
166 .await?;
167 anyhow::ensure!(
168 refs.ok() && refs.stdout.is_empty(),
169 "ambiguous source {token}"
170 );
171 git::commit_of(repo, token).await
172 } else {
173 let valid =
174 git::git_raw(repo, &["check-ref-format", &format!("refs/heads/{token}")]).await?;
175 anyhow::ensure!(
176 valid.ok() && !token.starts_with('-'),
177 "invalid source branch {token}"
178 );
179 let local = git::commit_of(repo, &format!("refs/heads/{token}")).await;
180 let tracked = git::commit_of(repo, &format!("refs/remotes/{remote}/{token}")).await;
181 match (local, tracked) {
182 (Some(l), Some(t)) if l != t => {
186 if git::ancestry(repo, &l, &t).await? {
187 Some(t)
188 } else if git::ancestry(repo, &t, &l).await? {
189 Some(l)
190 } else {
191 anyhow::bail!(
192 "local and remote source branches have diverged: {token}; specify a full SHA"
193 )
194 }
195 }
196 (local, tracked) => local.or(tracked),
197 }
198 }
199 .ok_or_else(|| anyhow::anyhow!("unknown or ambiguous source {token}"))?;
200 Ok(Input {
201 token: token.to_owned(),
202 sha,
203 provenance: Provenance::ExplicitCli,
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::proc::Quiet as _;
211
212 fn sh(dir: &Path, args: &[&str]) {
213 let out = std::process::Command::new("git")
214 .quiet()
215 .args(args)
216 .current_dir(dir)
217 .output()
218 .expect("spawn git");
219 assert!(
220 out.status.success(),
221 "git {args:?}: {}",
222 String::from_utf8_lossy(&out.stderr)
223 );
224 }
225
226 fn commit_file(dir: &Path, name: &str, body: &str) -> String {
227 std::fs::write(dir.join(name), body).unwrap();
228 sh(dir, &["add", "-A"]);
229 sh(
230 dir,
231 &[
232 "-c",
233 "user.name=t",
234 "-c",
235 "user.email=t@localhost",
236 "commit",
237 "-q",
238 "-m",
239 name,
240 ],
241 );
242 let out = std::process::Command::new("git")
243 .quiet()
244 .args(["rev-parse", "HEAD"])
245 .current_dir(dir)
246 .output()
247 .unwrap();
248 String::from_utf8_lossy(&out.stdout).trim().to_owned()
249 }
250
251 fn repo_with_side_branch() -> (
252 tempfile::TempDir,
253 std::path::PathBuf,
254 String,
255 String,
256 String,
257 ) {
258 let tmp = tempfile::tempdir().unwrap();
259 let repo = tmp.path().join("repo");
260 std::fs::create_dir_all(&repo).unwrap();
261 sh(&repo, &["init", "-q", "-b", "main"]);
262 let merged = commit_file(&repo, "a.txt", "a\n");
263 sh(&repo, &["checkout", "-q", "-b", "magi/27b2/A"]);
264 let fix = commit_file(&repo, "fix.txt", "fix\n");
265 sh(&repo, &["checkout", "-q", "main"]);
266 let base = merged.clone();
267 (tmp, repo, base, fix, merged)
268 }
269
270 #[tokio::test]
271 async fn explicit_picks_apply_single_commits_in_supplied_order() {
272 let (_g, repo, base, first, _) = repo_with_side_branch();
273 sh(&repo, &["checkout", "-q", "magi/27b2/A"]);
274 let second = commit_file(&repo, "second.txt", "second");
275 sh(&repo, &["checkout", "-q", "main"]);
276 let single = Sources::resolve(&repo, "origin", None, std::slice::from_ref(&second))
278 .await
279 .unwrap();
280 let plan = single.plan(&repo, &base).await.unwrap();
281 git::cherry_pick(&repo, &plan.picks[0]).await.unwrap();
282 assert!(repo.join("second.txt").exists());
283 assert!(!repo.join("fix.txt").exists());
284 sh(&repo, &["reset", "--hard", &base]);
285 let supplied = vec![second.clone(), first.clone()];
286 let picks = Sources::resolve(&repo, "origin", None, &supplied)
287 .await
288 .unwrap();
289 let plan = picks.plan(&repo, &base).await.unwrap();
290 assert_eq!(plan.picks, supplied);
291 for pick in plan.picks {
292 git::cherry_pick(&repo, &pick).await.unwrap();
293 }
294 assert!(repo.join("second.txt").exists() && repo.join("fix.txt").exists());
295 let duplicate = Sources::resolve(&repo, "origin", None, &[first.clone(), first])
296 .await
297 .unwrap();
298 assert!(duplicate.plan(&repo, &base).await.is_err());
299 }
300
301 #[tokio::test]
302 async fn branch_tips_resolve_along_one_line_and_divergence_and_merge_picks_are_rejected() {
303 let (_g, repo, base, fix, _) = repo_with_side_branch();
304 let start = |repo: std::path::PathBuf| async move {
305 Sources::resolve(&repo, "origin", Some("magi/27b2/A"), &[])
306 .await
307 .map(|s| s.start_from.unwrap().sha)
308 };
309 sh(
311 &repo,
312 &["update-ref", "refs/remotes/origin/magi/27b2/A", &base],
313 );
314 assert_eq!(start(repo.clone()).await.unwrap(), fix);
315 sh(
317 &repo,
318 &["update-ref", "refs/remotes/origin/magi/27b2/A", &fix],
319 );
320 sh(&repo, &["branch", "-f", "magi/27b2/A", &base]);
321 assert_eq!(start(repo.clone()).await.unwrap(), fix);
322 let diverged = commit_file(&repo, "main-only.txt", "main");
324 sh(&repo, &["branch", "-f", "magi/27b2/A", &diverged]);
325 let err = start(repo.clone()).await.unwrap_err().to_string();
326 assert!(err.contains("diverged"), "{err}");
327 assert!(
328 Sources::resolve(&repo, "origin", None, &["magi/27b2/A".into()])
329 .await
330 .is_err()
331 );
332 sh(&repo, &["checkout", "-q", "-b", "other"]);
333 commit_file(&repo, "other.txt", "other");
334 sh(
335 &repo,
336 &[
337 "-c",
338 "user.name=t",
339 "-c",
340 "user.email=t@localhost",
341 "merge",
342 "--no-ff",
343 "-m",
344 "merge",
345 &fix,
346 ],
347 );
348 let merge = git::rev_parse(&repo, "HEAD").await.unwrap();
349 let picks = Sources::resolve(&repo, "origin", None, &[merge])
350 .await
351 .unwrap();
352 assert!(picks.plan(&repo, &base).await.is_err());
353 }
354
355 #[tokio::test]
356 async fn a_base_only_commit_can_be_picked_onto_an_explicit_start() {
357 let (_g, repo, _, fix, _) = repo_with_side_branch();
358 let b = commit_file(&repo, "b.txt", "b\n");
360 let base = b.clone();
361 let onto_start = Sources::resolve(
362 &repo,
363 "origin",
364 Some("magi/27b2/A"),
365 std::slice::from_ref(&b),
366 )
367 .await
368 .unwrap();
369 let plan = onto_start.plan(&repo, &base).await.unwrap();
370 assert_eq!(plan.start.as_deref(), Some(fix.as_str()));
371 assert_eq!(plan.picks, vec![b.clone()]);
372 let onto_base = Sources::resolve(&repo, "origin", None, std::slice::from_ref(&b))
375 .await
376 .unwrap();
377 assert!(onto_base.plan(&repo, &base).await.is_err());
378 let contained = Sources::resolve(
380 &repo,
381 "origin",
382 Some("magi/27b2/A"),
383 std::slice::from_ref(&fix),
384 )
385 .await
386 .unwrap();
387 assert!(contained.plan(&repo, &base).await.is_err());
388 }
389
390 #[tokio::test]
391 async fn explicit_sources_are_pinned_and_legacy_text_is_inert() {
392 let (_g, repo, base, fix, _) = repo_with_side_branch();
393 let sources = Sources::resolve(&repo, "origin", Some("magi/27b2/A"), &[])
394 .await
395 .unwrap();
396 sh(&repo, &["branch", "-f", "magi/27b2/A", &base]);
397 assert_eq!(
398 sources.plan(&repo, &base).await.unwrap().start,
399 Some(fix.clone())
400 );
401 let picks = Sources::resolve(&repo, "origin", None, std::slice::from_ref(&fix))
402 .await
403 .unwrap();
404 let saved = serde_json::to_string(&picks).unwrap();
405 let restored: Sources = serde_json::from_str(&saved).unwrap();
406 assert_eq!(
407 restored.plan(&repo, &base).await.unwrap().picks,
408 vec![fix.clone()]
409 );
410 assert!(
411 Sources::resolve(&repo, "origin", Some("missing"), &[])
412 .await
413 .is_err()
414 );
415 let duplicate = Sources::resolve(&repo, "origin", Some(&fix), std::slice::from_ref(&fix))
416 .await
417 .unwrap();
418 assert!(duplicate.plan(&repo, &base).await.is_err());
419 assert_eq!(
420 Sources::default().plan(&repo, &base).await.unwrap(),
421 Plan::default()
422 );
423 }
424}