src/timeline.rs
Ref: Size: 19.8 KiB History
//! The unified event timeline for a patch: one sequence, every kind of event.
//!
//! `patch log` lists revisions. `patch show` lists comments and reviews. Between
//! them they hold everything that happened to a patch and answer, separately,
//! two questions nobody asks. The question a reviewer actually has is *what
//! answered what* — a comment on revision 1 followed by revision 2 is a causal
//! chain, and it is the chain this project's revision anchoring exists to
//! record. Reconstructing it meant reading two commands' output and interleaving
//! them by timestamp by eye.
//!
//! # Where the ordering comes from
//!
//! Events are ordered by the DAG walk, then stable-sorted by Lamport clock.
//! Both halves of that matter:
//!
//! - The walk is `Sort::TOPOLOGICAL | Sort::REVERSE`, the same order
//! `PatchState::from_ref_uncached` folds in, which is what guarantees this
//! view can never number a revision differently from `patch log`.
//! - Clocks are monotonic along parent links, so for the linear DAG every patch
//! in the wild actually has, sorting by clock changes nothing. Where it earns
//! its keep is a DAG joined from a fork: topological order emits one branch
//! then the other, and the clock interleaves them back into the order things
//! happened.
//!
//! Sorting by `(clock, oid)` — the total order the status fold resolves
//! conflicts with — was the obvious alternative and is wrong here. Events
//! written before clocks existed all carry clock 0, so the oid tiebreak would
//! sort a legacy patch's whole history into hash order. A *stable* sort keyed on
//! the clock alone leaves those events in the only order that is meaningful for
//! them, the one the DAG's parent links record.
//!
//! # Where the content comes from
//!
//! From `PatchState`, not from the raw events: revision numbering, the
//! per-`(author, revision)` vote rule, and every correction have already been
//! resolved there. Re-deriving any of it here would be a second implementation
//! of the same rules, free to drift from the first — and the first is the one
//! `patch show` prints. The walk supplies ordering and the correction events;
//! everything a reader sees is joined onto it by event OID.
//!
//! # Reads do not write
//!
//! Building a timeline appends no event and moves no ref. It resolves the
//! patch's ref, walks it, and folds it — including a patch still in the
//! pre-revision-refs layout, which resolves and walks where it lies rather
//! than being migrated on the way past. (Note `patch show` additionally moves
//! a `refs/collab/local/seen/` read-marker — the timeline deliberately does
//! *not* adopt that pattern and marks nothing read.)
use git2::Repository;
use serde::Serialize;
use crate::error::Error;
use crate::event::{Action, Author};
use crate::state::{self, PatchState};
/// One thing that happened to a patch.
///
/// `revision` is the anchor that makes the sequence causal rather than merely
/// chronological: the revision a comment or review was made against, or the
/// revision a revision entry *is*. `None` only where the event is not about a
/// revision at all (a label, a correction, the merge).
#[derive(Debug, Clone, Serialize)]
pub struct Entry {
#[serde(flatten)]
pub kind: Kind,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<u32>,
pub author: Author,
pub timestamp: String,
/// The event's own OID, so a caller can name it to `edit-comment`.
pub id: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Kind {
Revision {
commit: String,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
edited: bool,
},
Comment {
body: String,
edited: bool,
deleted: bool,
},
InlineComment {
file: String,
line: u32,
body: String,
edited: bool,
deleted: bool,
},
Review {
verdict: String,
body: String,
edited: bool,
},
Label {
label: String,
},
Unlabel {
label: String,
},
/// A `BodyEdit` that the fold honoured, naming what it corrected.
Edited {
target: String,
},
/// A `CommentDelete` that the fold honoured.
Deleted {
target: String,
},
/// A claim that an inline comment was answered.
///
/// Every one is listed, including a claim that later lost the
/// `(clock, oid)` race to a reopen. This is the one place the timeline
/// deliberately shows more than the standing state: "the author said this
/// was fixed and the reviewer disagreed" is the history the patch model
/// exists to keep, and showing only the winner would collapse it back into
/// the lost state transition a force-push produces.
///
/// That is also why there is no `authorized` filter here, unlike `Edited`
/// and `Deleted`. Those are hidden when refused because they never took
/// effect on anyone's words; a resolution is a signed claim by its own
/// author, and the fold honours it whoever wrote it.
Resolved {
target: String,
},
/// A withdrawal of a resolution.
///
/// Named for the CLI verb (`patch unresolve`) rather than "reopened",
/// which this timeline already uses for a patch being reopened. Two
/// unrelated things sharing one word in the same column is how a reader
/// concludes the patch was reopened when a comment was.
CommentUnresolved {
target: String,
},
Closed {
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Merged {
#[serde(skip_serializing_if = "Option::is_none")]
commit: Option<String>,
},
/// A `PatchReopen`. Listed for the same reason `Closed` and `Merged` are:
/// a timeline that ended at "merged" while the patch reads `open` would be
/// the one view of a patch that disagrees with every other.
Reopened,
}
impl Kind {
/// The fixed-width label opening a rendered line.
fn label(&self, revision: Option<u32>) -> String {
match self {
Kind::Revision { .. } => format!("r{}", revision.unwrap_or(0)),
Kind::Comment { .. } => "comment".to_string(),
Kind::InlineComment { .. } => "inline".to_string(),
Kind::Review { .. } => "review".to_string(),
Kind::Label { .. } => "label".to_string(),
Kind::Unlabel { .. } => "unlabel".to_string(),
Kind::Edited { .. } => "edited".to_string(),
Kind::Deleted { .. } => "deleted".to_string(),
Kind::Resolved { .. } => "resolved".to_string(),
Kind::CommentUnresolved { .. } => "unresolved".to_string(),
Kind::Closed { .. } => "closed".to_string(),
Kind::Merged { .. } => "merged".to_string(),
Kind::Reopened => "reopened".to_string(),
}
}
}
/// Build the timeline for the patch `id_prefix` resolves to.
pub fn build(repo: &Repository, id_prefix: &str) -> Result<(PatchState, Vec<Entry>), Error> {
let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
let patch = PatchState::from_ref(repo, &ref_name, &id)?;
let events = crate::dag::walk_events(repo, &ref_name)?;
// Who wrote each body-carrying event, so a correction naming one can be
// held to the same rule the fold applies: only the author of a body may
// correct it. Everything else is dropped in silence, exactly as
// `BodyOverrides::authorized` drops it — a timeline that listed a refused
// edit would be reporting a rewrite of someone else's words that never
// took effect.
let mut owners: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (oid, event) in &events {
if carries_a_body(&event.action) {
owners.insert(oid.to_string(), event.author.email.clone());
}
}
// Derived content, indexed by the event that produced it. Joining on the
// OID is what keeps this view's text identical to `patch show`'s.
let comments: std::collections::HashMap<String, &state::Comment> = patch
.comments
.iter()
.map(|c| (c.commit_id.to_string(), c))
.collect();
let inline: std::collections::HashMap<String, &state::InlineComment> = patch
.inline_comments
.iter()
.map(|c| (c.commit_id.to_string(), c))
.collect();
let reviews: std::collections::HashMap<String, &state::Review> = patch
.reviews
.iter()
.map(|r| (r.commit_id.to_string(), r))
.collect();
let revisions: std::collections::HashMap<String, &state::Revision> = patch
.revisions
.iter()
.filter(|r| !r.event_id.is_empty())
.map(|r| (r.event_id.clone(), r))
.collect();
let mut entries: Vec<(u64, Entry)> = Vec::new();
for (oid, event) in &events {
let oid_hex = oid.to_string();
let author = event.author.clone();
let timestamp = event.timestamp.clone();
let (kind, revision) = match &event.action {
Action::PatchCreate { .. }
| Action::PatchRevision { .. }
| Action::PatchRevise { .. } => {
// A revision the fold discarded as a duplicate commit has no
// entry here, which is right: it added nothing to the patch.
let Some(rev) = revisions.get(&oid_hex) else {
continue;
};
(
Kind::Revision {
commit: rev.commit.clone(),
body: rev.body.clone(),
edited: rev.edited,
},
Some(rev.number),
)
}
Action::PatchComment { .. } => {
let Some(c) = comments.get(&oid_hex) else {
continue;
};
(
Kind::Comment {
body: c.body.clone(),
edited: c.edited,
deleted: c.deleted,
},
// Anchored below, once the sequence is in order: a thread
// comment carries no revision of its own.
None,
)
}
Action::PatchInlineComment { .. } => {
let Some(c) = inline.get(&oid_hex) else {
continue;
};
(
Kind::InlineComment {
file: c.file.clone(),
line: c.line,
body: c.body.clone(),
edited: c.edited,
deleted: c.deleted,
},
c.revision,
)
}
Action::PatchReview { .. } => {
// A superseded vote is gone from `patch show`, so it is gone
// from here too; the two must not disagree about what stands.
let Some(r) = reviews.get(&oid_hex) else {
continue;
};
(
Kind::Review {
verdict: r.verdict.as_str().to_string(),
body: r.body.clone(),
edited: r.edited,
},
// `None` for a review written before reviews were
// revision-scoped. It stays unanchored: the anchoring pass
// below deliberately only touches thread comments, because
// filling in a review's revision from its position is the
// attribution that issue 33b5e541 is about.
r.revision,
)
}
Action::PatchLabel { label } => (
Kind::Label {
label: label.clone(),
},
None,
),
Action::PatchUnlabel { label } => (
Kind::Unlabel {
label: label.clone(),
},
None,
),
Action::BodyEdit { target, .. } => {
if !authorized(&owners, target, &author) {
continue;
}
(
Kind::Edited {
target: target.clone(),
},
None,
)
}
Action::CommentDelete { target } => {
if !authorized(&owners, target, &author) {
continue;
}
(
Kind::Deleted {
target: target.clone(),
},
None,
)
}
Action::PatchCommentResolve { comment, revision } => (
Kind::Resolved {
target: comment.clone(),
},
// The revision the claim was made against, so the sequence
// reads causally: comment on r1, revision r2, resolved at r2.
*revision,
),
Action::PatchCommentReopen { comment } => (
Kind::CommentUnresolved {
target: comment.clone(),
},
None,
),
Action::PatchClose { reason } => (
Kind::Closed {
reason: reason.clone(),
},
None,
),
Action::PatchMerge { commit } => (
Kind::Merged {
commit: (!commit.is_empty()).then(|| commit.clone()),
},
None,
),
Action::PatchReopen => (Kind::Reopened, None),
_ => continue,
};
entries.push((
event.clock,
Entry {
kind,
revision,
author,
timestamp,
id: oid_hex,
},
));
}
// Stable, so events sharing a clock — every event on a pre-clock patch —
// keep the DAG's own order rather than being shuffled by the sort.
entries.sort_by_key(|(clock, _)| *clock);
let mut entries: Vec<Entry> = entries.into_iter().map(|(_, e)| e).collect();
// Anchor thread comments to the revision that was current where they sit in
// the sequence — which is the revision their author was in fact looking at.
//
// By position, not by timestamp: timestamps come from whichever machine
// wrote the event, so comparing two authors' clocks decides nothing, and a
// skewed clock would file a comment under the wrong revision.
//
// Thread comments only. A review that recorded no revision stays
// unanchored: attributing one from DAG position is exactly what issue
// `e5096ffc` removed and issue `05b18df6` did not bring back — a comment
// mislabelled by a revision is a display detail, whereas a review is a vote
// and its revision decides which other vote it supersedes.
let mut current = 1;
for entry in &mut entries {
match (&entry.kind, entry.revision) {
(Kind::Revision { .. }, Some(n)) => current = n,
(Kind::Comment { .. }, None) => entry.revision = Some(current),
_ => {}
}
}
Ok((patch, entries))
}
/// Whether `action` carries a body a correction could name.
fn carries_a_body(action: &Action) -> bool {
matches!(
action,
Action::PatchCreate { .. }
| Action::PatchRevision { .. }
| Action::PatchRevise { .. }
| Action::PatchComment { .. }
| Action::PatchInlineComment { .. }
| Action::PatchReview { .. }
)
}
fn authorized(
owners: &std::collections::HashMap<String, String>,
target: &str,
author: &Author,
) -> bool {
owners
.get(target)
.is_some_and(|owner| *owner == author.email)
}
/// Render the timeline as one line per event.
///
/// One line each, because the value of the view is the shape of the sequence;
/// a full body per entry would bury it. `patch show` prints bodies in full.
pub fn to_writer(entries: &[Entry], writer: &mut dyn std::io::Write) -> Result<(), Error> {
if entries.is_empty() {
writeln!(writer, "No events recorded.")?;
return Ok(());
}
for entry in entries {
let label = entry.kind.label(entry.revision);
// The anchor. `patch log` already opens a revision line with `rN`, so
// repeating it as `@rN` there would be noise; everything else needs it
// to say which revision it is about.
let anchor = match (&entry.kind, entry.revision) {
(Kind::Revision { .. }, _) | (_, None) => String::new(),
(_, Some(n)) => format!("@r{} ", n),
};
// Built as parts and joined, so no arm has to reason about whether the
// one before it left a separator behind.
let mut parts: Vec<String> = Vec::new();
match &entry.kind {
Kind::Revision {
commit,
body,
edited,
} => {
parts.push(if commit.is_empty() {
state::UNKNOWN_COMMIT.to_string()
} else {
commit.chars().take(8).collect()
});
if entry.revision == Some(1) {
parts.push("(initial)".to_string());
}
parts.extend(body_part(body.as_deref(), *edited, false));
}
Kind::Comment {
body,
edited,
deleted,
} => parts.extend(body_part(Some(body), *edited, *deleted)),
Kind::InlineComment {
file,
line,
body,
edited,
deleted,
} => {
parts.push(format!("{}:{}", file, line));
parts.extend(body_part(Some(body), *edited, *deleted));
}
Kind::Review {
verdict,
body,
edited,
} => {
parts.push(verdict.clone());
parts.extend(body_part(Some(body), *edited, false));
}
Kind::Label { label } => parts.push(format!("+{}", label)),
Kind::Unlabel { label } => parts.push(format!("-{}", label)),
Kind::Edited { target }
| Kind::Deleted { target }
| Kind::Resolved { target }
| Kind::CommentUnresolved { target } => parts.push(format!("{:.8}", target)),
Kind::Closed { reason } => parts.extend(reason.clone()),
Kind::Merged { commit } => parts.extend(commit.as_deref().map(|c| format!("{:.8}", c))),
Kind::Reopened => {}
}
writeln!(
writer,
"{} {:<8} {} {}{}",
entry.timestamp,
label,
entry.author.name,
anchor,
parts.join(" ")
)?;
}
Ok(())
}
/// A body reduced to one line, with `(edited)` following the words it changed,
/// or the tombstone in place of a deleted one. `None` when there is nothing to
/// show — an empty body contributes no trailing whitespace.
fn body_part(body: Option<&str>, edited: bool, deleted: bool) -> Option<String> {
if deleted {
return Some(crate::TOMBSTONE.to_string());
}
let marker = if edited { " (edited)" } else { "" };
match body {
Some(b) if !b.trim().is_empty() => {
Some(format!("{}{}", crate::patch::summarize_body(b), marker))
}
// A body edited down to nothing still happened; say so rather than
// rendering an entry that looks like it never had one.
_ if edited => Some(marker.trim().to_string()),
_ => None,
}
}
pub fn to_json(entries: &[Entry]) -> Result<String, Error> {
Ok(serde_json::to_string_pretty(entries)?)
}