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