Skip to main content

tam_worktree/
worktree.rs

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
9/// Create a worktree for the current project.
10///
11/// Returns the path to the created worktree.
12pub 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    // Auto-create worktree root if it doesn't exist
29    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    // Branch resolution
41    if git::local_branch_exists(cwd, name)? {
42        // a. Local branch exists — check it out
43        git::worktree_add(cwd, &target, name)?;
44    } else if git::remote_branch_exists(cwd, name)? {
45        // b. Remote branch exists — track it
46        git::worktree_add(cwd, &target, name)?;
47    } else if let Some(base) = source {
48        // c. --source provided — create new branch from base
49        git::worktree_add_new_branch(cwd, &target, name, base)?;
50    } else {
51        // d. Create new branch from default branch
52        let default = git::default_branch(cwd)?;
53        git::worktree_add_new_branch(cwd, &target, name, &default)?;
54    }
55
56    Ok(target)
57}
58
59/// Delete a worktree for the current project.
60///
61/// If `delete_branch` is true, the local branch is deleted without prompting.
62/// Otherwise, if a terminal is attached, the user is prompted.
63///
64/// If `force` is true, worktrees with uncommitted changes are removed anyway.
65/// Without `--force`, dirty worktrees cause an error.
66pub 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                // Force: try git force-remove, fall back to manual removal
93                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    // Handle local branch cleanup
111    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        // Ensure we're on main
168        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    // --- create tests ---
181
182    #[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        // The worktree should have the README from the repo
197        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    // --- delete tests ---
246
247    #[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        // Create a worktree first
258        let wt_path = create("feature-x", None, &config, &repo).unwrap();
259        assert!(wt_path.exists());
260
261        // Delete it
262        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 with --branch flag
296        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        // Make the worktree dirty
314        fs::write(wt_path.join("dirty.txt"), "uncommitted").unwrap();
315
316        // Should fail without --force
317        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        // Make the worktree dirty
339        fs::write(wt_path.join("dirty.txt"), "uncommitted").unwrap();
340
341        // Should succeed with --force
342        delete("feature-x", false, true, &config, &repo).unwrap();
343        assert!(!wt_path.exists());
344    }
345}