use std::path::Path;
use std::process::{Command, Stdio};
pub fn branches(dir: &Path) -> Result<(Vec<String>, Option<usize>), String> {
let output = git(dir, &["for-each-ref", "--format=%(HEAD)%00%(refname:short)", "refs/heads/"])?;
let mut names = Vec::new();
let mut current = None;
for line in output.lines() {
let Some((head, name)) = line.split_once('\0') else {
continue;
};
if head == "*" {
current = Some(names.len());
}
names.push(name.to_string());
}
Ok((names, current))
}
pub fn switch(dir: &Path, branch: &str) -> Result<(), String> {
git(dir, &["switch", "--no-guess", branch]).map(drop)
}
fn git(dir: &Path, arguments: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(arguments)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output()
.map_err(|e| format!("Cannot run git: {e}"))?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
let said = String::from_utf8_lossy(&output.stderr).split_whitespace().collect::<Vec<_>>().join(" ");
let said = said.strip_prefix("error: ").or_else(|| said.strip_prefix("fatal: ")).unwrap_or(&said);
let message: String = said.chars().map(|c| if c.is_control() { '.' } else { c }).collect();
Err(if message.is_empty() { format!("git {} failed", arguments[0]) } else { message })
}
#[cfg(test)]
mod tests {
use super::*;
fn repository(name: &str) -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(format!("fm84-git-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |arguments: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&dir)
.args(["-c", "user.name=fm84", "-c", "user.email=fm84@localhost", "-c", "commit.gpgsign=false"])
.args(arguments)
.output()
.is_ok_and(|output| output.status.success())
};
if !run(&["init", "--quiet", "--initial-branch=main"]) {
let _ = std::fs::remove_dir_all(&dir);
return None;
}
std::fs::write(dir.join("file.txt"), "main\n").unwrap();
assert!(run(&["add", "file.txt"]));
assert!(run(&["commit", "--quiet", "-m", "first"]));
assert!(run(&["branch", "feature/tabs"]));
Some(dir)
}
#[test]
fn the_branches_and_which_is_checked_out_and_changing_it() {
let Some(dir) = repository("switch") else {
return;
};
assert_eq!(branches(&dir).unwrap(), (vec!["feature/tabs".to_string(), "main".to_string()], Some(1)));
std::fs::create_dir_all(dir.join("sub")).unwrap();
assert_eq!(branches(&dir.join("sub")).unwrap().1, Some(1));
switch(&dir, "feature/tabs").unwrap();
assert_eq!(branches(&dir).unwrap().1, Some(0));
assert_eq!(crate::fs_ops::git_branch(&dir).as_deref(), Some("feature/tabs"));
let refused = switch(&dir, "nowhere").unwrap_err();
assert!(refused.contains("nowhere"), "{refused}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn outside_a_repository_is_an_error() {
let dir = std::env::temp_dir().join(format!("fm84-git-none-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
if crate::fs_ops::git_branch(&dir).is_none() {
assert!(branches(&dir).is_err());
}
std::fs::remove_dir_all(&dir).unwrap();
}
}