Expand description
§vcs-watch — repo-event stream
vcs-watch filesystem-watches a git or jj repository and streams typed
state-change events — the foundation for prompts, status bars, TUIs, and
daemons. It’s built on vcs-core: on each filesystem change it
re-queries the repo’s batched snapshot, diffs it
against the previous state, and emits the deltas.
use vcs_core::Repo;
use vcs_watch::{RepoWatcher, RepoEvent};
let repo = Repo::open(".")?;
let mut watcher = RepoWatcher::watch(repo).await?;
while let Some(change) = watcher.recv().await {
for event in &change.events {
match event {
RepoEvent::HeadMoved { to, .. } => println!("head → {to:?}"),
RepoEvent::BranchCreated { name } => println!("+branch {name}"),
RepoEvent::WorkingCopyChanged { dirty, .. } => println!("dirty={dirty}"),
other => println!("{other:?}"),
}
}
// `change.snapshot` is the fresh full state — render a status line from it.
}§Why re-query + diff (not raw events)
Interpreting raw filesystem events is a trap: git writes refs through a temp-file
rename, churns index.lock, and appends to .git/logs/ constantly. vcs-watch
treats any event as “something changed — re-check”, coalesces the burst, takes
one fresh RepoSnapshot (+ the branch list), and diffs.
Noise that doesn’t change observable state produces no event. This also means
a stray event can’t desync the consumer — every emission carries the true current
state.
§Events
RepoEvent (#[non_exhaustive]), derived by diffing two snapshots:
| Event | Fires when |
|---|---|
HeadMoved { from, to } | the working-copy commit id changed (commit, checkout, reset, jj op) |
BranchSwitched { from, to } | the current branch/bookmark changed (or detached → None) |
BranchCreated { name } / BranchDeleted { name } | a local branch/bookmark appeared / was removed |
WorkingCopyChanged { dirty, change_count } | dirtiness or the changed-path count moved |
UpstreamChanged { upstream } | the upstream tracking branch changed (git only) |
AheadBehindChanged { ahead, behind } | ahead/behind vs upstream changed (git only) |
OperationChanged { from, to } | a git merge/rebase started or finished (git only) |
ConflictChanged { conflicted } | the unresolved-conflict flag toggled (both backends) |
Two semantics worth knowing:
- Conflicts →
ConflictChanged, on both backends.OperationChangedcovers only git’s merge/rebase lifecycle (Clear/Merge/Rebase); it never fires on jj.vcs-corederives jj’soperationandconflictedfrom the same bit, so a jj conflict appearing would otherwise double-signal — the redundantOperationChangedis suppressed, andConflictChangedis the one true conflict event everywhere. (A git merge that has conflicts is two distinct facts and fires bothOperationChangedandConflictChanged.) WorkingCopyChangedis dirty-flag + path count, not file identity. Swapping which file is edited while the count stays the same emits nothing (the status-line count is unchanged anyway). A consumer that needs the file set readschange.snapshot/ callsRepo::changed_files().
Each settled change arrives as a RepoChange { snapshot: RepoSnapshot, events: Vec<RepoEvent> } — events is never empty, and the events come in a stable order
(head, branch switch, created, deleted, working copy, upstream, ahead/behind,
operation, conflict; created/deleted names sorted).
§Building the watcher
let watcher = RepoWatcher::builder(repo)
.working_tree(true) // also watch the working tree
.debounce(Duration::from_millis(150)) // quiet window (default 250 ms)
.max_wait(Duration::from_secs(2)) // re-query ceiling (default 1 s)
.requery_timeout(Some(Duration::from_secs(10))) // per-query deadline (default 30 s)
.build()
.await?;Two orthogonal timing knobs are easy to confuse: max_wait bounds how long
a continuous event stream may defer a re-query (cadence under load);
requery_timeout bounds how long one re-query may run — a wedged command
(say, a held index.lock on a client with no timeout of its own) is killed and
skipped as transient instead of stalling the watch forever
(requery_timeout(None) disables it).
recv().await -> Option<RepoChange>— the next settled change;Noneonce the watcher is dropped.current() -> &RepoSnapshotis the last known state — the build-time baseline, advanced only when you pull a change (viarecvor the stream — it is as fresh as your last pull, not a live view).stats()— lock-free health counters: re-queries run, changes emitted, skips (transient failures + deadline overruns) and what the last skip failed on. A climbingskippedwith flatrequeriesmeans the repository is wedged — poll it from a health check instead of inferring health from event silence.- The
streamfeature addsimpl futures_core::Stream for RepoWatcher, so the watcher drops intotokio::select!/stream combinators directly.recv()and the stream pull from the same channel — an item is delivered to whichever is polled first, never duplicated — and both advancecurrent(). - Drop stops everything — dropping the
RepoWatcherends the OS watch and the background task.
§Watch scope — state dir vs working tree
By default the watcher monitors only the state directory (.git/.jj):
HEAD, refs, the index, packed-refs, merge/rebase markers, the jj op log. This is
cheap and robust, and catches structural changes plus anything that touches the
index (staging, commit) or a jj snapshot. A bare unstaged edit (vim file
with no git add) doesn’t touch the state dir, so it’s seen only once it’s staged
— unless you opt into working_tree(true), which also watches the working
tree recursively and fires WorkingCopyChanged immediately. The trade-off:
working-tree watching is .gitignore-unaware (it also watches target/ etc.) and
heavier on a large repo.
§Backends, colocation, worktrees
The backend (and which dir to watch) comes from vcs-core’s pure detect: .jj
for jj, .git for git, and jj wins when colocated — so a colocated repo is
watched via .jj (jj drives; its op-log change is the signal). A linked
worktree’s .git is a gitlink file; the watcher resolves it to that worktree’s
private git directory (HEAD/index) and — via its commondir file — to the
shared main .git, where branch refs (refs/heads/*, packed-refs) actually
live. Both are watched, so BranchCreated/BranchDeleted made from any checkout
are observed from a watched worktree too.
§Semantics & limits
- Transient re-query failures are skipped, not surfaced. A snapshot taken
while an operation holds
index.lockmay fail (or overrunrequery_timeout); the watcher skips that re-check and the next event re-queries the settled state. Setup failures (the watch can’t start) surface frombuild(). Skips are counted —stats()reports them (with the failure kind) — and thetracingfeature adds a debug line on each. - Runtime. Unlike the rest of the toolkit,
vcs-watchuses tokio at runtime (the watch task + debounce timer). Build/await it inside a tokio runtime.
§See also
- vcs-core guide — the
Repo/RepoSnapshotit re-queries. - Cookbook — a live status-line recipe.
- crate docs — quickstart.