1use std::collections::BTreeSet;
9
10use vcs_core::{OperationState, RepoSnapshot};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum RepoEvent {
17 #[non_exhaustive]
20 HeadMoved {
21 from: Option<String>,
23 to: Option<String>,
25 },
26 #[non_exhaustive]
29 BranchSwitched {
30 from: Option<String>,
32 to: Option<String>,
34 },
35 #[non_exhaustive]
37 BranchCreated {
38 name: String,
40 },
41 #[non_exhaustive]
43 BranchDeleted {
44 name: String,
46 },
47 #[non_exhaustive]
50 WorkingCopyChanged {
51 dirty: bool,
53 change_count: usize,
55 },
56 #[non_exhaustive]
58 UpstreamChanged {
59 upstream: Option<String>,
61 },
62 #[non_exhaustive]
64 AheadBehindChanged {
65 ahead: Option<usize>,
68 behind: Option<usize>,
70 },
71 #[non_exhaustive]
79 OperationChanged {
80 from: OperationState,
82 to: OperationState,
84 },
85 #[non_exhaustive]
87 ConflictChanged {
88 conflicted: bool,
90 },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct RepoChange {
100 pub snapshot: RepoSnapshot,
102 pub events: Vec<RepoEvent>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
110pub(crate) struct WatchState {
111 head: Option<String>,
112 branch: Option<String>,
113 upstream: Option<String>,
114 ahead: Option<usize>,
115 behind: Option<usize>,
116 dirty: bool,
117 change_count: usize,
118 conflicted: bool,
119 operation: OperationState,
120 branches: Vec<String>,
121}
122
123impl WatchState {
124 pub(crate) fn from_snapshot(snapshot: &RepoSnapshot, branches: Vec<String>) -> Self {
126 WatchState {
127 head: snapshot.head.clone(),
128 branch: snapshot.branch.clone(),
129 upstream: snapshot.tracking.as_ref().map(|t| t.branch.clone()),
135 ahead: snapshot.tracking.as_ref().and_then(|t| t.ahead),
136 behind: snapshot.tracking.as_ref().and_then(|t| t.behind),
137 dirty: snapshot.dirty,
138 change_count: snapshot.change_count,
139 conflicted: snapshot.conflicted,
140 operation: snapshot.operation,
141 branches,
142 }
143 }
144}
145
146pub(crate) fn diff(prev: &WatchState, next: &WatchState) -> Vec<RepoEvent> {
150 let mut events = Vec::new();
151
152 if prev.head != next.head {
153 events.push(RepoEvent::HeadMoved {
154 from: prev.head.clone(),
155 to: next.head.clone(),
156 });
157 }
158 if prev.branch != next.branch {
159 events.push(RepoEvent::BranchSwitched {
160 from: prev.branch.clone(),
161 to: next.branch.clone(),
162 });
163 }
164
165 let before: BTreeSet<&str> = prev.branches.iter().map(String::as_str).collect();
168 let after: BTreeSet<&str> = next.branches.iter().map(String::as_str).collect();
169 for name in after.difference(&before) {
170 events.push(RepoEvent::BranchCreated {
171 name: (*name).to_string(),
172 });
173 }
174 for name in before.difference(&after) {
175 events.push(RepoEvent::BranchDeleted {
176 name: (*name).to_string(),
177 });
178 }
179
180 if prev.dirty != next.dirty || prev.change_count != next.change_count {
181 events.push(RepoEvent::WorkingCopyChanged {
182 dirty: next.dirty,
183 change_count: next.change_count,
184 });
185 }
186 if prev.upstream != next.upstream {
187 events.push(RepoEvent::UpstreamChanged {
188 upstream: next.upstream.clone(),
189 });
190 }
191 if prev.ahead != next.ahead || prev.behind != next.behind {
192 events.push(RepoEvent::AheadBehindChanged {
193 ahead: next.ahead,
194 behind: next.behind,
195 });
196 }
197 if prev.operation != next.operation
201 && prev.operation != OperationState::Conflict
202 && next.operation != OperationState::Conflict
203 {
204 events.push(RepoEvent::OperationChanged {
205 from: prev.operation,
206 to: next.operation,
207 });
208 }
209 if prev.conflicted != next.conflicted {
210 events.push(RepoEvent::ConflictChanged {
211 conflicted: next.conflicted,
212 });
213 }
214
215 events
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn base() -> WatchState {
224 WatchState {
225 head: Some("aaaa".into()),
226 branch: Some("main".into()),
227 upstream: None,
228 ahead: None,
229 behind: None,
230 dirty: false,
231 change_count: 0,
232 conflicted: false,
233 operation: OperationState::Clear,
234 branches: vec!["main".into()],
235 }
236 }
237
238 #[test]
239 fn identical_states_yield_no_events() {
240 assert!(diff(&base(), &base()).is_empty());
241 }
242
243 #[test]
244 fn head_move_is_detected() {
245 let mut next = base();
246 next.head = Some("bbbb".into());
247 assert_eq!(
248 diff(&base(), &next),
249 vec![RepoEvent::HeadMoved {
250 from: Some("aaaa".into()),
251 to: Some("bbbb".into()),
252 }]
253 );
254 }
255
256 #[test]
257 fn branch_switch_is_detected() {
258 let mut next = base();
259 next.branch = Some("feature".into());
260 assert_eq!(
261 diff(&base(), &next),
262 vec![RepoEvent::BranchSwitched {
263 from: Some("main".into()),
264 to: Some("feature".into()),
265 }]
266 );
267 let mut detached = base();
269 detached.branch = None;
270 assert_eq!(
271 diff(&base(), &detached),
272 vec![RepoEvent::BranchSwitched {
273 from: Some("main".into()),
274 to: None,
275 }]
276 );
277 }
278
279 #[test]
280 fn branch_create_and_delete_are_sorted_and_paired() {
281 let mut next = base();
282 next.branches = vec!["main".into(), "feat-b".into(), "feat-a".into()];
284 assert_eq!(
285 diff(&base(), &next),
286 vec![
287 RepoEvent::BranchCreated {
288 name: "feat-a".into()
289 },
290 RepoEvent::BranchCreated {
291 name: "feat-b".into()
292 },
293 ],
294 "created names come out sorted"
295 );
296
297 let mut emptied = base();
299 emptied.branches = vec![];
300 assert_eq!(
301 diff(&base(), &emptied),
302 vec![RepoEvent::BranchDeleted {
303 name: "main".into()
304 }]
305 );
306 }
307
308 #[test]
309 fn working_copy_change_fires_on_dirty_or_count() {
310 let mut dirtied = base();
311 dirtied.dirty = true;
312 dirtied.change_count = 3;
313 assert_eq!(
314 diff(&base(), &dirtied),
315 vec![RepoEvent::WorkingCopyChanged {
316 dirty: true,
317 change_count: 3,
318 }]
319 );
320 let mut one = base();
322 one.dirty = true;
323 one.change_count = 1;
324 let mut two = base();
325 two.dirty = true;
326 two.change_count = 2;
327 assert_eq!(
328 diff(&one, &two),
329 vec![RepoEvent::WorkingCopyChanged {
330 dirty: true,
331 change_count: 2,
332 }]
333 );
334 }
335
336 #[test]
337 fn upstream_and_ahead_behind_are_separate_events() {
338 let mut next = base();
339 next.upstream = Some("origin/main".into());
340 next.ahead = Some(2);
341 next.behind = Some(0);
342 assert_eq!(
343 diff(&base(), &next),
344 vec![
345 RepoEvent::UpstreamChanged {
346 upstream: Some("origin/main".into()),
347 },
348 RepoEvent::AheadBehindChanged {
349 ahead: Some(2),
350 behind: Some(0),
351 },
352 ]
353 );
354 }
355
356 #[test]
357 fn operation_and_conflict_transitions_are_detected() {
358 let mut merging = base();
359 merging.operation = OperationState::Merge;
360 assert_eq!(
361 diff(&base(), &merging),
362 vec![RepoEvent::OperationChanged {
363 from: OperationState::Clear,
364 to: OperationState::Merge,
365 }]
366 );
367
368 let mut conflicted = base();
369 conflicted.conflicted = true;
370 assert_eq!(
371 diff(&base(), &conflicted),
372 vec![RepoEvent::ConflictChanged { conflicted: true }]
373 );
374 }
375
376 #[test]
380 fn sequencer_operation_transitions_are_detected() {
381 let mut cherry = base();
382 cherry.operation = OperationState::CherryPick;
383 assert_eq!(
384 diff(&base(), &cherry),
385 vec![RepoEvent::OperationChanged {
386 from: OperationState::Clear,
387 to: OperationState::CherryPick,
388 }]
389 );
390
391 let mut revert = base();
393 revert.operation = OperationState::Revert;
394 assert_eq!(
395 diff(&cherry, &revert),
396 vec![RepoEvent::OperationChanged {
397 from: OperationState::CherryPick,
398 to: OperationState::Revert,
399 }]
400 );
401
402 let mut bisect = base();
403 bisect.operation = OperationState::Bisect;
404 assert_eq!(
405 diff(&base(), &bisect),
406 vec![RepoEvent::OperationChanged {
407 from: OperationState::Clear,
408 to: OperationState::Bisect,
409 }]
410 );
411 }
412
413 #[test]
417 fn jj_conflict_emits_only_conflict_changed_not_operation() {
418 let mut next = base();
419 next.operation = OperationState::Conflict;
420 next.conflicted = true;
421 assert_eq!(
422 diff(&base(), &next),
423 vec![RepoEvent::ConflictChanged { conflicted: true }],
424 "Clear→Conflict must not also emit OperationChanged"
425 );
426 let mut cleared = base();
428 cleared.operation = OperationState::Clear;
429 cleared.conflicted = false;
430 let mut from = base();
431 from.operation = OperationState::Conflict;
432 from.conflicted = true;
433 assert_eq!(
434 diff(&from, &cleared),
435 vec![RepoEvent::ConflictChanged { conflicted: false }]
436 );
437 }
438
439 #[test]
442 fn git_merge_with_conflict_emits_both_operation_and_conflict() {
443 let mut next = base();
444 next.operation = OperationState::Merge;
445 next.conflicted = true;
446 assert_eq!(
447 diff(&base(), &next),
448 vec![
449 RepoEvent::OperationChanged {
450 from: OperationState::Clear,
451 to: OperationState::Merge,
452 },
453 RepoEvent::ConflictChanged { conflicted: true },
454 ]
455 );
456 }
457
458 #[test]
461 fn multiple_changes_emit_in_stable_order() {
462 let mut prev = base();
463 prev.dirty = true;
464 prev.change_count = 2;
465 let mut next = base(); next.head = Some("cccc".into());
467 assert_eq!(
468 diff(&prev, &next),
469 vec![
470 RepoEvent::HeadMoved {
471 from: Some("aaaa".into()),
472 to: Some("cccc".into()),
473 },
474 RepoEvent::WorkingCopyChanged {
475 dirty: false,
476 change_count: 0,
477 },
478 ]
479 );
480 }
481}