use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::error::Result;
use crate::model::RevisionId;
use crate::session::event::ShoreEvent;
use crate::session::projection::commit_range::RevisionCommitRangeProjection;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitOidGroupingProjection {
pub groups: BTreeMap<String, BTreeSet<RevisionId>>,
}
impl CommitOidGroupingProjection {
pub fn from_events(events: &[ShoreEvent]) -> Result<Self> {
let commit_range = RevisionCommitRangeProjection::from_events(events)?;
let mut groups: BTreeMap<String, BTreeSet<RevisionId>> = BTreeMap::new();
for (revision_id, view) in &commit_range.units {
for current in &view.current_commits {
groups
.entry(current.commit_oid.clone())
.or_default()
.insert(revision_id.clone());
}
}
Ok(Self { groups })
}
pub fn group_for(&self, commit_oid: &str) -> Option<&BTreeSet<RevisionId>> {
self.groups.get(commit_oid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
CommitRangeCaptureMode, EngagementId, JournalId, ObjectId, ReviewEndpoint, ReviewTargetRef,
RevisionId, RevisionSource, WorktreeCaptureMode,
};
use crate::session::event::{
EventTarget, EventType, GitProvenance, Revision, RevisionCommitAssociatedPayload,
RevisionCommitWithdrawnPayload, ShoreEvent, WorkObjectProposal, WorkObjectProposedPayload,
Writer, build_commit_association_id, build_commit_withdrawal_id,
};
fn envelope(unit: &RevisionId) -> EventTarget {
EventTarget::for_revision(JournalId::new("journal:default"), unit.clone(), None).unwrap()
}
fn capture_for(
unit: &RevisionId,
target: ReviewEndpoint,
source: RevisionSource,
) -> ShoreEvent {
ShoreEvent::new(
EventType::WorkObjectProposed,
format!("work_object_proposed:{}", unit.as_str()),
envelope(unit),
Writer::shore_local("test"),
WorkObjectProposedPayload {
engagement_id: EngagementId::new(format!(
"engagement:sha256:{}",
crate::canonical_hash::sha256_bytes_hex(unit.as_str().as_bytes())
)),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: unit.clone(),
object_id: ObjectId::new("snap:git:sha256:ghi"),
git_provenance: Some(GitProvenance {
source,
base: ReviewEndpoint::GitCommit {
commit_oid: "base".to_owned(),
tree_oid: "base-tree".to_owned(),
},
target,
}),
},
object_artifact_content_hash: "sha256:artifact".to_owned(),
supersedes: vec![],
},
},
"2026-06-19T00:00:00Z",
)
.unwrap()
}
fn worktree_capture_for(unit: &RevisionId) -> ShoreEvent {
capture_for(
unit,
ReviewEndpoint::GitWorkingTree {
worktree_root: "/repo".to_owned(),
},
RevisionSource::GitWorktree {
mode: WorktreeCaptureMode::CombinedHeadToWorkingTree,
include_untracked: true,
pathspecs: Vec::new(),
},
)
}
fn commit_range_capture_for(unit: &RevisionId, commit_oid: &str, tree_oid: &str) -> ShoreEvent {
capture_for(
unit,
ReviewEndpoint::GitCommit {
commit_oid: commit_oid.to_owned(),
tree_oid: tree_oid.to_owned(),
},
RevisionSource::GitCommitRange {
mode: CommitRangeCaptureMode::BaseTreeToTargetTree,
pathspecs: Vec::new(),
},
)
}
fn commit_associated_for(unit: &RevisionId, commit_oid: &str) -> ShoreEvent {
let cid = build_commit_association_id(unit, commit_oid).unwrap();
ShoreEvent::new(
EventType::RevisionCommitAssociated,
RevisionCommitAssociatedPayload::idempotency_key(unit, commit_oid),
envelope(unit),
Writer::shore_local("test"),
RevisionCommitAssociatedPayload {
commit_association_id: cid,
target: ReviewTargetRef::Revision {
revision_id: unit.clone(),
},
commit: ReviewEndpoint::GitCommit {
commit_oid: commit_oid.to_owned(),
tree_oid: format!("{commit_oid}-tree"),
},
},
"2026-06-19T00:00:01Z",
)
.unwrap()
}
fn commit_withdrawn_for(unit: &RevisionId, commit_oid: &str) -> ShoreEvent {
let cid = build_commit_association_id(unit, commit_oid).unwrap();
let wid = build_commit_withdrawal_id(unit, &cid).unwrap();
ShoreEvent::new(
EventType::RevisionCommitWithdrawn,
RevisionCommitWithdrawnPayload::idempotency_key(&cid),
envelope(unit),
Writer::shore_local("test"),
RevisionCommitWithdrawnPayload {
commit_withdrawal_id: wid,
target: ReviewTargetRef::Revision {
revision_id: unit.clone(),
},
commit_association_id: cid,
},
"2026-06-19T00:00:02Z",
)
.unwrap()
}
#[test]
fn two_units_sharing_a_commit_oid_group_together() {
let unit_a = RevisionId::new("review-unit:sha256:a");
let unit_b = RevisionId::new("review-unit:sha256:b");
let events = [
worktree_capture_for(&unit_a),
commit_associated_for(&unit_a, "oidShared"),
worktree_capture_for(&unit_b),
commit_associated_for(&unit_b, "oidShared"),
];
let grouping = CommitOidGroupingProjection::from_events(&events).unwrap();
let group = grouping
.group_for("oidShared")
.expect("oidShared is grouped");
assert_eq!(group.len(), 2);
assert!(group.contains(&unit_a));
assert!(group.contains(&unit_b));
}
#[test]
fn capture_target_seed_groups_without_an_association_event() {
let unit = RevisionId::new("review-unit:sha256:seed");
let events = [commit_range_capture_for(&unit, "oidSeed", "oidSeed-tree")];
let grouping = CommitOidGroupingProjection::from_events(&events).unwrap();
let group = grouping.group_for("oidSeed").expect("seed OID is grouped");
assert_eq!(group.len(), 1);
assert!(group.contains(&unit));
}
#[test]
fn a_floating_unit_stays_ungrouped() {
let unit = RevisionId::new("review-unit:sha256:floating");
let events = [worktree_capture_for(&unit)];
let grouping = CommitOidGroupingProjection::from_events(&events).unwrap();
assert!(grouping.groups.is_empty());
assert!(
grouping
.groups
.values()
.all(|members| !members.contains(&unit))
);
}
#[test]
fn a_withdrawn_commit_drops_from_its_group() {
let unit = RevisionId::new("review-unit:sha256:withdrawn");
let events = [
worktree_capture_for(&unit),
commit_associated_for(&unit, "oidGone"),
commit_withdrawn_for(&unit, "oidGone"),
];
let grouping = CommitOidGroupingProjection::from_events(&events).unwrap();
assert!(grouping.group_for("oidGone").is_none());
}
}