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()
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
146pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
152 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#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum HeadState {
180 Attached(String),
182 Detached,
184 Unborn(String),
186}
187
188pub 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 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
215pub 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
227pub 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
263pub 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
273pub 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 let repo = Repository::init(repo_path).unwrap();
306
307 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 let branch = get_current_branch(repo_path).unwrap();
319 assert!(branch == "master" || branch == "main");
320
321 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 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 Repository::init(repo_path).unwrap();
345
346 let state = get_head_state(repo_path).unwrap();
348 assert!(
349 matches!(state, HeadState::Unborn(_)),
350 "expected Unborn, got {state:?}"
351 );
352
353 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 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}