thoughts_tool/git/
utils.rs

1use crate::error::ThoughtsError;
2use anyhow::Result;
3use git2::Repository;
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7/// Get the current repository path, starting from current directory
8pub fn get_current_repo() -> Result<PathBuf> {
9    let current_dir = std::env::current_dir()?;
10    find_repo_root(&current_dir)
11}
12
13/// Find the repository root from a given path
14pub fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
15    let repo = Repository::discover(start_path).map_err(|_| ThoughtsError::NotInGitRepo)?;
16
17    let workdir = repo
18        .workdir()
19        .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
20
21    Ok(workdir.to_path_buf())
22}
23
24/// Check if a directory is a git worktree
25pub fn is_worktree(repo_path: &Path) -> Result<bool> {
26    let _repo = Repository::open(repo_path)?;
27
28    // Check if this is a linked worktree by examining the .git file
29    let git_path = repo_path.join(".git");
30    if git_path.is_file() {
31        // If .git is a file, it's a worktree
32        debug!("Found .git file, this is a worktree");
33        return Ok(true);
34    }
35
36    Ok(false)
37}
38
39/// Get the main repository path for a worktree
40pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
41    let _repo = Repository::open(worktree_path)?;
42
43    // For a worktree, we need to find the main repository
44    // The .git file in a worktree contains: "gitdir: /path/to/main/.git/worktrees/name"
45    let git_file = worktree_path.join(".git");
46    if git_file.is_file() {
47        let contents = std::fs::read_to_string(&git_file)?;
48        if let Some(gitdir_line) = contents.lines().find(|l| l.starts_with("gitdir:")) {
49            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
50            let gitdir_path = PathBuf::from(gitdir);
51
52            // Navigate from .git/worktrees/name to the main repo
53            if let Some(parent) = gitdir_path.parent()
54                && let Some(parent_parent) = parent.parent()
55                && parent_parent.ends_with(".git")
56                && let Some(main_repo) = parent_parent.parent()
57            {
58                debug!("Found main repo at: {:?}", main_repo);
59                return Ok(main_repo.to_path_buf());
60            }
61        }
62    }
63
64    // If we can't determine it from the .git file, fall back to the current repo
65    Ok(worktree_path.to_path_buf())
66}
67
68/// Get the control repository root (main repo for worktrees, repo root otherwise)
69/// This is the authoritative location for .thoughts/config.json and .thoughts-data
70pub fn get_control_repo_root(start_path: &Path) -> Result<PathBuf> {
71    let repo_root = find_repo_root(start_path)?;
72    if is_worktree(&repo_root)? {
73        // Best-effort: fall back to repo_root if main cannot be determined
74        Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
75    } else {
76        Ok(repo_root)
77    }
78}
79
80/// Get the control repository root for the current directory
81pub fn get_current_control_repo_root() -> Result<PathBuf> {
82    let cwd = std::env::current_dir()?;
83    get_control_repo_root(&cwd)
84}
85
86/// Check if a path is a git repository
87pub fn is_git_repo(path: &Path) -> bool {
88    Repository::open(path).is_ok()
89}
90
91/// Initialize a new git repository
92#[allow(dead_code)]
93// TODO(2): Plan initialization architecture for consumer vs source repos
94pub fn init_repo(path: &Path) -> Result<Repository> {
95    Ok(Repository::init(path)?)
96}
97
98/// Get the remote URL for a git repository
99pub fn get_remote_url(repo_path: &Path) -> Result<String> {
100    let repo = Repository::open(repo_path)
101        .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
102
103    let remote = repo
104        .find_remote("origin")
105        .map_err(|_| anyhow::anyhow!("No 'origin' remote found"))?;
106
107    remote
108        .url()
109        .ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
110        .map(|s| s.to_string())
111}
112
113/// Get the current branch name, or "detached" if in detached HEAD state
114pub fn get_current_branch(repo_path: &Path) -> Result<String> {
115    let repo = Repository::open(repo_path)
116        .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
117
118    let head = repo
119        .head()
120        .map_err(|e| anyhow::anyhow!("Failed to get HEAD reference: {}", e))?;
121
122    if head.is_branch() {
123        Ok(head.shorthand().unwrap_or("unknown").to_string())
124    } else {
125        Ok("detached".to_string())
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use tempfile::TempDir;
133
134    #[test]
135    fn test_is_git_repo() {
136        let temp_dir = TempDir::new().unwrap();
137        let repo_path = temp_dir.path();
138
139        assert!(!is_git_repo(repo_path));
140
141        Repository::init(repo_path).unwrap();
142        assert!(is_git_repo(repo_path));
143    }
144
145    #[test]
146    fn test_get_current_branch() {
147        let temp_dir = TempDir::new().unwrap();
148        let repo_path = temp_dir.path();
149
150        // Initialize repo
151        let repo = Repository::init(repo_path).unwrap();
152
153        // Create initial commit so we have a proper HEAD
154        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
155        let tree_id = {
156            let mut index = repo.index().unwrap();
157            index.write_tree().unwrap()
158        };
159        let tree = repo.find_tree(tree_id).unwrap();
160        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
161            .unwrap();
162
163        // Should be on master or main (depending on git version)
164        let branch = get_current_branch(repo_path).unwrap();
165        assert!(branch == "master" || branch == "main");
166
167        // Create and checkout a feature branch
168        let head = repo.head().unwrap();
169        let commit = head.peel_to_commit().unwrap();
170        repo.branch("feature-branch", &commit, false).unwrap();
171        repo.set_head("refs/heads/feature-branch").unwrap();
172        repo.checkout_head(None).unwrap();
173
174        let branch = get_current_branch(repo_path).unwrap();
175        assert_eq!(branch, "feature-branch");
176
177        // Test detached HEAD
178        let commit_oid = commit.id();
179        repo.set_head_detached(commit_oid).unwrap();
180        let branch = get_current_branch(repo_path).unwrap();
181        assert_eq!(branch, "detached");
182    }
183}