use async_trait::async_trait;
use crate::payload::{Body, EntriesData, Entry, Payload};
use crate::render::Shape;
use crate::samples;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::{fail, open_repo, payload, repo_cache_key, text_block_body};
const SHAPES: &[Shape] = &[Shape::Entries, Shape::TextBlock];
pub struct GitWorktrees;
#[async_trait]
impl Fetcher for GitWorktrees {
fn name(&self) -> &str {
"git_worktrees"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Linked worktrees plus the main worktree, each with its checked-out branch and path. Useful when you juggle several feature branches in parallel checkouts."
}
fn refresh_interval(&self) -> u64 {
60 * 5
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn cache_key(&self, ctx: &FetchContext) -> String {
repo_cache_key(self.name(), ctx)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Entries => samples::entries(&[
("main", "/repo"),
("feat/foo", "/repo-feat"),
("hotfix", "/repo-hotfix"),
]),
Shape::TextBlock => samples::text_block(&[
"main /repo",
"feat/foo /repo-feat",
"hotfix /repo-hotfix",
]),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let repo = open_repo()?;
let list = worktrees(&repo)?;
Ok(payload(render_body(
list,
ctx.shape.unwrap_or(Shape::Entries),
)))
}
}
struct WorktreeInfo {
id: String,
branch: Option<String>,
path: String,
}
fn worktrees(repo: &gix::Repository) -> Result<Vec<WorktreeInfo>, FetchError> {
let mut out = Vec::new();
if let Some(path) = repo.workdir() {
out.push(WorktreeInfo {
id: "(main)".into(),
branch: head_branch(repo),
path: path.display().to_string(),
});
}
for proxy in repo.worktrees().map_err(fail)? {
let id = proxy.id().to_string();
let path = proxy
.base()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_default();
let branch = proxy
.into_repo_with_possibly_inaccessible_worktree()
.ok()
.as_ref()
.and_then(head_branch);
out.push(WorktreeInfo { id, branch, path });
}
Ok(out)
}
fn head_branch(repo: &gix::Repository) -> Option<String> {
repo.head_ref()
.ok()
.flatten()
.map(|r| r.name().shorten().to_string())
}
fn render_body(list: Vec<WorktreeInfo>, shape: Shape) -> Body {
match shape {
Shape::TextBlock => text_block_body(list.into_iter().map(format_line).collect()),
_ => Body::Entries(EntriesData {
items: list
.into_iter()
.map(|wt| Entry {
key: wt.id,
value: Some(format_value(&wt.branch, &wt.path)),
status: None,
})
.collect(),
}),
}
}
fn format_line(wt: WorktreeInfo) -> String {
match wt.branch {
Some(b) => format!("{} ({}) @ {}", wt.id, b, wt.path),
None => format!("{} @ {}", wt.id, wt.path),
}
}
fn format_value(branch: &Option<String>, path: &str) -> String {
match branch {
Some(b) => format!("{b} @ {path}"),
None => path.to_string(),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use super::super::test_support::{commit, make_repo};
use super::*;
use tempfile::tempdir;
fn context(shape: Shape) -> FetchContext {
FetchContext {
widget_id: "git-worktrees".into(),
format: Some("plain".into()),
timeout: Duration::from_secs(1),
file_format: None,
shape: Some(shape),
options: None,
timezone: None,
locale: None,
}
}
fn run_git(dir: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn returns_main_worktree_only_for_plain_repo() {
let (_tmp, repo) = make_repo();
commit(&repo, "init");
let list = worktrees(&repo).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, "(main)");
assert_eq!(list[0].branch.as_deref(), Some("main"));
}
#[test]
fn entries_shape_key_is_id() {
let (_tmp, repo) = make_repo();
commit(&repo, "init");
let body = render_body(worktrees(&repo).unwrap(), Shape::Entries);
match body {
Body::Entries(d) => {
assert_eq!(d.items[0].key, "(main)");
let value = d.items[0].value.as_deref().unwrap();
assert!(value.starts_with("main @ "));
}
_ => panic!(),
}
}
#[test]
fn text_block_shape_formats_each_entry() {
let (_tmp, repo) = make_repo();
commit(&repo, "init");
let body = render_body(worktrees(&repo).unwrap(), Shape::TextBlock);
match body {
Body::TextBlock(d) => {
assert_eq!(d.lines.len(), 1);
assert!(d.lines[0].starts_with("(main) (main) @ "));
}
_ => panic!(),
}
}
#[test]
fn fetcher_contract_and_samples_cover_supported_shapes() {
let fetcher = GitWorktrees;
assert_eq!(fetcher.name(), "git_worktrees");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::Entries);
assert!(fetcher.description().contains("Linked worktrees"));
assert_eq!(fetcher.shapes(), SHAPES);
assert!(matches!(
fetcher.sample_body(Shape::Entries),
Some(Body::Entries(_))
));
assert!(matches!(
fetcher.sample_body(Shape::TextBlock),
Some(Body::TextBlock(_))
));
assert!(fetcher.sample_body(Shape::Text).is_none());
let entries_key = fetcher.cache_key(&context(Shape::Entries));
let text_block_key = fetcher.cache_key(&context(Shape::TextBlock));
assert!(entries_key.starts_with("git_worktrees-"));
assert_ne!(entries_key, text_block_key);
}
#[test]
fn worktrees_include_linked_and_detached_checkouts() {
let (_tmp, repo) = make_repo();
commit(&repo, "init");
let root = repo.workdir().unwrap().to_path_buf();
let linked_root = tempdir().unwrap();
let feature = linked_root.path().join("feature");
let detached = linked_root.path().join("detached");
run_git(
&root,
&[
"worktree",
"add",
"-b",
"feat/demo",
feature.to_str().unwrap(),
"HEAD",
],
);
run_git(
&root,
&[
"worktree",
"add",
"--detach",
detached.to_str().unwrap(),
"HEAD",
],
);
let repo = gix::discover(&root).unwrap();
let list = worktrees(&repo).unwrap();
assert_eq!(list.len(), 3);
let feature_canon = std::fs::canonicalize(&feature).unwrap();
let detached_canon = std::fs::canonicalize(&detached).unwrap();
let same_path = |wt: &&WorktreeInfo, canon: &Path| {
std::fs::canonicalize(&wt.path)
.map(|p| p == canon)
.unwrap_or(false)
};
let feature_wt = list
.iter()
.find(|wt| same_path(wt, &feature_canon))
.unwrap();
assert_eq!(feature_wt.branch.as_deref(), Some("feat/demo"));
let detached_wt = list
.iter()
.find(|wt| same_path(wt, &detached_canon))
.unwrap();
assert!(detached_wt.branch.is_none());
assert_ne!(feature_wt.id, detached_wt.id);
}
#[test]
fn detached_worktree_output_omits_branch_name() {
let text_body = render_body(
vec![WorktreeInfo {
id: "detached".into(),
branch: None,
path: "/tmp/detached".into(),
}],
Shape::TextBlock,
);
let Body::TextBlock(text) = text_body else {
panic!("expected text block");
};
assert_eq!(text.lines, vec!["detached @ /tmp/detached".to_string()]);
let entries_body = render_body(
vec![WorktreeInfo {
id: "detached".into(),
branch: None,
path: "/tmp/detached".into(),
}],
Shape::Entries,
);
let Body::Entries(entries) = entries_body else {
panic!("expected entries");
};
assert_eq!(entries.items[0].value.as_deref(), Some("/tmp/detached"));
}
}