thoughts_tool/git/
utils.rs1use crate::error::ThoughtsError;
2use crate::repo_identity::RepoIdentity;
3use anyhow::{Context, Result};
4use git2::{ErrorCode, Repository, StatusOptions};
5use std::path::{Path, PathBuf};
6use tracing::debug;
7
8pub fn get_current_repo() -> Result<PathBuf> {
10 let current_dir = std::env::current_dir()?;
11 find_repo_root(¤t_dir)
12}
13
14pub fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
16 let repo = Repository::discover(start_path).map_err(|_| ThoughtsError::NotInGitRepo)?;
17
18 let workdir = repo
19 .workdir()
20 .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
21
22 Ok(workdir.to_path_buf())
23}
24
25pub fn is_worktree(repo_path: &Path) -> Result<bool> {
30 let git_path = repo_path.join(".git");
31 if git_path.is_file() {
32 let contents = std::fs::read_to_string(&git_path)?;
33 if let Some(gitdir_line) = contents
34 .lines()
35 .find(|l| l.trim_start().starts_with("gitdir:"))
36 {
37 let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
38 let is_worktrees = gitdir.contains("/worktrees/");
40 let is_modules = gitdir.contains("/modules/");
41 if is_worktrees && !is_modules {
42 debug!("Found .git file with worktrees path, this is a worktree");
43 return Ok(true);
44 }
45 }
46 }
47 Ok(false)
48}
49
50pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
54 let git_file = worktree_path.join(".git");
58 if git_file.is_file() {
59 let contents = std::fs::read_to_string(&git_file)?;
60 if let Some(gitdir_line) = contents
61 .lines()
62 .find(|l| l.trim_start().starts_with("gitdir:"))
63 {
64 let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
65 let mut gitdir_path = PathBuf::from(gitdir);
66
67 if !gitdir_path.is_absolute() {
69 gitdir_path = worktree_path.join(&gitdir_path);
70 }
71
72 let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
74
75 if let Some(parent) = gitdir_path.parent()
77 && let Some(parent_parent) = parent.parent()
78 && parent_parent.ends_with(".git")
79 && let Some(main_repo) = parent_parent.parent()
80 {
81 debug!("Found main repo at: {:?}", main_repo);
82 return Ok(main_repo.to_path_buf());
83 }
84 }
85 }
86
87 Ok(worktree_path.to_path_buf())
89}
90
91pub fn get_control_repo_root(start_path: &Path) -> Result<PathBuf> {
94 let repo_root = find_repo_root(start_path)?;
95 if is_worktree(&repo_root)? {
96 Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
98 } else {
99 Ok(repo_root)
100 }
101}
102
103pub fn get_current_control_repo_root() -> Result<PathBuf> {
105 let cwd = std::env::current_dir()?;
106 get_control_repo_root(&cwd)
107}
108
109pub fn is_git_repo(path: &Path) -> bool {
111 Repository::open(path).is_ok()
112}
113
114#[allow(dead_code)]
116pub fn init_repo(path: &Path) -> Result<Repository> {
118 Ok(Repository::init(path)?)
119}
120
121pub fn get_remote_url(repo_path: &Path) -> Result<String> {
123 let repo = Repository::open(repo_path)
124 .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
125
126 let remote = repo
127 .find_remote("origin")
128 .map_err(|_| anyhow::anyhow!("No 'origin' remote found"))?;
129
130 remote
131 .url()
132 .ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
133 .map(|s| s.to_string())
134}
135
136pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
142 let repo = Repository::open(repo_path)
145 .with_context(|| format!("Failed to open git repository at {}", repo_path.display()))?;
146
147 let remote = match repo.find_remote("origin") {
148 Ok(r) => r,
149 Err(e) if e.code() == ErrorCode::NotFound => return Ok(None),
150 Err(e) => {
151 return Err(anyhow::Error::from(e)).with_context(|| {
152 format!(
153 "Failed to find 'origin' remote for git repository at {}",
154 repo_path.display()
155 )
156 });
157 }
158 };
159
160 let Some(url) = remote.url() else {
161 return Ok(None);
162 };
163
164 Ok(RepoIdentity::parse(url).ok())
165}
166
167pub fn get_current_branch(repo_path: &Path) -> Result<String> {
169 let repo = Repository::open(repo_path)
170 .map_err(|e| anyhow::anyhow!("Failed to open git repository at {:?}: {}", repo_path, e))?;
171
172 let head = repo
173 .head()
174 .map_err(|e| anyhow::anyhow!("Failed to get HEAD reference: {}", e))?;
175
176 if head.is_branch() {
177 Ok(head.shorthand().unwrap_or("unknown").to_string())
178 } else {
179 Ok("detached".to_string())
180 }
181}
182
183pub fn is_worktree_dirty(repo: &Repository) -> Result<bool> {
185 let mut opts = StatusOptions::new();
186 opts.include_untracked(true)
187 .recurse_untracked_dirs(true)
188 .exclude_submodules(true);
189 let statuses = repo.statuses(Some(&mut opts))?;
190 Ok(!statuses.is_empty())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use tempfile::TempDir;
197
198 #[test]
199 fn test_is_git_repo() {
200 let temp_dir = TempDir::new().unwrap();
201 let repo_path = temp_dir.path();
202
203 assert!(!is_git_repo(repo_path));
204
205 Repository::init(repo_path).unwrap();
206 assert!(is_git_repo(repo_path));
207 }
208
209 #[test]
210 fn test_get_current_branch() {
211 let temp_dir = TempDir::new().unwrap();
212 let repo_path = temp_dir.path();
213
214 let repo = Repository::init(repo_path).unwrap();
216
217 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
219 let tree_id = {
220 let mut index = repo.index().unwrap();
221 index.write_tree().unwrap()
222 };
223 let tree = repo.find_tree(tree_id).unwrap();
224 repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
225 .unwrap();
226
227 let branch = get_current_branch(repo_path).unwrap();
229 assert!(branch == "master" || branch == "main");
230
231 let head = repo.head().unwrap();
233 let commit = head.peel_to_commit().unwrap();
234 repo.branch("feature-branch", &commit, false).unwrap();
235 repo.set_head("refs/heads/feature-branch").unwrap();
236 repo.checkout_head(None).unwrap();
237
238 let branch = get_current_branch(repo_path).unwrap();
239 assert_eq!(branch, "feature-branch");
240
241 let commit_oid = commit.id();
243 repo.set_head_detached(commit_oid).unwrap();
244 let branch = get_current_branch(repo_path).unwrap();
245 assert_eq!(branch, "detached");
246 }
247
248 fn initial_commit(repo: &Repository) {
249 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
250 let tree_id = {
251 let mut idx = repo.index().unwrap();
252 idx.write_tree().unwrap()
253 };
254 let tree = repo.find_tree(tree_id).unwrap();
255 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
256 .unwrap();
257 }
258
259 #[test]
260 fn worktree_dirty_false_when_clean() {
261 let dir = tempfile::TempDir::new().unwrap();
262 let repo = Repository::init(dir.path()).unwrap();
263 initial_commit(&repo);
264 assert!(!is_worktree_dirty(&repo).unwrap());
265 }
266
267 #[test]
268 fn worktree_dirty_true_for_untracked() {
269 let dir = tempfile::TempDir::new().unwrap();
270 let repo = Repository::init(dir.path()).unwrap();
271 initial_commit(&repo);
272
273 let fpath = dir.path().join("untracked.txt");
274 std::fs::write(&fpath, "hello").unwrap();
275
276 assert!(is_worktree_dirty(&repo).unwrap());
277 }
278
279 #[test]
280 fn worktree_dirty_true_for_staged() {
281 use std::io::Write;
282 let dir = tempfile::TempDir::new().unwrap();
283 let repo = Repository::init(dir.path()).unwrap();
284 initial_commit(&repo);
285
286 let fpath = dir.path().join("file.txt");
287 {
288 let mut f = std::fs::File::create(&fpath).unwrap();
289 writeln!(f, "content").unwrap();
290 }
291 let mut idx = repo.index().unwrap();
292 idx.add_path(std::path::Path::new("file.txt")).unwrap();
293 idx.write().unwrap();
294
295 assert!(is_worktree_dirty(&repo).unwrap());
296 }
297
298 #[test]
299 fn try_get_origin_identity_some_when_origin_is_parseable() {
300 let dir = TempDir::new().unwrap();
301 let repo = Repository::init(dir.path()).unwrap();
302 repo.remote("origin", "https://github.com/org/repo.git")
303 .unwrap();
304
305 let expected = RepoIdentity::parse("https://github.com/org/repo.git")
306 .unwrap()
307 .canonical_key();
308 let actual = try_get_origin_identity(dir.path())
309 .unwrap()
310 .unwrap()
311 .canonical_key();
312
313 assert_eq!(actual, expected);
314 }
315
316 #[test]
317 fn try_get_origin_identity_none_when_no_origin_remote() {
318 let dir = TempDir::new().unwrap();
319 Repository::init(dir.path()).unwrap();
320
321 assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
322 }
323
324 #[test]
325 fn try_get_origin_identity_none_when_origin_url_unparseable() {
326 let dir = TempDir::new().unwrap();
327 let repo = Repository::init(dir.path()).unwrap();
328
329 repo.remote("origin", "https://github.com").unwrap();
331
332 assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
333 }
334
335 #[test]
336 fn try_get_origin_identity_err_when_repo_cannot_be_opened() {
337 let dir = TempDir::new().unwrap();
338 let non_repo = dir.path().join("not-a-repo");
339 std::fs::create_dir_all(&non_repo).unwrap();
340
341 let err = try_get_origin_identity(&non_repo).unwrap_err();
342 assert!(err.to_string().contains("Failed to open git repository"));
343 }
344}