thoughts_tool/git/
utils.rs1use 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
13pub fn get_current_repo() -> Result<PathBuf> {
15 let current_dir = std::env::current_dir()?;
16 find_repo_root(¤t_dir)
17}
18
19pub 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
30pub 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 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
55pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
59 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 if !gitdir_path.is_absolute() {
74 gitdir_path = worktree_path.join(&gitdir_path);
75 }
76
77 let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
79
80 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 Ok(worktree_path.to_path_buf())
94}
95
96pub 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 Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
103 } else {
104 Ok(repo_root)
105 }
106}
107
108pub fn get_current_control_repo_root() -> Result<PathBuf> {
110 let cwd = std::env::current_dir()?;
111 get_control_repo_root(&cwd)
112}
113
114pub fn is_git_repo(path: &Path) -> bool {
116 Repository::open(path).is_ok()
117}
118
119pub 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).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
144pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
150 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#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum HeadState {
178 Attached(String),
180 Detached,
182 Unborn(String),
184}
185
186pub 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 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
213pub 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
225pub 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
261pub 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
271pub 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 let repo = Repository::init(repo_path).unwrap();
304
305 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 let branch = get_current_branch(repo_path).unwrap();
317 assert!(branch == "master" || branch == "main");
318
319 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 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 Repository::init(repo_path).unwrap();
343
344 let state = get_head_state(repo_path).unwrap();
346 assert!(
347 matches!(state, HeadState::Unborn(_)),
348 "expected Unborn, got {state:?}"
349 );
350
351 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 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}