Skip to content
Merged
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
34 changes: 31 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -754,9 +754,11 @@ and a first GitHub release so the download buttons resolve.
- ☑ Blame view (per-line author + commit jump)
- ☑ Reflog browser
- ☑ File history (log for a path)
- ◐ Commit search — in-graph message/author/hash search shipped (highlight + ‹/›
nav, lanes intact); `-G` / `-S` content search still pending (needs a backend
`git log` search)
- ☑ Commit search — in-graph message/author/hash highlight over the loaded
window (instant, lanes intact) **plus** full-history message/author + `-G`
content (pickaxe) search via a backend `Repo::search_log`, surfaced in a
results dropdown (out-of-window hits open in the detail panel). `-S`
occurrence-count pickaxe is a possible future refinement.
- ☑ Stashes shown inline on the graph (synthetic node per stash, attached to its
base commit; distinct diamond marker + `stash@{n}` chip; right-click Apply/Pop/Drop)
- ☐ Drag-and-drop renames in file tree
Expand Down Expand Up @@ -1012,6 +1014,32 @@ successful delete clears the row with no optimistic bookkeeping. New
`tsc`, and an end-to-end bare-remote run confirming both the remote branch and
the local tracking ref are removed.

**Full-history commit search (2026-06-24):** Closed the last ◐ on the commit
search line. The in-graph search highlighted matches over the loaded log
client-side — instant, but blind past the loaded window and unable to search
file *contents* (it holds no diffs). A backend `Repo::search_log(query, mode,
limit)` (`log.rs`) now shells out to `git log` with the matching filter:
`--grep` / `--author` (`--fixed-strings -i`, mirroring the client's
case-insensitive substring) reach the whole history, and **`-G` (the pickaxe)**
searches each commit's diff for an added/removed line matching the query — the
one search the client can't do. Refs + empty-repo handling mirror `log`; the
`--format`/parse path is shared (`commit_format`). New `repo_search_log` IPC +
`repoSearchLog` wrapper + a `searchLog` store action that stashes hits in
`commitSearchResults`. UI: the search pill gained a **Content** field mode and a
"Search all history" button (⌘↵, or ↵ in Content mode) that opens a **results
dropdown** — keyboard-navigable (↑/↓/↵, combobox + listbox/`aria-activedescendant`
semantics) — listing matches across history with author/short-hash/date and a
marker for hits older than the loaded graph. Clicking a result scrolls + focuses
it when it's a loaded row, and opens the detail panel either way (`CommitDetail`
falls back to `commitSearchResults` so an out-of-window commit still renders;
its diff loads by oid). The loaded-window highlight + ‹/› stay untouched for
message/author/hash — the deep search is an explicit, separate action so a
`git log -G` over full history never fires per-keystroke. Verified with
`cargo test -p strand-core` (+5 `log` tests: empty query, case-insensitive
message, author name/email, the `-G` pickaxe touching-commit-only invariant, and
limit + cross-branch reach), `clippy`, `tsc`, `vitest` (131), and `vite build`.
`-S` occurrence-count pickaxe is a possible future refinement.

---

## 1.1+ — Post-1.0
Expand Down
24 changes: 17 additions & 7 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked
`FileBlob` + `BlobSource`, base64 over IPC via a std-only `base64_encode`, 8 MB
cap with a metadata pre-check on the worktree path, behind `safe_workdir_path`;
powers the image diff preview)
- ◐ Commit search (message, author, hash) — in-graph highlight over the loaded
log is done **client-side** (no backend; `Commits.tsx` `commitMatches`), so no
`Repo` search command exists yet. Full-history search + `-G` / `-S` content
search (which need `git log --grep`/`-G`) are still ☐.
- ☑ Commit search (message, author, hash, content). In-graph highlight over the
loaded log stays **client-side** (`Commits.tsx` `commitMatches`); full-history
search is now backed by `Repo::search_log(query, mode, limit)` (`log.rs`):
`--grep` / `--author` (`--fixed-strings -i`) reach beyond the loaded window,
and **`-G` (the pickaxe)** searches commit diffs — exposed via `repo_search_log`
and a Content field mode + results dropdown. (`-S` occurrence-count pickaxe is
a possible refinement; hash search stays a client-side prefix match.)

### Writes
- ☑ Stage / unstage path (`Repo::stage_path` / `unstage_path` via git2)
Expand Down Expand Up @@ -234,6 +237,7 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked
## strand-tauri (IPC + app shell)

