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 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 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 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 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 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); 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 fetch(&repo).unwrap();
312 }
313}