Skip to content

Commit 3321932

Browse files
committed
Git browser: real .git files alongside virtual
Drop the `raw/` escape hatch and merge real `.git/*` entries (HEAD, config, hooks/, info/, objects/, packed-refs, refs/, …) directly into the portal root listing. - `list_root` reads the resolved gitdir via `std::fs::read_dir` (follows gitlinks), filters out names colliding with virtual categories so the deprecated real `.git/branches/` and the `.git/worktrees/` internals stay hidden behind the friendly virtual entries, sorts dirs-first alphabetical, then appends the six virtual categories in fixed order. - `Cat::Raw` and `VirtualGitPath::Raw` are gone. `path::classify` now returns `None` for any `.git/*` segment that isn't a virtual category, so the LocalPosixVolume real-FS path takes over for `HEAD`, `config`, `objects/`, etc. — no new code on the read side. - `try_open_blob_stream` no longer needs a raw passthrough — real `.git/*` files stream through the standard local read path. - Updated `git/CLAUDE.md` (file map, decisions block, M-overview), `volume/CLAUDE.md` (delegation hooks), `worktrees.rs` collision note, and CHANGELOG. - New tests cover the mixed-listing shape, the dirs-first sort within real entries, and that real `.git/*` entries drop out of `classify`.
1 parent 3cead87 commit 3321932

10 files changed

