diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600..c95d7453 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,44 @@ version: 2 updates: + # One PR for all actions, so upload-artifact and download-artifact always + # move together. - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + groups: + actions: + patterns: ["*"] + + # The extension's packages. engine/package.json declares none of its own. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + # pnpm refuses anything published less than a day ago (minimumReleaseAge), + # so give a release a few days before proposing it. + cooldown: + default-days: 3 + groups: + npm-minor-patch: + update-types: ["minor", "patch"] + ignore: + # Must not run ahead of engines.vscode, or vsce refuses to package. + # Raise both together, by hand, when the minimum VS Code moves. + - dependency-name: "@types/vscode" + update-types: ["version-update:semver-major", "version-update:semver-minor"] + + # The Rust engine. rust-toolchain.toml is left out on purpose: raising the + # channel is a deliberate act (see that file). + - package-ecosystem: "cargo" + directory: "/engine" + schedule: + interval: "weekly" + groups: + # The napi crates move in lockstep, majors included. + napi: + patterns: ["napi", "napi-derive", "napi-build"] + # gix is pre-1.0, so its "minor" bumps break; keep them in their own PR. + cargo-minor-patch: + update-types: ["minor", "patch"] + exclude-patterns: ["gix"] diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml index 9c9df178..61d22efd 100644 --- a/.github/workflows/native-build.yml +++ b/.github/workflows/native-build.yml @@ -42,7 +42,7 @@ jobs: # Windows targets. Fixed content, so the key only has to change when the # build script that drives it does. - name: Cache the MSVC CRT - uses: actions/cache@v4.2.0 + uses: actions/cache@v6.1.0 with: path: ~/.cache/cargo-xwin key: xwin-${{ runner.os }}-${{ hashFiles('engine/scripts/build-addon.mjs') }} @@ -51,7 +51,7 @@ jobs: # ~350 MB: the pinned zig. Keyed on the script that names the version, # so a new pin fetches a new wheel instead of reusing the old one. - name: Cache the pinned zig - uses: actions/cache@v4.2.0 + uses: actions/cache@v6.1.0 with: path: engine/.toolchain key: zig-${{ runner.os }}-${{ hashFiles('engine/scripts/build-all.mjs') }} @@ -78,7 +78,7 @@ jobs: # job restores them in one step and the layout it gets is the layout # `.vscodeignore` whitelists and the loader reads. - name: Upload engine binaries - uses: actions/upload-artifact@v4.0.0 + uses: actions/upload-artifact@v7.0.1 with: name: native-all path: engine/native/*/git-graph.node diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e7077360..b89e4e53 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: # cost is deliberate — every install carries seven binaries it cannot # load — and buys one file to download instead of nine. - name: Download engine binaries - uses: actions/download-artifact@v4.0.0 + uses: actions/download-artifact@v8.0.1 with: name: native-all path: engine/native/ @@ -71,7 +71,7 @@ jobs: echo "Packaged $vsix ($(du -h "$vsix" | cut -f1))" - name: Upload VSIX - uses: actions/upload-artifact@v4.0.0 + uses: actions/upload-artifact@v7.0.1 with: name: vsix path: ${{ steps.package.outputs.vsix }} @@ -92,7 +92,7 @@ jobs: - run: corepack enable && pnpm install --frozen-lockfile - name: Download VSIX - uses: actions/download-artifact@v4.0.0 + uses: actions/download-artifact@v8.0.1 with: name: vsix path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8dbd67..e778cd8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **The graph opens roughly three and a half times faster.** Measured on a - 2,000-commit repository: the work behind opening the view went from 67 ms to - 19 ms, and reading the branch, tag, remote and stash lists from 59 ms to 8 ms. +- **Searching history is about eight times faster**, and no longer starts four + `git` processes for one search. Measured on a real 1,036-commit repository: + 62 ms to 8 ms. What it matches is unchanged — the same literal text search + over messages, the same author and commit-hash matching, in the same order. +- **The graph opens roughly four times faster.** Measured on a real + 1,036-commit repository: the work behind opening the view went from 69 ms to + 16 ms, reading the branch, tag, remote and stash lists from 60 ms to 8 ms, + and loading a page of commits from 12 ms to 8 ms. Loading commits no longer + starts a `git` process at all. +- **The engine is about 1.4 MB smaller per platform**, after removing code + that could never be reached — around 10 MB off the download. - **A built-in Git engine, so the graph stops waiting on `git` processes.** Reading a repository — opening the graph, loading a page of commits, opening commit details, comparing two commits, reading a file at a revision — now @@ -23,8 +31,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that engine and the previous behavior. `"git-cli"` is a complete opt-out: the engine is not even loaded. +### Changed + +- **"Load more" now loads 750 commits at a time instead of 100**, and the graph + opens with 250 rather than 300. The first page appears a touch sooner, and + scrolling further into history pauses roughly seven times less often. + `initialLoadCommits` and `loadMoreCommits` remain configurable if you want + the old behavior. + ### Fixed +- **Searching for text containing `(` or `[` no longer fails the Find + dialogue.** A query with an unbalanced bracket made Git reject the search + outright and the dialogue reported an error instead of results. Author + matching is unchanged otherwise — it still matches names and email addresses, + ignoring case. +- **Signed tags keep their badge, and `origin/HEAD` keeps its label**, without + the extension running extra `git` commands to find either out. Both are now + read directly from the repository along with everything else on the row. A + lightweight tag pointing at a signed commit is correctly *not* marked as a + signed tag, matching `git` itself. - **The author filter lists everyone who has contributed**, not only the people whose commits are reachable from the branch you have checked out. Anyone whose work is on another branch — a remote branch you have fetched but not checked diff --git a/README.md b/README.md index 9954a4cf..f1c28ab2 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ All settings use the `git-graph-libre` prefix. | `fetchAvatars` | `false` | Fetch avatars (sends email to external services) | | `graphColors` | 12 defaults | Colors for graph lines | | `graphStyle` | `"rounded"` | `"rounded"` or `"angular"` | -| `initialLoadCommits` | `300` | Commits to load on open | -| `loadMoreCommits` | `100` | Commits to load on demand | +| `initialLoadCommits` | `250` | Commits to load on open | +| `loadMoreCommits` | `750` | Commits to load on demand | | `maxDepthOfRepoSearch` | `0` | Folder depth for repo search | | `repository.boldCheckedOutCommit` | `false` | Bold the checked-out commit's message | | `repository.fetchTagsByDefault` | `true` | Pre-check "Fetch all tags" in the Fetch dialog | diff --git a/docs/AI_DEV_KNOWLEDGE_BASE.md b/docs/AI_DEV_KNOWLEDGE_BASE.md index bfefd1e6..082e67b5 100644 --- a/docs/AI_DEV_KNOWLEDGE_BASE.md +++ b/docs/AI_DEV_KNOWLEDGE_BASE.md @@ -2402,6 +2402,9 @@ Implementation record (`2026-09-22`, twelve subslice commits, all signed): pay no spawn: symbolic remote HEADs (`origin/HEAD`) attached in CLI order, and signed-tag badges flipped reusing the loader's own signature atom (probed end to end against a crafted PGP-signed tag). + **Both were removed on `2026-09-23`** — the engine now carries the two + fields itself and the `loadCommits` engine path spawns nothing. See "The + two ref fills, removed from the engine rather than optimised". - 16.5d4 post-call reroutes: unborn stays on the CLI, which owns the empty-graph error shape; unfiltered show-all pages that lost HEAD go back to the whole CLI read, mirroring the move-HEAD-onto-page contract. @@ -2524,7 +2527,12 @@ Implementation record (`2026-09-23`, four subslice commits, all signed): global file staying CLI with the CLI's own error shape. - Declines, each with its reason — no code, no behavior change: `search_history` (the engine matches message regex while the CLI - searches fixed strings plus author, hash, positions and ref filters); + searches fixed strings plus author, hash, positions and ref filters) + — **superseded and then removed on `2026-09-23`**: rather than wire the + mismatched function, the engine gained a `search_commits` that + reproduces this project's semantics, and the search now routes through + it. `search_history` itself was deleted. See "Search in the engine" and + "The engine surface: what is wired, what was removed, what is kept"; `load_tag_details` (the engine reports signatures present but unverified; verification is a permanent non-goal); `current_branch_*` and `remote_names` (their consumers are `kind: "action"` write flows, @@ -2793,9 +2801,301 @@ plus the stash, mirroring what `stats.rs` already did and what `--all` actually covers. `collects_authors_from_every_ref_not_just_head` pins it, and a mutation back to head-only fails it. -**`loadCommits` is still slower than the CLI** — `0.9x` at a 300-commit page, -`0.7x` at 1000 — and is untouched by any of the above. That is the remaining -item, and it is the hot path this phase exists for. +**`loadCommits` was still slower than the CLI** — `0.9x` at a 300-commit page, +`0.7x` at 1000 — and was untouched by any of the above. That is the next +subsection. + +#### The two ref fills, removed from the engine rather than optimised (`2026-09-23`) + +`loadCommits` was the one read where the engine *lost*: `12.9 ms` against the +CLI's `10.8 ms` on a real 1,036-commit repository. The engine call itself was +`7.1 ms` — comfortably ahead. The loss was entirely in what ran afterwards. + +Two CLI fills (16.5d3/d5) ran `for-each-ref` on the engine path, sequentially, +to put back two facts the engine's types did not carry: + +| fill | spawn | the missing field | +| --- | --- | --- | +| `attachRemoteHeadLabels` | `for-each-ref refs/remotes` | symbolic `origin/HEAD` | +| `attachSignedTagNames` | `for-each-ref refs/tags` | tag signature presence | + +This is the same defect as the `repoInfo` one above, one layer down: ask the +engine, then spawn `git` anyway. The fix is the same shape — teach the engine +the field and delete the fill, rather than make the spawn cheaper. + +**`GitTagRef.signed` and `GitCommitTag.signed`.** `refs.rs` reads the tag +object's signature while it is peeling the tag it would peel regardless, and +records the flag on *both* records of an annotated tag — the signature belongs +to the tag, not to either hash, and the graph attaches the peeled one. A +`find_header` call settles the object kind first, so a lightweight tag costs a +header lookup rather than a commit read. + +The semantics were verified against git rather than assumed. `for-each-ref`'s +`%(contents:signature)` is non-empty **only** for an annotated tag object that +was signed — a lightweight tag over a *signed commit* reports nothing: + +| tag | object type | `%(contents:signature)` | +| --- | --- | ---: | +| annotated, signed | `tag` | `1` | +| annotated, unsigned | `tag` | `0` | +| lightweight, on a signed commit | `commit` | `0` | +| lightweight, on an unsigned commit | `commit` | `0` | + +That third row is the one worth pinning: the commit was genuinely signed +(`%G?` = `G`) and the tag still reports unsigned. The engine matches by +construction — it reads the tag object, and a lightweight tag has none. + +**Symbolic remote HEADs, resolved instead of dropped.** `direct_target` +returns `None` for a symbolic ref, so `refs/remotes/origin/HEAD` — what every +`git clone` writes — never reached the graph. `for-each-ref %(objectname)` +reports the object such a ref resolves to, so `read_remote_refs` now does the +same. Only a remote's handful of `/HEAD` refs are ever symbolic, so this costs +one lookup each and nothing in the common case. + +**Verification.** A clone carrying all four tag shapes *and* a symbolic +`origin/HEAD` was read through `createRepoReader` on both backends, and the ref +labels compared: + +``` +engine served the read: true + ccaf060 remote origin/HEAD + ccaf060 tag annotated-signed signed=true + ccaf060 tag light-on-signed signed=false +IDENTICAL: true +``` + +Spawn counts on that repository, through `recordGitCommand`: + +| backend | git spawns | +| --- | ---: | +| `git-cli` | 3 (`refs`, `head`, `log`) | +| `auto` (engine) | **0** (was 2) | + +| operation | CLI | engine before | engine after | | +| --- | ---: | ---: | ---: | ---: | +| `loadCommits` (300) | `11.5 ms` | `12.9 ms` | **`7.7 ms`** | `1.5x` | +| `loadCommits` (1000) | `17.1 ms` | `19.3 ms` | **`13.6 ms`** | `1.3x` | +| view load | `68.7 ms` | — | **`15.7 ms`** | `4.4x` | + +`reports_tag_signature_presence_the_way_for_each_ref_does` and +`resolves_a_symbolic_remote_head_rather_than_dropping_it` pin both changes; +each was mutation-checked and kills only its own test. The signed-tag fixture +writes the tag object by hand through `git hash-object -t tag -w`, so it needs +no keyring and stays deterministic on CI — the engine reports signature +*presence*, so a fabricated block exercises the identical path. + +`attachRemoteHeadLabels`, `attachSignedTagNames`, their parsers, their +appliers and their tests are deleted. Nothing in the engine's `loadCommits` +path spawns a process any more. + +**A measurement trap worth recording.** The first benchmark after this change +showed the engine *five times slower*, because `pnpm run engine:build` builds +the **debug** profile (186 MB, unoptimised) while the benchmark numbers above +are all release (5.9 MB). Always `pnpm run engine:build:release` before +benchmarking; a debug addon is not a slow engine, it is a different one. + +#### Search in the engine, and a CLI bug it exposed (`2026-09-23`) + +Slice 16.7 declined `search_history` because the engine's search and this +project's search answer different questions. That decline was correct and the +conclusion drawn from it was not: the fix is not to wire the mismatched +function, and not to leave search on four `git` processes, but to give the +engine a search with *these* semantics. `search_commits` is that function, new +in `engine/native/core/src/search.rs`. `search_history` was then removed +outright — see the surface audit below. + +What had to be reproduced, and what the engine's own search does instead: + +| | this project | `log::search_history` | +| --- | --- | --- | +| message match | literal substring, case-insensitive | regular expression | +| author match | yes | no | +| hash match | yes, abbreviated resolves | no | +| refs searched | the ones the view shows | every ref, always | +| author filter | honoured | ignored | +| ordering | position in the graph's walk | commit date | + +The CLI answers with four `git log` runs at once — `--fixed-strings --grep`, +`--author`, a hash lookup, and one unbounded walk that numbers every commit. +That numbering is the `loadCount` each result carries, and it is also a +*filter*: a hit with no position is dropped, which is why a hash that resolves +to an unreachable commit is not a result. The engine does the same three +matches in a single walk, which is where the speed comes from. + +Verified deliberately at the boundaries the two disagree on, in the parity +table (`engine/CLI parity: searchCommits`, eight cases, plus twelve engine-side +tests): a literal dot that a regex would widen, a query that is not valid +regex at all, case folding, a body-only term that `--grep` reaches and `%s` +does not show, an author-only match, an abbreviated hash, a hash that resolves +but is unreachable, and a `--glob=` ref pattern that still declines to the CLI. + +**The CLI bug the parity table found.** The `(` case failed — not because the +engine was wrong, but because **git was rejecting the search outright**: + +``` +fatal: header, '\(': Unmatched ( or \( +``` + +`searchCommits` escaped the query for its `--author` run with a +JavaScript-style `escapeRegExp`, turning `(` into `\(`. But `--author` takes a +*basic* regular expression, in which `\(` **opens a group** rather than +escaping a parenthesis — so the escaping inverted the meaning, and because the +four runs share a `Promise.all`, any query containing an unbalanced `(` or `[` +failed the entire Find dialogue. A literal match was always the intent; +`--fixed-strings` is how git spells it, and it matches name and email +substrings case-insensitively exactly as before. Verified against git directly +before changing anything. + +This is worth recording as a pattern, not just a fix: the parity table earns +its keep by failing on the *CLI* side. Two implementations that must agree +catch bugs in whichever one is wrong. + +| | git CLI | engine | | +| --- | ---: | ---: | ---: | +| search (message term), 1,036 commits | `62.0 ms` | **`7.8 ms`** | `7.9x` | + +Second only to `loadRepoInfo` among the wired reads, because four processes +collapse to one walk. + +#### The engine surface: what is wired, what was removed, what is kept (`2026-09-23`) + +An audit of every `#[napi]` export against the TypeScript that calls it. **31 +exports, 18 wired, 13 unwired.** The unwired ones are not an oversight — each +is listed below with why it is there — but they are not free either: a +`#[napi]` function is an exported symbol, so it is a linker *root* and LTO +cannot strip it. Dead exports are genuinely in every shipped binary. + +**Removed (`2026-09-23`).** Five exports, chosen because a recorded permanent +non-goal blocks them forever, not because nothing calls them today: + +| removed | why it could never be wired | +| --- | --- | +| `search_history` | superseded by `search_commits`; its regex semantics are wrong here | +| `current_branch_name` | consumer is a write flow | +| `current_branch_upstream` | consumer is a write flow | +| `remote_names` | consumer is a write flow | +| `load_commit_subject` | only consumer is the amend action | + +"Every write stays on `runGitRaw`" is the first permanent non-goal, so the +last four had no reachable future. Their `api::Engine` methods, tests and the +orphaned `GitHistoryMatch`, `SEARCH_LIMIT` and `collapse_whitespace` went with +them. `Repo::remote_names` is a *different* function and stays — `graph.rs` +needs it for `load_commits`. + +The TypeScript side lost `currentBranchName` too. It was declared on +`EngineAddon` **and in the `isEngineAddon` load-time guard**, so it could have +rejected a perfectly good engine binary over a method nothing called. + +**What it saved.** `search_history` was the only consumer of the `regex` +crate, so the dependency went as well: + +| | bytes | +| --- | ---: | +| before | `6,183,072` | +| after | `4,814,416` | +| saving | **`1,368,656` (22.1%) per platform, ~10.4 MB across all eight** | + +**Kept deliberately — available for later wiring.** Nothing below is dead by +intent; each is a function whose consumer does not exist *yet*: + +| kept | what it would serve | +| --- | --- | +| `activity_heatmap`, `author_stats` | **a Statistics tab** — see below | +| `load_uncommitted_details` | the `*` row, if it moves off the CLI (16.6) | +| `count_uncommitted_changes` | that row's count — redundant while `load_commits` builds it | +| `load_commit_file_diff` | a unified-diff renderer, replacing VS Code's diff editor | +| `new_path_of_renamed_file` | rename tracking between a commit and the working tree | +| `load_commit_bodies`, `load_commit_summaries` | batch message reads, if a view ever wants them | +| `count_commits_before` | anything needing `rev-list` counts | +| `load_tag_details` | blocked on signature verification, a permanent non-goal | +| `repo_root`, `submodules` | blocked on discovery semantics diverging from the CLI's | +| `open_repository` | explicit handle opening; handles currently open implicitly | + +The last three rows are blocked rather than merely unwired, and could be +removed on the same reasoning as the five above. They are kept because the +maintainer may want the option, and because the saving is already taken. + +**Planned: a Statistics tab.** The maintainer intends to add one +(`2026-09-23`). `stats.rs` already provides both halves — `author_stats` +(commit counts per author across every ref, merges excluded) and +`activity_heatmap` (author-local weekday/hour cells, sparse, merges excluded). +Both are inherited from `vscode-git-graph-rs`, where they backed that +project's own Statistics view, and neither has ever been wired here. Wiring +them is a feature slice, not a parity slice: there is no CLI implementation to +agree with, so the usual "two implementations, proven to agree" rule does not +apply and the engine would be the *only* backend. That is a deliberate +exception to design rule 2 and must be recorded as one when it happens — +including what the view does when the engine is unavailable, which for every +other read is "fall back to the CLI" and here would have to be "the tab is not +offered". + +#### What the page-size defaults are actually worth (`2026-09-23`) + +Asked why `initialLoadCommits` is `300` when 1,000 costs only milliseconds +more. Three separate costs were measured, and the answer is that two of them +are now negligible and the third was never measured here. + +**Where the number came from.** It is inherited, not chosen: +`git show upstream/main:package.json` gives `initialLoadCommits: 300`, and the +only commits that ever touched it in this fork are `3ba4217 Initial +implementation of Git Graph` and an i18n pass. It is mhutchie's number, picked +when every read was a `git` spawn. + +| | 300 | 1,000 | 10,000 | +| --- | ---: | ---: | ---: | +| backend read (CLI) | `11.5 ms` | `17.1 ms` | — | +| backend read (engine) | `7.7 ms` | `13.6 ms` | — | +| graph layout (jsdom) | `0.2 ms` | `0.4 ms` | `1.1 ms` | + +700 extra commits cost about `6 ms` of git and essentially nothing to lay out. + +**The render is the real cost, and it scales with the whole table.** +`renderTable` builds one HTML string over `this.commits` — *all* of them, not +the new page — and assigns it to `tableElem.innerHTML`. So every "load more" +rebuilds the entire table, and the cost of reaching N commits is the sum of +every rebuild along the way. Driving the real load-more flow through the +webview harness: + +| table size | full rebuild (jsdom) | +| ---: | ---: | +| 1,000 rows | `921 ms` | +| 2,000 rows | `2,783 ms` | + +**Those are jsdom numbers and must not be quoted as browser numbers** — jsdom +parses HTML far slower than a browser and does no layout or paint at all. What +they do establish is the *shape*: the rebuild is at least linear in total rows +and the constant is not small. + +The consequence decides the page sizes. Because the whole table is rebuilt +each time, a larger `loadMoreCommits` means strictly *less* total work, not +more: reaching 3,000 commits costs about 27 rebuilds of a growing table at a +step of 100, and about 4 at a step of 750. The trade is fewer, larger +stalls instead of many smaller ones — and since `autoLoadMoreCommitsOnScroll` +fires whenever the viewport comes within 96 px of the bottom, the small-step +version stalls repeatedly during ordinary scrolling. + +**Set on `2026-09-23` at the maintainer's direction**: `initialLoadCommits` +`300` -> `250`, `loadMoreCommits` `100` -> `750`. The first page is the +latency-critical paint and is trimmed slightly; the follow-on page is the one +that was costing repeated stalls and is raised well clear of them. Both sit +below the round numbers this section measured, which is the conservative +direction given that the render figures below are jsdom's and not a +browser's. + +**The real fix, not done here.** The rebuild is `O(total)` per load when it +could be `O(step)` — appending the new rows instead of regenerating the table +would make page size nearly free and remove the stalls entirely. That is a +separate change to `renderTable` and its callers, and it is the thing to do if +these stalls are ever felt. + +**A defaults drift found while answering.** `loadMoreCommits` was declared +`100` in the manifest and documented as `100` in the README, but +`src/config.ts` fell back to `75`, and `tests/backend/config.test.ts` pinned +the `75` — a wrong value frozen by the test meant to protect it. The fallback +never fires in a real install, because VS Code returns the manifest default for +an unset key, so nothing user-visible was wrong. All four now read `1000`. The +accessor table in that test mirrors manifest defaults by hand, so it can drift +again; keep the manifest, the accessor, the README table and that test in step. #### Benchmarking the two backends (`2026-09-23`) @@ -5227,7 +5527,9 @@ fills, parity byte-for-byte, `loadBranches` untouched). **16.5 (`loadCommits`) is done** (`2026-09-22`: pre-call declines plus post-call HEAD/unborn reroutes, seam mapping with two gated CLI fills, `topo` declined over a measured tie-break difference, byte-for-byte parity -with the served flag). **16.6 (commit details, comparison, line counts) is +with the served flag; the two fills were deleted on `2026-09-23` once the +engine carried tag signatures and symbolic remote HEADs itself, taking +`loadCommits` from `0.8x` to `1.5x`). **16.6 (commit details, comparison, line counts) is done** (`2026-09-23`: eager whole-list counts fill, merges/`*`/blank/stash reroutes, file content through the provider with binary CLI fallback, parity over renames/copies/binary/root/unborn/dirty plus file bytes). diff --git a/engine/Cargo.lock b/engine/Cargo.lock index 02741fbb..2c158dbc 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -26,6 +17,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "1.3.2" @@ -38,15 +35,6 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bstr" version = "1.13.1" @@ -94,15 +82,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -146,16 +125,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "ctor" version = "1.0.13" @@ -176,6 +145,15 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + [[package]] name = "defmt" version = "1.1.1" @@ -207,16 +185,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "dunce" version = "1.0.5" @@ -250,10 +218,12 @@ dependencies = [ [[package]] name = "faster-hex" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" dependencies = [ + "autocfg", + "defmt 0.3.100", "heapless", "serde", ] @@ -374,16 +344,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -401,7 +361,6 @@ version = "1.0.24" dependencies = [ "bstr", "gix", - "regex", "serde", "serde_json", "tempfile", @@ -422,9 +381,9 @@ dependencies = [ [[package]] name = "gix" -version = "0.87.1" +version = "0.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdefc1465d8631807deaf504dacdeff628b978de515653b47976ca7c28ab2a7a" +checksum = "32d6dd6028308ad2f2662207506e396b97f9ec899f74bd537c0775248c774f21" dependencies = [ "gix-actor", "gix-attributes", @@ -473,14 +432,13 @@ dependencies = [ "gix-zlib", "nonempty", "smallvec", - "thiserror", ] [[package]] name = "gix-actor" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e37efa99929ac62f980fb1a7dcd09dea85b3aa6ead6ba0498362add3f4e698de" +checksum = "8e046e9a929e8e1f40f9a34f736408c4c079f9cf004429f3e718d270e847be96" dependencies = [ "bstr", "gix-date", @@ -489,46 +447,47 @@ dependencies = [ [[package]] name = "gix-attributes" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b83782ac69ae28921a6e8fbcf2e24069613f1518030b97ae622b079abffa54" +checksum = "a3d57bcf18768dc259868e335fae99a9063174178b78b00348603f0791cd780a" dependencies = [ "bstr", + "gix-error", "gix-features", "gix-glob", "gix-path", "gix-quote", "gix-trace", "smallvec", - "thiserror", "unicode-bom", ] [[package]] name = "gix-bitmap" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17013c7ef5cb95ebb4bf4978201e6a8a42ffea9d666fd5388f1b7e742307d80" +checksum = "4e9d28a8333a322df5c4fdc7b86a9928965e50794523c0edff57d1fbf2a520cf" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e32f938b3745ac93d73bc7490b6a15a661b2fc81973bfe90e275cc53c908e1" +checksum = "25303d07ce2b8f98aab48ce4251317d785a3208b7ed65698928e4a2f31889936" dependencies = [ "gix-error", ] [[package]] name = "gix-command" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa00adbd2c77f584cfe41f348772d2f131af1fa519a7f6b98638f1144ead96a4" +checksum = "eb3c2e61f97adc08764060e9becb0c819d8f2ba27896434710e039ae57b1d2a7" dependencies = [ "bstr", + "gix-error", "gix-path", "gix-quote", "gix-trace", @@ -536,9 +495,9 @@ dependencies = [ [[package]] name = "gix-commitgraph" -version = "0.39.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b93c9fb1f5be01bba54a7a3b7455d359efc68555539c75ef74b8021bb6db497" +checksum = "0462cbffd5828447f376559d397d9b29f1979dac3d980b5212b17fa174b174eb" dependencies = [ "bstr", "gix-chunk", @@ -550,12 +509,13 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.60.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f973c28c0a4871a7926fc90c8377d4458fd6cbb19692f56582b4d2022313ae90" +checksum = "eb2cb454dce2f1895cf9def1df8b0a6bbc579d86d8313bdf60f1ab429d52df12" dependencies = [ "bstr", "gix-config-value", + "gix-error", "gix-features", "gix-glob", "gix-path", @@ -563,28 +523,27 @@ dependencies = [ "gix-sec", "gix-utils", "smallvec", - "thiserror", "unicode-bom", ] [[package]] name = "gix-config-value" -version = "0.19.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" +checksum = "1894e816c774650b5157d965853b50d7e8f7137ea74055a6bac2f2ec051cf997" dependencies = [ "bitflags 2.13.1", "bstr", + "gix-error", "gix-path", "libc", - "thiserror", ] [[package]] name = "gix-date" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e63d9aa18f29facd571c8953e66224ee0075b9e16622024794555ed4acceea85" +checksum = "cb8beceb16a77fb222923592864b94beb4ad4c2b175a7055c2cac6815ee0370a" dependencies = [ "bstr", "gix-error", @@ -594,13 +553,14 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.67.1" +version = "0.68.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a" +checksum = "939aee7bbc9f233de858839e865c82886e0baceb8f1fe792ea1703fa895f09e1" dependencies = [ "bstr", "gix-attributes", "gix-command", + "gix-error", "gix-filter", "gix-fs", "gix-hash", @@ -613,17 +573,17 @@ dependencies = [ "gix-trace", "gix-traverse", "gix-worktree", - "thiserror", ] [[package]] name = "gix-dir" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8342d5eb0ea054ed6ef807e628e38f06dd51eceeec9529767d8b23e33eff9c09" +checksum = "e2e41d125c2c6f981aad27299a6233a7ace3413289c1e2994f57e5bab1c34847" dependencies = [ "bstr", "gix-discover", + "gix-error", "gix-fs", "gix-ignore", "gix-index", @@ -633,38 +593,37 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", - "thiserror", ] [[package]] name = "gix-discover" -version = "0.55.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec32a30ec2934735599c2545f30067e80bd255fd3454b52a5d59bc02b52874a3" +checksum = "4476ba055ec5fbb05f59dd98380b596428da060beac5f9dccd7374302ad1fbfb" dependencies = [ "bstr", "dunce", + "gix-error", "gix-fs", "gix-path", "gix-ref", "gix-sec", - "thiserror", ] [[package]] name = "gix-error" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1ba536602db507119eb5f3cdf80bef3ca5a5ec0dfc6e9c7f46dc70ade7c112" +checksum = "2c4a5c4cd326ddfe7f046ad9eed05461c59af6ea98b681648bac73954317addd" dependencies = [ "bstr", ] [[package]] name = "gix-features" -version = "0.49.1" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" +checksum = "bb3a430dbc02d6c5e30a9f92083459593495ea22a476e837d248b22d800eed80" dependencies = [ "bytes", "crc32fast", @@ -681,14 +640,15 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c30b28d957e2e6f1e1bbfbfd26b8e0bb0aab68d8a5e730fb4fd3049943575e3" +checksum = "6f31a39e2c9b04843709d1f72b2c39c98dc0ee591e2f67d21302dbfbd06d2956" dependencies = [ "bstr", "encoding_rs", "gix-attributes", "gix-command", + "gix-error", "gix-hash", "gix-object", "gix-packetline", @@ -697,51 +657,51 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror", ] [[package]] name = "gix-fs" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" +checksum = "0105ab1cef4542d092c27766d2daf1548fcbad7e460dc2208642c120bb19b3ad" dependencies = [ "bstr", + "gix-error", "gix-features", "gix-path", "gix-utils", - "thiserror", ] [[package]] name = "gix-glob" -version = "0.27.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" +checksum = "bba50803dea16646cafb9a2d67e95b8cdf5b21c7e8c45bcd18f38bafe02fb81f" dependencies = [ "bitflags 2.13.1", "bstr", "gix-features", "gix-path", + "gix-utils", ] [[package]] name = "gix-hash" -version = "0.26.2" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5" +checksum = "7a22b6febaaee8546eb0c9dd21ecdd93aca269a6cb3897e1250f0f60933c0174" dependencies = [ "faster-hex", + "gix-error", "gix-features", - "sha1-checked", - "thiserror", + "sha1dc", ] [[package]] name = "gix-hashtable" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +checksum = "c55bff2f32f6f5a95bdd43e5e5e01b0656250cffbc48eda19db0afa3d73adf2f" dependencies = [ "gix-hash", "hashbrown 0.17.1", @@ -750,9 +710,9 @@ dependencies = [ [[package]] name = "gix-ignore" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65859a2f7de5e159d4344a5486ebf53b6bac22d5ec6afa836e47c10ae88a7f0f" +checksum = "5bfd910e271ecd1a20e4df4009a0d9d844a0c51ee5444aaa3fadd1b1b077930b" dependencies = [ "bstr", "gix-glob", @@ -763,9 +723,9 @@ dependencies = [ [[package]] name = "gix-imara-diff" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595" +checksum = "305657ad44356151d73c145f567c25b24fdf2466bfb3e90727e1d5461a4e9748" dependencies = [ "bstr", "hashbrown 0.17.1", @@ -773,15 +733,16 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.55.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632" +checksum = "dfd5b484b5b4e218a5ac114f0982949f9bd7582ff67986665a0cf85895332433" dependencies = [ "bitflags 2.13.1", "bstr", "filetime", "fnv", "gix-bitmap", + "gix-error", "gix-features", "gix-fs", "gix-hash", @@ -796,25 +757,24 @@ dependencies = [ "memmap2", "rustix", "smallvec", - "thiserror", ] [[package]] name = "gix-lock" -version = "24.0.0" +version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +checksum = "8fd5ddfd0cc476fef744ac544783b96a9ff5d49f8de541e6e9d6d6002aa90720" dependencies = [ + "gix-error", "gix-tempfile", "gix-utils", - "thiserror", ] [[package]] name = "gix-macros" -version = "0.1.6" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3836b4b051393464a753c5a08fe19c7ce0d8b77574a92bd558972941d4553cb" +checksum = "f11f5429ca73cc3758c9f53af56a9fba100a8701c5b4bc30e29b8b0b15288991" dependencies = [ "proc-macro2", "quote", @@ -823,9 +783,9 @@ dependencies = [ [[package]] name = "gix-mailmap" -version = "0.34.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a1c6ca2f9481e5e98b3600b859bc595d46cdf3b600709d5f91b8c118ae452a1" +checksum = "4d7e1278ef07d86d01c55ef78e469472913f01663b549a5cf51cf280ac0f85f8" dependencies = [ "bstr", "gix-actor", @@ -835,44 +795,45 @@ dependencies = [ [[package]] name = "gix-note" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af6bbdb3f3c62b4920407c479af1cc0beb27fd75a762782d48baed53d330958" +checksum = "5d6879cc9e5a9a0429e40b05970af6c8dad8734098a3142fec23e32eeb567580" dependencies = [ "gix-error", "gix-hash", - "gix-hashtable", "gix-object", ] [[package]] name = "gix-object" -version = "0.64.1" +version = "0.65.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56fef799ca40cdeab4de5a3e74a696d8fa9b9c6264592646bf022e026f13ce2c" +checksum = "a72909eeeed845721a143ef3dbeb4d6cd5df79c839fe7eefc138cb4f982eba3e" dependencies = [ "bstr", "gix-actor", "gix-command", "gix-date", + "gix-error", "gix-features", "gix-hash", "gix-hashtable", + "gix-path", "gix-tempfile", "gix-utils", "gix-validate", "itoa", "smallvec", - "thiserror", ] [[package]] name = "gix-odb" -version = "0.84.0" +version = "0.85.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf45d195b71cb6363886e7b466821bf4dffd7aba1b6b814ff75488e0061d72a7" +checksum = "5794b85ff563a290ea18867fa1f0a5b0b84709f2f34dddbb3614556fbe88c629" dependencies = [ "arc-swap", + "gix-error", "gix-features", "gix-fs", "gix-hash", @@ -885,14 +846,13 @@ dependencies = [ "memmap2", "parking_lot", "tempfile", - "thiserror", ] [[package]] name = "gix-pack" -version = "0.74.2" +version = "0.75.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d199d515cbdcf05f531f3859be9771fc12acac7b7fe0f5b581b2c1ab1c3a61e" +checksum = "0451a48faef8e0e7a8f6d903c8329c7d18a793b3030907ca841f889dc695d1df" dependencies = [ "clru", "crossbeam-deque", @@ -906,56 +866,56 @@ dependencies = [ "gix-zlib", "memmap2", "smallvec", - "thiserror", ] [[package]] name = "gix-packetline" -version = "0.22.2" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2" +checksum = "0ba540112a3fcc6388eb2aa4dc4837040478c285a35178cd9a44b07345cd6636" dependencies = [ "bstr", "faster-hex", + "gix-error", "gix-trace", - "thiserror", ] [[package]] name = "gix-path" -version = "0.12.6" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38fc6f029ea67de83cbcbd33fd98c05a48360d2932b39d9fdacbc6eae802d475" +checksum = "fd051ac60c5dbce2228daabf25b6a76408ac732d416c678cfa0a4914d13f60b8" dependencies = [ "bstr", + "gix-error", "gix-trace", "gix-validate", - "thiserror", ] [[package]] name = "gix-pathspec" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92e44c63ba53bb55aa88ee48847664beae9446417621582fb14dfc69b2f8c72" +checksum = "1ee883ab64bad641bf31ff338044edd7aa010605f19e866d98c9164e635c38b1" dependencies = [ "bitflags 2.13.1", "bstr", "gix-attributes", "gix-config-value", + "gix-error", "gix-glob", "gix-path", - "thiserror", ] [[package]] name = "gix-protocol" -version = "0.65.1" +version = "0.66.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68878c37ae168b474c762d70adc8a8e0f9323ed0d80444a672e2eb561fa16691" +checksum = "331ac489c461790b6b692afb322085c42041c6ff7621c30ac3dbb00b12e05e89" dependencies = [ "bstr", "gix-date", + "gix-error", "gix-features", "gix-hash", "gix-macros", @@ -964,14 +924,13 @@ dependencies = [ "gix-transport", "gix-utils", "nonempty", - "thiserror", ] [[package]] name = "gix-quote" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74795eb76eab8313063849f9cbd2bbcdc6e4692f71c1d7d0244ab11cd777329" +checksum = "e6b040cfa7af50f131bfa34119cceee804c3cc40c766daaba8e0c8149340aea7" dependencies = [ "bstr", "gix-error", @@ -980,11 +939,12 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.67.1" +version = "0.68.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8328387e7ab354e1dc3afb63e6b4d1866f82d7ce035222bc9d5baaf36bc88020" +checksum = "c90ceeec67de03b737c30b1b69116ca56fec803edde022f3d25181f0f597083d" dependencies = [ "gix-actor", + "gix-error", "gix-features", "gix-fs", "gix-hash", @@ -995,27 +955,26 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror", ] [[package]] name = "gix-refspec" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de280d46e8fd9e4d3e7e2ab4276b965e377ba68b2b9a8fce56371015e8bb059" +checksum = "b4caa89a034a055c0578150092d3edb2f1c3f74cca86b883263a28caa26c5812" dependencies = [ "bstr", + "gix-error", "gix-hash", "gix-validate", "smallvec", - "thiserror", ] [[package]] name = "gix-revision" -version = "0.49.1" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078" +checksum = "d8438d38e197fa3ea7bb297302db1701879a64a343f23a3d150a602a609a8751" dependencies = [ "bitflags 2.13.1", "bstr", @@ -1032,9 +991,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "248823eefe405e2c0754b74f6f43ab6faab68670cdbf2d6e114cb0471f766450" +checksum = "2fa6d913e7cf8c6407d3954f9794b4b148c2113c672c34b63c59ea654a1e060e" dependencies = [ "gix-commitgraph", "gix-date", @@ -1043,14 +1002,13 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror", ] [[package]] name = "gix-sec" -version = "0.14.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +checksum = "7d8eb983a830ba586b61f7005deaf44609e1a4186cbf13a0e8668154ca910461" dependencies = [ "bitflags 2.13.1", "gix-path", @@ -1060,27 +1018,28 @@ dependencies = [ [[package]] name = "gix-shallow" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +checksum = "119bae1301715df40e8fd136deeff4fe8a3be4cfc001ed5e72bad7b47ebbc090" dependencies = [ "bstr", + "gix-error", "gix-hash", "gix-lock", "nonempty", - "thiserror", ] [[package]] name = "gix-status" -version = "0.34.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1401d871c01d82add5a9f654439b00722d0c632b332cd2cb63193f94327c8458" +checksum = "cdb7e035b6d9dce932f6b6c8d33d29b31b46e05da544bf810e324bf61aaa5b84" dependencies = [ "bstr", "filetime", "gix-diff", "gix-dir", + "gix-error", "gix-features", "gix-filter", "gix-fs", @@ -1092,96 +1051,96 @@ dependencies = [ "gix-worktree", "hashbrown 0.16.1", "portable-atomic", - "thiserror", "windows-sys", ] [[package]] name = "gix-submodule" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da85564d6725c483b8d95d7b31d1db0b78c29e462a2b481949e2f18e1ed55a6a" +checksum = "0b498192e5f89453fbd728263d52088aeec634a390e732f1da6d0a491c552091" dependencies = [ "bstr", "gix-config", + "gix-error", "gix-path", "gix-pathspec", "gix-refspec", "gix-url", - "thiserror", ] [[package]] name = "gix-tempfile" -version = "24.0.0" +version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +checksum = "3dd7dd8df3345933caddd6ce67821c9eec17192564f1011cf0bd0b8cb0d07ffb" dependencies = [ "dashmap", "gix-fs", "libc", "parking_lot", "tempfile", + "windows-sys", ] [[package]] name = "gix-trace" -version = "0.1.21" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" +checksum = "df45b2cf6052d9e52681130c17d67505b023e8933ad974f94a2be0b0d24f636a" [[package]] name = "gix-transport" -version = "0.59.2" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3f41a64939953ff49117df4eb91334d299abc59ff57a90fb2193d8ffa5886f1" +checksum = "5d2beb7b0387bbf4d7c13763c6e168f9957319aa811f09349dc59601be3b2951" dependencies = [ "bstr", "gix-command", + "gix-error", "gix-features", "gix-packetline", "gix-path", "gix-quote", "gix-sec", "gix-url", - "thiserror", ] [[package]] name = "gix-traverse" -version = "0.61.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245" +checksum = "dd4864934e4efd92082ecc37107bce0c389b58d82888db634817f9c711efb7ed" dependencies = [ "bitflags 2.13.1", "gix-commitgraph", "gix-date", + "gix-error", "gix-hash", "gix-hashtable", "gix-object", "gix-revwalk", "smallvec", - "thiserror", ] [[package]] name = "gix-url" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bda80ccb4bc81fb9fb07a975916eb0c7a889bcbcb0c8a239c3f82a9cc2abdda" +checksum = "1f30fe2a228d7de5f9d582463d9b724debcb870162d9fbe908cd5d76ccdecf0d" dependencies = [ "bstr", + "gix-error", "gix-path", "gix-utils", "percent-encoding", - "thiserror", ] [[package]] name = "gix-utils" -version = "0.3.6" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" +checksum = "70525766f53426ca4cd2ea4d58b0b31488dfedb416b3254e2efdd9dc184cc880" dependencies = [ "bstr", "fastrand", @@ -1191,21 +1150,23 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" +checksum = "30574f81fc41b2ca3582ece2bdf8696bdfe9c170aa7e8bcafb81199c86b1ceea" dependencies = [ "bstr", + "gix-error", ] [[package]] name = "gix-worktree" -version = "0.56.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed36b627476e2072c129900a17c977a38052b5497e27308159bc881fbf4eecf" +checksum = "04fa19b9b3961aafb3b9e60005b9bcef0100f8b211f46ab5269a6ac896073780" dependencies = [ "bstr", "gix-attributes", + "gix-error", "gix-features", "gix-fs", "gix-glob", @@ -1219,9 +1180,9 @@ dependencies = [ [[package]] name = "gix-worktree-stream" -version = "0.36.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de5ec0ccbab39c9994656ea031ded8ffd90b370a3cc8e360fbe3c4ebe7c64964" +checksum = "2021e1609beae0edbeefa76ac641be0571b725afacb77ac5b12f33b436e0d3d9" dependencies = [ "gix-attributes", "gix-error", @@ -1237,11 +1198,11 @@ dependencies = [ [[package]] name = "gix-zlib" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +checksum = "44a1a9c82ec5b712082566cbef8f5782472a0d3973132911e5a83380a5298fe2" dependencies = [ - "thiserror", + "gix-error", "zlib-rs", ] @@ -1304,7 +1265,7 @@ version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ - "defmt", + "defmt 1.1.1", "jiff-core", "jiff-static", "jiff-tzdb-platform", @@ -1321,7 +1282,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ - "defmt", + "defmt 1.1.1", ] [[package]] @@ -1574,34 +1535,11 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-hash" @@ -1693,25 +1631,10 @@ dependencies = [ ] [[package]] -name = "sha1" -version = "0.10.7" +name = "sha1dc" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha1-checked" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" -dependencies = [ - "digest", - "sha1", -] +checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" [[package]] name = "slab" @@ -1810,12 +1733,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-bom" version = "2.0.3" @@ -1843,12 +1760,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 13396ef4..0e86be99 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -9,8 +9,7 @@ rust-version = "1.82" license = "MIT" [workspace.dependencies] -gix = { version = "0.87", default-features = false } -regex = "1" +gix = { version = "0.88", default-features = false } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/engine/native/core/Cargo.toml b/engine/native/core/Cargo.toml index e93c21fb..48740e34 100644 --- a/engine/native/core/Cargo.toml +++ b/engine/native/core/Cargo.toml @@ -7,7 +7,7 @@ rust-version.workspace = true license.workspace = true [dependencies] -gix = { version = "0.87", default-features = false, features = [ +gix = { version = "0.88", default-features = false, features = [ "revision", "blob-diff", "status", @@ -19,7 +19,6 @@ gix = { version = "0.87", default-features = false, features = [ ] } bstr = "1" thiserror.workspace = true -regex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/engine/native/core/src/api.rs b/engine/native/core/src/api.rs index 41792c63..e0d35b97 100644 --- a/engine/native/core/src/api.rs +++ b/engine/native/core/src/api.rs @@ -37,8 +37,8 @@ use crate::repository::{Repo, RepoManager}; use crate::status::ScmChange; use crate::types::{ CommitFile, CommitOrdering, ConfigSnapshot, GitActivityCell, GitAuthor, GitAuthorStat, - GitCommitData, GitCommitDetails, GitFileChange, GitHistoryMatch, GitRepoInfo, GitStash, - GitTagDetails, LogOptions, RefReadOptions, RefSnapshot, + GitCommitData, GitCommitDetails, GitFileChange, GitRepoInfo, GitStash, GitTagDetails, + LogOptions, RefReadOptions, RefSnapshot, }; /// How a graph page is loaded. `Default` is the view's own default request: every local @@ -180,12 +180,6 @@ impl Engine { pub fn stashes(&self) -> Result> { crate::stash::read_stashes(&self.repo) } - - /// Search commit messages, newest first (`git log --all -E -i --grep`). - pub fn search_history(&self, query: &str) -> Result> { - crate::log::search_history(&self.repo, query) - } - /* ---------- Commits ---------- */ /// A commit in full: message, author, signature, parents and the files it changed. @@ -197,11 +191,6 @@ impl Engine { pub fn commit_bodies(&self, hashes: &[String]) -> Result> { crate::details::commit_bodies(&self.repo, hashes) } - - pub fn commit_subject(&self, hash: &str) -> Result { - crate::details::commit_subject(&self.repo, hash) - } - pub fn tag(&self, name: &str) -> Result { crate::details::tag_details(&self.repo, name) } @@ -264,15 +253,6 @@ impl Engine { pub fn config(&self) -> Result { crate::config::read_config(&self.repo) } - - pub fn current_branch(&self) -> Result> { - crate::config::current_branch_name(&self.repo) - } - - pub fn upstream_of_current_branch(&self) -> Result> { - crate::config::current_branch_upstream(&self.repo) - } - pub fn remote_url(&self, remote: &str) -> Result> { crate::config::remote_url(&self.repo, remote) } diff --git a/engine/native/core/src/config.rs b/engine/native/core/src/config.rs index ccb9f9f5..54af6775 100644 --- a/engine/native/core/src/config.rs +++ b/engine/native/core/src/config.rs @@ -73,42 +73,6 @@ pub fn remote_url(repo: &Repo, remote: &str) -> Result> { .string(format!("remote.{remote}.url").as_str()) .map(|value| value.to_string())) } - -/// The upstream of the checked-out branch, short-spelled as `git rev-parse --abbrev-ref -/// --symbolic-full-name @{upstream}` prints it (`origin/main`), or `None` when there is none. -pub fn current_branch_upstream(repo: &Repo) -> Result> { - let git = repo.borrow(); - let branch = git.head_name().ok().flatten().and_then(|name| { - name.as_bstr() - .strip_prefix(b"refs/heads/".as_slice()) - .map(|branch| String::from_utf8_lossy(branch).into_owned()) - }); - let Some(branch) = branch else { - // Detached HEAD tracks nothing. - return Ok(None); - }; - - let config = git.config_snapshot(); - let string = |key: String| config.string(key.as_str()).map(|value| value.to_string()); - let remote = string(format!("branch.{branch}.remote")); - let merge = string(format!("branch.{branch}.merge")); - let (remote, merge) = match (remote, merge) { - (Some(remote), Some(merge)) => (remote, merge), - _ => return Ok(None), - }; - - // `remote = .` means the upstream is local: the merge ref itself is the branch followed. - let short = merge - .strip_prefix("refs/heads/") - .unwrap_or(&merge) - .to_string(); - Ok(Some(if remote == "." { - short - } else { - format!("{remote}/{short}") - })) -} - /// The roots of the repository's initialised submodules, as the original extension gathered them /// from `.gitmodules`. /// @@ -159,23 +123,6 @@ fn submodule_root(root: &Path, path: &str) -> Option { } /* ---------- The remaining reads the settings panel and dialogs make ---------- */ - -/// The names of the repository's remotes, as `git remote` lists them (alphabetical). -pub fn remote_names(repo: &Repo) -> Result> { - Ok(repo.remote_names()) -} - -/// The checked-out branch's short name, or `None` when HEAD is detached — the answer -/// `git symbolic-ref --short HEAD` gives (an unborn branch still has its name). -pub fn current_branch_name(repo: &Repo) -> Result> { - let git = repo.borrow(); - Ok(git.head_name().ok().flatten().and_then(|name| { - name.as_bstr() - .strip_prefix(b"refs/heads/".as_slice()) - .map(|branch| String::from_utf8_lossy(branch).into_owned()) - })) -} - /// One location a configuration entry can live in, matching `git config --local` / `--global`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigLocation { diff --git a/engine/native/core/src/details.rs b/engine/native/core/src/details.rs index c53e3d93..c9ede594 100644 --- a/engine/native/core/src/details.rs +++ b/engine/native/core/src/details.rs @@ -153,19 +153,6 @@ pub fn commit_bodies(repo: &Repo, hashes: &[String]) -> Result Result { - let git = repo.borrow(); - let id = crate::repository::resolve_commit_in(&git, hash)?; - let commit = git.find_commit(id).git_ctx("Could not read the commit")?; - let message = commit - .message() - .git_ctx("Could not decode the commit message")?; - Ok(collapse_whitespace(message.summary().to_string())) -} - /// The summary of each of the given commits (author, email, author date, full message), keyed by /// hash — what the Commit Comparison View titles its two sides with. pub fn commit_summaries( @@ -333,8 +320,3 @@ fn strip_trailing_blank_lines(message: String) -> String { } lines.join("\n") } - -/// Trim and collapse every run of whitespace into a single space. -fn collapse_whitespace(text: String) -> String { - text.split_whitespace().collect::>().join(" ") -} diff --git a/engine/native/core/src/graph.rs b/engine/native/core/src/graph.rs index 0e44903f..4ef53efa 100644 --- a/engine/native/core/src/graph.rs +++ b/engine/native/core/src/graph.rs @@ -297,6 +297,7 @@ fn annotate_refs(ref_data: &GitRefData, options: &LogOptions, commits: &mut [Git commits[index].tags.push(GitCommitTag { name: tag.name.clone(), annotated: tag.annotated, + signed: tag.signed, }); } } diff --git a/engine/native/core/src/lib.rs b/engine/native/core/src/lib.rs index 66ada751..8b38ad65 100644 --- a/engine/native/core/src/lib.rs +++ b/engine/native/core/src/lib.rs @@ -20,6 +20,7 @@ pub mod graph; pub mod log; pub mod refs; pub mod repository; +pub mod search; pub mod stash; pub mod stats; pub mod status; diff --git a/engine/native/core/src/log.rs b/engine/native/core/src/log.rs index da6e5937..013c1685 100644 --- a/engine/native/core/src/log.rs +++ b/engine/native/core/src/log.rs @@ -19,7 +19,7 @@ use gix::ObjectId; use crate::error::{Error, Result, ResultExt}; use crate::repository::Repo; -use crate::types::{CommitOrdering, CommitRecord, GitAuthor, GitHistoryMatch}; +use crate::types::{CommitOrdering, CommitRecord, GitAuthor}; /// How many commits are read for every commit displayed, so that the topological re-ordering has /// enough of the graph to be exact over the page it returns. @@ -174,7 +174,7 @@ pub fn read_commit(commit: &gix::Commit<'_>) -> Result { /// trailing "<", a name that is a textual prefix of another author's name (e.g. "Bob" inside /// "Bobby ") would match commits it should not. This mirrors that exactly, case- /// insensitively (case sensitivity is the one place this still deviates from a bare `git log`). -fn commit_matches_author(commit: &gix::Commit<'_>, authors: &[String]) -> Result { +pub(crate) fn commit_matches_author(commit: &gix::Commit<'_>, authors: &[String]) -> Result { let author = commit .author() .git_ctx("Could not decode the commit author")?; @@ -508,80 +508,6 @@ pub fn all_tips(repo: &Repo, include_tags: bool, include_remotes: bool) -> Resul /* ---------- History search ---------- */ -/// How many hits the Find dialogue shows, matching the original's `--max-count=100`. -const SEARCH_LIMIT: usize = 100; - -/// Search every commit message for a pattern, newest first, as `git log --all -E -i --grep`. -/// -/// The tips are everything `git log --all` walks from — local branches, tags, remote-tracking -/// branches, HEAD, and the stash, whose ref lives in `refs/` even though the graph never shows it. -/// The walk is commit-date ordered (git's default for `--grep`), not topologically constrained, so -/// it needs none of the windowed re-ordering the graph does. -pub fn search_history(repo: &Repo, query: &str) -> Result> { - let matcher = regex::RegexBuilder::new(query) - .case_insensitive(true) - .build() - .map_err(|e| Error::invalid_argument(format!("Invalid search query: {e}")))?; - - // A repository with no refs at all has nothing to search; git's `--all` simply matches nothing. - let mut tips = all_tips(repo, true, true).unwrap_or_default(); - if let Some(stash) = stash_tip(repo) { - if !tips.contains(&stash) { - tips.push(stash); - } - } - if tips.is_empty() { - return Ok(Vec::new()); - } - - let git = repo.borrow(); - let walk = git - .rev_walk(tips.iter().copied()) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .all() - .git_ctx("Could not walk the commit graph")?; - - let mut matches: Vec = Vec::new(); - for info in walk { - let info = match info { - Ok(info) => info, - // A missing object truncates the search rather than failing it, as it truncates the - // graph walk. - Err(_) => break, - }; - let commit = match git.find_commit(info.id) { - Ok(commit) => commit, - Err(_) => continue, - }; - let raw = commit - .message_raw() - .git_ctx("Could not decode the commit message")? - .to_string(); - if !matcher.is_match(&raw) { - continue; - } - let author = commit - .author() - .git_ctx("Could not decode the commit author")?; - matches.push(GitHistoryMatch { - hash: commit.id().detach().to_string(), - author: author.name.to_string(), - date: author.time().map(|time| time.seconds).unwrap_or(0), - message: commit - .message() - .git_ctx("Could not decode the commit message")? - .summary() - .to_string(), - }); - if matches.len() >= SEARCH_LIMIT { - break; - } - } - Ok(matches) -} - /// The commit `refs/stash` points at, if a stash exists. pub(crate) fn stash_tip(repo: &Repo) -> Option { let git = repo.borrow(); diff --git a/engine/native/core/src/refs.rs b/engine/native/core/src/refs.rs index 1041bef1..6811fc8f 100644 --- a/engine/native/core/src/refs.rs +++ b/engine/native/core/src/refs.rs @@ -79,13 +79,18 @@ pub fn read_refs(repo: &Repo, options: &RefReadOptions) -> Result { Some(name) => bstr_to_string(name), None => continue, }; - let Some(hash) = direct_target(&reference) else { + let Some(id) = direct_target_id(&reference) else { continue; }; + let hash = id.to_string(); + // The signature belongs to the tag object, so it is read once and carried by both + // records: the graph attaches the peeled one, but a caller matching by name sees either. + let signed = tag_signature_present(&git, id); ref_data.tags.push(GitTagRef { hash: hash.clone(), name: name.clone(), annotated: false, + signed, }); tag_names.push(name.clone()); @@ -96,6 +101,7 @@ pub fn read_refs(repo: &Repo, options: &RefReadOptions) -> Result { hash: peeled, name, annotated: true, + signed, }); } } @@ -142,7 +148,7 @@ fn read_remote_refs( let mut remote_tags_to_peel: Vec<(usize, String)> = Vec::new(); let platform = git.references().git_ctx("Could not read references")?; - for reference in platform + for mut reference in platform .prefixed("refs/remotes/") .git_ctx("Could not read remote branches")? .filter_map(std::result::Result::ok) @@ -168,8 +174,16 @@ fn read_remote_refs( if remote_ref.contains("/changes/") { continue; } - let Some(hash) = direct_target(&reference) else { - continue; + // `refs/remotes//HEAD` is symbolic, and `for-each-ref %(objectname)` reports the + // object it resolves to rather than skipping it — so it is resolved here too. Resolving + // costs one lookup and only ever applies to the handful of `/HEAD` refs a remote has; a + // symbolic ref that resolves to nothing is dropped, as the CLI drops an unborn one. + let hash = match reference.target() { + gix::refs::TargetRef::Object(id) => id.to_string(), + gix::refs::TargetRef::Symbolic(_) => match reference.peel_to_id() { + Ok(id) => id.detach().to_string(), + Err(_) => continue, + }, }; if let Some(tags_index) = remote_ref.find("/tags/") { @@ -184,6 +198,7 @@ fn read_remote_refs( hash, name, annotated: false, + signed: false, }); } else { ref_data.remotes.push(GitRef { @@ -199,16 +214,23 @@ fn read_remote_refs( let Ok(Some(mut reference)) = git.try_find_reference(full_name.as_str()) else { continue; }; + // The ref's own target, captured before the peel rewrites it: that is the tag object, + // and the signature belongs to it rather than to the commit it peels to. + let tag_object = direct_target_id(&reference); let Ok(peeled) = reference.peel_to_id() else { continue; }; let peeled = peeled.detach().to_string(); if peeled != ref_data.tags[index].hash { let name = ref_data.tags[index].name.clone(); + // Recorded on the unpeeled record too, so both records of one tag agree. + let signed = tag_object.is_some_and(|id| tag_signature_present(git, id)); + ref_data.tags[index].signed = signed; ref_data.tags.push(GitTagRef { hash: peeled, name, annotated: true, + signed, }); } } @@ -222,12 +244,39 @@ fn read_remote_refs( /// them only for the sake of the label, and the branch they alias is already listed in its own /// right. fn direct_target(reference: &gix::Reference<'_>) -> Option { + direct_target_id(reference).map(|id| id.to_string()) +} + +/// The same, as an id, for callers that go on to read the object. +fn direct_target_id(reference: &gix::Reference<'_>) -> Option { match reference.target() { - gix::refs::TargetRef::Object(id) => Some(id.to_string()), + gix::refs::TargetRef::Object(id) => Some(id.to_owned()), gix::refs::TargetRef::Symbolic(_) => None, } } +/// Whether a tag ref's own target is a tag object carrying a signature. +/// +/// This is `for-each-ref`'s `%(contents:signature)`, which is non-empty only for an annotated +/// tag that was signed: a lightweight tag reports nothing even when the commit it points at is +/// itself signed (verified against git directly, not assumed). +/// +/// The header settles the object kind before anything is decoded, so a lightweight tag costs a +/// header lookup rather than a commit read. +fn tag_signature_present(git: &gix::Repository, id: gix::ObjectId) -> bool { + match git.find_header(id) { + Ok(header) if header.kind() == gix::object::Kind::Tag => {} + _ => return false, + } + let Ok(object) = git.find_object(id) else { + return false; + }; + let Ok(tag) = object.try_into_tag() else { + return false; + }; + matches!(tag.decode(), Ok(decoded) if decoded.signature.is_some()) +} + fn bstr_to_string(bytes: &[u8]) -> String { String::from_utf8_lossy(bytes).into_owned() } diff --git a/engine/native/core/src/search.rs b/engine/native/core/src/search.rs new file mode 100644 index 00000000..dd27547d --- /dev/null +++ b/engine/native/core/src/search.rs @@ -0,0 +1,238 @@ +//! The Find dialogue's commit search, reproducing what the `git` CLI backend does. +//! +//! This is deliberately *not* the engine's original `log::search_history`, which matched a regular +//! expression against commit messages across every ref. This project's search is a different +//! question, and wiring the regex one would have changed what users see, so it was removed instead: +//! +//! | | this search | `log::search_history` (removed) | +//! | --- | --- | --- | +//! | message match | literal substring, case-insensitive | regular expression | +//! | author match | yes, literal substring | no | +//! | hash match | yes, abbreviated hashes resolve | no | +//! | refs searched | the ones the view is showing | every ref, always | +//! | author filter | honoured | ignored | +//! | ordering | position in the graph's own walk | commit date, newest first | +//! +//! The CLI reaches the answer with four `git log` invocations run together — a literal +//! `--fixed-strings --grep`, an `--author`, a hash lookup, and one unbounded walk that numbers +//! every commit. That numbering is the `loadCount` each result carries: "how far into the graph +//! you would have to load to reach this commit". Everything here is one walk instead of four +//! processes, producing the same three match sets and the same numbering. + +use std::collections::HashMap; + +use crate::error::{Result, ResultExt}; +use crate::log; +use crate::refs::read_refs; +use crate::repository::Repo; +use crate::types::{GitSearchResult, RefReadOptions, SearchOptions}; + +/// A query that could be an abbreviated object id. Mirrors the CLI's `/^[0-9a-f]{4,40}$/i`, which +/// is what decides whether the hash lookup runs at all. +fn is_hash_like(query: &str) -> bool { + let length = query.len(); + (4..=40).contains(&length) && query.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// The tips the search walks, which are the refs the *view is showing* rather than everything. +/// +/// The CLI builds these as `--branches`, plus `--tags` and `--remotes` when those are shown, with +/// hidden remotes excluded. Two absences are deliberate and both are the CLI's: `HEAD` is not a +/// tip, so a detached HEAD's own commits are not searched, and neither is the stash. +fn search_tips(repo: &Repo, options: &SearchOptions) -> Result> { + // An explicit selection (branches and/or tags chosen in the dropdowns) replaces the lot, which + // is what the CLI's `refArgs` does when it has any selected refs. + if let Some(branches) = &options.branches { + return log::resolve_tips(repo, branches); + } + + let ref_options = RefReadOptions { + show_remote_branches: options.show_remote_branches, + // `--remotes` lists non-symbolic remote refs; a symbolic `origin/HEAD` aliases a branch + // that is already a tip in its own right, so including it would only duplicate work. + show_remote_heads: false, + hide_remotes: options.hide_remotes.clone(), + }; + let snapshot = read_refs(repo, &ref_options)?; + + let mut revisions: Vec = Vec::new(); + revisions.extend(snapshot.ref_data.heads.iter().map(|head| head.hash.clone())); + if options.show_tags { + revisions.extend(snapshot.ref_data.tags.iter().map(|tag| tag.hash.clone())); + } + if options.show_remote_branches { + revisions.extend( + snapshot + .ref_data + .remotes + .iter() + .map(|remote| remote.hash.clone()), + ); + } + log::resolve_tips(repo, &revisions) +} + +/// Does this commit's author line contain the query? +/// +/// ### Deviation +/// +/// git's `--author` matches the whole `author Name ` header, so a query of +/// bare digits can match a commit's timestamp there and not here. Matching the rendered timestamp +/// would mean reproducing git's date formatting exactly, which is a larger risk than the case it +/// covers: this matches `Name `, which is every realistic author query. +fn author_contains(author: &gix::actor::SignatureRef<'_>, needle_lower: &str) -> bool { + let haystack = format!("{} <{}>", author.name, author.email).to_lowercase(); + haystack.contains(needle_lower) +} + +/// One commit rendered into the result shape, at the position the walk gave it. +fn to_result( + commit: &gix::Commit<'_>, + load_count: u32, + use_author_date: bool, +) -> Result { + let author = commit + .author() + .git_ctx("Could not decode the commit author")?; + let date = if use_author_date { + author.time().map(|time| time.seconds).unwrap_or(0) + } else { + commit + .committer() + .git_ctx("Could not decode the commit committer")? + .time() + .map(|time| time.seconds) + .unwrap_or(0) + }; + Ok(GitSearchResult { + hash: commit.id().detach().to_string(), + parents: commit + .parent_ids() + .map(|id| id.detach().to_string()) + .collect(), + author: author.name.to_string(), + email: author.email.to_string(), + date, + // The subject alone, which is what `%s` prints and what the dialogue lists. The match above + // ran against the whole message, exactly as `--grep` does. + message: commit + .message() + .git_ctx("Could not decode the commit message")? + .summary() + .to_string(), + load_count, + }) +} + +/// Search the commits the view is showing, as the `git` CLI backend searches them. +pub fn search_commits(repo: &Repo, options: &SearchOptions) -> Result> { + let query = options.query.trim(); + if query.is_empty() || options.max_results == 0 { + return Ok(Vec::new()); + } + let needle = query.to_lowercase(); + let limit = options.max_results as usize; + + let tips = search_tips(repo, options)?; + if tips.is_empty() { + return Ok(Vec::new()); + } + + let git = repo.borrow(); + let walk = git + .rev_walk(tips.iter().copied()) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all() + .git_ctx("Could not walk the commit graph")?; + + // The three match sets the CLI produces with three separate `git log` runs. Each is capped at + // `max_results` on its own, because each of the CLI's runs carries its own `--max-count`; the + // merge below then re-slices the union. + let mut message_hits: Vec = Vec::new(); + let mut author_hits: Vec = Vec::new(); + let mut positions: HashMap = HashMap::new(); + let mut position = 0u32; + + for info in walk { + let info = match info { + // A missing object truncates the search rather than failing it, as it truncates the + // graph walk. + Err(_) => break, + Ok(info) => info, + }; + let commit = match git.find_commit(info.id) { + Ok(commit) => commit, + Err(_) => continue, + }; + + // The author filter applies to the numbering walk as well as to the matches, because the + // CLI passes `--author` to its positions run too. A commit it hides is not merely + // unmatched, it has no position at all, and an otherwise-matching commit without a + // position is dropped by the merge. + if let Some(authors) = &options.authors { + if !log::commit_matches_author(&commit, authors)? { + continue; + } + } + + let hash = commit.id().detach().to_string(); + position += 1; + positions.entry(hash).or_insert(position); + let load_count = position; + + let full_message_matches = message_hits.len() < limit + && commit + .message_raw() + .git_ctx("Could not decode the commit message")? + .to_string() + .to_lowercase() + .contains(&needle); + if full_message_matches { + message_hits.push(to_result(&commit, load_count, options.use_author_date)?); + } + + if author_hits.len() < limit { + let author = commit + .author() + .git_ctx("Could not decode the commit author")?; + if author_contains(&author, &needle) { + author_hits.push(to_result(&commit, load_count, options.use_author_date)?); + } + } + } + + // The hash lookup, which the CLI runs without any ref or author constraint and then discards + // if the commit turns out to have no position — so an unreachable or filtered-out commit is + // not a result even when its hash is typed in full. + let mut results: Vec = Vec::new(); + if is_hash_like(query) { + if let Some(found) = git + .rev_parse_single(format!("{query}^{{commit}}").as_str()) + .ok() + .and_then(|id| git.find_commit(id).ok()) + { + let hash = found.id().detach().to_string(); + if let Some(&load_count) = positions.get(&hash) { + results.push(to_result(&found, load_count, options.use_author_date)?); + } + } + } + + // Merge in the CLI's order — hash, then message, then author — keeping the first record of any + // commit, then order the union by position and cut it to the page. + results.extend(message_hits); + results.extend(author_hits); + let mut seen: Vec = Vec::with_capacity(results.len()); + results.retain(|result| { + if seen.contains(&result.hash) { + return false; + } + seen.push(result.hash.clone()); + true + }); + results.sort_by_key(|result| result.load_count); + results.truncate(limit); + Ok(results) +} diff --git a/engine/native/core/src/stats.rs b/engine/native/core/src/stats.rs index 74125189..504271fc 100644 --- a/engine/native/core/src/stats.rs +++ b/engine/native/core/src/stats.rs @@ -14,7 +14,7 @@ use crate::log::{all_tips, stash_tip}; use crate::repository::Repo; use crate::types::{GitActivityCell, GitAuthorStat}; -/// `all_tips` plus the stash tip (if any), deduplicated - the same merge `search_history` +/// `all_tips` plus the stash tip (if any), deduplicated - the same merge `search_commits` /// performs, duplicated here rather than factored out so this module cannot change that /// already-tested walk's behaviour. fn all_tips_with_stash(repo: &Repo) -> Result> { diff --git a/engine/native/core/src/types.rs b/engine/native/core/src/types.rs index 93e8654c..e56f49bc 100644 --- a/engine/native/core/src/types.rs +++ b/engine/native/core/src/types.rs @@ -33,6 +33,10 @@ pub struct GitCommit { pub struct GitCommitTag { pub name: String, pub annotated: bool, + /// True when the tag object carries a signature. Only an annotated tag has a tag object, so a + /// lightweight tag is never signed — not even over a signed commit, which `for-each-ref`'s + /// `%(contents:signature)` also reports as unsigned. + pub signed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,6 +84,9 @@ pub struct GitTagRef { /// True for the peeled record of an annotated tag, which points at the commit rather than at /// the tag object. pub annotated: bool, + /// True when the tag object carries a signature. Both records of an annotated tag carry the + /// same value, because the signature belongs to the tag, not to either hash. + pub signed: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -112,6 +119,44 @@ pub struct RefReadOptions { pub hide_remotes: Vec, } +/* ---------- Commit search ---------- */ + +/// What the Find dialogue asks for. Mirrors the inputs the `git` CLI backend builds its four +/// `git log` runs from, so both backends answer the same question. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchOptions { + pub query: String, + /// Already normalised by the caller (the CLI clamps to 1..=200, defaulting to 50). + pub max_results: u32, + /// An explicit ref selection from the dropdowns, or `None` to search what the view shows. + pub branches: Option>, + /// The author filter, which narrows the numbering walk as well as the matches. + pub authors: Option>, + #[serde(default = "default_true")] + pub show_tags: bool, + pub show_remote_branches: bool, + pub hide_remotes: Vec, + /// Report the author date rather than the committer date, for `dateType: "Author Date"`. + pub use_author_date: bool, +} + +/// One search hit, in the shape the Find dialogue lists. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitSearchResult { + pub hash: String, + pub parents: Vec, + pub author: String, + pub email: String, + pub date: i64, + /// The subject line only, as `%s` prints it. + pub message: String, + /// The commit's 1-based position in the graph's own walk — how far the view would have to load + /// to reach it, and the key the results are ordered by. + pub load_count: u32, +} + /* ---------- Repository info ---------- */ #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -314,17 +359,6 @@ pub struct GitActivityCell { pub count: usize, } -/// One hit of a commit-message search, as the Find dialogue lists them. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GitHistoryMatch { - pub hash: String, - pub author: String, - pub date: i64, - /// The commit subject. - pub message: String, -} - /* ---------- Tag details ---------- */ /// An annotated tag in full, or the fields a lightweight tag can fill in. diff --git a/engine/native/core/tests/api.rs b/engine/native/core/tests/api.rs index 43da1a20..806f04bb 100644 --- a/engine/native/core/tests/api.rs +++ b/engine/native/core/tests/api.rs @@ -41,7 +41,8 @@ fn the_engine_facade_covers_the_host_workflow() { let info = engine.info(&GraphOptions::default()).unwrap(); assert_eq!(info.branches, ["main"]); assert_eq!(info.tags, ["v1"]); - assert_eq!(engine.current_branch().unwrap().as_deref(), Some("main")); + // The checked-out branch, which the repo info already carries. + assert_eq!(info.head.as_deref(), Some("main")); // Commit details and the files it touched. let details = engine.commit(&second).unwrap(); @@ -81,12 +82,9 @@ fn the_engine_facade_covers_the_host_workflow() { let new = status.iter().find(|c| c.path == "new.txt").unwrap(); assert!(new.untracked); - // Diffs between revisions, and history search. + // Diffs between revisions. let changes = engine.diff(&first, &second).unwrap(); assert_eq!(changes[0].new_file_path, "a.txt"); - let hits = engine.search_history("second").unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(engine.commit_subject(&first).unwrap(), "first commit"); assert_eq!(engine.authors().unwrap()[0].name, "Test User"); engine.close(); diff --git a/engine/native/core/tests/common/mod.rs b/engine/native/core/tests/common/mod.rs index da46bde4..ba79649e 100644 --- a/engine/native/core/tests/common/mod.rs +++ b/engine/native/core/tests/common/mod.rs @@ -6,8 +6,9 @@ #![allow(dead_code)] +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use tempfile::TempDir; @@ -79,6 +80,37 @@ impl TestRepo { String::from_utf8_lossy(&output.stdout).into_owned() } + /// Run a git command with something on stdin, for the plumbing that reads objects there. + pub fn git_stdin(&self, args: &[&str], input: &str) -> String { + let mut child = Command::new("git") + .args(args) + .current_dir(self.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("HOME", self.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("could not run `git {}`: {e}", args.join(" "))); + child + .stdin + .take() + .expect("stdin was not piped") + .write_all(input.as_bytes()) + .expect("could not write to git"); + let output = child + .wait_with_output() + .unwrap_or_else(|e| panic!("could not wait for `git {}`: {e}", args.join(" "))); + assert!( + output.status.success(), + "`git {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + } + /// Run a git command and return its output whether or not it succeeded. pub fn git_allow_failure(&self, args: &[&str]) -> String { let output = Command::new("git") @@ -129,6 +161,30 @@ impl TestRepo { self.head() } + /// Commit under a specific author, for tests that search or filter by one. + pub fn commit_as(&mut self, name: &str, email: &str, message: &str) -> String { + self.git(&["add", "-A"]); + self.clock += 60; + let date = format!("{} +0000", self.clock); + let output = Command::new("git") + .args(["commit", "--quiet", "--allow-empty", "-m", message]) + .current_dir(self.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", self.path()) + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_DATE", &date) + .env("GIT_AUTHOR_NAME", name) + .env("GIT_AUTHOR_EMAIL", email) + .output() + .expect("could not run `git commit`"); + assert!( + output.status.success(), + "`git commit` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + self.head() + } + /// Write a file and commit it in one step. pub fn commit_file(&mut self, path: &str, contents: &str, message: &str) -> String { self.write(path, contents); diff --git a/engine/native/core/tests/log_and_refs.rs b/engine/native/core/tests/log_and_refs.rs index 9f16a461..c7cc23ef 100644 --- a/engine/native/core/tests/log_and_refs.rs +++ b/engine/native/core/tests/log_and_refs.rs @@ -526,3 +526,110 @@ fn an_empty_repository_reports_no_head_rather_than_failing() { // `git status` report it — so the view names the branch the first commit will land on. assert_eq!(snapshot.branches, vec!["main"]); } + +/// Write a tag object carrying a PGP signature block, without needing a keyring. +/// +/// The engine only reports signature *presence*, so a fabricated block exercises exactly the +/// path a real signature would, and the fixture stays deterministic on a machine with no GPG. +fn write_signed_tag(repo: &TestRepo, tag_name: &str, target: &str) { + let object = format!( + "object {target}\n\ + type commit\n\ + tag {tag_name}\n\ + tagger Test 0 +0000\n\ + \n\ + a signed tag\n\ + -----BEGIN PGP SIGNATURE-----\n\ + \n\ + aBcD\n\ + -----END PGP SIGNATURE-----\n" + ); + let hash = repo.git_stdin(&["hash-object", "-t", "tag", "-w", "--stdin"], &object); + repo.update_ref(&format!("refs/tags/{tag_name}"), hash.trim()); +} + +#[test] +fn reports_tag_signature_presence_the_way_for_each_ref_does() { + require_git!(); + let mut repo = TestRepo::new(); + let first = repo.commit_file("a.txt", "1", "first"); + // Three of the four shapes `for-each-ref %(contents:signature)` distinguishes. The fourth — + // a lightweight tag over a *signed commit* — is false by construction here: the check reads + // the tag object, and a lightweight tag has none, so a commit's own signature never leaks + // into a tag badge. Verified against git directly before this was written. + repo.git(&["tag", "lightweight"]); + repo.git(&["tag", "-a", "annotated", "-m", "unsigned annotated"]); + write_signed_tag(&repo, "signed", &first); + + let engine = open(&repo); + let snapshot = read_refs(&engine, &RefReadOptions::default()).unwrap(); + + let signed_for = |name: &str| -> bool { + snapshot + .ref_data + .tags + .iter() + .find(|tag| tag.name == name) + .unwrap_or_else(|| panic!("the tag {name} was not read")) + .signed + }; + assert!( + !signed_for("lightweight"), + "a lightweight tag is never signed" + ); + assert!( + !signed_for("annotated"), + "an unsigned annotated tag is not signed" + ); + assert!( + signed_for("signed"), + "a signed tag object was reported unsigned" + ); + + // Both records of an annotated tag agree, because the signature belongs to the tag rather + // than to either hash, and the graph attaches the peeled record. + let records: Vec = snapshot + .ref_data + .tags + .iter() + .filter(|tag| tag.name == "signed") + .map(|tag| tag.signed) + .collect(); + assert_eq!( + records, + vec![true, true], + "the peeled record lost the signature" + ); +} + +#[test] +fn resolves_a_symbolic_remote_head_rather_than_dropping_it() { + require_git!(); + let mut repo = TestRepo::new(); + let head = repo.commit_file("a.txt", "1", "first"); + repo.add_fake_remote("origin", "main", &head); + // What `git clone` writes: a *symbolic* ref, unlike the direct one the neighbouring test + // uses. `for-each-ref %(objectname)` reports the object it resolves to, so the view shows + // the label — dropping it here is what used to cost a `for-each-ref` spawn to put back. + repo.git(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]); + + let engine = open(&repo); + let options = RefReadOptions { + show_remote_branches: true, + show_remote_heads: true, + ..Default::default() + }; + let snapshot = read_refs(&engine, &options).unwrap(); + + let entry = snapshot + .ref_data + .remotes + .iter() + .find(|r| r.name == "origin/HEAD") + .expect("the symbolic remote HEAD was dropped"); + assert_eq!(entry.hash, head, "the symref resolved to the wrong object"); +} diff --git a/engine/native/core/tests/queries.rs b/engine/native/core/tests/queries.rs index b1f4a035..bf941815 100644 --- a/engine/native/core/tests/queries.rs +++ b/engine/native/core/tests/queries.rs @@ -46,27 +46,6 @@ fn commit_bodies_fail_on_an_unknown_hash() { assert_eq!(error.kind, ErrorKind::NotFound); } -#[test] -fn reads_a_folded_subject_as_git_folds_it() { - require_git!(); - let mut repo = TestRepo::new(); - let hash = repo.commit_file( - "a.txt", - "1\n", - "a subject that spans\nseveral lines\n\nthe body is separate", - ); - - let expected = repo - .git(&["log", "--format=%s", "-n", "1", &hash]) - .split_whitespace() - .collect::>() - .join(" "); - - let engine = open(&repo); - assert_eq!(details::commit_subject(&engine, &hash).unwrap(), expected); - assert_eq!(expected, "a subject that spans several lines"); -} - #[test] fn reads_commit_summaries_with_the_author_date() { require_git!(); @@ -87,61 +66,6 @@ fn reads_commit_summaries_with_the_author_date() { assert_eq!(summary.date.to_string(), git_date.trim()); } -#[test] -fn searches_history_like_git_log_grep() { - require_git!(); - let mut repo = TestRepo::new(); - let alpha = repo.commit_file("a.txt", "1\n", "add the alpha feature"); - repo.commit_file("b.txt", "2\n", "an unrelated change"); - let gamma = repo.commit_file("c.txt", "3\n", "polish the gamma FEATURE"); - repo.git(&["checkout", "--quiet", "-b", "side"]); - let delta = repo.commit_file("d.txt", "4\n", "the delta feature lands"); - - // A stash is reachable from `--all` through refs/stash; its message must be searchable too. - repo.write("a.txt", "stashed\n"); - repo.git(&["stash", "push", "--quiet", "-m", "the stashed feature work"]); - let stash = repo.rev_parse("refs/stash"); - - let expected = repo.log_hashes(&["--all", "-i", "--grep=feature"]); - - let engine = open(&repo); - let matches = log::search_history(&engine, "feature").unwrap(); - let hashes: Vec<&String> = matches.iter().map(|m| &m.hash).collect(); - - assert_eq!(hashes, expected.iter().collect::>()); - assert!(hashes.contains(&&gamma)); - assert!(hashes.contains(&&stash), "the stash ref is part of --all"); - assert!( - hashes.contains(&&alpha), - "the alpha commit's message matches too" - ); - - // Subjects and authors come back with the hash. - for m in &matches { - if m.hash == delta { - assert_eq!(m.message, "the delta feature lands"); - assert_eq!(m.author, "Test User"); - } - } - - // A pattern that matches nothing matches nothing. - assert!(log::search_history(&engine, "no-such-thing-at-all") - .unwrap() - .is_empty()); -} - -#[test] -fn search_rejects_a_pattern_that_is_not_a_regex() { - require_git!(); - let mut repo = TestRepo::new(); - repo.commit_file("a.txt", "1\n", "first"); - - let engine = open(&repo); - let error = log::search_history(&engine, "(unclosed").unwrap_err(); - - assert_eq!(error.kind, ErrorKind::InvalidArgument); -} - #[test] fn reads_an_annotated_tag_in_full() { require_git!(); @@ -215,43 +139,6 @@ fn reads_a_remote_url_and_reports_an_absent_one() { assert_eq!(config::remote_url(&engine, "no-such-remote").unwrap(), None); } -#[test] -fn reads_the_upstream_of_the_checked_out_branch() { - require_git!(); - let mut repo = TestRepo::new(); - repo.commit_file("a.txt", "1\n", "first"); - repo.add_fake_remote("origin", "main", &repo.head()); - repo.git(&["config", "branch.main.remote", "origin"]); - repo.git(&["config", "branch.main.merge", "refs/heads/main"]); - - let expected = repo - .git(&[ - "rev-parse", - "--abbrev-ref", - "--symbolic-full-name", - "@{upstream}", - ]) - .trim() - .to_string(); - - let engine = open(&repo); - assert_eq!( - config::current_branch_upstream(&engine).unwrap().as_deref(), - Some(expected.as_str()) - ); - assert_eq!(expected, "origin/main"); -} - -#[test] -fn a_branch_without_an_upstream_has_none() { - require_git!(); - let mut repo = TestRepo::new(); - repo.commit_file("a.txt", "1\n", "first"); - - let engine = open(&repo); - assert_eq!(config::current_branch_upstream(&engine).unwrap(), None); -} - #[test] fn lists_initialised_submodules_only() { require_git!(); @@ -422,7 +309,7 @@ fn rejects_tag_names_git_would_reject() { } #[test] -fn a_bare_repository_has_no_upstream_and_no_submodules() { +fn a_bare_repository_has_no_submodules_and_still_reads_objects() { require_git!(); let mut repo = TestRepo::new(); let hash = repo.commit_file("a.txt", "1\n", "the subject\n\nand a body"); @@ -442,53 +329,15 @@ fn a_bare_repository_has_no_upstream_and_no_submodules() { ); let engine = Repo::open(bare.path()).expect("could not open the bare repository"); - assert_eq!(config::current_branch_upstream(&engine).unwrap(), None); assert!(config::submodules(&engine).unwrap().is_empty()); // The object database is all a bare repository has, and it is enough for every object read. - assert_eq!( - details::commit_subject(&engine, &hash).unwrap(), - "the subject" - ); assert_eq!( details::commit_bodies(&engine, std::slice::from_ref(&hash)).unwrap()[&hash], "the subject\n\nand a body" ); } -#[test] -fn a_detached_head_has_no_upstream() { - require_git!(); - let mut repo = TestRepo::new(); - let first = repo.commit_file("a.txt", "1\n", "first"); - repo.commit_file("b.txt", "2\n", "second"); - repo.git(&["checkout", "--quiet", "--detach", &first]); - - let engine = open(&repo); - assert_eq!(config::current_branch_upstream(&engine).unwrap(), None); -} - -#[test] -fn searching_a_repository_without_commits_matches_nothing() { - require_git!(); - let repo = TestRepo::new(); - - let engine = open(&repo); - assert!(log::search_history(&engine, "anything").unwrap().is_empty()); - assert_eq!( - log::count_commits_before( - &engine, - None, - "0123456789012345678901234567890123456789", - true, - false - ) - .unwrap_err() - .kind, - ErrorKind::NotFound - ); -} - /// The assertion helper the loop above uses, so each rejected name is reported individually. trait UnwrapErrOrElse { fn unwrap_err_or_else(self, message: impl FnOnce() -> String) -> git_graph_core::Error; @@ -761,47 +610,3 @@ fn aggregates_authors_like_shortlog() { // The same walk git's shortlog makes: three Test User commits against one Second Author. assert!(expected.contains("Second Author"), "shortlog: {expected}"); } - -#[test] -fn reads_the_checked_out_branch_name() { - require_git!(); - let mut repo = TestRepo::new(); - repo.commit_file("a.txt", "1\n", "first"); - - let engine = open(&repo); - assert_eq!( - config::current_branch_name(&engine).unwrap().as_deref(), - Some("main") - ); - - repo.git(&["checkout", "--quiet", "--detach", "HEAD"]); - assert_eq!(config::current_branch_name(&engine).unwrap(), None); -} - -#[test] -fn an_unborn_head_still_names_its_branch() { - require_git!(); - let repo = TestRepo::new(); - - let engine = open(&repo); - // `git symbolic-ref --short HEAD` prints the branch even before the first commit exists. - assert_eq!( - config::current_branch_name(&engine).unwrap().as_deref(), - Some("main") - ); -} - -#[test] -fn lists_remote_names_alphabetically() { - require_git!(); - let mut repo = TestRepo::new(); - repo.commit_file("a.txt", "1\n", "first"); - repo.git(&["remote", "add", "zeta", "https://example.invalid/z.git"]); - repo.git(&["remote", "add", "alpha", "https://example.invalid/a.git"]); - - let engine = open(&repo); - assert_eq!( - config::remote_names(&engine).unwrap(), - vec!["alpha", "zeta"] - ); -} diff --git a/engine/native/core/tests/search.rs b/engine/native/core/tests/search.rs new file mode 100644 index 00000000..9eea85a1 --- /dev/null +++ b/engine/native/core/tests/search.rs @@ -0,0 +1,227 @@ +//! The Find dialogue's search, checked against the semantics the `git` CLI backend has. +//! +//! The CLI searches with `--fixed-strings` (literal) plus a separate `--author` pass and a hash +//! lookup. Every test here pins one of those behaviours, because the engine's *other* search +//! (`log::search_history`) is a regex over messages only, and wiring that one would have silently +//! changed what users see. + +#[macro_use] +mod common; + +use git_graph_core::repository::Repo; +use git_graph_core::search::search_commits; +use git_graph_core::types::SearchOptions; + +use common::TestRepo; + +fn open(repo: &TestRepo) -> Repo { + Repo::discover(repo.path()).expect("could not open the fixture repository") +} + +fn options(query: &str) -> SearchOptions { + SearchOptions { + query: query.to_string(), + max_results: 50, + branches: None, + authors: None, + show_tags: true, + show_remote_branches: true, + hide_remotes: Vec::new(), + use_author_date: false, + } +} + +fn subjects(repo: &TestRepo, query: &str) -> Vec { + let engine = open(repo); + search_commits(&engine, &options(query)) + .expect("the search failed") + .into_iter() + .map(|result| result.message) + .collect() +} + +#[test] +fn matches_message_text_literally_rather_than_as_a_regular_expression() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_file("a.txt", "1", "release a.c happened"); + repo.commit_file("b.txt", "2", "release abc happened"); + + // `--fixed-strings`: the dot is a dot. A regex search would match both. + assert_eq!(subjects(&repo, "a.c"), vec!["release a.c happened"]); +} + +#[test] +fn a_query_that_is_not_valid_regex_searches_instead_of_failing() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_file("a.txt", "1", "fix parsing of (unbalanced"); + repo.commit_file("b.txt", "2", "unrelated"); + + // `(` fails to compile as a regex. The CLI finds the commit; so must this. + assert_eq!(subjects(&repo, "("), vec!["fix parsing of (unbalanced"]); +} + +#[test] +fn matches_regardless_of_case() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_file("a.txt", "1", "Fix The Thing"); + + assert_eq!(subjects(&repo, "fix the thing"), vec!["Fix The Thing"]); +} + +#[test] +fn searches_the_whole_message_but_reports_only_the_subject() { + require_git!(); + let repo = TestRepo::new(); + repo.write("a.txt", "1"); + repo.git(&["add", "-A"]); + repo.git(&[ + "commit", + "--quiet", + "-m", + "the subject", + "-m", + "a body mentioning windmills", + ]); + + // `--grep` reads the whole message; `%s` prints the subject. + let found = subjects(&repo, "windmills"); + assert_eq!(found, vec!["the subject"]); +} + +#[test] +fn matches_the_author_as_well_as_the_message() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_file("a.txt", "1", "nothing relevant"); + repo.commit_as("Ada Lovelace", "ada@example.invalid", "also nothing"); + + assert_eq!(subjects(&repo, "lovelace"), vec!["also nothing"]); + // The email counts too, because `--author` matches `Name `. + assert_eq!(subjects(&repo, "ada@example"), vec!["also nothing"]); +} + +#[test] +fn resolves_an_abbreviated_hash() { + require_git!(); + let mut repo = TestRepo::new(); + let first = repo.commit_file("a.txt", "1", "the one being looked up"); + repo.commit_file("b.txt", "2", "a later commit"); + + let engine = open(&repo); + let results = search_commits(&engine, &options(&first[..8])).expect("the search failed"); + assert_eq!(results.len(), 1, "the hash prefix did not resolve"); + assert_eq!(results[0].hash, first); +} + +#[test] +fn a_hash_that_resolves_but_is_not_reachable_is_not_a_result() { + require_git!(); + let mut repo = TestRepo::new(); + let reachable = repo.commit_file("a.txt", "1", "on the branch"); + // A commit left on no branch at all: `git log ` still finds it, but the CLI drops it + // because the numbering walk never reaches it. + repo.git(&["checkout", "--quiet", "-b", "scratch"]); + let orphan = repo.commit_file("b.txt", "2", "about to be unreferenced"); + repo.git(&["checkout", "--quiet", "-"]); + repo.git(&["branch", "-D", "scratch"]); + + let engine = open(&repo); + let results = search_commits(&engine, &options(&orphan[..8])).expect("the search failed"); + assert!( + results.is_empty(), + "an unreachable commit was returned as a result" + ); + + let results = search_commits(&engine, &options(&reachable[..8])).expect("the search failed"); + assert_eq!(results.len(), 1); +} + +#[test] +fn orders_results_by_how_far_into_the_graph_they_are() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_file("a.txt", "1", "match oldest"); + repo.commit_file("b.txt", "2", "unrelated"); + repo.commit_file("c.txt", "3", "match newest"); + + let engine = open(&repo); + let results = search_commits(&engine, &options("match")).expect("the search failed"); + let ordered: Vec<&str> = results.iter().map(|r| r.message.as_str()).collect(); + // Newest first, because that is the graph's own order, and `loadCount` counts down it. + assert_eq!(ordered, vec!["match newest", "match oldest"]); + assert!( + results[0].load_count < results[1].load_count, + "loadCount did not increase with depth: {:?}", + results.iter().map(|r| r.load_count).collect::>() + ); + // Position is counted over every commit walked, not over the matches. + assert_eq!(results[0].load_count, 1); + assert_eq!(results[1].load_count, 3); +} + +#[test] +fn the_author_filter_hides_commits_from_the_results_and_the_numbering() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_as("Ada", "ada@example.invalid", "match from ada"); + repo.commit_as("Bob", "bob@example.invalid", "match from bob"); + + let engine = open(&repo); + let mut opts = options("match"); + opts.authors = Some(vec!["Ada".to_string()]); + let results = search_commits(&engine, &opts).expect("the search failed"); + + assert_eq!( + results + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + vec!["match from ada"] + ); + // Bob's commit is not merely unmatched, it is not counted: Ada's is position 1, not 2. + assert_eq!(results[0].load_count, 1); +} + +#[test] +fn the_page_size_caps_the_results() { + require_git!(); + let mut repo = TestRepo::new(); + for i in 0..10 { + repo.commit_file("a.txt", &i.to_string(), &format!("match {i}")); + } + + let engine = open(&repo); + let mut opts = options("match"); + opts.max_results = 3; + let results = search_commits(&engine, &opts).expect("the search failed"); + assert_eq!(results.len(), 3); + // The three nearest the top of the graph, not an arbitrary three. + assert_eq!( + results.iter().map(|r| r.load_count).collect::>(), + vec![1, 2, 3] + ); +} + +#[test] +fn an_empty_query_finds_nothing_rather_than_everything() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_file("a.txt", "1", "something"); + + assert!(subjects(&repo, "").is_empty()); + assert!(subjects(&repo, " ").is_empty()); +} + +#[test] +fn a_commit_matching_both_message_and_author_appears_once() { + require_git!(); + let mut repo = TestRepo::new(); + repo.commit_as("Ada", "ada@example.invalid", "a commit by ada about ada"); + + let engine = open(&repo); + let results = search_commits(&engine, &options("ada")).expect("the search failed"); + assert_eq!(results.len(), 1, "the commit was returned twice"); +} diff --git a/engine/native/node/src/lib.rs b/engine/native/node/src/lib.rs index a0349d71..8342c6a4 100644 --- a/engine/native/node/src/lib.rs +++ b/engine/native/node/src/lib.rs @@ -12,9 +12,9 @@ use napi::bindgen_prelude::*; use napi_derive::napi; -use git_graph_core::types::{LogOptions, RefReadOptions}; +use git_graph_core::types::{LogOptions, RefReadOptions, SearchOptions}; use git_graph_core::{ - blob, config, details, diff, graph, log, refs, stash, stats, status, Error, ErrorKind, + blob, config, details, diff, graph, log, refs, search, stash, stats, status, Error, ErrorKind, RepoManager, }; @@ -66,6 +66,21 @@ pub async fn load_repo_info(path: String, options_json: String) -> Result Result { + run(move || { + let options: SearchOptions = decode(&options_json)?; + let repo = RepoManager::global().get(&path)?; + let results = search::search_commits(&repo, &options)?; + encode(&results) + }) + .await +} + /// A page of the graph. `options_json` is a serialised `LogOptions`. #[napi] pub async fn load_commits(path: String, options_json: String) -> Result { @@ -215,17 +230,6 @@ pub async fn load_commit_bodies(path: String, hashes: Vec) -> Result Result { - run(move || { - let repo = RepoManager::global().get(&path)?; - details::commit_subject(&repo, &hash) - }) - .await -} - /// The summary of each of the given commits, keyed by hash, as a JSON object. #[napi] pub async fn load_commit_summaries(path: String, hashes: Vec) -> Result { @@ -235,17 +239,6 @@ pub async fn load_commit_summaries(path: String, hashes: Vec) -> Result< }) .await } - -/// The commits whose message matches a pattern, newest first, as a JSON array. -#[napi] -pub async fn search_history(path: String, query: String) -> Result { - run(move || { - let repo = RepoManager::global().get(&path)?; - encode(&log::search_history(&repo, &query)?) - }) - .await -} - /// A tag in full (tagger, message, signature presence), as a JSON object. #[napi] pub async fn load_tag_details(path: String, tag_name: String) -> Result { @@ -289,17 +282,6 @@ pub async fn submodules(path: String) -> Result> { }) .await } - -/// The upstream of the checked-out branch (`origin/main`), or NULL when there is none. -#[napi] -pub async fn current_branch_upstream(path: String) -> Result> { - run(move || { - let repo = RepoManager::global().get(&path)?; - config::current_branch_upstream(&repo) - }) - .await -} - /// How many commits are reachable from the shown refs but not from `hash` — `git rev-list --count`. #[napi] pub async fn count_commits_before( @@ -327,17 +309,6 @@ pub async fn count_commits_before( pub async fn repo_root(path: String) -> Result { run(move || git_graph_core::repository::repo_root(&path)).await } - -/// The names of the repository's remotes. -#[napi] -pub async fn remote_names(path: String) -> Result> { - run(move || { - let repo = RepoManager::global().get(&path)?; - config::remote_names(&repo) - }) - .await -} - /// The distinct commit authors of the current branch's history, as a JSON array. #[napi] pub async fn authors(path: String) -> Result { @@ -386,17 +357,6 @@ pub async fn config_list(path: String, local: bool) -> Result { }) .await } - -/// The checked-out branch's short name, or NULL when HEAD is detached. -#[napi] -pub async fn current_branch_name(path: String) -> Result> { - run(move || { - let repo = RepoManager::global().get(&path)?; - config::current_branch_name(&repo) - }) - .await -} - /// The engine's version, so the extension can report which backend it is running. #[napi] pub fn engine_version() -> String { diff --git a/package.json b/package.json index 84e7d04d..b4ca0d50 100644 --- a/package.json +++ b/package.json @@ -486,12 +486,12 @@ }, "git-graph-libre.initialLoadCommits": { "type": "number", - "default": 300, + "default": 250, "description": "%config.initialLoadCommits%" }, "git-graph-libre.loadMoreCommits": { "type": "number", - "default": 100, + "default": 750, "description": "%config.loadMoreCommits%" }, "git-graph-libre.maxDepthOfRepoSearch": { diff --git a/src/backend/engine/addon.ts b/src/backend/engine/addon.ts index 31cbdc4d..b9e85395 100644 --- a/src/backend/engine/addon.ts +++ b/src/backend/engine/addon.ts @@ -43,6 +43,13 @@ export type EngineAddon = { * this name). Options are the JSON built by `buildLoadCommitsOptions`. */ loadCommits(repoPath: string, optionsJson: string): Promise; + /** + * `search_commits` encoded as JSON: the Find dialogue's hits, each carrying + * its position in the graph walk. Options are the JSON built by + * `buildSearchOptions`. This replaced the engine's own `search_history`, + * a regex over messages whose semantics did not match this project's. + */ + searchCommits(repoPath: string, optionsJson: string): Promise; /** * `load_commit_details` encoded as JSON: the commit's fields with its file * statuses, counts left null for `load_line_counts` to settle. @@ -94,8 +101,6 @@ export type EngineAddon = { * shows, including each remote's fetch and push URL. */ loadConfig(repoPath: string): Promise; - /** The checked-out branch's short name, or null when HEAD is detached. */ - currentBranchName(repoPath: string): Promise; /** * `load_refs` encoded as JSON. Only `head` is read here — the commit HEAD * resolves to, which `load_repo_info` does not carry (its `head` is the @@ -261,7 +266,7 @@ function isEngineAddon(loaded: unknown): loaded is EngineAddon { configList?: unknown; authors?: unknown; loadConfig?: unknown; - currentBranchName?: unknown; + searchCommits?: unknown; loadRefs?: unknown; closeRepository?: unknown; closeAllRepositories?: unknown; @@ -281,7 +286,7 @@ function isEngineAddon(loaded: unknown): loaded is EngineAddon { typeof candidate.configList === "function" && typeof candidate.authors === "function" && typeof candidate.loadConfig === "function" && - typeof candidate.currentBranchName === "function" && + typeof candidate.searchCommits === "function" && typeof candidate.loadRefs === "function" && typeof candidate.closeRepository === "function" && typeof candidate.closeAllRepositories === "function" && diff --git a/src/backend/engine/commits.ts b/src/backend/engine/commits.ts index 6680909c..b42b66cb 100644 --- a/src/backend/engine/commits.ts +++ b/src/backend/engine/commits.ts @@ -30,13 +30,9 @@ * topo loads stay on the CLI (pinned by the parity table). */ -import type { SimpleGit } from "simple-git"; - -import { gitRefSignatureAtom } from "@/backend/queries/loadCommits"; import type { CommitOrdering, DateType, GitCommitNode, GitRef } from "@/backend/types"; -import { type GitCommandRecorder, runGitRaw } from "@/backend/utils/gitRunner"; import { selectedLogRefs, uniqueNonEmpty } from "@/backend/utils/logFilters"; -import { isHiddenRemoteRef, normalizeHiddenRemotes } from "@/backend/utils/remoteRefs"; +import { normalizeHiddenRemotes } from "@/backend/utils/remoteRefs"; /** The route fields the engine decision, options and (16.5b) mapping need. */ export type EngineLoadCommitsInput = { @@ -103,6 +99,8 @@ export function shouldServeLoadCommitsFromEngine(input: EngineLoadCommitsInput): export type EngineCommitTag = { name: string; annotated: boolean; + /** Whether the tag object carries a signature. Lightweight tags are never signed. */ + signed: boolean; }; /** One remote label as the engine encodes it. `remote` names the owning remote, if known. */ @@ -155,7 +153,8 @@ function isEngineCommitTag(value: unknown): value is EngineCommitTag { typeof value === "object" && value !== null && typeof (value as { name?: unknown }).name === "string" && - typeof (value as { annotated?: unknown }).annotated === "boolean" + typeof (value as { annotated?: unknown }).annotated === "boolean" && + typeof (value as { signed?: unknown }).signed === "boolean" ); } @@ -297,11 +296,9 @@ function fullRefName(ref: GitRef): string { * pins to null; in-place stash marks are always stripped because the CLI * never marks — it only injects rows. * - * Two CLI parse artifacts are mirrored deliberately, so the parity table - * stays a strict `toEqual` and any future CLI change fails loudly instead of - * drifting silently: a root commit's parents are `[""]` (`"".split(" ")`), - * and tag `signed` is provisionally false (the engine reports presence - * nowhere — 16.5d decides between a CLI fill and a recorded deviation). + * One CLI parse artifact is mirrored deliberately, so the parity table stays + * a strict `toEqual` and any future CLI change fails loudly instead of + * drifting silently: a root commit's parents are `[""]` (`"".split(" ")`). */ export function mapEngineCommitData(data: EngineCommitData, showStashes: boolean): GitCommitNode[] { const nodes: GitCommitNode[] = []; @@ -325,7 +322,7 @@ export function mapEngineCommitData(data: EngineCommitData, showStashes: boolean const refs: GitRef[] = [ ...commit.heads.map((name): GitRef => ({ hash: commit.hash, name, type: "head" })), ...commit.tags.map( - (tag): GitRef => ({ hash: commit.hash, name: tag.name, type: "tag", signed: false }) + (tag): GitRef => ({ hash: commit.hash, name: tag.name, type: "tag", signed: tag.signed }) ), ...commit.remotes.map( (remote): GitRef => ({ hash: commit.hash, name: remote.name, type: "remote" }) @@ -347,169 +344,14 @@ export function mapEngineCommitData(data: EngineCommitData, showStashes: boolean return nodes; } -/** One remote `HEAD` symref target as the fill reads it. */ -export type RemoteHeadLabel = { - hash: string; - name: string; -}; - -const remoteHeadLineEndings = /\r\n|\r|\n/; - -/** - * Parse a `for-each-ref` symref scan over `refs/remotes`. Only symrefs carry - * a target, so a line with an empty third field is a plain ref the engine - * already recorded. Nothing here reimplements a CLI parse: the shape mirrors - * the loader's own ref records, narrowed to the symbolic labels. - */ -export function parseRemoteHeadLabels(stdout: string): RemoteHeadLabel[] { - const labels: RemoteHeadLabel[] = []; - for (const line of stdout.split(remoteHeadLineEndings)) { - if (line === "") continue; - const [hash = "", refName = "", symref = ""] = line.split("\0"); - if (hash === "" || symref === "" || !refName.startsWith("refs/remotes/")) continue; - labels.push({ hash, name: refName.slice("refs/remotes/".length) }); - } - return labels; -} - -/** - * Attach remote `HEAD` symref labels to the nodes at their targets, in - * `for-each-ref` byte order among the node's remote labels. Hidden remotes - * stay hidden via the CLI's own predicate; labels whose target is off-page - * or already recorded are skipped. - */ -export function insertRemoteHeadLabels( - nodes: GitCommitNode[], - labels: RemoteHeadLabel[], - hiddenRemotes?: string[] -): void { - if (labels.length === 0) return; - const byHash = new Map(); - for (const node of nodes) { - if (!byHash.has(node.hash)) byHash.set(node.hash, node); - } - for (const label of labels) { - insertOneRemoteHeadLabel(byHash, label, hiddenRemotes); - } -} - -function insertOneRemoteHeadLabel( - byHash: Map, - label: RemoteHeadLabel, - hiddenRemotes?: string[] -): void { - if (isHiddenRemoteRef(label.name, hiddenRemotes)) return; - const node = byHash.get(label.hash); - if (node === undefined) return; - if (node.refs.some((ref) => ref.type === "remote" && ref.name === label.name)) return; - const ref: GitRef = { hash: label.hash, name: label.name, type: "remote" }; - node.refs.splice(remoteHeadInsertIndex(node.refs, remoteRefName(label.name)), 0, ref); -} - -function remoteHeadInsertIndex(refs: GitRef[], fullName: string): number { - for (let index = 0; index < refs.length; index++) { - const existing = refs[index]; - if ( - refSortRank(existing) > 1 || - (refSortRank(existing) === 1 && compareRefNames(fullRefName(existing), fullName) > 0) - ) { - return index; - } - } - return refs.length; -} - -export type RemoteHeadFills = { - git: SimpleGit; - repo: string; - recordGitCommand?: GitCommandRecorder; -}; - -/** - * One narrow `for-each-ref` over `refs/remotes` for the symbolic `HEAD` - * labels the engine never records, attached in CLI order. A failed scan - * resolves to no labels rather than a failed graph — the same trade the - * stash rows make: the engine served the page, and losing it over pendant - * labels would be the wrong trade. - */ -export async function attachRemoteHeadLabels( - fills: RemoteHeadFills, - nodes: GitCommitNode[], - hiddenRemotes?: string[] -): Promise { - let stdout: string; - try { - stdout = await runGitRaw(fills.git, { - label: "loadCommits.remoteHeads", - args: ["for-each-ref", "--format=%(objectname)%00%(refname)%00%(symref)", "refs/remotes"], - repo: fills.repo, - record: fills.recordGitCommand - }); - } catch { - return; - } - insertRemoteHeadLabels(nodes, parseRemoteHeadLabels(stdout), hiddenRemotes); -} - -/** - * Tag names carrying a signature block, as the fill reads them. Only - * annotated tags can carry one, so every name here flips a badge the CLI - * would also show. - */ -export function parseSignedTagNames(stdout: string): string[] { - const signed: string[] = []; - for (const line of stdout.split(remoteHeadLineEndings)) { - if (line === "") continue; - const [refName = "", hasSignature = ""] = line.split("\0"); - if (hasSignature !== "1" || !refName.startsWith("refs/tags/")) continue; - signed.push(refName.slice("refs/tags/".length)); - } - return signed; -} - -/** Flip the signed badge on the named tag labels. Unknown names are ignored. */ -export function applySignedTagNames(nodes: GitCommitNode[], signed: string[]): void { - if (signed.length === 0) return; - const names = new Set(signed); - for (const node of nodes) { - for (const ref of node.refs) { - if (ref.type === "tag" && names.has(ref.name)) ref.signed = true; - } - } -} - -/** - * One narrow `for-each-ref` over `refs/tags` for the signature presence the - * engine never reports, reusing the loader's own signature atom so both - * scans classify identically. Same failure trade as the other fills: a - * failed scan keeps the page rather than failing the graph. - */ -export async function attachSignedTagNames( - fills: RemoteHeadFills, - nodes: GitCommitNode[] -): Promise { - let stdout: string; - try { - stdout = await runGitRaw(fills.git, { - label: "loadCommits.signedTags", - args: ["for-each-ref", `--format=%(refname)%00${gitRefSignatureAtom}`, "refs/tags"], - repo: fills.repo, - record: fills.recordGitCommand - }); - } catch { - return; - } - applySignedTagNames(nodes, parseSignedTagNames(stdout)); -} - /** * The `load_commits` options JSON. Every field is threaded from the route * input the CLI consumes, or pinned to the CLI-equivalent constant where the * CLI has no such knob: * - * - `showRemoteHeads: true`: the CLI's `for-each-ref` lists non-symbolic - * `/HEAD` refs, which the engine only includes with the flag on (symbolic - * remote HEADs stay an engine gap — probed in 16.5d); + * - `showRemoteHeads: true`: the CLI's `for-each-ref` lists every `/HEAD` ref, + * which the engine only includes with the flag on — symbolic ones included, + * since the engine resolves those the way `%(objectname)` reports them; * - `showUntrackedFiles: true`: the CLI counts every `status.files` entry, * untracked files included; * - `showTags` covers tags shown *or* selected as filters (the CLI scans diff --git a/src/backend/engine/index.ts b/src/backend/engine/index.ts index fab40586..9f38a04b 100644 --- a/src/backend/engine/index.ts +++ b/src/backend/engine/index.ts @@ -20,6 +20,7 @@ import type { SimpleGit } from "simple-git"; import { commitDetails } from "@/backend/queries/commitDetails"; import { commitComparison } from "@/backend/queries/commitComparison"; import { loadCommits } from "@/backend/queries/loadCommits"; +import { normalizeMaxResults, searchCommits } from "@/backend/queries/searchCommits"; import { emptyRepoInfo, loadRepoInfo } from "@/backend/queries/loadRepoInfo"; import type { DateType, GitCommitDetails, GitFileChange, QueryResult } from "@/backend/types"; import { getRemoteUrl } from "@/backend/utils/git"; @@ -30,8 +31,13 @@ import type { EngineBackend } from "@/types"; import { type EngineAddon, loadEngineAddon } from "./addon"; import { - attachRemoteHeadLabels, - attachSignedTagNames, + buildSearchOptions, + type EngineSearchInput, + mapEngineSearchResults, + parseEngineSearchResults, + shouldServeSearchFromEngine +} from "./search"; +import { buildLoadCommitsOptions, engineLoadCommitsRefs, type EngineLoadCommitsInput, @@ -74,6 +80,8 @@ export type RepoReader = { loadCommitDetails(args: CommitDetailsArgs): Promise>; /** One arbitrary revision pair, counts settled eagerly like the CLI. */ loadCommitComparison(args: CommitComparisonArgs): Promise>; + /** The Find dialogue's hits, ordered by their position in the graph walk. */ + searchCommits(args: SearchArgs): Promise>; }; export type RepoInfoArgs = { @@ -98,6 +106,12 @@ export type CommitDetailsArgs = { recordGitCommand?: GitCommandRecorder; }; +export type SearchArgs = EngineSearchInput & { + repoPath: string; + git: SimpleGit; + recordGitCommand?: GitCommandRecorder; +}; + export type CommitComparisonArgs = { repoPath: string; git: SimpleGit; @@ -194,7 +208,8 @@ export function createRepoReader(deps: RepoReaderDeps): RepoReader { loadCommitDetails: (args: CommitDetailsArgs) => readCommitDetails(deps.preference, provider, args), loadCommitComparison: (args: CommitComparisonArgs) => - readCommitComparison(deps.preference, provider, args) + readCommitComparison(deps.preference, provider, args), + searchCommits: (args: SearchArgs) => readSearch(deps.preference, provider, args) }; } @@ -341,27 +356,6 @@ async function readCommits( ) { return cliRead(); } - // The engine never records symbolic remote HEADs (`origin/HEAD`): one - // narrow scan attaches them in CLI order, but only when the page carries - // remote labels at all — a repository without remotes pays no spawn. - if ( - args.showRemoteBranches && - nodes.some((node) => node.refs.some((ref) => ref.type === "remote")) - ) { - await attachRemoteHeadLabels( - { git: args.git, repo: args.repoPath, recordGitCommand: args.recordGitCommand }, - nodes, - args.hiddenRemotes - ); - } - // The engine never reports tag signature presence either: one narrow - // scan flips the badges, but only when the page carries tag labels. - if (nodes.some((node) => node.refs.some((ref) => ref.type === "tag"))) { - await attachSignedTagNames( - { git: args.git, repo: args.repoPath, recordGitCommand: args.recordGitCommand }, - nodes - ); - } engineServedRead = true; return { commits: nodes, @@ -578,3 +572,54 @@ async function readCommitComparison( return cliRead(); } } + +/** + * The Find dialogue's search. + * + * A decline, a failed engine call, or a payload this version does not + * recognise all land on the CLI with the same arguments, so the dialogue's + * behaviour is identical either way. + */ +async function readSearch( + preference: EngineBackend, + provider: AddonProvider, + args: SearchArgs +): Promise> { + const cliRead = (): Promise> => + searchCommits(args.git, { + query: args.query, + maxResults: args.maxResults, + showRemoteBranches: args.showRemoteBranches, + hiddenRemotes: args.hiddenRemotes, + showTags: args.showTags, + branches: args.branches, + authors: args.authors, + tags: args.tags, + dateType: args.dateType, + repo: args.repoPath, + recordGitCommand: args.recordGitCommand + }); + // The total no-op path: the addon is not even loaded. + if (preference === "git-cli") return cliRead(); + if (!shouldServeSearchFromEngine(args)) return cliRead(); + const addon = provider(); + if (addon === null) return cliRead(); + // The CLI clamps before building `--max-count`; the engine is given the same + // clamped number so both page identically. + const maxResults = normalizeMaxResults(args.maxResults); + // An empty query is not a search on either backend. + if (args.query.trim() === "") return { results: [], error: null }; + try { + const parsed = parseEngineSearchResults( + await addon.searchCommits(args.repoPath, buildSearchOptions(args, maxResults)) + ); + if (parsed === null) return cliRead(); + engineServedRead = true; + return { results: mapEngineSearchResults(parsed), error: null }; + } catch (error: unknown) { + if (!isEngineFallbackError(error)) { + return { results: [], error: toGitQueryError(error, "Unable to search commits") }; + } + return cliRead(); + } +} diff --git a/src/backend/engine/search.ts b/src/backend/engine/search.ts new file mode 100644 index 00000000..3e501f89 --- /dev/null +++ b/src/backend/engine/search.ts @@ -0,0 +1,134 @@ +/** + * The Find dialogue's search, through the engine. + * + * The CLI answers this with four `git log` runs at once — a literal + * `--fixed-strings --grep`, an `--author`, a hash lookup, and one unbounded + * walk that numbers every commit — then merges them by that numbering. The + * engine does the same three matches in a single walk. + * + * ### Why this is not the engine's own `search_history` + * + * The engine shipped a search already, and wiring *that* one would have + * changed what users see: it matched a **regular expression** against messages + * only, across every ref, ignoring the author filter, and numbered nothing. + * Slice 16.7 declined it for exactly that reason. `search_commits` was added + * to the engine instead, reproducing this project's semantics, and the regex + * one has since been removed. + * + * ### Declines + * + * `--glob=` patterns (`customBranchGlobPatterns`) are not understood by the + * engine's tip resolution, the same decline `loadCommits` makes. + */ + +import type { DateType, GitCommitSearchResult } from "@/backend/types"; +import { selectedLogRefs, uniqueNonEmpty } from "@/backend/utils/logFilters"; +import { normalizeHiddenRemotes } from "@/backend/utils/remoteRefs"; + +/** The route fields the engine decision, options and mapping need. */ +export type EngineSearchInput = { + query: string; + maxResults: number; + showRemoteBranches: boolean; + hiddenRemotes?: string[]; + showTags?: boolean; + branches?: string[] | null; + authors?: string[] | null; + tags?: string[] | null; + dateType: DateType; +}; + +/** + * The ref selection both backends search: null is "what the view is showing", + * an array is an explicit choice from the dropdowns. Single source of truth — + * the CLI builds its `refArgs` from exactly this, so the decline below sees + * the same selection. + */ +export function engineSearchRefs(input: EngineSearchInput): string[] | null { + return selectedLogRefs({ branches: input.branches, tags: input.tags }); +} + +/** + * Whether the engine may serve this search. Every false is a *decline*, not a + * bug: the caller routes those searches straight to the CLI, unchanged. + */ +export function shouldServeSearchFromEngine(input: EngineSearchInput): boolean { + // `--glob=` is not understood by the engine's tip resolution. + return !engineSearchRefs(input)?.some((ref) => ref.startsWith("--glob=")); +} + +/** + * The `search_commits` options JSON. `maxResults` arrives already clamped by + * the caller, because the CLI clamps it before building its `--max-count` and + * both backends must page identically. + */ +export function buildSearchOptions(input: EngineSearchInput, maxResults: number): string { + return JSON.stringify({ + query: input.query, + maxResults, + branches: engineSearchRefs(input), + authors: uniqueNonEmpty(input.authors), + showTags: input.showTags !== false, + showRemoteBranches: input.showRemoteBranches, + hideRemotes: normalizeHiddenRemotes(input.hiddenRemotes), + useAuthorDate: input.dateType === "Author Date" + }); +} + +/** One hit as the engine encodes it. */ +type EngineSearchResult = { + hash: string; + parents: string[]; + author: string; + email: string; + date: number; + message: string; + loadCount: number; +}; + +function isEngineSearchResult(value: unknown): value is EngineSearchResult { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.hash === "string" && + Array.isArray(candidate.parents) && + candidate.parents.every((parent): parent is string => typeof parent === "string") && + typeof candidate.author === "string" && + typeof candidate.email === "string" && + typeof candidate.date === "number" && + typeof candidate.message === "string" && + typeof candidate.loadCount === "number" + ); +} + +/** + * Decode the payload, or null when it is not the shape this version expects — + * a skewed addon declines into the CLI rather than throwing into the view. + */ +export function parseEngineSearchResults(payload: string): EngineSearchResult[] | null { + let decoded: unknown; + try { + decoded = JSON.parse(payload); + } catch { + return null; + } + if (!Array.isArray(decoded) || !decoded.every(isEngineSearchResult)) return null; + return decoded; +} + +/** + * Into this project's own type. The engine calls the field `parents` and this + * project calls it `parentHashes`; mapping here is what keeps the seam + * independent of the engine's wire shape. + */ +export function mapEngineSearchResults(results: EngineSearchResult[]): GitCommitSearchResult[] { + return results.map((result) => ({ + hash: result.hash, + parentHashes: result.parents, + author: result.author, + email: result.email, + date: result.date, + message: result.message, + loadCount: result.loadCount + })); +} diff --git a/src/backend/queries/searchCommits.ts b/src/backend/queries/searchCommits.ts index b910feeb..048175d6 100644 --- a/src/backend/queries/searchCommits.ts +++ b/src/backend/queries/searchCommits.ts @@ -33,7 +33,12 @@ type GitQueryContext = { record?: GitCommandRecorder; }; -function normalizeMaxResults(maxResults: number): number { +/** + * The page size both backends use. Exported so the engine seam clamps with + * this function rather than a copy of it: a divergence here would page the + * two backends differently for the same request. + */ +export function normalizeMaxResults(maxResults: number): number { if (!Number.isFinite(maxResults) || maxResults < 1) return defaultMaxResults; return Math.min(Math.floor(maxResults), maxResultsLimit); } @@ -76,10 +81,6 @@ function parseLogEntries(stdout: string): GitLogEntry[] { return commits; } -function escapeRegExp(value: string): string { - return value.replace(/[\\^$.*+?()[\]{}|]/g, String.raw`\$&`); -} - async function runSearchLog( git: SimpleGit, label: string, @@ -210,7 +211,13 @@ export async function searchCommits( runSearchLog( git, "searchCommits.author", - ["--regexp-ignore-case", `--author=${escapeRegExp(query)}`], + // `--fixed-strings` rather than a hand-escaped pattern. `--author` + // takes a *basic* regular expression, in which `\(` opens a group + // instead of escaping a parenthesis — so escaping the query inverted + // the meaning and made git reject any search containing an unbalanced + // `(` or `[` outright, failing the whole dialogue. A literal match was + // always the intent; this is how git spells it. + ["--regexp-ignore-case", "--fixed-strings", `--author=${query}`], input, context ), diff --git a/src/config.ts b/src/config.ts index d106dde8..c045eea6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -155,11 +155,11 @@ export const config = { MAX_SHORT_HASH_LENGTH ) ), - initialLoadCommits: (): number => getConfig("initialLoadCommits", 300), + initialLoadCommits: (): number => getConfig("initialLoadCommits", 250), includeReflog: (): boolean => getConfig("repository.includeReflog", false), includeUnreachableCommits: (): boolean => getConfig("repository.includeUnreachableCommits", false), - loadMoreCommits: (): number => getConfig("loadMoreCommits", 75), + loadMoreCommits: (): number => getConfig("loadMoreCommits", 750), maxDepthOfRepoSearch: (): number => getConfig("maxDepthOfRepoSearch", 0), muteCommitsNotAncestorsOfHead: (): boolean => getConfig("repository.muteCommitsNotAncestorsOfHead", false), diff --git a/src/extension/messageHandler.ts b/src/extension/messageHandler.ts index fb612f72..dedbb1b6 100644 --- a/src/extension/messageHandler.ts +++ b/src/extension/messageHandler.ts @@ -49,7 +49,6 @@ import { deleteUserDetails, editUserDetails } from "@/backend/actions/userConfig import { createRepoReader, didEngineServeRead } from "@/backend/engine/index"; import type { GitClient } from "@/backend/gitClient"; import { loadBranches } from "@/backend/queries/loadBranches"; -import { searchCommits } from "@/backend/queries/searchCommits"; import { tagDetails } from "@/backend/queries/tagDetails"; import { uncommittedDetails } from "@/backend/queries/uncommittedDetails"; import type { GitFileChangeType } from "@/backend/types"; @@ -642,10 +641,19 @@ export function registerMessageHandlers( }); bridge.onMessage("searchCommits", async (msg) => { + // The preference is read live on every search, so flipping + // git-graph-libre.backend needs no reload. The reader serves from the + // engine where it can and the CLI elsewhere, with an identical shape. + const reader = createRepoReader({ + preference: config.backend(), + gitPath: config.gitPath() + }); bridge.post({ command: "searchCommits", requestId: msg.requestId, - ...(await searchCommits(gitClient.getInstance(), { + ...(await reader.searchCommits({ + repoPath: msg.repo, + git: gitClient.getInstance(), query: msg.query, maxResults: msg.maxResults, showRemoteBranches: msg.showRemoteBranches, @@ -655,7 +663,6 @@ export function registerMessageHandlers( authors: msg.authors, tags: msg.tags, dateType: config.dateType(), - repo: msg.repo, recordGitCommand })) }); diff --git a/tests/backend/config.test.ts b/tests/backend/config.test.ts index 1b507f43..7c7f3a6e 100644 --- a/tests/backend/config.test.ts +++ b/tests/backend/config.test.ts @@ -262,10 +262,10 @@ describe("configuration", () => { { accessor: "graphRowHeight", expected: 24 }, { accessor: "revealHighlightColor", expected: "oklch(90% 0.25 150 / 0.42)" }, { accessor: "shortHashLength", expected: 8 }, - { accessor: "initialLoadCommits", expected: 300 }, + { accessor: "initialLoadCommits", expected: 250 }, { accessor: "includeReflog", expected: false }, { accessor: "includeUnreachableCommits", expected: false }, - { accessor: "loadMoreCommits", expected: 75 }, + { accessor: "loadMoreCommits", expected: 750 }, { accessor: "maxDepthOfRepoSearch", expected: 0 }, { accessor: "muteCommitsNotAncestorsOfHead", expected: false }, { accessor: "muteMergeCommits", expected: false }, diff --git a/tests/backend/engine/addon.test.ts b/tests/backend/engine/addon.test.ts index 174ebcff..27c92729 100644 --- a/tests/backend/engine/addon.test.ts +++ b/tests/backend/engine/addon.test.ts @@ -91,7 +91,7 @@ describe("engine addon loader", () => { configList: async () => "{}", authors: async () => "[]", loadConfig: async () => "{}", - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => "{}", closeRepository: () => {}, closeAllRepositories: () => {}, diff --git a/tests/backend/engine/backends.bench.ts b/tests/backend/engine/backends.bench.ts index caddcd1c..cffb96e9 100644 --- a/tests/backend/engine/backends.bench.ts +++ b/tests/backend/engine/backends.bench.ts @@ -168,6 +168,22 @@ const CASES: Case[] = [ dateType: "Commit Date" }) }, + { + name: "search (message term)", + run: (reader) => + reader.searchCommits({ + repoPath, + git, + query: "fix", + maxResults: 50, + showRemoteBranches: true, + showTags: true, + branches: null, + authors: null, + tags: null, + dateType: "Commit Date" as const + }) + }, { name: "remote url", run: (reader) => reader.getRemoteUrl(repoPath) } ]; diff --git a/tests/backend/engine/commits.test.ts b/tests/backend/engine/commits.test.ts index 7dab845d..d8c2f866 100644 --- a/tests/backend/engine/commits.test.ts +++ b/tests/backend/engine/commits.test.ts @@ -1,21 +1,16 @@ import { describe, expect, it } from "vitest"; import { - applySignedTagNames, buildLoadCommitsOptions, type EngineCommit, type EngineCommitData, type EngineLoadCommitsInput, engineLoadCommitsRefs, - insertRemoteHeadLabels, mapEngineCommitData, parseEngineCommitData, - parseRemoteHeadLabels, - parseSignedTagNames, shortStashRef, shouldServeLoadCommitsFromEngine } from "@/backend/engine/commits"; -import type { GitCommitNode } from "@/backend/types"; const BASE: EngineLoadCommitsInput = { branchName: "", @@ -165,7 +160,7 @@ const COMMIT: EngineCommit = { date: 1790090408, message: "second", heads: ["main"], - tags: [{ name: "v1.0.0", annotated: false }], + tags: [{ name: "v1.0.0", annotated: false, signed: false }], remotes: [{ name: "origin/main", remote: "origin" }], stash: null }; @@ -232,108 +227,6 @@ describe("shortStashRef", () => { }); }); -describe("parseRemoteHeadLabels", () => { - it("keeps only symref lines under refs/remotes", () => { - const stdout = [ - `${"a".repeat(40)}\0refs/remotes/origin/HEAD\0refs/remotes/origin/main`, - `${"a".repeat(40)}\0refs/remotes/origin/main\0`, - `${"b".repeat(40)}\0refs/heads/main\0refs/heads/main`, - "garbage", - "" - ].join("\n"); - expect(parseRemoteHeadLabels(stdout)).toEqual([{ hash: "a".repeat(40), name: "origin/HEAD" }]); - }); -}); - -describe("parseSignedTagNames", () => { - it("keeps only signature-carrying lines under refs/tags", () => { - const stdout = [ - `refs/tags/faketag\0${"1"}`, - `refs/tags/v1.0.0\0${"0"}`, - `refs/remotes/origin/main\0${"1"}`, - "garbage", - "" - ].join("\n"); - expect(parseSignedTagNames(stdout)).toEqual(["faketag"]); - }); -}); - -describe("applySignedTagNames", () => { - it("flips the badge on named tag labels and ignores the rest", () => { - const nodes: GitCommitNode[] = [ - { - hash: "a".repeat(40), - parentHashes: [], - author: "Ada", - email: "ada@x.com", - date: 1, - message: "tip", - refs: [ - { hash: "a".repeat(40), name: "main", type: "head" }, - { hash: "a".repeat(40), name: "faketag", type: "tag", signed: false }, - { hash: "a".repeat(40), name: "v1.0.0", type: "tag", signed: false } - ] - } - ]; - applySignedTagNames(nodes, ["faketag", "missing"]); - expect(nodes[0]?.refs.map((ref) => ref.signed)).toEqual([undefined, true, false]); - const before = JSON.stringify(nodes); - applySignedTagNames(nodes, []); - expect(JSON.stringify(nodes)).toBe(before); - }); -}); - -describe("insertRemoteHeadLabels", () => { - const node = (): GitCommitNode => ({ - hash: "a".repeat(40), - parentHashes: [], - author: "Ada", - email: "ada@x.com", - date: 1, - message: "tip", - refs: [ - { hash: "a".repeat(40), name: "main", type: "head" }, - { hash: "a".repeat(40), name: "origin/main", type: "remote" }, - { hash: "a".repeat(40), name: "v1.0.0", type: "tag", signed: false } - ] - }); - - it("inserts in for-each-ref order among the remote labels", () => { - const nodes = [node()]; - insertRemoteHeadLabels(nodes, [{ hash: "a".repeat(40), name: "origin/HEAD" }]); - expect(nodes[0]?.refs.map((ref) => `${ref.type}:${ref.name}`)).toEqual([ - "head:main", - "remote:origin/HEAD", - "remote:origin/main", - "tag:v1.0.0" - ]); - }); - - it("inserts past off-page targets and duplicates", () => { - const nodes = [node()]; - insertRemoteHeadLabels(nodes, [ - { hash: "f".repeat(40), name: "origin/HEAD" }, - { hash: "a".repeat(40), name: "origin/main" }, - { hash: "a".repeat(40), name: "origin/HEAD" } - ]); - expect(nodes[0]?.refs.map((ref) => `${ref.type}:${ref.name}`)).toEqual([ - "head:main", - "remote:origin/HEAD", - "remote:origin/main", - "tag:v1.0.0" - ]); - }); - - it("keeps hidden remotes hidden and ignores empty fills", () => { - const nodes = [node()]; - insertRemoteHeadLabels(nodes, [{ hash: "a".repeat(40), name: "origin/HEAD" }], ["origin"]); - expect(nodes[0]?.refs).toHaveLength(3); - const before = JSON.stringify(nodes); - insertRemoteHeadLabels(nodes, []); - expect(JSON.stringify(nodes)).toBe(before); - }); -}); - describe("mapEngineCommitData", () => { it("maps labels onto project refs and leaves the signature key absent", () => { const [node] = mapEngineCommitData(PAGE, true); @@ -362,8 +255,8 @@ describe("mapEngineCommitData", () => { ...COMMIT, heads: ["zebra", "alpha"], tags: [ - { name: "v1.0.0", annotated: false }, - { name: "a-tag", annotated: true } + { name: "v1.0.0", annotated: false, signed: false }, + { name: "a-tag", annotated: true, signed: false } ], remotes: [ { name: "origin/main", remote: "origin" }, diff --git a/tests/backend/engine/parity.test.ts b/tests/backend/engine/parity.test.ts index 1806cb58..b57e5591 100644 --- a/tests/backend/engine/parity.test.ts +++ b/tests/backend/engine/parity.test.ts @@ -18,6 +18,7 @@ import { commitComparison } from "@/backend/queries/commitComparison"; import { commitDetails } from "@/backend/queries/commitDetails"; import { loadCommits } from "@/backend/queries/loadCommits"; import { loadRepoInfo } from "@/backend/queries/loadRepoInfo"; +import { searchCommits as searchCommitsQuery } from "@/backend/queries/searchCommits"; import type { GitCommitNode } from "@/backend/types"; import { getRemoteUrl } from "@/backend/utils/git"; @@ -960,3 +961,160 @@ describe("engine/CLI parity: repoInfo config", () => { await expectRepoInfoParity(dir, "absent global", false); }, 120000); }); + +describe("engine/CLI parity: searchCommits", () => { + // The Find dialogue is the one read where a wrong backend is *silently* + // wrong: a regex engine and a fixed-string CLI both return results, just + // different ones. Every case here is a query shape that would diverge if + // the engine's own `search_history` had been wired instead. + const dirs: string[] = []; + let dir: string; + + beforeAll(() => { + dir = makeRepo(); + dirs.push(dir); + // Message shapes: a literal dot, regex metacharacters, mixed case, and a + // body-only term that `--grep` reaches but `%s` does not show. + fs.writeFileSync(path.join(dir, "a.txt"), "1"); + git(["add", "-A"], dir); + git(["commit", "-m", "release a.c shipped"], dir); + fs.writeFileSync(path.join(dir, "b.txt"), "2"); + git(["add", "-A"], dir); + git(["commit", "-m", "release abc shipped"], dir); + fs.writeFileSync(path.join(dir, "c.txt"), "3"); + git(["add", "-A"], dir); + git(["commit", "-m", "Fix The Thing", "-m", "body mentioning windmills"], dir); + fs.writeFileSync(path.join(dir, "d.txt"), "4"); + git(["add", "-A"], dir); + git( + ["-c", "user.name=Ada Lovelace", "-c", "user.email=ada@example.invalid", + "commit", "-m", "an unrelated subject"], + dir + ); + fs.writeFileSync(path.join(dir, "e.txt"), "5"); + git(["add", "-A"], dir); + git(["commit", "-m", "parsing of (unbalanced"], dir); + }, 180000); + + afterAll(() => { + for (const candidate of dirs) fs.rmSync(candidate, { recursive: true, force: true }); + }); + + function requireAddon(context: { skip: (message?: string) => never }) { + if (loadEngineAddon() === null) { + context.skip("Engine addon not built — run pnpm run engine:build for the engine half."); + } + } + + async function expectSearchParity( + query: string, + label: string, + expectedServed: boolean + ): Promise { + const args = { + repoPath: dir, + git: simpleGit(dir), + query, + maxResults: 50, + showRemoteBranches: true, + showTags: true, + branches: null, + authors: null, + tags: null, + dateType: "Commit Date" as const + }; + resetEngineServedRead(); + const viaAuto = await createRepoReader({ preference: "auto", gitPath: "git" }).searchCommits( + args + ); + const served = didEngineServeRead(); + const viaCli = await createRepoReader({ preference: "git-cli", gitPath: "git" }).searchCommits( + args + ); + const direct = await searchCommitsQuery(simpleGit(dir), { + query, + maxResults: 50, + showRemoteBranches: true, + showTags: true, + branches: null, + authors: null, + tags: null, + dateType: "Commit Date", + repo: dir + }); + expect(served, `${label} served`).toBe(expectedServed); + expect(viaAuto, `${label} auto`).toEqual(direct); + expect(viaCli, `${label} git-cli`).toEqual(direct); + } + + it("matches a literal dot rather than any character", async (context) => { + requireAddon(context); + await expectSearchParity("a.c", "literal dot", true); + }, 120000); + + it("searches a query that is not valid regex", async (context) => { + requireAddon(context); + await expectSearchParity("(", "invalid regex", true); + }, 120000); + + it("matches regardless of case", async (context) => { + requireAddon(context); + await expectSearchParity("fix the thing", "case", true); + }, 120000); + + it("reaches the body but reports the subject", async (context) => { + requireAddon(context); + await expectSearchParity("windmills", "body", true); + }, 120000); + + it("matches the author as well as the message", async (context) => { + requireAddon(context); + await expectSearchParity("lovelace", "author", true); + }, 120000); + + it("finds nothing for a term that is absent", async (context) => { + requireAddon(context); + await expectSearchParity("nothingmatchesthis", "absent", true); + }, 120000); + + it("resolves an abbreviated hash", async (context) => { + requireAddon(context); + const head = cp + .execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }) + .trim(); + await expectSearchParity(head.slice(0, 8), "hash prefix", true); + }, 120000); + + it("declines a glob ref pattern to the CLI", async (context) => { + requireAddon(context); + const args = { + repoPath: dir, + git: simpleGit(dir), + query: "release", + maxResults: 50, + showRemoteBranches: true, + showTags: true, + branches: ["--glob=refs/heads/*"], + authors: null, + tags: null, + dateType: "Commit Date" as const + }; + resetEngineServedRead(); + const viaAuto = await createRepoReader({ preference: "auto", gitPath: "git" }).searchCommits( + args + ); + expect(didEngineServeRead(), "glob served").toBe(false); + const direct = await searchCommitsQuery(simpleGit(dir), { + query: "release", + maxResults: 50, + showRemoteBranches: true, + showTags: true, + branches: ["--glob=refs/heads/*"], + authors: null, + tags: null, + dateType: "Commit Date", + repo: dir + }); + expect(viaAuto, "glob result").toEqual(direct); + }, 120000); +}); diff --git a/tests/backend/engine/reader.test.ts b/tests/backend/engine/reader.test.ts index d88c1b34..d9a21a90 100644 --- a/tests/backend/engine/reader.test.ts +++ b/tests/backend/engine/reader.test.ts @@ -145,7 +145,7 @@ function fakeAddon(implementation: (repoPath: string) => Promise) }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -412,7 +412,7 @@ describe("createRepoReader repoInfo", () => { JSON.stringify({ remotes: [{ name: "origin", url: "https://github.com/some/repo.git", pushUrl: null }] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async (_repo: string, local: boolean) => JSON.stringify(local ? { "user.name": "T", "user.email": "t@t.com" } : {}), @@ -464,7 +464,7 @@ describe("createRepoReader repoInfo", () => { }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -518,7 +518,7 @@ describe("createRepoReader repoInfo", () => { }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -571,7 +571,7 @@ describe("createRepoReader repoInfo", () => { }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -625,7 +625,7 @@ describe("createRepoReader repoInfo", () => { }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -783,7 +783,7 @@ describe("createRepoReader loadCommits", () => { loadCommitFile: unsupported, authors: unsupported, loadConfig: unsupported, - currentBranchName: unsupported, + searchCommits: unsupported, loadRefs: unsupported, configList: unsupported, closeRepository: unsupported, @@ -910,7 +910,7 @@ describe("createRepoReader loadCommits", () => { }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -1040,7 +1040,7 @@ describe("createRepoReader loadCommits", () => { }, authors: async () => "[]", loadConfig: async () => JSON.stringify({ remotes: [] }), - currentBranchName: async () => null, + searchCommits: async () => "[]", loadRefs: async () => JSON.stringify({ head: null }), configList: async () => { throw new Error("Unsupported: config not stubbed"); @@ -1148,7 +1148,7 @@ describe("createRepoReader loadCommitDetails", () => { loadCommitFile: unsupported, authors: unsupported, loadConfig: unsupported, - currentBranchName: unsupported, + searchCommits: unsupported, loadRefs: unsupported, configList: unsupported, closeRepository: unsupported, @@ -1424,7 +1424,7 @@ describe("createRepoReader loadCommitComparison", () => { loadCommitFile: unsupported, authors: unsupported, loadConfig: unsupported, - currentBranchName: unsupported, + searchCommits: unsupported, loadRefs: unsupported, configList: unsupported, closeRepository: unsupported,