Skip to main content

thoughts_tool/git/
utils.rs

1use crate::error::ThoughtsError;
2use crate::repo_identity::RepoIdentity;
3use anyhow::{Context, Result};
4use git2::{ErrorCode, Repository, StatusOptions};
5use std::path::{Path, PathBuf};
6use tracing::debug;
7
8/// Get the current repository path, starting from current directory
9pub fn get_current_repo() -> Result<PathBuf> {
10    let current_dir = std::env::current_dir()?;
11    find_repo_root(&current_dir)
12}
13
14/// Find the repository root from a given path
15pub fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
16    let repo = Repository::discover(start_path).map_err(|_| ThoughtsError::NotInGitRepo)?;
17
18    let workdir = repo
19        .workdir()
20        .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
21
22    Ok(workdir.to_path_buf())
23}
24
25/// Check if a directory is a git worktree (not a submodule)
26///
27/// Worktrees have gitdir paths containing "/worktrees/".
28/// Submodules have gitdir paths containing "/modules/".
29pub fn is_worktree(repo_path: &Path) -> Result<bool> {
30    let git_path = repo_path.join(".git");
31    if git_path.is_file() {
32        let contents = std::fs::read_to_string(&git_path)?;
33        if let Some(gitdir_line) = contents
34            .lines()
35            .find(|l| l.trim_start().starts_with("gitdir:"))
36        {
37            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
38            // Worktrees have "/worktrees/" in the path, submodules have "/modules/"
39            let is_worktrees = gitdir.contains("/worktrees/");
40            let is_modules = gitdir.contains("/modules/");
41            if is_worktrees && !is_modules {
42                debug!("Found .git file with worktrees path, this is a worktree");
43                return Ok(true);
44            }
45        }
46    }
47    Ok(false)
48}
49
50/// Get the main repository path for a worktree
51///
52/// Handles both absolute and relative gitdir paths in the .git file.
53pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
54    // For a worktree, we need to find the main repository
55    // The .git file in a worktree contains: "gitdir: /path/to/main/.git/worktrees/name"
56    // or a relative path like: "gitdir: ../.git/worktrees/name"
57    let git_file = worktree_path.join(".git");
58    if git_file.is_file() {
59        let contents = std::fs::read_to_string(&git_file)?;
60        if let Some(gitdir_line) = contents
61            .lines()
62            .find(|l| l.trim_start().starts_with("gitdir:"))
63        {
64            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
65            let mut gitdir_path = PathBuf::from(gitdir);
66
67            // Handle relative paths by resolving against worktree path
68            if !gitdir_path.is_absolute() {
69                gitdir_path = worktree_path.join(&gitdir_path);
70            }
71
72            // Canonicalize to resolve ".." components
73            let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
74
75            // Navigate from .git/worktrees/name to the main repo
76            if let Some(parent) = gitdir_path.parent()
77                && let Some(parent_parent) = parent.parent()
78                && parent_parent.ends_with(".git")
79                && let Some(main_repo) = parent_parent.parent()
80            {
81                debug!("Found main repo at: {:?}", main_repo);
82                return Ok(main_repo.to_path_buf());
83            }
84        }
85    }
86
87    // If we can't determine it from the .git file, fall back to the current repo
88    Ok(worktree_path.to_path_buf())
89}
90
91/// Get the control repository root (main repo for worktrees, repo root otherwise)
92/// This is the authoritative location for .thoughts/config.json and .thoughts-data
93pub fn get_control_repo_root(start_path: &Path) -> Result<PathBuf> {
94    let repo_root = find_repo_root(start_path)?;
95    if is_worktree(&repo_root)? {
96        // Best-effort: fall back to repo_root if main cannot be determined
97        Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
98    } else {
99        Ok(repo_root)
100    }
101}
102
103/// Get the control repository root for the current directory
104pub fn get_current_control_repo_root() -> Result<PathBuf> {
105    let cwd = std::env::current_dir()?;
106    get_control_repo_root(&cwd)
107}
108
109/// Check if a path is a git repository
110pub fn is_git_repo(path: &Path) -> bool {
111    Repository::open(path).is_ok()
112}
113
114/// Initialize a new git repository
115#[allow(dead_code)]
116// TODO(2): Plan initialization architecture for consumer vs source repos
117pub fn init_repo(path: &Path) -> Result<Repository> {
118    Ok(Repository::init(path)?)
119}
120
121/// Get the remote URL for a git repository
122pub fn get_remote_url(repo_path: &Path) -> Result<String> {
123    let repo = Repository::open(repo_path)
124        .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
125
126    let remote = repo
127        .find_remote("origin")
128        .map_err(|_| anyhow::anyhow!("No 'origin' remote found"))?;
129
130    remote
131        .url()
132        .ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
133        .map(|s| s.to_string())
134}
135
136/// Get the canonical identity of a repository's origin remote, if available.
137///
138/// Returns `Ok(Some(identity))` if the repo has an origin and it parses successfully,
139/// `Ok(None)` if the repo has no origin or it can't be parsed, or an error for
140/// other failures (permissions, corruption, etc.).
141pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
142    // TODO(2): Consider refactoring `get_remote_url()` to preserve `git2::Error` (ErrorCode)
143    // so callers can classify NotFound vs other failures without duplicating git2 logic.
144    let repo = Repository::open(repo_path)
145        .with_context(|| format!("Failed to open git repository at {}", repo_path.display()))?;
146
147    let remote = match repo.find_remote("origin") {
148        Ok(r) => r,
149        Err(e) if e.code() == ErrorCode::NotFound => return Ok(None),
150        Err(e) => {
151            return Err(anyhow::Error::from(e)).with_context(|| {
152                format!(
153                    "Failed to find 'origin' remote for git repository at {}",
154                    repo_path.display()
155                )
156            });
157        }
158    };
159
160    let Some(url) = remote.url() else {
161        return Ok(None);
162    };
163
164    Ok(RepoIdentity::parse(url).ok())
165}
166
167/// Get the current branch name, or "detached" if in detached HEAD state
168pub fn get_current_branch(repo_path: &Path) -> Result<String> {
169    let repo = Repository::open(repo_path)
170        .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
171
172    let head = repo
173        .head()
174        .map_err(|e| anyhow::anyhow!("Failed to get HEAD reference: {}", e))?;
175
176    if head.is_branch() {
177        Ok(head.shorthand().unwrap_or("unknown").to_string())
178    } else {
179        Ok("detached".to_string())
180    }
181}
182
183/// Return true if the repository's working tree has any changes (including untracked)
184pub fn is_worktree_dirty(repo: &Repository) -> Result<bool> {
185    let mut opts = StatusOptions::new();
186    opts.include_untracked(true)
187        .recurse_untracked_dirs(true)
188        .exclude_submodules(true);
189    let statuses = repo.statuses(Some(&mut opts))?;
190    Ok(!statuses.is_empty())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use tempfile::TempDir;
197
198    #[test]
199    fn test_is_git_repo() {
200        let temp_dir = TempDir::new().unwrap();
201        let repo_path = temp_dir.path();
202
203        assert!(!is_git_repo(repo_path));
204
205        Repository::init(repo_path).unwrap();
206        assert!(is_git_repo(repo_path));
207    }
208
209    #[test]
210    fn test_get_current_branch() {
211        let temp_dir = TempDir::new().unwrap();
212        let repo_path = temp_dir.path();
213
214        // Initialize repo
215        let repo = Repository::init(repo_path).unwrap();
216
217        // Create initial commit so we have a proper HEAD
218        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
219        let tree_id = {
220            let mut index = repo.index().unwrap();
221            index.write_tree().unwrap()
222        };
223        let tree = repo.find_tree(tree_id).unwrap();
224        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
225            .unwrap();
226
227        // Should be on master or main (depending on git version)
228        let branch = get_current_branch(repo_path).unwrap();
229        assert!(branch == "master" || branch == "main");
230
231        // Create and checkout a feature branch
232        let head = repo.head().unwrap();
233        let commit = head.peel_to_commit().unwrap();
234        repo.branch("feature-branch", &commit, false).unwrap();
235        repo.set_head("refs/heads/feature-branch").unwrap();
236        repo.checkout_head(None).unwrap();
237
238        let branch = get_current_branch(repo_path).unwrap();
239        assert_eq!(branch, "feature-branch");
240
241        // Test detached HEAD
242        let commit_oid = commit.id();
243        repo.set_head_detached(commit_oid).unwrap();
244        let branch = get_current_branch(repo_path).unwrap();
245        assert_eq!(branch, "detached");
246    }
247
248    fn initial_commit(repo: &Repository) {
249        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
250        let tree_id = {
251            let mut idx = repo.index().unwrap();
252            idx.write_tree().unwrap()
253        };
254        let tree = repo.find_tree(tree_id).unwrap();
255        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
256            .unwrap();
257    }
258
259    #[test]
260    fn worktree_dirty_false_when_clean() {
261        let dir = tempfile::TempDir::new().unwrap();
262        let repo = Repository::init(dir.path()).unwrap();
263        initial_commit(&repo);
264        assert!(!is_worktree_dirty(&repo).unwrap());
265    }
266
267    #[test]
268    fn worktree_dirty_true_for_untracked() {
269        let dir = tempfile::TempDir::new().unwrap();
270        let repo = Repository::init(dir.path()).unwrap();
271        initial_commit(&repo);
272
273        let fpath = dir.path().join("untracked.txt");
274        std::fs::write(&fpath, "hello").unwrap();
275
276        assert!(is_worktree_dirty(&repo).unwrap());
277    }
278
279    #[test]
280    fn worktree_dirty_true_for_staged() {
281        use std::io::Write;
282        let dir = tempfile::TempDir::new().unwrap();
283        let repo = Repository::init(dir.path()).unwrap();
284        initial_commit(&repo);
285
286        let fpath = dir.path().join("file.txt");
287        {
288            let mut f = std::fs::File::create(&fpath).unwrap();
289            writeln!(f, "content").unwrap();
290        }
291        let mut idx = repo.index().unwrap();
292        idx.add_path(std::path::Path::new("file.txt")).unwrap();
293        idx.write().unwrap();
294
295        assert!(is_worktree_dirty(&repo).unwrap());
296    }
297
298    #[test]
299    fn try_get_origin_identity_some_when_origin_is_parseable() {
300        let dir = TempDir::new().unwrap();
301        let repo = Repository::init(dir.path()).unwrap();
302        repo.remote("origin", "https://github.com/org/repo.git")
303            .unwrap();
304
305        let expected = RepoIdentity::parse("https://github.com/org/repo.git")
306            .unwrap()
307            .canonical_key();
308        let actual = try_get_origin_identity(dir.path())
309            .unwrap()
310            .unwrap()
311            .canonical_key();
312
313        assert_eq!(actual, expected);
314    }
315
316    #[test]
317    fn try_get_origin_identity_none_when_no_origin_remote() {
318        let dir = TempDir::new().unwrap();
319        Repository::init(dir.path()).unwrap();
320
321        assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
322    }
323
324    #[test]
325    fn try_get_origin_identity_none_when_origin_url_unparseable() {
326        let dir = TempDir::new().unwrap();
327        let repo = Repository::init(dir.path()).unwrap();
328
329        // URL without org/repo structure won't parse as RepoIdentity
330        repo.remote("origin", "https://github.com").unwrap();
331
332        assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
333    }
334
335    #[test]
336    fn try_get_origin_identity_err_when_repo_cannot_be_opened() {
337        let dir = TempDir::new().unwrap();
338        let non_repo = dir.path().join("not-a-repo");
339        std::fs::create_dir_all(&non_repo).unwrap();
340
341        let err = try_get_origin_identity(&non_repo).unwrap_err();
342        assert!(err.to_string().contains("Failed to open git repository"));
343    }
344}