Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions src/commands/git_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,21 @@ pub fn handle_git(args: &[String]) {
return;
}

let parsed = parse_git_cli_args(args);
let raw_parsed = parse_git_cli_args(args);

// Preserve the built-in read-only fast path before attempting alias
// resolution. Aliases need a repository config lookup, but commands that are
// already known to be read-only should stay as cheap as they were before.
if is_read_only_invocation(&raw_parsed) {
let exit_status = proxy_to_git(args, false, None);
exit_with_status(exit_status);
}

let repository = find_repository(&raw_parsed.global_args).ok();
let parsed = repository
.as_ref()
.and_then(|repo| resolve_alias_invocation(&raw_parsed, repo))
.unwrap_or_else(|| raw_parsed.clone());

// Read-only invocations don't need wrapper state (the daemon fast-paths
// their trace events and never processes them through the normalizer).
Expand All @@ -72,14 +86,7 @@ pub fn handle_git(args: &[String]) {
// so that subcommand-gated read-only calls like `git stash list` and
// `git worktree list` are also suppressed — these account for thousands
// of Zed IDE invocations per session.
let is_read_only = {
let subcommand = parsed.command_args.first().map(String::as_str);
parsed.command.as_deref().is_some_and(|cmd| {
crate::git::command_classification::is_definitely_read_only_invocation(cmd, subcommand)
})
};

if is_read_only {
if is_read_only_invocation(&parsed) {
let exit_status = proxy_to_git(args, false, None);
exit_with_status(exit_status);
}
Expand Down Expand Up @@ -114,7 +121,6 @@ pub fn handle_git(args: &[String]) {
exit_with_status(exit_status);
}

let repository = find_repository(&parsed.global_args).ok();
let worktree = repository.as_ref().and_then(|r| r.workdir().ok());

let pre_state = worktree
Expand Down Expand Up @@ -146,7 +152,13 @@ pub fn handle_git(args: &[String]) {
exit_with_status(exit_status);
}

#[cfg(feature = "test-support")]
fn is_read_only_invocation(parsed: &ParsedGitInvocation) -> bool {
let subcommand = parsed.command_args.first().map(String::as_str);
parsed.command.as_deref().is_some_and(|cmd| {
crate::git::command_classification::is_definitely_read_only_invocation(cmd, subcommand)
})
}

pub fn resolve_alias_invocation(
parsed_args: &ParsedGitInvocation,
repository: &Repository,
Expand Down Expand Up @@ -183,7 +195,6 @@ pub fn resolve_alias_invocation(
}
}

#[cfg(feature = "test-support")]
fn parse_alias_tokens(value: &str) -> Option<Vec<String>> {
let trimmed = value.trim_start();

Expand Down
14 changes: 12 additions & 2 deletions src/daemon/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ mod tests {
CommandScope, Confidence, FamilyKey, NormalizedCommand, RepoContext,
};
use crate::daemon::git_backend::{GitBackend, ReflogCut};
use crate::git::cli_parser::parse_git_cli_args;
use crate::git::cli_parser::{ParsedGitInvocation, parse_git_cli_args};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

Expand Down Expand Up @@ -150,6 +150,16 @@ mod tests {
_worktree: &Path,
argv: &[String],
) -> Result<Option<String>, GitAiError> {
Ok(self
.resolve_invocation(_worktree, argv)?
.and_then(|invocation| invocation.command))
}

fn resolve_invocation(
&self,
_worktree: &Path,
argv: &[String],
) -> Result<Option<ParsedGitInvocation>, GitAiError> {
let tokens: &[String] = if argv
.first()
.is_some_and(|value| value == "git" || value == "git.exe")
Expand All @@ -158,7 +168,7 @@ mod tests {
} else {
argv
};
Ok(parse_git_cli_args(tokens).command)
Ok(Some(parse_git_cli_args(tokens)))
}

fn clone_target(&self, _argv: &[String], _cwd_hint: Option<&Path>) -> Option<PathBuf> {
Expand Down
24 changes: 20 additions & 4 deletions src/daemon/git_backend.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::daemon::domain::{FamilyKey, RefChange, RepoContext};
use crate::error::GitAiError;
use crate::git::cli_parser::parse_git_cli_args;
use crate::git::cli_parser::{ParsedGitInvocation, parse_git_cli_args};
use crate::git::find_repository_in_path;
use crate::git::repo_state::common_dir_for_worktree;
use crate::git::repository::discover_repository_in_path_no_git_exec;
Expand Down Expand Up @@ -37,6 +37,12 @@ pub trait GitBackend: Send + Sync + 'static {
argv: &[String],
) -> Result<Option<String>, GitAiError>;

fn resolve_invocation(
&self,
worktree: &Path,
argv: &[String],
) -> Result<Option<ParsedGitInvocation>, GitAiError>;

fn clone_target(&self, argv: &[String], cwd_hint: Option<&Path>) -> Option<PathBuf>;

fn init_target(&self, argv: &[String], cwd_hint: Option<&Path>) -> Option<PathBuf>;
Expand Down Expand Up @@ -399,22 +405,32 @@ impl GitBackend for SystemGitBackend {
worktree: &Path,
argv: &[String],
) -> Result<Option<String>, GitAiError> {
Ok(self
.resolve_invocation(worktree, argv)?
.and_then(|invocation| invocation.command))
}

fn resolve_invocation(
&self,
worktree: &Path,
argv: &[String],
) -> Result<Option<ParsedGitInvocation>, GitAiError> {
let mut current = parse_git_cli_args(git_invocation_tokens(argv));
let mut seen = HashSet::new();
loop {
let Some(command) = current.command.clone() else {
return Ok(None);
return Ok(Some(current));
};
if !seen.insert(command.clone()) {
return Ok(None);
}
if is_builtin_primary_command(&command) {
return Ok(Some(command));
return Ok(Some(current));
}

let alias_value = match self.resolve_alias_cached(worktree, &command)? {
Some(value) => value,
None => return Ok(Some(command)),
None => return Ok(Some(current)),
};

let Some(alias_tokens) = parse_alias_tokens(&alias_value) else {
Expand Down
130 changes: 115 additions & 15 deletions src/daemon/trace_normalizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,8 +860,24 @@ impl<B: GitBackend> TraceNormalizer<B> {
pending.worktree.as_deref(),
pending.family_key.as_ref(),
)?;
let (invoked_command, invoked_args) =
canonical_invocation(&pending.raw_argv, primary_command.as_deref());
let resolved_invocation = if let (Some(worktree), Some(_family)) =
(pending.worktree.as_deref(), pending.family_key.as_ref())
{
self.backend
.resolve_invocation(worktree, &pending.raw_argv)?
} else {
None
};
let (invoked_command, invoked_args) = match resolved_invocation {
Some(invocation)
if invocation.command.is_some()
&& (primary_command.is_none()
|| invocation.command.as_deref() == primary_command.as_deref()) =>
{
(invocation.command, invocation.command_args)
}
_ => canonical_invocation(&pending.raw_argv, primary_command.as_deref()),
};
if primary_command.is_none() {
primary_command = invoked_command.clone();
}
Expand Down Expand Up @@ -1495,7 +1511,7 @@ fn select_primary_command(
mod tests {
use super::*;
use crate::daemon::domain::RefChange;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

Expand Down Expand Up @@ -1582,20 +1598,48 @@ mod tests {
worktree: &Path,
argv: &[String],
) -> Result<Option<String>, GitAiError> {
Ok(self
.resolve_invocation(worktree, argv)?
.and_then(|invocation| invocation.command))
}

fn resolve_invocation(
&self,
worktree: &Path,
argv: &[String],
) -> Result<Option<crate::git::cli_parser::ParsedGitInvocation>, GitAiError> {
let raw = argv_primary_command(argv);
let Some(command) = raw else {
return Ok(None);
};
let tokens = trace_argv_invocation_tokens(argv);
let mut current = parse_git_cli_args(tokens);
let worktree_key = normalize_path_key(worktree);
let resolved = self
.alias_by_worktree_command
.lock()
.unwrap()
.get(&worktree_key)
.and_then(|commands| commands.get(&command))
.cloned()
.unwrap_or(command);
Ok(Some(resolved))
let mut seen = HashSet::new();
loop {
let Some(command) = current.command.clone() else {
return if raw.is_some() {
Ok(Some(current))
} else {
Ok(None)
};
};
if !seen.insert(command.clone()) {
return Ok(None);
}
let alias_value = self
.alias_by_worktree_command
.lock()
.unwrap()
.get(&worktree_key)
.and_then(|commands| commands.get(&command))
.cloned();
let Some(alias_value) = alias_value else {
return Ok(Some(current));
};
let mut expanded_args = Vec::new();
expanded_args.extend(current.global_args.iter().cloned());
expanded_args.extend(alias_value.split_whitespace().map(|s| s.to_string()));
expanded_args.extend(current.command_args.iter().cloned());
current = parse_git_cli_args(&expanded_args);
}
}

fn clone_target(&self, _argv: &[String], _cwd_hint: Option<&Path>) -> Option<PathBuf> {
Expand Down Expand Up @@ -1841,6 +1885,62 @@ mod tests {
assert_eq!(cmd.primary_command.as_deref(), Some("commit"));
}

#[test]
fn alias_commit_amend_expands_invoked_args_for_history_analysis() {
let backend = Arc::new(MockBackend::default());
let temp = tempfile::tempdir().expect("create tempdir");
let worktree = temp.path().join("repo");
fs::create_dir_all(worktree.join(".git")).expect("create git dir");
let worktree_str = worktree.to_str().expect("utf8 worktree");
backend.set_family(worktree_str, "family");
backend.set_alias(worktree_str, "ca", "commit --amend");
let mut normalizer = TraceNormalizer::new(backend);

let old_head = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let new_head = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let start = serde_json::json!({
"event":"start",
"sid":"alias-amend",
"ts":1,
"argv":["git","ca","-m","msg"],
"worktree":worktree,
"git_ai_family_reflog_start": {"HEAD": 10}
});
let exit = serde_json::json!({
"event":"exit",
"sid":"alias-amend",
"ts":2,
"code":0,
"git_ai_family_reflog_end": {"HEAD": 11},
"git_ai_family_reflog_changes": [{
"reference": "HEAD",
"old": old_head,
"new": new_head
}]
});

assert!(normalizer.ingest_payload(&start).unwrap().is_none());
let cmd = normalizer.ingest_payload(&exit).unwrap().unwrap();
assert_eq!(cmd.primary_command.as_deref(), Some("commit"));
assert_eq!(cmd.invoked_command.as_deref(), Some("commit"));
assert!(cmd.invoked_args.iter().any(|arg| arg == "--amend"));

let analyzer = crate::daemon::analyzers::history::HistoryAnalyzer;
let result = crate::daemon::analyzers::CommandAnalyzer::analyze(
&analyzer,
&cmd,
crate::daemon::analyzers::AnalysisView {
refs: &HashMap::new(),
},
)
.unwrap();
assert!(result.events.iter().any(|event| matches!(
event,
crate::daemon::domain::SemanticEvent::CommitAmended { old_head: old, new_head: new }
if old == old_head && new == new_head
)));
}

#[test]
fn normalizer_errors_on_exit_without_start() {
let backend = Arc::new(MockBackend::default());
Expand Down
28 changes: 28 additions & 0 deletions tests/async_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,34 @@ fn async_mode_post_commit_shows_stats_for_ai_commit() {
);
}

#[test]
fn async_mode_post_commit_shows_stats_for_aliased_commit() {
let repo = TestRepo::new_with_mode(GitTestMode::WrapperDaemon);
repo.git(&["config", "alias.ci", "commit"])
.expect("alias config should succeed");

let mut file = repo.filename("alias.txt");
file.set_contents(crate::lines!["Base"]);
repo.stage_all_and_commit("Base").unwrap();

file.insert_at(1, crate::lines!["AI line".ai()]);
repo.git(&["add", "-A"]).expect("add should succeed");

let output = repo
.git_with_env(
&["ci", "-m", "AI additions via alias"],
&[("GIT_AI_TEST_FORCE_TTY", "1")],
None,
)
.expect("aliased commit should succeed");

assert!(
output.contains("you") && output.contains("ai"),
"expected stats output for aliased async commit, got:\n{}",
output
);
}

#[test]
fn async_mode_post_commit_quiet_flag_suppresses_stats() {
let repo = TestRepo::new_with_mode(GitTestMode::WrapperDaemon);
Expand Down
Loading