1use anyhow::Result;
2use std::path::{Path, PathBuf};
3use crate::config::Config;
4use crate::git_util::run;
5use crate::ticket::{Ticket, load_all_from_git};
6
7pub fn find_worktree_for_branch(root: &Path, branch: &str) -> Option<PathBuf> {
11 let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
12 let main_path = out.lines()
15 .find_map(|l| l.strip_prefix("worktree ").map(PathBuf::from))
16 .unwrap_or_else(|| root.to_path_buf());
17 let main = main_path.canonicalize().unwrap_or(main_path);
18 let mut current_path: Option<PathBuf> = None;
19 for line in out.lines() {
20 if let Some(p) = line.strip_prefix("worktree ") {
21 current_path = Some(PathBuf::from(p));
22 } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
23 if b == branch {
24 if let Some(p) = ¤t_path {
25 if p.canonicalize().unwrap_or_else(|_| p.clone()) != main {
26 return current_path;
27 }
28 }
29 }
30 }
31 }
32 None
33}
34
35pub fn list_ticket_worktrees(root: &Path) -> Result<Vec<(PathBuf, String)>> {
38 let out = run(root, &["worktree", "list", "--porcelain"])?;
39 let main = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
40
41 let mut result = Vec::new();
42 let mut current_path: Option<PathBuf> = None;
43 for line in out.lines() {
44 if let Some(p) = line.strip_prefix("worktree ") {
45 current_path = Some(PathBuf::from(p));
46 } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
47 if b.starts_with("ticket/") {
48 if let Some(p) = ¤t_path {
49 if p.canonicalize().unwrap_or_else(|_| p.clone()) != main {
50 result.push((p.clone(), b.to_string()));
51 }
52 }
53 }
54 }
55 }
56 Ok(result)
57}
58
59pub fn ensure_worktree(root: &Path, worktrees_base: &Path, branch: &str) -> Result<PathBuf> {
62 if let Some(existing) = find_worktree_for_branch(root, branch) {
63 return Ok(existing);
64 }
65 let wt_name = branch.replace('/', "-");
66 std::fs::create_dir_all(worktrees_base)?;
67 let wt_path = worktrees_base.join(&wt_name);
68 add_worktree(root, &wt_path, branch)?;
69 Ok(find_worktree_for_branch(root, branch).unwrap_or(wt_path))
70}
71
72pub fn add_worktree(root: &Path, wt_path: &Path, branch: &str) -> Result<()> {
75 let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
76 if !has_local {
77 let _ = run(root, &["fetch", "origin", branch]);
78 }
79 run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
80 crate::logger::log("add_worktree", &format!("{}", wt_path.display()));
81 Ok(())
82}
83
84pub fn remove_worktree(root: &Path, wt_path: &Path, force: bool) -> Result<()> {
86 clean_agent_dirs(root, wt_path);
87 let path_str = wt_path.to_string_lossy();
88 if force {
89 run(root, &["worktree", "remove", "--force", &path_str]).map(|_| ())
90 } else {
91 run(root, &["worktree", "remove", &path_str]).map(|_| ())
92 }
93}
94
95pub fn sync_agent_dirs(root: &Path, wt_path: &Path, agent_dirs: &[String], warnings: &mut Vec<String>) {
98 for dir_name in agent_dirs {
99 let src = root.join(dir_name);
100 if !src.is_dir() {
101 continue;
102 }
103 if is_tracked(root, dir_name) {
104 continue;
105 }
106 let dst = wt_path.join(dir_name);
107 if let Err(e) = copy_dir_recursive(&src, &dst) {
108 warnings.push(format!("warning: could not copy {dir_name} to worktree: {e}"));
109 }
110 }
111}
112
113fn clean_agent_dirs(root: &Path, wt_path: &Path) {
116 let config = match Config::load(root) {
117 Ok(c) => c,
118 Err(_) => return,
119 };
120 for dir_name in &config.worktrees.agent_dirs {
121 let dir = wt_path.join(dir_name);
122 if !dir.is_dir() {
123 continue;
124 }
125 if is_tracked(root, dir_name) {
126 continue;
127 }
128 let _ = std::fs::remove_dir_all(&dir);
129 }
130}
131
132fn is_tracked(root: &Path, path: &str) -> bool {
133 crate::git_util::is_file_tracked(root, path)
134}
135
136fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
137 if dst.exists() {
138 std::fs::remove_dir_all(dst)?;
139 }
140 std::fs::create_dir_all(dst)?;
141 for entry in std::fs::read_dir(src)? {
142 let entry = entry?;
143 let src_path = entry.path();
144 let dst_path = dst.join(entry.file_name());
145 if src_path.is_dir() {
146 copy_dir_recursive(&src_path, &dst_path)?;
147 } else {
148 std::fs::copy(&src_path, &dst_path)?;
149 }
150 }
151 Ok(())
152}
153
154pub fn provision_worktree(root: &Path, config: &Config, branch: &str, warnings: &mut Vec<String>) -> Result<PathBuf> {
155 let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
156 let worktrees_base = main_root.join(&config.worktrees.dir);
157 let wt = ensure_worktree(root, &worktrees_base, branch)?;
158 sync_agent_dirs(root, &wt, &config.worktrees.agent_dirs, warnings);
159 Ok(wt)
160}
161
162#[cfg(test)]
163mod tests {
164 use std::process::Command;
165 use tempfile::TempDir;
166
167 fn git_init(dir: &std::path::Path) {
168 Command::new("git").args(["init", "-b", "main"]).current_dir(dir).output().unwrap();
169 Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(dir).output().unwrap();
170 Command::new("git").args(["config", "user.name", "test"]).current_dir(dir).output().unwrap();
171 }
172
173 #[test]
174 fn find_worktree_for_branch_skips_main_worktree() {
175 let tmp = TempDir::new().unwrap();
176 let repo = tmp.path();
177 git_init(repo);
178 std::fs::write(repo.join("README"), "x").unwrap();
179 Command::new("git").args(["-c", "commit.gpgsign=false", "add", "README"]).current_dir(repo).output().unwrap();
180 Command::new("git").args(["-c", "commit.gpgsign=false", "commit", "-m", "init"]).current_dir(repo).output().unwrap();
181 Command::new("git").args(["checkout", "-b", "ticket/my-branch"]).current_dir(repo).output().unwrap();
183 let result = super::find_worktree_for_branch(repo, "ticket/my-branch");
186 assert!(result.is_none(), "expected None when main worktree holds the ticket branch, got {:?}", result);
187 }
188
189 #[test]
190 fn provision_worktree_creates_dir_inside_repo() {
191 let tmp = TempDir::new().unwrap();
192 let repo = tmp.path();
193 git_init(repo);
194 std::fs::write(repo.join("README"), "x").unwrap();
195 Command::new("git").args(["-c", "commit.gpgsign=false", "add", "README"]).current_dir(repo).output().unwrap();
196 Command::new("git").args(["-c", "commit.gpgsign=false", "commit", "-m", "init"]).current_dir(repo).output().unwrap();
197 Command::new("git").args(["branch", "ticket/test-branch"]).current_dir(repo).output().unwrap();
198
199 let toml = r#"[project]
200name = "test"
201
202[tickets]
203dir = "tickets"
204
205[worktrees]
206dir = "worktrees"
207"#;
208 let config: crate::config::Config = toml::from_str(toml).unwrap();
209
210 let mut warnings: Vec<String> = Vec::new();
211 let wt = super::provision_worktree(repo, &config, "ticket/test-branch", &mut warnings).unwrap();
212
213 let main_root = crate::git_util::main_worktree_root(repo)
214 .unwrap_or_else(|| repo.to_path_buf());
215 let expected = main_root.join("worktrees").join("ticket-test-branch");
216 assert_eq!(wt, expected, "provisioned path must be <repo>/worktrees/<branch-slug>");
217 assert!(wt.is_dir(), "provisioned worktree dir must exist on disk: {}", wt.display());
218 assert!(
219 wt.starts_with(&main_root),
220 "worktree path must be inside repo: wt={} repo={}",
221 wt.display(),
222 main_root.display()
223 );
224 }
225
226 #[test]
227 fn provision_worktree_honours_external_layout() {
228 let tmp = TempDir::new().unwrap();
231 let repo = tmp.path().join("repo");
232 std::fs::create_dir_all(&repo).unwrap();
233 git_init(&repo);
234 std::fs::write(repo.join("README"), "x").unwrap();
235 Command::new("git").args(["-c", "commit.gpgsign=false", "add", "README"]).current_dir(&repo).output().unwrap();
236 Command::new("git").args(["-c", "commit.gpgsign=false", "commit", "-m", "init"]).current_dir(&repo).output().unwrap();
237 Command::new("git").args(["branch", "ticket/ext-branch"]).current_dir(&repo).output().unwrap();
238
239 let toml = r#"[project]
240name = "test"
241
242[tickets]
243dir = "tickets"
244
245[worktrees]
246dir = "../external-worktrees"
247"#;
248 let config: crate::config::Config = toml::from_str(toml).unwrap();
249
250 let mut warnings: Vec<String> = Vec::new();
251 let wt = super::provision_worktree(&repo, &config, "ticket/ext-branch", &mut warnings).unwrap();
252
253 let expected = tmp.path().join("external-worktrees").join("ticket-ext-branch");
254 assert_eq!(
255 wt.canonicalize().unwrap(),
256 expected.canonicalize().unwrap(),
257 "external layout must place worktree as a sibling of the repo"
258 );
259 assert!(wt.is_dir(), "external worktree dir must exist on disk: {}", wt.display());
260 }
261}
262
263pub fn list_worktrees_with_tickets(
264 root: &Path,
265 tickets_dir: &Path,
266) -> Result<Vec<(PathBuf, String, Option<Ticket>)>> {
267 let worktrees = list_ticket_worktrees(root)?;
268 let tickets = load_all_from_git(root, tickets_dir).unwrap_or_default();
269 let result = worktrees.into_iter().map(|(wt_path, branch)| {
270 let ticket = tickets.iter().find(|t| {
271 t.frontmatter.branch.as_deref() == Some(branch.as_str())
272 || crate::ticket_fmt::branch_name_from_path(&t.path).as_deref() == Some(branch.as_str())
273 }).cloned();
274 (wt_path, branch, ticket)
275 }).collect();
276 Ok(result)
277}