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