- ☑ Read commands: `repo_open`, `repo_meta`, `repo_status`, `repo_log`,
`repo_search_log`,
`repo_refs`, `repo_diff_unstaged` / `_staged` / `_between`, `repo_tree`,
`repo_submodules`, `repo_blame`, `repo_reflog`, `repo_file_content`,
`repo_file_blob`, `repo_file_history`, `repo_diff_commit_file`,
Expand Down Expand Up @@ -492,8 +496,13 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked
N/M counter; `/` focuses the field, ⌘K "Search commits…" jumps to it, Esc
clears. Client-side over the loaded log (message **subject**, author
name/email, hash prefix — body is excluded so `Co-Authored-By:`/`Signed-off-by:`
trailers don't match nearly every commit) — full-history + `-G`/`-S` content
search remain ☐ under Reads.
trailers don't match nearly every commit). **Full-history + content search
shipped (2026-06-24):** a **Content** field mode + a "Search all history"
button (⌘↵, or ↵ in Content) run `Repo::search_log` and open a
keyboard-navigable results dropdown (combobox + listbox) over `--grep` /
`--author` / `-G` matches across all history; an out-of-window hit opens in the
detail panel (`CommitDetail` falls back to `commitSearchResults`). The
loaded-window highlight + ‹/› are untouched.
- ☑ Stashes shown inline on the graph (`mergeStashRows` in `Commits.tsx` splices a
synthetic node per stash above its base commit; `Stash` gained `base` + `time_unix`
from `stash_list`; `GraphRow.isStash` → neutral diamond in `CommitGraphCell`;
Expand Down Expand Up @@ -984,7 +993,8 @@ quick-wins from that audit already landed (see ROADMAP changelog).
- ☑ Wire commit-graph search (`Commits.tsx`). Resolved by **highlighting matches
in place instead of filtering** — every commit stays in the list, so the lane
algorithm's parent→child continuity is never broken. (Backend `git log`-based
search for full history / `-G`/`-S` is still ☐ under strand-core → Reads.)
full-history / `-G` content search shipped 2026-06-24 — `Repo::search_log`,
surfaced in a results dropdown; see strand-core → Reads.)

---

Expand Down
201 changes: 196 additions & 5 deletions crates/strand-core/src/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ pub struct Commit {
/// Field separator inside one commit's format line (ASCII unit separator).
const FS: char = '\u{1f}';

/// The `--format` arg shared by [`Repo::log`] and [`Repo::search_log`], so both
/// produce records [`parse_log`] can read. One line per commit, fields split by
/// FS; with `-z` each record is NUL-terminated so a multi-line body can't be
/// mistaken for the next record. `%ct` is committer time (what git2's
/// `commit.time()` returned); `%P` is space-separated full parent hashes (empty
/// for a root commit); `%s`/`%b` are subject and body.
fn commit_format() -> String {
format!("--format=%H{FS}%an{FS}%ae{FS}%ct{FS}%P{FS}%s{FS}%b")
}

/// Which field [`Repo::search_log`] matches against. Serialized lowercase
/// (`"message"` / `"author"` / `"content"`) over IPC.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchMode {
/// The commit *message* (subject + body) — `git log --grep`.
Message,
/// The author name / email — `git log --author`.
Author,
/// The commit's *diff* content — `git log -G` (the "pickaxe"): commits
/// whose change added or removed a line matching the query. This is the
/// one search the client side can't do over the loaded log (it has no
/// diffs), and the reason this command exists.
Content,
}

