1use anyhow::{bail, Context, Result};
2use std::fs;
3use std::io::{self, BufRead, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5
6use crate::config::Config;
7use crate::git;
8
9pub fn create(name: &str, source: Option<&str>, config: &Config, cwd: &Path) -> Result<PathBuf> {
13 let main_root = git::repo_root(cwd)
14 .context("not inside a git repository — run this from within a project")?;
15 let project_name = main_root
16 .file_name()
17 .map(|n| n.to_string_lossy().to_string())
18 .unwrap_or_else(|| "unknown".to_string());
19
20 let target = config
21 .worktree_root
22 .join(format!("{}--{}", project_name, name));
23
24 if target.exists() {
25 bail!("worktree directory already exists: {}", target.display());
26 }
27
28 if !config.worktree_root.exists() {
30 fs::create_dir_all(&config.worktree_root).with_context(|| {
31 format!(
32 "failed to create worktree root: {}",
33 config.worktree_root.display()
34 )
35 })?;
36 }
37
38 git::fetch(cwd)?;
39
40 if git::local_branch_exists(cwd, name)? {
42 git::worktree_add(cwd, &target, name)?;
44 } else if git::remote_branch_exists(cwd, name)? {
45 git::worktree_add(cwd, &target, name)?;
47 } else if let Some(base) = source {
48 git::worktree_add_new_branch(cwd, &target, name, base)?;
50 } else {
51 let default = git::default_branch(cwd)?;
53 git::worktree_add_new_branch(cwd, &target, name, &default)?;
54 }
55
56 Ok(target)
57}
58
59pub fn delete(
67 name: &str,
68 delete_branch: bool,
69 force: bool,
70 config: &Config,
71 cwd: &Path,
72) -> Result<()> {
73 let main_root = git::repo_root(cwd)
74 .context("not inside a git repository — run this from within a project")?;
75 let project_name = main_root
76 .file_name()
77 .map(|n| n.to_string_lossy().to_string())
78 .unwrap_or_else(|| "unknown".to_string());
79
80 let target = config
81 .worktree_root
82 .join(format!("{}--{}", project_name, name));
83
84 if !target.exists() {
85 bail!("worktree '{}' does not exist", name);
86 }
87
88 {
89 match git::worktree_remove(cwd, &target) {
90 Ok(()) => {}
91 Err(_) if force => {
92 if git::worktree_remove_force(cwd, &target).is_err() && target.exists() {
94 fs::remove_dir_all(&target).with_context(|| {
95 format!("failed to remove directory {}", target.display())
96 })?;
97 }
98 }
99 Err(e) => {
100 bail!(
101 "cannot remove worktree '{}': {}\n\
102 hint: use --force to remove anyway",
103 name,
104 e
105 );
106 }
107 }
108 }
109
110 if git::local_branch_exists(cwd, name)? {
112 let should_delete = if delete_branch {
113 true
114 } else if io::stderr().is_terminal() && io::stdin().is_terminal() {
115 eprint!("delete local branch '{}'? [y/N] ", name);
116 io::stderr().flush()?;
117 let mut answer = String::new();
118 io::stdin().lock().read_line(&mut answer)?;
119 matches!(answer.trim(), "y" | "Y" | "yes" | "YES")
120 } else {
121 eprintln!(
122 "note: local branch '{}' still exists — pass -b to delete it",
123 name
124 );
125 false
126 };
127
128 if should_delete {
129 if let Err(e) = git::delete_branch(cwd, name) {
130 eprintln!(
131 "warning: could not delete branch '{}': {}\n\
132 hint: if the branch is not fully merged, run: git branch -D {}",
133 name, e, name
134 );
135 } else {
136 eprintln!("deleted branch '{}'", name);
137 }
138 }
139 }
140
141 Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::process::Command;
148 use tempfile::TempDir;
149
150 fn git_cmd(dir: &Path, args: &[&str]) -> String {
151 let output = Command::new("git")
152 .args(args)
153 .current_dir(dir)
154 .output()
155 .unwrap();
156 String::from_utf8_lossy(&output.stdout).trim().to_string()
157 }
158
159 fn init_repo(path: &Path) {
160 fs::create_dir_all(path).unwrap();
161 git_cmd(path, &["init"]);
162 git_cmd(path, &["config", "user.email", "test@test.com"]);
163 git_cmd(path, &["config", "user.name", "Test"]);
164 fs::write(path.join("README.md"), "# test").unwrap();
165 git_cmd(path, &["add", "."]);
166 git_cmd(path, &["commit", "-m", "init"]);
167 let _ = git_cmd(path, &["branch", "-M", "main"]);
169 }
170
171 fn test_config(worktree_root: PathBuf) -> Config {
172 Config {
173 max_depth: 5,
174 ignore: vec![".*".to_string(), "node_modules".to_string()],
175 worktree_root,
176 auto_init: false,
177 }
178 }
179
180 #[test]
183 fn test_create_new_branch_from_default() {
184 let tmp = TempDir::new().unwrap();
185 let repo = tmp.path().join("myproject");
186 init_repo(&repo);
187
188 let wt_root = tmp.path().join("worktrees");
189 fs::create_dir_all(&wt_root).unwrap();
190 let config = test_config(wt_root.clone());
191
192 let result = create("feature-x", None, &config, &repo).unwrap();
193 assert_eq!(result, wt_root.join("myproject--feature-x"));
194 assert!(result.exists());
195
196 assert!(result.join("README.md").exists());
198 }
199
200 #[test]
201 fn test_create_with_source() {
202 let tmp = TempDir::new().unwrap();
203 let repo = tmp.path().join("myproject");
204 init_repo(&repo);
205
206 let wt_root = tmp.path().join("worktrees");
207 fs::create_dir_all(&wt_root).unwrap();
208 let config = test_config(wt_root.clone());
209
210 let result = create("feature-y", Some("main"), &config, &repo).unwrap();
211 assert!(result.exists());
212 }
213
214 #[test]
215 fn test_create_existing_local_branch() {
216 let tmp = TempDir::new().unwrap();
217 let repo = tmp.path().join("myproject");
218 init_repo(&repo);
219 git_cmd(&repo, &["branch", "existing-branch"]);
220
221 let wt_root = tmp.path().join("worktrees");
222 fs::create_dir_all(&wt_root).unwrap();
223 let config = test_config(wt_root.clone());
224
225 let result = create("existing-branch", None, &config, &repo).unwrap();
226 assert!(result.exists());
227 }
228
229 #[test]
230 fn test_create_target_already_exists() {
231 let tmp = TempDir::new().unwrap();
232 let repo = tmp.path().join("myproject");
233 init_repo(&repo);
234
235 let wt_root = tmp.path().join("worktrees");
236 let target = wt_root.join("myproject--feature-x");
237 fs::create_dir_all(&target).unwrap();
238 let config = test_config(wt_root);
239
240 let result = create("feature-x", None, &config, &repo);
241 assert!(result.is_err());
242 assert!(result.unwrap_err().to_string().contains("already exists"),);
243 }
244
245 #[test]
248 fn test_delete_worktree() {
249 let tmp = TempDir::new().unwrap();
250 let repo = tmp.path().join("myproject");
251 init_repo(&repo);
252
253 let wt_root = tmp.path().join("worktrees");
254 fs::create_dir_all(&wt_root).unwrap();
255 let config = test_config(wt_root.clone());
256
257 let wt_path = create("feature-x", None, &config, &repo).unwrap();
259 assert!(wt_path.exists());
260
261 delete("feature-x", false, false, &config, &repo).unwrap();
263 assert!(!wt_path.exists());
264 }
265
266 #[test]
267 fn test_delete_nonexistent_worktree() {
268 let tmp = TempDir::new().unwrap();
269 let repo = tmp.path().join("myproject");
270 init_repo(&repo);
271
272 let wt_root = tmp.path().join("worktrees");
273 fs::create_dir_all(&wt_root).unwrap();
274 let config = test_config(wt_root);
275
276 let result = delete("nonexistent", false, false, &config, &repo);
277 assert!(result.is_err());
278 assert!(result.unwrap_err().to_string().contains("does not exist"));
279 }
280
281 #[test]
282 fn test_delete_with_branch_flag() {
283 let tmp = TempDir::new().unwrap();
284 let repo = tmp.path().join("myproject");
285 init_repo(&repo);
286
287 let wt_root = tmp.path().join("worktrees");
288 fs::create_dir_all(&wt_root).unwrap();
289 let config = test_config(wt_root.clone());
290
291 let wt_path = create("feature-x", None, &config, &repo).unwrap();
292 assert!(wt_path.exists());
293 assert!(git::local_branch_exists(&repo, "feature-x").unwrap());
294
295 delete("feature-x", true, false, &config, &repo).unwrap();
297 assert!(!wt_path.exists());
298 assert!(!git::local_branch_exists(&repo, "feature-x").unwrap());
299 }
300
301 #[test]
302 fn test_delete_dirty_worktree_requires_force() {
303 let tmp = TempDir::new().unwrap();
304 let repo = tmp.path().join("myproject");
305 init_repo(&repo);
306
307 let wt_root = tmp.path().join("worktrees");
308 fs::create_dir_all(&wt_root).unwrap();
309 let config = test_config(wt_root.clone());
310
311 let wt_path = create("feature-x", None, &config, &repo).unwrap();
312
313 fs::write(wt_path.join("dirty.txt"), "uncommitted").unwrap();
315
316 let result = delete("feature-x", false, false, &config, &repo);
318 assert!(result.is_err());
319 assert!(
320 result.unwrap_err().to_string().contains("--force"),
321 "error should suggest --force"
322 );
323 assert!(wt_path.exists(), "dirty worktree should not be removed");
324 }
325
326 #[test]
327 fn test_delete_dirty_worktree_with_force() {
328 let tmp = TempDir::new().unwrap();
329 let repo = tmp.path().join("myproject");
330 init_repo(&repo);
331
332 let wt_root = tmp.path().join("worktrees");
333 fs::create_dir_all(&wt_root).unwrap();
334 let config = test_config(wt_root.clone());
335
336 let wt_path = create("feature-x", None, &config, &repo).unwrap();
337
338 fs::write(wt_path.join("dirty.txt"), "uncommitted").unwrap();
340
341 delete("feature-x", false, true, &config, &repo).unwrap();
343 assert!(!wt_path.exists());
344 }
345}