thoughts_tool/git/
utils.rs1use crate::error::ThoughtsError;
2use anyhow::Result;
3use git2::{Repository, StatusOptions};
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7pub fn get_current_repo() -> Result<PathBuf> {
9 let current_dir = std::env::current_dir()?;
10 find_repo_root(¤t_dir)
11}
12
13pub 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
24pub 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 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
49pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
53 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 if !gitdir_path.is_absolute() {
68 gitdir_path = worktree_path.join(&gitdir_path);
69 }
70
71 let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
73
74 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 Ok(worktree_path.to_path_buf())
88}
89
90pub 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 Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
97 } else {
98 Ok(repo_root)
99 }
100}
101
102pub fn get_current_control_repo_root() -> Result<PathBuf> {
104 let cwd = std::env::current_dir()?;
105 get_control_repo_root(&cwd)
106}
107
108pub fn is_git_repo(path: &Path) -> bool {
110 Repository::open(path).is_ok()
111}
112
113#[allow(dead_code)]
115pub fn init_repo(path: &Path) -> Result<Repository> {
117 Ok(Repository::init(path)?)
118}
119
120pub 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
135pub 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
151pub 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 let repo = Repository::init(repo_path).unwrap();
184
185 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 let branch = get_current_branch(repo_path).unwrap();
197 assert!(branch == "master" || branch == "main");
198
199 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 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}