use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
use vcs_git::{
CheckoutTarget, Git, GitApi, GitPush, OutputBudget, RefName, RevSpec, StatusEntry, WorktreeAdd,
};
use crate::dto::{
ChangeKind, Commit, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState,
RepoSnapshot, UpstreamTracking, WorktreeInfo,
};
use crate::error::{Error, Result};
pub(crate) async fn current_branch<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<Option<String>> {
Ok(git.current_branch(dir).await?)
}
pub(crate) async fn trunk<R: ProcessRunner>(git: &Git<R>, dir: &Path) -> Result<Option<String>> {
Ok(git.remote_head_branch(dir).await?)
}
pub(crate) async fn local_branches<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<Vec<String>> {
Ok(git
.branches(dir)
.await?
.into_iter()
.map(|b| b.name)
.collect())
}
pub(crate) async fn branch_exists<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
name: &str,
) -> Result<bool> {
Ok(git.branch_exists(dir, &RefName::new(name)?).await?)
}
pub(crate) async fn has_uncommitted_changes<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<bool> {
Ok(!git.status(dir).await?.is_empty())
}
pub(crate) async fn has_tracked_changes<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<bool> {
Ok(!git.status_tracked(dir).await?.is_empty())
}
pub(crate) async fn conflicted_files<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<Vec<PathBuf>> {
Ok(git.conflicted_files(dir).await?)
}
pub(crate) async fn delete_branch<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
name: &str,
force: bool,
) -> Result<()> {
let mut spec = vcs_git::BranchDelete::new(RefName::new(name)?);
if force {
spec = spec.force();
}
git.delete_branch(dir, spec).await?;
Ok(())
}
pub(crate) async fn rename_branch<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
old: &str,
new: &str,
) -> Result<()> {
git.rename_branch(dir, &RefName::new(old)?, &RefName::new(new)?)
.await?;
Ok(())
}
pub(crate) async fn changed_files<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<Vec<FileChange>> {
let entries = git.status(dir).await?;
Ok(entries.into_iter().map(file_change_from_status).collect())
}
pub(crate) async fn diff_stat<R: ProcessRunner>(git: &Git<R>, dir: &Path) -> Result<DiffStat> {
let range: String = if git.is_unborn(dir).await? {
git.empty_tree_oid(dir).await?
} else {
"HEAD".to_string()
};
git.diff_stat(dir, &RevSpec::new(&range)?)
.await
.map_err(Into::into)
}
pub(crate) async fn log<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
revspec: &str,
max: usize,
) -> Result<Vec<Commit>> {
Ok(git
.log(dir, &RevSpec::new(revspec)?, max)
.await?
.into_iter()
.map(|c| Commit::new(c.hash, c.subject).author(c.author).date(c.date))
.collect())
}
pub(crate) async fn show_file<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
rev: &str,
path: &str,
) -> Result<String> {
Ok(git.show_file(dir, &RevSpec::new(rev)?, path).await?)
}
pub(crate) async fn show_file_within<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
rev: &str,
path: &str,
budget: OutputBudget,
) -> Result<String> {
Ok(git
.show_file_within(dir, &RevSpec::new(rev)?, path, budget)
.await?)
}
pub(crate) async fn snapshot<R: ProcessRunner>(git: &Git<R>, dir: &Path) -> Result<RepoSnapshot> {
let bs = git.branch_status(dir).await?;
let raw = git.git_dir(dir).await?;
let git_dir = if raw.is_absolute() {
raw
} else {
dir.join(raw)
};
let rebase_apply = git_dir.join("rebase-apply");
let operation = if git_dir.join("MERGE_HEAD").exists() {
OperationState::Merge
} else if rebase_apply.join("applying").exists() {
OperationState::ApplyMailbox
} else if git_dir.join("rebase-merge").exists() || rebase_apply.exists() {
OperationState::Rebase
} else if git_dir.join("CHERRY_PICK_HEAD").exists() {
OperationState::CherryPick
} else if git_dir.join("REVERT_HEAD").exists() {
OperationState::Revert
} else if git_dir.join("BISECT_LOG").exists() {
OperationState::Bisect
} else {
OperationState::Clear
};
let dirty = bs.is_dirty();
let change_count = bs.tracked_changes + bs.untracked;
let conflicted = bs.conflicts > 0;
let tracking = bs.upstream.map(|branch| UpstreamTracking {
branch,
ahead: bs.ahead,
behind: bs.behind,
});
Ok(RepoSnapshot {
head: bs.head,
branch: bs.branch,
tracking,
dirty,
change_count,
conflicted,
operation,
})
}
pub(crate) async fn commit_paths<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
paths: &[PathBuf],
message: &str,
) -> Result<()> {
git.commit_paths(
dir,
vcs_git::CommitPaths::new(paths.iter().cloned(), message),
)
.await?;
Ok(())
}
pub(crate) async fn fetch<R: ProcessRunner>(git: &Git<R>, dir: &Path) -> Result<()> {
git.fetch(dir).await?;
Ok(())
}
pub(crate) async fn fetch_from<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
remote: &str,
) -> Result<()> {
git.fetch_from(dir, remote).await?;
Ok(())
}
pub(crate) async fn fetch_branch<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
branch: &str,
) -> Result<()> {
git.fetch_branch(dir, &RefName::new(branch)?).await?;
Ok(())
}
pub(crate) async fn push<R: ProcessRunner>(git: &Git<R>, dir: &Path, branch: &str) -> Result<()> {
git.push(dir, GitPush::branch(RefName::new(branch)?).set_upstream())
.await?;
Ok(())
}
pub(crate) async fn checkout<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
reference: &str,
) -> Result<()> {
git.checkout(dir, &checkout_target(reference)?).await?;
Ok(())
}
fn checkout_target(reference: &str) -> Result<CheckoutTarget> {
if reference == "-" {
Ok(CheckoutTarget::Previous)
} else {
Ok(CheckoutTarget::Ref(RevSpec::new(reference)?))
}
}
pub(crate) async fn new_child<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
reference: &str,
) -> Result<()> {
checkout(git, dir, reference).await
}
pub(crate) async fn rebase<R: ProcessRunner>(git: &Git<R>, dir: &Path, onto: &str) -> Result<()> {
git.rebase(dir, &RevSpec::new(onto)?).await?;
Ok(())
}
pub(crate) async fn try_merge<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
source: &str,
) -> Result<MergeProbe> {
let merged = git
.merge_no_commit(
dir,
vcs_git::MergeNoCommit::branch(RevSpec::new(source)?).no_ff(),
)
.await;
match merged {
Ok(()) => {
if git.is_merge_in_progress(dir).await? {
git.merge_abort(dir).await?;
}
Ok(MergeProbe::Clean)
}
Err(err) if vcs_git::is_merge_conflict(&err) => {
let files = git.conflicted_files(dir).await;
git.merge_abort(dir).await?;
Ok(MergeProbe::Conflicts(files?))
}
Err(err) => {
if git.is_merge_in_progress(dir).await? {
git.merge_abort(dir).await?;
}
Err(err.into())
}
}
}
pub(crate) async fn abort_in_progress<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<OperationState> {
match in_progress_state(git, dir).await? {
OperationState::Merge => git.merge_abort(dir).await?,
OperationState::Rebase => git.rebase_abort(dir).await?,
OperationState::ApplyMailbox => git.am_abort(dir).await?,
OperationState::CherryPick => git.cherry_pick_abort(dir).await?,
OperationState::Revert => git.revert_abort(dir).await?,
OperationState::Bisect => git.bisect_reset(dir).await?,
OperationState::Clear | OperationState::Conflict => {}
}
in_progress_state(git, dir).await
}
pub(crate) async fn continue_in_progress<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<OperationState> {
if !git.conflicted_files(dir).await?.is_empty() {
return Ok(OperationState::Conflict);
}
match in_progress_state(git, dir).await? {
OperationState::Merge => git.merge_continue(dir).await?,
state @ (OperationState::Rebase | OperationState::CherryPick | OperationState::Revert) => {
let continued = match state {
OperationState::CherryPick => git.cherry_pick_continue(dir).await,
OperationState::Revert => git.revert_continue(dir).await,
_ => git.rebase_continue(dir).await,
};
if let Err(err) = continued {
if !git.conflicted_files(dir).await?.is_empty() {
return Ok(OperationState::Conflict);
}
return Err(err.into());
}
}
OperationState::Bisect => {
return Err(Error::Unsupported(
"a git bisect has no continue step — mark commits with `git bisect \
good`/`bad`, or end it with abort_in_progress (`bisect reset`)"
.to_string(),
));
}
OperationState::ApplyMailbox | OperationState::Clear | OperationState::Conflict => {}
}
if !git.conflicted_files(dir).await?.is_empty() {
return Ok(OperationState::Conflict);
}
in_progress_state(git, dir).await
}
pub(crate) async fn in_progress_state<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<OperationState> {
if git.is_merge_in_progress(dir).await? {
Ok(OperationState::Merge)
} else if git.is_am_in_progress(dir).await? {
Ok(OperationState::ApplyMailbox)
} else if git.is_rebase_in_progress(dir).await? {
Ok(OperationState::Rebase)
} else if git.is_cherry_pick_in_progress(dir).await? {
Ok(OperationState::CherryPick)
} else if git.is_revert_in_progress(dir).await? {
Ok(OperationState::Revert)
} else if git.is_bisect_in_progress(dir).await? {
Ok(OperationState::Bisect)
} else {
Ok(OperationState::Clear)
}
}
pub(crate) async fn list_worktrees<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
) -> Result<Vec<WorktreeInfo>> {
let worktrees = git.worktree_list(dir).await?;
Ok(worktrees
.into_iter()
.map(|w| WorktreeInfo {
path: w.path,
branch: w.branch,
commit: w.head,
is_bare: w.bare,
})
.collect())
}
pub(crate) async fn create_worktree<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
path: &Path,
branch: &str,
base: &str,
) -> Result<CreateOutcome> {
git.worktree_add(
dir,
WorktreeAdd::create_branch(path, RefName::new(branch)?, RevSpec::new(base)?),
)
.await?;
Ok(CreateOutcome::Plain)
}
pub(crate) async fn remove_worktree<R: ProcessRunner>(
git: &Git<R>,
dir: &Path,
path: &Path,
force: bool,
) -> Result<()> {
let mut spec = vcs_git::WorktreeRemove::new(path);
if force {
spec = spec.force();
}
git.worktree_remove(dir, spec).await?;
Ok(())
}
fn file_change_from_status(entry: StatusEntry) -> FileChange {
FileChange {
kind: change_kind_from_code(&entry.code),
path: entry.path,
old_path: entry.old_path,
}
}
fn change_kind_from_code(code: &str) -> ChangeKind {
if code.contains('R') {
ChangeKind::Renamed
} else if code.contains('D') {
ChangeKind::Deleted
} else if code.contains('A') || code.contains('?') || code.contains('C') {
ChangeKind::Added
} else {
ChangeKind::Modified
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_code_maps_to_change_kind() {
assert_eq!(change_kind_from_code(" M"), ChangeKind::Modified);
assert_eq!(change_kind_from_code("??"), ChangeKind::Added);
assert_eq!(change_kind_from_code("A "), ChangeKind::Added);
assert_eq!(change_kind_from_code(" D"), ChangeKind::Deleted);
assert_eq!(change_kind_from_code("R "), ChangeKind::Renamed);
assert_eq!(change_kind_from_code("C "), ChangeKind::Added);
}
}