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>,
60 behind: Option<usize>,
62 },
63 OperationChanged {
70 from: OperationState,
72 to: OperationState,
74 },
75 ConflictChanged {
77 conflicted: bool,
79 },
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
87#[non_exhaustive]
88pub struct RepoChange {
89 pub snapshot: RepoSnapshot,
91 pub events: Vec<RepoEvent>,
93}
94
95#[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 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.tracking.as_ref().map(|t| t.branch.clone()),
121 ahead: snapshot.tracking.as_ref().map(|t| t.ahead),
122 behind: snapshot.tracking.as_ref().map(|t| t.behind),
123 dirty: snapshot.dirty,
124 change_count: snapshot.change_count,
125 conflicted: snapshot.conflicted,
126 operation: snapshot.operation,
127 branches,
128 }
129 }
130}
131
132pub(crate) fn diff(prev: &WatchState, next: &WatchState) -> Vec<RepoEvent> {
136 let mut events = Vec::new();
137
138 if prev.head != next.head {
139 events.push(RepoEvent::HeadMoved {
140 from: prev.head.clone(),
141 to: next.head.clone(),
142 });
143 }
144 if prev.branch != next.branch {
145 events.push(RepoEvent::BranchSwitched {
146 from: prev.branch.clone(),
147 to: next.branch.clone(),
148 });
149 }
150
151 let before: BTreeSet<&str> = prev.branches.iter().map(String::as_str).collect();
154 let after: BTreeSet<&str> = next.branches.iter().map(String::as_str).collect();
155 for name in after.difference(&before) {
156 events.push(RepoEvent::BranchCreated {
157 name: (*name).to_string(),
158 });
159 }
160 for name in before.difference(&after) {
161 events.push(RepoEvent::BranchDeleted {
162 name: (*name).to_string(),
163 });
164 }
165
166 if prev.dirty != next.dirty || prev.change_count != next.change_count {
167 events.push(RepoEvent::WorkingCopyChanged {
168 dirty: next.dirty,
169 change_count: next.change_count,
170 });
171 }
172 if prev.upstream != next.upstream {
173 events.push(RepoEvent::UpstreamChanged {
174 upstream: next.upstream.clone(),
175 });
176 }
177 if prev.ahead != next.ahead || prev.behind != next.behind {
178 events.push(RepoEvent::AheadBehindChanged {
179 ahead: next.ahead,
180 behind: next.behind,
181 });
182 }
183 if prev.operation != next.operation
187 && prev.operation != OperationState::Conflict
188 && next.operation != OperationState::Conflict
189 {
190 events.push(RepoEvent::OperationChanged {
191 from: prev.operation,
192 to: next.operation,
193 });
194 }
195 if prev.conflicted != next.conflicted {
196 events.push(RepoEvent::ConflictChanged {
197 conflicted: next.conflicted,
198 });
199 }
200
201 events
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn base() -> WatchState {
210 WatchState {
211 head: Some("aaaa".into()),
212 branch: Some("main".into()),
213 upstream: None,
214 ahead: None,
215 behind: None,
216 dirty: false,
217 change_count: 0,
218 conflicted: false,
219 operation: OperationState::Clear,
220 branches: vec!["main".into()],
221 }
222 }
223
224 #[test]
225 fn identical_states_yield_no_events() {
226 assert!(diff(&base(), &base()).is_empty());
227 }
228
229 #[test]
230 fn head_move_is_detected() {
231 let mut next = base();
232 next.head = Some("bbbb".into());
233 assert_eq!(
234 diff(&base(), &next),
235 vec![RepoEvent::HeadMoved {
236 from: Some("aaaa".into()),
237 to: Some("bbbb".into()),
238 }]
239 );
240 }
241
242 #[test]
243 fn branch_switch_is_detected() {
244 let mut next = base();
245 next.branch = Some("feature".into());
246 assert_eq!(
247 diff(&base(), &next),
248 vec![RepoEvent::BranchSwitched {
249 from: Some("main".into()),
250 to: Some("feature".into()),
251 }]
252 );
253 let mut detached = base();
255 detached.branch = None;
256 assert_eq!(
257 diff(&base(), &detached),
258 vec![RepoEvent::BranchSwitched {
259 from: Some("main".into()),
260 to: None,
261 }]
262 );
263 }
264
265 #[test]
266 fn branch_create_and_delete_are_sorted_and_paired() {
267 let mut next = base();
268 next.branches = vec!["main".into(), "feat-b".into(), "feat-a".into()];
270 assert_eq!(
271 diff(&base(), &next),
272 vec![
273 RepoEvent::BranchCreated {
274 name: "feat-a".into()
275 },
276 RepoEvent::BranchCreated {
277 name: "feat-b".into()
278 },
279 ],
280 "created names come out sorted"
281 );
282
283 let mut emptied = base();
285 emptied.branches = vec![];
286 assert_eq!(
287 diff(&base(), &emptied),
288 vec![RepoEvent::BranchDeleted {
289 name: "main".into()
290 }]
291 );
292 }
293
294 #[test]
295 fn working_copy_change_fires_on_dirty_or_count() {
296 let mut dirtied = base();
297 dirtied.dirty = true;
298 dirtied.change_count = 3;
299 assert_eq!(
300 diff(&base(), &dirtied),
301 vec![RepoEvent::WorkingCopyChanged {
302 dirty: true,
303 change_count: 3,
304 }]
305 );
306 let mut one = base();
308 one.dirty = true;
309 one.change_count = 1;
310 let mut two = base();
311 two.dirty = true;
312 two.change_count = 2;
313 assert_eq!(
314 diff(&one, &two),
315 vec![RepoEvent::WorkingCopyChanged {
316 dirty: true,
317 change_count: 2,
318 }]
319 );
320 }
321
322 #[test]
323 fn upstream_and_ahead_behind_are_separate_events() {
324 let mut next = base();
325 next.upstream = Some("origin/main".into());
326 next.ahead = Some(2);
327 next.behind = Some(0);
328 assert_eq!(
329 diff(&base(), &next),
330 vec![
331 RepoEvent::UpstreamChanged {
332 upstream: Some("origin/main".into()),
333 },
334 RepoEvent::AheadBehindChanged {
335 ahead: Some(2),
336 behind: Some(0),
337 },
338 ]
339 );
340 }
341
342 #[test]
343 fn operation_and_conflict_transitions_are_detected() {
344 let mut merging = base();
345 merging.operation = OperationState::Merge;
346 assert_eq!(
347 diff(&base(), &merging),
348 vec![RepoEvent::OperationChanged {
349 from: OperationState::Clear,
350 to: OperationState::Merge,
351 }]
352 );
353
354 let mut conflicted = base();
355 conflicted.conflicted = true;
356 assert_eq!(
357 diff(&base(), &conflicted),
358 vec![RepoEvent::ConflictChanged { conflicted: true }]
359 );
360 }
361
362 #[test]
366 fn jj_conflict_emits_only_conflict_changed_not_operation() {
367 let mut next = base();
368 next.operation = OperationState::Conflict;
369 next.conflicted = true;
370 assert_eq!(
371 diff(&base(), &next),
372 vec![RepoEvent::ConflictChanged { conflicted: true }],
373 "Clear→Conflict must not also emit OperationChanged"
374 );
375 let mut cleared = base();
377 cleared.operation = OperationState::Clear;
378 cleared.conflicted = false;
379 let mut from = base();
380 from.operation = OperationState::Conflict;
381 from.conflicted = true;
382 assert_eq!(
383 diff(&from, &cleared),
384 vec![RepoEvent::ConflictChanged { conflicted: false }]
385 );
386 }
387
388 #[test]
391 fn git_merge_with_conflict_emits_both_operation_and_conflict() {
392 let mut next = base();
393 next.operation = OperationState::Merge;
394 next.conflicted = true;
395 assert_eq!(
396 diff(&base(), &next),
397 vec![
398 RepoEvent::OperationChanged {
399 from: OperationState::Clear,
400 to: OperationState::Merge,
401 },
402 RepoEvent::ConflictChanged { conflicted: true },
403 ]
404 );
405 }
406
407 #[test]
410 fn multiple_changes_emit_in_stable_order() {
411 let mut prev = base();
412 prev.dirty = true;
413 prev.change_count = 2;
414 let mut next = base(); next.head = Some("cccc".into());
416 assert_eq!(
417 diff(&prev, &next),
418 vec![
419 RepoEvent::HeadMoved {
420 from: Some("aaaa".into()),
421 to: Some("cccc".into()),
422 },
423 RepoEvent::WorkingCopyChanged {
424 dirty: false,
425 change_count: 0,
426 },
427 ]
428 );
429 }
430}