thoughts_tool/git/
utils.rs

1use crate::error::ThoughtsError;
2use anyhow::Result;
3use git2::{Repository, StatusOptions};
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 (not a submodule)
25///
26/// Worktrees have gitdir paths containing "/worktrees/".
27/// Submodules have gitdir paths containing "/modules/".
28pub fn is_worktree(repo_path: &Path) -> Result<bool> {
29    let git_path = repo_path.join(".git");
30    if git_path.is_file() {
31        let contents = std::fs::read_to_string(&git_path)?;
32        if let Some(gitdir_line) = contents
33            .lines()
34            .find(|l| l.trim_start().starts_with("gitdir:"))
35        {
36            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
37            // Worktrees have "/worktrees/" in the path, submodules have "/modules/"
38            let is_worktrees = gitdir.contains("/worktrees/");
39            let is_modules = gitdir.contains("/modules/");
40            if is_worktrees && !is_modules {
41                debug!("Found .git file with worktrees path, this is a worktree");
42                return Ok(true);
43            }
44        }
45    }
46    Ok(false)
47}
48
49/// Get the main repository path for a worktree
50///
51/// Handles both absolute and relative gitdir paths in the .git file.
52pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
53    // For a worktree, we need to find the main repository
54    // The .git file in a worktree contains: "gitdir: /path/to/main/.git/worktrees/name"
55    // or a relative path like: "gitdir: ../.git/worktrees/name"
56    let git_file = worktree_path.join(".git");
57    if git_file.is_file() {
58        let contents = std::fs::read_to_string(&git_file)?;
59        if let Some(gitdir_line) = contents
60            .lines()
61            .find(|l| l.trim_start().starts_with("gitdir:"))
62        {
63            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
64            let mut gitdir_path = PathBuf::from(gitdir);
65
66            // Handle relative paths by resolving against worktree path
67            if !gitdir_path.is_absolute() {
68                gitdir_path = worktree_path.join(&gitdir_path);
69            }
70
71            // Canonicalize to resolve ".." components
72            let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
73
74            // Navigate from .git/worktrees/name to the main repo
75            if let Some(parent) = gitdir_path.parent()
76                && let Some(parent_parent) = parent.parent()
77                && parent_parent.ends_with(".git")
78                && let Some(main_repo) = parent_parent.parent()
79            {
80                debug!("Found main repo at: {:?}", main_repo);
81                return Ok(main_repo.to_path_buf());
82            }
83        }
84    }
85
86    // If we can't determine it from the .git file, fall back to the current repo
87    Ok(worktree_path.to_path_buf())
88}
89
90/// Get the control repository root (main repo for worktrees, repo root otherwise)
91/// This is the authoritative location for .thoughts/config.json and .thoughts-data
92pub fn get_control_repo_root(start_path: &Path) -> Result<PathBuf> {
93    let repo_root = find_repo_root(start_path)?;
94    if is_worktree(&repo_root)? {
95        // Best-effort: fall back to repo_root if main cannot be determined
96        Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
97    } else {
98        Ok(repo_root)
99    }
100}
101
102/// Get the control repository root for the current directory
103pub fn get_current_control_repo_root() -> Result<PathBuf> {
104    let cwd = std::env::current_dir()?;
105    get_control_repo_root(&cwd)
106}
107
108/// Check if a path is a git repository
109pub fn is_git_repo(path: &Path) -> bool {
110    Repository::open(path).is_ok()
111}
112
113/// Initialize a new git repository
114#[allow(dead_code)]
115// TODO(2): Plan initialization architecture for consumer vs source repos
116pub fn init_repo(path: &Path) -> Result<Repository> {
117    Ok(Repository::init(path)?)
118}
119
120/// Get the remote URL for a git repository
121pub fn get_remote_url(repo_path: &Path) -> Result<String> {
122    let repo = Repository::open(repo_path)
123        .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
124
125    let remote = repo
126        .find_remote("origin")
127        .map_err(|_| anyhow::anyhow!("No 'origin' remote found"))?;
128
129    remote
130        .url()
131        .ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
132        .map(|s| s.to_string())
133}
134
135/// Get the current branch name, or "detached" if in detached HEAD state
136pub fn get_current_branch(repo_path: &Path) -> Result<String> {
137    let repo = Repository::open(repo_path)
138        .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
139
140    let head = repo
141        .head()
142        .map_err(|e| anyhow::anyhow!("Failed to get HEAD reference: {}", e))?;
143
144    if head.is_branch() {
145        Ok(head.shorthand().unwrap_or("unknown").to_string())
146    } else {
147        Ok("detached".to_string())
148    }
149}
150
151/// Return true if the repository's working tree has any changes (including untracked)
152pub fn is_worktree_dirty(repo: &Repository) -> Result<bool> {
153    let mut opts = StatusOptions::new();
154    opts.include_untracked(true)
155        .recurse_untracked_dirs(true)
156        .exclude_submodules(true);
157    let statuses = repo.statuses(Some(&mut opts))?;
158    Ok(!statuses.is_empty())
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use tempfile::TempDir;
165
166    #[test]
167    fn test_is_git_repo() {
168        let temp_dir = TempDir::new().unwrap();
169        let repo_path = temp_dir.path();
170
171        assert!(!is_git_repo(repo_path));
172
173        Repository::init(repo_path).unwrap();
174        assert!(is_git_repo(repo_path));
175    }
176
177    #[test]
178    fn test_get_current_branch() {
179        let temp_dir = TempDir::new().unwrap();
180        let repo_path = temp_dir.path();
181
182        // Initialize repo
183        let repo = Repository::init(repo_path).unwrap();
184
185        // Create initial commit so we have a proper HEAD
186        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
187        let tree_id = {
188            let mut index = repo.index().unwrap();
189            index.write_tree().unwrap()
190        };
191        let tree = repo.find_tree(tree_id).unwrap();
192        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
193            .unwrap();
194
195        // Should be on master or main (depending on git version)
196        let branch = get_current_branch(repo_path).unwrap();
197        assert!(branch == "master" || branch == "main");
198
199        // Create and checkout a feature branch
200        let head = repo.head().unwrap();
201        let commit = head.peel_to_commit().unwrap();
202        repo.branch("feature-branch", &commit, false).unwrap();
203        repo.set_head("refs/heads/feature-branch").unwrap();
204        repo.checkout_head(None).unwrap();
205
206        let branch = get_current_branch(repo_path).unwrap();
207        assert_eq!(branch, "feature-branch");
208
209        // Test detached HEAD
210        let commit_oid = commit.id();
211        repo.set_head_detached(commit_oid).unwrap();
212        let branch = get_current_branch(repo_path).unwrap();
213        assert_eq!(branch, "detached");
214    }
215
216    fn initial_commit(repo: &Repository) {
217        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
218        let tree_id = {
219            let mut idx = repo.index().unwrap();
220            idx.write_tree().unwrap()
221        };
222        let tree = repo.find_tree(tree_id).unwrap();
223        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
224            .unwrap();
225    }
226
227    #[test]
228    fn worktree_dirty_false_when_clean() {
229        let dir = tempfile::TempDir::new().unwrap();
230        let repo = Repository::init(dir.path()).unwrap();
231        initial_commit(&repo);
232        assert!(!is_worktree_dirty(&repo).unwrap());
233    }
234
235    #[test]
236    fn worktree_dirty_true_for_untracked() {
237        let dir = tempfile::TempDir::new().unwrap();
238        let repo = Repository::init(dir.path()).unwrap();
239        initial_commit(&repo);
240
241        let fpath = dir.path().join("untracked.txt");
242        std::fs::write(&fpath, "hello").unwrap();
243
244        assert!(is_worktree_dirty(&repo).unwrap());
245    }
246
247    #[test]
248    fn worktree_dirty_true_for_staged() {
249        use std::io::Write;
250        let dir = tempfile::TempDir::new().unwrap();
251        let repo = Repository::init(dir.path()).unwrap();
252        initial_commit(&repo);
253
254        let fpath = dir.path().join("file.txt");
255        {
256            let mut f = std::fs::File::create(&fpath).unwrap();
257            writeln!(f, "content").unwrap();
258        }
259        let mut idx = repo.index().unwrap();
260        idx.add_path(std::path::Path::new("file.txt")).unwrap();
261        idx.write().unwrap();
262
263        assert!(is_worktree_dirty(&repo).unwrap());
264    }
265}