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