1use std::collections::BTreeSet;
9
10use vcs_core::{OperationState, RepoSnapshot};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum RepoEvent {
17 HeadMoved {
20 from: Option<String>,
22 to: Option<String>,
24 },
25 BranchSwitched {
28 from: Option<String>,
30 to: Option<String>,
32 },
33 BranchCreated {
35 name: String,
37 },
38 BranchDeleted {
40 name: String,
42 },
43 WorkingCopyChanged {
46 dirty: bool,
48 change_count: usize,
50 },
51 UpstreamChanged {
53 upstream: Option<String>,
55 },
56 AheadBehindChanged {
58 ahead: Option<usize>,
61 behind: Option<usize>,
63 },
64 OperationChanged {
71 from: OperationState,
73 to: OperationState,
75 },
76 ConflictChanged {
78 conflicted: bool,
80 },
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct RepoChange {
90 pub snapshot: RepoSnapshot,
92 pub events: Vec<RepoEvent>,
94}
95
96#[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 pub(crate) fn from_snapshot(snapshot: &RepoSnapshot, branches: Vec<String>) -> Self {
116 WatchState {
117 head: snapshot.head.clone(),
118 branch: snapshot.branch.clone(),
119 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
136pub(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 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 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 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 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 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 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 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 #[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 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 #[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 #[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(); 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}