From 356e396517534fb8f40abc43588680a147e66236 Mon Sep 17 00:00:00 2001
From: Trong Nguyen <62984954+nguyenphutrong@users.noreply.github.com>
Date: Mon, 22 Jun 2026 07:37:28 +0700
Subject: [PATCH] git_ui: Search commits by hash (#59132)
## Summary
- Allows Git Graph search to match abbreviated or full commit hashes
when the query looks like a SHA.
- Keeps the existing message search behavior for non-hash queries.
- Mirrors the hash search heuristic in the fake git backend and adds
GPUI coverage for hash and message search.
## Test Plan
- `cargo fmt --check --package git_ui`
- `cargo -q test -p git_ui
test_git_graph_search_matches_commit_hash_prefix -- --nocapture`
- `./script/clippy -p git_ui`
## Suggested .rules additions
- N/A
Release Notes:
- Improved Git Graph search to find commits by abbreviated or full hash.
---
crates/fs/src/fake_git_repo.rs | 14 +++-
crates/git/src/repository.rs | 29 ++++++--
crates/git_ui/src/git_graph.rs | 128 ++++++++++++++++++++++++++++++++-
3 files changed, 162 insertions(+), 9 deletions(-)
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 5d8fc647fe0..45ee57b35f5 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -15,6 +15,7 @@ use git::{
CreateWorktreeTarget, FetchOptions, FileHistoryChangedFileSets, GRAPH_CHUNK_SIZE,
GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource,
PushOptions, RefEdit, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
+ commit_hash_search_query,
},
stash::GitStash,
status::{
@@ -1491,7 +1492,9 @@ impl GitRepository for FakeGitRepository {
request_tx: Sender,
) -> BoxFuture<'_, Result<()>> {
async move {
- let query = if search_args.case_sensitive {
+ let hash_query = commit_hash_search_query(search_args.query.as_str())
+ .map(|query| query.to_ascii_lowercase());
+ let message_query = if search_args.case_sensitive {
search_args.query.to_string()
} else {
search_args.query.to_lowercase()
@@ -1505,12 +1508,19 @@ impl GitRepository for FakeGitRepository {
let FakeCommitDataEntry::Success(commit_data) = entry else {
return None;
};
+ if let Some(hash_query) = hash_query.as_ref() {
+ return sha
+ .to_string()
+ .to_ascii_lowercase()
+ .starts_with(hash_query)
+ .then_some(*sha);
+ }
let message = if search_args.case_sensitive {
commit_data.message.to_string()
} else {
commit_data.message.to_lowercase()
};
- message.contains(&query).then_some(*sha)
+ message.contains(&message_query).then_some(*sha)
})
.collect::>()
})?;
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index f077390375b..93338de75cc 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -780,6 +780,14 @@ pub struct SearchCommitArgs {
pub case_sensitive: bool,
}
+pub fn commit_hash_search_query(query: &str) -> Option<&str> {
+ let query = query.trim();
+ (7..=40)
+ .contains(&query.len())
+ .then_some(query)
+ .filter(|query| query.bytes().all(|byte| byte.is_ascii_hexdigit()))
+}
+
pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str {
match (is_remote_tracking_ref, force) {
(true, true) => "-Dr",
@@ -3139,16 +3147,20 @@ impl GitRepository for RealGitRepository {
async move {
let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
+ let hash_query = commit_hash_search_query(search_args.query.as_str())
+ .map(|query| query.to_ascii_lowercase());
- args.push("--fixed-strings");
+ if hash_query.is_none() {
+ args.push("--fixed-strings");
- if !search_args.case_sensitive {
- args.push("--regexp-ignore-case");
+ if !search_args.case_sensitive {
+ args.push("--regexp-ignore-case");
+ }
+
+ args.push("--grep");
+ args.push(search_args.query.as_str());
}
- args.push("--grep");
- args.push(search_args.query.as_str());
-
args.extend(log_source.get_args()?);
let mut command = git.build_command(&args);
command.stdout(Stdio::piped());
@@ -3169,6 +3181,11 @@ impl GitRepository for RealGitRepository {
}
let sha = line_buffer.trim_end_matches('\n');
+ if let Some(hash_query) = hash_query.as_ref()
+ && !sha.to_ascii_lowercase().starts_with(hash_query)
+ {
+ continue;
+ }
if let Ok(oid) = Oid::from_str(sha)
&& request_tx.send(oid).await.is_err()
diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs
index 36b63401c43..9422db54d82 100644
--- a/crates/git_ui/src/git_graph.rs
+++ b/crates/git_ui/src/git_graph.rs
@@ -4624,7 +4624,7 @@ mod tests {
use collections::{HashMap, HashSet};
use fs::FakeFs;
use git::Oid;
- use git::repository::InitialGraphCommitData;
+ use git::repository::{CommitData, InitialGraphCommitData};
use gpui::{TestAppContext, UpdateGlobal};
use project::git_store::{GitStoreEvent, RepositoryEvent};
use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation};
@@ -5973,6 +5973,132 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_git_graph_search_matches_commit_hash_prefix(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ Path::new("/project"),
+ json!({
+ ".git": {},
+ "file.txt": "content",
+ }),
+ )
+ .await;
+
+ let first_sha = Oid::from_bytes(&[1; 20]).unwrap();
+ let target_sha = Oid::from_bytes(&[2; 20]).unwrap();
+ let third_sha = Oid::from_bytes(&[3; 20]).unwrap();
+ let commits = vec![
+ Arc::new(InitialGraphCommitData {
+ sha: first_sha,
+ parents: smallvec![target_sha],
+ ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
+ }),
+ Arc::new(InitialGraphCommitData {
+ sha: target_sha,
+ parents: smallvec![third_sha],
+ ref_names: vec![],
+ }),
+ Arc::new(InitialGraphCommitData {
+ sha: third_sha,
+ parents: smallvec![],
+ ref_names: vec![],
+ }),
+ ];
+ fs.set_graph_commits(Path::new("/project/.git"), commits);
+ fs.set_commit_data(
+ Path::new("/project/.git"),
+ [
+ (
+ CommitData {
+ sha: first_sha,
+ parents: smallvec![target_sha],
+ author_name: "Author".into(),
+ author_email: "author@example.com".into(),
+ commit_timestamp: 1,
+ subject: "Add feature".into(),
+ message: "Add feature".into(),
+ },
+ false,
+ ),
+ (
+ CommitData {
+ sha: target_sha,
+ parents: smallvec![third_sha],
+ author_name: "Author".into(),
+ author_email: "author@example.com".into(),
+ commit_timestamp: 2,
+ subject: "Fix branch loading".into(),
+ message: "Fix branch loading".into(),
+ },
+ false,
+ ),
+ (
+ CommitData {
+ sha: third_sha,
+ parents: smallvec![],
+ author_name: "Author".into(),
+ author_email: "author@example.com".into(),
+ commit_timestamp: 3,
+ subject: "Update docs".into(),
+ message: "Update docs".into(),
+ },
+ false,
+ ),
+ ],
+ );
+
+ let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+ cx.run_until_parked();
+
+ let repository = project.read_with(cx, |project, cx| {
+ project
+ .active_repository(cx)
+ .expect("should have a repository")
+ });
+ let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+ workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+ });
+ let workspace_weak =
+ multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade());
+ let git_graph = cx.new_window_entity(|window, cx| {
+ GitGraph::new(
+ repository.read(cx).id,
+ project.read(cx).git_store().clone(),
+ workspace_weak,
+ None,
+ window,
+ cx,
+ )
+ });
+ cx.run_until_parked();
+
+ git_graph.update(cx, |graph, cx| {
+ graph.search_for_test("0202020".into(), cx);
+ });
+ cx.run_until_parked();
+
+ git_graph.read_with(&*cx, |graph, _| {
+ assert_eq!(graph.search_matches_for_test(), vec![target_sha]);
+ let selected_sha = graph
+ .selected_entry_idx
+ .and_then(|idx| graph.graph_data.commits.get(idx))
+ .map(|commit| commit.data.sha);
+ assert_eq!(selected_sha, Some(target_sha));
+ });
+
+ git_graph.update(cx, |graph, cx| {
+ graph.search_for_test("docs".into(), cx);
+ });
+ cx.run_until_parked();
+
+ git_graph.read_with(&*cx, |graph, _| {
+ assert_eq!(graph.search_matches_for_test(), vec![third_sha]);
+ });
+ }
+
#[gpui::test]
async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) {
init_test(cx);