impl Repo {
/// Walk commits across **all refs** (`git log --all`-style), newest first,
/// up to `limit`.
Expand Down Expand Up @@ -47,11 +73,7 @@ impl Repo {
/// would also pull in `refs/stash` and notes).
pub fn log(&self, limit: usize) -> Result<Vec<Commit>> {
let limit_arg = limit.to_string();
// One line per commit, fields split by FS; `-z` terminates each commit
// record with NUL, so a multi-line body can't be mistaken for the next
// record. `%ct` is committer time (what git2's `commit.time()` returned);
// `%P` is space-separated full parent hashes (empty for a root commit).
let format = format!("--format=%H{FS}%an{FS}%ae{FS}%ct{FS}%P{FS}%s{FS}%b");
let format = commit_format();
let out = crate::git_command()
.current_dir(&self.path)
.env("GIT_TERMINAL_PROMPT", "0")
Expand Down Expand Up @@ -96,6 +118,81 @@ impl Repo {

Ok(parse_log(&String::from_utf8_lossy(&out.stdout), limit))
}

/// Search commits across **all history** (every ref, not just the loaded
/// window) by message, author, or diff content, newest first, up to
/// `limit` matches.
///
/// The in-graph search highlights matches over the already-loaded log
/// client-side — instant, but blind to commits past the loaded window and
/// unable to search file *contents* (it holds no diffs). This shells out to
/// `git log` with the matching filter so both gaps close:
/// `--grep` / `--author` reach the full history, and `-G` (the pickaxe)
/// searches each commit's diff for an added/removed line matching the query.
///
/// `--grep` / `--author` use `--fixed-strings` so a plain-text query is a
/// literal substring match (mirroring the client side's `includes`); `-G`
/// is always a regular expression (the pickaxe has no fixed-string form), so
/// content queries are treated as regexes. `-i` makes all three
/// case-insensitive. Note `--grep` matches the **whole** message (subject +
/// body), unlike the client-side subject-only highlight — full-history
/// search is an explicit, scannable result list, not a wash over the graph,
/// so a body/trailer hit is acceptable here.
///
/// A blank query returns an empty list rather than matching everything. The
/// ref selectors and empty-repo handling mirror [`Repo::log`].
pub fn search_log(&self, query: &str, mode: SearchMode, limit: usize) -> Result<Vec<Commit>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let limit_arg = limit.to_string();
let format = commit_format();
// Attached forms (`--grep=…`, `-G…`) so a query beginning with `-`
// can't be re-read as an option.
let filter = match mode {
SearchMode::Message => format!("--grep={query}"),
SearchMode::Author => format!("--author={query}"),
SearchMode::Content => format!("-G{query}"),
};
let mut cmd = crate::git_command();
cmd.current_dir(&self.path)
.env("GIT_TERMINAL_PROMPT", "0")
.args(crate::GIT_SAFE_CONFIG)
.args(["log", "--date-order", "--no-color", "-z", "-i", "-n", &limit_arg]);
// Fixed-string match for message/author; `-G` stays a regex.
if !matches!(mode, SearchMode::Content) {
cmd.arg("--fixed-strings");
}
cmd.arg(&format)
.arg(&filter)
.args(["HEAD", "--branches", "--remotes", "--tags"]);

let out = cmd
.output()
.map_err(|e| Error::Other(format!("spawn git failed: {e}")))?;

if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
// Same unborn-HEAD / empty-repo mapping as `log`.
let e = err.to_lowercase();
if e.contains("does not have any commits")
|| e.contains("bad revision")
|| e.contains("unknown revision")
|| e.contains("bad default revision")
{
return Ok(Vec::new());
}
let err = err.trim().to_string();
return Err(Error::Other(if err.is_empty() {
"git log search failed".to_string()
} else {
err
}));
}

Ok(parse_log(&String::from_utf8_lossy(&out.stdout), limit))
}
}

/// Parse `git log -z --format=…` output. Records are NUL-terminated; within a
Expand Down Expand Up @@ -236,4 +333,98 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}

/// Run a search with a generous default limit, unwrapping the result.
fn search(repo: &Repo, q: &str, mode: SearchMode) -> Vec<Commit> {
repo.search_log(q, mode, 100).unwrap()
}

