Skip to main content

thoughts_tool/git/
utils.rs

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