From b6f471ed8959b8371760387a9c68ec100d750f62 Mon Sep 17 00:00:00 2001 From: Lars Jansen Date: Sat, 31 Oct 2026 11:13:04 +0100 Subject: [PATCH 1/4] feat: reload by itself when the working tree or refs change --- CONTRIBUTING.md | 6 +- Cargo.lock | 180 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- README.md | 2 + ROADMAP.md | 32 +++---- crates/app/src/app.rs | 69 ++++++++++++--- crates/app/src/tests.rs | 25 ++++++ crates/app/src/ui.rs | 4 + crates/core/Cargo.toml | 1 + crates/core/src/error.rs | 7 ++ crates/core/src/lib.rs | 1 + crates/core/src/status.rs | 5 +- crates/core/src/watch.rs | 90 +++++++++++++++++++ crates/core/tests/repo.rs | 66 ++++++++++++++ 14 files changed, 461 insertions(+), 30 deletions(-) create mode 100644 crates/core/src/watch.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06ee4eb..fcc339b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,9 @@ There are two crates. working without code of our own. - `status.rs` and `diff.rs` parse `git status --porcelain=v2` and unified diffs, and build the patches for staging single lines. +- `watch.rs` watches the working tree and the refs, and says when something changed that git + does not ignore. Reading should not write (`git status` runs without optional locks): a write + wakes the watcher again. Its tests run against real repositories made with `git` in a temporary folder (`crates/core/tests`), plus property tests for the graph layout. @@ -65,7 +68,8 @@ Its tests run against real repositories made with `git` in a temporary folder - `app.rs` holds the state. Views never change it directly: they push an `Action`, which `Workspace::apply` carries out after the frame. Anything that touches the repository runs on a - background thread and is followed by a reload, so the window never waits. + background thread and is followed by a reload, so the window never waits. The tab on screen + also reloads by itself when its watcher saw a change. - `start.rs` (open, clone and create), `ui.rs` (tabs and toolbar), `graph.rs`, `sidebar.rs`, `details.rs` and `diff.rs` draw the views, and `theme.rs` has the colours. diff --git a/Cargo.lock b/Cargo.lock index 374838c..c8863eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1008,6 +1008,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -1161,6 +1171,7 @@ version = "0.3.0" dependencies = [ "criterion", "gix", + "notify", "proptest", "tempfile", "thiserror 2.0.21", @@ -1183,6 +1194,8 @@ dependencies = [ "gix-glob", "gix-hash", "gix-hashtable", + "gix-ignore", + "gix-index", "gix-lock", "gix-note", "gix-object", @@ -1204,6 +1217,7 @@ dependencies = [ "gix-url", "gix-utils", "gix-validate", + "gix-worktree", "gix-worktree-stream", "gix-zlib", "nonempty", @@ -1238,6 +1252,15 @@ dependencies = [ "unicode-bom", ] +[[package]] +name = "gix-bitmap" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422cd23418fd80e9e965595323975d72abd6364a66ff8f0daade5c105a7f35d2" +dependencies = [ + "gix-error", +] + [[package]] name = "gix-chunk" version = "0.10.1" @@ -1429,6 +1452,48 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "gix-ignore" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "968f12fe42896873504f46127cd5bf0f9a34fc1479a781f43cf94b5d8d760a15" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-index" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bfc736774a623307eda890c918d308ef5acd37402bde2d6768fccb1f6ecb5d" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-trace", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix 1.1.6", + "smallvec", +] + [[package]] name = "gix-lock" version = "26.0.1" @@ -1776,6 +1841,23 @@ dependencies = [ "gix-error", ] +[[package]] +name = "gix-worktree" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe92aec0a25cf073394492a6ee2aa4d27632acde91a653d34de0e4da6afb485d" +dependencies = [ + "bstr", + "gix-error", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", +] + [[package]] name = "gix-worktree-stream" version = "0.39.0" @@ -1979,6 +2061,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2022,6 +2106,26 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2195,6 +2299,26 @@ dependencies = [ "accesskit_consumer", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2315,6 +2439,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2419,6 +2555,34 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "notify" +version = "8.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" +dependencies = [ + "bitflags 2.13.2", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify-types" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2590,6 +2754,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3761,6 +3935,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/Cargo.toml b/Cargo.toml index b3ceb3e..5da8024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ repository = "https://github.com/Lars-/gitcraft" [workspace.dependencies] gitcraft-core = { path = "crates/core", version = "0.3.0" } -gix = { version = "0.90", default-features = false, features = ["max-performance", "sha1"] } +gix = { version = "0.90", default-features = false, features = ["max-performance", "sha1", "excludes"] } +notify = "8.2" thiserror = "2" eframe = { version = "0.37", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "persistence"] } egui_kittest = "0.37" diff --git a/README.md b/README.md index 517a985..b98b9bd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ with that one. - **Start**: open a folder, clone from a URL with git's progress as it goes, or create a new repository. - **Tabs**: keep several repositories open at once; they open again where you left them. +- **Live**: what you change in an editor, a build or a terminal shows up by itself; files git + ignores do not wake it. - **Graph**: every branch, remote branch and tag in one graph, children above parents, with coloured lanes, branch and tag labels and the uncommitted changes on top. Arrow keys walk it. - **Commits**: message, author, parents and the files they changed, with the diff of each file. diff --git a/ROADMAP.md b/ROADMAP.md index 18a9fba..4b542e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,28 +21,28 @@ drive it the way a user does, and keeps every benchmark as fast as before. - [x] Refresh when the window gets focus - [x] Clone and init from the start screen, with progress - [x] Tabs: several repositories open at once, remembered between runs +- [x] Live refresh: watch the working tree and refs, and reload by itself ## Next -1. **Live refresh**: watch the working tree and refs, and reload by itself. -2. **Discard** changes per file, hunk or line. -3. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push. -4. **Undo and redo** of the last action, through the reflog. -5. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result), +1. **Discard** changes per file, hunk or line. +2. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push. +3. **Undo and redo** of the last action, through the reflog. +4. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result), mark them resolved, and continue or abort a merge, rebase or cherry-pick. -6. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the +5. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the graph. -7. **Credentials**: ask for a username, password or passphrase when git needs one. -8. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase. -9. **Remotes**: add, edit and remove. -10. **Search the graph** by message, author or SHA, and jump to any branch or tag. -11. **File history and blame.** -12. **Better diffs**: side by side, word-level changes, syntax colouring, images. -13. **Keyboard**: shortcuts for everything, and a command palette. -14. **Settings**: light and dark theme, font size, date format, default pull mode. -15. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default +6. **Credentials**: ask for a username, password or passphrase when git needs one. +7. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase. +8. **Remotes**: add, edit and remove. +9. **Search the graph** by message, author or SHA, and jump to any branch or tag. +10. **File history and blame.** +11. **Better diffs**: side by side, word-level changes, syntax colouring, images. +12. **Keyboard**: shortcuts for everything, and a command palette. +13. **Settings**: light and dark theme, font size, date format, default pull mode. +14. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default over the network). -16. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux +15. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux (AppImage, .deb, Flatpak), signed where the platform expects it. ## After 1.0 diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 47240b3..9291394 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -6,13 +6,17 @@ use std::collections::{BTreeSet, HashMap}; use std::ops::Range; use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::time::{Duration, Instant}; use eframe::egui; use gitcraft_core::diff::{self, FileDiff, Source}; use gitcraft_core::git::Op; use gitcraft_core::repo::{Commit, History, Repo}; use gitcraft_core::status::{self, FileChange, Status}; +use gitcraft_core::watch::Watch; use gitcraft_core::{ObjectId, Result}; /// What the user asked for. @@ -121,13 +125,14 @@ impl Commits { } } -type Outcome = (Option, Result); +type Outcome = (Option, Result, Option>); /// An operation running in the background, followed by a reload. #[derive(Debug)] struct Job { op: Option, outcome: Receiver, + started: Instant, } /// An open repository. @@ -160,6 +165,12 @@ pub struct Workspace { /// The graph rows on screen. pub visible: Range, job: Option, + /// Set when something changed since the last reload began. + changed: Arc, + /// Keeps `changed` up to date. + watch: Option, + /// When the next reload may start by itself. + quiet_until: Instant, } impl Workspace { @@ -180,8 +191,11 @@ impl Workspace { scroll_to: None, visible: 0..0, job: None, + changed: Arc::default(), + watch: None, + quiet_until: Instant::now(), }; - workspace.start(ctx, None); + workspace.spawn(ctx, None, true); workspace } @@ -250,30 +264,58 @@ impl Workspace { } } - fn start(&mut self, ctx: &egui::Context, op: Option) { - if self.job.is_some() { + /// Reloads when something changed, leaving at least as much time between two reloads as the + /// last one took, so that a build or a long checkout never keeps the window busy. + pub fn reload_if_changed(&mut self, ctx: &egui::Context) { + if self.job.is_some() || !self.changed.load(Ordering::Relaxed) { return; } + match self.quiet_until.checked_duration_since(Instant::now()) { + Some(wait) => ctx.request_repaint_after(wait), + None => self.start(ctx, None), + } + } + + fn start(&mut self, ctx: &egui::Context, op: Option) { + if self.job.is_none() { + self.spawn(ctx, op, false); + } + } + + /// Runs `op` and reloads, in the background. The first job also starts watching, which takes + /// a while in a large working tree. + fn spawn(&mut self, ctx: &egui::Context, op: Option, watch: bool) { let (send, outcome) = mpsc::channel(); - let (repo, ctx, job_op) = (self.repo.clone(), ctx.clone(), op.clone()); + let (repo, ctx, job_op, changed) = (self.repo.clone(), ctx.clone(), op.clone(), self.changed.clone()); std::thread::spawn(move || { + let watch = watch.then(|| { + let (changed, ctx) = (changed.clone(), ctx.clone()); + repo.watch(move || { + changed.store(true, Ordering::Relaxed); + ctx.request_repaint(); + }) + }); let failed = job_op.and_then(|op| repo.git.apply(&op).err()).map(|error| error.to_string()); + // The reload sees every change made until here. + changed.store(false, Ordering::Relaxed); // Sending fails only when the repository was closed in the meantime. - let _ = send.send((failed, Snapshot::load(&repo))); + let _ = send.send((failed, Snapshot::load(&repo), watch)); ctx.request_repaint(); }); - self.job = Some(Job { op, outcome }); + self.job = Some(Job { op, outcome, started: Instant::now() }); } /// Takes in the result of the background job, once it is there. pub fn poll(&mut self) { let Some(job) = &self.job else { return }; - let (failed, snapshot) = match job.outcome.try_recv() { + let (failed, snapshot, watch) = match job.outcome.try_recv() { Err(TryRecvError::Empty) => return, - Err(TryRecvError::Disconnected) => (Some("The background job stopped unexpectedly".to_owned()), None), - Ok((failed, snapshot)) => (failed, Some(snapshot)), + Err(TryRecvError::Disconnected) => (Some("The background job stopped unexpectedly".to_owned()), None, None), + Ok((failed, snapshot, watch)) => (failed, Some(snapshot), watch), }; - let op = self.job.take().and_then(|job| job.op); + let job = self.job.take().expect("a job is running"); + self.quiet_until = Instant::now() + job.started.elapsed().max(Duration::from_millis(100)); + let op = job.op; if failed.is_none() && matches!(op, Some(Op::Commit { .. })) { self.summary.clear(); self.description.clear(); @@ -285,6 +327,11 @@ impl Workspace { Some(Err(error)) => self.error = Some(error.to_string()), None => {} } + match watch { + Some(Ok(watch)) => self.watch = Some(watch), + Some(Err(error)) => self.error = Some(error.to_string()), + None => {} + } if self.selection == Some(Selection::Wip) && self.snapshot.status.is_clean() || self.selection.is_none() { let head = self.snapshot.history.head.map(|row| self.snapshot.history.ids[row]); match head { diff --git a/crates/app/src/tests.rs b/crates/app/src/tests.rs index d884738..9f1d183 100644 --- a/crates/app/src/tests.rs +++ b/crates/app/src/tests.rs @@ -60,6 +60,16 @@ fn settle(harness: &mut Harness<'_, App>) { harness.run(); } +/// Steps the app until `shown`, as the watcher wakes it. +fn wait_until(harness: &mut Harness<'_, App>, what: &str, shown: impl Fn(&Harness<'_, App>) -> bool) { + let start = Instant::now(); + while !shown(harness) { + assert!(start.elapsed() < Duration::from_secs(10), "{what} never showed up"); + std::thread::sleep(Duration::from_millis(5)); + harness.step(); + } +} + fn busy(app: &App) -> bool { app.tabs.iter().any(|ws| ws.busy().is_some()) || app.start.dialog.as_ref().is_some_and(crate::start::Dialog::busy) } @@ -186,6 +196,21 @@ fn stage_and_commit_a_change() { assert!(harness.query_by_label("Uncommitted changes").is_none()); } +#[test] +fn changes_made_outside_show_up_by_themselves() { + let (_dir, git) = repo(); + let mut harness = app(Some(git.dir())); + + fs::write(git.dir().join("file.txt"), "edited elsewhere").unwrap(); + wait_until(&mut harness, "the edit", |h| h.query_by_label("Uncommitted changes").is_some()); + commit(&git, "Committed elsewhere"); + wait_until(&mut harness, "the commit", |h| { + h.query_by_role_and_label(Role::Button, "Committed elsewhere").is_some() + }); + settle(&mut harness); + assert!(harness.query_by_label("Uncommitted changes").is_none()); +} + #[test] fn arrow_keys_walk_the_history() { let (_dir, git) = repo(); diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index c9ee031..fc707e1 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -98,6 +98,10 @@ impl App { self.open(&ctx, &path); } self.tabs.iter_mut().for_each(Workspace::poll); + // A tab out of sight reloads when it is shown. + if let Some(ws) = self.workspace_mut() { + ws.reload_if_changed(&ctx); + } let mut actions = Vec::new(); if !self.tabs.is_empty() { egui::Panel::top("tabs").show(ui, |ui| tabs(ui, &self.tabs, self.active, &mut actions)); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 73648fa..85753c1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] gix.workspace = true +notify.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 4016013..13784fd 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -18,12 +18,19 @@ pub enum Error { /// `git` printed something we do not understand. #[error("unexpected output from git: {0}")] Parse(String), + /// Watching the repository for changes failed. + #[error("could not watch for changes: {0}")] + Watch(Box), } impl Error { pub(crate) fn repository(error: impl std::error::Error + Send + Sync + 'static) -> Self { Self::Repository(Box::new(error)) } + + pub(crate) fn watch(error: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::Watch(Box::new(error)) + } } /// The result of everything in this crate. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 758c0b3..a85e587 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,6 +11,7 @@ pub mod git; pub mod graph; pub mod repo; pub mod status; +pub mod watch; pub use error::{Error, Result}; pub use gix::{ObjectId, date}; diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index a2974ff..7ab1203 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -69,7 +69,10 @@ pub struct Status { impl Status { /// Reads the status of the repository. pub fn read(git: &Git) -> Result { - Self::parse(&git.run(&["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all"])?) + // Without optional locks, `git status` leaves the index alone: it neither wakes the watcher + // nor holds a lock that makes the user's own `git` fail. + let args = ["--no-optional-locks", "status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all"]; + Self::parse(&git.run(&args)?) } /// Whether nothing changed. diff --git a/crates/core/src/watch.rs b/crates/core/src/watch.rs new file mode 100644 index 0000000..86e6edb --- /dev/null +++ b/crates/core/src/watch.rs @@ -0,0 +1,90 @@ +//! Noticing changes made outside GitCraft: by an editor, a build or `git` in a terminal. + +use std::path::{Path, PathBuf}; + +use gix::worktree::Stack; +use gix::worktree::stack::state::ignore::Source; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; + +use crate::repo::Repo; +use crate::{Error, Result}; + +/// Watches a repository until it is dropped. +#[derive(Debug)] +pub struct Watch { + _watcher: RecommendedWatcher, +} + +impl Repo { + /// Calls `changed` whenever something changes that GitCraft shows: a file in the working tree + /// that git does not ignore, a branch, a tag, `HEAD` or the index. Reading files does not + /// count. + pub fn watch(&self, changed: impl Fn() + Send + 'static) -> Result { + let canonical = |dir: &Path| dir.canonicalize().map_err(Error::watch); + let git_dirs = vec![canonical(self.gix.git_dir())?, canonical(self.gix.common_dir())?]; + let workdir = self.gix.workdir().map(canonical).transpose()?; + let mut dirs: Vec<_> = workdir.iter().chain(&git_dirs).cloned().collect(); + // A folder inside another one is watched with it. + dirs.sort(); + dirs.dedup_by(|dir, outer| dir.starts_with(outer)); + let mut filter = Filter { repo: self.gix.clone(), git_dirs, workdir, ignored: None }; + let mut watcher = notify::recommended_watcher(move |event| { + if filter.matters(event) { + changed(); + } + }) + .map_err(Error::watch)?; + for dir in dirs { + watcher.watch(&dir, RecursiveMode::Recursive).map_err(Error::watch)?; + } + Ok(Watch { _watcher: watcher }) + } +} + +/// Tells the changes that matter from the ones that do not. +struct Filter { + repo: gix::Repository, + git_dirs: Vec, + workdir: Option, + /// The ignore rules, read when first needed and again after they change. + ignored: Option, +} + +impl Filter { + fn matters(&mut self, event: notify::Result) -> bool { + // When the events themselves went wrong, anything may have changed. + let Ok(event) = event else { return true }; + event.need_rescan() || !matches!(event.kind, EventKind::Access(_)) && event.paths.iter().any(|p| self.path(p)) + } + + fn path(&mut self, path: &Path) -> bool { + if let Some(inside) = self.git_dirs.iter().find_map(|dir| path.strip_prefix(dir).ok()) { + if inside == Path::new("info/exclude") { + self.ignored = None; + } + // Objects and logs change along with a ref; a lock file just before the file it locks. + return !(inside.starts_with("objects") + || inside.starts_with("logs") + || path.extension().is_some_and(|e| e == "lock")); + } + let Some(relative) = self.workdir.as_deref().and_then(|dir| path.strip_prefix(dir).ok()) else { + return true; + }; + if path.file_name().is_some_and(|name| name == ".gitignore") { + self.ignored = None; + } + // A file whose rules cannot be read counts as not ignored: reloading is always safe. + !self.is_ignored(relative).unwrap_or(false) + } + + fn is_ignored(&mut self, relative: &Path) -> Result { + let stack = if let Some(stack) = &mut self.ignored { + stack + } else { + let index = self.repo.index_or_empty().map_err(Error::repository)?; + let rules = self.repo.excludes(&index, None, Source::default()).map_err(Error::repository)?; + self.ignored.insert(rules.detach()) + }; + Ok(stack.at_path(relative, None, &self.repo.objects)?.is_excluded()) + } +} diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index df425d9..2dec8f9 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -1,11 +1,14 @@ //! End-to-end tests against real repositories made with the installed `git`. use std::fs; +use std::sync::mpsc::{self, Receiver}; +use std::time::Duration; use gitcraft_core::diff::{self, FileDiff, LineKind, Source}; use gitcraft_core::git::{self, Git, Op, ResetMode}; use gitcraft_core::repo::{Label, RefKind, Repo}; use gitcraft_core::status::{self, Change, Status}; +use gitcraft_core::watch::Watch; use gitcraft_core::{Error, ObjectId}; use tempfile::TempDir; @@ -319,3 +322,66 @@ fn clone_picks_the_folder_name_git_would() { assert_eq!(git::clone_name(url), name, "{url}"); } } + +/// Watches the repository, with a message for every change it reports. +fn watch(git: &Git) -> (Watch, Receiver<()>) { + let (send, changes) = mpsc::channel(); + let watch = Repo::open(git.dir()) + .unwrap() + .watch(move || { + // Sending fails only once the test is over. + let _ = send.send(()); + }) + .unwrap(); + (watch, changes) +} + +/// Whether a change is reported within `time`. Forgets the rest of the burst, so the next +/// question starts afresh. +fn noticed(changes: &Receiver<()>, time: Duration) -> bool { + let noticed = changes.recv_timeout(time).is_ok(); + std::thread::sleep(Duration::from_millis(200)); + while changes.try_recv().is_ok() {} + noticed +} + +const SOON: Duration = Duration::from_secs(10); +const QUIET: Duration = Duration::from_millis(500); + +#[test] +fn changes_made_outside_are_noticed() { + let (_dir, git) = repo(); + commit(&git, "a.txt", "a\n", "Root"); + let (_watch, changes) = watch(&git); + + write(&git, "a.txt", "edited\n"); + assert!(noticed(&changes, SOON), "an edited file"); + git.run(&["add", "a.txt"]).unwrap(); + assert!(noticed(&changes, SOON), "staged in a terminal"); + git.run(&["branch", "feature"]).unwrap(); + assert!(noticed(&changes, SOON), "a new branch"); + git.run(&["checkout", "-q", "feature"]).unwrap(); + assert!(noticed(&changes, SOON), "another branch checked out"); +} + +#[test] +fn ignored_files_and_reading_are_not_noticed() { + let (_dir, git) = repo(); + commit(&git, ".gitignore", "target/\n", "Ignore builds"); + fs::create_dir(git.dir().join("target")).unwrap(); + write(&git, "new.txt", "untracked\n"); + let repo = Repo::open(git.dir()).unwrap(); + let (_watch, changes) = watch(&git); + + write(&git, "target/build.log", "compiling\n"); + repo.history().unwrap(); + Status::read(&git).unwrap(); + status::stashes(&git).unwrap(); + diff::read(&git, &Source::Untracked("new.txt".into())).unwrap(); + assert!(!noticed(&changes, QUIET)); + + write(&git, ".gitignore", ""); + assert!(noticed(&changes, SOON), "the ignore rules changed"); + write(&git, "target/build.log", "done\n"); + assert!(noticed(&changes, SOON), "no longer ignored"); +} From 8393b2c45c97d14be5a4246c0b7053a05b426651 Mon Sep 17 00:00:00 2001 From: Lars Jansen Date: Sat, 31 Oct 2026 11:20:49 +0100 Subject: [PATCH 2/4] test: print watch events to compare platforms --- crates/core/src/watch.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/core/src/watch.rs b/crates/core/src/watch.rs index 86e6edb..c5c6dbf 100644 --- a/crates/core/src/watch.rs +++ b/crates/core/src/watch.rs @@ -54,7 +54,11 @@ impl Filter { fn matters(&mut self, event: notify::Result) -> bool { // When the events themselves went wrong, anything may have changed. let Ok(event) = event else { return true }; - event.need_rescan() || !matches!(event.kind, EventKind::Access(_)) && event.paths.iter().any(|p| self.path(p)) + let debug = format!("{event:?}"); + let result = event.need_rescan() + || !matches!(event.kind, EventKind::Access(_)) && event.paths.iter().any(|p| self.path(p)); + eprintln!("WATCH {result} {debug} dirs={:?} workdir={:?}", self.git_dirs, self.workdir); + result } fn path(&mut self, path: &Path) -> bool { From c139c58240c8216522cc4b02274bd73ea1ce2c1e Mon Sep 17 00:00:00 2001 From: Lars Jansen Date: Sat, 31 Oct 2026 11:27:01 +0100 Subject: [PATCH 3/4] test: print watch events with times --- crates/core/src/watch.rs | 3 ++- crates/core/tests/repo.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/core/src/watch.rs b/crates/core/src/watch.rs index c5c6dbf..3a9f90c 100644 --- a/crates/core/src/watch.rs +++ b/crates/core/src/watch.rs @@ -57,7 +57,8 @@ impl Filter { let debug = format!("{event:?}"); let result = event.need_rescan() || !matches!(event.kind, EventKind::Access(_)) && event.paths.iter().any(|p| self.path(p)); - eprintln!("WATCH {result} {debug} dirs={:?} workdir={:?}", self.git_dirs, self.workdir); + let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100_000; + std::io::Write::write_all(&mut std::io::stderr(), format!("WATCH {ms} {result} {debug}\n").as_bytes()).unwrap(); result } diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index 2dec8f9..3c3e8aa 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -339,6 +339,7 @@ fn watch(git: &Git) -> (Watch, Receiver<()>) { /// Whether a change is reported within `time`. Forgets the rest of the burst, so the next /// question starts afresh. fn noticed(changes: &Receiver<()>, time: Duration) -> bool { + phase("noticed? start"); let noticed = changes.recv_timeout(time).is_ok(); std::thread::sleep(Duration::from_millis(200)); while changes.try_recv().is_ok() {} @@ -372,12 +373,14 @@ fn ignored_files_and_reading_are_not_noticed() { write(&git, "new.txt", "untracked\n"); let repo = Repo::open(git.dir()).unwrap(); let (_watch, changes) = watch(&git); + phase("IGNORED watch started"); write(&git, "target/build.log", "compiling\n"); repo.history().unwrap(); Status::read(&git).unwrap(); status::stashes(&git).unwrap(); diff::read(&git, &Source::Untracked("new.txt".into())).unwrap(); + phase("IGNORED reads done"); assert!(!noticed(&changes, QUIET)); write(&git, ".gitignore", ""); @@ -385,3 +388,8 @@ fn ignored_files_and_reading_are_not_noticed() { write(&git, "target/build.log", "done\n"); assert!(noticed(&changes, SOON), "no longer ignored"); } + +fn phase(name: &str) { + let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100_000; + std::io::Write::write_all(&mut std::io::stderr(), format!("PHASE {ms} {name}\n").as_bytes()).unwrap(); +} From 6e573be2a3b34985136eb51b1c9896ead35ddab0 Mon Sep 17 00:00:00 2001 From: Lars Jansen Date: Sat, 31 Oct 2026 11:29:25 +0100 Subject: [PATCH 4/4] test: let early macOS reports pass before checking what is noticed --- crates/core/src/watch.rs | 7 +------ crates/core/tests/repo.rs | 10 ++-------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/crates/core/src/watch.rs b/crates/core/src/watch.rs index 3a9f90c..86e6edb 100644 --- a/crates/core/src/watch.rs +++ b/crates/core/src/watch.rs @@ -54,12 +54,7 @@ impl Filter { fn matters(&mut self, event: notify::Result) -> bool { // When the events themselves went wrong, anything may have changed. let Ok(event) = event else { return true }; - let debug = format!("{event:?}"); - let result = event.need_rescan() - || !matches!(event.kind, EventKind::Access(_)) && event.paths.iter().any(|p| self.path(p)); - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100_000; - std::io::Write::write_all(&mut std::io::stderr(), format!("WATCH {ms} {result} {debug}\n").as_bytes()).unwrap(); - result + event.need_rescan() || !matches!(event.kind, EventKind::Access(_)) && event.paths.iter().any(|p| self.path(p)) } fn path(&mut self, path: &Path) -> bool { diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index 3c3e8aa..71c5a44 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -333,13 +333,14 @@ fn watch(git: &Git) -> (Watch, Receiver<()>) { let _ = send.send(()); }) .unwrap(); + // macOS may still report changes made just before watching began. + noticed(&changes, QUIET); (watch, changes) } /// Whether a change is reported within `time`. Forgets the rest of the burst, so the next /// question starts afresh. fn noticed(changes: &Receiver<()>, time: Duration) -> bool { - phase("noticed? start"); let noticed = changes.recv_timeout(time).is_ok(); std::thread::sleep(Duration::from_millis(200)); while changes.try_recv().is_ok() {} @@ -373,14 +374,12 @@ fn ignored_files_and_reading_are_not_noticed() { write(&git, "new.txt", "untracked\n"); let repo = Repo::open(git.dir()).unwrap(); let (_watch, changes) = watch(&git); - phase("IGNORED watch started"); write(&git, "target/build.log", "compiling\n"); repo.history().unwrap(); Status::read(&git).unwrap(); status::stashes(&git).unwrap(); diff::read(&git, &Source::Untracked("new.txt".into())).unwrap(); - phase("IGNORED reads done"); assert!(!noticed(&changes, QUIET)); write(&git, ".gitignore", ""); @@ -388,8 +387,3 @@ fn ignored_files_and_reading_are_not_noticed() { write(&git, "target/build.log", "done\n"); assert!(noticed(&changes, SOON), "no longer ignored"); } - -fn phase(name: &str) { - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100_000; - std::io::Write::write_all(&mut std::io::stderr(), format!("PHASE {ms} {name}\n").as_bytes()).unwrap(); -}