Skip to main content

orch/engine/runner/
cow.rs

1//! Copy-on-write task worktrees cloned from a per-project template by the git-cow crate, see docs/content/cow-worktrees.md
2
3use super::worktree::resolve_branch_start_point;
4use crate::home;
5use std::env;
6use std::path::{Path, PathBuf};
7
8use tokio::task;
9
10fn template_dir(project: &str) -> anyhow::Result<PathBuf> {
11    Ok(home::state_dir()?.join("templates").join(project))
12}
13
14// Called after a task PR merges and before its worktree is removed.
15// Projects with a working checkout are warmed from that checkout instead
16pub async fn warm_from_merged(repo_root: &Path, project: &str, wt_dir: &Path) {
17    let (repo, wt) = (repo_root.to_path_buf(), wt_dir.to_path_buf());
18    let Ok(template) = template_dir(project) else {
19        return;
20    };
21    let res = task::spawn_blocking(move || {
22        let bare =
23            git_cow::git(&repo, &["rev-parse", "--is-bare-repository"]).is_ok_and(|v| v == "true");
24        let wt_parent = wt.parent().unwrap_or(&wt);
25        let templates = template.parent().unwrap_or(&template);
26        if !bare || !template.join(".git").exists() || !git_cow::can_clone(templates, wt_parent) {
27            return Ok(());
28        }
29        git_cow::warm(&wt, &template)
30    })
31    .await;
32    if let Ok(Err(e)) = res {
33        tracing::debug!(error = %e, "template warm-up from merged task failed");
34    }
35}
36
37// Create `wt_dir` as a worktree of `branch` by cloning the project template.
38// On error nothing is left behind and the caller falls back to `git worktree add`
39pub async fn create_worktree(
40    main_dir: &Path,
41    project: &str,
42    wt_dir: &Path,
43    branch: &str,
44    default_branch: &str,
45) -> anyhow::Result<()> {
46    if env::var("ORCH_COW_WORKTREES").is_ok_and(|v| v == "0") {
47        anyhow::bail!("disabled by ORCH_COW_WORKTREES=0");
48    }
49    let template = template_dir(project)?;
50    let start =
51        resolve_branch_start_point(&main_dir.to_string_lossy(), default_branch, default_branch)
52            .await;
53    let (repo, wt, branch) = (
54        main_dir.to_path_buf(),
55        wt_dir.to_path_buf(),
56        branch.to_string(),
57    );
58    task::spawn_blocking(move || git_cow::add(&repo, &template, &start, &wt, None, Some(&branch)))
59        .await??;
60    Ok(())
61}
62
63#[cfg(test)]
64mod tests {
65    use std::fs;
66    use std::process::Command;
67
68    use super::*;
69
70    fn sh(dir: &Path, args: &[&str]) {
71        let mut cmd = Command::new("git");
72        cmd.current_dir(dir);
73        // Global commit.gpgsign calls the operator's signer (1Password)
74        if args.first() == Some(&"commit") {
75            cmd.args(["-c", "commit.gpgsign=false"]);
76        }
77        let st = cmd.args(args).status().unwrap();
78        assert!(st.success(), "git {args:?}");
79    }
80
81    // CI jobs on a clone-capable volume set this so a failed probe fails the test
82    fn expect_clone() -> bool {
83        env::var("ORCH_COW_EXPECT_CLONE").is_ok_and(|v| v == "1")
84    }
85
86    fn repo_with_origin() -> (tempfile::TempDir, tempfile::TempDir, tempfile::TempDir) {
87        let remote = tempfile::tempdir().unwrap();
88        sh(remote.path(), &["init", "--bare", "-b", "main"]);
89        let project = tempfile::tempdir().unwrap();
90        sh(
91            project.path(),
92            &["clone", remote.path().to_str().unwrap(), "."],
93        );
94        sh(project.path(), &["config", "user.email", "t@t.com"]);
95        sh(project.path(), &["config", "user.name", "T"]);
96        fs::write(project.path().join("a.txt"), "a\n").unwrap();
97        fs::write(project.path().join("d.txt"), "d\n").unwrap();
98        sh(project.path(), &["add", "."]);
99        sh(project.path(), &["commit", "-m", "init"]);
100        sh(project.path(), &["push", "origin", "HEAD:main"]);
101        sh(project.path(), &["fetch", "origin"]);
102        let home = tempfile::tempdir().unwrap();
103        (remote, project, home)
104    }
105
106    #[tokio::test]
107    #[serial_test::serial(orch_home)]
108    async fn clone_gives_clean_worktree_or_falls_back() {
109        let (_remote, project, home) = repo_with_origin();
110        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
111        unsafe { env::set_var("ORCH_HOME", home.path()) };
112        let templates = home.path().join("state/templates");
113        fs::create_dir_all(&templates).unwrap();
114        let wt_dir = home.path().join("worktrees/p/task-1");
115        fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
116        sh(project.path(), &["branch", "task-1", "origin/main"]);
117
118        // Volumes without clone support (ext4, HFS+) take the else branch
119        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
120        if expect_clone() {
121            assert!(
122                supported,
123                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
124            );
125        }
126        let res = create_worktree(project.path(), "p", &wt_dir, "task-1", "main").await;
127        if supported {
128            res.expect("clone path should succeed");
129            assert_eq!(fs::read_to_string(wt_dir.join("a.txt")).unwrap(), "a\n");
130            let status = Command::new("git")
131                .args(["status", "--porcelain"])
132                .current_dir(&wt_dir)
133                .output()
134                .unwrap();
135            assert!(status.stdout.is_empty(), "clone must have a clean index");
136            assert!(super::super::worktree::validate_worktree_gitdir(&wt_dir).await);
137        } else {
138            assert!(res.is_err());
139            assert!(!wt_dir.exists(), "failed clone must leave no directory");
140        }
141        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
142        unsafe { env::remove_var("ORCH_HOME") };
143    }
144
145    #[tokio::test]
146    #[serial_test::serial(orch_home)]
147    async fn branch_differing_from_template_gets_its_own_files() {
148        let (_remote, project, home) = repo_with_origin();
149        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
150        unsafe { env::set_var("ORCH_HOME", home.path()) };
151        let templates = home.path().join("state/templates");
152        fs::create_dir_all(&templates).unwrap();
153        let wt_dir = home.path().join("worktrees/p/task-3");
154        fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
155
156        // Task branch: a.txt changed, b.txt added, d.txt deleted
157        sh(
158            project.path(),
159            &["checkout", "-q", "-b", "task-3", "origin/main"],
160        );
161        fs::write(project.path().join("a.txt"), "changed\n").unwrap();
162        fs::write(project.path().join("b.txt"), "b\n").unwrap();
163        fs::remove_file(project.path().join("d.txt")).unwrap();
164        sh(project.path(), &["add", "-A"]);
165        sh(project.path(), &["commit", "-m", "task"]);
166        sh(project.path(), &["checkout", "-q", "--detach"]);
167
168        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
169        if expect_clone() {
170            assert!(
171                supported,
172                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
173            );
174        }
175        if supported {
176            create_worktree(project.path(), "p", &wt_dir, "task-3", "main")
177                .await
178                .expect("clone path should succeed");
179            assert_eq!(
180                fs::read_to_string(wt_dir.join("a.txt")).unwrap(),
181                "changed\n"
182            );
183            assert_eq!(fs::read_to_string(wt_dir.join("b.txt")).unwrap(), "b\n");
184            assert!(!wt_dir.join("d.txt").exists());
185            let status = Command::new("git")
186                .args(["status", "--porcelain"])
187                .current_dir(&wt_dir)
188                .output()
189                .unwrap();
190            assert!(status.stdout.is_empty(), "clone must have a clean index");
191        }
192        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
193        unsafe { env::remove_var("ORCH_HOME") };
194    }
195
196    #[tokio::test]
197    #[serial_test::serial(orch_home)]
198    async fn checkout_ignored_dirs_reach_template_and_clone() {
199        let (_remote, project, home) = repo_with_origin();
200        fs::write(
201            project.path().join(".gitignore"),
202            "out/\n.env\n.local/\nnode/\n",
203        )
204        .unwrap();
205        sh(project.path(), &["add", "."]);
206        sh(project.path(), &["commit", "-m", "ignore"]);
207        sh(project.path(), &["push", "origin", "HEAD:main"]);
208        sh(project.path(), &["fetch", "origin"]);
209        fs::create_dir_all(project.path().join("out/sub")).unwrap();
210        fs::write(project.path().join("out/sub/dep.bin"), "dep\n").unwrap();
211        fs::create_dir_all(project.path().join(".local")).unwrap();
212        fs::write(project.path().join(".local/settings"), "x\n").unwrap();
213        fs::create_dir_all(project.path().join("pkg/a/node")).unwrap();
214        fs::write(project.path().join("pkg/a/node/dep.bin"), "nested\n").unwrap();
215        fs::create_dir_all(project.path().join("pkg/a/.hidden/node")).unwrap();
216        fs::write(project.path().join("pkg/a/.hidden/node/x"), "x\n").unwrap();
217        fs::write(project.path().join("pkg/a/keep.txt"), "k\n").unwrap();
218        fs::write(project.path().join(".env"), "secret\n").unwrap();
219
220        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
221        unsafe { env::set_var("ORCH_HOME", home.path()) };
222        let templates = home.path().join("state/templates");
223        fs::create_dir_all(&templates).unwrap();
224        let wt_dir = home.path().join("worktrees/p/task-4");
225        fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
226        sh(project.path(), &["branch", "task-4", "origin/main"]);
227
228        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
229        if expect_clone() {
230            assert!(
231                supported,
232                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
233            );
234        }
235        if supported {
236            create_worktree(project.path(), "p", &wt_dir, "task-4", "main")
237                .await
238                .expect("clone path should succeed");
239            let tpl = templates.join("p");
240            for root in [&tpl, &wt_dir] {
241                assert_eq!(
242                    fs::read_to_string(root.join("out/sub/dep.bin")).unwrap(),
243                    "dep\n"
244                );
245                assert!(!root.join(".env").exists(), "loose ignored file was copied");
246                assert!(!root.join(".local").exists(), "hidden dir was copied");
247                assert!(
248                    !root.join("pkg/a/.hidden").exists(),
249                    "nested hidden dir was copied"
250                );
251                assert_eq!(
252                    fs::read_to_string(root.join("pkg/a/node/dep.bin")).unwrap(),
253                    "nested\n"
254                );
255            }
256            // The worktree keeps its own gitdir
257            assert!(wt_dir.join(".git").is_file());
258            assert_ne!(
259                fs::read_to_string(wt_dir.join(".git")).unwrap(),
260                fs::read_to_string(tpl.join(".git")).unwrap()
261            );
262            // git-cow warms the template from the checkout at most once an hour, so a second task
263            // right after the first keeps the build output the template already has
264            fs::remove_file(project.path().join("out/sub/dep.bin")).unwrap();
265            fs::write(project.path().join("out/new.bin"), "new\n").unwrap();
266            let wt2 = home.path().join("worktrees/p/task-5");
267            sh(project.path(), &["branch", "task-5", "origin/main"]);
268            create_worktree(project.path(), "p", &wt2, "task-5", "main")
269                .await
270                .unwrap();
271            assert!(wt2.join("out/sub/dep.bin").exists());
272            assert!(!wt2.join("out/new.bin").exists());
273        }
274        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
275        unsafe { env::remove_var("ORCH_HOME") };
276    }
277
278    #[tokio::test]
279    #[serial_test::serial(orch_home)]
280    async fn merged_task_ignored_dirs_reach_template_of_bare_project() {
281        let (remote, project, home) = repo_with_origin();
282        fs::write(project.path().join(".gitignore"), "out/\n").unwrap();
283        sh(project.path(), &["add", "."]);
284        sh(project.path(), &["commit", "-m", "ignore"]);
285        sh(project.path(), &["push", "origin", "HEAD:main"]);
286        let bare = home.path().join("bare.git");
287        sh(
288            home.path(),
289            &[
290                "clone",
291                "--bare",
292                remote.path().to_str().unwrap(),
293                "bare.git",
294            ],
295        );
296        sh(&bare, &["branch", "task-6", "main"]);
297
298        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
299        unsafe { env::set_var("ORCH_HOME", home.path()) };
300        let templates = home.path().join("state/templates");
301        fs::create_dir_all(&templates).unwrap();
302        let wt_dir = home.path().join("worktrees/pb/task-6");
303        fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
304
305        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
306        if expect_clone() {
307            assert!(
308                supported,
309                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
310            );
311        }
312        if supported {
313            create_worktree(&bare, "pb", &wt_dir, "task-6", "main")
314                .await
315                .expect("clone path should succeed");
316            let tpl = templates.join("pb");
317            assert!(!tpl.join("out").exists());
318
319            fs::create_dir_all(wt_dir.join("out")).unwrap();
320            fs::write(wt_dir.join("out/dep.bin"), "dep\n").unwrap();
321            fs::write(wt_dir.join("loose.log"), "x\n").unwrap();
322
323            // A project with a working checkout is not warmed from tasks
324            warm_from_merged(project.path(), "pb", &wt_dir).await;
325            assert!(!tpl.join("out").exists());
326
327            warm_from_merged(&bare, "pb", &wt_dir).await;
328            assert_eq!(
329                fs::read_to_string(tpl.join("out/dep.bin")).unwrap(),
330                "dep\n"
331            );
332            assert!(!tpl.join("loose.log").exists());
333            assert!(tpl.join(".git").is_file());
334        }
335        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
336        unsafe { env::remove_var("ORCH_HOME") };
337    }
338
339    #[tokio::test]
340    #[serial_test::serial(orch_home)]
341    async fn env_switch_disables_clone_path() {
342        let (_remote, project, home) = repo_with_origin();
343        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
344        unsafe { env::set_var("ORCH_HOME", home.path()) };
345        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
346        unsafe { env::set_var("ORCH_COW_WORKTREES", "0") };
347        let wt_dir = home.path().join("worktrees/p/task-2");
348        let res = create_worktree(project.path(), "p", &wt_dir, "task-2", "main").await;
349        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
350        unsafe { env::remove_var("ORCH_COW_WORKTREES") };
351        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
352        unsafe { env::remove_var("ORCH_HOME") };
353        assert!(res.is_err());
354        assert!(!wt_dir.exists());
355    }
356}