1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use crate::{git, run::RunState};
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct ForeignCheckout {
13 pub branch: String,
15 pub path: PathBuf,
17 pub git_dir: PathBuf,
19 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
32pub(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 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
124pub(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}