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 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 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 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 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 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 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}