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.

<img width="1912" height="1241" alt="image"
src="https://github.com/user-attachments/assets/903b438e-baa8-4447-95dc-faf321bca6a5"
/>


## 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.
This commit is contained in:
Trong Nguyen 2026-06-22 07:37:28 +07:00 committed by GitHub
parent 13dd39b4e4
commit 356e396517
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 162 additions and 9 deletions

View file

@ -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<Oid>,
) -> 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::<Vec<_>>()
})?;

View file

@ -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()

View file

@ -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);