1use 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
14pub 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
37pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 unsafe { env::set_var("ORCH_HOME", home.path()) };
345 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 unsafe { env::remove_var("ORCH_COW_WORKTREES") };
351 unsafe { env::remove_var("ORCH_HOME") };
353 assert!(res.is_err());
354 assert!(!wt_dir.exists());
355 }
356}