Skip to content

Commit 31aec35

Browse files
committed
Git browser: populate Modified + Size columns
- Every virtual git entry gets a real `modifiedAt` (branch tip date, tag/commit date, snapshot commit date for files inside trees). - New `displaySize` field on `FileEntry` overrides the Size cell with a short string per row: `+12 / -3` for branches (ahead/behind vs. upstream, falling back to `main`/`master`), short SHA for tags/submodules, `5 files` for commits (vs. parent), `on main` for stash + worktrees, `12 branches` etc. for category roots. - `size` keeps the within-category numeric sort key (ahead-count, files-changed, item count). Cross-category Size sorting is meaningless on purpose; tooltips + aria-labels make every cell self-explaining. - Frontend `FullList.svelte` reads `displaySize`, `pickSizeDisplay` in `full-list-utils.ts`. `measure-column-widths.ts` widens the Size column to fit the override string. - New `column_meta` Rust module shares per-row helpers (`pluralize`, `ahead_behind_for_branch`, `commit_meta`, `files_changed_count`, `recursive_tree_size`). - New `m4_tests.rs` covers root counts, branches ahead/behind + sort key, tags short SHA, commits files-changed, stash branch parsing, worktree branch/SHA, submodule pinned SHA, snapshot-interior date + recursive bytes. Frontend test covers `pickSizeDisplay`. - Bench: 100-branch repo with ahead/behind = p95 36 ms; 200-commit files-changed walk = p95 40 ms (~200 µs/commit). Eager-load wins; 5000-commit cap hits ~1 s worst case, accepted because the listing pipeline is already off-thread and the cap is rarely reached in practice.
1 parent bfcbfa4 commit 31aec35

19 files changed

Lines changed: 1441 additions & 41 deletions

File tree

