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