#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
use anyhow::{Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::domain::worktree::{
calculate_relative_path, calculate_worktree_root_from_paths, display_path,
};
#[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}' | sed \"s|^~|$HOME|\" | 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()
.is_ok_and(|output| output.status.success())
}
struct ParsedWorktree {
path: String,
branch: Option<String>,
}
pub fn build_worktree_items(porcelain_output: &str) -> Vec<FzfItem> {
let mut entries = 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() {
entries.push(ParsedWorktree {
path,
branch: current_branch.take(),
});
}
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 {
entries.push(ParsedWorktree {
path,
branch: current_branch,
});
}
if entries.is_empty() {
return Vec::new();
}
let non_main_paths: Vec<PathBuf> = entries
.iter()
.skip(1)
.map(|e| PathBuf::from(&e.path))
.collect();
let worktree_root = calculate_worktree_root_from_paths(&non_main_paths);
let display_entries: Vec<(String, String, String)> = entries
.iter()
.enumerate()
.map(|(index, entry)| {
let name = if index == 0 {
"@".to_string()
} else {
worktree_root
.as_ref()
.and_then(|root| calculate_relative_path(&PathBuf::from(&entry.path), root))
.unwrap_or_else(|| {
PathBuf::from(&entry.path)
.file_name()
.map_or_else(|| entry.path.clone(), |n| n.to_string_lossy().to_string())
})
};
let branch = if index == 0 {
"[@]".to_string()
} else {
entry
.branch
.as_deref()
.map_or_else(|| "[detached]".to_string(), |b| format!("[{b}]"))
};
let path = display_path(&PathBuf::from(&entry.path));
(name, branch, path)
})
.collect();
let max_name_width = display_entries
.iter()
.map(|(n, _, _)| n.len())
.max()
.unwrap_or(0);
let max_branch_width = display_entries
.iter()
.map(|(_, b, _)| b.len())
.max()
.unwrap_or(0);
display_entries
.into_iter()
.zip(entries.iter())
.map(|((name, branch, path), entry)| {
let name_padding = " ".repeat(max_name_width.saturating_sub(name.len()));
let branch_padding = " ".repeat(max_branch_width.saturating_sub(branch.len()));
let display = format!("{name}{name_padding} · {branch}{branch_padding} · {path}");
FzfItem {
display,
value: entry.path.clone(),
}
})
.collect()
}
#[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 · feat/new · /path/to/worktree".to_string(),
value: "/path/to/worktree".to_string(),
};
assert_eq!(item.display, "feature · feat/new · /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.starts_with('@'));
assert!(items[0].display.contains(" · [@]"));
assert!(items[0].display.contains(" · /path/to/main"));
assert_eq!(items[1].value, "/path/to/feature");
assert!(items[1].display.contains("feature"));
assert!(items[1].display.contains(" · [feature-branch]"));
assert!(items[1].display.contains(" · /path/to/feature"));
}
#[test]
fn test_build_worktree_items_detached() {
let porcelain = r"worktree /path/to/main
HEAD abc123
branch refs/heads/main
worktree /path/to/detached
HEAD def456
detached
";
let items = build_worktree_items(porcelain);
assert_eq!(items.len(), 2);
assert_eq!(items[1].value, "/path/to/detached");
assert!(items[1].display.contains("detached"));
assert!(items[1].display.contains(" · [detached]"));
}
#[test]
fn test_build_worktree_items_single_main_only() {
let porcelain = r"worktree /path/to/main
HEAD abc123
branch refs/heads/main
";
let items = build_worktree_items(porcelain);
assert_eq!(items.len(), 1);
assert_eq!(items[0].value, "/path/to/main");
assert!(items[0].display.starts_with('@'));
assert!(items[0].display.contains(" · [@]"));
}
#[test]
fn test_build_worktree_items_nested_branch() {
let porcelain = r"worktree /path/to/main
HEAD abc123
branch refs/heads/main
worktree /worktrees/feat/foo
HEAD def456
branch refs/heads/feat/foo
worktree /worktrees/fix/bar
HEAD ghi789
branch refs/heads/fix/bar
";
let items = build_worktree_items(porcelain);
assert_eq!(items.len(), 3);
assert_eq!(items[1].value, "/worktrees/feat/foo");
assert!(items[1].display.contains("feat/foo"));
assert!(items[1].display.contains(" · [feat/foo]"));
assert_eq!(items[2].value, "/worktrees/fix/bar");
assert!(items[2].display.contains(" · [fix/bar]"));
}
#[test]
fn test_build_worktree_items_padding_alignment() {
let porcelain = r"worktree /path/to/main
HEAD abc123
branch refs/heads/main
worktree /worktrees/a
HEAD def456
branch refs/heads/short
worktree /worktrees/long-name
HEAD ghi789
branch refs/heads/very-long-branch-name
";
let items = build_worktree_items(porcelain);
assert_eq!(items.len(), 3);
let first_sep_positions: Vec<usize> = items
.iter()
.map(|item| item.display.find(" · ").unwrap())
.collect();
assert!(
first_sep_positions.windows(2).all(|w| w[0] == w[1]),
"First separator positions should be aligned: {first_sep_positions:?}"
);
let second_sep_positions: Vec<usize> = items
.iter()
.map(|item| {
let after_first = first_sep_positions[0] + 3;
after_first + item.display[after_first..].find(" · ").unwrap()
})
.collect();
assert!(
second_sep_positions.windows(2).all(|w| w[0] == w[1]),
"Second separator positions should be aligned: {second_sep_positions:?}"
);
}
#[test]
fn test_build_worktree_items_no_trailing_whitespace() {
let porcelain = r"worktree /path/to/main
HEAD abc123
branch refs/heads/main
worktree /worktrees/feature
HEAD def456
branch refs/heads/feature-branch
";
let items = build_worktree_items(porcelain);
for item in &items {
assert_eq!(
item.display,
item.display.trim_end(),
"Display should not have trailing whitespace: {:?}",
item.display
);
}
}
}