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        std::env::set_var("ORCH_HOME", home.path());
106        let templates = home.path().join("state/templates");
107        std::fs::create_dir_all(&templates).unwrap();
108        let wt_dir = home.path().join("worktrees/p/task-1");
109        std::fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
110        sh(project.path(), &["branch", "task-1", "origin/main"]);
111
112        // Volumes without clone support (ext4, HFS+) take the else branch
113        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
114        if expect_clone() {
115            assert!(
116                supported,
117                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
118            );
119        }
120        let res = create_worktree(project.path(), "p", &wt_dir, "task-1", "main").await;
121        if supported {
122            res.expect("clone path should succeed");
123            assert_eq!(
124                std::fs::read_to_string(wt_dir.join("a.txt")).unwrap(),
125                "a\n"
126            );
127            let status = std::process::Command::new("git")
128                .args(["status", "--porcelain"])
129                .current_dir(&wt_dir)
130                .output()
131                .unwrap();
132            assert!(status.stdout.is_empty(), "clone must have a clean index");
133            assert!(super::super::worktree::validate_worktree_gitdir(&wt_dir).await);
134        } else {
135            assert!(res.is_err());
136            assert!(!wt_dir.exists(), "failed clone must leave no directory");
137        }
138        std::env::remove_var("ORCH_HOME");
139    }
140
141    #[tokio::test]
142    #[serial_test::serial(orch_home)]
143    async fn branch_differing_from_template_gets_its_own_files() {
144        let (_remote, project, home) = repo_with_origin();
145        std::env::set_var("ORCH_HOME", home.path());
146        let templates = home.path().join("state/templates");
147        std::fs::create_dir_all(&templates).unwrap();
148        let wt_dir = home.path().join("worktrees/p/task-3");
149        std::fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
150
151        // Task branch: a.txt changed, b.txt added, d.txt deleted
152        sh(
153            project.path(),
154            &["checkout", "-q", "-b", "task-3", "origin/main"],
155        );
156        std::fs::write(project.path().join("a.txt"), "changed\n").unwrap();
157        std::fs::write(project.path().join("b.txt"), "b\n").unwrap();
158        std::fs::remove_file(project.path().join("d.txt")).unwrap();
159        sh(project.path(), &["add", "-A"]);
160        sh(project.path(), &["commit", "-m", "task"]);
161        sh(project.path(), &["checkout", "-q", "--detach"]);
162
163        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
164        if expect_clone() {
165            assert!(
166                supported,
167                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
168            );
169        }
170        if supported {
171            create_worktree(project.path(), "p", &wt_dir, "task-3", "main")
172                .await
173                .expect("clone path should succeed");
174            assert_eq!(
175                std::fs::read_to_string(wt_dir.join("a.txt")).unwrap(),
176                "changed\n"
177            );
178            assert_eq!(
179                std::fs::read_to_string(wt_dir.join("b.txt")).unwrap(),
180                "b\n"
181            );
182            assert!(!wt_dir.join("d.txt").exists());
183            let status = std::process::Command::new("git")
184                .args(["status", "--porcelain"])
185                .current_dir(&wt_dir)
186                .output()
187                .unwrap();
188            assert!(status.stdout.is_empty(), "clone must have a clean index");
189        }
190        std::env::remove_var("ORCH_HOME");
191    }
192
193    #[tokio::test]
194    #[serial_test::serial(orch_home)]
195    async fn checkout_ignored_dirs_reach_template_and_clone() {
196        let (_remote, project, home) = repo_with_origin();
197        std::fs::write(
198            project.path().join(".gitignore"),
199            "out/\n.env\n.local/\nnode/\n",
200        )
201        .unwrap();
202        sh(project.path(), &["add", "."]);
203        sh(project.path(), &["commit", "-m", "ignore"]);
204        sh(project.path(), &["push", "origin", "HEAD:main"]);
205        sh(project.path(), &["fetch", "origin"]);
206        std::fs::create_dir_all(project.path().join("out/sub")).unwrap();
207        std::fs::write(project.path().join("out/sub/dep.bin"), "dep\n").unwrap();
208        std::fs::create_dir_all(project.path().join(".local")).unwrap();
209        std::fs::write(project.path().join(".local/settings"), "x\n").unwrap();
210        std::fs::create_dir_all(project.path().join("pkg/a/node")).unwrap();
211        std::fs::write(project.path().join("pkg/a/node/dep.bin"), "nested\n").unwrap();
212        std::fs::create_dir_all(project.path().join("pkg/a/.hidden/node")).unwrap();
213        std::fs::write(project.path().join("pkg/a/.hidden/node/x"), "x\n").unwrap();
214        std::fs::write(project.path().join("pkg/a/keep.txt"), "k\n").unwrap();
215        std::fs::write(project.path().join(".env"), "secret\n").unwrap();
216
217        std::env::set_var("ORCH_HOME", home.path());
218        let templates = home.path().join("state/templates");
219        std::fs::create_dir_all(&templates).unwrap();
220        let wt_dir = home.path().join("worktrees/p/task-4");
221        std::fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
222        sh(project.path(), &["branch", "task-4", "origin/main"]);
223
224        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
225        if expect_clone() {
226            assert!(
227                supported,
228                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
229            );
230        }
231        if supported {
232            create_worktree(project.path(), "p", &wt_dir, "task-4", "main")
233                .await
234                .expect("clone path should succeed");
235            let tpl = templates.join("p");
236            for root in [&tpl, &wt_dir] {
237                assert_eq!(
238                    std::fs::read_to_string(root.join("out/sub/dep.bin")).unwrap(),
239                    "dep\n"
240                );
241                assert!(!root.join(".env").exists(), "loose ignored file was copied");
242                assert!(!root.join(".local").exists(), "hidden dir was copied");
243                assert!(
244                    !root.join("pkg/a/.hidden").exists(),
245                    "nested hidden dir was copied"
246                );
247                assert_eq!(
248                    std::fs::read_to_string(root.join("pkg/a/node/dep.bin")).unwrap(),
249                    "nested\n"
250                );
251            }
252            // The worktree keeps its own gitdir
253            assert!(wt_dir.join(".git").is_file());
254            assert_ne!(
255                std::fs::read_to_string(wt_dir.join(".git")).unwrap(),
256                std::fs::read_to_string(tpl.join(".git")).unwrap()
257            );
258            // git-cow warms the template from the checkout at most once an hour, so a second task
259            // right after the first keeps the build output the template already has
260            std::fs::remove_file(project.path().join("out/sub/dep.bin")).unwrap();
261            std::fs::write(project.path().join("out/new.bin"), "new\n").unwrap();
262            let wt2 = home.path().join("worktrees/p/task-5");
263            sh(project.path(), &["branch", "task-5", "origin/main"]);
264            create_worktree(project.path(), "p", &wt2, "task-5", "main")
265                .await
266                .unwrap();
267            assert!(wt2.join("out/sub/dep.bin").exists());
268            assert!(!wt2.join("out/new.bin").exists());
269        }
270        std::env::remove_var("ORCH_HOME");
271    }
272
273    #[tokio::test]
274    #[serial_test::serial(orch_home)]
275    async fn merged_task_ignored_dirs_reach_template_of_bare_project() {
276        let (remote, project, home) = repo_with_origin();
277        std::fs::write(project.path().join(".gitignore"), "out/\n").unwrap();
278        sh(project.path(), &["add", "."]);
279        sh(project.path(), &["commit", "-m", "ignore"]);
280        sh(project.path(), &["push", "origin", "HEAD:main"]);
281        let bare = home.path().join("bare.git");
282        sh(
283            home.path(),
284            &[
285                "clone",
286                "--bare",
287                remote.path().to_str().unwrap(),
288                "bare.git",
289            ],
290        );
291        sh(&bare, &["branch", "task-6", "main"]);
292
293        std::env::set_var("ORCH_HOME", home.path());
294        let templates = home.path().join("state/templates");
295        std::fs::create_dir_all(&templates).unwrap();
296        let wt_dir = home.path().join("worktrees/pb/task-6");
297        std::fs::create_dir_all(wt_dir.parent().unwrap()).unwrap();
298
299        let supported = git_cow::can_clone(&templates, wt_dir.parent().unwrap());
300        if expect_clone() {
301            assert!(
302                supported,
303                "ORCH_COW_EXPECT_CLONE is set but the volume cannot clone"
304            );
305        }
306        if supported {
307            create_worktree(&bare, "pb", &wt_dir, "task-6", "main")
308                .await
309                .expect("clone path should succeed");
310            let tpl = templates.join("pb");
311            assert!(!tpl.join("out").exists());
312
313            std::fs::create_dir_all(wt_dir.join("out")).unwrap();
314            std::fs::write(wt_dir.join("out/dep.bin"), "dep\n").unwrap();
315            std::fs::write(wt_dir.join("loose.log"), "x\n").unwrap();
316
317            // A project with a working checkout is not warmed from tasks
318            warm_from_merged(project.path(), "pb", &wt_dir).await;
319            assert!(!tpl.join("out").exists());
320
321            warm_from_merged(&bare, "pb", &wt_dir).await;
322            assert_eq!(
323                std::fs::read_to_string(tpl.join("out/dep.bin")).unwrap(),
324                "dep\n"
325            );
326            assert!(!tpl.join("loose.log").exists());
327            assert!(tpl.join(".git").is_file());
328        }
329        std::env::remove_var("ORCH_HOME");
330    }
331
332    #[tokio::test]
333    #[serial_test::serial(orch_home)]
334    async fn env_switch_disables_clone_path() {
335        let (_remote, project, home) = repo_with_origin();
336        std::env::set_var("ORCH_HOME", home.path());
337        std::env::set_var("ORCH_COW_WORKTREES", "0");
338        let wt_dir = home.path().join("worktrees/p/task-2");
339        let res = create_worktree(project.path(), "p", &wt_dir, "task-2", "main").await;
340        std::env::remove_var("ORCH_COW_WORKTREES");
341        std::env::remove_var("ORCH_HOME");
342        assert!(res.is_err());
343        assert!(!wt_dir.exists());
344    }
345}