thoughts_tool/git/
utils.rs1use 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
12pub fn get_current_repo() -> Result<PathBuf> {
14 let current_dir = std::env::current_dir()?;
15 find_repo_root(¤t_dir)
16}
17
18pub 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
29pub 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 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
54pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
58 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 if !gitdir_path.is_absolute() {
73 gitdir_path = worktree_path.join(&gitdir_path);
74 }
75
76 let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
78
79 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 Ok(worktree_path.to_path_buf())
93}
94
95pub 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 Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
102 } else {
103 Ok(repo_root)
104 }
105}
106
107pub fn get_current_control_repo_root() -> Result<PathBuf> {
109 let cwd = std::env::current_dir()?;
110 get_control_repo_root(&cwd)
111}
112
113pub fn is_git_repo(path: &Path) -> bool {
115 Repository::open(path).is_ok()
116}
117
118#[allow(dead_code)]
120pub fn init_repo(path: &Path) -> Result<Repository> {
122 Ok(Repository::init(path)?)
123}
124
125pub 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
140pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
146 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
171pub 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
187pub 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 let repo = Repository::init(repo_path).unwrap();
220
221 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 let branch = get_current_branch(repo_path).unwrap();
233 assert!(branch == "master" || branch == "main");
234
235 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 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 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}