a73x

06f86b6e

Add revision-aware review with interdiffs

a73x   2026-03-21 19:38

Commit message
Add revision-aware review with interdiffs

CLAUDE.md
Old New
@@ -8,6 +8,7 @@ Auto-generated from all feature plans. Last updated: 2026-03-21
8 - Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 (004-dashboard-filtering) 8 - Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 (004-dashboard-filtering)
9 - N/A (ephemeral filter state, no persistence) (004-dashboard-filtering) 9 - N/A (ephemeral filter state, no persistence) (004-dashboard-filtering)
10 - Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2 (012-patch-branch-refactor) 10 - Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2 (012-patch-branch-refactor)
11 - Git object database (collab DAG refs under `.git/refs/collab/`) (015-gerrit-style-patchsets)
11 12
12 - Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` (001-gpg-event-signing) 13 - Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` (001-gpg-event-signing)
13 14
@@ -27,9 +28,9 @@ cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECH
27 Rust 2021 edition: Follow standard conventions 28 Rust 2021 edition: Follow standard conventions
28 29
29 ## Recent Changes 30 ## Recent Changes
31 - 015-gerrit-style-patchsets: Added Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2
30 - 012-patch-branch-refactor: Added Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2 32 - 012-patch-branch-refactor: Added Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2
31 - 004-dashboard-filtering: Added Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 33 - 004-dashboard-filtering: Added Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19
32 - 003-key-trust-allowlist: Added Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2
33 34
34 35
35 <!-- MANUAL ADDITIONS START --> 36 <!-- MANUAL ADDITIONS START -->
benches/core_ops.rs
Old New
@@ -76,6 +76,8 @@ fn setup_patches(n: usize) -> (Repository, TempDir) {
76 base_ref: "main".to_string(), 76 base_ref: "main".to_string(),
77 branch: format!("feature-{}", i), 77 branch: format!("feature-{}", i),
78 fixes: None, 78 fixes: None,
79 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
80 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
79 }, 81 },
80 clock: 0, 82 clock: 0,
81 }; 83 };
@@ -215,6 +217,8 @@ fn bench_patch_from_ref(c: &mut Criterion) {
215 base_ref: "main".to_string(), 217 base_ref: "main".to_string(),
216 branch: format!("feature-bench-{}", count), 218 branch: format!("feature-bench-{}", count),
217 fixes: None, 219 fixes: None,
220 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
221 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
218 }, 222 },
219 clock: 0, 223 clock: 0,
220 }; 224 };
src/cli.rs
Old New
@@ -266,11 +266,20 @@ pub enum PatchCmd {
266 /// Output as JSON 266 /// Output as JSON
267 #[arg(long)] 267 #[arg(long)]
268 json: bool, 268 json: bool,
269 /// Show only comments/reviews from this revision
270 #[arg(long)]
271 revision: Option<u32>,
269 }, 272 },
270 /// Show diff between base and head 273 /// Show diff between base and head
271 Diff { 274 Diff {
272 /// Patch ID (prefix match) 275 /// Patch ID (prefix match)
273 id: String, 276 id: String,
277 /// Show historical diff for a specific revision against the base
278 #[arg(long)]
279 revision: Option<u32>,
280 /// Show interdiff between two revisions (N M or just N for N..latest)
281 #[arg(long, num_args = 1..=2)]
282 between: Option<Vec<u32>>,
274 }, 283 },
275 /// Comment on a patch (use --file and --line for inline comments) 284 /// Comment on a patch (use --file and --line for inline comments)
276 Comment { 285 Comment {
@@ -285,6 +294,9 @@ pub enum PatchCmd {
285 /// Line number for inline comment 294 /// Line number for inline comment
286 #[arg(short, long)] 295 #[arg(short, long)]
287 line: Option<u32>, 296 line: Option<u32>,
297 /// Target revision for inline comment
298 #[arg(long)]
299 revision: Option<u32>,
288 }, 300 },
289 /// Review a patch 301 /// Review a patch
290 Review { 302 Review {
@@ -296,15 +308,26 @@ pub enum PatchCmd {
296 /// Review body 308 /// Review body
297 #[arg(short, long)] 309 #[arg(short, long)]
298 body: String, 310 body: String,
311 /// Target revision for review
312 #[arg(long)]
313 revision: Option<u32>,
299 }, 314 },
300 /// Revise a patch (record a revision note) 315 /// Revise a patch (record a new revision snapshot)
301 Revise { 316 Revise {
302 /// Patch ID (prefix match) 317 /// Patch ID (prefix match)
303 id: String, 318 id: String,
304 /// Updated description 319 /// Revision description
305 #[arg(short, long)] 320 #[arg(short, long)]
306 body: Option<String>, 321 body: Option<String>,
307 }, 322 },
323 /// Show revision log for a patch
324 Log {
325 /// Patch ID (prefix match)
326 id: String,
327 /// Output as JSON
328 #[arg(long)]
329 json: bool,
330 },
308 /// Merge a patch into its base branch 331 /// Merge a patch into its base branch
309 Merge { 332 Merge {
310 /// Patch ID (prefix match) 333 /// Patch ID (prefix match)
src/dag.rs
Old New
@@ -332,7 +332,7 @@ fn commit_message(action: &Action) -> String {
332 Action::IssueClose { .. } => "issue: close".to_string(), 332 Action::IssueClose { .. } => "issue: close".to_string(),
333 Action::IssueReopen => "issue: reopen".to_string(), 333 Action::IssueReopen => "issue: reopen".to_string(),
334 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title), 334 Action::PatchCreate { title, .. } => format!("patch: create \"{}\"", title),
335 Action::PatchRevise { .. } => "patch: revise".to_string(), 335 Action::PatchRevision { .. } => "patch: revision".to_string(),
336 Action::PatchReview { verdict, .. } => format!("patch: review ({:?})", verdict), 336 Action::PatchReview { verdict, .. } => format!("patch: review ({:?})", verdict),
337 Action::PatchComment { .. } => "patch: comment".to_string(), 337 Action::PatchComment { .. } => "patch: comment".to_string(),
338 Action::PatchInlineComment { ref file, line, .. } => { 338 Action::PatchInlineComment { ref file, line, .. } => {
src/event.rs
Old New
@@ -65,15 +65,21 @@ pub enum Action {
65 branch: String, 65 branch: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")] 66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 fixes: Option<String>, 67 fixes: Option<String>,
68 commit: String,
69 tree: String,
68 }, 70 },
69 #[serde(rename = "patch.revise")] 71 #[serde(rename = "patch.revision")]
70 PatchRevise { 72 PatchRevision {
73 commit: String,
74 tree: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
71 body: Option<String>, 76 body: Option<String>,
72 }, 77 },
73 #[serde(rename = "patch.review")] 78 #[serde(rename = "patch.review")]
74 PatchReview { 79 PatchReview {
75 verdict: ReviewVerdict, 80 verdict: ReviewVerdict,
76 body: String, 81 body: String,
82 revision: u32,
77 }, 83 },
78 #[serde(rename = "patch.comment")] 84 #[serde(rename = "patch.comment")]
79 PatchComment { 85 PatchComment {
@@ -84,6 +90,7 @@ pub enum Action {
84 file: String, 90 file: String,
85 line: u32, 91 line: u32,
86 body: String, 92 body: String,
93 revision: u32,
87 }, 94 },
88 #[serde(rename = "patch.close")] 95 #[serde(rename = "patch.close")]
89 PatchClose { 96 PatchClose {
@@ -100,4 +107,5 @@ pub enum ReviewVerdict {
100 Approve, 107 Approve,
101 RequestChanges, 108 RequestChanges,
102 Comment, 109 Comment,
110 Reject,
103 } 111 }
src/lib.rs
Old New
@@ -22,6 +22,27 @@ use event::ReviewVerdict;
22 use git2::Repository; 22 use git2::Repository;
23 use state::{IssueStatus, PatchStatus}; 23 use state::{IssueStatus, PatchStatus};
24 24
25 /// Check if the reviewer's base ref has moved ahead of the patch's latest revision.
26 pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> {
27 let latest_commit = &patch.revisions.last()?.commit;
28 let commit_oid = git2::Oid::from_str(latest_commit).ok()?;
29 let base_ref = format!("refs/heads/{}", patch.base_ref);
30 let base_tip = repo.refname_to_id(&base_ref).ok()?;
31 let merge_base = repo.merge_base(commit_oid, base_tip).ok()?;
32 if merge_base == base_tip {
33 return None;
34 }
35 let (ahead, _) = repo.graph_ahead_behind(base_tip, merge_base).ok()?;
36 if ahead == 0 {
37 return None;
38 }
39 let mb_short = &merge_base.to_string()[..8];
40 Some(format!(
41 "\u{26a0} Based on {}@{} ({} commits behind your {})",
42 patch.base_ref, mb_short, ahead, patch.base_ref
43 ))
44 }
45
25 pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { 46 pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
26 match cli.command { 47 match cli.command {
27 Commands::Init => sync::init(repo), 48 Commands::Init => sync::init(repo),
@@ -209,7 +230,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
209 } 230 }
210 Ok(()) 231 Ok(())
211 } 232 }
212 PatchCmd::Show { id, json } => { 233 PatchCmd::Show { id, json, revision } => {
213 if json { 234 if json {
214 let output = patch::show_json(repo, &id)?; 235 let output = patch::show_json(repo, &id)?;
215 println!("{}", output); 236 println!("{}", output);
@@ -221,7 +242,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
221 PatchStatus::Closed => "closed", 242 PatchStatus::Closed => "closed",
222 PatchStatus::Merged => "merged", 243 PatchStatus::Merged => "merged",
223 }; 244 };
224 println!("Patch {} [{}]", &p.id[..8], status); 245 let rev_count = p.revisions.len();
246 println!("Patch {} [{}] (r{})", &p.id[..8], status, rev_count);
225 println!("Title: {}", p.title); 247 println!("Title: {}", p.title);
226 println!("Author: {} <{}>", p.author.name, p.author.email); 248 println!("Author: {} <{}>", p.author.name, p.author.email);
227 match p.resolve_head(repo) { 249 match p.resolve_head(repo) {
@@ -240,27 +262,55 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
240 if let Some(ref fixes) = p.fixes { 262 if let Some(ref fixes) = p.fixes {
241 println!("Fixes: {:.8}", fixes); 263 println!("Fixes: {:.8}", fixes);
242 } 264 }
265 // Staleness warning
266 if let Some(warning) = staleness_warning(repo, &p) {
267 eprintln!("{}", warning);
268 }
269 // Show revisions
270 if !p.revisions.is_empty() {
271 println!("\n--- Revisions ---");
272 for rev in &p.revisions {
273 let short = if rev.commit.len() >= 8 { &rev.commit[..8] } else { &rev.commit };
274 let body_display = rev.body.as_deref().map(|b| format!(" {}", b)).unwrap_or_default();
275 println!(" r{}: {} ({}){}", rev.number, short, rev.timestamp, body_display);
276 }
277 }
243 if !p.body.is_empty() { 278 if !p.body.is_empty() {
244 println!("\n{}", p.body); 279 println!("\n{}", p.body);
245 } 280 }
246 if !p.reviews.is_empty() { 281 // Filter reviews by revision if requested
282 let reviews: Vec<_> = if let Some(rev) = revision {
283 p.reviews.iter().filter(|r| r.revision == Some(rev)).collect()
284 } else {
285 p.reviews.iter().collect()
286 };
287 if !reviews.is_empty() {
247 println!("\n--- Reviews ---"); 288 println!("\n--- Reviews ---");
248 for r in &p.reviews { 289 for r in &reviews {
290 let rev_label = r.revision.map(|n| format!(" (r{})", n)).unwrap_or_default();
249 println!( 291 println!(
250 "\n{} ({:?}) - {}:\n{}", 292 "\n{} ({:?}) - {}{}:\n{}",
251 r.author.name, r.verdict, r.timestamp, r.body 293 r.author.name, r.verdict, r.timestamp, rev_label, r.body
252 ); 294 );
253 } 295 }
254 } 296 }
255 if !p.inline_comments.is_empty() { 297 // Filter inline comments by revision if requested
298 let inline_comments: Vec<_> = if let Some(rev) = revision {
299 p.inline_comments.iter().filter(|c| c.revision == Some(rev)).collect()
300 } else {
301 p.inline_comments.iter().collect()
302 };
303 if !inline_comments.is_empty() {
256 println!("\n--- Inline Comments ---"); 304 println!("\n--- Inline Comments ---");
257 for c in &p.inline_comments { 305 for c in &inline_comments {
306 let rev_label = c.revision.map(|n| format!(" r{}", n)).unwrap_or_default();
258 println!( 307 println!(
259 "\n{} on {}:{} ({}):\n {}", 308 "\n{} on {}:{} ({}{}):\n {}",
260 c.author.name, c.file, c.line, c.timestamp, c.body 309 c.author.name, c.file, c.line, c.timestamp, rev_label, c.body
261 ); 310 );
262 } 311 }
263 } 312 }
313 // Thread comments always shown
264 if !p.comments.is_empty() { 314 if !p.comments.is_empty() {
265 println!("\n--- Comments ---"); 315 println!("\n--- Comments ---");
266 for c in &p.comments { 316 for c in &p.comments {
@@ -269,8 +319,18 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
269 } 319 }
270 Ok(()) 320 Ok(())
271 } 321 }
272 PatchCmd::Diff { id } => { 322 PatchCmd::Diff { id, revision, between } => {
273 let diff = patch::diff(repo, &id)?; 323 if revision.is_some() && between.is_some() {
324 return Err(error::Error::Cmd(
325 "--revision and --between are mutually exclusive".to_string(),
326 ));
327 }
328 let between_pair = between.map(|v| {
329 let from = v[0];
330 let to = v.get(1).copied();
331 (from, to)
332 });
333 let diff = patch::diff(repo, &id, revision, between_pair)?;
274 if diff.is_empty() { 334 if diff.is_empty() {
275 println!("No diff available (commits may be identical)."); 335 println!("No diff available (commits may be identical).");
276 } else { 336 } else {
@@ -283,24 +343,26 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
283 body, 343 body,
284 file, 344 file,
285 line, 345 line,
346 revision,
286 } => { 347 } => {
287 patch::comment(repo, &id, &body, file.as_deref(), line)?; 348 patch::comment(repo, &id, &body, file.as_deref(), line, revision)?;
288 println!("Comment added."); 349 println!("Comment added.");
289 Ok(()) 350 Ok(())
290 } 351 }
291 PatchCmd::Review { id, verdict, body } => { 352 PatchCmd::Review { id, verdict, body, revision } => {
292 let v = match verdict.as_str() { 353 let v = match verdict.as_str() {
293 "approve" => ReviewVerdict::Approve, 354 "approve" => ReviewVerdict::Approve,
294 "request-changes" => ReviewVerdict::RequestChanges, 355 "request-changes" => ReviewVerdict::RequestChanges,
295 "comment" => ReviewVerdict::Comment, 356 "comment" => ReviewVerdict::Comment,
357 "reject" => ReviewVerdict::Reject,
296 _ => { 358 _ => {
297 return Err(git2::Error::from_str( 359 return Err(git2::Error::from_str(
298 "verdict must be: approve, request-changes, or comment", 360 "verdict must be: approve, request-changes, comment, or reject",
299 ) 361 )
300 .into()); 362 .into());
301 } 363 }
302 }; 364 };
303 patch::review(repo, &id, v, &body)?; 365 patch::review(repo, &id, v, &body, revision)?;
304 println!("Review submitted."); 366 println!("Review submitted.");
305 Ok(()) 367 Ok(())
306 } 368 }
@@ -309,6 +371,16 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
309 println!("Patch revised."); 371 println!("Patch revised.");
310 Ok(()) 372 Ok(())
311 } 373 }
374 PatchCmd::Log { id, json } => {
375 let p = patch::patch_log(repo, &id)?;
376 if json {
377 let output = patch::patch_log_json(&p)?;
378 println!("{}", output);
379 } else {
380 patch::patch_log_to_writer(repo, &p, &mut std::io::stdout())?;
381 }
382 Ok(())
383 }
312 PatchCmd::Merge { id } => { 384 PatchCmd::Merge { id } => {
313 let p = patch::merge(repo, &id)?; 385 let p = patch::merge(repo, &id)?;
314 println!("Patch {:.8} merged into {}.", p.id, p.base_ref); 386 println!("Patch {:.8} merged into {}.", p.id, p.base_ref);
src/log.rs
Old New
@@ -116,7 +116,7 @@ fn action_type_name(action: &Action) -> String {
116 Action::IssueUnassign { .. } => "IssueUnassign".to_string(), 116 Action::IssueUnassign { .. } => "IssueUnassign".to_string(),
117 Action::IssueReopen => "IssueReopen".to_string(), 117 Action::IssueReopen => "IssueReopen".to_string(),
118 Action::PatchCreate { .. } => "PatchCreate".to_string(), 118 Action::PatchCreate { .. } => "PatchCreate".to_string(),
119 Action::PatchRevise { .. } => "PatchRevise".to_string(), 119 Action::PatchRevision { .. } => "PatchRevision".to_string(),
120 Action::PatchReview { .. } => "PatchReview".to_string(), 120 Action::PatchReview { .. } => "PatchReview".to_string(),
121 Action::PatchComment { .. } => "PatchComment".to_string(), 121 Action::PatchComment { .. } => "PatchComment".to_string(),
122 Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(), 122 Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(),
@@ -150,9 +150,9 @@ fn action_summary(action: &Action) -> String {
150 Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee), 150 Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee),
151 Action::IssueReopen => "reopen".to_string(), 151 Action::IssueReopen => "reopen".to_string(),
152 Action::PatchCreate { title, .. } => format!("create \"{}\"", title), 152 Action::PatchCreate { title, .. } => format!("create \"{}\"", title),
153 Action::PatchRevise { body, .. } => match body { 153 Action::PatchRevision { body, .. } => match body {
154 Some(b) => format!("revise: {}", truncate(b, 50)), 154 Some(b) => format!("revision: {}", truncate(b, 50)),
155 None => "revise".to_string(), 155 None => "revision".to_string(),
156 }, 156 },
157 Action::PatchReview { verdict, .. } => format!("review: {:?}", verdict), 157 Action::PatchReview { verdict, .. } => format!("review: {:?}", verdict),
158 Action::PatchComment { body } => truncate(body, 60), 158 Action::PatchComment { body } => truncate(body, 60),
src/patch.rs
Old New
@@ -1,4 +1,4 @@
1 use git2::{DiffFormat, Repository}; 1 use git2::{DiffFormat, Oid, Repository};
2 2
3 use crate::cli::SortMode; 3 use crate::cli::SortMode;
4 use crate::dag; 4 use crate::dag;
@@ -6,7 +6,69 @@ use crate::error::Error;
6 use crate::event::{Action, Event, ReviewVerdict}; 6 use crate::event::{Action, Event, ReviewVerdict};
7 use crate::identity::get_author; 7 use crate::identity::get_author;
8 use crate::signing; 8 use crate::signing;
9 use crate::state::{self, PatchState, PatchStatus}; 9 use crate::state::{self, PatchState, PatchStatus, Revision};
10
11 /// Auto-detect whether the branch tip has changed since the last recorded revision.
12 /// If it has, append a PatchRevision event and return the new `Revision` so callers
13 /// can update their in-memory `PatchState` without re-walking the DAG.
14 fn auto_detect_revision(
15 repo: &Repository,
16 ref_name: &str,
17 patch: &PatchState,
18 sk: &ed25519_dalek::SigningKey,
19 ) -> Result<Option<Revision>, Error> {
20 let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0);
21
22 // Try to resolve the branch tip
23 let tip_oid = match patch.resolve_head(repo) {
24 Ok(oid) => oid,
25 Err(_) => return Ok(None), // Branch deleted or unavailable
26 };
27
28 let last_commit = patch.revisions.last().map(|r| r.commit.as_str()).unwrap_or("");
29 let tip_hex = tip_oid.to_string();
30
31 if tip_hex == last_commit {
32 return Ok(None);
33 }
34
35 // Branch tip changed — insert a PatchRevision event
36 let commit = repo.find_commit(tip_oid)?;
37 let tree_oid = commit.tree()?.id();
38 let author = get_author(repo)?;
39 let timestamp = chrono::Utc::now().to_rfc3339();
40 let event = Event {
41 timestamp: timestamp.clone(),
42 author,
43 action: Action::PatchRevision {
44 commit: tip_hex.clone(),
45 tree: tree_oid.to_string(),
46 body: None,
47 },
48 clock: 0,
49 };
50 dag::append_event(repo, ref_name, &event, sk)?;
51 Ok(Some(Revision {
52 number: current_rev + 1,
53 commit: tip_hex,
54 tree: tree_oid.to_string(),
55 body: None,
56 timestamp,
57 }))
58 }
59
60 /// Auto-detect revision changes and update the in-memory PatchState in place.
61 fn auto_detect_and_update(
62 repo: &Repository,
63 ref_name: &str,
64 patch: &mut PatchState,
65 sk: &ed25519_dalek::SigningKey,
66 ) -> Result<(), Error> {
67 if let Some(rev) = auto_detect_revision(repo, ref_name, patch, sk)? {
68 patch.revisions.push(rev);
69 }
70 Ok(())
71 }
10 72
11 pub fn create( 73 pub fn create(
12 repo: &Repository, 74 repo: &Repository,
@@ -23,9 +85,9 @@ pub fn create(
23 )); 85 ));
24 } 86 }
25 87
26 // Verify branch exists 88 // Verify branch exists and get tip
27 let branch_ref = format!("refs/heads/{}", branch); 89 let branch_ref = format!("refs/heads/{}", branch);
28 repo.refname_to_id(&branch_ref) 90 let tip_oid = repo.refname_to_id(&branch_ref)
29 .map_err(|e| crate::error::Error::Cmd(format!("branch '{}' not found: {}", branch, e)))?; 91 .map_err(|e| crate::error::Error::Cmd(format!("branch '{}' not found: {}", branch, e)))?;
30 92
31 // Check for duplicate: scan open patches for matching branch 93 // Check for duplicate: scan open patches for matching branch
@@ -39,6 +101,10 @@ pub fn create(
39 } 101 }
40 } 102 }
41 103
104 // Get commit and tree OIDs for revision 1
105 let commit = repo.find_commit(tip_oid)?;
106 let tree_oid = commit.tree()?.id();
107
42 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; 108 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
43 let author = get_author(repo)?; 109 let author = get_author(repo)?;
44 let event = Event { 110 let event = Event {
@@ -50,6 +116,8 @@ pub fn create(
50 base_ref: base_ref.to_string(), 116 base_ref: base_ref.to_string(),
51 branch: branch.to_string(), 117 branch: branch.to_string(),
52 fixes: fixes.map(|s| s.to_string()), 118 fixes: fixes.map(|s| s.to_string()),
119 commit: tip_oid.to_string(),
120 tree: tree_oid.to_string(),
53 }, 121 },
54 clock: 0, 122 clock: 0,
55 }; 123 };
@@ -155,26 +223,53 @@ pub fn comment(
155 body: &str, 223 body: &str,
156 file: Option<&str>, 224 file: Option<&str>,
157 line: Option<u32>, 225 line: Option<u32>,
226 target_revision: Option<u32>,
158 ) -> Result<(), crate::error::Error> { 227 ) -> Result<(), crate::error::Error> {
159 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; 228 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
160 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 229 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
230 let mut patch = PatchState::from_ref(repo, &ref_name, &id)?;
231
232 // Auto-detect revision (runs for all comment types to record branch changes)
233 auto_detect_and_update(repo, &ref_name, &mut patch, &sk)?;
234
161 let author = get_author(repo)?; 235 let author = get_author(repo)?;
162 236
163 let action = match (file, line) { 237 let action = match (file, line) {
164 (Some(f), Some(l)) => Action::PatchInlineComment { 238 (Some(f), Some(l)) => {
165 file: f.to_string(), 239 // Determine revision for the inline comment
166 line: l, 240 let rev = if let Some(target) = target_revision {
167 body: body.to_string(), 241 // Validate target revision exists
168 }, 242 if !patch.revisions.iter().any(|r| r.number == target) {
243 return Err(Error::Cmd(format!("revision {} not found", target)));
244 }
245 target
246 } else {
247 patch.revisions.last().map(|r| r.number).unwrap_or(1)
248 };
249 Action::PatchInlineComment {
250 file: f.to_string(),
251 line: l,
252 body: body.to_string(),
253 revision: rev,
254 }
255 }
169 (Some(_), None) | (None, Some(_)) => { 256 (Some(_), None) | (None, Some(_)) => {
170 return Err(git2::Error::from_str( 257 return Err(git2::Error::from_str(
171 "--file and --line must both be provided for inline comments", 258 "--file and --line must both be provided for inline comments",
172 ) 259 )
173 .into()); 260 .into());
174 } 261 }
175 (None, None) => Action::PatchComment { 262 (None, None) => {
176 body: body.to_string(), 263 // Thread comment — not revision-anchored
177 }, 264 if target_revision.is_some() {
265 return Err(Error::Cmd(
266 "thread comments are not revision-scoped; use --file and --line for inline comments".to_string(),
267 ));
268 }
269 Action::PatchComment {
270 body: body.to_string(),
271 }
272 }
178 }; 273 };
179 274
180 let event = Event { 275 let event = Event {
@@ -192,20 +287,55 @@ pub fn review(
192 id_prefix: &str, 287 id_prefix: &str,
193 verdict: ReviewVerdict, 288 verdict: ReviewVerdict,
194 body: &str, 289 body: &str,
290 target_revision: Option<u32>,
195 ) -> Result<(), crate::error::Error> { 291 ) -> Result<(), crate::error::Error> {
196 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; 292 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
197 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 293 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
294 let mut patch = PatchState::from_ref(repo, &ref_name, &id)?;
295
296 // Auto-detect revision
297 auto_detect_and_update(repo, &ref_name, &mut patch, &sk)?;
298
299 let rev = if let Some(target) = target_revision {
300 if !patch.revisions.iter().any(|r| r.number == target) {
301 return Err(Error::Cmd(format!("revision {} not found", target)));
302 }
303 target
304 } else {
305 patch.revisions.last().map(|r| r.number).unwrap_or(1)
306 };
307
308 let is_reject = verdict == ReviewVerdict::Reject;
198 let author = get_author(repo)?; 309 let author = get_author(repo)?;
199 let event = Event { 310 let event = Event {
200 timestamp: chrono::Utc::now().to_rfc3339(), 311 timestamp: chrono::Utc::now().to_rfc3339(),
201 author, 312 author: author.clone(),
202 action: Action::PatchReview { 313 action: Action::PatchReview {
203 verdict, 314 verdict,
204 body: body.to_string(), 315 body: body.to_string(),
316 revision: rev,
205 }, 317 },
206 clock: 0, 318 clock: 0,
207 }; 319 };
208 dag::append_event(repo, &ref_name, &event, &sk)?; 320 dag::append_event(repo, &ref_name, &event, &sk)?;
321
322 // A reject verdict also closes the patch
323 if is_reject {
324 let close_event = Event {
325 timestamp: chrono::Utc::now().to_rfc3339(),
326 author,
327 action: Action::PatchClose {
328 reason: Some(format!("Rejected: {}", body)),
329 },
330 clock: 0,
331 };
332 dag::append_event(repo, &ref_name, &close_event, &sk)?;
333 // Archive the ref
334 if ref_name.starts_with("refs/collab/patches/") {
335 state::archive_patch_ref(repo, &id)?;
336 }
337 }
338
209 Ok(()) 339 Ok(())
210 } 340 }
211 341
@@ -215,12 +345,31 @@ pub fn revise(
215 body: Option<&str>, 345 body: Option<&str>,
216 ) -> Result<(), crate::error::Error> { 346 ) -> Result<(), crate::error::Error> {
217 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; 347 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
218 let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; 348 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
349 let patch = PatchState::from_ref(repo, &ref_name, &id)?;
350
351 // Resolve current branch tip
352 let tip_oid = patch.resolve_head(repo)?;
353 let tip_hex = tip_oid.to_string();
354 let last_commit = patch.revisions.last().map(|r| r.commit.as_str()).unwrap_or("");
355
356 if tip_hex == last_commit {
357 let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0);
358 return Err(Error::Cmd(format!(
359 "no changes since revision {}",
360 current_rev
361 )));
362 }
363
364 let commit = repo.find_commit(tip_oid)?;
365 let tree_oid = commit.tree()?.id();
219 let author = get_author(repo)?; 366 let author = get_author(repo)?;
220 let event = Event { 367 let event = Event {
221 timestamp: chrono::Utc::now().to_rfc3339(), 368 timestamp: chrono::Utc::now().to_rfc3339(),
222 author, 369 author,
223 action: Action::PatchRevise { 370 action: Action::PatchRevision {
371 commit: tip_hex,
372 tree: tree_oid.to_string(),
224 body: body.map(|s| s.to_string()), 373 body: body.map(|s| s.to_string()),
225 }, 374 },
226 clock: 0, 375 clock: 0,
@@ -232,7 +381,7 @@ pub fn revise(
232 pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { 381 pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
233 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; 382 let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
234 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 383 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
235 let p = PatchState::from_ref(repo, &ref_name, &id)?; 384 let mut p = PatchState::from_ref(repo, &ref_name, &id)?;
236 385
237 if p.status != PatchStatus::Open { 386 if p.status != PatchStatus::Open {
238 return Err(git2::Error::from_str(&format!( 387 return Err(git2::Error::from_str(&format!(
@@ -242,7 +391,25 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
242 .into()); 391 .into());
243 } 392 }
244 393
245 // Resolve the head commit: use branch tip for branch-based patches, stored OID otherwise 394 // Auto-detect revision before merge
395 auto_detect_and_update(repo, &ref_name, &mut p, &sk)?;
396
397 // Check merge policy
398 let config = read_collab_config(repo);
399 if config.require_approval_on_latest {
400 let latest_rev = p.revisions.last().map(|r| r.number).unwrap_or(0);
401 let has_approval = p.reviews.iter().any(|r| {
402 r.verdict == ReviewVerdict::Approve && r.revision == Some(latest_rev)
403 });
404 if !has_approval {
405 return Err(Error::Cmd(format!(
406 "merge requires approval on the latest revision (revision {})",
407 latest_rev
408 )));
409 }
410 }
411
412 // Resolve the head commit
246 let head_oid = p.resolve_head(repo)?; 413 let head_oid = p.resolve_head(repo)?;
247 let head_commit = repo.find_commit(head_oid) 414 let head_commit = repo.find_commit(head_oid)
248 .map_err(|_| git2::Error::from_str("cannot resolve head commit in patch"))?; 415 .map_err(|_| git2::Error::from_str("cannot resolve head commit in patch"))?;
@@ -319,10 +486,23 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
319 } 486 }
320 487
321 /// Generate a unified diff between a patch's base branch and head commit. 488 /// Generate a unified diff between a patch's base branch and head commit.
322 pub fn diff(repo: &Repository, id_prefix: &str) -> Result<String, Error> { 489 pub fn diff(
490 repo: &Repository,
491 id_prefix: &str,
492 revision: Option<u32>,
493 between: Option<(u32, Option<u32>)>,
494 ) -> Result<String, Error> {
323 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 495 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
324 let p = PatchState::from_ref(repo, &ref_name, &id)?; 496 let p = PatchState::from_ref(repo, &ref_name, &id)?;
325 generate_diff(repo, &p) 497
498 if let Some((from, to)) = between {
499 let to_rev = to.unwrap_or_else(|| p.revisions.last().map(|r| r.number).unwrap_or(1));
500 interdiff(repo, &p, from, to_rev)
501 } else if let Some(rev) = revision {
502 generate_diff_at_revision(repo, &p, rev)
503 } else {
504 generate_diff(repo, &p)
505 }
326 } 506 }
327 507
328 /// Generate a diff string from a patch's base and head using three-dot (merge-base) diff. 508 /// Generate a diff string from a patch's base and head using three-dot (merge-base) diff.
@@ -346,12 +526,67 @@ pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<Str
346 }; 526 };
347 527
348 let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; 528 let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
529 format_diff(&git_diff)
530 }
531
532 /// Generate a diff for a specific revision against the base branch (historical full diff).
533 fn generate_diff_at_revision(
534 repo: &Repository,
535 patch: &PatchState,
536 rev_number: u32,
537 ) -> Result<String, Error> {
538 let revision = patch.revisions.iter().find(|r| r.number == rev_number)
539 .ok_or_else(|| Error::Cmd(format!("revision {} not found", rev_number)))?;
540
541 let tree_oid = Oid::from_str(&revision.tree)?;
542 let head_tree = repo.find_tree(tree_oid)?;
543
544 let base_ref = format!("refs/heads/{}", patch.base_ref);
545 let commit_oid = Oid::from_str(&revision.commit)?;
546 let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
547 if let Ok(merge_base_oid) = repo.merge_base(base_oid, commit_oid) {
548 let merge_base_commit = repo.find_commit(merge_base_oid)?;
549 Some(merge_base_commit.tree()?)
550 } else {
551 let base_commit = repo.find_commit(base_oid)?;
552 Some(base_commit.tree()?)
553 }
554 } else {
555 None
556 };
557
558 let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
559 format_diff(&git_diff)
560 }
561
562 /// Compute the interdiff between two revisions.
563 pub fn interdiff(
564 repo: &Repository,
565 patch: &PatchState,
566 from_rev: u32,
567 to_rev: u32,
568 ) -> Result<String, Error> {
569 let from = patch.revisions.iter().find(|r| r.number == from_rev)
570 .ok_or_else(|| Error::Cmd(format!("revision {} not found", from_rev)))?;
571 let to = patch.revisions.iter().find(|r| r.number == to_rev)
572 .ok_or_else(|| Error::Cmd(format!("revision {} not found", to_rev)))?;
573
574 let from_tree_oid = Oid::from_str(&from.tree)?;
575 let to_tree_oid = Oid::from_str(&to.tree)?;
576 let from_tree = repo.find_tree(from_tree_oid)?;
577 let to_tree = repo.find_tree(to_tree_oid)?;
578
579 let git_diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
580 format_diff(&git_diff)
581 }
349 582
583 /// Format a git2::Diff as a unified diff string.
584 fn format_diff(git_diff: &git2::Diff) -> Result<String, Error> {
350 let mut output = String::new(); 585 let mut output = String::new();
351 let mut lines = 0usize; 586 let mut lines = 0usize;
352 git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| { 587 git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
353 if lines >= 5000 { 588 if lines >= 5000 {
354 return true; // stop appending but don't abort libgit2 589 return true;
355 } 590 }
356 let prefix = match line.origin() { 591 let prefix = match line.origin() {
357 '+' => "+", 592 '+' => "+",
@@ -374,6 +609,65 @@ pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<Str
374 Ok(output) 609 Ok(output)
375 } 610 }
376 611
612 /// Patch log: list all revisions with timestamps and file-change summaries.
613 pub fn patch_log(
614 repo: &Repository,
615 id_prefix: &str,
616 ) -> Result<PatchState, Error> {
617 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
618 PatchState::from_ref(repo, &ref_name, &id)
619 }
620
621 pub fn patch_log_to_writer(
622 repo: &Repository,
623 patch: &PatchState,
624 writer: &mut dyn std::io::Write,
625 ) -> Result<(), Error> {
626 if patch.revisions.is_empty() {
627 writeln!(writer, "No revisions recorded.")?;
628 return Ok(());
629 }
630
631 for (i, rev) in patch.revisions.iter().enumerate() {
632 let short_oid = if rev.commit.len() >= 8 { &rev.commit[..8] } else { &rev.commit };
633 let label = if i == 0 { " (initial)" } else { "" };
634 let body_display = rev.body.as_deref().map(|b| format!(" \"{}\"", b)).unwrap_or_default();
635
636 // Compute file-change summary between consecutive revisions
637 let file_summary = if i > 0 {
638 let prev = &patch.revisions[i - 1];
639 match (Oid::from_str(&prev.tree), Oid::from_str(&rev.tree)) {
640 (Ok(from_oid), Ok(to_oid)) => {
641 if let (Ok(from_tree), Ok(to_tree)) = (repo.find_tree(from_oid), repo.find_tree(to_oid)) {
642 if let Ok(diff) = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) {
643 let stats = diff.stats().ok();
644 stats.map(|s| format!(" {} file(s) changed, +{} -{}", s.files_changed(), s.insertions(), s.deletions())).unwrap_or_default()
645 } else {
646 String::new()
647 }
648 } else {
649 String::new()
650 }
651 }
652 _ => String::new(),
653 }
654 } else {
655 String::new()
656 };
657
658 writeln!(
659 writer,
660 "r{} {} {}{}{}{}",
661 rev.number, short_oid, rev.timestamp, label, file_summary, body_display
662 )?;
663 }
664 Ok(())
665 }
666
667 pub fn patch_log_json(patch: &PatchState) -> Result<String, Error> {
668 Ok(serde_json::to_string_pretty(&patch.revisions)?)
669 }
670
377 pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { 671 pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
378 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; 672 let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
379 repo.find_reference(&ref_name)?.delete()?; 673 repo.find_reference(&ref_name)?.delete()?;
@@ -404,3 +698,24 @@ pub fn close(
404 Ok(()) 698 Ok(())
405 } 699 }
406 700
701 /// Collab config (from refs/collab/config).
702 #[derive(Default)]
703 struct CollabConfig {
704 require_approval_on_latest: bool,
705 }
706
707 fn read_collab_config(repo: &Repository) -> CollabConfig {
708 (|| -> Option<CollabConfig> {
709 let tip = repo.refname_to_id("refs/collab/config").ok()?;
710 let tree = repo.find_commit(tip).ok()?.tree().ok()?;
711 let blob = repo.find_blob(tree.get_name("config.json")?.id()).ok()?;
712 let val: serde_json::Value = serde_json::from_slice(blob.content()).ok()?;
713 Some(CollabConfig {
714 require_approval_on_latest: val
715 .pointer("/merge/require_approval_on_latest")
716 .and_then(|v| v.as_bool())
717 .unwrap_or(false),
718 })
719 })()
720 .unwrap_or_default()
721 }
src/state.rs
Old New
@@ -34,6 +34,7 @@ fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S:
34 ReviewVerdict::Approve => "approve", 34 ReviewVerdict::Approve => "approve",
35 ReviewVerdict::RequestChanges => "request-changes", 35 ReviewVerdict::RequestChanges => "request-changes",
36 ReviewVerdict::Comment => "comment", 36 ReviewVerdict::Comment => "comment",
37 ReviewVerdict::Reject => "reject",
37 }; 38 };
38 s.serialize_str(str_val) 39 s.serialize_str(str_val)
39 } 40 }
@@ -44,6 +45,7 @@ fn deserialize_verdict<'de, D: serde::Deserializer<'de>>(d: D) -> Result<ReviewV
44 "approve" => Ok(ReviewVerdict::Approve), 45 "approve" => Ok(ReviewVerdict::Approve),
45 "request-changes" => Ok(ReviewVerdict::RequestChanges), 46 "request-changes" => Ok(ReviewVerdict::RequestChanges),
46 "comment" => Ok(ReviewVerdict::Comment), 47 "comment" => Ok(ReviewVerdict::Comment),
48 "reject" => Ok(ReviewVerdict::Reject),
47 other => Err(serde::de::Error::custom(format!( 49 other => Err(serde::de::Error::custom(format!(
48 "unknown verdict: {}", 50 "unknown verdict: {}",
49 other 51 other
@@ -98,6 +100,8 @@ pub struct Review {
98 pub verdict: ReviewVerdict, 100 pub verdict: ReviewVerdict,
99 pub body: String, 101 pub body: String,
100 pub timestamp: String, 102 pub timestamp: String,
103 #[serde(default)]
104 pub revision: Option<u32>,
101 } 105 }
102 106
103 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 107 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -110,12 +114,23 @@ pub enum PatchStatus {
110 } 114 }
111 115
112 #[derive(Debug, Clone, Serialize, Deserialize)] 116 #[derive(Debug, Clone, Serialize, Deserialize)]
117 pub struct Revision {
118 pub number: u32,
119 pub commit: String,
120 pub tree: String,
121 pub body: Option<String>,
122 pub timestamp: String,
123 }
124
125 #[derive(Debug, Clone, Serialize, Deserialize)]
113 pub struct InlineComment { 126 pub struct InlineComment {
114 pub author: Author, 127 pub author: Author,
115 pub file: String, 128 pub file: String,
116 pub line: u32, 129 pub line: u32,
117 pub body: String, 130 pub body: String,
118 pub timestamp: String, 131 pub timestamp: String,
132 #[serde(default)]
133 pub revision: Option<u32>,
119 } 134 }
120 135
121 #[derive(Debug, Clone, Serialize, Deserialize)] 136 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -130,6 +145,7 @@ pub struct PatchState {
130 pub comments: Vec<Comment>, 145 pub comments: Vec<Comment>,
131 pub inline_comments: Vec<InlineComment>, 146 pub inline_comments: Vec<InlineComment>,
132 pub reviews: Vec<Review>, 147 pub reviews: Vec<Review>,
148 pub revisions: Vec<Revision>,
133 pub created_at: String, 149 pub created_at: String,
134 #[serde(default)] 150 #[serde(default)]
135 pub last_updated: String, 151 pub last_updated: String,
@@ -344,7 +360,16 @@ impl PatchState {
344 base_ref, 360 base_ref,
345 branch, 361 branch,
346 fixes, 362 fixes,
363 commit,
364 tree,
347 } => { 365 } => {
366 let revisions = vec![Revision {
367 number: 1,
368 commit: commit.clone(),
369 tree,
370 body: None,
371 timestamp: event.timestamp.clone(),
372 }];
348 state = Some(PatchState { 373 state = Some(PatchState {
349 id: id.to_string(), 374 id: id.to_string(),
350 title, 375 title,
@@ -356,25 +381,36 @@ impl PatchState {
356 comments: Vec::new(), 381 comments: Vec::new(),
357 inline_comments: Vec::new(), 382 inline_comments: Vec::new(),
358 reviews: Vec::new(), 383 reviews: Vec::new(),
384 revisions,
359 created_at: event.timestamp.clone(), 385 created_at: event.timestamp.clone(),
360 last_updated: String::new(), 386 last_updated: String::new(),
361 author: event.author.clone(), 387 author: event.author.clone(),
362 }); 388 });
363 } 389 }
364 Action::PatchRevise { body } => { 390 Action::PatchRevision { commit, tree, body } => {
365 if let Some(ref mut s) = state { 391 if let Some(ref mut s) = state {
366 if let Some(b) = body { 392 // Dedup by commit OID — skip if already seen
367 s.body = b; 393 let already_seen = s.revisions.iter().any(|r| r.commit == commit);
394 if !already_seen {
395 let number = s.revisions.len() as u32 + 1;
396 s.revisions.push(Revision {
397 number,
398 commit,
399 tree,
400 body,
401 timestamp: event.timestamp.clone(),
402 });
368 } 403 }
369 } 404 }
370 } 405 }
371 Action::PatchReview { verdict, body } => { 406 Action::PatchReview { verdict, body, revision } => {
372 if let Some(ref mut s) = state { 407 if let Some(ref mut s) = state {
373 s.reviews.push(Review { 408 s.reviews.push(Review {
374 author: event.author.clone(), 409 author: event.author.clone(),
375 verdict, 410 verdict,
376 body, 411 body,
377 timestamp: event.timestamp.clone(), 412 timestamp: event.timestamp.clone(),
413 revision: Some(revision),
378 }); 414 });
379 } 415 }
380 } 416 }
@@ -388,7 +424,7 @@ impl PatchState {
388 }); 424 });
389 } 425 }
390 } 426 }
391 Action::PatchInlineComment { file, line, body } => { 427 Action::PatchInlineComment { file, line, body, revision } => {
392 if let Some(ref mut s) = state { 428 if let Some(ref mut s) = state {
393 s.inline_comments.push(InlineComment { 429 s.inline_comments.push(InlineComment {
394 author: event.author.clone(), 430 author: event.author.clone(),
@@ -396,6 +432,7 @@ impl PatchState {
396 line, 432 line,
397 body, 433 body,
398 timestamp: event.timestamp.clone(), 434 timestamp: event.timestamp.clone(),
435 revision: Some(revision),
399 }); 436 });
400 } 437 }
401 } 438 }
src/status.rs
Old New
@@ -68,6 +68,7 @@ pub fn compute(repo: &Repository) -> Result<ProjectStatus, Error> {
68 ReviewVerdict::Approve => patches_approved += 1, 68 ReviewVerdict::Approve => patches_approved += 1,
69 ReviewVerdict::RequestChanges => patches_changes_requested += 1, 69 ReviewVerdict::RequestChanges => patches_changes_requested += 1,
70 ReviewVerdict::Comment => {} 70 ReviewVerdict::Comment => {}
71 ReviewVerdict::Reject => {}
71 } 72 }
72 } 73 }
73 } 74 }
src/tui/events.rs
Old New
@@ -10,6 +10,7 @@ use ratatui::widgets::ListState;
10 10
11 use crate::error::Error; 11 use crate::error::Error;
12 use crate::issue as issue_mod; 12 use crate::issue as issue_mod;
13 use crate::patch as patch_mod;
13 14
14 use super::state::{App, InputMode, KeyAction, ViewMode}; 15 use super::state::{App, InputMode, KeyAction, ViewMode};
15 use super::widgets::ui; 16 use super::widgets::ui;
@@ -197,7 +198,30 @@ pub(crate) fn run_loop(
197 198
198 match app.handle_key(key.code, key.modifiers) { 199 match app.handle_key(key.code, key.modifiers) {
199 KeyAction::Quit => return Ok(()), 200 KeyAction::Quit => return Ok(()),
201 KeyAction::Reload if app.mode == ViewMode::PatchDetail => {
202 // Regenerate diff for current revision/interdiff state
203 regenerate_patch_diff(app, repo);
204 }
200 KeyAction::Reload => app.reload(repo), 205 KeyAction::Reload => app.reload(repo),
206 KeyAction::OpenPatchDetail => {
207 if let Some(patch) = app.linked_patch_for_selected().cloned() {
208 // Check staleness
209 let warning = crate::staleness_warning(repo, &patch);
210 app.patch_revision_idx = patch.revisions.len().saturating_sub(1);
211 app.patch_interdiff_mode = false;
212 app.patch_scroll = 0;
213
214 // Generate initial diff (latest revision vs base)
215 let diff = generate_patch_diff_for(repo, &patch, app.patch_revision_idx, false);
216 app.patch_diff = diff;
217 app.current_patch = Some(patch);
218 app.mode = ViewMode::PatchDetail;
219
220 if let Some(w) = warning {
221 app.status_msg = Some(w);
222 }
223 }
224 }
201 KeyAction::OpenCommitBrowser => { 225 KeyAction::OpenCommitBrowser => {
202 if let Some(ref_name) = app.selected_ref_name() { 226 if let Some(ref_name) = app.selected_ref_name() {
203 match crate::dag::walk_events(repo, &ref_name) { 227 match crate::dag::walk_events(repo, &ref_name) {
@@ -223,3 +247,43 @@ pub(crate) fn run_loop(
223 } 247 }
224 } 248 }
225 } 249 }
250
251 fn generate_patch_diff_for(
252 repo: &Repository,
253 patch: &crate::state::PatchState,
254 rev_idx: usize,
255 interdiff_mode: bool,
256 ) -> String {
257 if patch.revisions.is_empty() {
258 return "(no revisions)".to_string();
259 }
260
261 let rev = &patch.revisions[rev_idx];
262
263 if interdiff_mode && rev_idx > 0 {
264 let from_rev = patch.revisions[rev_idx - 1].number;
265 let to_rev = rev.number;
266 match patch_mod::interdiff(repo, patch, from_rev, to_rev) {
267 Ok(d) => d,
268 Err(e) => format!("(error generating interdiff: {})", e),
269 }
270 } else {
271 // Diff at specific revision vs base
272 match patch_mod::diff(repo, &patch.id, Some(rev.number), None) {
273 Ok(d) => d,
274 Err(e) => format!("(error generating diff: {})", e),
275 }
276 }
277 }
278
279 fn regenerate_patch_diff(app: &mut App, repo: &Repository) {
280 if let Some(ref patch) = app.current_patch {
281 let diff = generate_patch_diff_for(
282 repo,
283 patch,
284 app.patch_revision_idx,
285 app.patch_interdiff_mode,
286 );
287 app.patch_diff = diff;
288 }
289 }
src/tui/mod.rs
Old New
@@ -82,6 +82,7 @@ mod tests {
82 comments: vec![], 82 comments: vec![],
83 inline_comments: vec![], 83 inline_comments: vec![],
84 reviews: vec![], 84 reviews: vec![],
85 revisions: vec![],
85 created_at: String::new(), 86 created_at: String::new(),
86 last_updated: String::new(), 87 last_updated: String::new(),
87 author: make_author(), 88 author: make_author(),
@@ -276,6 +277,7 @@ mod tests {
276 comments: Vec::new(), 277 comments: Vec::new(),
277 inline_comments: Vec::new(), 278 inline_comments: Vec::new(),
278 reviews: Vec::new(), 279 reviews: Vec::new(),
280 revisions: Vec::new(),
279 created_at: "2026-01-01T00:00:00Z".to_string(), 281 created_at: "2026-01-01T00:00:00Z".to_string(),
280 last_updated: "2026-01-01T00:00:00Z".to_string(), 282 last_updated: "2026-01-01T00:00:00Z".to_string(),
281 author: test_author(), 283 author: test_author(),
@@ -404,6 +406,8 @@ mod tests {
404 base_ref: "main".to_string(), 406 base_ref: "main".to_string(),
405 branch: "feature/test".to_string(), 407 branch: "feature/test".to_string(),
406 fixes: None, 408 fixes: None,
409 commit: "abc123".to_string(),
410 tree: "def456".to_string(),
407 }; 411 };
408 assert_eq!(action_type_label(&action), "Patch Create"); 412 assert_eq!(action_type_label(&action), "Patch Create");
409 } 413 }
@@ -413,6 +417,7 @@ mod tests {
413 let action = Action::PatchReview { 417 let action = Action::PatchReview {
414 verdict: ReviewVerdict::Approve, 418 verdict: ReviewVerdict::Approve,
415 body: "lgtm".to_string(), 419 body: "lgtm".to_string(),
420 revision: 1,
416 }; 421 };
417 assert_eq!(action_type_label(&action), "Patch Review"); 422 assert_eq!(action_type_label(&action), "Patch Review");
418 } 423 }
@@ -423,6 +428,7 @@ mod tests {
423 file: "src/main.rs".to_string(), 428 file: "src/main.rs".to_string(),
424 line: 42, 429 line: 42,
425 body: "nit".to_string(), 430 body: "nit".to_string(),
431 revision: 1,
426 }; 432 };
427 assert_eq!(action_type_label(&action), "Inline Comment"); 433 assert_eq!(action_type_label(&action), "Inline Comment");
428 } 434 }
@@ -481,6 +487,7 @@ mod tests {
481 action: Action::PatchReview { 487 action: Action::PatchReview {
482 verdict: ReviewVerdict::Approve, 488 verdict: ReviewVerdict::Approve,
483 body: "Looks good!".to_string(), 489 body: "Looks good!".to_string(),
490 revision: 1,
484 }, 491 },
485 clock: 0, 492 clock: 0,
486 }; 493 };
@@ -1012,4 +1019,368 @@ mod tests {
1012 let buf = render_app(&mut app); 1019 let buf = render_app(&mut app);
1013 assert_buffer_contains(&buf, "Error loading events"); 1020 assert_buffer_contains(&buf, "Error loading events");
1014 } 1021 }
1022
1023 // ── Patch detail view tests ─────────────────────────────────────────
1024
1025 fn make_patch_with_revisions() -> PatchState {
1026 use crate::state::Revision;
1027 PatchState {
1028 id: "deadbeef".into(),
1029 title: "Fix the thing".into(),
1030 body: "Detailed description".into(),
1031 status: PatchStatus::Open,
1032 base_ref: "main".into(),
1033 fixes: Some("i1".into()),
1034 branch: "feature/fix-thing".into(),
1035 comments: vec![crate::state::Comment {
1036 author: make_author(),
1037 body: "Thread comment".into(),
1038 timestamp: "2026-01-05T00:00:00Z".into(),
1039 commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(),
1040 }],
1041 inline_comments: vec![crate::state::InlineComment {
1042 author: make_author(),
1043 file: "src/main.rs".into(),
1044 line: 42,
1045 body: "Nit: rename this".into(),
1046 timestamp: "2026-01-03T00:00:00Z".into(),
1047 revision: Some(1),
1048 }],
1049 reviews: vec![crate::state::Review {
1050 author: make_author(),
1051 verdict: ReviewVerdict::Approve,
1052 body: "LGTM".into(),
1053 timestamp: "2026-01-04T00:00:00Z".into(),
1054 revision: Some(2),
1055 }],
1056 revisions: vec![
1057 Revision {
1058 number: 1,
1059 commit: "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111".into(),
1060 tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(),
1061 body: None,
1062 timestamp: "2026-01-01T00:00:00Z".into(),
1063 },
1064 Revision {
1065 number: 2,
1066 commit: "aaaa2222aaaa2222aaaa2222aaaa2222aaaa2222".into(),
1067 tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(),
1068 body: Some("Addressed review comments".into()),
1069 timestamp: "2026-01-02T00:00:00Z".into(),
1070 },
1071 ],
1072 created_at: "2026-01-01T00:00:00Z".into(),
1073 last_updated: "2026-01-04T00:00:00Z".into(),
1074 author: make_author(),
1075 }
1076 }
1077
1078 #[test]
1079 fn test_p_key_opens_patch_detail_when_linked_patch_exists() {
1080 let mut app = test_app();
1081 // Issue i1 has a linked patch (p1 has fixes=None, so we need to set it)
1082 app.patches[0].fixes = Some("i1".into());
1083 app.pane = Pane::Detail;
1084 app.list_state.select(Some(0)); // selects issue i1
1085
1086 let result = app.handle_key(
1087 crossterm::event::KeyCode::Char('p'),
1088 crossterm::event::KeyModifiers::empty(),
1089 );
1090 assert_eq!(result, KeyAction::OpenPatchDetail);
1091 }
1092
1093 #[test]
1094 fn test_p_key_noop_when_no_linked_patch() {
1095 let mut app = test_app();
1096 // No patches fix any issues by default
1097 app.pane = Pane::Detail;
1098 app.list_state.select(Some(0));
1099
1100 let result = app.handle_key(
1101 crossterm::event::KeyCode::Char('p'),
1102 crossterm::event::KeyModifiers::empty(),
1103 );
1104 assert_eq!(result, KeyAction::Continue);
1105 }
1106
1107 #[test]
1108 fn test_p_key_noop_in_item_list_pane() {
1109 let mut app = test_app();
1110 app.patches[0].fixes = Some("i1".into());
1111 app.pane = Pane::ItemList;
1112 app.list_state.select(Some(0));
1113
1114 let result = app.handle_key(
1115 crossterm::event::KeyCode::Char('p'),
1116 crossterm::event::KeyModifiers::empty(),
1117 );
1118 assert_eq!(result, KeyAction::Continue);
1119 }
1120
1121 #[test]
1122 fn test_patch_detail_esc_returns_to_details() {
1123 let mut app = test_app();
1124 app.current_patch = Some(make_patch_with_revisions());
1125 app.mode = ViewMode::PatchDetail;
1126 app.patch_scroll = 5;
1127 app.patch_revision_idx = 1;
1128 app.patch_interdiff_mode = true;
1129 app.patch_diff = "some diff".into();
1130
1131 let result = app.handle_key(
1132 crossterm::event::KeyCode::Esc,
1133 crossterm::event::KeyModifiers::empty(),
1134 );
1135 assert_eq!(result, KeyAction::Continue);
1136 assert_eq!(app.mode, ViewMode::Details);
1137 assert!(app.current_patch.is_none());
1138 assert!(app.patch_diff.is_empty());
1139 assert_eq!(app.patch_scroll, 0);
1140 assert_eq!(app.patch_revision_idx, 0);
1141 assert!(!app.patch_interdiff_mode);
1142 }
1143
1144 #[test]
1145 fn test_patch_detail_q_quits() {
1146 let mut app = test_app();
1147 app.mode = ViewMode::PatchDetail;
1148 let result = app.handle_key(
1149 crossterm::event::KeyCode::Char('q'),
1150 crossterm::event::KeyModifiers::empty(),
1151 );
1152 assert_eq!(result, KeyAction::Quit);
1153 }
1154
1155 #[test]
1156 fn test_patch_detail_scroll() {
1157 let mut app = test_app();
1158 app.mode = ViewMode::PatchDetail;
1159 app.current_patch = Some(make_patch_with_revisions());
1160 app.patch_scroll = 0;
1161
1162 app.handle_key(
1163 crossterm::event::KeyCode::Char('j'),
1164 crossterm::event::KeyModifiers::empty(),
1165 );
1166 assert_eq!(app.patch_scroll, 1);
1167 app.handle_key(
1168 crossterm::event::KeyCode::Char('j'),
1169 crossterm::event::KeyModifiers::empty(),
1170 );
1171 assert_eq!(app.patch_scroll, 2);
1172 app.handle_key(
1173 crossterm::event::KeyCode::Char('k'),
1174 crossterm::event::KeyModifiers::empty(),
1175 );
1176 assert_eq!(app.patch_scroll, 1);
1177 }
1178
1179 #[test]
1180 fn test_patch_detail_page_scroll() {
1181 let mut app = test_app();
1182 app.mode = ViewMode::PatchDetail;
1183 app.patch_scroll = 0;
1184
1185 app.handle_key(
1186 crossterm::event::KeyCode::PageDown,
1187 crossterm::event::KeyModifiers::empty(),
1188 );
1189 assert_eq!(app.patch_scroll, 20);
1190 app.handle_key(
1191 crossterm::event::KeyCode::PageUp,
1192 crossterm::event::KeyModifiers::empty(),
1193 );
1194 assert_eq!(app.patch_scroll, 0);
1195 }
1196
1197 #[test]
1198 fn test_patch_detail_revision_navigation() {
1199 let mut app = test_app();
1200 app.mode = ViewMode::PatchDetail;
1201 app.current_patch = Some(make_patch_with_revisions());
1202 app.patch_revision_idx = 0;
1203
1204 // Navigate forward
1205 let result = app.handle_key(
1206 crossterm::event::KeyCode::Char(']'),
1207 crossterm::event::KeyModifiers::empty(),
1208 );
1209 assert_eq!(result, KeyAction::Reload);
1210 assert_eq!(app.patch_revision_idx, 1);
1211 assert_eq!(app.patch_scroll, 0);
1212
1213 // Can't go past last revision
1214 let result = app.handle_key(
1215 crossterm::event::KeyCode::Char(']'),
1216 crossterm::event::KeyModifiers::empty(),
1217 );
1218 assert_eq!(result, KeyAction::Continue);
1219 assert_eq!(app.patch_revision_idx, 1);
1220
1221 // Navigate backward
1222 let result = app.handle_key(
1223 crossterm::event::KeyCode::Char('['),
1224 crossterm::event::KeyModifiers::empty(),
1225 );
1226 assert_eq!(result, KeyAction::Reload);
1227 assert_eq!(app.patch_revision_idx, 0);
1228
1229 // Can't go below 0
1230 let result = app.handle_key(
1231 crossterm::event::KeyCode::Char('['),
1232 crossterm::event::KeyModifiers::empty(),
1233 );
1234 assert_eq!(result, KeyAction::Continue);
1235 assert_eq!(app.patch_revision_idx, 0);
1236 }
1237
1238 #[test]
1239 fn test_patch_detail_interdiff_toggle() {
1240 let mut app = test_app();
1241 app.mode = ViewMode::PatchDetail;
1242 app.current_patch = Some(make_patch_with_revisions());
1243 app.patch_interdiff_mode = false;
1244
1245 let result = app.handle_key(
1246 crossterm::event::KeyCode::Char('d'),
1247 crossterm::event::KeyModifiers::empty(),
1248 );
1249 assert_eq!(result, KeyAction::Reload);
1250 assert!(app.patch_interdiff_mode);
1251 assert_eq!(app.patch_scroll, 0);
1252
1253 let result = app.handle_key(
1254 crossterm::event::KeyCode::Char('d'),
1255 crossterm::event::KeyModifiers::empty(),
1256 );
1257 assert_eq!(result, KeyAction::Reload);
1258 assert!(!app.patch_interdiff_mode);
1259 }
1260
1261 #[test]
1262 fn test_render_patch_detail() {
1263 let mut app = make_app(3, 0);
1264 app.mode = ViewMode::PatchDetail;
1265 app.current_patch = Some(make_patch_with_revisions());
1266 app.patch_revision_idx = 1;
1267 app.patch_diff = "+added line\n-removed line\n context".into();
1268
1269 let buf = render_app(&mut app);
1270 assert_buffer_contains(&buf, "Patch Detail");
1271 assert_buffer_contains(&buf, "Fix the thing");
1272 assert_buffer_contains(&buf, "deadbeef");
1273 assert_buffer_contains(&buf, "feature/fix-thing");
1274 }
1275
1276 #[test]
1277 fn test_render_patch_detail_shows_reviews() {
1278 let mut app = make_app(3, 0);
1279 app.mode = ViewMode::PatchDetail;
1280 app.current_patch = Some(make_patch_with_revisions());
1281 app.patch_revision_idx = 1;
1282 app.patch_diff = String::new();
1283
1284 let buf = render_app(&mut app);
1285 assert_buffer_contains(&buf, "Reviews");
1286 assert_buffer_contains(&buf, "approve");
1287 assert_buffer_contains(&buf, "LGTM");
1288 }
1289
1290 #[test]
1291 fn test_render_patch_detail_footer() {
1292 let mut app = make_app(3, 0);
1293 app.mode = ViewMode::PatchDetail;
1294 let buf = render_app(&mut app);
1295 assert_buffer_contains(&buf, "Esc:back");
1296 assert_buffer_contains(&buf, "[/]:revision");
1297 // "d:interdiff" may be truncated at 80 cols, check prefix
1298 assert_buffer_contains(&buf, "d:inter");
1299 }
1300
1301 #[test]
1302 fn test_render_patch_detail_no_patch_loaded() {
1303 let mut app = make_app(3, 0);
1304 app.mode = ViewMode::PatchDetail;
1305 app.current_patch = None;
1306
1307 let buf = render_app(&mut app);
1308 assert_buffer_contains(&buf, "No patch loaded");
1309 }
1310
1311 #[test]
1312 fn test_patch_detail_ctrl_c_quits() {
1313 let mut app = test_app();
1314 app.mode = ViewMode::PatchDetail;
1315 let result = app.handle_key(
1316 crossterm::event::KeyCode::Char('c'),
1317 crossterm::event::KeyModifiers::CONTROL,
1318 );
1319 assert_eq!(result, KeyAction::Quit);
1320 }
1321
1322 #[test]
1323 fn test_full_patch_detail_flow() {
1324 let mut app = test_app();
1325 app.patches[0].fixes = Some("i1".into());
1326 app.pane = Pane::Detail;
1327 app.list_state.select(Some(0));
1328
1329 // Press p to open patch detail
1330 let action = app.handle_key(
1331 crossterm::event::KeyCode::Char('p'),
1332 crossterm::event::KeyModifiers::empty(),
1333 );
1334 assert_eq!(action, KeyAction::OpenPatchDetail);
1335
1336 // Simulate what events.rs does
1337 app.current_patch = Some(make_patch_with_revisions());
1338 app.patch_revision_idx = 1;
1339 app.patch_diff = "diff content".into();
1340 app.mode = ViewMode::PatchDetail;
1341
1342 // Navigate revisions
1343 app.handle_key(
1344 crossterm::event::KeyCode::Char('['),
1345 crossterm::event::KeyModifiers::empty(),
1346 );
1347 assert_eq!(app.patch_revision_idx, 0);
1348
1349 // Toggle interdiff
1350 app.handle_key(
1351 crossterm::event::KeyCode::Char('d'),
1352 crossterm::event::KeyModifiers::empty(),
1353 );
1354 assert!(app.patch_interdiff_mode);
1355
1356 // Scroll
1357 app.handle_key(
1358 crossterm::event::KeyCode::Char('j'),
1359 crossterm::event::KeyModifiers::empty(),
1360 );
1361 assert_eq!(app.patch_scroll, 1);
1362
1363 // Escape back
1364 app.handle_key(
1365 crossterm::event::KeyCode::Esc,
1366 crossterm::event::KeyModifiers::empty(),
1367 );
1368 assert_eq!(app.mode, ViewMode::Details);
1369 assert!(app.current_patch.is_none());
1370 }
1371
1372 #[test]
1373 fn test_linked_patch_for_selected() {
1374 let mut app = test_app();
1375 app.patches[0].fixes = Some("i1".into());
1376 app.list_state.select(Some(0));
1377
1378 let patch = app.linked_patch_for_selected();
1379 assert!(patch.is_some());
1380 assert_eq!(patch.unwrap().title, "Login fix patch");
1381
1382 // Second issue has no linked patch
1383 app.list_state.select(Some(1));
1384 assert!(app.linked_patch_for_selected().is_none());
1385 }
1015 } 1386 }
src/tui/state.rs
Old New
@@ -15,6 +15,7 @@ pub(crate) enum ViewMode {
15 Details, 15 Details,
16 CommitList, 16 CommitList,
17 CommitDetail, 17 CommitDetail,
18 PatchDetail,
18 } 19 }
19 20
20 #[derive(Debug, PartialEq)] 21 #[derive(Debug, PartialEq)]
@@ -23,6 +24,7 @@ pub(crate) enum KeyAction {
23 Quit, 24 Quit,
24 Reload, 25 Reload,
25 OpenCommitBrowser, 26 OpenCommitBrowser,
27 OpenPatchDetail,
26 } 28 }
27 29
28 #[derive(Debug, PartialEq, Clone, Copy)] 30 #[derive(Debug, PartialEq, Clone, Copy)]
@@ -73,6 +75,12 @@ pub(crate) struct App {
73 pub(crate) status_msg: Option<String>, 75 pub(crate) status_msg: Option<String>,
74 pub(crate) event_history: Vec<(Oid, crate::event::Event)>, 76 pub(crate) event_history: Vec<(Oid, crate::event::Event)>,
75 pub(crate) event_list_state: ListState, 77 pub(crate) event_list_state: ListState,
78 // Patch detail view state
79 pub(crate) current_patch: Option<PatchState>,
80 pub(crate) patch_diff: String,
81 pub(crate) patch_scroll: u16,
82 pub(crate) patch_revision_idx: usize,
83 pub(crate) patch_interdiff_mode: bool,
76 } 84 }
77 85
78 impl App { 86 impl App {
@@ -96,6 +104,11 @@ impl App {
96 status_msg: None, 104 status_msg: None,
97 event_history: Vec::new(), 105 event_history: Vec::new(),
98 event_list_state: ListState::default(), 106 event_list_state: ListState::default(),
107 current_patch: None,
108 patch_diff: String::new(),
109 patch_scroll: 0,
110 patch_revision_idx: 0,
111 patch_interdiff_mode: false,
99 } 112 }
100 } 113 }
101 114
@@ -139,7 +152,77 @@ impl App {
139 self.scroll = 0; 152 self.scroll = 0;
140 } 153 }
141 154
155 /// Find the first linked patch for the currently selected issue.
156 pub(crate) fn linked_patch_for_selected(&self) -> Option<&PatchState> {
157 let idx = self.list_state.selected()?;
158 let visible = self.visible_issues();
159 let issue = visible.get(idx)?;
160 self.patches
161 .iter()
162 .find(|p| p.fixes.as_deref() == Some(&issue.id))
163 }
164
142 pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { 165 pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
166 // Handle PatchDetail mode
167 if self.mode == ViewMode::PatchDetail {
168 match code {
169 KeyCode::Esc => {
170 self.mode = ViewMode::Details;
171 self.current_patch = None;
172 self.patch_diff.clear();
173 self.patch_scroll = 0;
174 self.patch_revision_idx = 0;
175 self.patch_interdiff_mode = false;
176 return KeyAction::Continue;
177 }
178 KeyCode::Char('q') => return KeyAction::Quit,
179 KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
180 return KeyAction::Quit;
181 }
182 KeyCode::Char('j') | KeyCode::Down => {
183 self.patch_scroll = self.patch_scroll.saturating_add(1);
184 return KeyAction::Continue;
185 }
186 KeyCode::Char('k') | KeyCode::Up => {
187 self.patch_scroll = self.patch_scroll.saturating_sub(1);
188 return KeyAction::Continue;
189 }
190 KeyCode::PageDown => {
191 self.patch_scroll = self.patch_scroll.saturating_add(20);
192 return KeyAction::Continue;
193 }
194 KeyCode::PageUp => {
195 self.patch_scroll = self.patch_scroll.saturating_sub(20);
196 return KeyAction::Continue;
197 }
198 KeyCode::Char(']') => {
199 if let Some(ref patch) = self.current_patch {
200 let max = patch.revisions.len().saturating_sub(1);
201 if self.patch_revision_idx < max {
202 self.patch_revision_idx += 1;
203 self.patch_scroll = 0;
204 return KeyAction::Reload; // signal to regenerate diff
205 }
206 }
207 return KeyAction::Continue;
208 }
209 KeyCode::Char('[') => {
210 if self.patch_revision_idx > 0 {
211 self.patch_revision_idx -= 1;
212 self.patch_scroll = 0;
213 return KeyAction::Reload; // signal to regenerate diff
214 }
215 return KeyAction::Continue;
216 }
217 KeyCode::Char('d') => {
218 self.patch_interdiff_mode = !self.patch_interdiff_mode;
219 self.patch_scroll = 0;
220 return KeyAction::Reload; // signal to regenerate diff
221 }
222 _ => return KeyAction::Continue,
223 }
224 }
225
143 // Handle CommitDetail mode first 226 // Handle CommitDetail mode first
144 if self.mode == ViewMode::CommitDetail { 227 if self.mode == ViewMode::CommitDetail {
145 match code { 228 match code {
@@ -226,6 +309,14 @@ impl App {
226 KeyAction::Continue 309 KeyAction::Continue
227 } 310 }
228 } 311 }
312 KeyCode::Char('p') => {
313 // Open patch detail: only when in detail pane with a linked patch
314 if self.pane == Pane::Detail && self.linked_patch_for_selected().is_some() {
315 KeyAction::OpenPatchDetail
316 } else {
317 KeyAction::Continue
318 }
319 }
229 KeyCode::Char('j') | KeyCode::Down => { 320 KeyCode::Char('j') | KeyCode::Down => {
230 if self.pane == Pane::ItemList { 321 if self.pane == Pane::ItemList {
231 self.move_selection(1); 322 self.move_selection(1);
src/tui/widgets.rs
Old New
@@ -2,7 +2,7 @@ use git2::Oid;
2 use ratatui::prelude::*; 2 use ratatui::prelude::*;
3 use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; 3 use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
4 4
5 use crate::event::Action; 5 use crate::event::{Action, ReviewVerdict};
6 use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; 6 use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};
7 7
8 use super::state::{App, InputMode, Pane, StatusFilter, ViewMode}; 8 use super::state::{App, InputMode, Pane, StatusFilter, ViewMode};
@@ -14,7 +14,7 @@ pub(crate) fn action_type_label(action: &Action) -> &str {
14 Action::IssueClose { .. } => "Issue Close", 14 Action::IssueClose { .. } => "Issue Close",
15 Action::IssueReopen => "Issue Reopen", 15 Action::IssueReopen => "Issue Reopen",
16 Action::PatchCreate { .. } => "Patch Create", 16 Action::PatchCreate { .. } => "Patch Create",
17 Action::PatchRevise { .. } => "Patch Revise", 17 Action::PatchRevision { .. } => "Patch Revision",
18 Action::PatchReview { .. } => "Patch Review", 18 Action::PatchReview { .. } => "Patch Review",
19 Action::PatchComment { .. } => "Patch Comment", 19 Action::PatchComment { .. } => "Patch Comment",
20 Action::PatchInlineComment { .. } => "Inline Comment", 20 Action::PatchInlineComment { .. } => "Inline Comment",
@@ -68,20 +68,22 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str
68 detail.push_str(&format!("\n{}\n", body)); 68 detail.push_str(&format!("\n{}\n", body));
69 } 69 }
70 } 70 }
71 Action::PatchRevise { body } => { 71 Action::PatchRevision { commit, tree, body } => {
72 detail.push_str(&format!("\nCommit: {}\n", commit));
73 detail.push_str(&format!("Tree: {}\n", tree));
72 if let Some(b) = body { 74 if let Some(b) = body {
73 if !b.is_empty() { 75 if !b.is_empty() {
74 detail.push_str(&format!("\n{}\n", b)); 76 detail.push_str(&format!("\n{}\n", b));
75 } 77 }
76 } 78 }
77 } 79 }
78 Action::PatchReview { verdict, body } => { 80 Action::PatchReview { verdict, body, .. } => {
79 detail.push_str(&format!("\nVerdict: {:?}\n", verdict)); 81 detail.push_str(&format!("\nVerdict: {:?}\n", verdict));
80 if !body.is_empty() { 82 if !body.is_empty() {
81 detail.push_str(&format!("\n{}\n", body)); 83 detail.push_str(&format!("\n{}\n", body));
82 } 84 }
83 } 85 }
84 Action::PatchInlineComment { file, line, body } => { 86 Action::PatchInlineComment { file, line, body, .. } => {
85 detail.push_str(&format!("\nFile: {}:{}\n", file, line)); 87 detail.push_str(&format!("\nFile: {}:{}\n", file, line));
86 if !body.is_empty() { 88 if !body.is_empty() {
87 detail.push_str(&format!("\n{}\n", body)); 89 detail.push_str(&format!("\n{}\n", body));
@@ -186,6 +188,21 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
186 Style::default().fg(Color::DarkGray) 188 Style::default().fg(Color::DarkGray)
187 }; 189 };
188 190
191 // Handle patch detail mode
192 if app.mode == ViewMode::PatchDetail {
193 let content = build_patch_detail_text(app);
194 let block = Block::default()
195 .borders(Borders::ALL)
196 .title("Patch Detail")
197 .border_style(border_style);
198 let para = Paragraph::new(content)
199 .block(block)
200 .wrap(Wrap { trim: false })
201 .scroll((app.patch_scroll, 0));
202 frame.render_widget(para, area);
203 return;
204 }
205
189 // Handle commit browser modes 206 // Handle commit browser modes
190 if app.mode == ViewMode::CommitList { 207 if app.mode == ViewMode::CommitList {
191 let items: Vec<ListItem> = app 208 let items: Vec<ListItem> = app
@@ -392,6 +409,245 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati
392 Text::from(lines) 409 Text::from(lines)
393 } 410 }
394 411
412 fn build_patch_detail_text(app: &App) -> Text<'static> {
413 let patch = match &app.current_patch {
414 Some(p) => p,
415 None => return Text::raw("No patch loaded."),
416 };
417
418 let status_str = match patch.status {
419 PatchStatus::Open => "open",
420 PatchStatus::Closed => "closed",
421 PatchStatus::Merged => "merged",
422 };
423 let status_color = match patch.status {
424 PatchStatus::Open => Color::Green,
425 PatchStatus::Closed => Color::Red,
426 PatchStatus::Merged => Color::Cyan,
427 };
428
429 let mut lines: Vec<Line> = vec![
430 Line::from(vec![
431 Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)),
432 Span::styled(
433 format!("{:.8}", patch.id),
434 Style::default()
435 .fg(Color::Yellow)
436 .add_modifier(Modifier::BOLD),
437 ),
438 Span::raw(" "),
439 Span::styled(status_str, Style::default().fg(status_color)),
440 ]),
441 Line::from(vec![
442 Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
443 Span::raw(patch.title.clone()),
444 ]),
445 Line::from(vec![
446 Span::styled("Author: ", Style::default().fg(Color::DarkGray)),
447 Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
448 ]),
449 Line::from(vec![
450 Span::styled("Branch: ", Style::default().fg(Color::DarkGray)),
451 Span::raw(patch.branch.clone()),
452 ]),
453 Line::from(vec![
454 Span::styled("Base: ", Style::default().fg(Color::DarkGray)),
455 Span::raw(patch.base_ref.clone()),
456 ]),
457 Line::from(vec![
458 Span::styled("Revisions:", Style::default().fg(Color::DarkGray)),
459 Span::raw(format!(" {}", patch.revisions.len())),
460 ]),
461 ];
462
463 if let Some(ref fixes) = patch.fixes {
464 lines.push(Line::from(vec![
465 Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)),
466 Span::raw(format!("{:.8}", fixes)),
467 ]));
468 }
469
470 // Staleness warning (stored in status_msg or computed text)
471 if let Some(ref warning) = app.status_msg {
472 if warning.contains("behind") {
473 lines.push(Line::raw(""));
474 lines.push(Line::styled(
475 warning.clone(),
476 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
477 ));
478 }
479 }
480
481 // Revision list
482 if !patch.revisions.is_empty() {
483 lines.push(Line::raw(""));
484 lines.push(Line::styled(
485 "--- Revisions ---",
486 Style::default()
487 .fg(Color::Magenta)
488 .add_modifier(Modifier::BOLD),
489 ));
490 for (i, rev) in patch.revisions.iter().enumerate() {
491 let short = if rev.commit.len() >= 8 {
492 &rev.commit[..8]
493 } else {
494 &rev.commit
495 };
496 let marker = if i == app.patch_revision_idx {
497 "> "
498 } else {
499 " "
500 };
501 let label = if i == 0 { " (initial)" } else { "" };
502 lines.push(Line::from(vec![
503 Span::raw(format!("{}r{} {} {}{}", marker, rev.number, short, rev.timestamp, label)),
504 ]));
505 }
506 }
507
508 // Reviews
509 if !patch.reviews.is_empty() {
510 lines.push(Line::raw(""));
511 lines.push(Line::styled(
512 "--- Reviews ---",
513 Style::default()
514 .fg(Color::Blue)
515 .add_modifier(Modifier::BOLD),
516 ));
517 for review in &patch.reviews {
518 let verdict_str = match review.verdict {
519 ReviewVerdict::Approve => "approve",
520 ReviewVerdict::RequestChanges => "request-changes",
521 ReviewVerdict::Comment => "comment",
522 ReviewVerdict::Reject => "reject",
523 };
524 let verdict_color = match review.verdict {
525 ReviewVerdict::Approve => Color::Green,
526 ReviewVerdict::RequestChanges => Color::Yellow,
527 ReviewVerdict::Comment => Color::White,
528 ReviewVerdict::Reject => Color::Red,
529 };
530 let rev_label = review
531 .revision
532 .map(|r| format!(" (r{})", r))
533 .unwrap_or_default();
534 lines.push(Line::from(vec![
535 Span::styled(
536 review.author.name.clone(),
537 Style::default().add_modifier(Modifier::BOLD),
538 ),
539 Span::raw(format!(" {} ", review.timestamp)),
540 Span::styled(verdict_str, Style::default().fg(verdict_color)),
541 Span::raw(rev_label),
542 ]));
543 if !review.body.is_empty() {
544 for l in review.body.lines() {
545 lines.push(Line::raw(format!(" {}", l)));
546 }
547 }
548 }
549 }
550
551 // Inline comments
552 if !patch.inline_comments.is_empty() {
553 lines.push(Line::raw(""));
554 lines.push(Line::styled(
555 "--- Inline Comments ---",
556 Style::default()
557 .fg(Color::Blue)
558 .add_modifier(Modifier::BOLD),
559 ));
560 for ic in &patch.inline_comments {
561 let rev_label = ic
562 .revision
563 .map(|r| format!(" (r{})", r))
564 .unwrap_or_default();
565 lines.push(Line::from(vec![
566 Span::styled(
567 ic.author.name.clone(),
568 Style::default().add_modifier(Modifier::BOLD),
569 ),
570 Span::raw(format!(" {}:{}", ic.file, ic.line)),
571 Span::raw(rev_label),
572 ]));
573 for l in ic.body.lines() {
574 lines.push(Line::raw(format!(" {}", l)));
575 }
576 }
577 }
578
579 // Thread comments
580 if !patch.comments.is_empty() {
581 lines.push(Line::raw(""));
582 lines.push(Line::styled(
583 "--- Comments ---",
584 Style::default()
585 .fg(Color::Blue)
586 .add_modifier(Modifier::BOLD),
587 ));
588 for c in &patch.comments {
589 lines.push(Line::raw(""));
590 lines.push(Line::from(vec![
591 Span::styled(
592 c.author.name.clone(),
593 Style::default().add_modifier(Modifier::BOLD),
594 ),
595 Span::styled(
596 format!(" ({})", c.timestamp),
597 Style::default().fg(Color::DarkGray),
598 ),
599 ]));
600 for l in c.body.lines() {
601 lines.push(Line::raw(format!(" {}", l)));
602 }
603 }
604 }
605
606 // Diff header
607 lines.push(Line::raw(""));
608 let diff_mode = if app.patch_interdiff_mode {
609 let rev_idx = app.patch_revision_idx;
610 if rev_idx > 0 {
611 let from_rev = patch.revisions.get(rev_idx - 1).map(|r| r.number).unwrap_or(0);
612 let to_rev = patch.revisions.get(rev_idx).map(|r| r.number).unwrap_or(0);
613 format!("--- Interdiff r{} -> r{} ---", from_rev, to_rev)
614 } else {
615 "--- Diff vs base (no previous revision) ---".to_string()
616 }
617 } else {
618 let rev_num = patch.revisions.get(app.patch_revision_idx).map(|r| r.number).unwrap_or(1);
619 format!("--- Diff r{} vs base ---", rev_num)
620 };
621 lines.push(Line::styled(
622 diff_mode,
623 Style::default()
624 .fg(Color::Green)
625 .add_modifier(Modifier::BOLD),
626 ));
627
628 // Diff content
629 if app.patch_diff.is_empty() {
630 lines.push(Line::raw("(no diff available)"));
631 } else {
632 for l in app.patch_diff.lines() {
633 let style = if l.starts_with('+') {
634 Style::default().fg(Color::Green)
635 } else if l.starts_with('-') {
636 Style::default().fg(Color::Red)
637 } else if l.starts_with("@@") {
638 Style::default().fg(Color::Cyan)
639 } else if l.starts_with("diff ") {
640 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
641 } else {
642 Style::default()
643 };
644 lines.push(Line::styled(l.to_string(), style));
645 }
646 }
647
648 Text::from(lines)
649 }
650
395 fn render_footer(frame: &mut Frame, app: &App, area: Rect) { 651 fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
396 match app.input_mode { 652 match app.input_mode {
397 InputMode::Search => { 653 InputMode::Search => {
@@ -447,7 +703,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
447 let mode_hint = match app.mode { 703 let mode_hint = match app.mode {
448 ViewMode::CommitList => " Esc:back", 704 ViewMode::CommitList => " Esc:back",
449 ViewMode::CommitDetail => " Esc:back j/k:scroll", 705 ViewMode::CommitDetail => " Esc:back j/k:scroll",
450 ViewMode::Details => " c:events", 706 ViewMode::PatchDetail => " Esc:back j/k:scroll [/]:revision d:interdiff",
707 ViewMode::Details => " c:events p:patch",
451 }; 708 };
452 let filter_hint = match app.status_filter { 709 let filter_hint = match app.status_filter {
453 StatusFilter::Open => "a:show all", 710 StatusFilter::Open => "a:show all",
tests/adversarial_test.rs
Old New
@@ -542,9 +542,15 @@ fn arb_action() -> impl Strategy<Value = Action> {
542 base_ref, 542 base_ref,
543 branch, 543 branch,
544 fixes: None, 544 fixes: None,
545 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
546 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
545 } 547 }
546 }), 548 }),
547 proptest::option::of(".*").prop_map(|body| Action::PatchRevise { body }), 549 proptest::option::of(".*").prop_map(|body| Action::PatchRevision {
550 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
551 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
552 body,
553 }),
548 (".*",).prop_map(|(body,)| Action::PatchComment { body }), 554 (".*",).prop_map(|(body,)| Action::PatchComment { body }),
549 Just(Action::PatchMerge), 555 Just(Action::PatchMerge),
550 Just(Action::Merge), 556 Just(Action::Merge),
tests/collab_test.rs
Old New
@@ -332,7 +332,9 @@ fn test_patch_review_workflow() {
332 let event = Event { 332 let event = Event {
333 timestamp: now(), 333 timestamp: now(),
334 author: alice(), 334 author: alice(),
335 action: Action::PatchRevise { 335 action: Action::PatchRevision {
336 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
337 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
336 body: Some("Updated implementation".to_string()), 338 body: Some("Updated implementation".to_string()),
337 }, 339 },
338 clock: 0, 340 clock: 0,
@@ -343,7 +345,9 @@ fn test_patch_review_workflow() {
343 345
344 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); 346 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
345 assert_eq!(state.reviews.len(), 2); 347 assert_eq!(state.reviews.len(), 2);
346 assert_eq!(state.body, "Updated implementation"); 348 // PatchRevision body is on the revision, not the patch body
349 assert_eq!(state.revisions.len(), 2); // revision 1 from create + revision 2 from PatchRevision
350 assert_eq!(state.revisions[1].body.as_deref(), Some("Updated implementation"));
347 } 351 }
348 352
349 #[test] 353 #[test]
@@ -637,6 +641,8 @@ fn create_branch_patch(
637 base_ref: base_ref.to_string(), 641 base_ref: base_ref.to_string(),
638 branch: branch.to_string(), 642 branch: branch.to_string(),
639 fixes: None, 643 fixes: None,
644 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
645 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
640 }, 646 },
641 clock: 0, 647 clock: 0,
642 }; 648 };
@@ -850,7 +856,9 @@ fn test_patch_create_with_head_commit_field_deserializes() {
850 "title": "Agent patch", 856 "title": "Agent patch",
851 "body": "from worktree", 857 "body": "from worktree",
852 "base_ref": "main", 858 "base_ref": "main",
853 "head_commit": "abc123def456" 859 "head_commit": "abc123def456",
860 "commit": "abc123",
861 "tree": "def456"
854 } 862 }
855 }"#; 863 }"#;
856 let event: Event = serde_json::from_str(json).unwrap(); 864 let event: Event = serde_json::from_str(json).unwrap();
@@ -872,7 +880,9 @@ fn test_patch_create_with_branch_field_still_works() {
872 "title": "Normal patch", 880 "title": "Normal patch",
873 "body": "", 881 "body": "",
874 "base_ref": "main", 882 "base_ref": "main",
875 "branch": "feature-branch" 883 "branch": "feature-branch",
884 "commit": "abc123",
885 "tree": "def456"
876 } 886 }
877 }"#; 887 }"#;
878 let event: Event = serde_json::from_str(json).unwrap(); 888 let event: Event = serde_json::from_str(json).unwrap();
@@ -903,6 +913,8 @@ fn test_resolve_head_with_oid_string() {
903 base_ref: "main".to_string(), 913 base_ref: "main".to_string(),
904 branch: tip.to_string(), 914 branch: tip.to_string(),
905 fixes: None, 915 fixes: None,
916 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
917 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
906 }, 918 },
907 clock: 0, 919 clock: 0,
908 }; 920 };
tests/common/mod.rs
Old New
@@ -45,7 +45,8 @@ pub fn setup_signing_key(config_dir: &Path) {
45 git_collab::signing::generate_keypair(config_dir).expect("generate test keypair"); 45 git_collab::signing::generate_keypair(config_dir).expect("generate test keypair");
46 } 46 }
47 47
48 /// Create a non-bare repo in a directory with user identity configured. 48 /// Create a non-bare repo in a directory with user identity configured
49 /// and an initial empty commit on `main`.
49 pub fn init_repo(dir: &Path, author: &Author) -> Repository { 50 pub fn init_repo(dir: &Path, author: &Author) -> Repository {
50 let repo = Repository::init(dir).expect("init repo"); 51 let repo = Repository::init(dir).expect("init repo");
51 { 52 {
@@ -53,6 +54,13 @@ pub fn init_repo(dir: &Path, author: &Author) -> Repository {
53 config.set_str("user.name", &author.name).unwrap(); 54 config.set_str("user.name", &author.name).unwrap();
54 config.set_str("user.email", &author.email).unwrap(); 55 config.set_str("user.email", &author.email).unwrap();
55 } 56 }
57 // Create initial empty commit so that HEAD and refs/heads/main exist
58 {
59 let sig = git2::Signature::now(&author.name, &author.email).unwrap();
60 let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
61 let tree = repo.find_tree(tree_oid).unwrap();
62 repo.commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[]).unwrap();
63 }
56 repo 64 repo
57 } 65 }
58 66
@@ -117,6 +125,11 @@ pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
117 /// Create a patch using DAG primitives. Returns (ref_name, id). 125 /// Create a patch using DAG primitives. Returns (ref_name, id).
118 pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) { 126 pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
119 let sk = test_signing_key(); 127 let sk = test_signing_key();
128 // Get branch tip for commit/tree fields
129 let head = repo.head().unwrap();
130 let commit_oid = head.target().unwrap();
131 let commit = repo.find_commit(commit_oid).unwrap();
132 let tree_oid = commit.tree().unwrap().id();
120 let event = Event { 133 let event = Event {
121 timestamp: now(), 134 timestamp: now(),
122 author: author.clone(), 135 author: author.clone(),
@@ -126,6 +139,8 @@ pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String,
126 base_ref: "main".to_string(), 139 base_ref: "main".to_string(),
127 branch: "test-branch".to_string(), 140 branch: "test-branch".to_string(),
128 fixes: None, 141 fixes: None,
142 commit: commit_oid.to_string(),
143 tree: tree_oid.to_string(),
129 }, 144 },
130 clock: 0, 145 clock: 0,
131 }; 146 };
@@ -145,6 +160,7 @@ pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: R
145 action: Action::PatchReview { 160 action: Action::PatchReview {
146 verdict, 161 verdict,
147 body: "review comment".to_string(), 162 body: "review comment".to_string(),
163 revision: 1,
148 }, 164 },
149 clock: 0, 165 clock: 0,
150 }; 166 };
tests/crdt_test.rs
Old New
@@ -361,6 +361,8 @@ fn concurrent_patch_close_merge_higher_clock_wins() {
361 base_ref: "main".to_string(), 361 base_ref: "main".to_string(),
362 branch: "test-branch".to_string(), 362 branch: "test-branch".to_string(),
363 fixes: None, 363 fixes: None,
364 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
365 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
364 }, 366 },
365 clock: 0, 367 clock: 0,
366 }; 368 };
tests/revision_test.rs
Old New
@@ -0,0 +1,767 @@
1 mod common;
2
3 use common::TestRepo;
4 use common::{alice, bob, init_repo, test_signing_key, now};
5
6 use git_collab::dag;
7 use git_collab::event::{Action, Event};
8 use git_collab::state::PatchState;
9
10 use tempfile::TempDir;
11
12 // ===========================================================================
13 // Revision recording on patch create
14 // ===========================================================================
15
16 #[test]
17 fn test_patch_create_records_revision_1() {
18 let repo = TestRepo::new("Alice", "alice@example.com");
19 let id = repo.patch_create("Feature X");
20
21 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
22 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
23 let revisions = json["revisions"].as_array().unwrap();
24 assert_eq!(revisions.len(), 1);
25 assert_eq!(revisions[0]["number"], 1);
26 assert!(!revisions[0]["commit"].as_str().unwrap().is_empty());
27 assert!(!revisions[0]["tree"].as_str().unwrap().is_empty());
28 }
29
30 // ===========================================================================
31 // Auto-detect revision on comment
32 // ===========================================================================
33
34 #[test]
35 fn test_auto_detect_revision_on_comment() {
36 let repo = TestRepo::new("Alice", "alice@example.com");
37
38 // Create feature branch and patch
39 repo.git(&["checkout", "-b", "feat-auto"]);
40 repo.commit_file("v1.txt", "v1", "initial commit");
41 let out = repo.run_ok(&["patch", "create", "-t", "Auto-detect test", "-B", "feat-auto"]);
42 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
43
44 // Push a new commit to the branch
45 repo.git(&["checkout", "feat-auto"]);
46 repo.commit_file("v2.txt", "v2", "second commit");
47 repo.git(&["checkout", "main"]);
48
49 // Comment should auto-insert a PatchRevision first
50 repo.run_ok(&["patch", "comment", &id, "-b", "Looks interesting"]);
51
52 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
53 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
54 let revisions = json["revisions"].as_array().unwrap();
55 assert_eq!(revisions.len(), 2, "should have 2 revisions (create + auto-detect)");
56 assert_eq!(revisions[0]["number"], 1);
57 assert_eq!(revisions[1]["number"], 2);
58 }
59
60 #[test]
61 fn test_no_revision_when_branch_unchanged() {
62 let repo = TestRepo::new("Alice", "alice@example.com");
63
64 repo.git(&["checkout", "-b", "feat-no-change"]);
65 repo.commit_file("v1.txt", "v1", "initial commit");
66 let out = repo.run_ok(&["patch", "create", "-t", "No change test", "-B", "feat-no-change"]);
67 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
68
69 // Comment WITHOUT branch change — no new revision
70 repo.run_ok(&["patch", "comment", &id, "-b", "Just a thought"]);
71
72 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
73 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
74 let revisions = json["revisions"].as_array().unwrap();
75 assert_eq!(revisions.len(), 1, "should still have only 1 revision");
76 }
77
78 // ===========================================================================
79 // Auto-detect revision on review
80 // ===========================================================================
81
82 #[test]
83 fn test_auto_detect_revision_on_review() {
84 let repo = TestRepo::new("Alice", "alice@example.com");
85
86 repo.git(&["checkout", "-b", "feat-review"]);
87 repo.commit_file("v1.txt", "v1", "initial commit");
88 let out = repo.run_ok(&["patch", "create", "-t", "Review test", "-B", "feat-review"]);
89 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
90
91 // Push a new commit
92 repo.git(&["checkout", "feat-review"]);
93 repo.commit_file("v2.txt", "v2", "second commit");
94 repo.git(&["checkout", "main"]);
95
96 // Review should auto-insert a PatchRevision
97 repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM"]);
98
99 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
100 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
101 let revisions = json["revisions"].as_array().unwrap();
102 assert_eq!(revisions.len(), 2);
103
104 // Review should be anchored to revision 2
105 let reviews = json["reviews"].as_array().unwrap();
106 assert_eq!(reviews.len(), 1);
107 assert_eq!(reviews[0]["revision"], 2);
108 }
109
110 // ===========================================================================
111 // Explicit revise
112 // ===========================================================================
113
114 #[test]
115 fn test_revise_creates_revision() {
116 let repo = TestRepo::new("Alice", "alice@example.com");
117
118 repo.git(&["checkout", "-b", "feat-revise"]);
119 repo.commit_file("v1.txt", "v1", "initial commit");
120 let out = repo.run_ok(&["patch", "create", "-t", "Revise test", "-B", "feat-revise"]);
121 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
122
123 // Push new commit
124 repo.git(&["checkout", "feat-revise"]);
125 repo.commit_file("v2.txt", "v2", "second commit");
126 repo.git(&["checkout", "main"]);
127
128 repo.run_ok(&["patch", "revise", &id, "-b", "Addressed feedback"]);
129
130 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
131 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
132 let revisions = json["revisions"].as_array().unwrap();
133 assert_eq!(revisions.len(), 2);
134 assert_eq!(revisions[1]["body"].as_str().unwrap(), "Addressed feedback");
135 }
136
137 #[test]
138 fn test_revise_rejects_when_unchanged() {
139 let repo = TestRepo::new("Alice", "alice@example.com");
140
141 repo.git(&["checkout", "-b", "feat-revise-err"]);
142 repo.commit_file("v1.txt", "v1", "initial commit");
143 let out = repo.run_ok(&["patch", "create", "-t", "Revise error test", "-B", "feat-revise-err"]);
144 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
145
146 // No new commit — revise should fail
147 let err = repo.run_err(&["patch", "revise", &id]);
148 assert!(err.contains("no changes since revision"));
149 }
150
151 // ===========================================================================
152 // Inline comments anchored to revisions
153 // ===========================================================================
154
155 #[test]
156 fn test_inline_comment_anchored_to_revision() {
157 let repo = TestRepo::new("Alice", "alice@example.com");
158
159 repo.git(&["checkout", "-b", "feat-inline"]);
160 repo.commit_file("src/lib.rs", "fn hello() {}", "initial");
161 let out = repo.run_ok(&["patch", "create", "-t", "Inline test", "-B", "feat-inline"]);
162 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
163
164 // Comment on revision 1
165 repo.run_ok(&[
166 "patch", "comment", &id, "-b", "nit: naming", "-f", "src/lib.rs", "-l", "1",
167 ]);
168
169 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
170 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
171 let inline = json["inline_comments"].as_array().unwrap();
172 assert_eq!(inline.len(), 1);
173 assert_eq!(inline[0]["revision"], 1);
174 }
175
176 #[test]
177 fn test_inline_comment_explicit_revision() {
178 let repo = TestRepo::new("Alice", "alice@example.com");
179
180 repo.git(&["checkout", "-b", "feat-inline-rev"]);
181 repo.commit_file("lib.rs", "fn a() {}", "v1");
182 let out = repo.run_ok(&["patch", "create", "-t", "Inline rev test", "-B", "feat-inline-rev"]);
183 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
184
185 // Push v2
186 repo.git(&["checkout", "feat-inline-rev"]);
187 repo.commit_file("lib.rs", "fn b() {}", "v2");
188 repo.git(&["checkout", "main"]);
189
190 // Auto-detect by commenting
191 repo.run_ok(&["patch", "comment", &id, "-b", "trigger revision"]);
192
193 // Now explicitly target revision 1
194 repo.run_ok(&[
195 "patch", "comment", &id, "-b", "old nit", "-f", "lib.rs", "-l", "1", "--revision", "1",
196 ]);
197
198 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
199 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
200 let inline = json["inline_comments"].as_array().unwrap();
201 assert_eq!(inline.len(), 1);
202 assert_eq!(inline[0]["revision"], 1);
203 }
204
205 #[test]
206 fn test_thread_comment_rejects_revision_flag() {
207 let repo = TestRepo::new("Alice", "alice@example.com");
208
209 repo.git(&["checkout", "-b", "feat-thread"]);
210 repo.commit_file("x.txt", "x", "init");
211 let out = repo.run_ok(&["patch", "create", "-t", "Thread test", "-B", "feat-thread"]);
212 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
213
214 let err = repo.run_err(&["patch", "comment", &id, "-b", "note", "--revision", "1"]);
215 assert!(err.contains("thread comments are not revision-scoped"));
216 }
217
218 // ===========================================================================
219 // patch show --revision N filters
220 // ===========================================================================
221
222 #[test]
223 fn test_show_revision_filter() {
224 let repo = TestRepo::new("Alice", "alice@example.com");
225
226 repo.git(&["checkout", "-b", "feat-show-rev"]);
227 repo.commit_file("a.txt", "a", "v1");
228 let out = repo.run_ok(&["patch", "create", "-t", "Show rev test", "-B", "feat-show-rev"]);
229 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
230
231 // Comment on r1
232 repo.run_ok(&[
233 "patch", "comment", &id, "-b", "r1 comment", "-f", "a.txt", "-l", "1",
234 ]);
235
236 // Push v2 and comment on r2
237 repo.git(&["checkout", "feat-show-rev"]);
238 repo.commit_file("b.txt", "b", "v2");
239 repo.git(&["checkout", "main"]);
240 repo.run_ok(&[
241 "patch", "comment", &id, "-b", "r2 comment", "-f", "b.txt", "-l", "1",
242 ]);
243
244 // Show --revision 1: should show r1 comment, not r2
245 let out = repo.run_ok(&["patch", "show", &id, "--revision", "1"]);
246 assert!(out.contains("r1 comment"));
247 assert!(!out.contains("r2 comment"));
248
249 // Show --revision 2: should show r2 comment, not r1
250 let out = repo.run_ok(&["patch", "show", &id, "--revision", "2"]);
251 assert!(out.contains("r2 comment"));
252 assert!(!out.contains("r1 comment"));
253 }
254
255 // ===========================================================================
256 // Interdiff between revisions
257 // ===========================================================================
258
259 #[test]
260 fn test_interdiff_between_revisions() {
261 let repo = TestRepo::new("Alice", "alice@example.com");
262
263 repo.git(&["checkout", "-b", "feat-interdiff"]);
264 repo.commit_file("a.txt", "hello", "v1");
265 let out = repo.run_ok(&["patch", "create", "-t", "Interdiff test", "-B", "feat-interdiff"]);
266 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
267
268 // Push v2
269 repo.git(&["checkout", "feat-interdiff"]);
270 repo.commit_file("b.txt", "world", "v2");
271 repo.git(&["checkout", "main"]);
272
273 // Create revision 2 via revise
274 repo.run_ok(&["patch", "revise", &id]);
275
276 // Interdiff between r1 and r2 should show b.txt added
277 let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);
278 assert!(out.contains("b.txt"));
279 }
280
281 #[test]
282 fn test_interdiff_single_arg_means_n_to_latest() {
283 let repo = TestRepo::new("Alice", "alice@example.com");
284
285 repo.git(&["checkout", "-b", "feat-between-single"]);
286 repo.commit_file("a.txt", "hello", "v1");
287 let out = repo.run_ok(&["patch", "create", "-t", "Between single test", "-B", "feat-between-single"]);
288 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
289
290 // Push v2
291 repo.git(&["checkout", "feat-between-single"]);
292 repo.commit_file("c.txt", "new", "v2");
293 repo.git(&["checkout", "main"]);
294 repo.run_ok(&["patch", "revise", &id]);
295
296 // --between 1 (single arg) = 1..latest
297 let out = repo.run_ok(&["patch", "diff", &id, "--between", "1"]);
298 assert!(out.contains("c.txt"));
299 }
300
301 #[test]
302 fn test_interdiff_nonexistent_revision_errors() {
303 let repo = TestRepo::new("Alice", "alice@example.com");
304
305 repo.git(&["checkout", "-b", "feat-bad-rev"]);
306 repo.commit_file("a.txt", "a", "v1");
307 let out = repo.run_ok(&["patch", "create", "-t", "Bad rev test", "-B", "feat-bad-rev"]);
308 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
309
310 let err = repo.run_err(&["patch", "diff", &id, "--between", "1", "2"]);
311 assert!(err.contains("revision 2 not found"));
312 }
313
314 #[test]
315 fn test_diff_revision_flag_shows_historical_diff() {
316 let repo = TestRepo::new("Alice", "alice@example.com");
317
318 repo.git(&["checkout", "-b", "feat-hist"]);
319 repo.commit_file("a.txt", "hello", "v1");
320 let out = repo.run_ok(&["patch", "create", "-t", "Hist diff test", "-B", "feat-hist"]);
321 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
322
323 // Push v2 (adds b.txt)
324 repo.git(&["checkout", "feat-hist"]);
325 repo.commit_file("b.txt", "world", "v2");
326 repo.git(&["checkout", "main"]);
327 repo.run_ok(&["patch", "revise", &id]);
328
329 // --revision 1 should show only a.txt
330 let out = repo.run_ok(&["patch", "diff", &id, "--revision", "1"]);
331 assert!(out.contains("a.txt"));
332 assert!(!out.contains("b.txt"));
333 }
334
335 #[test]
336 fn test_diff_revision_and_between_mutually_exclusive() {
337 let repo = TestRepo::new("Alice", "alice@example.com");
338
339 repo.git(&["checkout", "-b", "feat-mutex"]);
340 repo.commit_file("a.txt", "a", "v1");
341 let out = repo.run_ok(&["patch", "create", "-t", "Mutex test", "-B", "feat-mutex"]);
342 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
343
344 let err = repo.run_err(&["patch", "diff", &id, "--revision", "1", "--between", "1"]);
345 assert!(err.contains("mutually exclusive"));
346 }
347
348 // ===========================================================================
349 // Patch log
350 // ===========================================================================
351
352 #[test]
353 fn test_patch_log() {
354 let repo = TestRepo::new("Alice", "alice@example.com");
355
356 repo.git(&["checkout", "-b", "feat-log"]);
357 repo.commit_file("a.txt", "hello", "v1");
358 let out = repo.run_ok(&["patch", "create", "-t", "Log test", "-B", "feat-log"]);
359 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
360
361 // Push v2
362 repo.git(&["checkout", "feat-log"]);
363 repo.commit_file("b.txt", "world", "v2");
364 repo.git(&["checkout", "main"]);
365 repo.run_ok(&["patch", "revise", &id, "-b", "added b.txt"]);
366
367 // Push v3
368 repo.git(&["checkout", "feat-log"]);
369 repo.commit_file("c.txt", "!", "v3");
370 repo.git(&["checkout", "main"]);
371 repo.run_ok(&["patch", "revise", &id]);
372
373 let out = repo.run_ok(&["patch", "log", &id]);
374 assert!(out.contains("r1"));
375 assert!(out.contains("(initial)"));
376 assert!(out.contains("r2"));
377 assert!(out.contains("added b.txt"));
378 assert!(out.contains("r3"));
379 }
380
381 #[test]
382 fn test_patch_log_json() {
383 let repo = TestRepo::new("Alice", "alice@example.com");
384
385 repo.git(&["checkout", "-b", "feat-log-json"]);
386 repo.commit_file("a.txt", "a", "v1");
387 let out = repo.run_ok(&["patch", "create", "-t", "Log JSON test", "-B", "feat-log-json"]);
388 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
389
390 let out = repo.run_ok(&["patch", "log", &id, "--json"]);
391 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
392 let revisions = json.as_array().unwrap();
393 assert_eq!(revisions.len(), 1);
394 assert_eq!(revisions[0]["number"], 1);
395 }
396
397 // ===========================================================================
398 // Reviews anchored to revisions
399 // ===========================================================================
400
401 #[test]
402 fn test_review_explicit_revision() {
403 let repo = TestRepo::new("Alice", "alice@example.com");
404
405 repo.git(&["checkout", "-b", "feat-rev-explicit"]);
406 repo.commit_file("a.txt", "a", "v1");
407 let out = repo.run_ok(&["patch", "create", "-t", "Rev review", "-B", "feat-rev-explicit"]);
408 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
409
410 // Push v2
411 repo.git(&["checkout", "feat-rev-explicit"]);
412 repo.commit_file("b.txt", "b", "v2");
413 repo.git(&["checkout", "main"]);
414 repo.run_ok(&["patch", "revise", &id]);
415
416 // Review targeting revision 1
417 repo.run_ok(&[
418 "patch", "review", &id, "-v", "request-changes", "-b", "fix r1", "--revision", "1",
419 ]);
420
421 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
422 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
423 let reviews = json["reviews"].as_array().unwrap();
424 assert_eq!(reviews.len(), 1);
425 assert_eq!(reviews[0]["revision"], 1);
426 }
427
428 #[test]
429 fn test_review_shows_revision_context_in_show() {
430 let repo = TestRepo::new("Alice", "alice@example.com");
431
432 repo.git(&["checkout", "-b", "feat-rev-show"]);
433 repo.commit_file("a.txt", "a", "v1");
434 let out = repo.run_ok(&["patch", "create", "-t", "Review show", "-B", "feat-rev-show"]);
435 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
436
437 repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM"]);
438
439 let out = repo.run_ok(&["patch", "show", &id]);
440 assert!(out.contains("(r1)"));
441 }
442
443 // ===========================================================================
444 // Dedup by commit OID
445 // ===========================================================================
446
447 #[test]
448 fn test_revision_dedup_by_commit_oid() {
449 // Two comments with no branch change should not create duplicate revisions
450 let repo = TestRepo::new("Alice", "alice@example.com");
451
452 repo.git(&["checkout", "-b", "feat-dedup"]);
453 repo.commit_file("a.txt", "a", "v1");
454 let out = repo.run_ok(&["patch", "create", "-t", "Dedup test", "-B", "feat-dedup"]);
455 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
456
457 // Push v2
458 repo.git(&["checkout", "feat-dedup"]);
459 repo.commit_file("b.txt", "b", "v2");
460 repo.git(&["checkout", "main"]);
461
462 // Two comments — first should auto-detect revision, second should not create another
463 repo.run_ok(&["patch", "comment", &id, "-b", "first"]);
464 repo.run_ok(&["patch", "comment", &id, "-b", "second"]);
465
466 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
467 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
468 let revisions = json["revisions"].as_array().unwrap();
469 assert_eq!(revisions.len(), 2, "should have exactly 2 revisions, not 3");
470 }
471
472 // ===========================================================================
473 // Revision count in show output
474 // ===========================================================================
475
476 #[test]
477 fn test_show_displays_revision_count() {
478 let repo = TestRepo::new("Alice", "alice@example.com");
479
480 repo.git(&["checkout", "-b", "feat-count"]);
481 repo.commit_file("a.txt", "a", "v1");
482 let out = repo.run_ok(&["patch", "create", "-t", "Count test", "-B", "feat-count"]);
483 let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();
484
485 let out = repo.run_ok(&["patch", "show", &id]);
486 assert!(out.contains("(r1)"));
487 }
488
489 // ===========================================================================
490 // D1: Concurrent PatchRevision dedup after DAG reconciliation
491 // ===========================================================================
492
493 #[test]
494 fn test_concurrent_revision_dedup_after_reconcile() {
495 // Two collaborators independently detect the same branch change and
496 // create PatchRevision events with the same commit OID. After DAG
497 // reconciliation, only one revision boundary should exist.
498 let tmp = TempDir::new().unwrap();
499 let repo = init_repo(tmp.path(), &alice());
500
501 // Create a branch with a commit
502 let head = repo.head().unwrap().target().unwrap();
503 let head_commit = repo.find_commit(head).unwrap();
504 repo.branch("feat-concurrent", &head_commit, false).unwrap();
505
506 let sk = test_signing_key();
507
508 // Create a patch (revision 1)
509 let tree_oid = head_commit.tree().unwrap().id();
510 let create_event = Event {
511 timestamp: now(),
512 author: alice(),
513 action: Action::PatchCreate {
514 title: "Concurrent test".to_string(),
515 body: "".to_string(),
516 base_ref: "main".to_string(),
517 branch: "feat-concurrent".to_string(),
518 fixes: None,
519 commit: head.to_string(),
520 tree: tree_oid.to_string(),
521 },
522 clock: 0,
523 };
524 let patch_oid = dag::create_root_event(&repo, &create_event, &sk).unwrap();
525 let id = patch_oid.to_string();
526 let ref_name = format!("refs/collab/patches/{}", id);
527 repo.reference(&ref_name, patch_oid, false, "test").unwrap();
528
529 // Push a new commit on the branch
530 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
531 let blob = repo.blob(b"new content").unwrap();
532 let mut tb = repo.treebuilder(Some(&head_commit.tree().unwrap())).unwrap();
533 tb.insert("new.txt", blob, 0o100644).unwrap();
534 let new_tree_oid = tb.write().unwrap();
535 let new_tree = repo.find_tree(new_tree_oid).unwrap();
536 let new_commit = repo.commit(
537 Some("refs/heads/feat-concurrent"),
538 &sig, &sig, "new commit", &new_tree, &[&head_commit],
539 ).unwrap();
540
541 let root_tip = repo.refname_to_id(&ref_name).unwrap();
542
543 // Alice appends a PatchRevision for the new commit
544 let rev_event = Event {
545 timestamp: now(),
546 author: alice(),
547 action: Action::PatchRevision {
548 commit: new_commit.to_string(),
549 tree: new_tree_oid.to_string(),
550 body: None,
551 },
552 clock: 0,
553 };
554 dag::append_event(&repo, &ref_name, &rev_event, &sk).unwrap();
555 let alice_tip = repo.refname_to_id(&ref_name).unwrap();
556
557 // Reset ref back to root, then Bob also appends same PatchRevision
558 repo.reference(&ref_name, root_tip, true, "reset for bob").unwrap();
559 let rev_event_bob = Event {
560 timestamp: now(),
561 author: bob(),
562 action: Action::PatchRevision {
563 commit: new_commit.to_string(),
564 tree: new_tree_oid.to_string(),
565 body: None,
566 },
567 clock: 0,
568 };
569 dag::append_event(&repo, &ref_name, &rev_event_bob, &sk).unwrap();
570 let bob_tip = repo.refname_to_id(&ref_name).unwrap();
571
572 // Reconcile: create a remote ref pointing to bob's tip, local to alice's tip
573 let remote_ref = format!("refs/collab/sync/origin/patches/{}", id);
574 repo.reference(&remote_ref, bob_tip, true, "remote").unwrap();
575 repo.reference(&ref_name, alice_tip, true, "restore alice").unwrap();
576 dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap();
577
578 // Materialize and verify: should have exactly 2 revisions (create + 1 deduped)
579 let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
580 assert_eq!(
581 state.revisions.len(), 2,
582 "duplicate PatchRevision events with same commit OID should be deduped to one revision"
583 );
584 assert_eq!(state.revisions[0].number, 1);
585 assert_eq!(state.revisions[1].number, 2);
586 assert_eq!(state.revisions[1].commit, new_commit.to_string());
587 }
588
589 // ===========================================================================
590 // D2: Merge policy enforcement (require_approval_on_latest)
591 // ===========================================================================
592
593 #[test]
594 fn test_merge_policy_require_approval_on_latest() {
595 let tmp = TempDir::new().unwrap();
596 let repo = init_repo(tmp.path(), &alice());
597 let sk = test_signing_key();
598
599 // Create a branch with a commit
600 let head = repo.head().unwrap().target().unwrap();
601 let head_commit = repo.find_commit(head).unwrap();
602 repo.branch("feat-policy", &head_commit, false).unwrap();
603
604 // Add a commit on the branch so it differs from main
605 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
606 let blob = repo.blob(b"feature code").unwrap();
607 let mut tb = repo.treebuilder(Some(&head_commit.tree().unwrap())).unwrap();
608 tb.insert("feature.txt", blob, 0o100644).unwrap();
609 let feat_tree_oid = tb.write().unwrap();
610 let feat_tree = repo.find_tree(feat_tree_oid).unwrap();
611 let feat_commit = repo.commit(
612 Some("refs/heads/feat-policy"),
613 &sig, &sig, "add feature", &feat_tree, &[&head_commit],
614 ).unwrap();
615
616 // Create patch (revision 1)
617 let create_event = Event {
618 timestamp: now(),
619 author: alice(),
620 action: Action::PatchCreate {
621 title: "Policy test".to_string(),
622 body: "".to_string(),
623 base_ref: "main".to_string(),
624 branch: "feat-policy".to_string(),
625 fixes: None,
626 commit: feat_commit.to_string(),
627 tree: feat_tree_oid.to_string(),
628 },
629 clock: 0,
630 };
631 let patch_oid = dag::create_root_event(&repo, &create_event, &sk).unwrap();
632 let patch_id = patch_oid.to_string();
633 let patch_ref = format!("refs/collab/patches/{}", patch_id);
634 repo.reference(&patch_ref, patch_oid, false, "test").unwrap();
635
636 // Approve on revision 1
637 let review_event = Event {
638 timestamp: now(),
639 author: bob(),
640 action: Action::PatchReview {
641 verdict: git_collab::event::ReviewVerdict::Approve,
642 body: "LGTM".to_string(),
643 revision: 1,
644 },
645 clock: 0,
646 };
647 dag::append_event(&repo, &patch_ref, &review_event, &sk).unwrap();
648
649 // Push revision 2 (new commit on branch)
650 let blob2 = repo.blob(b"v2 code").unwrap();
651 let parent = repo.find_commit(feat_commit).unwrap();
652 let mut tb2 = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
653 tb2.insert("v2.txt", blob2, 0o100644).unwrap();
654 let v2_tree_oid = tb2.write().unwrap();
655 let v2_tree = repo.find_tree(v2_tree_oid).unwrap();
656 let v2_commit = repo.commit(
657 Some("refs/heads/feat-policy"),
658 &sig, &sig, "v2", &v2_tree, &[&parent],
659 ).unwrap();
660
661 // Record revision 2
662 let rev_event = Event {
663 timestamp: now(),
664 author: alice(),
665 action: Action::PatchRevision {
666 commit: v2_commit.to_string(),
667 tree: v2_tree_oid.to_string(),
668 body: None,
669 },
670 clock: 0,
671 };
672 dag::append_event(&repo, &patch_ref, &rev_event, &sk).unwrap();
673
674 // Write merge policy config: require_approval_on_latest = true
675 let config_json = br#"{"merge":{"require_approval_on_latest":true}}"#;
676 let config_blob = repo.blob(config_json).unwrap();
677 let mut ctb = repo.treebuilder(None).unwrap();
678 ctb.insert("config.json", config_blob, 0o100644).unwrap();
679 let config_tree_oid = ctb.write().unwrap();
680 let config_tree = repo.find_tree(config_tree_oid).unwrap();
681 repo.commit(
682 Some("refs/collab/config"),
683 &sig, &sig, "set merge policy", &config_tree, &[],
684 ).unwrap();
685
686 // Try to merge via the library — should fail because approval is on revision 1, not 2
687 let result = git_collab::patch::merge(&repo, &patch_id[..8]);
688 assert!(result.is_err(), "merge should fail when approval is not on latest revision");
689 let err_msg = result.unwrap_err().to_string();
690 assert!(
691 err_msg.contains("merge requires approval on the latest revision"),
692 "error message should mention latest revision requirement, got: {}",
693 err_msg
694 );
695 }
696
697 #[test]
698 fn test_merge_policy_passes_when_approved_on_latest() {
699 let tmp = TempDir::new().unwrap();
700 let repo = init_repo(tmp.path(), &alice());
701 let sk = test_signing_key();
702
703 // Create a branch with a commit
704 let head = repo.head().unwrap().target().unwrap();
705 let head_commit = repo.find_commit(head).unwrap();
706 repo.branch("feat-policy-ok", &head_commit, false).unwrap();
707
708 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
709 let blob = repo.blob(b"feature code").unwrap();
710 let mut tb = repo.treebuilder(Some(&head_commit.tree().unwrap())).unwrap();
711 tb.insert("feature.txt", blob, 0o100644).unwrap();
712 let feat_tree_oid = tb.write().unwrap();
713 let feat_tree = repo.find_tree(feat_tree_oid).unwrap();
714 let feat_commit = repo.commit(
715 Some("refs/heads/feat-policy-ok"),
716 &sig, &sig, "add feature", &feat_tree, &[&head_commit],
717 ).unwrap();
718
719 // Create patch
720 let create_event = Event {
721 timestamp: now(),
722 author: alice(),
723 action: Action::PatchCreate {
724 title: "Policy pass test".to_string(),
725 body: "".to_string(),
726 base_ref: "main".to_string(),
727 branch: "feat-policy-ok".to_string(),
728 fixes: None,
729 commit: feat_commit.to_string(),
730 tree: feat_tree_oid.to_string(),
731 },
732 clock: 0,
733 };
734 let patch_oid = dag::create_root_event(&repo, &create_event, &sk).unwrap();
735 let patch_id = patch_oid.to_string();
736 let patch_ref = format!("refs/collab/patches/{}", patch_id);
737 repo.reference(&patch_ref, patch_oid, false, "test").unwrap();
738
739 // Approve on revision 1 (which IS the latest)
740 let review_event = Event {
741 timestamp: now(),
742 author: bob(),
743 action: Action::PatchReview {
744 verdict: git_collab::event::ReviewVerdict::Approve,
745 body: "LGTM".to_string(),
746 revision: 1,
747 },
748 clock: 0,
749 };
750 dag::append_event(&repo, &patch_ref, &review_event, &sk).unwrap();
751
752 // Write merge policy config
753 let config_json = br#"{"merge":{"require_approval_on_latest":true}}"#;
754 let config_blob = repo.blob(config_json).unwrap();
755 let mut ctb = repo.treebuilder(None).unwrap();
756 ctb.insert("config.json", config_blob, 0o100644).unwrap();
757 let config_tree_oid = ctb.write().unwrap();
758 let config_tree = repo.find_tree(config_tree_oid).unwrap();
759 repo.commit(
760 Some("refs/collab/config"),
761 &sig, &sig, "set merge policy", &config_tree, &[],
762 ).unwrap();
763
764 // Merge should succeed — approval is on latest revision (1)
765 let result = git_collab::patch::merge(&repo, &patch_id[..8]);
766 assert!(result.is_ok(), "merge should succeed when approval is on latest revision: {:?}", result.err());
767 }
tests/signing_test.rs
Old New
@@ -210,6 +210,8 @@ fn event_json_uses_namespaced_action_types() {
210 base_ref: "main".to_string(), 210 base_ref: "main".to_string(),
211 branch: "feature/fix-bug".to_string(), 211 branch: "feature/fix-bug".to_string(),
212 fixes: Some("deadbeef".to_string()), 212 fixes: Some("deadbeef".to_string()),
213 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
214 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
213 }, 215 },
214 clock: 0, 216 clock: 0,
215 }; 217 };
tests/sort_test.rs
Old New
@@ -87,6 +87,8 @@ fn create_patch_at(
87 base_ref: "main".to_string(), 87 base_ref: "main".to_string(),
88 branch: format!("branch-{}", title.replace(' ', "-")), 88 branch: format!("branch-{}", title.replace(' ', "-")),
89 fixes: None, 89 fixes: None,
90 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
91 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
90 }, 92 },
91 clock: 0, 93 clock: 0,
92 }; 94 };
tests/sync_test.rs
Old New
@@ -291,6 +291,8 @@ fn test_patch_review_across_repos() {
291 base_ref: "main".to_string(), 291 base_ref: "main".to_string(),
292 branch: "feature/x".to_string(), 292 branch: "feature/x".to_string(),
293 fixes: None, 293 fixes: None,
294 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
295 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
294 }, 296 },
295 clock: 0, 297 clock: 0,
296 }; 298 };
@@ -312,6 +314,7 @@ fn test_patch_review_across_repos() {
312 action: Action::PatchReview { 314 action: Action::PatchReview {
313 verdict: ReviewVerdict::Approve, 315 verdict: ReviewVerdict::Approve,
314 body: "LGTM!".to_string(), 316 body: "LGTM!".to_string(),
317 revision: 1,
315 }, 318 },
316 clock: 0, 319 clock: 0,
317 }; 320 };
@@ -340,6 +343,8 @@ fn test_concurrent_review_and_revise() {
340 base_ref: "main".to_string(), 343 base_ref: "main".to_string(),
341 branch: "feature/wip".to_string(), 344 branch: "feature/wip".to_string(),
342 fixes: None, 345 fixes: None,
346 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
347 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
343 }, 348 },
344 clock: 0, 349 clock: 0,
345 }; 350 };
@@ -356,7 +361,9 @@ fn test_concurrent_review_and_revise() {
356 let revise_event = Event { 361 let revise_event = Event {
357 timestamp: now(), 362 timestamp: now(),
358 author: alice(), 363 author: alice(),
359 action: Action::PatchRevise { 364 action: Action::PatchRevision {
365 commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
366 tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
360 body: Some("Updated description".to_string()), 367 body: Some("Updated description".to_string()),
361 }, 368 },
362 clock: 0, 369 clock: 0,
@@ -370,6 +377,7 @@ fn test_concurrent_review_and_revise() {
370 action: Action::PatchReview { 377 action: Action::PatchReview {
371 verdict: ReviewVerdict::RequestChanges, 378 verdict: ReviewVerdict::RequestChanges,
372 body: "Needs work".to_string(), 379 body: "Needs work".to_string(),
380 revision: 1,
373 }, 381 },
374 clock: 0, 382 clock: 0,
375 }; 383 };
@@ -457,15 +465,6 @@ fn test_unsigned_event_sync_rejected() {
457 // Set up Alice's repo with an unsigned event directly via git2 465 // Set up Alice's repo with an unsigned event directly via git2
458 let alice_dir = TempDir::new().unwrap(); 466 let alice_dir = TempDir::new().unwrap();
459 let alice_repo = common::init_repo(alice_dir.path(), &alice()); 467 let alice_repo = common::init_repo(alice_dir.path(), &alice());
460 // Create initial commit so repo is not empty
461 {
462 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
463 let tree_oid = alice_repo.treebuilder(None).unwrap().write().unwrap();
464 let tree = alice_repo.find_tree(tree_oid).unwrap();
465 alice_repo
466 .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
467 .unwrap();
468 }
469 468
470 // Create an unsigned event 469 // Create an unsigned event
471 let event = Event { 470 let event = Event {
@@ -507,14 +506,6 @@ fn test_tampered_event_sync_rejected() {
507 // Set up a repo with a tampered event 506 // Set up a repo with a tampered event
508 let dir = TempDir::new().unwrap(); 507 let dir = TempDir::new().unwrap();
509 let repo = common::init_repo(dir.path(), &alice()); 508 let repo = common::init_repo(dir.path(), &alice());
510 // Create initial commit
511 {
512 let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
513 let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
514 let tree = repo.find_tree(tree_oid).unwrap();
515 repo.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
516 .unwrap();
517 }
518 509
519 // Create a tampered event (signed then modified) 510 // Create a tampered event (signed then modified)
520 let event = Event { 511 let event = Event {