Skip to main content

magi/
refs.rs

1//! Structured implementation source selection. Instruction text is never input.
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use crate::git;
8
9/// What a reference turned out to be, relative to the base commit.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum SeedKind {
13    /// A commit that exists but is not reachable from the base.
14    Unmerged,
15    /// A commit the base already contains.
16    AlreadyMerged,
17    /// Could not be resolved or judged.
18    Unresolved,
19}
20
21/// One reference found in a task, and what the repository says about it.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Seed {
24    /// The text as written in the task.
25    pub token: String,
26    /// What the repository says about it.
27    pub kind: SeedKind,
28    /// Full commit id; empty for [`SeedKind::Unresolved`].
29    #[serde(default)]
30    pub sha: String,
31    /// Was the token a branch name (as opposed to a bare sha)?
32    #[serde(default)]
33    pub branch: bool,
34    /// Branches that contain the commit (unmerged), or why it is unresolved.
35    #[serde(default)]
36    pub detail: String,
37}
38
39/// Explicit CLI input, resolved once before a task is filed. Never built from prose.
40#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub struct Sources {
42    /// Starting commit, including its ancestry, selected by `--start-from`.
43    pub start_from: Option<Input>,
44    /// Individual commits selected by `--pick-commit`, in application order.
45    pub pick_commits: Vec<Input>,
46}
47
48/// The original argument and immutable commit identity. This type is only used
49/// by structured source selection; old `RunState::seeds` remain legacy inference.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Input {
52    /// Branch name or commit abbreviation originally supplied by the caller.
53    pub token: String,
54    /// Full immutable commit identity resolved when the task was filed.
55    pub sha: String,
56    /// Structured authorization for importing this commit.
57    pub provenance: Provenance,
58}
59
60/// Authorization for new source inputs; legacy `Seed` records have no such
61/// provenance and cannot be converted into this type automatically.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Provenance {
65    /// Selected through an explicit source argument, independent of task text.
66    ExplicitCli,
67}
68
69/// Validated immutable inputs used to prepare candidate worktrees.
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct Plan {
72    /// Starting commit instead of the configured base, when selected.
73    pub start: Option<String>,
74    /// Single commits to apply in the caller's specified order.
75    pub picks: Vec<String>,
76}
77
78impl Sources {
79    /// No explicit input. Records then omit the field entirely, so a task or
80    /// run without sources stays byte-compatible with builds that predate it
81    /// (`Task` and `RunState` reject unknown fields).
82    pub fn is_empty(&self) -> bool {
83        self.start_from.is_none() && self.pick_commits.is_empty()
84    }
85
86    /// Resolve explicit CLI arguments to immutable commits without parsing prose.
87    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    /// Recheck immutable objects before creating any worktrees. Never resolve
108    /// the saved token again: a branch may have moved since registration.
109    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            // Contained in the history the candidates actually start from
132            // (`onto`: `--start-from` when given, else the base). A base-only
133            // commit picked onto an explicit start that lacks it is a real
134            // import, not a duplicate.
135            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        // rev-parse refuses ambiguous object abbreviations. Also refuse a
156        // branch/tag of the same name rather than relying on Git precedence.
157        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            // A fetch that leaves the local branch behind (or an unpushed
183            // local commit ahead) is one line of history: take its tip. Only
184            // true divergence is ambiguous.
185            (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        // Picking only the second commit must not bring the first one's file.
277        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        // The remote is behind the local branch: the local tip is the newer one.
310        sh(
311            &repo,
312            &["update-ref", "refs/remotes/origin/magi/27b2/A", &base],
313        );
314        assert_eq!(start(repo.clone()).await.unwrap(), fix);
315        // A fetch left the local branch behind: the remote tip is the newer one.
316        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        // Neither contains the other: ambiguous, so a full SHA is required.
323        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        // main moves on after the side branch forked: B is in the base, not in F.
359        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        // Without an explicit start the candidates begin at the base, which
373        // already contains B: picking it again is a duplicate.
374        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        // A start that already contains the picked commit rejects it too.
379        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}