1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5pub 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
14fn 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
30pub fn toplevel(dir: &Path) -> Result<PathBuf> {
33 let tl = git(dir, &["rev-parse", "--show-toplevel"])?;
34 Ok(PathBuf::from(tl))
35}
36
37pub 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 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
59pub fn fetch(dir: &Path) -> Result<()> {
61 let _ = git(dir, &["fetch", "--quiet"]);
63 Ok(())
64}
65
66pub 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
75pub 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
88pub fn default_branch(dir: &Path) -> Result<String> {
90 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 if remote_branch_exists(dir, "main")? || local_branch_exists(dir, "main")? {
99 return Ok("main".to_string());
100 }
101
102 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
110pub fn worktree_add(dir: &Path, target: &Path, branch: &str) -> Result<()> {
112 git(dir, &["worktree", "add", &target.to_string_lossy(), branch])?;
113 Ok(())
114}
115
116pub 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
137pub fn worktree_remove(dir: &Path, target: &Path) -> Result<()> {
139 git(dir, &["worktree", "remove", &target.to_string_lossy()])?;
140 Ok(())
141}
142
143pub 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
152pub fn delete_branch(dir: &Path, name: &str) -> Result<()> {
154 git(dir, &["branch", "-d", name])?;
155 Ok(())
156}
157
158pub 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
170pub 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 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 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 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 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); 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 git(&repo, &["branch", "merged-branch"]).unwrap();
325 assert!(is_branch_merged(&repo, "merged-branch", &default).unwrap());
326
327 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 fetch(&repo).unwrap();
345 }
346}