1use 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
10pub 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
33pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 unsafe { std::env::set_var("ORCH_HOME", home.path()) };
346 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 unsafe { std::env::remove_var("ORCH_COW_WORKTREES") };
352 unsafe { std::env::remove_var("ORCH_HOME") };
354 assert!(res.is_err());
355 assert!(!wt_dir.exists());
356 }
357}