From 9526876b045d5a5033e7fac7c834100539084e74 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 13 Oct 2026 21:10:20 +0200 Subject: [PATCH] feat: show repository changes in normal file pickers --- TODO.md | 5 + docs/architecture.md | 6 +- docs/configuration.md | 2 +- docs/development.md | 3 + docs/editing.md | 2 + src/blame.rs | 1 + src/editor.rs | 27 +- src/files.rs | 397 +++++++++++++- src/gutter.rs | 110 +++- src/repository.rs | 1188 +++++++++++++++++++++++++++++++++++++++++ src/session.rs | 190 ++++++- src/tui.rs | 271 +++++++++- 12 files changed, 2169 insertions(+), 33 deletions(-) diff --git a/TODO.md b/TODO.md index 749b13f..8623df6 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,11 @@ - new: green - deleted: red - changed: yellow +- [ ] mark changed files in normal file picker too (jj/git). +- [ ] git gutter flashes on every keystroke/change; this should be fixed. +- [ ] make all buffers (including pickers) scrolls the window when the cursor reaches a certain threshold. setting could something like "distance from frame edges", and the value could be an integer or none: + - "none" would mean "always keep the cursor in the center of the frame" (i.e. scroll the viewport whenever the cursor moves to a different line) + - an integer value would be the distance from the edge of the viewport that the cursor must hit before the viewport begins to scroll. a value of 0 means the viewport scrolls only right before the cursor would otherwise have left the viewport; a value greater than half the width of the viewport behaves similarly to "none" (at least until the viewport is resized to support that range in a meaningful way). - [ ] github/gitlab/gitea integration (probably requries authentication with github) - github pr picker (floating window) - three tabs: open, closed, all diff --git a/docs/architecture.md b/docs/architecture.md index 86e5cba..bc590e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency. - `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured. - `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated. + Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog. - `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies. - `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. @@ -53,7 +53,7 @@ Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached r Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame. -Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. +Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling. Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates. @@ -88,6 +88,8 @@ The buffer viewport includes a fixed left gutter with right-aligned hybrid line Repository gutter work runs off the render/editor thread. Revision/path-validated marker vectors belong to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode. +The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules. + ## Syntax caches Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download. diff --git a/docs/configuration.md b/docs/configuration.md index aa61562..5ed4dc9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,7 +49,7 @@ Plugin paths resolve relative to the config file, not the shell's working direct `config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut. -`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` disables repository indicators, not line numbers. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration. +`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration. Indicators compare the current editor text (including unsaved edits) against Git `HEAD`, or the parent `@-` in Git-backed jj repositories, rather than against the last save or Git index. Line endings use the editor's LF normalization. They refresh after edits/saves, on focus regain, and once per second for repository changes. Historical snapshots do not receive live indicators. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository, ignored, binary, symlink, and unsupported files have no indicators; neither do jj working changes with multiple parents. Both colocated and non-colocated Git-backed jj repositories are supported. Files above 16 MiB report an error and clear their indicators; request batches are limited to 64 MiB or 1024 files. diff --git a/docs/development.md b/docs/development.md index af54e30..fef84f0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,6 +57,8 @@ Diff checks cover staged category/filter navigation, stale request cancellation, Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. +Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. + Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior. Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document. @@ -71,5 +73,6 @@ Diff styling checks cover independent snapshot language/shebang/injection detect - Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. - Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes. +- Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops. See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling. diff --git a/docs/editing.md b/docs/editing.md index aefd788..2863c41 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -305,6 +305,8 @@ The picker includes ordinary buffers from every tab and unnamed buffers as `[No Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette. +Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status refreshes on opening, edits/saves, focus regain, and once per second. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files. + While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available. The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it. diff --git a/src/blame.rs b/src/blame.rs index 00ac5f7..c95867e 100644 --- a/src/blame.rs +++ b/src/blame.rs @@ -185,6 +185,7 @@ pub(crate) fn run_status( .env("GIT_OPTIONAL_LOCKS", "0") .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_NO_REPLACE_OBJECTS", "1") + .env("GIT_NO_LAZY_FETCH", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); diff --git a/src/editor.rs b/src/editor.rs index c14b36f..c456cc5 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1351,6 +1351,7 @@ pub struct DocumentInfo<'a> { pub path: Option<&'a Path>, pub lines: &'a [String], pub save_revision: u64, + pub dirty: bool, } struct AnnotationSet { @@ -1674,6 +1675,7 @@ impl Buffer { .or(self.path.as_deref()), lines: &self.lines, save_revision: self.save_revision, + dirty: self.dirty(), } } @@ -4693,6 +4695,29 @@ impl Editor { } } + pub(crate) fn normal_file_picker(&self) -> Option<&FilePicker> { + self.file_picker.as_ref().filter(|_| self.diff.is_none()) + } + + pub(crate) fn set_picker_repository_changes( + &mut self, + tab: usize, + root: &Path, + changes: Vec, + ) -> bool { + let picker = if tab == self.tab_id { + self.file_picker.as_mut().filter(|_| self.diff.is_none()) + } else { + self.tabs + .get_mut(&tab) + .and_then(|tab| tab.file_picker.as_mut().filter(|_| tab.diff.is_none())) + }; + let Some(picker) = picker.filter(|picker| picker.root == root) else { + return false; + }; + picker.set_repository_changes(changes) + } + pub fn file_explorer_side(&self) -> DockSide { self.file_explorer_side } @@ -10485,7 +10510,7 @@ fn lsp_navigation_position( Ok(TextPosition { row, column }) } -fn path_identity(path: &Path) -> io::Result { +pub(crate) fn path_identity(path: &Path) -> io::Result { let absolute = if path.is_absolute() { path.to_owned() } else { diff --git a/src/files.rs b/src/files.rs index 2841fd4..88bed35 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,3 +1,5 @@ +use crate::diff::FileStatus; +use crate::repository::WorkingFileChange; use ignore::WalkBuilder; use std::collections::{HashMap, HashSet}; use std::fs; @@ -39,6 +41,8 @@ pub struct FileRow<'a> { pub label: &'a str, /// Original virtual-catalog input index; absent for scanned entries and directories. pub catalog_index: Option, + pub status: Option, + pub old_path: Option<&'a Path>, pub is_dir: bool, pub depth: usize, pub expanded: bool, @@ -51,6 +55,9 @@ struct Node { search_label: String, catalog_index: Option, is_dir: bool, + status: Option, + old_path: Option, + virtual_entry: bool, parent: Option, children: Vec, } @@ -92,6 +99,9 @@ pub struct FilePicker { filtered_selection: [Option; 2], tree_searching: bool, warning: Option, + repository_changes: Vec, + has_repository_changes: bool, + has_virtual_entries: bool, } fn label(path: &Path) -> String { @@ -101,6 +111,28 @@ fn label(path: &Path) -> String { .collect() } +fn safe_repository_path(path: &Path) -> bool { + !path.as_os_str().is_empty() + && path.components().all(|component| { + matches!(component, std::path::Component::Normal(name) if name != ".git" && name != ".jj") + }) +} + +fn missing_repository_path(root: &Path, path: &Path) -> bool { + // A deleted leaf may be absent with all its parents, but never walk through symlinks. + let mut current = root.to_owned(); + for component in path.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => return false, + Err(error) if error.kind() == io::ErrorKind::NotFound => return true, + Err(_) => return false, + } + } + false +} + impl FilePicker { pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result { if !fs::metadata(&root)?.is_dir() { @@ -156,6 +188,9 @@ impl FilePicker { search_label: label.to_lowercase(), label, catalog_index: None, + status: None, + old_path: None, + virtual_entry: false, is_dir: entry.file_type().unwrap().is_dir(), parent: None, children: Vec::new(), @@ -186,6 +221,9 @@ impl FilePicker { search_label: label.to_lowercase(), label, catalog_index, + status: None, + old_path: None, + virtual_entry: false, is_dir, parent: None, children: Vec::new(), @@ -255,6 +293,7 @@ impl FilePicker { focused: true, error: warning.clone(), tree_for_node: vec![0; nodes.len()], + has_virtual_entries: nodes.iter().any(|node| node.virtual_entry), nodes, tree: Vec::new(), tree_roots: Vec::new(), @@ -265,6 +304,8 @@ impl FilePicker { filtered_selection: [None, None], tree_searching: false, warning, + repository_changes: Vec::new(), + has_repository_changes: false, }; for root in roots { let tree = picker.compact(root, None); @@ -315,6 +356,8 @@ impl FilePicker { .map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()), is_dir: node.is_dir, catalog_index: node.catalog_index, + status: node.status, + old_path: node.old_path.as_deref(), depth: row.depth, expanded: row.expanded, } @@ -721,11 +764,158 @@ impl FilePicker { pub(crate) fn refresh(&mut self) { match Self::scan(self.root.clone(), self.presentation, self.view) { - Ok(replacement) => self.replace_catalog(replacement), + Ok(mut replacement) => { + replacement.set_repository_changes(self.repository_changes.clone()); + self.replace_catalog(replacement); + } Err(error) => self.error = Some(format!("Refresh failed: {error}")), } } + pub(crate) fn has_repository_changes(&self) -> bool { + self.has_repository_changes + } + + pub(crate) fn set_repository_changes(&mut self, changes: Vec) -> bool { + if !self.has_virtual_entries && changes == self.repository_changes { + return false; + } + if self.nodes.iter().any(|node| { + node.virtual_entry + && !node.is_dir + && fs::symlink_metadata(self.root.join(&node.path)) + .is_ok_and(|metadata| metadata.is_file()) + }) { + // Reuse the scanner's ignore/symlink rules when a deleted file returns. + match Self::scan(self.root.clone(), self.presentation, self.view) { + Ok(mut replacement) => { + replacement.set_repository_changes(changes); + replacement.error = self.error.clone().or(replacement.error); + self.replace_catalog(replacement); + return true; + } + Err(error) => self.error = Some(format!("Refresh failed: {error}")), + } + } + let changes: Vec<_> = changes + .into_iter() + .filter(|change| safe_repository_path(&change.path)) + .map(|mut change| { + change.old_path = change.old_path.filter(|path| safe_repository_path(path)); + change + }) + .collect(); + let by_path: HashMap<_, _> = changes + .iter() + .map(|change| (change.path.as_path(), change)) + .collect(); + let scanned: HashSet<_> = self + .nodes + .iter() + .filter(|node| !node.virtual_entry) + .map(|node| (node.path.as_path(), node.is_dir)) + .collect(); + let mut virtual_files = HashSet::new(); + let mut virtual_dirs = HashSet::new(); + for change in &changes { + if change.status != FileStatus::Removed + || scanned.contains(&(change.path.as_path(), false)) + || !missing_repository_path(&self.root, &change.path) + { + continue; + } + virtual_files.insert(change.path.clone()); + for parent in change.path.ancestors().skip(1) { + if parent.as_os_str().is_empty() { + break; + } + if !scanned.contains(&(parent, true)) && !virtual_dirs.contains(parent) { + virtual_dirs.insert(parent.to_owned()); + } + } + } + let topology_changed = self.nodes.iter().filter(|node| node.virtual_entry).count() + != virtual_files.len() + virtual_dirs.len() + || self + .nodes + .iter() + .filter(|node| node.virtual_entry) + .any(|node| { + !(if node.is_dir { + &virtual_dirs + } else { + &virtual_files + }) + .contains(node.path.as_path()) + }); + let mut metadata_changed = false; + for node in &mut self.nodes { + let change = (!node.is_dir && node.catalog_index.is_none()) + .then(|| by_path.get(node.path.as_path())) + .flatten(); + let status = change.map(|change| change.status); + let old_path = change.and_then(|change| change.old_path.as_ref()); + metadata_changed |= node.status != status || node.old_path.as_ref() != old_path; + node.status = status; + if node.old_path.as_ref() != old_path { + node.old_path = old_path.cloned(); + } + } + if topology_changed { + let mut nodes: Vec<_> = self + .nodes + .iter() + .filter(|node| !node.virtual_entry) + .map(|node| Node { + path: node.path.clone(), + label: node.label.clone(), + search_label: node.search_label.clone(), + catalog_index: node.catalog_index, + status: node.status, + old_path: node.old_path.clone(), + virtual_entry: false, + is_dir: node.is_dir, + parent: None, + children: Vec::new(), + }) + .collect(); + nodes.extend( + virtual_files + .into_iter() + .map(|path| (path, false)) + .chain(virtual_dirs.into_iter().map(|path| (path, true))) + .map(|(path, is_dir)| { + let label = label(&path); + let change = (!is_dir).then(|| by_path.get(path.as_path())).flatten(); + Node { + status: change.map(|change| change.status), + old_path: change.and_then(|change| change.old_path.clone()), + path, + search_label: label.to_lowercase(), + label, + catalog_index: None, + virtual_entry: true, + is_dir, + parent: None, + children: Vec::new(), + } + }), + ); + let mut replacement = Self::from_nodes( + self.root.clone(), + nodes, + self.presentation, + self.view, + self.warning.clone(), + ); + replacement.error = self.error.clone(); + self.replace_catalog(replacement); + } + self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some()); + self.repository_changes = changes; + topology_changed || metadata_changed + } + pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator) { self.replace_catalog(Self::from_paths(self.root.clone(), paths)); } @@ -835,6 +1025,7 @@ mod tests { assert!(!rows.is_empty()); for row in rows { assert_eq!(row.catalog_index, expected_index(&row)); + assert!(row.status.is_none() && row.old_path.is_none()); } } } @@ -881,9 +1072,213 @@ mod tests { picker.set_view(view); picker.reveal_path(Path::new("nested/file.txt")); assert!(picker.entries().all(|row| row.catalog_index.is_none())); + assert!( + picker + .entries() + .all(|row| row.status.is_none() && row.old_path.is_none()) + ); } } + fn repository_change( + path: &str, + status: FileStatus, + old_path: Option<&str>, + ) -> WorkingFileChange { + WorkingFileChange { + path: path.into(), + status, + old_path: old_path.map(PathBuf::from), + } + } + + #[test] + fn repository_metadata_keeps_clean_files_selection_and_filtered_views() { + let directory = Directory::new(); + for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] { + directory.put(name, ""); + } + let mut picker = directory.scan(); + picker.presentation = FilePresentation::Docked; + picker.set_view(FileView::Tree); + picker.reveal_path(Path::new("src/clean.rs")); + picker.query = "src".into(); + picker.refilter(); + picker.focused = false; + let changes = vec![ + repository_change("src/modified.rs", FileStatus::Modified, None), + repository_change("added.rs", FileStatus::Added, None), + repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")), + repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None), + ]; + assert!(picker.set_repository_changes(changes.clone())); + assert!(!picker.set_repository_changes(changes)); + assert_eq!(selected_path(&picker), Path::new("src/clean.rs")); + assert_eq!(picker.query, "src"); + assert!(!picker.focused); + assert_eq!(picker.presentation, FilePresentation::Docked); + assert!(picker.has_repository_changes()); + for view in [FileView::List, FileView::Tree] { + picker.set_view(view); + for query in ["", "src", "modified.rs", "deleted.rs"] { + picker.query = query.into(); + picker.refilter(); + let rows: Vec<_> = picker.entries().collect(); + assert!(!rows.is_empty()); + for row in rows { + assert!(row.catalog_index.is_none()); + if row.is_dir || row.path == Path::new("src/clean.rs") { + assert!(row.status.is_none() && row.old_path.is_none()); + } else { + let status = match row.path.to_str().unwrap() { + "src/modified.rs" => FileStatus::Modified, + "added.rs" => FileStatus::Added, + "renamed.rs" => FileStatus::Renamed, + "vanished/deep/deleted.rs" => FileStatus::Removed, + other => panic!("unexpected {other}"), + }; + assert_eq!(row.status, Some(status)); + assert_eq!( + row.old_path, + (status == FileStatus::Renamed).then(|| Path::new("old.rs")) + ); + } + } + if query.is_empty() || query == "src" { + assert!( + picker + .entries() + .any(|row| row.path == Path::new("src/clean.rs")) + ); + } + } + } + picker.set_view(FileView::List); + picker.clear_filter(); + picker.reveal_path(Path::new("src/clean.rs")); + assert!(picker.set_repository_changes(Vec::new())); + assert_eq!(selected_path(&picker), Path::new("src/clean.rs")); + assert!(!picker.has_repository_changes()); + assert_eq!(picker.entries().len(), 4); + assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); + assert!( + picker + .entries() + .all(|row| row.status.is_none() && row.old_path.is_none()) + ); + assert!(!picker.set_repository_changes(Vec::new())); + } + + #[test] + fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() { + let directory = Directory::new(); + directory.put("keep.txt", ""); + let mut picker = directory.scan(); + let deleted = Path::new("lost/deep/deleted.txt"); + picker.set_repository_changes(vec![repository_change( + "lost/deep/deleted.txt", + FileStatus::Removed, + None, + )]); + picker.set_view(FileView::Tree); + assert_eq!( + picker.entries().find(|row| row.is_dir).unwrap().label, + "lost/deep" + ); + picker.reveal_path(deleted); + picker.query = "deleted".into(); + picker.refilter(); + picker.start_search(); + picker.focused = false; + picker.refresh(); + assert_eq!(selected_path(&picker), deleted); + assert!(picker.searching && !picker.focused); + assert_eq!(picker.query, "deleted"); + assert_eq!(picker.accept(), Some(directory.0.join(deleted))); + assert!(!directory.0.join(deleted).exists()); + assert_eq!( + picker.entries().find(|row| !row.is_dir).unwrap().status, + Some(FileStatus::Removed) + ); + directory.put("lost/deep/deleted.txt", "restored"); + assert!(picker.set_repository_changes(vec![repository_change( + "lost/deep/deleted.txt", + FileStatus::Removed, + None, + )])); + picker.refresh(); + assert_eq!(selected_path(&picker), deleted); + assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); + assert!(picker.set_repository_changes(Vec::new())); + assert_eq!(selected_path(&picker), deleted); + assert_eq!(picker.accept(), Some(directory.0.join(deleted))); + assert_eq!( + fs::read_to_string(directory.0.join(deleted)).unwrap(), + "restored" + ); + assert!(picker.entries().all(|row| row.status.is_none())); + fs::remove_dir_all(directory.0.join("lost")).unwrap(); + picker.refresh(); + picker.set_repository_changes(vec![repository_change( + "lost/deep/deleted.txt", + FileStatus::Removed, + None, + )]); + picker.reveal_path(deleted); + picker.set_repository_changes(Vec::new()); + assert!(picker.entries().len() == 0); + picker.clear_filter(); + assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]); + assert!(picker.nodes.iter().all(|node| !node.virtual_entry)); + } + + #[test] + fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() { + let directory = Directory::new(); + directory.put(".gitignore", "ignored/\n"); + directory.put("ignored/hidden.txt", ""); + directory.put("directory/keep.txt", ""); + let mut picker = directory.scan(); + let mut changes: Vec<_> = [ + "", + ".", + "../outside.txt", + "directory/../../outside.txt", + ".git/secret", + "nested/.jj/secret", + "/outside.txt", + "directory", + "ignored/hidden.txt", + ] + .into_iter() + .map(|path| repository_change(path, FileStatus::Removed, None)) + .collect(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap(); + changes.push(repository_change( + "link/missing.txt", + FileStatus::Removed, + None, + )); + } + changes.push(repository_change("missing.txt", FileStatus::Removed, None)); + picker.set_repository_changes(changes); + assert_eq!( + paths(&picker), + [".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from) + ); + assert_eq!( + picker.entries().filter(|row| row.status.is_some()).count(), + 1 + ); + assert_eq!( + picker.entries().last().unwrap().status, + Some(FileStatus::Removed) + ); + assert!(!directory.0.join("missing.txt").exists()); + } + #[test] fn nested_ignore_negation_hidden_files_and_vcs_metadata() { let directory = Directory::new(); diff --git a/src/gutter.rs b/src/gutter.rs index 4b0e4fb..7cf9bf8 100644 --- a/src/gutter.rs +++ b/src/gutter.rs @@ -3,7 +3,9 @@ use crate::{ blame::{git, git_without_literal_pathspecs, run, run_status}, diff::line_hunks, editor::normalize_newlines, - repository::{jj_command, jj_file_pattern, safe_path, store_for_jj}, + repository::{ + WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj, + }, }; use serde::Deserialize; use std::{ @@ -23,7 +25,13 @@ const FILE_COUNT_LIMIT: usize = 1024; type Files = Vec<(usize, PathBuf, Arc)>; type FileResult = Result>, String>; pub(crate) type ResultBatch = Vec<(usize, FileResult)>; -type Request = (u64, u64, Files); +type PickerRequest = (PathBuf, Vec<(PathBuf, Arc)>); +type Request = (u64, u64, Files, Option); + +pub(crate) struct PickerChanges { + pub(crate) root: PathBuf, + pub(crate) result: Result, String>, +} #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] @@ -45,7 +53,7 @@ pub(crate) enum Change { #[derive(Default)] struct State { request: Option, - result: Option<(u64, ResultBatch)>, + result: Option<(u64, ResultBatch, Option)>, } #[derive(Default)] struct Shared { @@ -65,6 +73,7 @@ impl Worker { let thread = thread::Builder::new() .name("vamp-gutter".into()) .spawn(move || { + let mut picker_reader = WorkingFileReader::default(); loop { let Ok(mut state) = worker.state.lock() else { return; @@ -78,7 +87,7 @@ impl Worker { if worker.stopped.load(Ordering::Acquire) { return; } - let (generation, id, files) = state.request.take().unwrap(); + let (generation, id, files, picker) = state.request.take().unwrap(); drop(state); let cancelled = || { worker.stopped.load(Ordering::Acquire) @@ -91,11 +100,15 @@ impl Worker { } results.push((buffer, read_file(&path, &contents, &cancelled))); } + let picker = picker.map(|(root, buffers)| PickerChanges { + result: picker_reader.read(&root, &buffers, &cancelled), + root, + }); let Ok(mut state) = worker.state.lock() else { return; }; if !cancelled() { - state.result = Some((id, results)); + state.result = Some((id, results, picker)); } } })?; @@ -105,12 +118,31 @@ impl Worker { }) } - pub(crate) fn request(&mut self, id: u64, files: Files) -> io::Result<()> { + pub(crate) fn request( + &mut self, + id: u64, + files: Files, + picker: Option, + ) -> io::Result<()> { let size = files.iter().try_fold(0usize, |size, (_, path, text)| { size.checked_add(text.len())? .checked_add(path.as_os_str().len()) }); - if files.len() > FILE_COUNT_LIMIT || size.is_none_or(|size| size > BATCH_LIMIT) { + let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| { + buffers + .iter() + .try_fold(root.as_os_str().len(), |size, (path, text)| { + size.checked_add(text.len())? + .checked_add(path.as_os_str().len()) + }) + }); + let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len()); + if count > FILE_COUNT_LIMIT + || size + .zip(picker_size) + .and_then(|(files, picker)| files.checked_add(picker)) + .is_none_or(|size| size > BATCH_LIMIT) + { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Gutter batch exceeds 64 MiB or 1024 files", @@ -126,13 +158,13 @@ impl Worker { .generation .fetch_add(1, Ordering::AcqRel) .wrapping_add(1); - state.request = Some((generation, id, files)); + state.request = Some((generation, id, files, picker)); state.result = None; self.shared.wake.notify_one(); Ok(()) } - pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch)> { + pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option)> { self.shared.state.lock().ok()?.result.take() } } @@ -766,11 +798,13 @@ mod tests { (5, path.clone(), "first\nchanged\nthird\n".into()), (8, path.clone(), Arc::clone(&oversized)), ], + None, ) .unwrap(); } loop { - if let Some((id, result)) = worker.poll() { + if let Some((id, result, picker)) = worker.poll() { + assert!(picker.is_none()); assert_eq!(id, 19); assert_eq!(result[0].0, 5); assert_eq!( @@ -788,10 +822,64 @@ mod tests { } assert_eq!( worker - .request(20, vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())]) + .request( + 20, + vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())], + None + ) .unwrap_err() .kind(), io::ErrorKind::InvalidInput ); } + + #[test] + fn picker_status_is_optional_and_errors_do_not_discard_line_results() { + let (temp, path) = git_fixture(); + let mut worker = Worker::start().unwrap(); + worker + .request( + 1, + vec![], + Some(( + temp.0.clone(), + vec![(path.clone(), "dirty buffer\n".into())], + )), + ) + .unwrap(); + loop { + if let Some((id, lines, picker)) = worker.poll() { + assert_eq!(id, 1); + assert!(lines.is_empty()); + let picker = picker.unwrap(); + assert_eq!(picker.root, temp.0); + let changes = picker.result.unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap()); + assert_eq!(changes[0].status, crate::diff::FileStatus::Modified); + break; + } + thread::yield_now(); + } + worker + .request( + 2, + vec![(4, path.clone(), "first\nchanged\nthird\n".into())], + Some(( + temp.0.clone(), + vec![(path, "x".repeat(FILE_LIMIT + 1).into())], + )), + ) + .unwrap(); + loop { + if let Some((id, lines, picker)) = worker.poll() { + assert_eq!(id, 2); + assert_eq!(lines[0].0, 4); + assert!(lines[0].1.is_ok()); + assert!(picker.unwrap().result.unwrap_err().contains("16 MiB")); + break; + } + thread::yield_now(); + } + } } diff --git a/src/repository.rs b/src/repository.rs index 7469399..b40390a 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -306,6 +306,739 @@ pub(crate) fn jj_file_pattern(path: &str) -> String { pattern } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkingFileChange { + pub(crate) path: PathBuf, + pub(crate) old_path: Option, + pub(crate) status: FileStatus, +} + +const WORKING_PATH_LIMIT: usize = 100_000; + +fn working_checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result, String> { + crate::blame::run(command, &[], cancelled)? + .ok_or_else(|| "Cannot read working-file repository metadata".into()) +} + +fn working_paths(bytes: &[u8]) -> Result, String> { + let mut paths = Vec::new(); + for path in bytes + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + if paths.len() == WORKING_PATH_LIMIT { + return Err("Working-file catalog exceeds 100000 paths".into()); + } + paths.push(path_bytes(path)?); + } + Ok(paths) +} + +fn working_safe(root: &Path, path: &Path) -> Result { + if safe_path(path).is_err() { + return Ok(false); + } + let mut full = root.to_path_buf(); + for component in path.components() { + full.push(component); + match fs::symlink_metadata(&full) { + Ok(metadata) if metadata.file_type().is_symlink() => return Ok(false), + Ok(_) => {} + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => {} + Err(error) => return Err(format!("{}: {error}", full.display())), + } + } + Ok(true) +} + +struct WorkingBaseline { + root: PathBuf, + parent: String, + store: PathBuf, + settings: Vec, + conflicted: bool, + private: PathBuf, + _temporary: Temporary, +} + +#[derive(Default)] +pub(crate) struct WorkingFileReader { + baseline: Option, +} + +impl WorkingFileReader { + pub(crate) fn read( + &mut self, + root: &Path, + buffers: &[(PathBuf, Arc)], + cancelled: &dyn Fn() -> bool, + ) -> Result, String> { + working_file_changes_cached(root, buffers, cancelled, &mut self.baseline) + } +} + +fn working_safe_config(command: &mut Command) { + command + .env_remove("GIT_CONFIG_PARAMETERS") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_CONFIG_COUNT", "0"); +} + +fn working_private_command(private: &Path, checkout: &Path) -> Command { + let mut command = git_command(private); + command + .arg("--literal-pathspecs") + .arg("--work-tree") + .arg(checkout) + .current_dir(checkout); + working_safe_config(&mut command); + command +} + +fn working_private( + checkout: &Path, + store: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result<(Temporary, PathBuf), String> { + let temporary = Temporary::new()?; + let private = temporary.0.join("store"); + let mut format = crate::blame::git(checkout); + format + .arg("--git-dir") + .arg(store) + .args(["rev-parse", "--show-object-format"]); + let format = text(working_checked(format, cancelled)?)?; + let mut init = Command::new("git"); + init.args(["init", "--bare", "--quiet", "--template="]) + .arg(format!("--object-format={}", format.trim())) + .arg(&private); + working_safe_config(&mut init); + working_checked(init, cancelled)?; + let mut objects = crate::blame::git(checkout); + objects.arg("--git-dir").arg(store).args([ + "rev-parse", + "--path-format=absolute", + "--git-path", + "objects", + ]); + let objects = working_checked(objects, cancelled)?; + let mut alternate = vec![b'"']; + for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) { + if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') { + alternate.push(byte); + } else { + write!(&mut alternate, "\\{byte:03o}").unwrap(); + } + } + alternate.extend_from_slice(b"\"\n"); + fs::write(private.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?; + Ok((temporary, private)) +} + +fn working_conflict_baseline( + root: &Path, + store: &Path, + parent: &str, + cancelled: &dyn Fn() -> bool, +) -> Result { + let (temporary, private) = working_private(root, store, cancelled)?; + let mut empty = working_private_command(&private, root); + empty.args(["read-tree", "--empty"]); + working_checked(empty, cancelled)?; + let mut list = jj_command(root); + list.args([ + "file", + "list", + "-r", + parent, + "-T", + "\"[\" ++ json(path) ++ \",\" ++ json(file_type) ++ \",\" ++ json(executable) ++ \"]\\n\"", + ]); + let bytes = working_checked(list, cancelled)?; + let mut index = Vec::new(); + let mut size = 0usize; + let mut count = 0usize; + for line in bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + if cancelled() { + return Err("Working-file query cancelled".into()); + } + count += 1; + if count > WORKING_PATH_LIMIT { + return Err("Working-file conflict baseline exceeds 100000 paths".into()); + } + let (path, kind, executable): (String, String, bool) = serde_json::from_slice(line) + .map_err(|_| "Invalid Jujutsu working-file conflict metadata")?; + if !matches!(kind.as_str(), "file" | "conflict") || safe_path(Path::new(&path)).is_err() { + continue; + } + let mut show = jj_command(root); + show.args([ + "file", + "show", + "-r", + parent, + "-T", + "", + "--", + &jj_file_pattern(&path), + ]); + let contents = working_checked(show, cancelled)?; + size += contents.len(); + if contents.len() > FILE_LIMIT || size > OUTPUT_LIMIT { + return Err( + "Working-file conflict baseline exceeds 16 MiB per file or 64 MiB total".into(), + ); + } + let mut hash = working_private_command(&private, root); + hash.args(["hash-object", "--no-filters", "-w", "--stdin"]); + let object = crate::blame::run(hash, &contents, cancelled)? + .ok_or("Cannot store private Jujutsu working-file projection")?; + let object = text(object)?; + write!( + &mut index, + "{} {}\t", + if executable { "100755" } else { "100644" }, + object.trim() + ) + .unwrap(); + index.extend_from_slice(path.as_bytes()); + index.push(0); + } + let mut update = working_private_command(&private, root); + update.args(["update-index", "-z", "--index-info"]); + crate::blame::run(update, &index, cancelled)? + .ok_or("Cannot build private Jujutsu working-file index")?; + Ok(WorkingBaseline { + root: root.to_path_buf(), + parent: parent.to_owned(), + store: store.to_path_buf(), + settings: Vec::new(), + conflicted: true, + private, + _temporary: temporary, + }) +} + +#[cfg(test)] +fn working_file_changes( + root: &Path, + buffers: &[(PathBuf, Arc)], + cancelled: &dyn Fn() -> bool, +) -> Result, String> { + WorkingFileReader::default().read(root, buffers, cancelled) +} + +fn working_file_changes_cached( + root: &Path, + buffers: &[(PathBuf, Arc)], + cancelled: &dyn Fn() -> bool, + cache: &mut Option, +) -> Result, String> { + use crate::blame::{git, git_without_literal_pathspecs, run_status}; + if cancelled() { + return Err("Working-file query cancelled".into()); + } + let size = buffers.iter().try_fold(0usize, |size, (path, contents)| { + size.checked_add(contents.len())? + .checked_add(path.as_os_str().len()) + }); + if buffers.len() > 1024 || size.is_none_or(|size| size > OUTPUT_LIMIT) { + return Err("Working-file buffers exceed 64 MiB or 1024 files".into()); + } + let anchored; + let absolute = if root.is_absolute() { + root + } else { + anchored = std::env::current_dir() + .map_err(|e| e.to_string())? + .join(root); + anchored.as_path() + }; + if absolute + .components() + .any(|part| matches!(part.as_os_str().to_str(), Some(".git" | ".jj"))) + { + *cache = None; + return Ok(vec![]); + } + let Some(checkout) = absolute + .ancestors() + .find(|path| path.join(".jj").exists() || path.join(".git").exists()) + else { + *cache = None; + return Ok(vec![]); + }; + if absolute + .ancestors() + .take_while(|path| *path != checkout) + .any(|path| { + fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) + }) + || fs::symlink_metadata(checkout).is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + *cache = None; + return Ok(vec![]); + } + let picker = fs::canonicalize(absolute).map_err(|e| e.to_string())?; + let lexical_checkout = checkout; + let checkout = fs::canonicalize(checkout).map_err(|e| e.to_string())?; + if cache.as_ref().is_some_and(|cached| cached.root != checkout) { + *cache = None; + } + let prefix = picker.strip_prefix(&checkout).map_err(|e| e.to_string())?; + let is_jj = checkout.join(".jj").exists(); + let (store, baseline, conflicted) = if is_jj { + let store = match store_for_jj(&checkout) { + Ok(store) => store, + Err(_) => { + *cache = None; + return Ok(vec![]); + } + }; + let mut parents = jj_command(&checkout); + parents.args([ + "log", + "--no-graph", + "-r", + "@-", + "-T", + "\"[\" ++ json(commit_id) ++ \",\" ++ json(conflict) ++ \"]\\n\"", + ]); + let bytes = working_checked(parents, cancelled)?; + let mut ids = bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()); + let Some(parent) = ids.next() else { + *cache = None; + return Ok(vec![]); + }; + // ponytail: merged jj parents need a merged-tree baseline; match the gutter ceiling. + if ids.next().is_some() { + *cache = None; + return Ok(vec![]); + } + let (parent, conflicted): (String, bool) = serde_json::from_slice(parent) + .map_err(|_| "Invalid Jujutsu working-file parent metadata")?; + ( + store, + (!parent.bytes().all(|byte| byte == b'0')).then_some(parent), + conflicted, + ) + } else { + let mut discover = git(&checkout); + discover.args(["rev-parse", "--absolute-git-dir"]); + let bytes = working_checked(discover, cancelled)?; + let store = path_bytes(bytes.strip_suffix(b"\n").unwrap_or(&bytes))?; + let mut head = git(&checkout); + head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]); + let (code, bytes) = run_status(head, &[], cancelled)?; + let baseline = match code { + Some(0) => Some(text(bytes)?.trim().to_owned()), + Some(1) => None, + _ => return Err("Cannot resolve Git working-file HEAD".into()), + }; + (store, baseline, false) + }; + let mut settings = Vec::new(); + for key in ["core.autocrlf", "core.filemode"] { + let mut config = git(&checkout); + config.arg("--git-dir").arg(&store).arg("config"); + if key == "core.filemode" { + config.arg("--bool"); + } + config.args(["--get", key]); + let (code, bytes) = run_status(config, &[], cancelled)?; + match code { + Some(0) => { + let value = text(bytes)?; + let value = value.strip_suffix('\n').unwrap_or(&value); + settings.push(format!("{key}={value}")); + } + Some(1) => {} + _ => return Err("Cannot read working-file Git settings".into()), + } + } + let parent = baseline.as_deref().unwrap_or(""); + if cache.as_ref().is_none_or(|cached| { + cached.root != checkout + || cached.store != store + || cached.parent != parent + || cached.settings != settings + || cached.conflicted != conflicted + }) { + *cache = None; + let mut cached = if conflicted { + working_conflict_baseline(&checkout, &store, parent, cancelled)? + } else { + let (temporary, private) = working_private(&checkout, &store, cancelled)?; + let mut read = working_private_command(&private, &checkout); + for setting in &settings { + read.arg("-c").arg(setting); + } + read.arg("read-tree") + .arg(baseline.as_deref().unwrap_or("--empty")); + working_checked(read, cancelled)?; + WorkingBaseline { + root: checkout.clone(), + parent: parent.to_owned(), + store: store.clone(), + settings: Vec::new(), + conflicted: false, + private, + _temporary: temporary, + } + }; + cached.settings = settings.clone(); + *cache = Some(cached); + } + let private = cache.as_ref().unwrap().private.as_path(); + let check_mode = !settings + .iter() + .any(|setting| setting == "core.filemode=false"); + let command = || { + let mut command = working_private_command(private, &checkout); + for setting in &settings { + command.arg("-c").arg(setting); + } + command + }; + // Refresh only the private stat cache; dirty paths keep their baseline object IDs. + let mut refresh = command(); + refresh.args(["update-index", "-q", "--refresh", "--ignore-missing"]); + working_checked(refresh, cancelled)?; + let mut tree = command(); + tree.args(["ls-files", "--stage", "-z"]); + let bytes = working_checked(tree, cancelled)?; + let mut entries = BTreeMap::new(); + for entry in bytes + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + { + let tab = entry + .iter() + .position(|byte| *byte == b'\t') + .ok_or("Invalid working-file tree")?; + let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid working-file tree")?; + let mut fields = fields.split(' '); + let mode = fields.next().ok_or("Missing working-file mode")?.to_owned(); + let object = fields + .next() + .ok_or("Missing working-file object")? + .to_owned(); + entries.insert(path_bytes(&entry[tab + 1..])?, (mode, object)); + if entries.len() > WORKING_PATH_LIMIT { + return Err("Working-file catalog exceeds 100000 paths".into()); + } + } + let mut changes = BTreeMap::new(); + if baseline.is_some() { + let mut diff = command(); + diff.args([ + "diff", + "--name-status", + "-z", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=all", + "--find-renames=100%", + "--", + ]); + // A baseline-populated private index makes diff compare tracked files to disk. + // No unstaged file is read merely to construct a Rust snapshot. + let bytes = working_checked(diff, cancelled)?; + let mut records = bytes + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()); + while let Some(status) = records.next() { + let path = path_bytes(records.next().ok_or("Truncated working-file status")?)?; + let status = match status.first() { + Some(b'D') => FileStatus::Removed, + Some(b'A') => FileStatus::Added, + Some(b'M' | b'T' | b'U') => FileStatus::Modified, + Some(b'R') => { + changes.insert(path, FileStatus::Removed); + let path = path_bytes(records.next().ok_or("Truncated working-file rename")?)?; + changes.insert(path, FileStatus::Added); + continue; + } + _ => return Err("Unsupported working-file status".into()), + }; + changes.insert(path, status); + } + } + let mut candidates = git(&checkout); + candidates + .arg("--git-dir") + .arg(&store) + .arg("--work-tree") + .arg(&checkout) + .args([ + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]); + let mut candidates = working_paths(&working_checked(candidates, cancelled)?)?; + if is_jj { + let mut tracked = jj_command(&checkout); + tracked.args(["file", "list", "-r", "@", "-T", "json(path) ++ \"\\n\""]); + let bytes = working_checked(tracked, cancelled)?; + for line in bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + let path: String = + serde_json::from_slice(line).map_err(|_| "Invalid Jujutsu working-file path")?; + candidates.push(PathBuf::from(path)); + if candidates.len() > WORKING_PATH_LIMIT { + return Err("Working-file catalog exceeds 100000 paths".into()); + } + } + } + for path in candidates { + if cancelled() { + return Err("Working-file query cancelled".into()); + } + if !entries.contains_key(&path) + && fs::symlink_metadata(checkout.join(&path)).is_ok_and(|metadata| metadata.is_file()) + { + changes.insert(path, FileStatus::Added); + } + if changes.len() > WORKING_PATH_LIMIT { + return Err("Working-file catalog exceeds 100000 paths".into()); + } + } + // Pair exact content identities only when both ends are unique. This includes unstaged + // moves whose destination is untracked, which `git diff` alone cannot see. + let mut removed: BTreeMap> = BTreeMap::new(); + let mut added: BTreeMap> = BTreeMap::new(); + for (path, status) in &changes { + if !working_safe(&checkout, path)? { + continue; + } + if *status == FileStatus::Removed + && let Some((mode, object)) = entries.get(path) + && matches!(mode.as_str(), "100644" | "100755") + { + removed + .entry(object.clone()) + .or_default() + .push(path.clone()); + } + } + if !removed.is_empty() { + let mut paths = Vec::new(); + let mut input = Vec::new(); + let mut total = 0u64; + for (path, status) in &changes { + if cancelled() { + return Err("Working-file query cancelled".into()); + } + if *status != FileStatus::Added || !working_safe(&checkout, path)? { + continue; + } + let metadata = fs::symlink_metadata(checkout.join(path)).map_err(|e| e.to_string())?; + if !metadata.is_file() { + continue; + } + if metadata.len() > FILE_LIMIT as u64 { + return Err(format!( + "{}: working-file rename candidate exceeds 16 MiB", + path.display() + )); + } + total += metadata.len(); + if total > OUTPUT_LIMIT as u64 { + return Err("Working-file rename candidates exceed 64 MiB".into()); + } + input.push(b'"'); + for &byte in path.as_os_str().as_encoded_bytes() { + if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') { + input.push(byte); + } else { + write!(&mut input, "\\{byte:03o}").unwrap(); + } + } + input.extend_from_slice(b"\"\n"); + paths.push(path); + } + if !paths.is_empty() { + let mut hash = command(); + // Only built-in newline conversion is configured in this isolated store. + hash.args(["hash-object", "--stdin-paths"]); + let bytes = crate::blame::run(hash, &input, cancelled)? + .ok_or("Cannot hash working-file rename candidates")?; + let hashes = text(bytes)?; + let mut hashes = hashes.lines(); + for path in paths { + let object = hashes.next().ok_or("Truncated working-file hashes")?; + added + .entry(object.to_owned()) + .or_default() + .push(path.clone()); + } + if hashes.next().is_some() { + return Err("Unexpected working-file hashes".into()); + } + } + } + let mut renames = BTreeMap::new(); + for (object, old) in removed { + if old.len() == 1 + && let Some(new) = added.get(&object).filter(|paths| paths.len() == 1) + && old[0].starts_with(prefix) + && new[0].starts_with(prefix) + { + changes.remove(&old[0]); + changes.insert(new[0].clone(), FileStatus::Renamed); + renames.insert(new[0].clone(), old[0].clone()); + } + } + for (path, contents) in buffers { + if cancelled() { + return Err("Working-file query cancelled".into()); + } + let anchored; + let absolute = if path.is_absolute() { + path.as_path() + } else { + anchored = std::env::current_dir() + .map_err(|error| error.to_string())? + .join(path); + anchored.as_path() + }; + let resolved; + let path = match absolute + .strip_prefix(&checkout) + .or_else(|_| absolute.strip_prefix(lexical_checkout)) + { + Ok(path) => path, + Err(_) => { + resolved = match crate::editor::path_identity(absolute) { + Ok(path) => path, + Err(_) => continue, + }; + let Ok(path) = resolved.strip_prefix(&checkout) else { + continue; + }; + path + } + }; + if !path.starts_with(prefix) || !working_safe(&checkout, path)? { + continue; + } + if contents.len() > FILE_LIMIT { + return Err(format!( + "{}: working-file buffer exceeds 16 MiB", + path.display() + )); + } + // A dirty buffer does not resurrect a missing tracked file or destroy a rename. + if matches!( + changes.get(path), + Some(FileStatus::Removed | FileStatus::Renamed) + ) { + continue; + } + if let Some((mode, object)) = entries.get(path) { + if !matches!(mode.as_str(), "100644" | "100755") { + continue; + } + let mut cat = command(); + cat.args(["cat-file", "blob", object]); + let bytes = working_checked(cat, cancelled)?; + if bytes.len() > FILE_LIMIT { + return Err(format!( + "{}: working-file baseline exceeds 16 MiB", + path.display() + )); + } + let same = std::str::from_utf8(&bytes).is_ok_and(|before| { + crate::editor::normalize_newlines(before) + == crate::editor::normalize_newlines(contents) + }); + let mode_changed = check_mode + && fs::symlink_metadata(checkout.join(path)).is_ok_and(|metadata| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + (metadata.permissions().mode() & 0o111 != 0) != (mode == "100755") + } + #[cfg(not(unix))] + { + false + } + }); + if same && !mode_changed { + changes.remove(path); + } else { + changes.insert(path.to_path_buf(), FileStatus::Modified); + } + } else if changes.get(path) != Some(&FileStatus::Added) { + let mut ignored = git_without_literal_pathspecs(&checkout); + ignored + .arg("--git-dir") + .arg(&store) + .arg("--work-tree") + .arg(&checkout) + .args(["check-ignore", "--no-index", "-q", "--"]) + .arg(path); + match run_status(ignored, &[], cancelled)?.0 { + Some(1) => { + changes.insert(path.to_path_buf(), FileStatus::Added); + } + Some(0) => {} + _ => return Err("Cannot read working-file ignore rules".into()), + } + } + } + if changes.len() > WORKING_PATH_LIMIT { + return Err("Working-file catalog exceeds 100000 paths".into()); + } + let mut result = Vec::with_capacity(changes.len()); + for (path, status) in changes { + if cancelled() { + return Err("Working-file query cancelled".into()); + } + if !path.starts_with(prefix) || !working_safe(&checkout, &path)? { + continue; + } + if status == FileStatus::Removed + && entries + .get(&path) + .is_some_and(|(mode, _)| !matches!(mode.as_str(), "100644" | "100755")) + { + continue; + } + let old_path = renames + .remove(&path) + .and_then(|old| old.strip_prefix(prefix).ok().map(Path::to_path_buf)); + let status = if status == FileStatus::Renamed && old_path.is_none() { + FileStatus::Added + } else { + status + }; + result.push(WorkingFileChange { + path: path + .strip_prefix(prefix) + .map_err(|e| e.to_string())? + .to_path_buf(), + old_path, + status, + }); + } + Ok(result) +} + struct Remote { name: String, url: String, @@ -1851,6 +2584,461 @@ fn eof_label(file: Option<&FileData>) -> &'static str { mod tests { use super::*; + fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String { + let mut command = Command::new(program); + if program == "jj" { + command.args([ + "--config=user.name=\"Picker Author\"", + "--config=user.email=\"picker@example.test\"", + "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", + "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", + ]); + } + let output = command + .args(args) + .current_dir(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") + .env("JJ_CONFIG", "/dev/null") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().into() + } + + fn picker_git_fixture() -> Temporary { + let temp = Temporary::new().unwrap(); + fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]); + fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]); + fixture_command( + &temp.0, + "git", + &["config", "user.email", "picker@example.test"], + ); + temp + } + + fn changes_by_path( + root: &Path, + buffers: &[(PathBuf, Arc)], + ) -> BTreeMap { + working_file_changes(root, buffers, &|| false) + .unwrap() + .into_iter() + .map(|change| (change.path.clone(), change)) + .collect() + } + + #[test] + fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() { + use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt}; + let temp = picker_git_fixture(); + let root = &temp.0; + for (name, contents) in [ + (".gitignore", "ignored*\n"), + ("modified", "original\r\n"), + ("unchanged", "same\n"), + ("removed", "deleted\n"), + ("old", "rename content\n"), + ("duplicate-one", "duplicate\n"), + ("duplicate-two", "duplicate\n"), + ("mode", "mode\n"), + ("binary", "before\0binary"), + ] { + fs::write(root.join(name), contents).unwrap(); + } + let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec())); + let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") { + Ok(()) => Some(non_utf8), + Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None, + Err(error) => panic!("cannot create byte filename: {error}"), + }; + fixture_command(root, "git", &["add", "--all"]); + fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); + fs::write(root.join("modified"), "staged\n").unwrap(); + fixture_command(root, "git", &["add", "--", "modified"]); + fs::write(root.join("modified"), "unstaged\n").unwrap(); + fs::write(root.join("binary"), "after\0binary").unwrap(); + if let Some(name) = &non_utf8 { + fs::write(root.join(name), "after\n").unwrap(); + } + fs::remove_file(root.join("removed")).unwrap(); + let renamed = "-new [file]\n\t\".txt"; + fs::rename(root.join("old"), root.join(renamed)).unwrap(); + fs::remove_file(root.join("duplicate-one")).unwrap(); + fs::remove_file(root.join("duplicate-two")).unwrap(); + fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap(); + fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap(); + fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::write(root.join("added"), "added\n").unwrap(); + fs::write(root.join("ignored"), "ignored\n").unwrap(); + fs::write(root.join("ignored-tracked"), "forced\n").unwrap(); + fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]); + std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap(); + let index = fs::read(root.join(".git/index")).unwrap(); + let head = fs::read(root.join(".git/HEAD")).unwrap(); + let disk = changes_by_path(root, &[]); + for name in ["modified", "binary", "mode"] { + assert_eq!(disk[Path::new(name)].status, FileStatus::Modified); + } + if let Some(name) = &non_utf8 { + assert_eq!(disk[name].status, FileStatus::Modified); + } + assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed); + assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed); + assert_eq!( + disk[Path::new(renamed)].old_path.as_deref(), + Some(Path::new("old")) + ); + for name in [ + "added", + "ignored-tracked", + "new-duplicate-one", + "new-duplicate-two", + ] { + assert_eq!(disk[Path::new(name)].status, FileStatus::Added); + } + for name in ["duplicate-one", "duplicate-two"] { + assert_eq!(disk[Path::new(name)].status, FileStatus::Removed); + } + for name in ["unchanged", "old", "ignored", "link"] { + assert!(!disk.contains_key(Path::new(name)), "{name}"); + } + let overlays = changes_by_path( + root, + &[ + (root.join("modified"), "original\n".into()), + (root.join("unchanged"), "dirty\n".into()), + (root.join("removed"), "deleted\n".into()), + (root.join(renamed), "dirty renamed\n".into()), + (root.join("mode"), "mode\n".into()), + (root.join("ignored-buffer"), "ignored\n".into()), + ], + ); + assert!(!overlays.contains_key(Path::new("modified"))); + assert!(!overlays.contains_key(Path::new("ignored-buffer"))); + assert_eq!( + overlays[Path::new("unchanged")].status, + FileStatus::Modified + ); + assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed); + assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed); + assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified); + let aliases = Temporary::new().unwrap(); + let alias = aliases.0.join("checkout"); + std::os::unix::fs::symlink(root, &alias).unwrap(); + let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]); + assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified); + fixture_command(root, "git", &["config", "core.filemode", "0"]); + assert!( + !changes_by_path(root, &[(root.join("mode"), "mode\n".into())]) + .contains_key(Path::new("mode")) + ); + fixture_command(root, "git", &["config", "core.autocrlf", "1"]); + fs::write(root.join("unchanged"), "same\r\n").unwrap(); + assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged"))); + fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]); + fixture_command(root, "git", &["config", "core.filemode", "true"]); + fs::write(root.join("unchanged"), "same\n").unwrap(); + assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); + assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head); + assert_eq!( + fs::read_to_string(root.join("modified")).unwrap(), + "unstaged\n" + ); + assert!(!root.join("removed").exists()); + } + + #[test] + fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() { + let temp = picker_git_fixture(); + let root = &temp.0; + fs::create_dir(root.join("sub")).unwrap(); + fs::write(root.join("sub/outgoing"), "out\n").unwrap(); + fs::write(root.join("incoming"), "in\n").unwrap(); + fixture_command(root, "git", &["add", "--all"]); + fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); + fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap(); + fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap(); + let changes = changes_by_path(&root.join("sub"), &[]); + assert_eq!(changes.len(), 2); + assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed); + assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added); + assert_eq!(changes[Path::new("incoming")].old_path, None); + std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap(); + assert!(changes_by_path(&root.join("linked"), &[]).is_empty()); + assert!(changes_by_path(&root.join(".git"), &[]).is_empty()); + let unrelated = Temporary::new().unwrap(); + assert!(changes_by_path(&unrelated.0, &[]).is_empty()); + let worktree = temp.0.join("worktree"); + fixture_command( + root, + "git", + &["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"], + ); + fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap(); + let changes = changes_by_path(&worktree.join("sub"), &[]); + assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified); + } + + #[test] + fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() { + let temp = picker_git_fixture(); + let root = &temp.0; + fs::write(root.join(".gitignore"), "ignored\n").unwrap(); + fs::write(root.join("tracked"), "tracked\n").unwrap(); + fs::write(root.join("untracked"), "untracked\n").unwrap(); + fs::write(root.join("ignored"), "ignored\n").unwrap(); + fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]); + let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]); + for name in [".gitignore", "tracked", "untracked", "named"] { + assert_eq!(changes[Path::new(name)].status, FileStatus::Added); + } + assert!(!changes.contains_key(Path::new("ignored"))); + assert!(!root.join("named").exists()); + fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]); + fs::write( + root.join(".gitattributes"), + "tracked filter=evil diff=evil\n", + ) + .unwrap(); + let evil = "sh -c 'touch executed; cat'"; + for key in [ + "filter.evil.clean", + "filter.evil.process", + "diff.evil.command", + "diff.evil.textconv", + ] { + fixture_command(root, "git", &["config", key, evil]); + } + fixture_command(root, "git", &["config", "filter.evil.required", "true"]); + fs::write(root.join("tracked"), "changed\n").unwrap(); + assert_eq!( + changes_by_path(root, &[])[Path::new("tracked")].status, + FileStatus::Modified + ); + assert_eq!( + changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())]) + .get(Path::new("tracked")), + None + ); + assert!(!root.join("executed").exists()); + assert!( + working_file_changes(root, &[], &|| true) + .unwrap_err() + .contains("cancelled") + ); + assert!( + working_file_changes( + root, + &[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())], + &|| false + ) + .unwrap_err() + .contains("16 MiB") + ); + } + + #[test] + fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() { + for colocate in ["--colocate", "--no-colocate"] { + let temp = Temporary::new().unwrap(); + let root = &temp.0; + fixture_command(root, "jj", &["git", "init", colocate, "."]); + fs::write(root.join("file"), "baseline\n").unwrap(); + assert_eq!( + changes_by_path(root, &[])[Path::new("file")].status, + FileStatus::Added + ); + fs::write(root.join(".gitignore"), "ignored\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Baseline"]); + fixture_command(root, "jj", &["new"]); + let operation = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ], + ); + let parent = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@-", + "-T", + "commit_id", + ], + ); + let store = store_for_jj(root).unwrap(); + let head = fs::read(store.join("HEAD")).unwrap(); + let index = fs::read(store.join("index")).ok(); + fs::write(root.join("file"), "disk\n").unwrap(); + fs::write(root.join("new"), "new\n").unwrap(); + fs::write(root.join("ignored"), "ignored\n").unwrap(); + let changes = changes_by_path(root, &[]); + assert_eq!(changes[Path::new("file")].status, FileStatus::Modified); + assert_eq!(changes[Path::new("new")].status, FileStatus::Added); + assert!(!changes.contains_key(Path::new("ignored"))); + assert!( + !changes_by_path(root, &[(root.join("file"), "baseline\n".into())]) + .contains_key(Path::new("file")) + ); + assert_eq!(fs::read(store.join("HEAD")).unwrap(), head); + assert_eq!(fs::read(store.join("index")).ok(), index); + assert_eq!( + fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ] + ), + operation + ); + fixture_command(root, "jj", &["new", "root()"]); + fs::write(root.join("sibling"), "sibling\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Sibling"]); + fixture_command(root, "jj", &["new", &parent, "@"]); + assert!(changes_by_path(root, &[]).is_empty()); + } + } + + #[test] + fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() { + for colocate in ["--colocate", "--no-colocate"] { + let temp = Temporary::new().unwrap(); + let root = &temp.0; + fixture_command(root, "jj", &["git", "init", colocate, "."]); + fs::write(root.join("conflicted"), "base\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Base"]); + let base = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + fixture_command(root, "jj", &["new"]); + fs::write(root.join("conflicted"), "left\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Left"]); + let left = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + fixture_command(root, "jj", &["new", &base]); + fs::write(root.join("conflicted"), "right\n").unwrap(); + fixture_command(root, "jj", &["describe", "-m", "Right"]); + let right = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "@", + "-T", + "commit_id", + ], + ); + fixture_command(root, "jj", &["new", &left, &right]); + fixture_command(root, "jj", &["new"]); + let projected = fs::read_to_string(root.join("conflicted")).unwrap(); + assert!(projected.contains("<<<<<<<")); + let operation = fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id", + ], + ); + let mut reader = WorkingFileReader::default(); + assert!(reader.read(root, &[], &|| false).unwrap().is_empty()); + fs::write(root.join("conflicted"), "resolved in working file\n").unwrap(); + let changes = reader.read(root, &[], &|| false).unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].path, Path::new("conflicted")); + assert_eq!(changes[0].status, FileStatus::Modified); + assert!( + reader + .read( + root, + &[(root.join("conflicted"), projected.into())], + &|| false + ) + .unwrap() + .is_empty() + ); + assert_eq!( + fs::read_to_string(root.join("conflicted")).unwrap(), + "resolved in working file\n" + ); + assert_eq!( + fixture_command( + root, + "jj", + &[ + "--ignore-working-copy", + "op", + "log", + "--no-graph", + "-n", + "1", + "-T", + "id" + ] + ), + operation + ); + } + } + #[test] fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { let temp = Temporary::new().unwrap(); diff --git a/src/session.rs b/src/session.rs index 9157910..2a1b5d0 100644 --- a/src/session.rs +++ b/src/session.rs @@ -170,6 +170,7 @@ struct GutterSource { revision: u64, save_revision: u64, path: PathBuf, + dirty: bool, contents: Arc, } @@ -198,6 +199,7 @@ pub struct EditorSession { gutter_request_id: u64, gutter_pending: bool, last_gutter_refresh: Option, + picker_source: Option<(usize, PathBuf)>, battery: Option, battery_enabled: bool, last_file_refresh: Instant, @@ -312,6 +314,7 @@ impl EditorSession { gutter_request_id: 0, gutter_pending: false, last_gutter_refresh: None, + picker_source: None, battery: None, battery_enabled: false, last_file_refresh: Instant::now(), @@ -356,6 +359,14 @@ impl EditorSession { } pub fn handle(&mut self, input: Input) { + if input == Input::Ctrl('r') + && self + .editor + .normal_file_picker() + .is_some_and(|picker| picker.focused) + { + self.last_gutter_refresh = None; + } self.cancel_pending_popup_request(); let invocation = self.editor.handle(input); self.synchronize_documents(); @@ -1056,21 +1067,36 @@ impl EditorSession { } fn poll_gutter_at(&mut self, now: Instant) -> bool { - if self.editor.should_quit() - || self.editor.gutter_style() == crate::gutter::GutterStyle::None + let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None; + let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) { + (Some(picker), Some((tab, root))) => { + *tab != self.editor.tab_id() || *root != picker.root + } + (None, None) => false, + _ => true, + }; + if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none()) { self.gutter_worker = None; self.gutter_sources.clear(); self.gutter_pending = false; + self.picker_source = None; return false; } let sources_changed = !self .editor .documents() + .filter(|document| line_gutter || document.dirty) .filter_map(|document| { - document - .path - .map(|path| (document.id, document.revision, document.save_revision, path)) + document.path.map(|path| { + ( + document.id, + document.revision, + document.save_revision, + path, + document.dirty, + ) + }) }) .eq(self.gutter_sources.iter().map(|source| { ( @@ -1078,17 +1104,25 @@ impl EditorSession { source.revision, source.save_revision, source.path.as_path(), + source.dirty, ) })); let refresh_due = !self.gutter_pending && self .last_gutter_refresh .is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1)); - if sources_changed || refresh_due { + if sources_changed || picker_changed || refresh_due { + if picker_changed { + self.picker_source = self + .editor + .normal_file_picker() + .map(|picker| (self.editor.tab_id(), picker.root.clone())); + } if sources_changed { self.gutter_sources = self .editor .documents() + .filter(|document| line_gutter || document.dirty) .filter_map(|document| { let path = document.path?; let contents = self @@ -1108,6 +1142,7 @@ impl EditorSession { revision: document.revision, save_revision: document.save_revision, path: path.to_path_buf(), + dirty: document.dirty, contents, }) }) @@ -1115,7 +1150,7 @@ impl EditorSession { } self.gutter_request_id = self.gutter_request_id.wrapping_add(1); self.last_gutter_refresh = Some(now); - if self.gutter_sources.is_empty() { + if self.gutter_sources.is_empty() && self.picker_source.is_none() { self.gutter_worker = None; self.gutter_pending = false; return false; @@ -1132,6 +1167,7 @@ impl EditorSession { let files = self .gutter_sources .iter() + .filter(|_| line_gutter) .map(|source| { ( source.buffer, @@ -1140,11 +1176,20 @@ impl EditorSession { ) }) .collect(); + let picker = self.picker_source.as_ref().map(|(_, root)| { + let buffers = self + .gutter_sources + .iter() + .filter(|source| source.dirty) + .map(|source| (source.path.clone(), Arc::clone(&source.contents))) + .collect(); + (root.clone(), buffers) + }); match self .gutter_worker .as_mut() .expect("gutter worker started") - .request(self.gutter_request_id, files) + .request(self.gutter_request_id, files, picker) { Ok(()) => self.gutter_pending = true, Err(error) => { @@ -1156,12 +1201,15 @@ impl EditorSession { } } let mut changed = false; - while let Some((id, result)) = self + while let Some((id, result, picker)) = self .gutter_worker .as_mut() .and_then(crate::gutter::Worker::poll) { changed |= self.apply_gutter_result(id, result); + if let Some(picker) = picker { + changed |= self.apply_picker_result(id, picker); + } } changed } @@ -1195,6 +1243,36 @@ impl EditorSession { changed } + fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool { + let Some((tab, root)) = &self.picker_source else { + return false; + }; + if id != self.gutter_request_id + || *tab != self.editor.tab_id() + || *root != reply.root + || self + .editor + .normal_file_picker() + .is_none_or(|picker| picker.root != *root) + { + return false; + } + let tab = *tab; + let mut changed = false; + let changes = match reply.result { + Ok(changes) => changes, + Err(error) => { + self.editor.set_message(format!("File status: {error}")); + changed = true; + Vec::new() + } + }; + changed + | self + .editor + .set_picker_repository_changes(tab, &reply.root, changes) + } + pub(crate) fn refresh_gutter(&mut self) -> bool { self.last_gutter_refresh = None; self.poll_gutter_at(Instant::now()) @@ -1399,6 +1477,100 @@ mod tests { sync::atomic::{AtomicU64, Ordering}, }; + #[test] + fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() { + use crate::diff::FileStatus; + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base\n"); + fixture.file("clean.txt", "clean\n"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .current_dir(&fixture.0) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Author") + .env("GIT_AUTHOR_EMAIL", "author@example.com") + .env("GIT_COMMITTER_NAME", "Author") + .env("GIT_COMMITTER_EMAIL", "author@example.com") + .env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-q", "--template="]); + git(&["add", "buffer.txt", "clean.txt"]); + git(&["commit", "-qm", "baseline"]); + fixture.file("buffer.txt", "disk\n"); + fixture.file("added.txt", "new\n"); + let mut session = fixture.open( + "Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n", + ); + session.editor.set_project_root(fixture.0.clone()); + let now = session.last_file_refresh; + let open = |session: &mut EditorSession| { + session.handle(Input::Char(' ')); + session.handle(Input::Char('o')); + }; + let ready = |session: &mut EditorSession, clock: Instant, expected| { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + session.poll_gutter_at(clock); + let picker = session.editor.normal_file_picker().unwrap(); + let status = picker + .entries() + .find(|row| row.path == Path::new("buffer.txt")) + .expect("normal picker retained the buffer file") + .status; + if !session.gutter_pending && status == expected { + assert!( + picker.entries().any(|row| { + row.path == Path::new("clean.txt") && row.status.is_none() + }) + ); + assert!(picker.entries().any(|row| { + row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added) + })); + break; + } + assert!( + Instant::now() < deadline, + "normal picker status did not settle: {}", + session.editor.message() + ); + thread::sleep(Duration::from_millis(5)); + } + }; + open(&mut session); + ready(&mut session, now, Some(FileStatus::Modified)); + session.handle(Input::Ctrl('w')); + session.poll_gutter_at(now); + session + .editor + .apply(vec![Effect::Replace("base\n".into())]) + .unwrap(); + assert!(session.editor.dirty()); + open(&mut session); + ready(&mut session, now, None); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "disk\n" + ); + session.handle(Input::Ctrl('w')); + session.poll_gutter_at(now); + session.handle(Input::Char('u')); + open(&mut session); + ready(&mut session, now, Some(FileStatus::Modified)); + git(&["add", "buffer.txt"]); + git(&["commit", "-qm", "new baseline"]); + ready(&mut session, now + Duration::from_secs(1), None); + } + #[test] fn gutter_results_follow_buffer_identity_and_reject_stale_edits() { use crate::gutter::Change; diff --git a/src/tui.rs b/src/tui.rs index a89bfdf..e9fd180 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4269,6 +4269,7 @@ fn draw_file_list( ); } let entries = picker.entries(); + let normal_markers = changed_files.is_none() && picker.has_repository_changes(); if entries.len() == 0 { frame.render_widget( Paragraph::new(if picker.query.is_empty() { @@ -4304,6 +4305,10 @@ fn draw_file_list( let changed = entry .catalog_index .and_then(|index| changed_files.and_then(|(files, _)| files.get(index))); + let status = changed.map(|file| file.status).or(entry.status); + let old_path = changed + .and_then(|file| file.old_path.as_deref()) + .or(entry.old_path); let prefix = format!( "{} {:indent$}", if selected { ">" } else { " " }, @@ -4314,9 +4319,9 @@ fn draw_file_list( 0 }, ); - let mut spans = Vec::with_capacity(if changed.is_some() { 7 } else { 3 }); + let mut spans = Vec::with_capacity(if status.is_some() { 7 } else { 3 }); spans.push(Span::raw(prefix)); - if let Some(file) = changed { + if changed.is_some() { let reviewed = entry.catalog_index.is_some_and(|index| { changed_files.is_some_and(|(_, reviewed)| reviewed.contains(&index)) }); @@ -4324,25 +4329,32 @@ fn draw_file_list( if reviewed { "✓ " } else { " " }, Style::default().fg(Color::Green), )); - let color = match file.status { + } + if let Some(status) = status { + let color = match status { FileStatus::Added => Color::Green, FileStatus::Removed => Color::Red, FileStatus::Modified => Color::Yellow, FileStatus::Renamed => Color::Cyan, }; spans.push(Span::styled( - diff_status(file.status), + diff_status(status), Style::default().fg(color), )); spans.push(Span::raw(" ")); + } else if normal_markers { + spans.push(Span::raw(" ")); } - if let Some(file) = changed - && file.status == FileStatus::Renamed - && let Some(old_path) = &file.old_path + if status == Some(FileStatus::Renamed) + && let Some(old_path) = old_path { spans.push(Span::raw(display_text(&old_path.to_string_lossy()))); spans.push(Span::raw(" -> ")); - spans.push(Span::raw(display_text(&file.path.to_string_lossy()))); + spans.push(Span::raw(display_text( + &changed + .map_or(entry.path, |file| file.path.as_path()) + .to_string_lossy(), + ))); } else { spans.push(Span::raw(entry.label)); if entry.is_dir && picker.view == FileView::Tree { @@ -5956,6 +5968,249 @@ mod tests { } } + #[test] + fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() { + use crate::repository::WorkingFileChange; + + let directory = Directory::new(); + let changes: Vec<_> = [ + ("z.txt", FileStatus::Added), + ("chain/a/m.txt", FileStatus::Modified), + ("gone/deep/d.txt", FileStatus::Removed), + ("r.txt", FileStatus::Renamed), + ] + .into_iter() + .map(|(path, status)| { + if status != FileStatus::Removed { + directory.file(path, ""); + } + WorkingFileChange { + path: path.into(), + status, + old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()), + } + }) + .collect(); + directory.file("clean.txt", ""); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + picker.set_repository_changes(changes.clone()); + for change in &changes { + picker.reveal_path(&change.path); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); + for presentation in [FilePresentation::Floating, FilePresentation::Docked] { + picker.presentation = presentation; + for view in [FileView::Tree, FileView::List] { + picker.set_view(view); + for focused in [true, false] { + picker.focused = focused; + for selected in changes + .iter() + .map(|change| change.path.as_path()) + .chain(std::iter::once(std::path::Path::new("clean.txt"))) + { + picker.reveal_path(selected); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("clean.txt")); + assert!(!text.contains('✓')); + assert!(!text.contains("old\nname.txt")); + let buffer = terminal.backend().buffer(); + for (change, color) in changes.iter().zip([ + Color::Green, + Color::Yellow, + Color::Red, + Color::Cyan, + ]) { + let path = if view == FileView::Tree { + std::path::Path::new(change.path.file_name().unwrap()) + } else { + change.path.as_path() + }; + let expected = if change.status == FileStatus::Renamed { + "R old�name.txt -> r.txt".to_owned() + } else { + format!("{} {}", diff_status(change.status), path.to_string_lossy()) + }; + let (x, y) = (0..24) + .find_map(|y| { + let row = (0..100) + .map(|x| buffer[(x, y)].symbol()) + .collect::(); + row.find(&expected).map(|offset| { + (UnicodeWidthStr::width(&row[..offset]) as u16, y) + }) + }) + .unwrap_or_else(|| panic!("missing {expected}")); + assert_eq!(buffer[(x, y)].fg, color); + assert_eq!( + buffer[(x, y)].bg, + if change.path == selected { + if focused { + Color::Blue + } else { + Color::DarkGray + } + } else { + Color::Reset + } + ); + } + let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap(); + assert!(clean_row.contains( + if selected == std::path::Path::new("clean.txt") { + "> clean.txt" + } else { + " clean.txt" + } + )); + } + for query in ["m.txt", "clean"] { + picker.query = query.into(); + picker.refilter(); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + if query == "m.txt" { + assert!(text.contains("M ")); + assert!(!text.contains("clean.txt")); + } else { + assert!(text.contains("> clean.txt")); + assert!(!text.contains("M ")); + } + assert!(!text.contains('✓')); + picker.clear_filter(); + } + } + } + } + picker.refresh(); + assert!(picker.has_repository_changes()); + assert!( + picker + .entries() + .any(|row| row.status == Some(FileStatus::Renamed)) + ); + picker.set_repository_changes(Vec::new()); + picker.set_view(FileView::List); + picker.reveal_path(std::path::Path::new("clean.txt")); + terminal + .draw(|frame| { + draw_file_list( + frame, + &picker, + frame.area(), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("> clean.txt")); + assert!(text.contains("r.txt")); + assert!(!text.contains("old�name.txt")); + assert!(!text.contains("d.txt")); + assert!(!text.contains('✓')); + } + + #[test] + fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() { + let directory = Directory::new(); + directory.file("界.txt", ""); + let mut picker = FilePicker::scan( + directory.0.clone(), + FilePresentation::Floating, + FileView::List, + ) + .unwrap(); + picker.set_repository_changes(vec![crate::repository::WorkingFileChange { + path: "界.txt".into(), + status: FileStatus::Modified, + old_path: None, + }]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); + for focused in [true, false] { + picker.focused = focused; + for height in 0..=3 { + for width in 0..=8 { + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new( + "XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX", + ), + frame.area(), + ); + draw_file_list( + frame, + &picker, + Rect::new(0, 0, width, height), + "", + &mut WheelPanels::default(), + None, + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + for y in 0..4 { + for x in 0..12 { + if x >= width || y >= height { + assert_eq!(buffer[(x, y)].symbol(), "X"); + } + } + } + if height < 2 { + continue; + } + for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() { + assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string()); + } + if width >= 3 { + assert_eq!(buffer[(2, 1)].fg, Color::Yellow); + assert_eq!( + buffer[(2, 1)].bg, + if focused { + Color::Blue + } else { + Color::DarkGray + } + ); + } + if width >= 6 { + assert_eq!(buffer[(4, 1)].symbol(), "界"); + } + } + } + } + } + #[test] fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() { let mut diff = diff_comparison(vec![deletion_file()]);