use std::collections::HashMap;
use reedline::{Span, Suggestion};
fn current_branch() -> Option<String> {
let repo = git2::Repository::discover(".").ok()?;
let head = repo.head().ok()?;
head.shorthand().map(str::to_string)
}
impl super::JarvishCompleter {
pub(super) fn try_complete_git(
&self,
tokens: &[&str],
partial: &str,
span: Span,
) -> Option<Vec<Suggestion>> {
let first_token = tokens.first().copied().unwrap_or("");
if first_token != "git" || tokens.len() < 2 {
return None;
}
let subcmd = tokens[1];
let commands = self.git_branch_commands.read().ok()?;
if commands.iter().any(|c| c == subcmd) {
return Some(self.complete_git_branch(partial, span));
}
if let Some(resolved) = self.resolve_git_alias(subcmd) {
let main_cmd = resolved.split_whitespace().next().unwrap_or("");
if commands.iter().any(|c| c == main_cmd) {
return Some(self.complete_git_branch(partial, span));
}
}
None
}
pub(super) fn complete_git_branch(&self, partial: &str, span: Span) -> Vec<Suggestion> {
let output = match std::process::Command::new("git")
.args(["branch", "--format=%(refname:short)"])
.stderr(std::process::Stdio::null())
.output()
{
Ok(o) if o.status.success() => o,
_ => return vec![],
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut branches: Vec<&str> = stdout.lines().filter(|b| b.starts_with(partial)).collect();
branches.sort_unstable();
branches.dedup();
if let Some(ref current) = current_branch() {
if let Some(pos) = branches.iter().position(|b| *b == current.as_str()) {
let branch = branches.remove(pos);
branches.insert(0, branch);
}
}
branches
.into_iter()
.map(|branch| Suggestion {
value: branch.to_string(),
description: None,
style: None,
extra: None,
span,
append_whitespace: true,
match_indices: None,
})
.collect()
}
pub(super) fn resolve_git_alias(&self, alias: &str) -> Option<String> {
let cwd = std::env::current_dir().ok()?;
if let Ok(cache) = self.git_aliases_cache.read() {
if let Some(aliases) = cache.get(&cwd) {
return aliases.get(alias).cloned();
}
}
let aliases_map = Self::fetch_git_aliases();
let result = aliases_map.get(alias).cloned();
if let Ok(mut cache) = self.git_aliases_cache.write() {
cache.insert(cwd, aliases_map);
}
result
}
fn fetch_git_aliases() -> HashMap<String, String> {
let output = match std::process::Command::new("git")
.args(["config", "--get-regexp", "^alias\\."])
.stderr(std::process::Stdio::null())
.output()
{
Ok(o) if o.status.success() => o,
_ => return HashMap::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix("alias.") {
if let Some((name, value)) = rest.split_once(' ') {
map.insert(name.to_string(), value.to_string());
}
}
}
map
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, RwLock};
use reedline::Span;
use serial_test::serial;
use std::env;
use crate::cli::completer::JarvishCompleter;
use crate::config::CompletionConfig;
fn test_completer() -> JarvishCompleter {
let commands = CompletionConfig::default().git_branch_commands;
JarvishCompleter::new(Arc::new(RwLock::new(commands)))
}
fn create_test_git_repo() -> tempfile::TempDir {
use std::process::Command;
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path();
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["branch", "test-feature"])
.current_dir(dir)
.output()
.unwrap();
tmpdir
}
fn create_test_git_repo_with_aliases() -> tempfile::TempDir {
use std::process::Command;
let tmpdir = create_test_git_repo();
let dir = tmpdir.path();
Command::new("git")
.args(["config", "alias.co", "checkout"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "alias.nb", "checkout -b"])
.current_dir(dir)
.output()
.unwrap();
tmpdir
}
#[test]
#[serial]
fn complete_git_branch_returns_candidates() {
let tmpdir = create_test_git_repo();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let span = Span::new(0, 0);
let suggestions = completer.complete_git_branch("", span);
env::set_current_dir(&original_dir).unwrap();
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(
values.contains(&"test-feature"),
"test-feature branch should be in suggestions: {values:?}"
);
}
#[test]
#[serial]
fn complete_git_branch_filters_by_prefix() {
let tmpdir = create_test_git_repo();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let span = Span::new(0, 5);
let suggestions = completer.complete_git_branch("test-", span);
env::set_current_dir(&original_dir).unwrap();
let values: Vec<&str> = suggestions.iter().map(|s| s.value.as_str()).collect();
assert!(values.contains(&"test-feature"));
for v in &values {
assert!(v.starts_with("test-"), "'{v}' should start with 'test-'");
}
}
#[test]
fn complete_git_branch_nonexistent_prefix_returns_empty() {
let completer = test_completer();
let span = Span::new(0, 0);
let suggestions = completer.complete_git_branch("zzz_no_such_branch_", span);
assert!(suggestions.is_empty());
}
#[test]
#[serial]
fn resolve_git_alias_returns_target() {
let tmpdir = create_test_git_repo_with_aliases();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let result = completer.resolve_git_alias("co");
env::set_current_dir(&original_dir).unwrap();
assert_eq!(result, Some("checkout".to_string()));
}
#[test]
#[serial]
fn resolve_git_alias_nonexistent_returns_none() {
let tmpdir = create_test_git_repo_with_aliases();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let result = completer.resolve_git_alias("zzz_no_such_alias");
env::set_current_dir(&original_dir).unwrap();
assert_eq!(result, None);
}
#[test]
#[serial]
fn resolve_git_alias_multi_word() {
let tmpdir = create_test_git_repo_with_aliases();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let result = completer.resolve_git_alias("nb");
env::set_current_dir(&original_dir).unwrap();
assert_eq!(result, Some("checkout -b".to_string()));
}
#[test]
#[serial]
fn complete_git_branch_current_branch_comes_first() {
let tmpdir = create_test_git_repo();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let completer = test_completer();
let span = Span::new(0, 0);
let suggestions = completer.complete_git_branch("", span);
env::set_current_dir(&original_dir).unwrap();
assert!(
suggestions.len() >= 2,
"should have at least 2 branches (main/master + test-feature): {suggestions:?}"
);
let first = &suggestions[0].value;
let current = &["main", "master"];
assert!(
current.contains(&first.as_str()),
"first suggestion should be the current branch (main or master), got: {first}"
);
}
#[test]
#[serial]
fn cache_is_populated_after_first_call() {
let tmpdir = create_test_git_repo_with_aliases();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(tmpdir.path()).unwrap();
let canonical_cwd = env::current_dir().unwrap();
let completer = test_completer();
{
let cache = completer.git_aliases_cache.read().unwrap();
assert!(cache.is_empty(), "cache should be empty before first call");
}
let _ = completer.resolve_git_alias("co");
env::set_current_dir(&original_dir).unwrap();
let cache = completer.git_aliases_cache.read().unwrap();
assert_eq!(cache.len(), 1, "cache should have one CWD entry");
let aliases = cache.get(&canonical_cwd).unwrap();
assert_eq!(aliases.get("co"), Some(&"checkout".to_string()));
assert_eq!(aliases.get("nb"), Some(&"checkout -b".to_string()));
}
}