use std::collections::BTreeSet;
use vcs_core::{OperationState, RepoSnapshot};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RepoEvent {
HeadMoved {
from: Option<String>,
to: Option<String>,
},
BranchSwitched {
from: Option<String>,
to: Option<String>,
},
BranchCreated {
name: String,
},
BranchDeleted {
name: String,
},
WorkingCopyChanged {
dirty: bool,
change_count: usize,
},
UpstreamChanged {
upstream: Option<String>,
},
AheadBehindChanged {
ahead: Option<usize>,
behind: Option<usize>,
},
OperationChanged {
from: OperationState,
to: OperationState,
},
ConflictChanged {
conflicted: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RepoChange {
pub snapshot: RepoSnapshot,
pub events: Vec<RepoEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WatchState {
head: Option<String>,
branch: Option<String>,
upstream: Option<String>,
ahead: Option<usize>,
behind: Option<usize>,
dirty: bool,
change_count: usize,
conflicted: bool,
operation: OperationState,
branches: Vec<String>,
}
impl WatchState {
pub(crate) fn from_snapshot(snapshot: &RepoSnapshot, branches: Vec<String>) -> Self {
WatchState {
head: snapshot.head.clone(),
branch: snapshot.branch.clone(),
upstream: snapshot.upstream.clone(),
ahead: snapshot.ahead,
behind: snapshot.behind,
dirty: snapshot.dirty,
change_count: snapshot.change_count,
conflicted: snapshot.conflicted,
operation: snapshot.operation,
branches,
}
}
}
pub(crate) fn diff(prev: &WatchState, next: &WatchState) -> Vec<RepoEvent> {
let mut events = Vec::new();
if prev.head != next.head {
events.push(RepoEvent::HeadMoved {
from: prev.head.clone(),
to: next.head.clone(),
});
}
if prev.branch != next.branch {
events.push(RepoEvent::BranchSwitched {
from: prev.branch.clone(),
to: next.branch.clone(),
});
}
let before: BTreeSet<&str> = prev.branches.iter().map(String::as_str).collect();
let after: BTreeSet<&str> = next.branches.iter().map(String::as_str).collect();
for name in after.difference(&before) {
events.push(RepoEvent::BranchCreated {
name: (*name).to_string(),
});
}
for name in before.difference(&after) {
events.push(RepoEvent::BranchDeleted {
name: (*name).to_string(),
});
}
if prev.dirty != next.dirty || prev.change_count != next.change_count {
events.push(RepoEvent::WorkingCopyChanged {
dirty: next.dirty,
change_count: next.change_count,
});
}
if prev.upstream != next.upstream {
events.push(RepoEvent::UpstreamChanged {
upstream: next.upstream.clone(),
});
}
if prev.ahead != next.ahead || prev.behind != next.behind {
events.push(RepoEvent::AheadBehindChanged {
ahead: next.ahead,
behind: next.behind,
});
}
if prev.operation != next.operation
&& prev.operation != OperationState::Conflict
&& next.operation != OperationState::Conflict
{
events.push(RepoEvent::OperationChanged {
from: prev.operation,
to: next.operation,
});
}
if prev.conflicted != next.conflicted {
events.push(RepoEvent::ConflictChanged {
conflicted: next.conflicted,
});
}
events
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> WatchState {
WatchState {
head: Some("aaaa".into()),
branch: Some("main".into()),
upstream: None,
ahead: None,
behind: None,
dirty: false,
change_count: 0,
conflicted: false,
operation: OperationState::Clear,
branches: vec!["main".into()],
}
}
#[test]
fn identical_states_yield_no_events() {
assert!(diff(&base(), &base()).is_empty());
}
#[test]
fn head_move_is_detected() {
let mut next = base();
next.head = Some("bbbb".into());
assert_eq!(
diff(&base(), &next),
vec![RepoEvent::HeadMoved {
from: Some("aaaa".into()),
to: Some("bbbb".into()),
}]
);
}
#[test]
fn branch_switch_is_detected() {
let mut next = base();
next.branch = Some("feature".into());
assert_eq!(
diff(&base(), &next),
vec![RepoEvent::BranchSwitched {
from: Some("main".into()),
to: Some("feature".into()),
}]
);
let mut detached = base();
detached.branch = None;
assert_eq!(
diff(&base(), &detached),
vec![RepoEvent::BranchSwitched {
from: Some("main".into()),
to: None,
}]
);
}
#[test]
fn branch_create_and_delete_are_sorted_and_paired() {
let mut next = base();
next.branches = vec!["main".into(), "feat-b".into(), "feat-a".into()];
assert_eq!(
diff(&base(), &next),
vec![
RepoEvent::BranchCreated {
name: "feat-a".into()
},
RepoEvent::BranchCreated {
name: "feat-b".into()
},
],
"created names come out sorted"
);
let mut emptied = base();
emptied.branches = vec![];
assert_eq!(
diff(&base(), &emptied),
vec![RepoEvent::BranchDeleted {
name: "main".into()
}]
);
}
#[test]
fn working_copy_change_fires_on_dirty_or_count() {
let mut dirtied = base();
dirtied.dirty = true;
dirtied.change_count = 3;
assert_eq!(
diff(&base(), &dirtied),
vec![RepoEvent::WorkingCopyChanged {
dirty: true,
change_count: 3,
}]
);
let mut one = base();
one.dirty = true;
one.change_count = 1;
let mut two = base();
two.dirty = true;
two.change_count = 2;
assert_eq!(
diff(&one, &two),
vec![RepoEvent::WorkingCopyChanged {
dirty: true,
change_count: 2,
}]
);
}
#[test]
fn upstream_and_ahead_behind_are_separate_events() {
let mut next = base();
next.upstream = Some("origin/main".into());
next.ahead = Some(2);
next.behind = Some(0);
assert_eq!(
diff(&base(), &next),
vec![
RepoEvent::UpstreamChanged {
upstream: Some("origin/main".into()),
},
RepoEvent::AheadBehindChanged {
ahead: Some(2),
behind: Some(0),
},
]
);
}
#[test]
fn operation_and_conflict_transitions_are_detected() {
let mut merging = base();
merging.operation = OperationState::Merge;
assert_eq!(
diff(&base(), &merging),
vec![RepoEvent::OperationChanged {
from: OperationState::Clear,
to: OperationState::Merge,
}]
);
let mut conflicted = base();
conflicted.conflicted = true;
assert_eq!(
diff(&base(), &conflicted),
vec![RepoEvent::ConflictChanged { conflicted: true }]
);
}
#[test]
fn jj_conflict_emits_only_conflict_changed_not_operation() {
let mut next = base();
next.operation = OperationState::Conflict;
next.conflicted = true;
assert_eq!(
diff(&base(), &next),
vec![RepoEvent::ConflictChanged { conflicted: true }],
"Clear→Conflict must not also emit OperationChanged"
);
let mut cleared = base();
cleared.operation = OperationState::Clear;
cleared.conflicted = false;
let mut from = base();
from.operation = OperationState::Conflict;
from.conflicted = true;
assert_eq!(
diff(&from, &cleared),
vec![RepoEvent::ConflictChanged { conflicted: false }]
);
}
#[test]
fn git_merge_with_conflict_emits_both_operation_and_conflict() {
let mut next = base();
next.operation = OperationState::Merge;
next.conflicted = true;
assert_eq!(
diff(&base(), &next),
vec![
RepoEvent::OperationChanged {
from: OperationState::Clear,
to: OperationState::Merge,
},
RepoEvent::ConflictChanged { conflicted: true },
]
);
}
#[test]
fn multiple_changes_emit_in_stable_order() {
let mut prev = base();
prev.dirty = true;
prev.change_count = 2;
let mut next = base(); next.head = Some("cccc".into());
assert_eq!(
diff(&prev, &next),
vec![
RepoEvent::HeadMoved {
from: Some("aaaa".into()),
to: Some("cccc".into()),
},
RepoEvent::WorkingCopyChanged {
dirty: false,
change_count: 0,
},
]
);
}
}