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            upstream: snapshot.upstream.clone(),
119            ahead: snapshot.ahead,
120            behind: snapshot.behind,
121            dirty: snapshot.dirty,
122            change_count: snapshot.change_count,
123            conflicted: snapshot.conflicted,
124            operation: snapshot.operation,
125            branches,
126        }
127    }
128}
129
130/// Diff two consecutive states into the events that changed. Pure; the order is
131/// stable (head, branch switch, created, deleted, working copy, upstream,
132/// ahead/behind, operation, conflict — created/deleted names sorted).
133pub(crate) fn diff(prev: &WatchState, next: &WatchState) -> Vec<RepoEvent> {
134    let mut events = Vec::new();
135
136    if prev.head != next.head {
137        events.push(RepoEvent::HeadMoved {
138            from: prev.head.clone(),
139            to: next.head.clone(),
140        });
141    }
142    if prev.branch != next.branch {
143        events.push(RepoEvent::BranchSwitched {
144            from: prev.branch.clone(),
145            to: next.branch.clone(),
146        });
147    }
148
149    // Branch-set delta (sorted for deterministic output, regardless of the
150    // order git/jj listed them in).
151    let before: BTreeSet<&str> = prev.branches.iter().map(String::as_str).collect();
152    let after: BTreeSet<&str> = next.branches.iter().map(String::as_str).collect();
153    for name in after.difference(&before) {
154        events.push(RepoEvent::BranchCreated {
155            name: (*name).to_string(),
156        });
157    }
158    for name in before.difference(&after) {
159        events.push(RepoEvent::BranchDeleted {
160            name: (*name).to_string(),
161        });
162    }
163
164    if prev.dirty != next.dirty || prev.change_count != next.change_count {
165        events.push(RepoEvent::WorkingCopyChanged {
166            dirty: next.dirty,
167            change_count: next.change_count,
168        });
169    }
170    if prev.upstream != next.upstream {
171        events.push(RepoEvent::UpstreamChanged {
172            upstream: next.upstream.clone(),
173        });
174    }
175    if prev.ahead != next.ahead || prev.behind != next.behind {
176        events.push(RepoEvent::AheadBehindChanged {
177            ahead: next.ahead,
178            behind: next.behind,
179        });
180    }
181    // Only the git merge/rebase lifecycle: a transition to/from `Conflict` (jj's
182    // conflict marker, which tracks the same bit as `conflicted`) is left to
183    // `ConflictChanged` so a jj conflict isn't double-signalled.
184    if prev.operation != next.operation
185        && prev.operation != OperationState::Conflict
186        && next.operation != OperationState::Conflict
187    {
188        events.push(RepoEvent::OperationChanged {
189            from: prev.operation,
190            to: next.operation,
191        });
192    }
193    if prev.conflicted != next.conflicted {
194        events.push(RepoEvent::ConflictChanged {
195            conflicted: next.conflicted,
196        });
197    }
198
199    events
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// A clean baseline state on `main` at one commit, no branches.
207    fn base() -> WatchState {
208        WatchState {
209            head: Some("aaaa".into()),
210            branch: Some("main".into()),
211            upstream: None,
212            ahead: None,
213            behind: None,
214            dirty: false,
215            change_count: 0,
216            conflicted: false,
217            operation: OperationState::Clear,
218            branches: vec!["main".into()],
219        }
220    }
221
222    #[test]
223    fn identical_states_yield_no_events() {
224        assert!(diff(&base(), &base()).is_empty());
225    }
226
227    #[test]
228    fn head_move_is_detected() {
229        let mut next = base();
230        next.head = Some("bbbb".into());
231        assert_eq!(
232            diff(&base(), &next),
233            vec![RepoEvent::HeadMoved {
234                from: Some("aaaa".into()),
235                to: Some("bbbb".into()),
236            }]
237        );
238    }
239
240    #[test]
241    fn branch_switch_is_detected() {
242        let mut next = base();
243        next.branch = Some("feature".into());
244        assert_eq!(
245            diff(&base(), &next),
246            vec![RepoEvent::BranchSwitched {
247                from: Some("main".into()),
248                to: Some("feature".into()),
249            }]
250        );
251        // Detaching maps to `to: None`.
252        let mut detached = base();
253        detached.branch = None;
254        assert_eq!(
255            diff(&base(), &detached),
256            vec![RepoEvent::BranchSwitched {
257                from: Some("main".into()),
258                to: None,
259            }]
260        );
261    }
262
263    #[test]
264    fn branch_create_and_delete_are_sorted_and_paired() {
265        let mut next = base();
266        // main stays; add feat-b and feat-a, drop nothing.
267        next.branches = vec!["main".into(), "feat-b".into(), "feat-a".into()];
268        assert_eq!(
269            diff(&base(), &next),
270            vec![
271                RepoEvent::BranchCreated {
272                    name: "feat-a".into()
273                },
274                RepoEvent::BranchCreated {
275                    name: "feat-b".into()
276                },
277            ],
278            "created names come out sorted"
279        );
280
281        // Deleting `main`, keeping nothing.
282        let mut emptied = base();
283        emptied.branches = vec![];
284        assert_eq!(
285            diff(&base(), &emptied),
286            vec![RepoEvent::BranchDeleted {
287                name: "main".into()
288            }]
289        );
290    }
291
292    #[test]
293    fn working_copy_change_fires_on_dirty_or_count() {
294        let mut dirtied = base();
295        dirtied.dirty = true;
296        dirtied.change_count = 3;
297        assert_eq!(
298            diff(&base(), &dirtied),
299            vec![RepoEvent::WorkingCopyChanged {
300                dirty: true,
301                change_count: 3,
302            }]
303        );
304        // A count change while already dirty still fires (e.g. 1 → 2 edits).
305        let mut one = base();
306        one.dirty = true;
307        one.change_count = 1;
308        let mut two = base();
309        two.dirty = true;
310        two.change_count = 2;
311        assert_eq!(
312            diff(&one, &two),
313            vec![RepoEvent::WorkingCopyChanged {
314                dirty: true,
315                change_count: 2,
316            }]
317        );
318    }
319
320    #[test]
321    fn upstream_and_ahead_behind_are_separate_events() {
322        let mut next = base();
323        next.upstream = Some("origin/main".into());
324        next.ahead = Some(2);
325        next.behind = Some(0);
326        assert_eq!(
327            diff(&base(), &next),
328            vec![
329                RepoEvent::UpstreamChanged {
330                    upstream: Some("origin/main".into()),
331                },
332                RepoEvent::AheadBehindChanged {
333                    ahead: Some(2),
334                    behind: Some(0),
335                },
336            ]
337        );
338    }
339
340    #[test]
341    fn operation_and_conflict_transitions_are_detected() {
342        let mut merging = base();
343        merging.operation = OperationState::Merge;
344        assert_eq!(
345            diff(&base(), &merging),
346            vec![RepoEvent::OperationChanged {
347                from: OperationState::Clear,
348                to: OperationState::Merge,
349            }]
350        );
351
352        let mut conflicted = base();
353        conflicted.conflicted = true;
354        assert_eq!(
355            diff(&base(), &conflicted),
356            vec![RepoEvent::ConflictChanged { conflicted: true }]
357        );
358    }
359
360    // jj derives `operation` and `conflicted` from the same bit, so a conflict
361    // appearing flips BOTH (Clear→Conflict and false→true). The redundant
362    // `OperationChanged` is suppressed — only `ConflictChanged` is emitted.
363    #[test]
364    fn jj_conflict_emits_only_conflict_changed_not_operation() {
365        let mut next = base();
366        next.operation = OperationState::Conflict;
367        next.conflicted = true;
368        assert_eq!(
369            diff(&base(), &next),
370            vec![RepoEvent::ConflictChanged { conflicted: true }],
371            "Clear→Conflict must not also emit OperationChanged"
372        );
373        // …and clearing it the same way.
374        let mut cleared = base();
375        cleared.operation = OperationState::Clear;
376        cleared.conflicted = false;
377        let mut from = base();
378        from.operation = OperationState::Conflict;
379        from.conflicted = true;
380        assert_eq!(
381            diff(&from, &cleared),
382            vec![RepoEvent::ConflictChanged { conflicted: false }]
383        );
384    }
385
386    // A git merge with conflicts is two *distinct* facts: a merge started AND it
387    // conflicts — both fire (the Merge endpoint isn't `Conflict`, so it's kept).
388    #[test]
389    fn git_merge_with_conflict_emits_both_operation_and_conflict() {
390        let mut next = base();
391        next.operation = OperationState::Merge;
392        next.conflicted = true;
393        assert_eq!(
394            diff(&base(), &next),
395            vec![
396                RepoEvent::OperationChanged {
397                    from: OperationState::Clear,
398                    to: OperationState::Merge,
399                },
400                RepoEvent::ConflictChanged { conflicted: true },
401            ]
402        );
403    }
404
405    // A realistic "commit" burst: HEAD moves, the working copy goes clean — two
406    // events from one diff, in the documented order.
407    #[test]
408    fn multiple_changes_emit_in_stable_order() {
409        let mut prev = base();
410        prev.dirty = true;
411        prev.change_count = 2;
412        let mut next = base(); // clean again, new head
413        next.head = Some("cccc".into());
414        assert_eq!(
415            diff(&prev, &next),
416            vec![
417                RepoEvent::HeadMoved {
418                    from: Some("aaaa".into()),
419                    to: Some("cccc".into()),
420                },
421                RepoEvent::WorkingCopyChanged {
422                    dirty: false,
423                    change_count: 0,
424                },
425            ]
426        );
427    }
428}