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