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/// List worktrees. Returns list of worktree paths.
159pub fn worktree_list(dir: &Path) -> Result<Vec<PathBuf>> {
160    let output = git(dir, &["worktree", "list", "--porcelain"])?;
161    let paths = output
162        .lines()
163        .filter_map(|line| line.strip_prefix("worktree "))
164        .map(PathBuf::from)
165        .collect();
166    Ok(paths)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::fs;
173    use tempfile::TempDir;
174
175    /// Create a real git repo with an initial commit.
176    fn init_repo(path: &Path) {
177        fs::create_dir_all(path).unwrap();
178        git(path, &["init"]).unwrap();
179        git(path, &["config", "user.email", "test@test.com"]).unwrap();
180        git(path, &["config", "user.name", "Test"]).unwrap();
181        // Create initial commit so we have a branch
182        fs::write(path.join("README.md"), "# test").unwrap();
183        git(path, &["add", "."]).unwrap();
184        git(path, &["commit", "-m", "init"]).unwrap();
185    }
186
187    #[test]
188    fn test_repo_root_regular_repo() {
189        let tmp = TempDir::new().unwrap();
190        let repo = tmp.path().join("myrepo");
191        init_repo(&repo);
192
193        let root = repo_root(&repo).unwrap();
194        assert_eq!(root.canonicalize().unwrap(), repo.canonicalize().unwrap());
195    }
196
197    #[test]
198    fn test_repo_root_from_worktree() {
199        let tmp = TempDir::new().unwrap();
200        let repo = tmp.path().join("myrepo");
201        init_repo(&repo);
202
203        let wt_path = tmp.path().join("myrepo--feature");
204        git(
205            &repo,
206            &[
207                "worktree",
208                "add",
209                "-b",
210                "feature",
211                &wt_path.to_string_lossy(),
212            ],
213        )
214        .unwrap();
215
216        let root = repo_root(&wt_path).unwrap();
217        assert_eq!(root.canonicalize().unwrap(), repo.canonicalize().unwrap());
218    }
219
220    #[test]
221    fn test_local_branch_exists() {
222        let tmp = TempDir::new().unwrap();
223        let repo = tmp.path().join("repo");
224        init_repo(&repo);
225
226        // The default branch (main or master) should exist
227        let default = git(&repo, &["branch", "--show-current"]).unwrap();
228        assert!(local_branch_exists(&repo, &default).unwrap());
229        assert!(!local_branch_exists(&repo, "nonexistent-branch").unwrap());
230    }
231
232    #[test]
233    fn test_local_branch_exists_after_create() {
234        let tmp = TempDir::new().unwrap();
235        let repo = tmp.path().join("repo");
236        init_repo(&repo);
237
238        git(&repo, &["branch", "feature-x"]).unwrap();
239        assert!(local_branch_exists(&repo, "feature-x").unwrap());
240    }
241
242    #[test]
243    fn test_default_branch_fallback_to_main() {
244        let tmp = TempDir::new().unwrap();
245        let repo = tmp.path().join("repo");
246        init_repo(&repo);
247        // Ensure we're on main
248        let _ = git(&repo, &["branch", "-M", "main"]);
249
250        let branch = default_branch(&repo).unwrap();
251        assert!(branch == "main" || branch == "master");
252    }
253
254    #[test]
255    fn test_worktree_add_and_list() {
256        let tmp = TempDir::new().unwrap();
257        let repo = tmp.path().join("repo");
258        init_repo(&repo);
259
260        let wt_path = tmp.path().join("repo--feature");
261        worktree_add_new_branch(&repo, &wt_path, "feature", "HEAD").unwrap();
262
263        let worktrees = worktree_list(&repo).unwrap();
264        assert!(worktrees.len() >= 2); // main + the new worktree
265        let wt_canonical = wt_path.canonicalize().unwrap();
266        assert!(
267            worktrees
268                .iter()
269                .any(|p| p.canonicalize().unwrap_or_default() == wt_canonical),
270            "worktree list should contain the new worktree"
271        );
272    }
273
274    #[test]
275    fn test_worktree_add_existing_branch() {
276        let tmp = TempDir::new().unwrap();
277        let repo = tmp.path().join("repo");
278        init_repo(&repo);
279
280        git(&repo, &["branch", "existing-branch"]).unwrap();
281
282        let wt_path = tmp.path().join("repo--existing");
283        worktree_add(&repo, &wt_path, "existing-branch").unwrap();
284
285        assert!(wt_path.exists());
286        let worktrees = worktree_list(&repo).unwrap();
287        assert!(worktrees.len() >= 2);
288    }
289
290    #[test]
291    fn test_worktree_remove() {
292        let tmp = TempDir::new().unwrap();
293        let repo = tmp.path().join("repo");
294        init_repo(&repo);
295
296        let wt_path = tmp.path().join("repo--feature");
297        worktree_add_new_branch(&repo, &wt_path, "feature", "HEAD").unwrap();
298        assert!(wt_path.exists());
299
300        worktree_remove(&repo, &wt_path).unwrap();
301        assert!(!wt_path.exists());
302    }
303
304    #[test]
305    fn test_fetch_no_remote() {
306        let tmp = TempDir::new().unwrap();
307        let repo = tmp.path().join("repo");
308        init_repo(&repo);
309
310        // Should not error even without a remote
311        fetch(&repo).unwrap();
312    }
313}