Skip to main content

magi/
foreign_checkout.rs

1//! Guarded fast-forward delivery to a hand-written branch's worktree.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use crate::{git, run::RunState};
9
10/// A foreign checkout is evidence, never a worktree owned by the run.
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct ForeignCheckout {
13    /// Original branch, distinct from the isolated review branch.
14    pub branch: String,
15    /// Canonical checkout path.
16    pub path: PathBuf,
17    /// Canonical Git administration directory, to detect path replacement.
18    pub git_dir: PathBuf,
19    /// Last head delivered or observed when the review began.
20    pub synced_head: String,
21}
22
23pub(crate) fn quote(s: &str) -> String {
24    format!("'{}'", s.replace('\'', "'\\''"))
25}
26
27async fn administration(path: &Path) -> Result<PathBuf> {
28    let dir = git::git(path, &["rev-parse", "--absolute-git-dir"]).await?;
29    Ok(std::fs::canonicalize(dir.trim())?)
30}
31
32/// Inspect without modifying the checkout, including untracked files and Git operations.
33pub(crate) async fn inspect(repo: &Path, branch: &str, path: &Path) -> Result<ForeignCheckout> {
34    let checkout = ForeignCheckout {
35        branch: branch.to_owned(),
36        path: std::fs::canonicalize(path)?,
37        git_dir: administration(path).await?,
38        synced_head: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
39    };
40    checkout.validate(repo).await?;
41    Ok(checkout)
42}
43
44impl ForeignCheckout {
45    async fn validate(&self, repo: &Path) -> Result<()> {
46        ensure!(
47            administration(&self.path).await? == self.git_dir,
48            "checkout was replaced"
49        );
50        ensure!(
51            git::git(
52                &self.path,
53                &[
54                    "--no-optional-locks",
55                    "status",
56                    "--porcelain",
57                    "--untracked-files=all",
58                    "--ignore-submodules=none"
59                ]
60            )
61            .await?
62            .is_empty(),
63            "checkout is dirty"
64        );
65        ensure!(
66            git::git(&self.path, &["symbolic-ref", "HEAD"]).await?
67                == format!("refs/heads/{}", self.branch),
68            "checkout switched branches"
69        );
70        ensure!(
71            git::rev_parse(&self.path, "HEAD").await? == self.synced_head
72                && git::rev_parse(repo, &format!("refs/heads/{}", self.branch)).await?
73                    == self.synced_head,
74            "branch moved since the last synchronization"
75        );
76        for name in [
77            "locked",
78            "index.lock",
79            "HEAD.lock",
80            "rebase-merge",
81            "rebase-apply",
82            "sequencer",
83            "MERGE_HEAD",
84            "CHERRY_PICK_HEAD",
85            "REVERT_HEAD",
86            "BISECT_LOG",
87        ] {
88            ensure!(
89                !self.git_dir.join(name).exists(),
90                "checkout has a lock or Git operation in progress ({name})"
91            );
92        }
93        Ok(())
94    }
95
96    /// Fast-forward only after revalidating the original checkout and branch.
97    pub(crate) async fn deliver(&mut self, repo: &Path, head: &str) -> Result<()> {
98        self.validate(repo).await?;
99        ensure!(
100            git::is_ancestor(repo, &self.synced_head, head).await,
101            "review rewrote history; automatic delivery requires a fast-forward"
102        );
103        if head != self.synced_head {
104            git::git(
105                &self.path,
106                &[
107                    "-c",
108                    "core.hooksPath=/dev/null",
109                    "merge",
110                    "--ff-only",
111                    "--no-autostash",
112                    "--no-edit",
113                    head,
114                ],
115            )
116            .await?;
117            self.synced_head = head.to_owned();
118            self.validate(repo).await?;
119        }
120        Ok(())
121    }
122}
123
124/// Deliver the isolated head before completion or publication; failures retain both trees.
125/// Acquires the configured repository coordination lease before inspecting or delivering.
126pub(crate) async fn write_back(state: &mut RunState, head: &str) -> Result<bool> {
127    let Some(mut checkout) = state.foreign_checkout.clone() else {
128        return Ok(true);
129    };
130    let inherited = crate::repo_lock::WaitPolicy::current();
131    let wait = crate::repo_lock::WaitPolicy::new(
132        std::time::Duration::from_secs(state.config.graph.timeout_resource_wait),
133        move || inherited.cancelled(),
134    );
135    let _coordination = crate::repo_lock::SeatLease::operation_with(
136        &state.repo,
137        state.config.coordination.as_ref(),
138        &wait,
139    )
140    .await?;
141    if let Err(error) = checkout.deliver(&state.repo, head).await {
142        let shadow = state.winner().map(|w| w.branch.as_str()).unwrap_or(head);
143        let message = format!(
144            "foreign checkout delivery refused: {error:#}. Review fixes remain on `{shadow}`. After resolving the checkout, run: git -C {} merge --ff-only {}",
145            quote(&checkout.path.to_string_lossy()),
146            quote(shadow)
147        );
148        state.status = crate::run::RunStatus::Blocked;
149        state.event("foreign-checkout", &message);
150        state.save()?;
151        crate::notices::raise(
152            crate::notices::Notice::error(&format!("foreign-checkout:{}", state.id), message)
153                .link(crate::notices::Link::Run {
154                    id: state.id.clone(),
155                })
156                .about([state.id.clone()]),
157        );
158        return Ok(false);
159    }
160    state.foreign_checkout = Some(checkout);
161    state
162        .save()
163        .context("save foreign checkout synchronization")?;
164    Ok(true)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    async fn fixture() -> (tempfile::TempDir, PathBuf, ForeignCheckout) {
172        let dir = tempfile::tempdir().unwrap();
173        let repo = dir.path().join("repo");
174        std::fs::create_dir_all(&repo).unwrap();
175        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
176        git::git(&repo, &["config", "user.name", "Test"])
177            .await
178            .unwrap();
179        git::git(&repo, &["config", "user.email", "test@example.invalid"])
180            .await
181            .unwrap();
182        git::git(&repo, &["commit", "--allow-empty", "-m", "base"])
183            .await
184            .unwrap();
185        let path = dir.path().join("foreign");
186        git::worktree_add_branch(&repo, &path, "topic", "main")
187            .await
188            .unwrap();
189        let checkout = inspect(&repo, "topic", &path).await.unwrap();
190        (dir, repo, checkout)
191    }
192
193    #[tokio::test]
194    async fn locked_checkout_is_refused_without_removing_its_lock() {
195        let (_dir, repo, mut checkout) = fixture().await;
196        let lock = checkout.git_dir.join("locked");
197        std::fs::write(&lock, "operator lock").unwrap();
198        let head = checkout.synced_head.clone();
199        assert!(
200            checkout
201                .deliver(&repo, &head)
202                .await
203                .unwrap_err()
204                .to_string()
205                .contains("lock")
206        );
207        assert_eq!(std::fs::read_to_string(lock).unwrap(), "operator lock");
208        assert_eq!(git::rev_parse(&checkout.path, "HEAD").await.unwrap(), head);
209    }
210
211    #[tokio::test]
212    async fn rewritten_review_is_refused_without_moving_the_foreign_branch() {
213        let (_dir, repo, mut checkout) = fixture().await;
214        git::git(
215            &checkout.path,
216            &["commit", "--allow-empty", "-m", "original work"],
217        )
218        .await
219        .unwrap();
220        checkout.synced_head = git::rev_parse(&checkout.path, "HEAD").await.unwrap();
221        let rewritten = git::rev_parse(&repo, "main").await.unwrap();
222        assert!(
223            checkout
224                .deliver(&repo, &rewritten)
225                .await
226                .unwrap_err()
227                .to_string()
228                .contains("rewrote history")
229        );
230        assert_eq!(
231            git::rev_parse(&checkout.path, "HEAD").await.unwrap(),
232            checkout.synced_head
233        );
234        assert!(git::is_clean(&checkout.path).await.unwrap());
235    }
236}