#[test]
fn search_empty_query_returns_empty() {
let (repo, dir) = scratch();
commit(&dir, "a.txt", "x\n", "only commit");
// Blank / whitespace-only must not match everything.
assert!(search(&repo, "", SearchMode::Message).is_empty());
assert!(search(&repo, " ", SearchMode::Content).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn search_message_matches_subject_case_insensitive() {
let (repo, dir) = scratch();
commit(&dir, "a.txt", "1\n", "fix the parser");
commit(&dir, "b.txt", "2\n", "add a feature");

// Substring, case-insensitive.
let got = search(&repo, "PARSER", SearchMode::Message);
assert_eq!(got.len(), 1);
assert_eq!(got[0].subject, "fix the parser");
// No match → empty (not an error).
assert!(search(&repo, "nonexistent", SearchMode::Message).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn search_author_matches_name_or_email() {
let (repo, dir) = scratch();
commit(&dir, "a.txt", "1\n", "by test");
// A commit by a different author.
std::fs::write(dir.join("b.txt"), "2\n").unwrap();
git(&dir, &["add", "b.txt"]);
git(&dir, &["commit", "-q", "-m", "by alice", "--author=Alice <alice@example.com>"]);

let alice = search(&repo, "alice", SearchMode::Author);
assert_eq!(alice.len(), 1, "only Alice's commit matches");
assert_eq!(alice[0].subject, "by alice");
// The default author (Test <test@example.com>) authored the other.
let test = search(&repo, "test@example.com", SearchMode::Author);
assert_eq!(test.len(), 1);
assert_eq!(test[0].subject, "by test");
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn search_content_pickaxe_finds_touching_commit_only() {
let (repo, dir) = scratch();
// Content tokens deliberately absent from the messages, so a hit can
// only come from the diff — not the subject.
commit(&dir, "f.txt", "needle_x\n", "first change");
commit(&dir, "f.txt", "needle_x\nneedle_y\n", "second change");

let yy = search(&repo, "needle_y", SearchMode::Content);
assert_eq!(yy.len(), 1, "only the commit whose diff added needle_y");
assert_eq!(yy[0].subject, "second change");

// `-G needle_x` matches only the commit that added that line, not the
// one where it's mere context — that's the pickaxe's whole point.
let xx = search(&repo, "needle_x", SearchMode::Content);
assert_eq!(xx.len(), 1);
assert_eq!(xx[0].subject, "first change");

// A message-mode search for the same token finds nothing (no message
// contains it), proving content search reaches where message can't.
assert!(search(&repo, "needle_y", SearchMode::Message).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn search_respects_limit_and_searches_all_branches() {
let (repo, dir) = scratch();
commit(&dir, "a.txt", "1\n", "match one");
commit(&dir, "b.txt", "2\n", "match two");
// A commit on another branch must still be reachable (mirrors `log`'s
// `--branches` ref set, so search isn't limited to the current branch).
git(&dir, &["checkout", "-q", "-b", "side"]);
commit(&dir, "c.txt", "3\n", "match three");
git(&dir, &["checkout", "-q", "main"]);

let all = search(&repo, "match", SearchMode::Message);
let unique: std::collections::HashSet<_> = all.iter().map(|c| &c.hash).collect();
assert_eq!(unique.len(), 3, "all three across both branches");

// `limit` bounds the result count (newest first).
let capped = repo.search_log("match", SearchMode::Message, 2).unwrap();
assert_eq!(capped.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
}
15 changes: 14 additions & 1 deletion crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use strand_core::{
apply::ApplyTarget, blame::BlameLine, branch::CheckoutOutcome, commit::CommitOutcome,
diff::FileDiff, file::{BlobSource, FileBlob, FileContent, FileHistoryEntry},
gitconfig::{self, GlobalIdentity},
history::{MergeMode, RebaseEntry, RebaseStep}, log::Commit,
history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode},
network::{clone as core_clone, CancelHandle, CloneOutcome, NetworkOutcome, Progress},
reflog::ReflogEntry,
refs::Refs, repo::RepoMeta, reset::{ResetMode, ResetOutcome},
Expand Down Expand Up @@ -126,6 +126,19 @@ pub fn repo_log(path: String, limit: Option<usize>) -> CmdResult<Vec<Commit>> {
Ok(Repo::discover(&path)?.log(limit.unwrap_or(500))?)
}

/// Full-history commit search (message / author / diff content) — the backend
/// reach the client-side, loaded-window highlight can't cover. `mode` is one of
/// `"message"` / `"author"` / `"content"`.
#[tauri::command(async)]
pub fn repo_search_log(
path: String,
query: String,
mode: SearchMode,
limit: Option<usize>,
) -> CmdResult<Vec<Commit>> {
Ok(Repo::discover(&path)?.search_log(&query, mode, limit.unwrap_or(200))?)
}

#[tauri::command(async)]
pub fn repo_refs(path: String) -> CmdResult<Refs> {
Ok(Repo::discover(&path)?.refs()?)
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn main() {
commands::repo_diff_unstaged_full,
commands::repo_merge_base,
commands::repo_log,
commands::repo_search_log,
commands::repo_refs,
commands::repo_diff_unstaged,
commands::repo_diff_staged,
Expand Down
1 change: 1 addition & 0 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,7 @@ export function App() {
{ id: 'worktrees', label: 'Show: Worktrees', group: 'Actions', shortcut: keyHint('view-worktrees'), keywords: 'worktree agent feature checkout overview', run: () => { setView('worktrees'); selectFile(null); } },
{ id: 'worktree-new', label: 'New worktree…', group: 'Actions', keywords: 'worktree add branch checkout agent', run: () => setWorktreeOpen(true) },
{ id: 'search-commits', label: 'Search commits…', group: 'Actions', shortcut: '/', keywords: 'find filter grep message author hash', run: () => { requestCommitSearch(); } },
{ id: 'search-content', label: 'Search file contents…', group: 'Actions', keywords: 'pickaxe content diff code history full grep -G -S', run: () => { requestCommitSearch('content'); } },
// Opens the ⌘F bar in whichever diff view is showing; other views
// route to Local Changes first (the signal is consumed on mount).
{ id: 'search-diff', label: 'Search in diff…', group: 'Actions', shortcut: formatBinding('Mod+F', platform), keywords: 'find in diff grep text content search', run: () => {
Expand Down
Loading
Loading