use anyhow::Result;
use std::process::Command;
pub struct Repo {
pub path: String,
pub name: String,
pub identifier: String,
pub current_branch: String,
pub pinned: bool,
pub pin_index: i32,
}
pub fn new(path: &str, name: &str) -> Repo {
let (identifier, current_branch) = read_repo_metadata(path);
Repo {
path: path.to_string(),
name: name.to_string(),
identifier,
current_branch,
pinned: false,
pin_index: -1,
}
}
pub fn new_pinned(path: &str, name: &str, pin_index: i32) -> Repo {
let (identifier, current_branch) = read_repo_metadata(path);
Repo {
path: path.to_string(),
name: name.to_string(),
identifier,
current_branch,
pinned: true,
pin_index,
}
}
pub fn normalize_remote_url(url: &str) -> Option<String> {
let url = url.trim();
if url.is_empty() {
return None;
}
let url = url.trim_end_matches(".git");
if let Some(rest) = url.strip_prefix("git@") {
return Some(rest.replacen(':', "/", 1));
}
if let Some(rest) = url.strip_prefix("ssh://") {
return Some(rest.strip_prefix("git@").unwrap_or(rest).to_string());
}
if let Some(rest) = url.strip_prefix("https://") {
return Some(rest.to_string());
}
if let Some(rest) = url.strip_prefix("http://") {
return Some(rest.to_string());
}
None
}
#[allow(dead_code)]
pub fn identifier_from_path(repo_path: &str) -> String {
read_repo_metadata(repo_path).0
}
fn read_repo_metadata(repo_path: &str) -> (String, String) {
let repo = match git2::Repository::open(repo_path) {
Ok(r) => r,
Err(_) => return (repo_path.to_string(), String::new()),
};
let identifier = read_identifier(&repo, repo_path);
let current_branch = read_current_branch(&repo);
(identifier, current_branch)
}
fn read_identifier(repo: &git2::Repository, fallback: &str) -> String {
repo
.find_remote("origin")
.ok()
.and_then(|r| r.url().map(|s| s.to_string()))
.and_then(|url| normalize_remote_url(&url))
.unwrap_or_else(|| fallback.to_string())
}
fn read_current_branch(repo: &git2::Repository) -> String {
let head = match repo.head() {
Ok(h) => h,
Err(_) => return String::new(),
};
if head.is_branch() {
return head.shorthand().unwrap_or("").to_string();
}
head
.target()
.map(|oid| {
let hex = oid.to_string();
hex.get(..7).unwrap_or(&hex).to_string()
})
.unwrap_or_default()
}
pub fn list_branches(repo: &Repo) -> Vec<String> {
let output = Command::new("git")
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads/"])
.current_dir(&repo.path)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Vec::new();
}
trimmed.lines().map(|s| s.to_string()).collect()
}
Err(_) => Vec::new(),
}
}
pub fn is_merged(repo: &Repo, branch: &str, targets: &[String]) -> bool {
targets
.iter()
.any(|target| is_merged_into(repo, branch, target))
}
fn is_merged_into(repo: &Repo, branch: &str, target: &str) -> bool {
Command::new("git")
.args(["merge-base", "--is-ancestor", branch, target])
.current_dir(&repo.path)
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
pub fn is_stale(repo: &Repo, branch: &str) -> bool {
let output = Command::new("git")
.args([
"for-each-ref",
"--format=%(upstream:track)",
&format!("refs/heads/{branch}"),
])
.current_dir(&repo.path)
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.contains("[gone]")
}
Err(_) => false,
}
}
pub fn has_worktree(repo: &Repo, branch: &str) -> bool {
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&repo.path)
.output();
let expected = format!("branch refs/heads/{branch}");
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.lines().any(|line| line == expected)
}
Err(_) => false,
}
}
pub fn delete_branch(repo: &Repo, branch: &str) -> Result<()> {
let output = Command::new("git")
.args(["branch", "-D", branch])
.current_dir(&repo.path)
.output()?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git branch -D failed: {}", stderr.trim())
}
#[cfg(test)]
mod tests {
use super::*;
fn configure_test_identity(path: &std::path::Path) {
for args in [
vec!["config", "user.name", "test"],
vec!["config", "user.email", "test@test.com"],
] {
std::process::Command::new("git")
.args(&args)
.current_dir(path)
.output()
.unwrap();
}
}
#[test]
fn normalize_ssh_with_git_suffix() {
assert_eq!(
normalize_remote_url("git@github.com:user/repo.git"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_ssh_without_git_suffix() {
assert_eq!(
normalize_remote_url("git@github.com:user/repo"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_https_with_git_suffix() {
assert_eq!(
normalize_remote_url("https://github.com/user/repo.git"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_https_without_git_suffix() {
assert_eq!(
normalize_remote_url("https://github.com/user/repo"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_ssh_protocol_with_git_suffix() {
assert_eq!(
normalize_remote_url("ssh://git@github.com/user/repo.git"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_ssh_protocol_without_git_suffix() {
assert_eq!(
normalize_remote_url("ssh://git@github.com/user/repo"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_http_format() {
assert_eq!(
normalize_remote_url("http://github.com/user/repo.git"),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_empty_string() {
assert_eq!(normalize_remote_url(""), None);
}
#[test]
fn normalize_whitespace_only() {
assert_eq!(normalize_remote_url(" "), None);
}
#[test]
fn normalize_with_surrounding_whitespace() {
assert_eq!(
normalize_remote_url(" git@github.com:user/repo.git "),
Some("github.com/user/repo".to_string())
);
}
#[test]
fn normalize_unrecognized_format() {
assert_eq!(normalize_remote_url("not-a-url"), None);
}
#[test]
fn new_repo_with_origin_remote() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let git_repo = git2::Repository::init(path).unwrap();
git_repo
.remote("origin", "git@github.com:testuser/testrepo.git")
.unwrap();
let repo = new(path.to_str().unwrap(), "testrepo");
assert_eq!(repo.identifier, "github.com/testuser/testrepo");
assert_eq!(repo.name, "testrepo");
assert!(!repo.pinned);
assert_eq!(repo.pin_index, -1);
}
#[test]
fn new_repo_without_origin_falls_back_to_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
git2::Repository::init(path).unwrap();
let path_str = path.to_str().unwrap();
let repo = new(path_str, "localrepo");
assert_eq!(repo.identifier, path_str);
}
#[test]
fn new_pinned_repo() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let git_repo = git2::Repository::init(path).unwrap();
git_repo
.remote("origin", "https://github.com/myorg/myrepo.git")
.unwrap();
let repo = new_pinned(path.to_str().unwrap(), "myrepo", 2);
assert_eq!(repo.identifier, "github.com/myorg/myrepo");
assert!(repo.pinned);
assert_eq!(repo.pin_index, 2);
}
#[test]
fn identifier_from_path_with_origin() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let git_repo = git2::Repository::init(path).unwrap();
git_repo
.remote("origin", "git@github.com:org/project.git")
.unwrap();
assert_eq!(
identifier_from_path(path.to_str().unwrap()),
"github.com/org/project"
);
}
#[test]
fn identifier_from_path_non_git_falls_back() {
let dir = tempfile::tempdir().unwrap();
let path_str = dir.path().to_str().unwrap().to_string();
assert_eq!(identifier_from_path(&path_str), path_str);
}
#[test]
fn list_branches_returns_created_branches() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let git_repo = git2::Repository::init(path).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let tree_id = {
let mut index = git_repo.index().unwrap();
std::fs::write(path.join("test.txt"), "initial").unwrap();
index.add_path(std::path::Path::new("test.txt")).unwrap();
index.write().unwrap();
index.write_tree().unwrap()
};
let tree = git_repo.find_tree(tree_id).unwrap();
git_repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
std::process::Command::new("git")
.args(["branch", "feature-1"])
.current_dir(path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["branch", "feature-2"])
.current_dir(path)
.output()
.unwrap();
let repo = new(path.to_str().unwrap(), "test-repo");
let branches = list_branches(&repo);
let default_branch = &repo.current_branch;
assert_eq!(branches.len(), 3);
assert!(branches.contains(default_branch));
assert!(branches.contains(&"feature-1".to_string()));
assert!(branches.contains(&"feature-2".to_string()));
}
#[test]
fn is_merged_detects_merged_branch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let path_str = path.to_str().unwrap();
git2::Repository::init(path).unwrap();
configure_test_identity(path);
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "initial"])
.current_dir(path)
.output()
.unwrap();
let default_branch = {
let r = new(path_str, "tmp");
r.current_branch
};
let cmds = [
vec!["git", "checkout", "-b", "feature-merged"],
vec!["git", "commit", "--allow-empty", "-m", "feature"],
vec!["git", "checkout", &default_branch],
vec!["git", "merge", "--no-ff", "feature-merged", "-m", "merge"],
vec!["git", "checkout", "-b", "feature-unmerged"],
vec!["git", "commit", "--allow-empty", "-m", "unmerged"],
vec!["git", "checkout", &default_branch],
];
for cmd in &cmds {
let status = std::process::Command::new(cmd[0])
.args(&cmd[1..])
.current_dir(path)
.output()
.unwrap();
assert!(
status.status.success(),
"Command {:?} failed: {}",
cmd,
String::from_utf8_lossy(&status.stderr)
);
}
let repo = new(path_str, "test-repo");
let targets = vec![default_branch];
assert!(is_merged(&repo, "feature-merged", &targets));
assert!(!is_merged(&repo, "feature-unmerged", &targets));
}
#[test]
fn is_stale_local_branch_not_stale() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
git2::Repository::init(path).unwrap();
configure_test_identity(path);
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "initial"])
.current_dir(path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["branch", "local-only"])
.current_dir(path)
.output()
.unwrap();
let repo = new(path.to_str().unwrap(), "test-repo");
assert!(!is_stale(&repo, "local-only"));
}
#[test]
fn has_worktree_detects_worktree() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
git2::Repository::init(path).unwrap();
configure_test_identity(path);
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "initial"])
.current_dir(path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["branch", "with-worktree"])
.current_dir(path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["branch", "without-worktree"])
.current_dir(path)
.output()
.unwrap();
let wt_dir = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args([
"worktree",
"add",
wt_dir.path().to_str().unwrap(),
"with-worktree",
])
.current_dir(path)
.output()
.unwrap();
let repo = new(path.to_str().unwrap(), "test-repo");
assert!(has_worktree(&repo, "with-worktree"));
assert!(!has_worktree(&repo, "without-worktree"));
}
}