Skip to main content

vcs_watch/
event.rs

1//! The typed events and the **pure** snapshot-diff that derives them.
2//!
3//! The watcher re-queries repo state on each filesystem change and diffs the new
4//! state against the old; [`diff`] turns a (previous, next) pair into the list of
5//! [`RepoEvent`]s that changed. It's pure data in, pure data out — no filesystem,
6//! no process, no async — so the load-bearing logic is hermetically unit-tested.
7
8use std::collections::BTreeSet;
9
10use vcs_core::{OperationState, RepoSnapshot};
11
12/// One typed change to a repository's observable state, derived by diffing two
13/// consecutive [`RepoSnapshot`]s (plus the branch set).
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum RepoEvent {
17    /// The working-copy commit moved (a commit, checkout, reset, `jj` op, …).
18    /// `from`/`to` are the full object ids; `None` on an unborn git repo.
19    #[non_exhaustive]
20    HeadMoved {
21        /// The previous HEAD/`@` object id.
22        from: Option<String>,
23        /// The new HEAD/`@` object id.
24        to: Option<String>,
25    },
26    /// The *current* branch (git) / bookmark (jj) changed — a switch/checkout, or
27    /// going (in)to a detached/unset state (`None`).
28    #[non_exhaustive]
29    BranchSwitched {
30        /// The previously checked-out branch/bookmark.
31        from: Option<String>,
32        /// The newly checked-out branch/bookmark.
33        to: Option<String>,
34    },
35    /// A local branch/bookmark appeared.
36    #[non_exhaustive]
37    BranchCreated {
38        /// The new branch/bookmark name.
39        name: String,
40    },
41    /// A local branch/bookmark was removed.
42    #[non_exhaustive]
43    BranchDeleted {
44        /// The removed branch/bookmark name.
45        name: String,
46    },
47    /// The working-copy dirtiness or change count changed (an edit was staged,
48    /// committed, stashed, snapshotted, …).
49    #[non_exhaustive]
50    WorkingCopyChanged {
51        /// Whether the working copy now has uncommitted changes.
52        dirty: bool,
53        /// The new count of changed paths.
54        change_count: usize,
55    },
56    /// The upstream tracking branch changed (git only; always absent on jj).
57    #[non_exhaustive]
58    UpstreamChanged {
59        /// The new upstream tracking branch, or `None` when unset.
60        upstream: Option<String>,
61    },
62    /// The ahead/behind counts versus the upstream changed (git only).
63    #[non_exhaustive]
64    AheadBehindChanged {
65        /// Commits ahead of the upstream now; `None` when there's no upstream **or**
66        /// the upstream is set but uncountable (gone/unfetched).
67        ahead: Option<usize>,
68        /// Commits behind the upstream now; `None` when uncountable (see `ahead`).
69        behind: Option<usize>,
70    },
71    /// The in-progress **operation** changed — a git merge, rebase, `am`,
72    /// cherry-pick, revert, or bisect started or finished. A transition to/from
73    /// [`OperationState::Conflict`] (jj's conflict marker) is **not** reported here:
74    /// `vcs-core` derives jj's `operation` and `conflicted` from the same bit, so
75    /// [`ConflictChanged`](RepoEvent::ConflictChanged) already signals it on both
76    /// backends. So this event fires only on git, and `from`/`to` are
77    /// `Clear`/`Merge`/`Rebase`/`ApplyMailbox`/`CherryPick`/`Revert`/`Bisect`.
78    #[non_exhaustive]
79    OperationChanged {
80        /// The previous operation state.
81        from: OperationState,
82        /// The new operation state.
83        to: OperationState,
84    },
85    /// Whether the working copy has an unresolved conflict changed.
86    #[non_exhaustive]
87    ConflictChanged {
88        /// Whether the working copy is now conflicted.
89        conflicted: bool,
90    },
91}
92
93/// A batch of changes observed in one settled re-query: the **new full
94/// [`RepoSnapshot`]** (ready to render a prompt/status line) plus the typed
95/// [`RepoEvent`]s that produced it. A [`RepoWatcher`](crate::RepoWatcher) only
96/// yields a `RepoChange` when at least one event fired.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct RepoChange {
100    /// The repository state after the change.
101    pub snapshot: RepoSnapshot,
102    /// The typed deltas from the previous state (never empty).
103    pub events: Vec<RepoEvent>,
104}
105
106/// The observable state the watcher diffs across re-queries: the snapshot's
107/// fields (mirrored so this is constructible in-crate — `RepoSnapshot` is
108/// `#[non_exhaustive]`) plus the full local-branch set.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub(crate) struct WatchState {
111    head: Option<String>,
112    branch: Option<String>,
113    upstream: Option<String>,
114    ahead: Option<usize>,
115    behind: Option<usize>,
116    dirty: bool,
117    change_count: usize,
118    conflicted: bool,
119    operation: OperationState,
120    branches: Vec<String>,
121}
122
123impl WatchState {
124    /// Mirror a [`RepoSnapshot`] (reading its public fields) plus the branch list.
125    pub(crate) fn from_snapshot(snapshot: &RepoSnapshot, branches: Vec<String>) -> Self {
126        WatchState {
127            head: snapshot.head.clone(),
128            branch: snapshot.branch.clone(),
129            // Flatten the bundled tracking back into the watcher's per-field deltas
130            // so `UpstreamChanged` / `AheadBehindChanged` stay distinct signals.
131            // `and_then`: the count is `None` for either "no upstream" or an upstream
132            // that's set-but-uncountable (M17) — both read as "no count" for the delta,
133            // and a set→gone transition still flips `ahead`/`behind` so the event fires.
134            upstream: snapshot.tracking.as_ref().map(|t| t.branch.clone()),
135            ahead: snapshot.tracking.as_ref().and_then(|t| t.ahead),
136            behind: snapshot.tracking.as_ref().and_then(|t| t.behind),
137            dirty: snapshot.dirty,
138            change_count: snapshot.change_count,
139            conflicted: snapshot.conflicted,
140            operation: snapshot.operation,
141            branches,
142        }
143    }
144}
145
146/// Diff two consecutive states into the events that changed. Pure; the order is
147/// stable (head, branch switch, created, deleted, working copy, upstream,
148/// ahead/behind, operation, conflict — created/deleted names sorted).
149pub(crate) fn diff(prev: &WatchState, next: &WatchState) -> Vec<RepoEvent> {
150    let mut events = Vec::new();
151
152    if prev.head != next.head {
153        events.push(RepoEvent::HeadMoved {
154            from: prev.head.clone(),
155            to: next.head.clone(),
156        });
157    }
158    if prev.branch != next.branch {
159        events.push(RepoEvent::BranchSwitched {
160            from: prev.branch.clone(),
161            to: next.branch.clone(),
162        });
163    }
164
165    // Branch-set delta (sorted for deterministic output, regardless of the
166    // order git/jj listed them in).
167    let before: BTreeSet<&str> = prev.branches.iter().map(String::as_str).collect();
168    let after: BTreeSet<&str> = next.branches.iter().map(String::as_str).collect();
169    for name in after.difference(&before) {
170        events.push(RepoEvent::BranchCreated {
171            name: (*name).to_string(),
172        });
173    }
174    for name in before.difference(&after) {
175        events.push(RepoEvent::BranchDeleted {
176            name: (*name).to_string(),
177        });
178    }
179
180    if prev.dirty != next.dirty || prev.change_count != next.change_count {
181        events.push(RepoEvent::WorkingCopyChanged {
182            dirty: next.dirty,
183            change_count: next.change_count,
184        });
185    }
186    if prev.upstream != next.upstream {
187        events.push(RepoEvent::UpstreamChanged {
188            upstream: next.upstream.clone(),
189        });
190    }
191    if prev.ahead != next.ahead || prev.behind != next.behind {
192        events.push(RepoEvent::AheadBehindChanged {
193            ahead: next.ahead,
194            behind: next.behind,
195        });
196    }
197    // Only the git merge/rebase lifecycle: a transition to/from `Conflict` (jj's
198    // conflict marker, which tracks the same bit as `conflicted`) is left to
199    // `ConflictChanged` so a jj conflict isn't double-signalled.
200    if prev.operation != next.operation
201        && prev.operation != OperationState::Conflict
202        && next.operation != OperationState::Conflict
203    {
204        events.push(RepoEvent::OperationChanged {
205            from: prev.operation,
206            to: next.operation,
207        });
208    }
209    if prev.conflicted != next.conflicted {
210        events.push(RepoEvent::ConflictChanged {
211            conflicted: next.conflicted,
212        });
213    }
214
215    events
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// A clean baseline state on `main` at one commit, no branches.
223    fn base() -> WatchState {
224        WatchState {
225            head: Some("aaaa".into()),
226            branch: Some("main".into()),
227            upstream: None,
228            ahead: None,
229            behind: None,
230            dirty: false,
231            change_count: 0,
232            conflicted: false,
233            operation: OperationState::Clear,
234            branches: vec!["main".into()],
235        }
236    }
237
238    #[test]
239    fn identical_states_yield_no_events() {
240        assert!(diff(&base(), &base()).is_empty());
241    }
242
243    #[test]
244    fn head_move_is_detected() {
245        let mut next = base();
246        next.head = Some("bbbb".into());
247        assert_eq!(
248            diff(&base(), &next),
249            vec![RepoEvent::HeadMoved {
250                from: Some("aaaa".into()),
251                to: Some("bbbb".into()),
252            }]
253        );
254    }
255
256    #[test]
257    fn branch_switch_is_detected() {
258        let mut next = base();
259        next.branch = Some("feature".into());
260        assert_eq!(
261            diff(&base(), &next),
262            vec![RepoEvent::BranchSwitched {
263                from: Some("main".into()),
264                to: Some("feature".into()),
265            }]
266        );
267        // Detaching maps to `to: None`.
268        let mut detached = base();
269        detached.branch = None;
270        assert_eq!(
271            diff(&base(), &detached),
272            vec![RepoEvent::BranchSwitched {
273                from: Some("main".into()),
274                to: None,
275            }]
276        );
277    }
278
279    #[test]
280    fn branch_create_and_delete_are_sorted_and_paired() {
281        let mut next = base();
282        // main stays; add feat-b and feat-a, drop nothing.
283        next.branches = vec!["main".into(), "feat-b".into(), "feat-a".into()];
284        assert_eq!(
285            diff(&base(), &next),
286            vec![
287                RepoEvent::BranchCreated {
288                    name: "feat-a".into()
289                },
290                RepoEvent::BranchCreated {
291                    name: "feat-b".into()
292                },
293            ],
294            "created names come out sorted"
295        );
296
297        // Deleting `main`, keeping nothing.
298        let mut emptied = base();
299        emptied.branches = vec![];
300        assert_eq!(
301            diff(&base(), &emptied),
302            vec![RepoEvent::BranchDeleted {
303                name: "main".into()
304            }]
305        );
306    }
307
308    #[test]
309    fn working_copy_change_fires_on_dirty_or_count() {
310        let mut dirtied = base();
311        dirtied.dirty = true;
312        dirtied.change_count = 3;
313        assert_eq!(
314            diff(&base(), &dirtied),
315            vec![RepoEvent::WorkingCopyChanged {
316                dirty: true,
317                change_count: 3,
318            }]
319        );
320        // A count change while already dirty still fires (e.g. 1 → 2 edits).
321        let mut one = base();
322        one.dirty = true;
323        one.change_count = 1;
324        let mut two = base();
325        two.dirty = true;
326        two.change_count = 2;
327        assert_eq!(
328            diff(&one, &two),
329            vec![RepoEvent::WorkingCopyChanged {
330                dirty: true,
331                change_count: 2,
332            }]
333        );
334    }
335
336    #[test]
337    fn upstream_and_ahead_behind_are_separate_events() {
338        let mut next = base();
339        next.upstream = Some("origin/main".into());
340        next.ahead = Some(2);
341        next.behind = Some(0);
342        assert_eq!(
343            diff(&base(), &next),
344            vec![
345                RepoEvent::UpstreamChanged {
346                    upstream: Some("origin/main".into()),
347                },
348                RepoEvent::AheadBehindChanged {
349                    ahead: Some(2),
350                    behind: Some(0),
351                },
352            ]
353        );
354    }
355
356    #[test]
357    fn operation_and_conflict_transitions_are_detected() {
358        let mut merging = base();
359        merging.operation = OperationState::Merge;
360        assert_eq!(
361            diff(&base(), &merging),
362            vec![RepoEvent::OperationChanged {
363                from: OperationState::Clear,
364                to: OperationState::Merge,
365            }]
366        );
367
368        let mut conflicted = base();
369        conflicted.conflicted = true;
370        assert_eq!(
371            diff(&base(), &conflicted),
372            vec![RepoEvent::ConflictChanged { conflicted: true }]
373        );
374    }
375
376    // The sequencer states (cherry-pick/revert/bisect) flow through the same
377    // `OperationChanged` path as merge/rebase — starting one and moving between them
378    // both fire, since neither endpoint is `Conflict`.
379    #[test]
380    fn sequencer_operation_transitions_are_detected() {
381        let mut cherry = base();
382        cherry.operation = OperationState::CherryPick;
383        assert_eq!(
384            diff(&base(), &cherry),
385            vec![RepoEvent::OperationChanged {
386                from: OperationState::Clear,
387                to: OperationState::CherryPick,
388            }]
389        );
390
391        // A move directly between two sequencer states is a single OperationChanged.
392        let mut revert = base();
393        revert.operation = OperationState::Revert;
394        assert_eq!(
395            diff(&cherry, &revert),
396            vec![RepoEvent::OperationChanged {
397                from: OperationState::CherryPick,
398                to: OperationState::Revert,
399            }]
400        );
401
402        let mut bisect = base();
403        bisect.operation = OperationState::Bisect;
404        assert_eq!(
405            diff(&base(), &bisect),
406            vec![RepoEvent::OperationChanged {
407                from: OperationState::Clear,
408                to: OperationState::Bisect,
409            }]
410        );
411    }
412
413    // jj derives `operation` and `conflicted` from the same bit, so a conflict
414    // appearing flips BOTH (Clear→Conflict and false→true). The redundant
415    // `OperationChanged` is suppressed — only `ConflictChanged` is emitted.
416    #[test]
417    fn jj_conflict_emits_only_conflict_changed_not_operation() {
418        let mut next = base();
419        next.operation = OperationState::Conflict;
420        next.conflicted = true;
421        assert_eq!(
422            diff(&base(), &next),
423            vec![RepoEvent::ConflictChanged { conflicted: true }],
424            "Clear→Conflict must not also emit OperationChanged"
425        );
426        // …and clearing it the same way.
427        let mut cleared = base();
428        cleared.operation = OperationState::Clear;
429        cleared.conflicted = false;
430        let mut from = base();
431        from.operation = OperationState::Conflict;
432        from.conflicted = true;
433        assert_eq!(
434            diff(&from, &cleared),
435            vec![RepoEvent::ConflictChanged { conflicted: false }]
436        );
437    }
438
439    // A git merge with conflicts is two *distinct* facts: a merge started AND it
440    // conflicts — both fire (the Merge endpoint isn't `Conflict`, so it's kept).
441    #[test]
442    fn git_merge_with_conflict_emits_both_operation_and_conflict() {
443        let mut next = base();
444        next.operation = OperationState::Merge;
445        next.conflicted = true;
446        assert_eq!(
447            diff(&base(), &next),
448            vec![
449                RepoEvent::OperationChanged {
450                    from: OperationState::Clear,
451                    to: OperationState::Merge,
452                },
453                RepoEvent::ConflictChanged { conflicted: true },
454            ]
455        );
456    }
457
458    // A realistic "commit" burst: HEAD moves, the working copy goes clean — two
459    // events from one diff, in the documented order.
460    #[test]
461    fn multiple_changes_emit_in_stable_order() {
462        let mut prev = base();
463        prev.dirty = true;
464        prev.change_count = 2;
465        let mut next = base(); // clean again, new head
466        next.head = Some("cccc".into());
467        assert_eq!(
468            diff(&prev, &next),
469            vec![
470                RepoEvent::HeadMoved {
471                    from: Some("aaaa".into()),
472                    to: Some("cccc".into()),
473                },
474                RepoEvent::WorkingCopyChanged {
475                    dirty: false,
476                    change_count: 0,
477                },
478            ]
479        );
480    }
481}