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 anyhow::bail;
6use git2::ErrorCode;
7use git2::Repository;
8use git2::StatusOptions;
9use std::path::Path;
10use std::path::PathBuf;
11use tracing::debug;
12
13/// Get the current repository path, starting from current directory
14pub fn get_current_repo() -> Result<PathBuf> {
15    let current_dir = std::env::current_dir()?;
16    find_repo_root(&current_dir)
17}
18
19/// Find the repository root from a given path
20pub fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
21    let repo = Repository::discover(start_path).map_err(|_| ThoughtsError::NotInGitRepo)?;
22
23    let workdir = repo
24        .workdir()
25        .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
26
27    Ok(workdir.to_path_buf())
28}
29
30/// Check if a directory is a git worktree (not a submodule)
31///
32/// Worktrees have gitdir paths containing "/worktrees/".
33/// Submodules have gitdir paths containing "/modules/".
34pub fn is_worktree(repo_path: &Path) -> Result<bool> {
35    let git_path = repo_path.join(".git");
36    if git_path.is_file() {
37        let contents = std::fs::read_to_string(&git_path)?;
38        if let Some(gitdir_line) = contents
39            .lines()
40            .find(|l| l.trim_start().starts_with("gitdir:"))
41        {
42            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
43            // Worktrees have "/worktrees/" in the path, submodules have "/modules/"
44            let is_worktrees = gitdir.contains("/worktrees/");
45            let is_modules = gitdir.contains("/modules/");
46            if is_worktrees && !is_modules {
47                debug!("Found .git file with worktrees path, this is a worktree");
48                return Ok(true);
49            }
50        }
51    }
52    Ok(false)
53}
54
55/// Get the main repository path for a worktree
56///
57/// Handles both absolute and relative gitdir paths in the .git file.
58pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
59    // For a worktree, we need to find the main repository
60    // The .git file in a worktree contains: "gitdir: /path/to/main/.git/worktrees/name"
61    // or a relative path like: "gitdir: ../.git/worktrees/name"
62    let git_file = worktree_path.join(".git");
63    if git_file.is_file() {
64        let contents = std::fs::read_to_string(&git_file)?;
65        if let Some(gitdir_line) = contents
66            .lines()
67            .find(|l| l.trim_start().starts_with("gitdir:"))
68        {
69            let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
70            let mut gitdir_path = PathBuf::from(gitdir);
71
72            // Handle relative paths by resolving against worktree path
73            if !gitdir_path.is_absolute() {
74                gitdir_path = worktree_path.join(&gitdir_path);
75            }
76
77            // Canonicalize to resolve ".." components
78            let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
79
80            // Navigate from .git/worktrees/name to the main repo
81            if let Some(parent) = gitdir_path.parent()
82                && let Some(parent_parent) = parent.parent()
83                && parent_parent.ends_with(".git")
84                && let Some(main_repo) = parent_parent.parent()
85            {
86                debug!("Found main repo at: {:?}", main_repo);
87                return Ok(main_repo.to_path_buf());
88            }
89        }
90    }
91
92    // If we can't determine it from the .git file, fall back to the current repo
93    Ok(worktree_path.to_path_buf())
94}
95
96/// Get the control repository root (main repo for worktrees, repo root otherwise)
97/// This is the authoritative location for .thoughts/config.json and .thoughts-data
98pub fn get_control_repo_root(start_path: &Path) -> Result<PathBuf> {
99    let repo_root = find_repo_root(start_path)?;
100    if is_worktree(&repo_root)? {
101        // Best-effort: fall back to repo_root if main cannot be determined
102        Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
103    } else {
104        Ok(repo_root)
105    }
106}
107
108/// Get the control repository root for the current directory
109pub fn get_current_control_repo_root() -> Result<PathBuf> {
110    let cwd = std::env::current_dir()?;
111    get_control_repo_root(&cwd)
112}
113
114/// Check if a path is a git repository
115pub fn is_git_repo(path: &Path) -> bool {
116    Repository::open(path).is_ok()
117}
118
119/// Initialize a new git repository
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).map_err(|e| {
128        anyhow::anyhow!(
129            "Failed to open git repository at {}: {e}",
130            repo_path.display()
131        )
132    })?;
133
134    let remote = repo
135        .find_remote("origin")
136        .map_err(|_| anyhow::anyhow!("No 'origin' remote found"))?;
137
138    remote
139        .url()
140        .ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
141        .map(std::string::ToString::to_string)
142}
143
144/// Get the canonical identity of a repository's origin remote, if available.
145///
146/// Returns `Ok(Some(identity))` if the repo has an origin and it parses successfully,
147/// `Ok(None)` if the repo has no origin or it can't be parsed, or an error for
148/// other failures (permissions, corruption, etc.).
149pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
150    // TODO(2): Consider refactoring `get_remote_url()` to preserve `git2::Error` (ErrorCode)
151    // so callers can classify NotFound vs other failures without duplicating git2 logic.
152    let repo = Repository::open(repo_path)
153        .with_context(|| format!("Failed to open git repository at {}", repo_path.display()))?;
154
155    let remote = match repo.find_remote("origin") {
156        Ok(r) => r,
157        Err(e) if e.code() == ErrorCode::NotFound => return Ok(None),
158        Err(e) => {
159            return Err(anyhow::Error::from(e)).with_context(|| {
160                format!(
161                    "Failed to find 'origin' remote for git repository at {}",
162                    repo_path.display()
163                )
164            });
165        }
166    };
167
168    let Some(url) = remote.url() else {
169        return Ok(None);
170    };
171
172    Ok(RepoIdentity::parse(url).ok())
173}
174
175/// Represents the state of HEAD in a git repository
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum HeadState {
178    /// HEAD points to a branch that has commits
179    Attached(String),
180    /// HEAD points directly to a commit (detached HEAD)
181    Detached,
182    /// HEAD points to a branch that has no commits yet
183    Unborn(String),
184}
185
186/// Get the current HEAD state with full type safety
187pub fn get_head_state(repo_path: &Path) -> Result<HeadState> {
188    let repo = Repository::open(repo_path).map_err(|e| {
189        anyhow::anyhow!(
190            "Failed to open git repository at {}: {e}",
191            repo_path.display()
192        )
193    })?;
194
195    match repo.head() {
196        Ok(head) if head.is_branch() => Ok(HeadState::Attached(
197            head.shorthand().unwrap_or("unknown").to_string(),
198        )),
199        Ok(_) => Ok(HeadState::Detached),
200        Err(e) if e.code() == ErrorCode::UnbornBranch => {
201            // Extract branch name from symbolic HEAD
202            let head_ref = repo.find_reference("HEAD")?;
203            let name = head_ref.symbolic_target().map_or_else(
204                || "unknown".to_string(),
205                |s| s.strip_prefix("refs/heads/").unwrap_or(s).to_string(),
206            );
207            Ok(HeadState::Unborn(name))
208        }
209        Err(e) => Err(anyhow::anyhow!("Failed to get HEAD reference: {e}")),
210    }
211}
212
213/// Get the current branch name, or "detached" if in detached HEAD state.
214/// For unborn branches, returns an error with descriptive message.
215pub fn get_current_branch(repo_path: &Path) -> Result<String> {
216    match get_head_state(repo_path)? {
217        HeadState::Attached(name) => Ok(name),
218        HeadState::Detached => Ok("detached".to_string()),
219        HeadState::Unborn(name) => {
220            bail!("Branch '{name}' has no commits yet")
221        }
222    }
223}
224
225/// Returns Ok(()) if the repository is in a state suitable to begin a sync.
226///
227/// Authoritative sync preflight:
228/// - Rejects detached HEAD
229/// - Rejects in-progress merge/rebase/cherry-pick/revert operations
230/// - Allows unborn HEAD so bootstrap sync can create the first commit
231pub fn ensure_repo_ready_for_sync(repo_path: &Path) -> Result<()> {
232    let repo = Repository::open(repo_path).map_err(|e| {
233        anyhow::anyhow!(
234            "Failed to open git repository at {}: {e}",
235            repo_path.display()
236        )
237    })?;
238    let git_dir = repo.path();
239
240    if git_dir.join("MERGE_HEAD").exists() {
241        bail!("Repository has an in-progress merge. Complete or abort it before syncing.");
242    }
243    if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() {
244        bail!("Repository has an in-progress rebase. Complete or abort it before syncing.");
245    }
246    if git_dir.join("CHERRY_PICK_HEAD").exists() || git_dir.join("sequencer").exists() {
247        bail!("Repository has an in-progress cherry-pick. Complete or abort it before syncing.");
248    }
249    if git_dir.join("REVERT_HEAD").exists() {
250        bail!("Repository has an in-progress revert. Complete or abort it before syncing.");
251    }
252
253    match repo.head() {
254        Ok(head) if head.is_branch() => Ok(()),
255        Ok(_) => bail!("Repository is in detached HEAD state. Check out a branch before syncing."),
256        Err(e) if e.code() == ErrorCode::UnbornBranch => Ok(()),
257        Err(e) => bail!("Failed to get HEAD reference: {e}"),
258    }
259}
260
261/// Returns the current branch name or an error when sync is unsafe.
262pub fn get_sync_branch(repo_path: &Path) -> Result<String> {
263    match get_head_state(repo_path)? {
264        HeadState::Attached(name) | HeadState::Unborn(name) => Ok(name),
265        HeadState::Detached => {
266            bail!("Repository is in detached HEAD state. Check out a branch before syncing.")
267        }
268    }
269}
270
271/// Return true if the repository's working tree has any changes (including untracked)
272pub fn is_worktree_dirty(repo: &Repository) -> Result<bool> {
273    let mut opts = StatusOptions::new();
274    opts.include_untracked(true)
275        .recurse_untracked_dirs(true)
276        .exclude_submodules(true);
277    let statuses = repo.statuses(Some(&mut opts))?;
278    Ok(!statuses.is_empty())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use tempfile::TempDir;
285
286    #[test]
287    fn test_is_git_repo() {
288        let temp_dir = TempDir::new().unwrap();
289        let repo_path = temp_dir.path();
290
291        assert!(!is_git_repo(repo_path));
292
293        Repository::init(repo_path).unwrap();
294        assert!(is_git_repo(repo_path));
295    }
296
297    #[test]
298    fn test_get_current_branch() {
299        let temp_dir = TempDir::new().unwrap();
300        let repo_path = temp_dir.path();
301
302        // Initialize repo
303        let repo = Repository::init(repo_path).unwrap();
304
305        // Create initial commit so we have a proper HEAD
306        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
307        let tree_id = {
308            let mut index = repo.index().unwrap();
309            index.write_tree().unwrap()
310        };
311        let tree = repo.find_tree(tree_id).unwrap();
312        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
313            .unwrap();
314
315        // Should be on master or main (depending on git version)
316        let branch = get_current_branch(repo_path).unwrap();
317        assert!(branch == "master" || branch == "main");
318
319        // Create and checkout a feature branch
320        let head = repo.head().unwrap();
321        let commit = head.peel_to_commit().unwrap();
322        repo.branch("feature-branch", &commit, false).unwrap();
323        repo.set_head("refs/heads/feature-branch").unwrap();
324        repo.checkout_head(None).unwrap();
325
326        let branch = get_current_branch(repo_path).unwrap();
327        assert_eq!(branch, "feature-branch");
328
329        // Test detached HEAD
330        let commit_oid = commit.id();
331        repo.set_head_detached(commit_oid).unwrap();
332        let branch = get_current_branch(repo_path).unwrap();
333        assert_eq!(branch, "detached");
334    }
335
336    #[test]
337    fn test_get_head_state_unborn() {
338        let temp_dir = TempDir::new().unwrap();
339        let repo_path = temp_dir.path();
340
341        // Init repo without any commits
342        Repository::init(repo_path).unwrap();
343
344        // Should detect unborn branch
345        let state = get_head_state(repo_path).unwrap();
346        assert!(
347            matches!(state, HeadState::Unborn(_)),
348            "expected Unborn, got {state:?}"
349        );
350
351        // get_current_branch should return error for unborn
352        let err = get_current_branch(repo_path).unwrap_err();
353        assert!(err.to_string().contains("no commits yet"));
354
355        let HeadState::Unborn(unborn_name) = state else {
356            unreachable!()
357        };
358
359        assert_eq!(get_sync_branch(repo_path).unwrap(), unborn_name);
360    }
361
362    fn initial_commit(repo: &Repository) {
363        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
364        let tree_id = {
365            let mut idx = repo.index().unwrap();
366            idx.write_tree().unwrap()
367        };
368        let tree = repo.find_tree(tree_id).unwrap();
369        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
370            .unwrap();
371    }
372
373    #[test]
374    fn worktree_dirty_false_when_clean() {
375        let dir = tempfile::TempDir::new().unwrap();
376        let repo = Repository::init(dir.path()).unwrap();
377        initial_commit(&repo);
378        assert!(!is_worktree_dirty(&repo).unwrap());
379    }
380
381    #[test]
382    fn worktree_dirty_true_for_untracked() {
383        let dir = tempfile::TempDir::new().unwrap();
384        let repo = Repository::init(dir.path()).unwrap();
385        initial_commit(&repo);
386
387        let fpath = dir.path().join("untracked.txt");
388        std::fs::write(&fpath, "hello").unwrap();
389
390        assert!(is_worktree_dirty(&repo).unwrap());
391    }
392
393    #[test]
394    fn worktree_dirty_true_for_staged() {
395        use std::io::Write;
396        let dir = tempfile::TempDir::new().unwrap();
397        let repo = Repository::init(dir.path()).unwrap();
398        initial_commit(&repo);
399
400        let fpath = dir.path().join("file.txt");
401        {
402            let mut f = std::fs::File::create(&fpath).unwrap();
403            writeln!(f, "content").unwrap();
404        }
405        let mut idx = repo.index().unwrap();
406        idx.add_path(std::path::Path::new("file.txt")).unwrap();
407        idx.write().unwrap();
408
409        assert!(is_worktree_dirty(&repo).unwrap());
410    }
411
412    #[test]
413    fn try_get_origin_identity_some_when_origin_is_parseable() {
414        let dir = TempDir::new().unwrap();
415        let repo = Repository::init(dir.path()).unwrap();
416        repo.remote("origin", "https://github.com/org/repo.git")
417            .unwrap();
418
419        let expected = RepoIdentity::parse("https://github.com/org/repo.git")
420            .unwrap()
421            .canonical_key();
422        let actual = try_get_origin_identity(dir.path())
423            .unwrap()
424            .unwrap()
425            .canonical_key();
426
427        assert_eq!(actual, expected);
428    }
429
430    #[test]
431    fn try_get_origin_identity_none_when_no_origin_remote() {
432        let dir = TempDir::new().unwrap();
433        Repository::init(dir.path()).unwrap();
434
435        assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
436    }
437
438    #[test]
439    fn try_get_origin_identity_none_when_origin_url_unparseable() {
440        let dir = TempDir::new().unwrap();
441        let repo = Repository::init(dir.path()).unwrap();
442
443        // URL without org/repo structure won't parse as RepoIdentity
444        repo.remote("origin", "https://github.com").unwrap();
445
446        assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
447    }
448
449    #[test]
450    fn try_get_origin_identity_err_when_repo_cannot_be_opened() {
451        let dir = TempDir::new().unwrap();
452        let non_repo = dir.path().join("not-a-repo");
453        std::fs::create_dir_all(&non_repo).unwrap();
454
455        let err = try_get_origin_identity(&non_repo).unwrap_err();
456        assert!(err.to_string().contains("Failed to open git repository"));
457    }
458
459    #[test]
460    fn ensure_repo_ready_for_sync_rejects_merge_state() {
461        let dir = TempDir::new().unwrap();
462        let repo = Repository::init(dir.path()).unwrap();
463        std::fs::write(repo.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
464
465        let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
466        assert!(err.to_string().contains("in-progress merge"));
467    }
468
469    #[test]
470    fn ensure_repo_ready_for_sync_rejects_rebase_state() {
471        let dir = TempDir::new().unwrap();
472        let repo = Repository::init(dir.path()).unwrap();
473        std::fs::create_dir_all(repo.path().join("rebase-merge")).unwrap();
474
475        let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
476        assert!(err.to_string().contains("in-progress rebase"));
477    }
478
479    #[test]
480    fn ensure_repo_ready_for_sync_rejects_detached_head() {
481        let dir = TempDir::new().unwrap();
482        let repo = Repository::init(dir.path()).unwrap();
483
484        initial_commit(&repo);
485        let head_oid = repo.head().unwrap().target().unwrap();
486        repo.set_head_detached(head_oid).unwrap();
487
488        let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
489        assert!(err.to_string().contains("detached HEAD state"));
490    }
491
492    #[test]
493    fn ensure_repo_ready_for_sync_accepts_clean_repo() {
494        let dir = TempDir::new().unwrap();
495        Repository::init(dir.path()).unwrap();
496
497        ensure_repo_ready_for_sync(dir.path()).unwrap();
498    }
499
500    #[test]
501    fn get_sync_branch_rejects_detached_head() {
502        let temp_dir = TempDir::new().unwrap();
503        let repo_path = temp_dir.path();
504        let repo = Repository::init(repo_path).unwrap();
505
506        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
507        let tree_id = {
508            let mut index = repo.index().unwrap();
509            index.write_tree().unwrap()
510        };
511        let tree = repo.find_tree(tree_id).unwrap();
512        let commit_oid = repo
513            .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
514            .unwrap();
515        repo.set_head_detached(commit_oid).unwrap();
516
517        let err = get_sync_branch(repo_path).unwrap_err();
518        assert!(err.to_string().contains("detached HEAD state"));
519    }
520}