Skip to main content

tam_worktree/
git.rs

1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5/// Check that git is available.
6pub fn check_git_available() -> Result<()> {
7    Command::new("git")
8        .arg("--version")
9        .output()
10        .context("git is not installed or not in PATH")?;
11    Ok(())
12}
13
14/// Run a git command in the given directory and return stdout.
15fn git(dir: &Path, args: &[&str]) -> Result<String> {
16    let output = Command::new("git")
17        .args(args)
18        .current_dir(dir)
19        .output()
20        .context("git is not installed or not in PATH")?;
21
22    if !output.status.success() {
23        let stderr = String::from_utf8_lossy(&output.stderr);
24        bail!("git {} failed: {}", args.join(" "), stderr.trim());
25    }
26
27    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
28}
29
30/// Resolve the toplevel of the current working tree (the checkout root).
31/// For the main repo this is the repo root; for a worktree it's the worktree directory.
32pub fn toplevel(dir: &Path) -> Result<PathBuf> {
33    let tl = git(dir, &["rev-parse", "--show-toplevel"])?;
34    Ok(PathBuf::from(tl))
35}
36
37/// Resolve the main repository root (the common dir for worktrees).
38pub fn repo_root(dir: &Path) -> Result<PathBuf> {
39    let common_dir = git(dir, &["rev-parse", "--git-common-dir"])?;
40    let git_common = PathBuf::from(&common_dir);
41
42    // git-common-dir returns the .git dir; we want its parent
43    let root = if git_common.is_absolute() {
44        git_common
45            .parent()
46            .map(|p| p.to_path_buf())
47            .unwrap_or(git_common)
48    } else {
49        let abs = dir.join(&git_common);
50        abs.canonicalize()?
51            .parent()
52            .map(|p| p.to_path_buf())
53            .unwrap_or(abs)
54    };
55
56    Ok(root)
57}
58
59/// Run `git fetch --quiet`.
60pub fn fetch(dir: &Path) -> Result<()> {
61    // fetch may fail if there's no remote, that's ok
62    let _ = git(dir, &["fetch", "--quiet"]);
63    Ok(())
64}
65
66/// Check if a local branch exists.
67pub fn local_branch_exists(dir: &Path, name: &str) -> Result<bool> {
68    let result = git(
69        dir,
70        &["show-ref", "--verify", &format!("refs/heads/{}", name)],
71    );
72    Ok(result.is_ok())
73}
74
75/// Check if a remote tracking branch exists (origin/<name>).
76pub fn remote_branch_exists(dir: &Path, name: &str) -> Result<bool> {
77    let result = git(
78        dir,
79        &[
80            "show-ref",
81            "--verify",
82            &format!("refs/remotes/origin/{}", name),
83        ],
84    );
85    Ok(result.is_ok())
86}
87
88/// Detect the default branch via origin/HEAD, falling back to main then master.
89pub fn default_branch(dir: &Path) -> Result<String> {
90    // Try origin/HEAD
91    if let Ok(output) = git(dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]) {
92        if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
93            return Ok(branch.to_string());
94        }
95    }
96
97    // Fallback: check if main exists
98    if remote_branch_exists(dir, "main")? || local_branch_exists(dir, "main")? {
99        return Ok("main".to_string());
100    }
101
102    // Fallback: check if master exists
103    if remote_branch_exists(dir, "master")? || local_branch_exists(dir, "master")? {
104        return Ok("master".to_string());
105    }
106
107    bail!("could not detect default branch")
108}
109
110/// Add a worktree.
111pub fn worktree_add(dir: &Path, target: &Path, branch: &str) -> Result<()> {
112    git(dir, &["worktree", "add", &target.to_string_lossy(), branch])?;
113    Ok(())
114}
115
116/// Add a worktree with a new branch.
117pub fn worktree_add_new_branch(
118    dir: &Path,
119    target: &Path,
120    branch: &str,
121    start_point: &str,
122) -> Result<()> {
123    git(
124        dir,
125        &[
126            "worktree",
127            "add",
128            "-b",
129            branch,
130            &target.to_string_lossy(),
131            start_point,
132        ],
133    )?;
134    Ok(())
135}
136
137/// Remove a worktree.
138pub fn worktree_remove(dir: &Path, target: &Path) -> Result<()> {
139    git(dir, &["worktree", "remove", &target.to_string_lossy()])?;
140    Ok(())
141}
142
143/// Force-remove a worktree (allows dirty/locked worktrees).
144pub fn worktree_remove_force(dir: &Path, target: &Path) -> Result<()> {
145    git(
146        dir,
147        &["worktree", "remove", "--force", &target.to_string_lossy()],
148    )?;
149    Ok(())
150}
151
152/// Delete a local branch with `git branch -d` (safe delete).
153pub fn delete_branch(dir: &Path, name: &str) -> Result<()> {
154    git(dir, &["branch", "-d", name])?;
155    Ok(())
156}
157
158/// Check if a branch is merged into another branch.
159pub fn is_branch_merged(dir: &Path, branch: &str, into: &str) -> Result<bool> {
160    let output = git(dir, &["branch", "--merged", into])?;
161    Ok(output.lines().any(|line| {
162        let name = line
163            .trim()
164            .trim_start_matches("* ")
165            .trim_start_matches("+ ");
166        name == branch
167    }))
168}
169
170/// List worktrees. Returns list of worktree paths.
171pub fn worktree_list(dir: &Path) -> Result<Vec<PathBuf>> {
172    let output = git(dir, &["worktree", "list", "--porcelain"])?;
173    let paths = output
174        .lines()
175        .filter_map(|line| line.strip_prefix("worktree "))
176        .map(PathBuf::from)
177        .collect();
178    Ok(paths)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::fs;
185    use tempfile::TempDir;
186
187    /// Create a real git repo with an initial commit.
188    fn init_repo(path: &Path) {
189        fs::create_dir_all(path).unwrap();
190        git(path, &["init"]).unwrap();
191        git(path, &["config", "user.email", "test@test.com"]).unwrap();
192        git(path, &["config", "user.name", "Test"]).unwrap();
193        // Create initial commit so we have a branch
194        fs::write(path.join("README.md"), "# test").unwrap();
195        git(path, &["add", "."]).unwrap();
196        git(path, &["commit", "-m", "init"]).unwrap();
197    }
198
199    #[test]
200    fn test_repo_root_regular_repo() {
201        let tmp = TempDir::new().unwrap();
202        let repo = tmp.path().join("myrepo");
203        init_repo(&repo);
204
205        let root = repo_root(&repo).unwrap();
206        assert_eq!(root.canonicalize().unwrap(), repo.canonicalize().unwrap());
207    }
208
209    #[test]
210    fn test_repo_root_from_worktree() {
211        let tmp = TempDir::new().unwrap();
212        let repo = tmp.path().join("myrepo");
213        init_repo(&repo);
214
215        let wt_path = tmp.path().join("myrepo--feature");
216        git(
217            &repo,
218            &[
219                "worktree",
220                "add",
221                "-b",
222                "feature",
223                &wt_path.to_string_lossy(),
224            ],
225        )
226        .unwrap();
227
228        let root = repo_root(&wt_path).unwrap();
229        assert_eq!(root.canonicalize().unwrap(), repo.canonicalize().unwrap());
230    }
231
232    #[test]
233    fn test_local_branch_exists() {
234        let tmp = TempDir::new().unwrap();
235        let repo = tmp.path().join("repo");
236        init_repo(&repo);
237
238        // The default branch (main or master) should exist
239        let default = git(&repo, &["branch", "--show-current"]).unwrap();
240        assert!(local_branch_exists(&repo, &default).unwrap());
241        assert!(!local_branch_exists(&repo, "nonexistent-branch").unwrap());
242    }
243
244    #[test]
245    fn test_local_branch_exists_after_create() {
246        let tmp = TempDir::new().unwrap();
247        let repo = tmp.path().join("repo");
248        init_repo(&repo);
249
250        git(&repo, &["branch", "feature-x"]).unwrap();
251        assert!(local_branch_exists(&repo, "feature-x").unwrap());
252    }
253
254    #[test]
255    fn test_default_branch_fallback_to_main() {
256        let tmp = TempDir::new().unwrap();
257        let repo = tmp.path().join("repo");
258        init_repo(&repo);
259        // Ensure we're on main
260        let _ = git(&repo, &["branch", "-M", "main"]);
261
262        let branch = default_branch(&repo).unwrap();
263        assert!(branch == "main" || branch == "master");
264    }
265
266    #[test]
267    fn test_worktree_add_and_list() {
268        let tmp = TempDir::new().unwrap();
269        let repo = tmp.path().join("repo");
270        init_repo(&repo);
271
272        let wt_path = tmp.path().join("repo--feature");
273        worktree_add_new_branch(&repo, &wt_path, "feature", "HEAD").unwrap();
274
275        let worktrees = worktree_list(&repo).unwrap();
276        assert!(worktrees.len() >= 2); // main + the new worktree
277        let wt_canonical = wt_path.canonicalize().unwrap();
278        assert!(
279            worktrees
280                .iter()
281                .any(|p| p.canonicalize().unwrap_or_default() == wt_canonical),
282            "worktree list should contain the new worktree"
283        );
284    }
285
286    #[test]
287    fn test_worktree_add_existing_branch() {
288        let tmp = TempDir::new().unwrap();
289        let repo = tmp.path().join("repo");
290        init_repo(&repo);
291
292        git(&repo, &["branch", "existing-branch"]).unwrap();
293
294        let wt_path = tmp.path().join("repo--existing");
295        worktree_add(&repo, &wt_path, "existing-branch").unwrap();
296
297        assert!(wt_path.exists());
298        let worktrees = worktree_list(&repo).unwrap();
299        assert!(worktrees.len() >= 2);
300    }
301
302    #[test]
303    fn test_worktree_remove() {
304        let tmp = TempDir::new().unwrap();
305        let repo = tmp.path().join("repo");
306        init_repo(&repo);
307
308        let wt_path = tmp.path().join("repo--feature");
309        worktree_add_new_branch(&repo, &wt_path, "feature", "HEAD").unwrap();
310        assert!(wt_path.exists());
311
312        worktree_remove(&repo, &wt_path).unwrap();
313        assert!(!wt_path.exists());
314    }
315
316    #[test]
317    fn test_is_branch_merged() {
318        let tmp = TempDir::new().unwrap();
319        let repo = tmp.path().join("repo");
320        init_repo(&repo);
321        let default = git(&repo, &["branch", "--show-current"]).unwrap();
322
323        // Create a branch, merge it, then check
324        git(&repo, &["branch", "merged-branch"]).unwrap();
325        assert!(is_branch_merged(&repo, "merged-branch", &default).unwrap());
326
327        // Create a branch with a new commit — not merged
328        git(&repo, &["checkout", "-b", "unmerged-branch"]).unwrap();
329        fs::write(repo.join("new-file.txt"), "content").unwrap();
330        git(&repo, &["add", "."]).unwrap();
331        git(&repo, &["commit", "-m", "new commit"]).unwrap();
332        git(&repo, &["checkout", &default]).unwrap();
333
334        assert!(!is_branch_merged(&repo, "unmerged-branch", &default).unwrap());
335    }
336
337    #[test]
338    fn test_fetch_no_remote() {
339        let tmp = TempDir::new().unwrap();
340        let repo = tmp.path().join("repo");
341        init_repo(&repo);
342
343        // Should not error even without a remote
344        fetch(&repo).unwrap();
345    }
346}