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()
141        .and_then(|url| (!url.is_empty()).then_some(url))
142        .ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
143        .map(std::string::ToString::to_string)
144}
145
146/// Get the canonical identity of a repository's origin remote, if available.
147///
148/// Returns `Ok(Some(identity))` if the repo has an origin and it parses successfully,
149/// `Ok(None)` if the repo has no origin or it can't be parsed, or an error for
150/// other failures (permissions, corruption, etc.).
151pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
152    // TODO(2): Consider refactoring `get_remote_url()` to preserve `git2::Error` (ErrorCode)
153    // so callers can classify NotFound vs other failures without duplicating git2 logic.
154    let repo = Repository::open(repo_path)
155        .with_context(|| format!("Failed to open git repository at {}", repo_path.display()))?;
156
157    let remote = match repo.find_remote("origin") {
158        Ok(r) => r,
159        Err(e) if e.code() == ErrorCode::NotFound => return Ok(None),
160        Err(e) => {
161            return Err(anyhow::Error::from(e)).with_context(|| {
162                format!(
163                    "Failed to find 'origin' remote for git repository at {}",
164                    repo_path.display()
165                )
166            });
167        }
168    };
169
170    let Some(url) = remote.url().ok().filter(|url| !url.is_empty()) else {
171        return Ok(None);
172    };
173
174    Ok(RepoIdentity::parse(url).ok())
175}
176
177/// Represents the state of HEAD in a git repository
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum HeadState {
180    /// HEAD points to a branch that has commits
181    Attached(String),
182    /// HEAD points directly to a commit (detached HEAD)
183    Detached,
184    /// HEAD points to a branch that has no commits yet
185    Unborn(String),
186}
187
188/// Get the current HEAD state with full type safety
189pub fn get_head_state(repo_path: &Path) -> Result<HeadState> {
190    let repo = Repository::open(repo_path).map_err(|e| {
191        anyhow::anyhow!(
192            "Failed to open git repository at {}: {e}",
193            repo_path.display()
194        )
195    })?;
196
197    match repo.head() {
198        Ok(head) if head.is_branch() => Ok(HeadState::Attached(
199            head.shorthand().unwrap_or("unknown").to_string(),
200        )),
201        Ok(_) => Ok(HeadState::Detached),
202        Err(e) if e.code() == ErrorCode::UnbornBranch => {
203            // Extract branch name from symbolic HEAD
204            let head_ref = repo.find_reference("HEAD")?;
205            let name = head_ref.symbolic_target().ok().flatten().map_or_else(
206                || "unknown".to_string(),
207                |s| s.strip_prefix("refs/heads/").unwrap_or(s).to_string(),
208            );
209            Ok(HeadState::Unborn(name))
210        }
211        Err(e) => Err(anyhow::anyhow!("Failed to get HEAD reference: {e}")),
212    }
213}
214
215/// Get the current branch name, or "detached" if in detached HEAD state.
216/// For unborn branches, returns an error with descriptive message.
217pub fn get_current_branch(repo_path: &Path) -> Result<String> {
218    match get_head_state(repo_path)? {
219        HeadState::Attached(name) => Ok(name),
220        HeadState::Detached => Ok("detached".to_string()),
221        HeadState::Unborn(name) => {
222            bail!("Branch '{name}' has no commits yet")
223        }
224    }
225}
226
227/// Returns Ok(()) if the repository is in a state suitable to begin a sync.
228///
229/// Authoritative sync preflight:
230/// - Rejects detached HEAD
231/// - Rejects in-progress merge/rebase/cherry-pick/revert operations
232/// - Allows unborn HEAD so bootstrap sync can create the first commit
233pub fn ensure_repo_ready_for_sync(repo_path: &Path) -> Result<()> {
234    let repo = Repository::open(repo_path).map_err(|e| {
235        anyhow::anyhow!(
236            "Failed to open git repository at {}: {e}",
237            repo_path.display()
238        )
239    })?;
240    let git_dir = repo.path();
241
242    if git_dir.join("MERGE_HEAD").exists() {
243        bail!("Repository has an in-progress merge. Complete or abort it before syncing.");
244    }
245    if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() {
246        bail!("Repository has an in-progress rebase. Complete or abort it before syncing.");
247    }
248    if git_dir.join("CHERRY_PICK_HEAD").exists() || git_dir.join("sequencer").exists() {
249        bail!("Repository has an in-progress cherry-pick. Complete or abort it before syncing.");
250    }
251    if git_dir.join("REVERT_HEAD").exists() {
252        bail!("Repository has an in-progress revert. Complete or abort it before syncing.");
253    }
254
255    match repo.head() {
256        Ok(head) if head.is_branch() => Ok(()),
257        Ok(_) => bail!("Repository is in detached HEAD state. Check out a branch before syncing."),
258        Err(e) if e.code() == ErrorCode::UnbornBranch => Ok(()),
259        Err(e) => bail!("Failed to get HEAD reference: {e}"),
260    }
261}
262
263/// Returns the current branch name or an error when sync is unsafe.
264pub fn get_sync_branch(repo_path: &Path) -> Result<String> {
265    match get_head_state(repo_path)? {
266        HeadState::Attached(name) | HeadState::Unborn(name) => Ok(name),
267        HeadState::Detached => {
268            bail!("Repository is in detached HEAD state. Check out a branch before syncing.")
269        }
270    }
271}
272
273/// Return true if the repository's working tree has any changes (including untracked)
274pub fn is_worktree_dirty(repo: &Repository) -> Result<bool> {
275    let mut opts = StatusOptions::new();
276    opts.include_untracked(true)
277        .recurse_untracked_dirs(true)
278        .exclude_submodules(true);
279    let statuses = repo.statuses(Some(&mut opts))?;
280    Ok(!statuses.is_empty())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use tempfile::TempDir;
287
288    #[test]
289    fn test_is_git_repo() {
290        let temp_dir = TempDir::new().unwrap();
291        let repo_path = temp_dir.path();
292
293        assert!(!is_git_repo(repo_path));
294
295        Repository::init(repo_path).unwrap();
296        assert!(is_git_repo(repo_path));
297    }
298
299    #[test]
300    fn test_get_current_branch() {
301        let temp_dir = TempDir::new().unwrap();
302        let repo_path = temp_dir.path();
303
304        // Initialize repo
305        let repo = Repository::init(repo_path).unwrap();
306
307        // Create initial commit so we have a proper HEAD
308        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
309        let tree_id = {
310            let mut index = repo.index().unwrap();
311            index.write_tree().unwrap()
312        };
313        let tree = repo.find_tree(tree_id).unwrap();
314        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
315            .unwrap();
316
317        // Should be on master or main (depending on git version)
318        let branch = get_current_branch(repo_path).unwrap();
319        assert!(branch == "master" || branch == "main");
320
321        // Create and checkout a feature branch
322        let head = repo.head().unwrap();
323        let commit = head.peel_to_commit().unwrap();
324        repo.branch("feature-branch", &commit, false).unwrap();
325        repo.set_head("refs/heads/feature-branch").unwrap();
326        repo.checkout_head(None).unwrap();
327
328        let branch = get_current_branch(repo_path).unwrap();
329        assert_eq!(branch, "feature-branch");
330
331        // Test detached HEAD
332        let commit_oid = commit.id();
333        repo.set_head_detached(commit_oid).unwrap();
334        let branch = get_current_branch(repo_path).unwrap();
335        assert_eq!(branch, "detached");
336    }
337
338    #[test]
339    fn test_get_head_state_unborn() {
340        let temp_dir = TempDir::new().unwrap();
341        let repo_path = temp_dir.path();
342
343        // Init repo without any commits
344        Repository::init(repo_path).unwrap();
345
346        // Should detect unborn branch
347        let state = get_head_state(repo_path).unwrap();
348        assert!(
349            matches!(state, HeadState::Unborn(_)),
350            "expected Unborn, got {state:?}"
351        );
352
353        // get_current_branch should return error for unborn
354        let err = get_current_branch(repo_path).unwrap_err();
355        assert!(err.to_string().contains("no commits yet"));
356
357        let HeadState::Unborn(unborn_name) = state else {
358            unreachable!()
359        };
360
361        assert_eq!(get_sync_branch(repo_path).unwrap(), unborn_name);
362    }
363
364    fn initial_commit(repo: &Repository) {
365        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
366        let tree_id = {
367            let mut idx = repo.index().unwrap();
368            idx.write_tree().unwrap()
369        };
370        let tree = repo.find_tree(tree_id).unwrap();
371        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
372            .unwrap();
373    }
374
375    #[test]
376    fn worktree_dirty_false_when_clean() {
377        let dir = tempfile::TempDir::new().unwrap();
378        let repo = Repository::init(dir.path()).unwrap();
379        initial_commit(&repo);
380        assert!(!is_worktree_dirty(&repo).unwrap());
381    }
382
383    #[test]
384    fn worktree_dirty_true_for_untracked() {
385        let dir = tempfile::TempDir::new().unwrap();
386        let repo = Repository::init(dir.path()).unwrap();
387        initial_commit(&repo);
388
389        let fpath = dir.path().join("untracked.txt");
390        std::fs::write(&fpath, "hello").unwrap();
391
392        assert!(is_worktree_dirty(&repo).unwrap());
393    }
394
395    #[test]
396    fn worktree_dirty_true_for_staged() {
397        use std::io::Write;
398        let dir = tempfile::TempDir::new().unwrap();
399        let repo = Repository::init(dir.path()).unwrap();
400        initial_commit(&repo);
401
402        let fpath = dir.path().join("file.txt");
403        {
404            let mut f = std::fs::File::create(&fpath).unwrap();
405            writeln!(f, "content").unwrap();
406        }
407        let mut idx = repo.index().unwrap();
408        idx.add_path(std::path::Path::new("file.txt")).unwrap();
409        idx.write().unwrap();
410
411        assert!(is_worktree_dirty(&repo).unwrap());
412    }
413
414    #[test]
415    fn try_get_origin_identity_some_when_origin_is_parseable() {
416        let dir = TempDir::new().unwrap();
417        let repo = Repository::init(dir.path()).unwrap();
418        repo.remote("origin", "https://github.com/org/repo.git")
419            .unwrap();
420
421        let expected = RepoIdentity::parse("https://github.com/org/repo.git")
422            .unwrap()
423            .canonical_key();
424        let actual = try_get_origin_identity(dir.path())
425            .unwrap()
426            .unwrap()
427            .canonical_key();
428
429        assert_eq!(actual, expected);
430    }
431
432    #[test]
433    fn try_get_origin_identity_none_when_no_origin_remote() {
434        let dir = TempDir::new().unwrap();
435        Repository::init(dir.path()).unwrap();
436
437        assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
438    }
439
440    #[test]
441    fn try_get_origin_identity_none_when_origin_url_unparseable() {
442        let dir = TempDir::new().unwrap();
443        let repo = Repository::init(dir.path()).unwrap();
444
445        // URL without org/repo structure won't parse as RepoIdentity
446        repo.remote("origin", "https://github.com").unwrap();
447
448        assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
449    }
450
451    #[test]
452    fn try_get_origin_identity_err_when_repo_cannot_be_opened() {
453        let dir = TempDir::new().unwrap();
454        let non_repo = dir.path().join("not-a-repo");
455        std::fs::create_dir_all(&non_repo).unwrap();
456
457        let err = try_get_origin_identity(&non_repo).unwrap_err();
458        assert!(err.to_string().contains("Failed to open git repository"));
459    }
460
461    #[test]
462    fn ensure_repo_ready_for_sync_rejects_merge_state() {
463        let dir = TempDir::new().unwrap();
464        let repo = Repository::init(dir.path()).unwrap();
465        std::fs::write(repo.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
466
467        let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
468        assert!(err.to_string().contains("in-progress merge"));
469    }
470
471    #[test]
472    fn ensure_repo_ready_for_sync_rejects_rebase_state() {
473        let dir = TempDir::new().unwrap();
474        let repo = Repository::init(dir.path()).unwrap();
475        std::fs::create_dir_all(repo.path().join("rebase-merge")).unwrap();
476
477        let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
478        assert!(err.to_string().contains("in-progress rebase"));
479    }
480
481    #[test]
482    fn ensure_repo_ready_for_sync_rejects_detached_head() {
483        let dir = TempDir::new().unwrap();
484        let repo = Repository::init(dir.path()).unwrap();
485
486        initial_commit(&repo);
487        let head_oid = repo.head().unwrap().target().unwrap();
488        repo.set_head_detached(head_oid).unwrap();
489
490        let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
491        assert!(err.to_string().contains("detached HEAD state"));
492    }
493
494    #[test]
495    fn ensure_repo_ready_for_sync_accepts_clean_repo() {
496        let dir = TempDir::new().unwrap();
497        Repository::init(dir.path()).unwrap();
498
499        ensure_repo_ready_for_sync(dir.path()).unwrap();
500    }
501
502    #[test]
503    fn get_sync_branch_rejects_detached_head() {
504        let temp_dir = TempDir::new().unwrap();
505        let repo_path = temp_dir.path();
506        let repo = Repository::init(repo_path).unwrap();
507
508        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
509        let tree_id = {
510            let mut index = repo.index().unwrap();
511            index.write_tree().unwrap()
512        };
513        let tree = repo.find_tree(tree_id).unwrap();
514        let commit_oid = repo
515            .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
516            .unwrap();
517        repo.set_head_detached(commit_oid).unwrap();
518
519        let err = get_sync_branch(repo_path).unwrap_err();
520        assert!(err.to_string().contains("detached HEAD state"));
521    }
522}