use crate::error::ThoughtsError;
use crate::repo_identity::RepoIdentity;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use git2::ErrorCode;
use git2::Repository;
use git2::StatusOptions;
use std::path::Path;
use std::path::PathBuf;
use tracing::debug;
pub fn get_current_repo() -> Result<PathBuf> {
let current_dir = std::env::current_dir()?;
find_repo_root(¤t_dir)
}
pub fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
let repo = Repository::discover(start_path).map_err(|_| ThoughtsError::NotInGitRepo)?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
Ok(workdir.to_path_buf())
}
pub fn is_worktree(repo_path: &Path) -> Result<bool> {
let git_path = repo_path.join(".git");
if git_path.is_file() {
let contents = std::fs::read_to_string(&git_path)?;
if let Some(gitdir_line) = contents
.lines()
.find(|l| l.trim_start().starts_with("gitdir:"))
{
let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
let is_worktrees = gitdir.contains("/worktrees/");
let is_modules = gitdir.contains("/modules/");
if is_worktrees && !is_modules {
debug!("Found .git file with worktrees path, this is a worktree");
return Ok(true);
}
}
}
Ok(false)
}
pub fn get_main_repo_for_worktree(worktree_path: &Path) -> Result<PathBuf> {
let git_file = worktree_path.join(".git");
if git_file.is_file() {
let contents = std::fs::read_to_string(&git_file)?;
if let Some(gitdir_line) = contents
.lines()
.find(|l| l.trim_start().starts_with("gitdir:"))
{
let gitdir = gitdir_line.trim_start_matches("gitdir:").trim();
let mut gitdir_path = PathBuf::from(gitdir);
if !gitdir_path.is_absolute() {
gitdir_path = worktree_path.join(&gitdir_path);
}
let gitdir_path = std::fs::canonicalize(&gitdir_path).unwrap_or(gitdir_path);
if let Some(parent) = gitdir_path.parent()
&& let Some(parent_parent) = parent.parent()
&& parent_parent.ends_with(".git")
&& let Some(main_repo) = parent_parent.parent()
{
debug!("Found main repo at: {:?}", main_repo);
return Ok(main_repo.to_path_buf());
}
}
}
Ok(worktree_path.to_path_buf())
}
pub fn get_control_repo_root(start_path: &Path) -> Result<PathBuf> {
let repo_root = find_repo_root(start_path)?;
if is_worktree(&repo_root)? {
Ok(get_main_repo_for_worktree(&repo_root).unwrap_or(repo_root))
} else {
Ok(repo_root)
}
}
pub fn get_current_control_repo_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
get_control_repo_root(&cwd)
}
pub fn is_git_repo(path: &Path) -> bool {
Repository::open(path).is_ok()
}
pub fn init_repo(path: &Path) -> Result<Repository> {
Ok(Repository::init(path)?)
}
pub fn get_remote_url(repo_path: &Path) -> Result<String> {
let repo = Repository::open(repo_path).map_err(|e| {
anyhow::anyhow!(
"Failed to open git repository at {}: {e}",
repo_path.display()
)
})?;
let remote = repo
.find_remote("origin")
.map_err(|_| anyhow::anyhow!("No 'origin' remote found"))?;
remote
.url()
.ok_or_else(|| anyhow::anyhow!("Remote 'origin' has no URL"))
.map(std::string::ToString::to_string)
}
pub fn try_get_origin_identity(repo_path: &Path) -> Result<Option<RepoIdentity>> {
let repo = Repository::open(repo_path)
.with_context(|| format!("Failed to open git repository at {}", repo_path.display()))?;
let remote = match repo.find_remote("origin") {
Ok(r) => r,
Err(e) if e.code() == ErrorCode::NotFound => return Ok(None),
Err(e) => {
return Err(anyhow::Error::from(e)).with_context(|| {
format!(
"Failed to find 'origin' remote for git repository at {}",
repo_path.display()
)
});
}
};
let Some(url) = remote.url() else {
return Ok(None);
};
Ok(RepoIdentity::parse(url).ok())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeadState {
Attached(String),
Detached,
Unborn(String),
}
pub fn get_head_state(repo_path: &Path) -> Result<HeadState> {
let repo = Repository::open(repo_path).map_err(|e| {
anyhow::anyhow!(
"Failed to open git repository at {}: {e}",
repo_path.display()
)
})?;
match repo.head() {
Ok(head) if head.is_branch() => Ok(HeadState::Attached(
head.shorthand().unwrap_or("unknown").to_string(),
)),
Ok(_) => Ok(HeadState::Detached),
Err(e) if e.code() == ErrorCode::UnbornBranch => {
let head_ref = repo.find_reference("HEAD")?;
let name = head_ref.symbolic_target().map_or_else(
|| "unknown".to_string(),
|s| s.strip_prefix("refs/heads/").unwrap_or(s).to_string(),
);
Ok(HeadState::Unborn(name))
}
Err(e) => Err(anyhow::anyhow!("Failed to get HEAD reference: {e}")),
}
}
pub fn get_current_branch(repo_path: &Path) -> Result<String> {
match get_head_state(repo_path)? {
HeadState::Attached(name) => Ok(name),
HeadState::Detached => Ok("detached".to_string()),
HeadState::Unborn(name) => {
bail!("Branch '{name}' has no commits yet")
}
}
}
pub fn ensure_repo_ready_for_sync(repo_path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(|e| {
anyhow::anyhow!(
"Failed to open git repository at {}: {e}",
repo_path.display()
)
})?;
let git_dir = repo.path();
if git_dir.join("MERGE_HEAD").exists() {
bail!("Repository has an in-progress merge. Complete or abort it before syncing.");
}
if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() {
bail!("Repository has an in-progress rebase. Complete or abort it before syncing.");
}
if git_dir.join("CHERRY_PICK_HEAD").exists() || git_dir.join("sequencer").exists() {
bail!("Repository has an in-progress cherry-pick. Complete or abort it before syncing.");
}
if git_dir.join("REVERT_HEAD").exists() {
bail!("Repository has an in-progress revert. Complete or abort it before syncing.");
}
match repo.head() {
Ok(head) if head.is_branch() => Ok(()),
Ok(_) => bail!("Repository is in detached HEAD state. Check out a branch before syncing."),
Err(e) if e.code() == ErrorCode::UnbornBranch => Ok(()),
Err(e) => bail!("Failed to get HEAD reference: {e}"),
}
}
pub fn get_sync_branch(repo_path: &Path) -> Result<String> {
match get_head_state(repo_path)? {
HeadState::Attached(name) | HeadState::Unborn(name) => Ok(name),
HeadState::Detached => {
bail!("Repository is in detached HEAD state. Check out a branch before syncing.")
}
}
}
pub fn is_worktree_dirty(repo: &Repository) -> Result<bool> {
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.exclude_submodules(true);
let statuses = repo.statuses(Some(&mut opts))?;
Ok(!statuses.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_is_git_repo() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
assert!(!is_git_repo(repo_path));
Repository::init(repo_path).unwrap();
assert!(is_git_repo(repo_path));
}
#[test]
fn test_get_current_branch() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let repo = Repository::init(repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let branch = get_current_branch(repo_path).unwrap();
assert!(branch == "master" || branch == "main");
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
repo.branch("feature-branch", &commit, false).unwrap();
repo.set_head("refs/heads/feature-branch").unwrap();
repo.checkout_head(None).unwrap();
let branch = get_current_branch(repo_path).unwrap();
assert_eq!(branch, "feature-branch");
let commit_oid = commit.id();
repo.set_head_detached(commit_oid).unwrap();
let branch = get_current_branch(repo_path).unwrap();
assert_eq!(branch, "detached");
}
#[test]
fn test_get_head_state_unborn() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
Repository::init(repo_path).unwrap();
let state = get_head_state(repo_path).unwrap();
assert!(
matches!(state, HeadState::Unborn(_)),
"expected Unborn, got {state:?}"
);
let err = get_current_branch(repo_path).unwrap_err();
assert!(err.to_string().contains("no commits yet"));
let HeadState::Unborn(unborn_name) = state else {
unreachable!()
};
assert_eq!(get_sync_branch(repo_path).unwrap(), unborn_name);
}
fn initial_commit(repo: &Repository) {
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = {
let mut idx = repo.index().unwrap();
idx.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
}
#[test]
fn worktree_dirty_false_when_clean() {
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
initial_commit(&repo);
assert!(!is_worktree_dirty(&repo).unwrap());
}
#[test]
fn worktree_dirty_true_for_untracked() {
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
initial_commit(&repo);
let fpath = dir.path().join("untracked.txt");
std::fs::write(&fpath, "hello").unwrap();
assert!(is_worktree_dirty(&repo).unwrap());
}
#[test]
fn worktree_dirty_true_for_staged() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
initial_commit(&repo);
let fpath = dir.path().join("file.txt");
{
let mut f = std::fs::File::create(&fpath).unwrap();
writeln!(f, "content").unwrap();
}
let mut idx = repo.index().unwrap();
idx.add_path(std::path::Path::new("file.txt")).unwrap();
idx.write().unwrap();
assert!(is_worktree_dirty(&repo).unwrap());
}
#[test]
fn try_get_origin_identity_some_when_origin_is_parseable() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let expected = RepoIdentity::parse("https://github.com/org/repo.git")
.unwrap()
.canonical_key();
let actual = try_get_origin_identity(dir.path())
.unwrap()
.unwrap()
.canonical_key();
assert_eq!(actual, expected);
}
#[test]
fn try_get_origin_identity_none_when_no_origin_remote() {
let dir = TempDir::new().unwrap();
Repository::init(dir.path()).unwrap();
assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
}
#[test]
fn try_get_origin_identity_none_when_origin_url_unparseable() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
repo.remote("origin", "https://github.com").unwrap();
assert!(try_get_origin_identity(dir.path()).unwrap().is_none());
}
#[test]
fn try_get_origin_identity_err_when_repo_cannot_be_opened() {
let dir = TempDir::new().unwrap();
let non_repo = dir.path().join("not-a-repo");
std::fs::create_dir_all(&non_repo).unwrap();
let err = try_get_origin_identity(&non_repo).unwrap_err();
assert!(err.to_string().contains("Failed to open git repository"));
}
#[test]
fn ensure_repo_ready_for_sync_rejects_merge_state() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
std::fs::write(repo.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
assert!(err.to_string().contains("in-progress merge"));
}
#[test]
fn ensure_repo_ready_for_sync_rejects_rebase_state() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
std::fs::create_dir_all(repo.path().join("rebase-merge")).unwrap();
let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
assert!(err.to_string().contains("in-progress rebase"));
}
#[test]
fn ensure_repo_ready_for_sync_rejects_detached_head() {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
initial_commit(&repo);
let head_oid = repo.head().unwrap().target().unwrap();
repo.set_head_detached(head_oid).unwrap();
let err = ensure_repo_ready_for_sync(dir.path()).unwrap_err();
assert!(err.to_string().contains("detached HEAD state"));
}
#[test]
fn ensure_repo_ready_for_sync_accepts_clean_repo() {
let dir = TempDir::new().unwrap();
Repository::init(dir.path()).unwrap();
ensure_repo_ready_for_sync(dir.path()).unwrap();
}
#[test]
fn get_sync_branch_rejects_detached_head() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
let repo = Repository::init(repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
repo.set_head_detached(commit_oid).unwrap();
let err = get_sync_branch(repo_path).unwrap_err();
assert!(err.to_string().contains("detached HEAD state"));
}
}