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]
78 OperationChanged {
79 from: OperationState,
81 to: OperationState,
83 },
84 #[non_exhaustive]
86 ConflictChanged {
87 conflicted: bool,
89 },
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
97#[non_exhaustive]
98pub struct RepoChange {
99 pub snapshot: RepoSnapshot,
101 pub events: Vec<RepoEvent>,
103}
104
105#[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 pub(crate) fn from_snapshot(snapshot: &RepoSnapshot, branches: Vec<String>) -> Self {
125 WatchState {
126 head: snapshot.head.clone(),
127 branch: snapshot.branch.clone(),
128 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
145pub(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 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 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 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 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 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 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 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 #[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 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 #[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 #[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(); 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}