#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
use anyhow::{Context, Result};
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FzfItem {
pub display: String,
pub value: String,
}
pub trait FzfPicker {
fn pick(&self, items: &[FzfItem], multi: bool) -> Result<Vec<String>>;
}
#[derive(Debug)]
pub struct RealFzfPicker {
extra_options: Vec<String>,
}
impl RealFzfPicker {
pub const fn new(extra_options: Vec<String>) -> Self {
Self { extra_options }
}
}
impl FzfPicker for RealFzfPicker {
fn pick(&self, items: &[FzfItem], multi: bool) -> Result<Vec<String>> {
if items.is_empty() {
return Ok(Vec::new());
}
let input = items
.iter()
.map(|item| item.display.clone())
.collect::<Vec<_>>()
.join("\n");
let mut cmd = Command::new("fzf");
if multi {
cmd.arg("--multi");
}
for opt in &self.extra_options {
cmd.arg(opt);
}
let preview_cmd =
"echo {} | awk '{print $NF}' | xargs -I % git -C % log --oneline -n 10 2>/dev/null";
cmd.arg("--preview").arg(preview_cmd);
cmd.arg("--height=50%")
.arg("--reverse")
.arg("--border")
.arg("--prompt=Select worktree: ");
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = cmd.spawn().context("Failed to spawn fzf")?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(input.as_bytes())
.context("Failed to write to fzf stdin")?;
stdin.flush().context("Failed to flush fzf stdin")?;
}
let output = child.wait_with_output().context("Failed to wait for fzf")?;
match output.status.code() {
Some(0) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let selected_displays: Vec<&str> = stdout.lines().collect();
let mut results = Vec::new();
for display in selected_displays {
if let Some(item) = items.iter().find(|item| item.display == display) {
results.push(item.value.clone());
}
}
Ok(results)
}
Some(130 | 1) => {
Ok(Vec::new())
}
Some(code) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("fzf exited with code {code}: {stderr}")
}
None => {
anyhow::bail!("fzf was terminated by signal")
}
}
}
}
pub fn is_fzf_available() -> bool {
Command::new("fzf")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn build_worktree_items(porcelain_output: &str) -> Vec<FzfItem> {
let mut items = Vec::new();
let mut current_path: Option<String> = None;
let mut current_branch: Option<String> = None;
for line in porcelain_output.lines() {
let line = line.trim();
if line.is_empty() {
if let Some(path) = current_path.take() {
let display = current_branch.take().map_or_else(
|| format!("(detached) {path}"),
|branch| format!("{branch} {path}"),
);
items.push(FzfItem {
display,
value: path,
});
}
continue;
}
if let Some(path) = line.strip_prefix("worktree ") {
current_path = Some(path.to_string());
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
if let Some(branch_name) = branch_ref.strip_prefix("refs/heads/") {
current_branch = Some(branch_name.to_string());
}
} else if line == "detached" {
current_branch = None;
}
}
if let Some(path) = current_path {
let display = current_branch.map_or_else(
|| format!("(detached) {path}"),
|branch| format!("{branch} {path}"),
);
items.push(FzfItem {
display,
value: path,
});
}
items
}
#[cfg(test)]
mod tests {
use super::*;
struct MockFzfPicker {
return_values: Vec<String>,
should_fail: bool,
}
impl FzfPicker for MockFzfPicker {
fn pick(&self, _items: &[FzfItem], _multi: bool) -> Result<Vec<String>> {
if self.should_fail {
anyhow::bail!("Mock fzf failure");
}
Ok(self.return_values.clone())
}
}
#[test]
fn test_fzf_item_creation() {
let item = FzfItem {
display: "feature-branch /path/to/worktree".to_string(),
value: "/path/to/worktree".to_string(),
};
assert_eq!(item.display, "feature-branch /path/to/worktree");
assert_eq!(item.value, "/path/to/worktree");
}
#[test]
fn test_mock_fzf_picker_success() {
let picker = MockFzfPicker {
return_values: vec!["/path/to/worktree".to_string()],
should_fail: false,
};
let items = vec![FzfItem {
display: "test".to_string(),
value: "/path/to/worktree".to_string(),
}];
let result = picker.pick(&items, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), vec!["/path/to/worktree"]);
}
#[test]
fn test_mock_fzf_picker_failure() {
let picker = MockFzfPicker {
return_values: Vec::new(),
should_fail: true,
};
let items = vec![FzfItem {
display: "test".to_string(),
value: "/test".to_string(),
}];
let result = picker.pick(&items, false);
assert!(result.is_err());
}
#[test]
fn test_mock_fzf_picker_cancel() {
let picker = MockFzfPicker {
return_values: Vec::new(),
should_fail: false,
};
let items = vec![FzfItem {
display: "test".to_string(),
value: "/test".to_string(),
}];
let result = picker.pick(&items, false);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_is_fzf_available() {
let _ = is_fzf_available();
}
#[test]
fn test_build_worktree_items_basic() {
let porcelain = r"worktree /path/to/main
HEAD abc123
branch refs/heads/main
worktree /path/to/feature
HEAD def456
branch refs/heads/feature-branch
";
let items = build_worktree_items(porcelain);
assert_eq!(items.len(), 2);
assert_eq!(items[0].value, "/path/to/main");
assert!(items[0].display.contains("main"));
assert_eq!(items[1].value, "/path/to/feature");
assert!(items[1].display.contains("feature-branch"));
}
#[test]
fn test_build_worktree_items_detached() {
let porcelain = r"worktree /path/to/detached
HEAD abc123
detached
";
let items = build_worktree_items(porcelain);
assert_eq!(items.len(), 1);
assert_eq!(items[0].value, "/path/to/detached");
assert!(items[0].display.contains("(detached)"));
}
}