use std::{fmt, fs::create_dir_all, path::Path};
use git2::WorktreeAddOptions;
use git2::{Repository, Worktree};
use log::debug;
use crate::error::{Result, WorktreeError};
use crate::workon_root;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BranchType {
#[default]
Normal,
Orphan,
Detached,
}
pub struct WorktreeDescriptor {
worktree: Worktree,
}
impl WorktreeDescriptor {
pub fn new(repo: &Repository, name: &str) -> Result<Self> {
Ok(Self {
worktree: repo.find_worktree(name)?,
})
}
pub fn of(worktree: Worktree) -> Self {
Self { worktree }
}
pub fn name(&self) -> Option<&str> {
self.worktree.name()
}
pub fn path(&self) -> &Path {
self.worktree.path()
}
pub fn branch(&self) -> Result<Option<String>> {
use std::fs;
let git_dir = self.worktree.path().join(".git");
let head_path = if git_dir.is_file() {
let git_file_content = fs::read_to_string(&git_dir)?;
let git_dir_path = git_file_content
.strip_prefix("gitdir: ")
.and_then(|s| s.trim().strip_suffix('\n').or(Some(s.trim())))
.ok_or(WorktreeError::InvalidGitFile)?;
Path::new(git_dir_path).join("HEAD")
} else {
git_dir.join("HEAD")
};
let head_content = fs::read_to_string(&head_path)?;
if let Some(ref_line) = head_content.strip_prefix("ref: ") {
let ref_name = ref_line.trim();
Ok(ref_name.strip_prefix("refs/heads/").map(|s| s.to_string()))
} else {
Ok(None)
}
}
pub fn is_detached(&self) -> Result<bool> {
Ok(self.branch()?.is_none())
}
pub fn is_dirty(&self) -> Result<bool> {
let repo = Repository::open(self.path())?;
let statuses = repo.statuses(None)?;
Ok(!statuses.is_empty())
}
pub fn is_locked(&self) -> Result<bool> {
Ok(!matches!(
self.worktree.is_locked()?,
git2::WorktreeLockStatus::Unlocked
))
}
pub fn is_valid(&self) -> bool {
self.worktree.validate().is_ok()
}
pub fn has_tracked_changes(&self) -> Result<bool> {
let repo = Repository::open(self.path())?;
let mut opts = git2::StatusOptions::new();
opts.include_untracked(false);
let statuses = repo.statuses(Some(&mut opts))?;
Ok(!statuses.is_empty())
}
pub fn has_unpushed_commits(&self) -> Result<bool> {
let branch_name = match self.branch()? {
Some(name) => name,
None => return Ok(false), };
let repo = Repository::open(self.path())?;
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(false), };
let config = repo.config()?;
let remote_key = format!("branch.{}.remote", branch_name);
let _remote = match config.get_string(&remote_key) {
Ok(r) => r,
Err(_) => return Ok(false), };
let upstream = match branch.upstream() {
Ok(u) => u,
Err(_) => {
return Ok(true);
}
};
let local_oid = branch
.get()
.target()
.ok_or(WorktreeError::NoLocalBranchTarget)?;
let upstream_oid = upstream
.get()
.target()
.ok_or(WorktreeError::NoBranchTarget)?;
let (ahead, _behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
Ok(ahead > 0)
}
pub fn is_behind_upstream(&self) -> Result<bool> {
let branch_name = match self.branch()? {
Some(name) => name,
None => return Ok(false), };
let repo = Repository::open(self.path())?;
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(false), };
let config = repo.config()?;
let remote_key = format!("branch.{}.remote", branch_name);
let _remote = match config.get_string(&remote_key) {
Ok(r) => r,
Err(_) => return Ok(false), };
let upstream = match branch.upstream() {
Ok(u) => u,
Err(_) => {
return Ok(false);
}
};
let local_oid = branch
.get()
.target()
.ok_or(WorktreeError::NoLocalBranchTarget)?;
let upstream_oid = upstream
.get()
.target()
.ok_or(WorktreeError::NoBranchTarget)?;
let (_ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
Ok(behind > 0)
}
pub fn has_gone_upstream(&self) -> Result<bool> {
let branch_name = match self.branch()? {
Some(name) => name,
None => return Ok(false), };
let repo = Repository::open(self.path())?;
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(false), };
let config = repo.config()?;
let remote_key = format!("branch.{}.remote", branch_name);
match config.get_string(&remote_key) {
Ok(_) => {
match branch.upstream() {
Ok(_) => Ok(false), Err(_) => Ok(true), }
}
Err(_) => Ok(false), }
}
pub fn is_merged_into(&self, target_branch: &str) -> Result<bool> {
let branch_name = match self.branch()? {
Some(name) => name,
None => return Ok(false), };
if branch_name == target_branch {
return Ok(false);
}
let worktree_repo = Repository::open(self.path())?;
let commondir = worktree_repo.commondir();
let repo = Repository::open(commondir)?;
let current_branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(false), };
let target = match repo.find_branch(target_branch, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(false), };
let current_oid = current_branch
.get()
.target()
.ok_or(WorktreeError::NoCurrentBranchTarget)?;
let target_oid = target.get().target().ok_or(WorktreeError::NoBranchTarget)?;
if current_oid == target_oid {
return Ok(true);
}
Ok(repo.graph_descendant_of(target_oid, current_oid)?)
}
pub fn head_commit(&self) -> Result<Option<String>> {
let repo = Repository::open(self.path())?;
let commit_oid = match repo.head() {
Ok(head) => match head.peel_to_commit() {
Ok(commit) => Some(commit.id()),
Err(_) => return Ok(None), },
Err(_) => return Ok(None), };
Ok(commit_oid.map(|oid| oid.to_string()))
}
pub fn last_activity(&self) -> Result<Option<i64>> {
let repo = Repository::open(self.path())?;
let seconds = match repo.head() {
Ok(head) => match head.peel_to_commit() {
Ok(commit) => Some(commit.time().seconds()),
Err(_) => None,
},
Err(_) => None,
};
Ok(seconds)
}
pub fn is_stale(&self, days: u32) -> Result<bool> {
let last = match self.last_activity()? {
Some(ts) => ts,
None => return Ok(false),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(std::io::Error::other)?
.as_secs() as i64;
let threshold = i64::from(days) * 86400;
Ok((now - last) > threshold)
}
pub fn remote(&self) -> Result<Option<String>> {
let branch_name = match self.branch()? {
Some(name) => name,
None => return Ok(None), };
let repo = Repository::open(self.path())?;
let config = repo.config()?;
let remote_key = format!("branch.{}.remote", branch_name);
match config.get_string(&remote_key) {
Ok(remote) => Ok(Some(remote)),
Err(_) => Ok(None), }
}
pub fn remote_branch(&self) -> Result<Option<String>> {
let branch_name = match self.branch()? {
Some(name) => name,
None => return Ok(None), };
let repo = Repository::open(self.path())?;
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(None), };
let upstream_name = match branch.upstream() {
Ok(upstream) => match upstream.name() {
Ok(Some(name)) => Some(name.to_string()),
_ => None,
},
Err(_) => return Ok(None), };
Ok(upstream_name)
}
pub fn remote_url(&self) -> Result<Option<String>> {
let remote_name = match self.remote()? {
Some(name) => name,
None => return Ok(None),
};
let repo = Repository::open(self.path())?;
let url = match repo.find_remote(&remote_name) {
Ok(remote) => remote.url().map(|s| s.to_string()),
Err(_) => return Ok(None), };
Ok(url)
}
pub fn remote_fetch_url(&self) -> Result<Option<String>> {
let remote_name = match self.remote()? {
Some(name) => name,
None => return Ok(None),
};
let repo = Repository::open(self.path())?;
let url = match repo.find_remote(&remote_name) {
Ok(remote) => remote.url().map(|s| s.to_string()),
Err(_) => return Ok(None), };
Ok(url)
}
pub fn remote_push_url(&self) -> Result<Option<String>> {
let remote_name = match self.remote()? {
Some(name) => name,
None => return Ok(None),
};
let repo = Repository::open(self.path())?;
let url = match repo.find_remote(&remote_name) {
Ok(remote) => remote
.pushurl()
.or_else(|| remote.url())
.map(|s| s.to_string()),
Err(_) => return Ok(None), };
Ok(url)
}
}
impl fmt::Debug for WorktreeDescriptor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "WorktreeDescriptor({:?})", self.worktree.path())
}
}
impl fmt::Display for WorktreeDescriptor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.worktree.path().display())
}
}
pub fn get_worktrees(repo: &Repository) -> Result<Vec<WorktreeDescriptor>> {
repo.worktrees()?
.into_iter()
.map(|name| {
let name = name.ok_or(WorktreeError::InvalidName)?;
WorktreeDescriptor::new(repo, name)
})
.collect()
}
pub fn current_worktree(repo: &Repository) -> Result<WorktreeDescriptor> {
let current_dir = std::env::current_dir().map_err(std::io::Error::other)?;
let worktrees = get_worktrees(repo)?;
worktrees
.into_iter()
.find(|wt| current_dir.starts_with(wt.path()))
.ok_or_else(|| WorktreeError::NotInWorktree.into())
}
pub fn find_worktree(repo: &Repository, name: &str) -> Result<WorktreeDescriptor> {
let worktrees = get_worktrees(repo)?;
worktrees
.into_iter()
.find(|wt| {
wt.name() == Some(name) || wt.branch().ok().flatten().as_deref() == Some(name)
})
.ok_or_else(|| WorktreeError::NotFound(name.to_string()).into())
}
pub fn add_worktree(
repo: &Repository,
branch_name: &str,
branch_type: BranchType,
base_branch: Option<&str>,
lock: bool,
) -> Result<WorktreeDescriptor> {
debug!(
"adding worktree for branch {:?} with type: {:?}",
branch_name, branch_type
);
let reference = match branch_type {
BranchType::Orphan => {
debug!("creating orphan branch {:?}", branch_name);
None
}
BranchType::Detached => {
debug!("creating detached HEAD worktree at {:?}", branch_name);
None
}
BranchType::Normal => {
let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
Ok(b) => b,
Err(e) => {
debug!("local branch not found: {:?}", e);
debug!("looking for remote branch {:?}", branch_name);
match repo.find_branch(branch_name, git2::BranchType::Remote) {
Ok(b) => b,
Err(e) => {
debug!("remote branch not found: {:?}", e);
debug!("creating new local branch {:?}", branch_name);
let base_commit = if let Some(base) = base_branch {
debug!("branching from base branch {:?}", base);
let base_branch =
match repo.find_branch(base, git2::BranchType::Local) {
Ok(b) => b,
Err(_) => {
debug!("base branch not found as local, trying remote");
repo.find_branch(base, git2::BranchType::Remote)?
}
};
base_branch.into_reference().peel_to_commit()?
} else {
repo.head()?.peel_to_commit()?
};
repo.branch(branch_name, &base_commit, false)?
}
}
}
};
Some(branch.into_reference())
}
};
let root = workon_root(repo)?;
let worktree_name = match Path::new(&branch_name).file_name() {
Some(basename) => basename.to_str().ok_or(WorktreeError::InvalidName)?,
None => branch_name,
};
let worktree_path = root.join(branch_name);
if let Some(parent) = worktree_path.parent() {
create_dir_all(parent)?;
}
let mut opts = WorktreeAddOptions::new();
if let Some(ref r) = reference {
opts.reference(Some(r));
}
if lock {
opts.lock(true);
}
debug!(
"adding worktree {} at {}",
worktree_name,
worktree_path.display()
);
let worktree = repo.worktree(worktree_name, worktree_path.as_path(), Some(&opts))?;
if branch_type == BranchType::Detached {
debug!("setting up detached HEAD for worktree {:?}", branch_name);
use std::fs;
let head_commit = repo.head()?.peel_to_commit()?;
let commit_sha = head_commit.id().to_string();
let git_dir = repo.path().join("worktrees").join(worktree_name);
let head_path = git_dir.join("HEAD");
fs::write(&head_path, format!("{}\n", commit_sha).as_bytes())?;
debug!(
"detached HEAD setup complete for worktree {:?} at {}",
branch_name, commit_sha
);
}
if branch_type == BranchType::Orphan {
debug!(
"setting up orphan branch {:?} with initial empty commit",
branch_name
);
use std::fs;
let common_dir = repo.commondir();
let git_dir = common_dir.join("worktrees").join(worktree_name);
let head_path = git_dir.join("HEAD");
let branch_ref = format!("ref: refs/heads/{}\n", branch_name);
fs::write(&head_path, branch_ref.as_bytes())?;
let branch_ref_path = common_dir.join("refs/heads").join(branch_name);
let _ = fs::remove_file(&branch_ref_path);
let worktree_repo = Repository::open(&worktree_path)?;
for entry in fs::read_dir(&worktree_path)? {
let entry = entry?;
let path = entry.path();
if path.file_name() != Some(std::ffi::OsStr::new(".git")) {
if path.is_dir() {
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
}
}
}
let mut index = worktree_repo.index()?;
index.clear()?;
index.write()?;
let tree_id = index.write_tree()?;
let tree = worktree_repo.find_tree(tree_id)?;
let config = worktree_repo.config()?;
let sig = worktree_repo.signature().or_else(|_| {
git2::Signature::now(
config
.get_string("user.name")
.unwrap_or_else(|_| "git-workon".to_string())
.as_str(),
config
.get_string("user.email")
.unwrap_or_else(|_| "git-workon@localhost".to_string())
.as_str(),
)
})?;
worktree_repo.commit(
Some("HEAD"),
&sig,
&sig,
"Initial commit",
&tree,
&[], )?;
debug!("orphan branch setup complete for {:?}", branch_name);
}
Ok(WorktreeDescriptor::of(worktree))
}
pub fn set_upstream_tracking(
worktree: &WorktreeDescriptor,
remote: &str,
remote_ref: &str,
) -> Result<()> {
let repo = Repository::open(worktree.path())?;
let mut config = repo.config()?;
let head = repo.head()?;
let branch_name = head
.shorthand()
.ok_or(WorktreeError::NoCurrentBranchTarget)?;
let remote_key = format!("branch.{}.remote", branch_name);
config.set_str(&remote_key, remote)?;
let merge_key = format!("branch.{}.merge", branch_name);
config.set_str(&merge_key, remote_ref)?;
debug!(
"Set upstream tracking: {} -> {}/{}",
branch_name, remote, remote_ref
);
Ok(())
}