apps/desktop/src-tauri/src/file_system/git/CLAUDE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ three new error variants (`ShallowBoundary`, `MissingObject`,
3232
| `status.rs` | `list_status(repo, dir)` shells out to `git status --porcelain=v2 -z`. Parses the output into a `Vec<EntryStatus>` |
3333
| `watcher.rs` | `GitWatcherRegistry` – per-repo notify-rs debouncer. `subscribe(app, root)` returns the current `RepoInfo` synchronously and emits `git-state-changed` on relevant `.git/*` mutations. 200 ms debounce. M2: also calls `notify_directory_changed(.., FullRefresh)` for any cached `.git/{branches,tags}/` listings on the local volume |
3434
| `friendly.rs` | `FriendlyGitError`, `FriendlyGitErrorKind` – ten variants (M1's six, `BlobTooLarge` from M2, plus M4's `ShallowBoundary`, `MissingObject`, `GitDirPermissionDenied`). Active-voice copy, no "error" / "failed". `to_friendly_error()` builds a `volume::FriendlyError` for `ErrorPane`; `encode_for_volume_error()` + `try_decode_git_friendly()` carry the structured payload through `VolumeError::IoError` so the streaming pipeline rebuilds it on the way out |
35+
| `column_meta.rs` | Per-row column-population helpers shared across `virtual_listing`, `log`, `tree`, etc. — `pluralize`, `ahead_behind_for_branch`, `commit_meta`, `files_changed_count`, `recursive_tree_size`, plus newest-of-set helpers for category-level Modified dates |
3536
| `tests.rs` | M1 tests: discover, repo_info, status, friendly errors |
3637
| `m2_tests.rs` | M2 tests: classify, list_branches/tags/root, list_tree, blob-read parity with `git show`, cross-volume copy round-trip |
3738
| `m3_tests.rs` | M3 tests: list_commits + sha browsing + cancellation + 1000-commit walk (`#[ignore]`), list_stashes, list_worktrees + redirect, list_submodules + redirect, watcher invalidation for `commits/` |
39+
| `m4_tests.rs` | M4 follow-up tests: Modified + Size column population per category — root counts, branches ahead/behind + sort key, tags short SHA, commits files-changed, stash branch parsing, worktree branch/SHA, submodule pinned SHA, snapshot-interior date + recursive bytes |
3840
| `bench.rs` | `#[ignore]` benchmark over a 50k-file synth fixture. Run with `cargo test --release -- --ignored --test-threads=1 bench_50k` |
3941

4042
## Tauri commands
@@ -113,6 +115,35 @@ gix in 0.81 returns whole-blob `Vec<u8>` for `Object::data` – there's no chunk
113115

114116
Branches like `feature/foo` show as a single entry called `feature/foo`, not nested `feature/` then `foo`. The classifier (`path::classify`) greedy-matches ref names against the repo's known refs (longest-first) before treating any remainder as a tree sub-path. The inverse (`to_path`) splits ref names on `/` so OS-native separators are used in the on-disk representation. This is the only place where the URL → path round-trip needs the repo open.
115117

118+
## Modified + Size columns for virtual entries
119+
120+
Every virtual entry carries a real `modified_at` and most carry a `display_size` string that the frontend renders verbatim in the Full mode Size column. Backend-built; frontend is dumb.
121+
122+
| Path | `modified_at` | `display_size` | `size` (sort key) |
123+
|---|---|---|---|
124+
| `.git/branches/` | newest branch tip date | `12 branches` | branch count |
125+
| `.git/tags/` | newest tag/commit date | `5 tags` | tag count |
126+
| `.git/commits/` | HEAD committer date | `123 commits` | commit count (capped at 5000) |
127+
| `.git/stash/` | newest stash creation date | `3 stash entries` | stash count |
128+
| `.git/worktrees/` | newest linked worktree HEAD | `2 linked worktrees` | worktree count |
129+
| `.git/submodules/` | newest pinned commit | `1 submodule` | submodule count |
130+
| `.git/raw/` | real `.git/` mtime | None (real bytes) | real bytes |
131+
| `branches/<name>/` | branch tip committer date | `+12 / -3` vs upstream (or fallback `main`/`master`) | ahead-count |
132+
| `tags/<name>/` | annotated tag date or commit date | short SHA | 0 |
133+
| `commits/<sha>/` | commit committer date | `5 files` (or `1 file`) | files-changed count |
134+
| `stash/<n>/` | stash creation date | `on main` (parsed from stash subject) | 0 |
135+
| `worktrees/<name>` (redirect) | worktree HEAD date | `on feature-x` or short SHA | 0 |
136+
| `submodules/<name>` (redirect) | pinned commit date | short SHA | 0 |
137+
| inside snapshots — files | snapshot commit date | None (blob bytes) | blob bytes |
138+
| inside snapshots — subdirs | snapshot commit date | None (recursive bytes) | recursive blob bytes |
139+
140+
Cross-category Size sort is meaningless (ahead-count vs files-changed vs item count); that's an honest tradeoff — each cell is self-explaining via `display_size_tooltip` (also used as the aria-label).
141+
142+
The frontend reads `display_size` / `display_size_tooltip` from `FileEntry`; the Full mode renderer (`FullList.svelte`) calls `pickSizeDisplay` from `full-list-utils.ts`, and `measure-column-widths.ts` already widens the Size column to fit the override string.
143+
144+
**Decision (M4 follow-up)**: Eager-load ahead/behind for branches; eager-load files-changed for commits
145+
**Why**: Bench (release build, M-series): 100 branches with ahead/behind takes p50=33 ms / p95=36 ms — well under the 300 ms p95 budget the spec sets for the listing pipeline. Files-changed for 200 commits: p50=37 ms / p95=40 ms (200 µs / commit), so the typical Cmdr-sized repo (~3000 commits) lands ~600 ms and the 5000-commit cap lands ~1 s. We accept the worst-case 1 s on the cap because (1) Cmdr's own repo never hits the cap, (2) the listing pipeline runs the hook in `spawn_blocking` so the UI stays responsive, and (3) the alternative — lazy-load via a streamed IPC — would mean another round-trip per row and a placeholder `` in the cell while it resolves. Document worth re-checking if a user reports the 5000-commit cap feeling slow; the M3 bench harness in `bench.rs` already covers 1000 commits and the new `bench_list_commits_files_changed` covers 200.
146+
116147
## Decisions
117148

118149
**Decision (M4)**: Live-toggleable portal via a process-global `AtomicBool`

apps/desktop/src-tauri/src/file_system/git/bench.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,95 @@ fn bench_50k_files_list_status_under_budget() {
139139
);
140140
assert!(p95_us / 1000 <= 100, "p95 over budget: {}ms", p95_us / 1000);
141141
}
142+
143+
// ── Modified + Size column population bench (M4 follow-up) ──────────
144+
145+
/// Builds a small repo with `branches` branches, each `ahead` commits ahead
146+
/// of `main`. Used to bench `list_branches` (Modified + ahead/behind).
147+
fn build_branches_fixture(branches: usize, ahead: usize) -> PathBuf {
148+
let dir = std::env::temp_dir().join(format!("cmdr_bench_branches_{}_{}", branches, std::process::id()));
149+
let _ = std::fs::remove_dir_all(&dir);
150+
std::fs::create_dir_all(&dir).expect("create temp dir");
151+
run(&dir, &["init", "-q", "-b", "main"]);
152+
run(&dir, &["config", "user.name", "Bench"]);
153+
run(&dir, &["config", "user.email", "bench@cmdr.local"]);
154+
std::fs::write(dir.join("README.md"), "main\n").unwrap();
155+
run(&dir, &["add", "."]);
156+
run(&dir, &["commit", "-q", "-m", "main"]);
157+
for b in 0..branches {
158+
let name = format!("feature-{:03}", b);
159+
run(&dir, &["branch", &name]);
160+
run(&dir, &["checkout", "-q", &name]);
161+
for a in 0..ahead {
162+
std::fs::write(dir.join(format!("{}-{}.txt", name, a)), "x\n").unwrap();
163+
run(&dir, &["add", "."]);
164+
run(&dir, &["commit", "-q", "-m", &format!("{} #{}", name, a)]);
165+
}
166+
run(&dir, &["checkout", "-q", "main"]);
167+
}
168+
dir
169+
}
170+
171+
#[test]
172+
#[ignore = "Slow: builds a 100-branch fixture; opt-in via `cargo test -- --ignored`"]
173+
fn bench_list_branches_with_ahead_behind() {
174+
use super::virtual_listing;
175+
let dir = build_branches_fixture(100, 3);
176+
let (handle, root) = discover_repo(&dir).expect("discover");
177+
178+
// Warm caches.
179+
let _ = virtual_listing::list_branches(&handle, &root);
180+
181+
let mut samples_us = Vec::with_capacity(RUNS);
182+
for _ in 0..RUNS {
183+
let start = Instant::now();
184+
let entries = virtual_listing::list_branches(&handle, &root).expect("list_branches");
185+
samples_us.push(start.elapsed().as_micros());
186+
assert_eq!(entries.len(), 101, "main + 100 features");
187+
}
188+
let p95_us = percentile(samples_us.clone(), 95.0);
189+
let p50_us = percentile(samples_us.clone(), 50.0);
190+
eprintln!(
191+
"list_branches (100 branches, ahead/behind): p50={}ms p95={}ms (lazy-load threshold: 500 ms total)",
192+
p50_us / 1000,
193+
p95_us / 1000
194+
);
195+
// Sanity guard: stay under the 500 ms threshold the spec calls out.
196+
assert!(p95_us / 1000 <= 500, "p95 over 500 ms threshold: {}ms", p95_us / 1000);
197+
198+
let _ = std::fs::remove_dir_all(&dir);
199+
}
200+
201+
#[test]
202+
#[ignore = "Slow: builds a 200-commit fixture; opt-in via `cargo test -- --ignored`"]
203+
fn bench_list_commits_files_changed() {
204+
use super::log;
205+
let dir = std::env::temp_dir().join(format!("cmdr_bench_commits_{}", std::process::id()));
206+
let _ = std::fs::remove_dir_all(&dir);
207+
std::fs::create_dir_all(&dir).unwrap();
208+
run(&dir, &["init", "-q", "-b", "main"]);
209+
run(&dir, &["config", "user.name", "Bench"]);
210+
run(&dir, &["config", "user.email", "bench@cmdr.local"]);
211+
for n in 0..200 {
212+
std::fs::write(dir.join(format!("f{:03}.txt", n)), format!("x{}\n", n)).unwrap();
213+
run(&dir, &["add", "."]);
214+
run(&dir, &["commit", "-q", "-m", &format!("c{}", n)]);
215+
}
216+
217+
let (handle, root) = discover_repo(&dir).expect("discover");
218+
let _ = log::list_commits(&handle, &root);
219+
let mut samples_us = Vec::with_capacity(RUNS);
220+
for _ in 0..RUNS {
221+
let start = Instant::now();
222+
let _entries = log::list_commits(&handle, &root).expect("list_commits");
223+
samples_us.push(start.elapsed().as_micros());
224+
}
225+
let p95_us = percentile(samples_us.clone(), 95.0);
226+
let p50_us = percentile(samples_us.clone(), 50.0);
227+
eprintln!(
228+
"list_commits (200 commits, files-changed each): p50={}ms p95={}ms",
229+
p50_us / 1000,
230+
p95_us / 1000
231+
);
232+
let _ = std::fs::remove_dir_all(&dir);
233+
}

0 commit comments

Comments
 (0)