git_worktree_cli/core/
project.rs

1//! Project discovery and management utilities
2//!
3//! This module handles finding project roots, git directories, and managing
4//! project-related operations.
5
6use crate::error::{Error, Result};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Represents a git worktree project with its root and git directory
11#[derive(Debug, Clone)]
12pub struct Project {
13    /// The root directory containing git-worktree-config.jsonc
14    pub root: PathBuf,
15    /// The .git directory path
16    pub git_dir: PathBuf,
17}
18
19impl Project {
20    /// Find a project starting from the current directory
21    pub fn find() -> Result<Self> {
22        let root = find_project_root()?;
23        let git_dir = find_git_directory_from(&root)?;
24        Ok(Self { root, git_dir })
25    }
26
27    /// Find a project starting from a specific path
28    pub fn find_from(start_path: &Path) -> Result<Self> {
29        let root = find_project_root_from(start_path)?;
30        let git_dir = find_git_directory_from(&root)?;
31        Ok(Self { root, git_dir })
32    }
33
34    /// Get the bare repository directory (usually named after the main branch)
35    pub fn bare_repo_dir(&self) -> Result<PathBuf> {
36        find_existing_worktree(&self.root)
37    }
38}
39
40/// Find the project root containing git-worktree-config.jsonc
41pub fn find_project_root() -> Result<PathBuf> {
42    let current_dir = std::env::current_dir().map_err(Error::Io)?;
43    find_project_root_from(&current_dir)
44}
45
46/// Find the project root starting from a specific path
47pub fn find_project_root_from(start_path: &Path) -> Result<PathBuf> {
48    let mut search_path = start_path.to_path_buf();
49
50    loop {
51        // First check in current directory
52        if search_path.join("git-worktree-config.jsonc").exists() {
53            // Special case: if current directory is named "main" and contains the config,
54            // return the parent directory as the project root
55            if search_path.file_name().and_then(|n| n.to_str()) == Some("main") {
56                if let Some(parent) = search_path.parent() {
57                    return Ok(parent.to_path_buf());
58                }
59            }
60            return Ok(search_path);
61        }
62
63        // Then check in ./main/ subdirectory
64        // If found there, return the parent directory (project root), not ./main/ itself
65        if search_path.join("main").join("git-worktree-config.jsonc").exists() {
66            return Ok(search_path);
67        }
68
69        if !search_path.pop() {
70            break;
71        }
72    }
73
74    // Check if we're in a git repository but missing config
75    if let Ok(Some(_)) = crate::git::get_git_root() {
76        Err(Error::Other(
77            "Found git repository but no git-worktree-config.jsonc. This doesn't appear to be a worktree project."
78                .to_string(),
79        ))
80    } else {
81        Err(Error::ProjectRootNotFound)
82    }
83}
84
85/// Find the .git directory within a project
86pub fn find_git_directory() -> Result<PathBuf> {
87    let project_root = find_project_root()?;
88    find_git_directory_from(&project_root)
89}
90
91/// Find the .git directory starting from a specific path
92pub fn find_git_directory_from(project_root: &Path) -> Result<PathBuf> {
93    // First check if the project root itself has a .git directory
94    // This handles the case where config is inside main/ directory
95    if project_root.join(".git").exists() {
96        return Ok(project_root.to_path_buf());
97    }
98
99    let entries = fs::read_dir(project_root).map_err(Error::Io)?;
100
101    for entry in entries {
102        let entry = entry.map_err(Error::Io)?;
103        if entry.file_type().map_err(Error::Io)?.is_dir() {
104            let dir_path = entry.path();
105            if dir_path.join(".git").exists() {
106                // This is a git directory (worktree or regular repository)
107                return Ok(dir_path);
108            }
109        }
110    }
111
112    Err(Error::GitDirectoryNotFound)
113}
114
115/// Find an existing git directory (worktree or main repository)
116///
117/// This function looks for any directory with a .git file or directory,
118/// prioritizing worktrees (where .git is a file) over main repositories.
119pub fn find_existing_worktree(project_root: &Path) -> Result<PathBuf> {
120    // First check if the project root itself has a .git directory
121    // This handles the case where config is inside main/ directory
122    let root_git_path = project_root.join(".git");
123    if root_git_path.exists() {
124        if root_git_path.is_file() {
125            // Project root is a worktree
126            return Ok(project_root.to_path_buf());
127        } else if root_git_path.is_dir() {
128            // Project root is a main repository - save as fallback
129            // But continue checking subdirectories for worktrees first
130        }
131    }
132
133    let entries = fs::read_dir(project_root).map_err(Error::Io)?;
134
135    let mut main_repo: Option<PathBuf> = None;
136
137    // If project root has .git directory, use it as fallback
138    if root_git_path.exists() && root_git_path.is_dir() {
139        main_repo = Some(project_root.to_path_buf());
140    }
141
142    for entry in entries {
143        let entry = entry.map_err(Error::Io)?;
144        if entry.file_type().map_err(Error::Io)?.is_dir() {
145            let dir_path = entry.path();
146            let git_path = dir_path.join(".git");
147
148            if git_path.exists() {
149                if git_path.is_file() {
150                    // This is a worktree - prefer these over main repos
151                    return Ok(dir_path);
152                } else if git_path.is_dir() {
153                    // This is a main repository - save as fallback
154                    main_repo = Some(dir_path);
155                }
156            }
157        }
158    }
159
160    // If no worktree found, use main repository if available
161    main_repo.ok_or_else(|| {
162        Error::Other(format!(
163            "No existing git directory found in project at {}. Have you run 'gwt init' yet?",
164            project_root.display()
165        ))
166    })
167}
168
169/// Check if a path is an orphaned worktree
170///
171/// A worktree is orphaned if its .git file points to a non-existent gitdir path.
172/// This can happen when a repository is moved to a new location.
173pub fn is_orphaned_worktree(path: &Path) -> bool {
174    let git_file = path.join(".git");
175
176    // Check if .git is a file (worktree indicator)
177    if !git_file.is_file() {
178        return false;
179    }
180
181    // Read the .git file to get the gitdir path
182    let Ok(content) = fs::read_to_string(&git_file) else {
183        return false;
184    };
185
186    // Parse "gitdir: /path/to/gitdir"
187    let Some(gitdir_line) = content.lines().find(|line| line.starts_with("gitdir: ")) else {
188        return false;
189    };
190
191    let gitdir_path = gitdir_line.trim_start_matches("gitdir: ").trim();
192
193    // Check if the gitdir path exists
194    !Path::new(gitdir_path).exists()
195}
196
197/// Find a valid (non-orphaned) git directory in the project
198///
199/// This searches the project root for a git directory that is not orphaned.
200/// Useful when the current directory is an orphaned worktree.
201pub fn find_valid_git_directory(project_root: &Path) -> Result<PathBuf> {
202    // Check if project root itself has valid .git directory
203    let root_git_path = project_root.join(".git");
204    if root_git_path.is_dir() {
205        return Ok(project_root.to_path_buf());
206    }
207
208    // Search subdirectories for valid git directories
209    let entries = fs::read_dir(project_root).map_err(Error::Io)?;
210
211    for entry in entries {
212        let entry = entry.map_err(Error::Io)?;
213        if entry.file_type().map_err(Error::Io)?.is_dir() {
214            let dir_path = entry.path();
215            let git_path = dir_path.join(".git");
216
217            if git_path.is_dir() {
218                // Main repository - always valid
219                return Ok(dir_path);
220            } else if git_path.is_file() && !is_orphaned_worktree(&dir_path) {
221                // Valid worktree (not orphaned)
222                return Ok(dir_path);
223            }
224        }
225    }
226
227    Err(Error::Other(
228        "No valid git directory found in project".to_string()
229    ))
230}
231
232/// Clean a branch name by removing refs/heads/ prefix
233pub fn clean_branch_name(branch: &str) -> &str {
234    branch.trim().strip_prefix("refs/heads/").unwrap_or(branch.trim())
235}