Skip to main content

ag_git/
worktree.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use tokio::task::spawn_blocking;
5
6use super::error::GitError;
7use super::repo::{main_repo_root_sync, resolve_git_dir, run_git_command_sync};
8
9/// Detects git repository information for the given directory.
10/// Returns the current branch name if in a git repository, None otherwise.
11pub async fn detect_git_info(dir: PathBuf) -> Option<String> {
12    spawn_blocking(move || detect_git_info_sync(&dir))
13        .await
14        .ok()
15        .flatten()
16}
17
18/// Walks up the directory tree to find a .git directory.
19/// Returns the directory containing .git (the repository root) if found, None
20/// otherwise.
21pub async fn find_git_repo_root(dir: PathBuf) -> Option<PathBuf> {
22    spawn_blocking(move || find_git_repo_root_sync(&dir))
23        .await
24        .ok()
25        .flatten()
26}
27
28/// Creates a git worktree at the specified path with a new branch.
29///
30/// # Arguments
31/// * `repo_path` - Path to the git repository root
32/// * `worktree_path` - Path where the worktree should be created
33/// * `branch_name` - Name of the new branch to create
34/// * `start_ref` - Git ref used as the new worktree branch starting point
35///
36/// # Returns
37/// Ok(()) on success, Err([`GitError`]) on failure.
38///
39/// # Errors
40/// Returns [`GitError::CommandFailed`] if spawning fails or the worktree
41/// command exits with a non-zero status.
42pub async fn create_worktree(
43    repo_path: PathBuf,
44    worktree_path: PathBuf,
45    branch_name: String,
46    start_ref: String,
47) -> Result<(), GitError> {
48    spawn_blocking(move || {
49        let worktree_path = worktree_path.to_string_lossy().to_string();
50        run_git_command_sync(
51            &repo_path,
52            &[
53                "worktree",
54                "add",
55                "-b",
56                branch_name.as_str(),
57                worktree_path.as_str(),
58                start_ref.as_str(),
59            ],
60            "Git worktree command failed",
61        )?;
62
63        Ok(())
64    })
65    .await?
66}
67
68/// Removes a git worktree at the specified path.
69///
70/// Uses --force to remove even with uncommitted changes.
71/// Finds the main repository by comparing git-dir and git-common-dir.
72///
73/// # Arguments
74/// * `worktree_path` - Path to the worktree to remove
75///
76/// # Returns
77/// Ok(()) on success, Err([`GitError`]) on failure.
78///
79/// # Errors
80/// Returns [`GitError::CommandFailed`] if spawning fails or the worktree
81/// remove command exits with a non-zero status.
82pub async fn remove_worktree(worktree_path: PathBuf) -> Result<(), GitError> {
83    spawn_blocking(move || {
84        let repo_root = main_repo_root_sync(&worktree_path)?;
85        let worktree_path = worktree_path.to_string_lossy().to_string();
86        run_git_command_sync(
87            &repo_root,
88            &["worktree", "remove", "--force", worktree_path.as_str()],
89            "Git worktree command failed",
90        )?;
91
92        Ok(())
93    })
94    .await?
95}
96
97/// Returns branch information for a repository directory in synchronous code.
98pub(super) fn detect_git_info_sync(dir: &Path) -> Option<String> {
99    let repo_dir = find_git_repo(dir)?;
100
101    get_git_branch(&repo_dir)
102}
103
104/// Legacy alias for `find_git_repo_root`, kept for internal use.
105fn find_git_repo(dir: &Path) -> Option<PathBuf> {
106    find_git_repo_root_sync(dir)
107}
108
109/// Returns the repository root by searching upward for `.git`.
110fn find_git_repo_root_sync(dir: &Path) -> Option<PathBuf> {
111    let mut current = dir.to_path_buf();
112    loop {
113        let git_dir = current.join(".git");
114        if git_dir.exists() {
115            return Some(current);
116        }
117
118        if !current.pop() {
119            return None;
120        }
121    }
122}
123
124/// Reads `.git/HEAD` and extracts the current branch identifier.
125fn get_git_branch(repo_dir: &Path) -> Option<String> {
126    let git_dir = resolve_git_dir(repo_dir)?;
127    let head_path = git_dir.join("HEAD");
128    let content = fs::read_to_string(head_path).ok()?;
129    let content = content.trim();
130
131    if let Some(branch_ref) = content.strip_prefix("ref: refs/heads/") {
132        return Some(branch_ref.to_string());
133    }
134
135    if content.len() >= 7
136        && content
137            .chars()
138            .all(|character| character.is_ascii_hexdigit())
139    {
140        return Some(format!("HEAD@{}", &content[..7]));
141    }
142
143    None
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn find_git_repo_root_sync_finds_repo_at_current_dir() {
152        // Arrange
153        let temp_dir = tempfile::tempdir().expect("should create temp dir");
154        fs::create_dir(temp_dir.path().join(".git")).expect("should create .git");
155
156        // Act
157        let result = find_git_repo_root_sync(temp_dir.path());
158
159        // Assert
160        assert_eq!(result, Some(temp_dir.path().to_path_buf()));
161    }
162
163    #[test]
164    fn find_git_repo_root_sync_walks_up_to_parent() {
165        // Arrange
166        let temp_dir = tempfile::tempdir().expect("should create temp dir");
167        fs::create_dir(temp_dir.path().join(".git")).expect("should create .git");
168        let nested = temp_dir.path().join("src").join("lib");
169        fs::create_dir_all(&nested).expect("should create nested dirs");
170
171        // Act
172        let result = find_git_repo_root_sync(&nested);
173
174        // Assert
175        assert_eq!(result, Some(temp_dir.path().to_path_buf()));
176    }
177
178    #[test]
179    fn find_git_repo_root_sync_returns_none_when_no_git_dir() {
180        // Arrange
181        let temp_dir = tempfile::tempdir().expect("should create temp dir");
182
183        // Act
184        let result = find_git_repo_root_sync(temp_dir.path());
185
186        // Assert — walks up to filesystem root and returns None
187        assert!(result.is_none());
188    }
189
190    #[test]
191    fn get_git_branch_returns_branch_from_ref() {
192        // Arrange
193        let temp_dir = tempfile::tempdir().expect("should create temp dir");
194        let git_dir = temp_dir.path().join(".git");
195        fs::create_dir(&git_dir).expect("should create .git");
196        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("should write HEAD");
197
198        // Act
199        let result = get_git_branch(temp_dir.path());
200
201        // Assert
202        assert_eq!(result, Some("main".to_string()));
203    }
204
205    #[test]
206    fn get_git_branch_returns_detached_head_for_commit_hash() {
207        // Arrange
208        let temp_dir = tempfile::tempdir().expect("should create temp dir");
209        let git_dir = temp_dir.path().join(".git");
210        fs::create_dir(&git_dir).expect("should create .git");
211        fs::write(git_dir.join("HEAD"), "abc1234def5678\n").expect("should write HEAD");
212
213        // Act
214        let result = get_git_branch(temp_dir.path());
215
216        // Assert
217        assert_eq!(result, Some("HEAD@abc1234".to_string()));
218    }
219
220    #[test]
221    fn get_git_branch_returns_none_for_unrecognized_content() {
222        // Arrange
223        let temp_dir = tempfile::tempdir().expect("should create temp dir");
224        let git_dir = temp_dir.path().join(".git");
225        fs::create_dir(&git_dir).expect("should create .git");
226        fs::write(git_dir.join("HEAD"), "unknown\n").expect("should write HEAD");
227
228        // Act
229        let result = get_git_branch(temp_dir.path());
230
231        // Assert
232        assert!(result.is_none());
233    }
234
235    #[test]
236    fn get_git_branch_returns_none_when_no_git_dir() {
237        // Arrange
238        let temp_dir = tempfile::tempdir().expect("should create temp dir");
239
240        // Act
241        let result = get_git_branch(temp_dir.path());
242
243        // Assert
244        assert!(result.is_none());
245    }
246
247    #[test]
248    fn detect_git_info_sync_returns_branch_for_repo() {
249        // Arrange
250        let temp_dir = tempfile::tempdir().expect("should create temp dir");
251        let git_dir = temp_dir.path().join(".git");
252        fs::create_dir(&git_dir).expect("should create .git");
253        fs::write(git_dir.join("HEAD"), "ref: refs/heads/feature\n").expect("should write HEAD");
254
255        // Act
256        let result = detect_git_info_sync(temp_dir.path());
257
258        // Assert
259        assert_eq!(result, Some("feature".to_string()));
260    }
261
262    #[test]
263    fn detect_git_info_sync_returns_none_for_non_repo() {
264        // Arrange
265        let temp_dir = tempfile::tempdir().expect("should create temp dir");
266
267        // Act
268        let result = detect_git_info_sync(temp_dir.path());
269
270        // Assert
271        assert!(result.is_none());
272    }
273}