use crate::{FileRefStore, Ref, RefTarget, refname_pattern_matches_case};
use sley_core::GitError;
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchListScope {
Local,
Remote,
All,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchListOptions {
pub scope: BranchListScope,
pub patterns: Vec<String>,
pub ignore_case: bool,
}
impl BranchListOptions {
#[must_use]
pub const fn all_in(scope: BranchListScope) -> Self {
Self {
scope,
patterns: Vec::new(),
ignore_case: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchListOutcome {
pub refs: Vec<Ref>,
pub current_branch_ref: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchTransferKind {
Move,
Copy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchTransferOptions {
pub kind: BranchTransferKind,
pub source: String,
pub destination: String,
pub force: bool,
pub committer: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchTransferOutcome {
pub source_ref: String,
pub destination_ref: String,
pub target: RefTarget,
pub source_deleted: bool,
}
#[derive(Debug)]
pub enum BranchOperationError {
Backend(GitError),
}
impl BranchOperationError {
#[must_use]
pub fn into_git_error(self) -> GitError {
match self {
Self::Backend(error) => error,
}
}
}
impl fmt::Display for BranchOperationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Backend(error) => write!(formatter, "branch reference operation failed: {error}"),
}
}
}
impl Error for BranchOperationError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Backend(error) => Some(error),
}
}
}
impl From<GitError> for BranchOperationError {
fn from(error: GitError) -> Self {
Self::Backend(error)
}
}
pub fn list_branches(
store: &FileRefStore,
options: &BranchListOptions,
) -> Result<BranchListOutcome, BranchOperationError> {
let mut refs = match options.scope {
BranchListScope::Local => store.list_refs_with_prefix("refs/heads/")?,
BranchListScope::Remote => store.list_refs_with_prefix("refs/remotes/")?,
BranchListScope::All => {
let mut refs = store.list_refs_with_prefix("refs/heads/")?;
refs.extend(store.list_refs_with_prefix("refs/remotes/")?);
refs
}
};
refs.retain(|reference| {
let Some(short_name) = branch_short_name(&reference.name, options.scope) else {
return false;
};
options.patterns.is_empty()
|| options.patterns.iter().any(|pattern| {
refname_pattern_matches_case(pattern, short_name, options.ignore_case)
})
});
refs.sort_by(|left, right| left.name.cmp(&right.name));
Ok(BranchListOutcome {
refs,
current_branch_ref: store.current_branch_ref()?,
})
}
pub fn transfer_branch(
store: &FileRefStore,
options: BranchTransferOptions,
) -> Result<BranchTransferOutcome, BranchOperationError> {
let source_ref = format!("refs/heads/{}", options.source);
let destination_ref = format!("refs/heads/{}", options.destination);
let target = store
.read_ref(&source_ref)?
.ok_or_else(|| GitError::reference_not_found(format!("branch {}", options.source)))?;
match options.kind {
BranchTransferKind::Move => store.move_branch(
&options.source,
&options.destination,
options.force,
options.committer,
)?,
BranchTransferKind::Copy => store.copy_branch(
&options.source,
&options.destination,
options.force,
options.committer,
)?,
}
Ok(BranchTransferOutcome {
source_ref,
destination_ref,
target,
source_deleted: matches!(options.kind, BranchTransferKind::Move),
})
}
fn branch_short_name(name: &str, scope: BranchListScope) -> Option<&str> {
if matches!(scope, BranchListScope::Local | BranchListScope::All)
&& let Some(name) = name.strip_prefix("refs/heads/")
{
return Some(name);
}
if matches!(scope, BranchListScope::Remote | BranchListScope::All) {
return name.strip_prefix("refs/remotes/");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RefUpdate;
use sley_core::{ObjectFormat, ObjectId};
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn fixture() -> (std::path::PathBuf, FileRefStore, ObjectId) {
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("sley-refs-branch-{}-{counter}", std::process::id()));
fs::create_dir_all(root.join("refs/heads"))
.expect("branch fixture should create heads directory");
fs::create_dir_all(root.join("refs/remotes/origin"))
.expect("branch fixture should create remote directory");
let store = FileRefStore::new(&root, ObjectFormat::Sha1);
let oid = ObjectId::from_hex(
ObjectFormat::Sha1,
"1111111111111111111111111111111111111111",
)
.expect("fixture object id should parse");
let mut transaction = store.transaction();
for name in [
"refs/heads/main",
"refs/heads/topic/One",
"refs/remotes/origin/main",
] {
transaction.update(RefUpdate {
name: name.into(),
expected: None,
new: RefTarget::Direct(oid),
reflog: None,
});
}
transaction.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Symbolic("refs/heads/main".into()),
reflog: None,
});
transaction
.commit()
.expect("branch fixture refs should commit");
(root, store, oid)
}
#[test]
fn list_options_select_scope_patterns_and_current_branch() {
let (root, store, _) = fixture();
let outcome = list_branches(
&store,
&BranchListOptions {
scope: BranchListScope::All,
patterns: vec!["*one".into()],
ignore_case: true,
},
)
.expect("branch query should succeed");
assert_eq!(outcome.refs.len(), 1);
assert_eq!(outcome.refs[0].name, "refs/heads/topic/One");
assert_eq!(
outcome.current_branch_ref.as_deref(),
Some("refs/heads/main")
);
fs::remove_dir_all(root).expect("branch fixture should be removed");
}
#[test]
fn transfer_returns_typed_copy_and_move_outcomes() {
let (root, store, oid) = fixture();
let copied = transfer_branch(
&store,
BranchTransferOptions {
kind: BranchTransferKind::Copy,
source: "main".into(),
destination: "copy".into(),
force: false,
committer: b"A U Thor <a@example.com> 1 +0000".to_vec(),
},
)
.expect("branch copy should succeed");
assert_eq!(copied.target, RefTarget::Direct(oid));
assert!(!copied.source_deleted);
let moved = transfer_branch(
&store,
BranchTransferOptions {
kind: BranchTransferKind::Move,
source: "copy".into(),
destination: "moved".into(),
force: false,
committer: b"A U Thor <a@example.com> 2 +0000".to_vec(),
},
)
.expect("branch move should succeed");
assert!(moved.source_deleted);
assert!(
store
.read_ref("refs/heads/copy")
.expect("copy ref read should succeed")
.is_none()
);
assert_eq!(
store
.read_ref("refs/heads/moved")
.expect("moved ref read should succeed"),
Some(RefTarget::Direct(oid))
);
fs::remove_dir_all(root).expect("branch fixture should be removed");
}
#[test]
fn operation_errors_retain_backend_source() {
let (root, store, _) = fixture();
let error = transfer_branch(
&store,
BranchTransferOptions {
kind: BranchTransferKind::Move,
source: "missing".into(),
destination: "new".into(),
force: false,
committer: Vec::new(),
},
)
.expect_err("missing source should fail");
assert!(error.source().is_some());
fs::remove_dir_all(root).expect("branch fixture should be removed");
}
}