Lines changed: 294 additions & 286 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ The format is based on [keep a changelog](https://keepachangelog.com/en/1.1.0/),
5757
`yml`/`yaml`, `tif`/`tiff`, `mpg`/`mpeg`, `mid`/`midi`, `aif`/`aiff`, `qt`/`mov`, `md`/`markdown`/`txt`) no longer
5858
trips the "you're changing the file type" confirmation. Cross-group changes still warn
5959
([55592ba4](https://github.com/vdavid/cmdr/commit/55592ba4)).
60+
- **Git browser: real `.git/*` files alongside virtual entries.** Opening `.git/` now shows the real on-disk contents
61+
(HEAD, config, hooks/, info/, objects/, packed-refs, refs/, ORIG_HEAD, …) sorted dirs-first alphabetical, followed by
62+
the six virtual categories (branches, tags, commits, stash, worktrees, submodules) in fixed order. Real entries
63+
navigate through the standard real-FS path; virtual entries route to the git module's tree-walking code. The previous
64+
`raw/` escape hatch is gone — its contents are now one click away instead of two. The deprecated real `.git/branches/`
65+
directory and the real `.git/worktrees/` in linked-worktree setups stay hidden behind the friendly virtual entries
66+
with the same name.
6067

6168
### Fixed
6269

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22

33
Backend module for the git browser. M1 shipped repo discovery, repo
44
info, status, the watcher, and the friendly-error skeleton. M2 added
5-
the virtual `.git` portal – `branches/`, `tags/`, `raw/` browsable as
5+
the virtual `.git` portal – `branches/` and `tags/` browsable as
66
virtual trees, with cross-volume copy "for free" because git blobs flow
77
through the existing `VolumeReadStream` abstraction. M3 filled in
88
commits, stash, worktrees, and submodules: the first two browse a
99
commit tree just like branches/tags; the latter two surface
1010
`redirectToPath` so the frontend opens the worktree's / submodule's
11-
working dir directly. **M4 (this milestone) adds three things: a live
12-
toggle for the portal so `cd .git` can fall through to raw on-disk
13-
contents, FriendlyError integration end-to-end so every git failure
14-
reaches `ErrorPane` with a warm title + explanation + suggestion, and
15-
three new error variants (`ShallowBoundary`, `MissingObject`,
16-
`GitDirPermissionDenied`).**
11+
working dir directly. M4 added three things: a live toggle for the
12+
portal so `cd .git` can fall through to raw on-disk contents,
13+
FriendlyError integration end-to-end so every git failure reaches
14+
`ErrorPane` with a warm title + explanation + suggestion, and three
15+
new error variants (`ShallowBoundary`, `MissingObject`,
16+
`GitDirPermissionDenied`). **The portal root listing now mixes real
17+
`.git/*` entries (HEAD, config, hooks/, objects/, refs/, …) with the
18+
six virtual categories so the user sees everything in one place; the
19+
old `raw/` escape hatch is gone.**
1720

1821
## File map
1922

@@ -22,7 +25,7 @@ three new error variants (`ShallowBoundary`, `MissingObject`,
2225
| `mod.rs` | Public API + the three volume hooks (`try_route_listing`, `try_route_metadata`, `try_open_blob_stream`) plus `is_virtual` for the mutation guards |
2326
| `repo.rs` | `discover_repo(path)` walking up via `gix::discover` (follows gitlinks). `repo_info(handle, root)` collects branch, detached SHA, unborn flag, upstream, ahead/behind, and `is_dirty`. Process-global `RepoCache` (`Arc<RwLock<HashMap>>`) keyed by canonical worktree root |
2427
| `path.rs` | `VirtualGitPath` enum, `Cat` enum, `classify(path)` parser, `to_path` inverse, `is_virtual(path)` for the volume hook short-circuits. Greedy ref-name match against the repo's known refs so `feature/foo` parses as one ref |
25-
| `virtual_listing.rs` | `list_root` (M2 exposes `branches/`, `tags/`, `raw/`), `list_branches`, `list_tags`, `list_raw` (real-FS passthrough), `get_metadata_for`, `resolve_ref_commit` (annotated tags peel through). Real-FS reads use `std::fs` to avoid recursing through the volume hook |
28+
| `virtual_listing.rs` | `list_root` (mixes real `.git/*` entries + the six virtual categories), `list_branches`, `list_tags`, `get_metadata_for`, `resolve_ref_commit` (annotated tags peel through), `real_gitdir_path` (follows gitlinks). Real-FS reads use `std::fs` to avoid recursing through the volume hook |
2629
| `log.rs` | `list_commits` – gix `rev_walk` over HEAD-reachable commits; cap 5000, batch 200, `#[cfg(test)]` cooperative cancel flag (production relies on `spawn_blocking` task abort). `resolve_commit_id` resolves a SHA prefix even for unreachable commits |
2730
| `stash.rs` | `list_stashes(repo_root)`, `resolve_stash_commit(handle, n)` – shells out to `git stash list -z` and `git rev-parse stash@{n}` (gix has no public stash API). `list_stashes` doesn't take a `RepoHandle` because the shell-out only needs `repo_root` for `git -C` |
2831
| `worktrees.rs` | `list_worktrees` – gix `Repository::worktrees()`. Each entry sets `redirect_to_path` to the worktree's working dir |
@@ -128,7 +131,6 @@ Every virtual entry carries a real `modified_at` and most carry a `display_size`
128131
| `.git/stash/` | newest stash creation date | `3 stash entries` | stash count |
129132
| `.git/worktrees/` | newest linked worktree HEAD | `2 linked worktrees` | worktree count |
130133
| `.git/submodules/` | newest pinned commit | `1 submodule` | submodule count |
131-
| `.git/raw/` | real `.git/` mtime | None (real bytes) | real bytes |
132134
| `branches/<name>/` | branch tip committer date | `+12 / -3` vs upstream (or fallback `main`/`master`) | ahead-count |
133135
| `tags/<name>/` | annotated tag date or commit date | short SHA | 0 |
134136
| `commits/<sha>/` | commit committer date | `5 files` (or `1 file`) | files-changed count |
@@ -147,6 +149,9 @@ The frontend reads `display_size` / `display_size_tooltip` from `FileEntry`; the
147149

148150
## Decisions
149151

152+
**Decision**: Mixed real + virtual portal root listing; `raw/` escape hatch dropped
153+
**Why**: Hiding real `.git/*` contents behind a separate `raw/` category meant two extra clicks (open `.git/`, open `raw/`) for anyone wanting to peek at `HEAD`, `config`, `hooks/`, `objects/`, etc. The virtual entries already cover the friendly view; surfacing the real entries in the same listing gives power users one-click access without the `raw/` indirection. The classifier (`path::classify`) returns `None` for any `.git/*` segment that isn't a virtual category name, so the volume hook falls through to the real-FS path automatically — no new code on the read side, the existing LocalPosixVolume handles it. Real entries whose name collides with a virtual category get filtered out: the deprecated `.git/branches/` directory (git itself stopped writing to it years ago) and `.git/worktrees/` in linked-worktree setups (its internals belong to git, not to the user) hide behind the friendly virtual entries. Power users who really want the raw bytes open the gitdir from the terminal. Sort order: real entries dirs-first alphabetical (matching the listing pipeline default), then the six virtual categories in fixed order (branches, tags, commits, stash, worktrees, submodules).
154+
150155
**Decision (M4 follow-up)**: Per-file Modified dates inside snapshot listings via walk-once batching
151156
**Why**: The snapshot date ("when this commit landed") is the same value for every file inside a `branches/main/`, `commits/<sha>/`, etc. listing — semantically correct as a "frozen point in time", but useless as a "when did I last work on this?" hint. We now run a single rev-walk per `(commit_id, dir_path)` listing: from the snapshot commit backwards by commit time, first-parent only, diffing each commit against its first parent (gix's `Tree::changes()::for_each_to_obtain_tree`). Each `Change.location` is matched against the directory's top-level entries; the first-seen commit's committer time wins. The walk stops early when every entry is dated, after `MAX_COMMITS_PER_WALK` (1000), or when the rev-walk exits. Initial commits short-circuit. Cache is process-global, FIFO-bounded at 50 keys, content-addressable so it never invalidates. Bench: 100 entries × 5000 commits cold p95=21 ms (budget 200 ms), warm p95=2 µs. 50k-commit fixture sits inside the 500 ms budget too. Entries that don't surface within the cap fall back to the snapshot date so the cell never reads as blank.
152157

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

Lines changed: 97 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,17 @@ fn classify_and_round_trip() {
121121
VirtualGitPath::RefTree(Cat::Tags, "v1.0".into(), "README.md".into())
122122
);
123123

124-
// Raw passthrough.
125-
let p = dot_git.join("raw").join("HEAD");
126-
let (virt, _, _) = classify(&p).expect("classify raw");
127-
assert_eq!(virt, VirtualGitPath::Raw("HEAD".into()));
124+
// Real `.git/*` entries don't classify as virtual – the volume hook
125+
// returns `None` and the LocalPosixVolume real-FS path takes over.
126+
assert!(classify(&dot_git.join("HEAD")).is_none(), "HEAD is real, not virtual");
127+
assert!(
128+
classify(&dot_git.join("config")).is_none(),
129+
"config is real, not virtual"
130+
);
131+
assert!(
132+
classify(&dot_git.join("refs").join("heads").join("main")).is_none(),
133+
"refs/ is real, not virtual"
134+
);
128135

129136
cleanup(&dir);
130137
}
@@ -154,16 +161,92 @@ fn list_tags_yields_v1() {
154161
}
155162

156163
#[test]
157-
fn list_root_includes_all_categories() {
158-
// M3 added commits/stash/worktrees/submodules to the root listing.
164+
fn list_root_mixes_real_and_virtual_entries() {
165+
// The portal root surfaces real `.git/*` files (HEAD, config, hooks/,
166+
// objects/, refs/) followed by the six virtual category entries
167+
// (branches, tags, commits, stash, worktrees, submodules) in fixed
168+
// order. Real entries that collide with a virtual category name get
169+
// filtered out so the virtual one wins.
159170
let dir = build_fixture_repo();
160171
let (handle, root) = discover_repo(&dir).unwrap();
161172
let entries = virtual_listing::list_root(&handle, &root);
162173
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
163-
assert_eq!(
164-
names,
165-
vec!["branches", "tags", "commits", "stash", "worktrees", "submodules", "raw"]
166-
);
174+
175+
// Real `.git/*` entries appear (every fresh git init creates these).
176+
for must_have in ["HEAD", "config", "hooks", "info", "objects", "refs"] {
177+
assert!(
178+
names.contains(&must_have),
179+
"real .git/{} must show up in the root listing: got {:?}",
180+
must_have,
181+
names
182+
);
183+
}
184+
185+
// The six virtual categories appear in fixed order, after every real
186+
// entry.
187+
let virtual_order = ["branches", "tags", "commits", "stash", "worktrees", "submodules"];
188+
let positions: Vec<usize> = virtual_order
189+
.iter()
190+
.map(|n| {
191+
names
192+
.iter()
193+
.position(|x| x == n)
194+
.unwrap_or_else(|| panic!("virtual entry {} missing from {:?}", n, names))
195+
})
196+
.collect();
197+
assert_eq!(positions, (names.len() - 6..names.len()).collect::<Vec<_>>());
198+
for w in positions.windows(2) {
199+
assert!(w[0] < w[1], "virtual entries should keep fixed order: {:?}", positions);
200+
}
201+
202+
// Collision filter: the deprecated real `.git/branches/` directory
203+
// must not show up as a real entry. The virtual `branches/` is the
204+
// only entry called `branches` in the listing.
205+
let branches_count = names.iter().filter(|n| **n == "branches").count();
206+
assert_eq!(branches_count, 1, "virtual branches/ takes precedence over real one");
207+
208+
// `raw/` should not appear anywhere – we dropped it in favour of the
209+
// mixed real + virtual listing.
210+
assert!(!names.contains(&"raw"), "raw/ category was removed");
211+
212+
cleanup(&dir);
213+
}
214+
215+
#[test]
216+
fn list_root_real_entries_sort_dirs_first_alpha() {
217+
let dir = build_fixture_repo();
218+
let (handle, root) = discover_repo(&dir).unwrap();
219+
let entries = virtual_listing::list_root(&handle, &root);
220+
221+
// Slice off the trailing six virtual entries; everything before is real.
222+
let real = &entries[..entries.len() - 6];
223+
224+
// Dirs come before files.
225+
let last_dir = real.iter().rposition(|e| e.is_directory);
226+
let first_file = real.iter().position(|e| !e.is_directory);
227+
if let (Some(ld), Some(ff)) = (last_dir, first_file) {
228+
assert!(ld < ff, "dirs must come before files in real entries: {:?}", real);
229+
}
230+
231+
// Within dirs and within files, alphabetical (case-insensitive).
232+
let dir_names: Vec<String> = real
233+
.iter()
234+
.filter(|e| e.is_directory)
235+
.map(|e| e.name.to_lowercase())
236+
.collect();
237+
let mut sorted = dir_names.clone();
238+
sorted.sort();
239+
assert_eq!(dir_names, sorted, "real dirs must sort alphabetically");
240+
241+
let file_names: Vec<String> = real
242+
.iter()
243+
.filter(|e| !e.is_directory)
244+
.map(|e| e.name.to_lowercase())
245+
.collect();
246+
let mut sorted = file_names.clone();
247+
sorted.sort();
248+
assert_eq!(file_names, sorted, "real files must sort alphabetically");
249+
167250
cleanup(&dir);
168251
}
169252

@@ -225,24 +308,17 @@ async fn blob_stream_drains_to_full_blob() {
225308
cleanup(&dir);
226309
}
227310

228-
#[test]
229-
fn raw_passthrough_lists_real_gitdir() {
230-
let dir = build_fixture_repo();
231-
let (_, root) = discover_repo(&dir).unwrap();
232-
let entries = virtual_listing::list_raw(&root, "").expect("list_raw");
233-
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
234-
assert!(names.contains(&"HEAD"));
235-
assert!(names.contains(&"refs"));
236-
cleanup(&dir);
237-
}
238-
239311
#[test]
240312
fn is_virtual_routes_through_volume_hooks() {
241313
let dir = build_fixture_repo();
242314
let (_, root) = discover_repo(&dir).unwrap();
315+
// `is_virtual` is the mutation-guard cheap shape check: any path with
316+
// `.git` in it counts. That's by design – we never want to write to
317+
// `.git/HEAD` from a copy dialog, even though it's a real on-disk file.
243318
assert!(is_virtual(&root.join(".git")));
244319
assert!(is_virtual(&root.join(".git/branches")));
245320
assert!(is_virtual(&root.join(".git/branches/main/README.md")));
321+
assert!(is_virtual(&root.join(".git/HEAD")));
246322
assert!(!is_virtual(&root.join("scripts/run.sh")));
247323
cleanup(&dir);
248324
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,11 @@ fn root_listing_populates_size_with_item_counts() {
112112
"commits/ display says 'commits'"
113113
);
114114

115-
let raw = by_name["raw"];
116-
assert!(raw.modified_at.is_some(), "raw/ Modified comes from .git/ mtime");
115+
// Real `.git/*` entries land in the mixed listing too. HEAD is the
116+
// canary: every fresh git init writes one.
117+
let head = by_name.get("HEAD").expect("real .git/HEAD shows up in root");
118+
assert!(!head.is_directory, "HEAD is a real file");
119+
assert!(head.modified_at.is_some(), "real entries carry stat mtime");
117120

118121
cleanup(&dir);
119122
}

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

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
//!
33
//! M1 ships repo detection, repo info, status, the `.git/*` watcher, and
44
//! friendly-error mapping. M2 adds the virtual portal: `branches/`,
5-
//! `tags/`, `raw/` browsable as virtual trees, with cross-volume copy
6-
//! "for free" because git blobs flow through the existing `VolumeReadStream`
7-
//! abstraction.
5+
//! `tags/`, `commits/`, `stash/`, `worktrees/`, `submodules/` browsable
6+
//! as virtual trees, with cross-volume copy "for free" because git blobs
7+
//! flow through the existing `VolumeReadStream` abstraction. The portal
8+
//! root listing also surfaces real `.git/*` entries (HEAD, config,
9+
//! hooks/, objects/, refs/, …) alongside the virtual categories – the
10+
//! user sees everything in one place and navigates real entries through
11+
//! the standard real-FS path.
812
//!
9-
//! ## Volume hook contract (M2)
13+
//! ## Volume hook contract
1014
//!
1115
//! `LocalPosixVolume` calls `git::try_route_*` after `resolve()`. Order is
1216
//! load-bearing: `resolve` normalizes the absolute path, then we classify
1317
//! against any enclosing `.git/`. If a virtual path matches we return its
14-
//! result; otherwise the volume falls through to real-FS code.
18+
//! result; otherwise (real `.git/*` entries, or paths outside any `.git/`)
19+
//! the classifier returns `None` and the volume falls through to real-FS
20+
//! code.
1521
//!
1622
//! All mutation methods short-circuit virtual paths via `path::is_virtual`
1723
//! and return `VolumeError::NotSupported`. Git mutations happen out-of-band
@@ -112,7 +118,9 @@ pub fn is_virtual_portal_enabled() -> bool {
112118
/// Volume hook for `list_directory`.
113119
///
114120
/// Returns `Some(result)` when the path lives under a virtual `.git/...`
115-
/// portal; `None` when the caller should run real-FS code.
121+
/// portal; `None` when the caller should run real-FS code (real `.git/*`
122+
/// entries like `HEAD`, `config`, `objects/`, etc., and paths outside any
123+
/// `.git/`).
116124
pub fn try_route_listing(path: &Path) -> Option<Result<Vec<FileEntry>, VolumeError>> {
117125
if !is_virtual_portal_enabled() {
118126
return None;
@@ -127,14 +135,12 @@ pub fn try_route_listing(path: &Path) -> Option<Result<Vec<FileEntry>, VolumeErr
127135
Category(path::Cat::Stash) => stash::list_stashes(&root),
128136
Category(path::Cat::Worktrees) => worktrees::list_worktrees(&handle, &root),
129137
Category(path::Cat::Submodules) => submodules::list_submodules(&handle, &root),
130-
Category(path::Cat::Raw) => virtual_listing::list_raw(&root, ""),
131138
Ref(cat, name) if cat.browses_commit_tree() => list_ref_tree(&handle, &root, *cat, name, ""),
132139
RefTree(cat, name, sub) if cat.browses_commit_tree() => list_ref_tree(&handle, &root, *cat, name, sub),
133140
// Worktrees and submodules are leaf entries with `redirectToPath`;
134141
// listing them as if they were directories returns empty (the
135142
// frontend redirects on Enter so this rarely fires in practice).
136143
Ref(_, _) | RefTree(_, _, _) => Ok(Vec::new()),
137-
Raw(sub) => virtual_listing::list_raw(&root, sub),
138144
};
139145
Some(result.map_err(friendly_to_volume_error))
140146
}
@@ -150,12 +156,13 @@ pub fn try_route_metadata(path: &Path) -> Option<Result<FileEntry, VolumeError>>
150156
}
151157

152158
/// Volume hook for `open_read_stream`. Returns `None` for paths that aren't
153-
/// virtual blobs.
159+
/// virtual blobs (real `.git/*` files fall through to the real-FS reader
160+
/// via the volume hook returning `None`).
154161
pub fn try_open_blob_stream(path: &Path) -> Option<Result<Box<dyn VolumeReadStream>, VolumeError>> {
155162
if !is_virtual_portal_enabled() {
156163
return None;
157164
}
158-
let (virt, handle, root) = path::classify(path)?;
165+
let (virt, handle, _root) = path::classify(path)?;
159166
use path::VirtualGitPath::*;
160167
let result = match &virt {
161168
RefTree(cat, name, sub) if cat.browses_commit_tree() => {
@@ -170,11 +177,6 @@ pub fn try_open_blob_stream(path: &Path) -> Option<Result<Box<dyn VolumeReadStre
170177
tree::read_blob(&handle, blob_id)
171178
.map(|bytes| Box::new(read_blob::GitBlobReadStream::new(bytes)) as Box<dyn VolumeReadStream>)
172179
}
173-
Raw(sub) if !sub.is_empty() => {
174-
// Real-FS file under .git/raw/...
175-
let real = virtual_listing::real_gitdir_path(&root, sub);
176-
return Some(open_real_file_stream(&real));
177-
}
178180
_ => return Some(Err(VolumeError::NotSupported)),
179181
};
180182
Some(result.map_err(friendly_to_volume_error))
@@ -213,18 +215,13 @@ pub(crate) fn resolve_commit_for_cat(
213215
.map_err(|_| FriendlyGitError::new(FriendlyGitErrorKind::CorruptRepo, name.to_string()))?;
214216
stash::resolve_stash_commit(handle, n)
215217
}
216-
_ => Err(FriendlyGitError::new(
218+
path::Cat::Worktrees | path::Cat::Submodules => Err(FriendlyGitError::new(
217219
FriendlyGitErrorKind::CorruptRepo,
218220
name.to_string(),
219221
)),
220222
}
221223
}
222224

223-
fn open_real_file_stream(real: &Path) -> Result<Box<dyn VolumeReadStream>, VolumeError> {
224-
let bytes = std::fs::read(real).map_err(VolumeError::from)?;
225-
Ok(Box::new(read_blob::GitBlobReadStream::new(bytes)) as Box<dyn VolumeReadStream>)
226-
}
227-
228225
fn friendly_to_volume_error(err: FriendlyGitError) -> VolumeError {
229226
// Carry the structured payload through the typed variant so the listing
230227
// pipeline's friendly-error mapper rebuilds a fully-shaped `FriendlyError`

0 commit comments

Comments
 (0)