53f3bfbb
Show the sequence, and say what left the machine
a73x 2026-08-11 10:09
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -41,6 +41,28 @@ $ git-collab patch diff a1b2c3d4 --between 1 2 # interdiff: what changed betwe | |||
| 41 | $ git-collab patch diff a1b2c3d4 --revision 2 # revision 2 against the base | 41 | $ git-collab patch diff a1b2c3d4 --revision 2 # revision 2 against the base |
| 42 | ``` | 42 | ``` |
| 43 | 43 | ||
| 44 | `patch log --timeline` puts the whole story in one sequence — revisions, | ||
| 45 | comments, reviews, corrections and the merge — each entry anchored to the | ||
| 46 | revision it was made against, so the causal chain is legible without | ||
| 47 | interleaving two commands' output by eye: | ||
| 48 | |||
| 49 | ```console | ||
| 50 | $ git-collab patch log a1b2c3d4 --timeline | ||
| 51 | 2026-08-11T10:01:03Z r1 Alice edd93bf7 (initial) | ||
| 52 | 2026-08-11T10:01:08Z comment Bob @r1 needs a test for the empty case | ||
| 53 | 2026-08-11T10:01:12Z review Bob @r1 request-changes holding off until that lands | ||
| 54 | 2026-08-11T10:01:31Z r2 Alice 37a3675a added the empty-case test | ||
| 55 | 2026-08-11T10:01:36Z review Bob @r2 approve that covers it | ||
| 56 | 2026-08-11T10:01:44Z merged Alice 66f21e82 | ||
| 57 | ``` | ||
| 58 | |||
| 59 | Reading it top to bottom: the request for changes was made against r1, and r2 is | ||
| 60 | what answered it. Corrections appear as their own entries rather than being | ||
| 61 | folded silently into the text they changed — the DAG is append-only so that the | ||
| 62 | record stays an audit trail, and the timeline is where that is visible. Add | ||
| 63 | `--json` for the same sequence as an array. Without `--timeline`, `patch log` | ||
| 64 | prints the revision list exactly as before. | ||
| 65 | |||
| 44 | Each revision is pinned by a ref in the patch's own namespace, named after the | 66 | Each revision is pinned by a ref in the patch's own namespace, named after the |
| 45 | commit it points at (`refs/collab/patches/<id>/rev/<oid>`), so `git-collab sync` | 67 | commit it points at (`refs/collab/patches/<id>/rev/<oid>`), so `git-collab sync` |
| 46 | carries the patch and every revision it ever had. Contributing needs no `git push` of a branch and no | 68 | carries the patch and every revision it ever had. Contributing needs no `git push` of a branch and no |
| @@ -258,6 +280,35 @@ $ git config collab.autoSync false # disable auto-sync | |||
| 258 | $ git config collab.autoSyncRemote origin # auto-sync only 'origin' | 280 | $ git config collab.autoSyncRemote origin # auto-sync only 'origin' |
| 259 | ``` | 281 | ``` |
| 260 | 282 | ||
| 283 | A write command does two separable things, and says so separately. Its own | ||
| 284 | result goes to stdout; the auto-sync that follows narrates itself on stderr, | ||
| 285 | every line under an `auto-sync:` prefix, naming the remotes *before* it reaches | ||
| 286 | them: | ||
| 287 | |||
| 288 | ```console | ||
| 289 | $ git-collab issue open -t "Something broke" | ||
| 290 | Opened issue 27eff033 | ||
| 291 | auto-sync: publishing to 'origin'... | ||
| 292 | auto-sync: Pushing to 'origin'... | ||
| 293 | auto-sync: published to 'origin'. | ||
| 294 | ``` | ||
| 295 | |||
| 296 | The split is the point. Recording the event locally and publishing it are | ||
| 297 | different operations with different failure modes, so a failed push never fails | ||
| 298 | the command — the event is already recorded, and reporting otherwise would send | ||
| 299 | a script off to retry a write that already happened: | ||
| 300 | |||
| 301 | ```console | ||
| 302 | $ git-collab issue open -t "Something broke" # exit status 0 | ||
| 303 | Opened issue 27eff033 | ||
| 304 | auto-sync: publishing to 'origin'... | ||
| 305 | auto-sync: failed: git fetch exited with status exit status: 128 | ||
| 306 | auto-sync: your changes are recorded locally; run 'git-collab sync' to publish them. | ||
| 307 | ``` | ||
| 308 | |||
| 309 | Because the narration stays on stderr, stdout carries the command's result and | ||
| 310 | nothing else, whether or not a sync happened. | ||
| 311 | |||
| 261 | ## Trust | 312 | ## Trust |
| 262 | 313 | ||
| 263 | Sync verifies every signature it fetches. Until you add a trusted key, valid | 314 | Sync verifies every signature it fetches. Until you add a trusted key, valid |
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -708,9 +708,17 @@ pub enum PatchCmd { | |||
| 708 | body_file: Option<String>, | 708 | body_file: Option<String>, |
| 709 | }, | 709 | }, |
| 710 | /// Show revision log for a patch | 710 | /// Show revision log for a patch |
| 711 | /// | ||
| 712 | /// With --timeline, shows every event on the patch instead — revisions, | ||
| 713 | /// comments, reviews, corrections and the merge — in the order they | ||
| 714 | /// happened, each anchored to the revision it was made against, so the | ||
| 715 | /// sequence reads as what answered what. | ||
| 711 | Log { | 716 | Log { |
| 712 | /// Patch ID (prefix match) | 717 | /// Patch ID (prefix match) |
| 713 | id: String, | 718 | id: String, |
| 719 | /// Show all events, not just revisions, in the order they happened | ||
| 720 | #[arg(long)] | ||
| 721 | timeline: bool, | ||
| 714 | /// Output as JSON | 722 | /// Output as JSON |
| 715 | #[arg(long)] | 723 | #[arg(long)] |
| 716 | json: bool, | 724 | json: bool, |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ pub mod identity; | |||
| 12 | pub mod issue; | 12 | pub mod issue; |
| 13 | pub mod log; | 13 | pub mod log; |
| 14 | pub mod merge_scan; | 14 | pub mod merge_scan; |
| 15 | pub mod output; | ||
| 15 | pub mod patch; | 16 | pub mod patch; |
| 16 | pub mod release; | 17 | pub mod release; |
| 17 | pub mod signing; | 18 | pub mod signing; |
| @@ -19,6 +20,7 @@ pub mod state; | |||
| 19 | pub mod status; | 20 | pub mod status; |
| 20 | pub mod sync; | 21 | pub mod sync; |
| 21 | pub mod sync_lock; | 22 | pub mod sync_lock; |
| 23 | pub mod timeline; | ||
| 22 | pub mod trailer; | 24 | pub mod trailer; |
| 23 | pub mod trust; | 25 | pub mod trust; |
| 24 | pub mod tui; | 26 | pub mod tui; |
| @@ -70,6 +72,23 @@ pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option | |||
| 70 | )) | 72 | )) |
| 71 | } | 73 | } |
| 72 | 74 | ||
| 75 | /// Publish the event a write command just recorded, and say so out loud. | ||
| 76 | /// | ||
| 77 | /// Two facts, reported separately, because they fail separately: the command's | ||
| 78 | /// own result (on stdout, already printed by the time this runs) says the event | ||
| 79 | /// is recorded locally; everything below is about whether it also reached a | ||
| 80 | /// remote. All of it goes to stderr under an `auto-sync:` prefix — see | ||
| 81 | /// `output` for why the split matters and what used to happen instead. | ||
| 82 | /// | ||
| 83 | /// The remote is named *before* anything is sent to it. Something that reaches | ||
| 84 | /// a remote should never be indistinguishable from local work, and a reviewer | ||
| 85 | /// working under an explicit "do not push" instruction should not have to read | ||
| 86 | /// the source to discover that `patch comment` pushes. | ||
| 87 | /// | ||
| 88 | /// Nothing here can fail the command. The local write already succeeded, and | ||
| 89 | /// reporting it as a failure would send a caller checking the exit code off to | ||
| 90 | /// retry a write that already happened — so a failed push says the write is | ||
| 91 | /// safe locally and names the command that will publish it later. | ||
| 73 | fn maybe_auto_sync(repo: &Repository) { | 92 | fn maybe_auto_sync(repo: &Repository) { |
| 74 | let enabled = repo | 93 | let enabled = repo |
| 75 | .config() | 94 | .config() |
| @@ -90,48 +109,55 @@ fn maybe_auto_sync(repo: &Repository) { | |||
| 90 | .ok() | 109 | .ok() |
| 91 | .and_then(|c| c.get_string("collab.autoSyncRemote").ok()); | 110 | .and_then(|c| c.get_string("collab.autoSyncRemote").ok()); |
| 92 | 111 | ||
| 93 | if let Some(remote) = pinned_remote { | 112 | let (targets, result) = if let Some(remote) = pinned_remote { |
| 94 | eprintln!("Auto-syncing with '{}'...", remote); | 113 | let targets = format!("'{}'", remote); |
| 95 | match sync::sync(repo, &remote) { | 114 | let result = output::during_auto_sync(|| { |
| 96 | Ok(()) => {} | 115 | outln!("publishing to {}...", targets); |
| 97 | Err(error::Error::PartialSync { succeeded, total }) => { | 116 | sync::sync(repo, &remote) |
| 98 | eprintln!( | 117 | }); |
| 99 | "warning: auto-sync partially failed ({}/{} refs pushed)", | 118 | (targets, result) |
| 100 | succeeded, total | 119 | } else { |
| 101 | ); | 120 | let remotes = match sync::collab_remotes(repo) { |
| 102 | } | 121 | Ok(remotes) => remotes, |
| 103 | Err(e) => { | 122 | Err(e) => { |
| 104 | eprintln!("warning: auto-sync failed: {}", e); | 123 | errln!("warning: auto-sync failed to list remotes: {}", e); |
| 124 | return; | ||
| 105 | } | 125 | } |
| 106 | } | 126 | }; |
| 107 | return; | ||
| 108 | } | ||
| 109 | 127 | ||
| 110 | let remotes = match sync::collab_remotes(repo) { | 128 | // No remote to publish to is not a failed publish. Staying silent keeps |
| 111 | Ok(remotes) => remotes, | 129 | // a purely local repo's write commands free of network vocabulary. |
| 112 | Err(e) => { | 130 | if remotes.is_empty() { |
| 113 | eprintln!("warning: auto-sync failed to list remotes: {}", e); | ||
| 114 | return; | 131 | return; |
| 115 | } | 132 | } |
| 116 | }; | ||
| 117 | 133 | ||
| 118 | if remotes.is_empty() { | 134 | let targets = sync::format_remote_list(&remotes); |
| 119 | return; | 135 | let result = output::during_auto_sync(|| { |
| 120 | } | 136 | outln!("publishing to {}...", targets); |
| 137 | sync::sync_all(repo) | ||
| 138 | }); | ||
| 139 | (targets, result) | ||
| 140 | }; | ||
| 121 | 141 | ||
| 122 | eprintln!("Auto-syncing with {}...", sync::format_remote_list(&remotes)); | 142 | output::during_auto_sync(|| match result { |
| 123 | match sync::sync_all(repo) { | 143 | Ok(()) => outln!("published to {}.", targets), |
| 124 | Ok(()) => {} | ||
| 125 | Err(error::Error::MultiRemoteSync { failed, total }) => { | ||
| 126 | eprintln!( | ||
| 127 | "warning: auto-sync failed for {} of {} remote(s)", | ||
| 128 | failed, total | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | Err(e) => { | 144 | Err(e) => { |
| 132 | eprintln!("warning: auto-sync failed: {}", e); | 145 | // `sync` itself already itemizes a partial or multi-remote |
| 146 | // failure — which refs, which remotes — so restating the counts | ||
| 147 | // here would only say the same thing twice. Anything else reaches | ||
| 148 | // this point unreported. | ||
| 149 | if !matches!( | ||
| 150 | e, | ||
| 151 | error::Error::PartialSync { .. } | error::Error::MultiRemoteSync { .. } | ||
| 152 | ) { | ||
| 153 | outln!("failed: {}", e); | ||
| 154 | } | ||
| 155 | // The one thing the user actually needs from a failed push, and | ||
| 156 | // the fact the old interleaved output never stated: the write is | ||
| 157 | // not lost, and this is how to publish it. | ||
| 158 | outln!("your changes are recorded locally; run 'git-collab sync' to publish them."); | ||
| 133 | } | 159 | } |
| 134 | } | 160 | }); |
| 135 | } | 161 | } |
| 136 | 162 | ||
| 137 | pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | 163 | pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { |
| @@ -776,7 +802,20 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 776 | println!("Revision description updated."); | 802 | println!("Revision description updated."); |
| 777 | Ok(()) | 803 | Ok(()) |
| 778 | } | 804 | } |
| 779 | PatchCmd::Log { id, json } => { | 805 | PatchCmd::Log { |
| 806 | id, | ||
| 807 | timeline, | ||
| 808 | json, | ||
| 809 | } => { | ||
| 810 | if timeline { | ||
| 811 | let (_, entries) = timeline::build(repo, &id)?; | ||
| 812 | if json { | ||
| 813 | println!("{}", timeline::to_json(&entries)?); | ||
| 814 | } else { | ||
| 815 | timeline::to_writer(&entries, &mut std::io::stdout())?; | ||
| 816 | } | ||
| 817 | return Ok(()); | ||
| 818 | } | ||
| 780 | let p = patch::patch_log(repo, &id)?; | 819 | let p = patch::patch_log(repo, &id)?; |
| 781 | if json { | 820 | if json { |
| 782 | let output = patch::patch_log_json(&p)?; | 821 | let output = patch::patch_log_json(&p)?; |
src/output.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,105 @@ | |||
| 1 | //! Where a command's own result ends and a network operation's narration begins. | ||
| 2 | //! | ||
| 3 | //! A write command does two separable things: it records an event in the local | ||
| 4 | //! DAG, and — unless `collab.autoSync` says otherwise — it tries to publish that | ||
| 5 | //! event to every configured remote. They have different failure modes. The | ||
| 6 | //! local write either happened or it didn't; the push may fail for reasons that | ||
| 7 | //! say nothing about whether the event is safely recorded. | ||
| 8 | //! | ||
| 9 | //! Those two facts used to arrive as one stream, and worse, split across the | ||
| 10 | //! wrong pair of streams: the `Auto-syncing with...` banner went to stderr while | ||
| 11 | //! the fetch and push progress it introduced went to *stdout*, interleaved with | ||
| 12 | //! the command's own result. `issue open` printed the id you asked for and then | ||
| 13 | //! four lines of network traffic you did not, and a script capturing stdout got | ||
| 14 | //! all of it. | ||
| 15 | //! | ||
| 16 | //! So `sync` narrates through this module instead of printing directly. Run | ||
| 17 | //! normally, it prints to stdout as before. Run underneath a write command, the | ||
| 18 | //! whole narration moves to stderr and every line of it is prefixed, which | ||
| 19 | //! leaves stdout carrying the command's own result and nothing else, and makes | ||
| 20 | //! the network operation legible as a separate thing that happened afterwards. | ||
| 21 | //! | ||
| 22 | //! Keeping stdout clean is also what stops the `--json` gap (issue a6adfe39, | ||
| 23 | //! tracked separately) from getting worse: `--json` exists only on read | ||
| 24 | //! commands today, and read commands never auto-sync, so there is no overlap | ||
| 25 | //! yet. When a write command does grow `--json`, its stdout is already pure | ||
| 26 | //! result and the sync status is already separable — by stream, without any | ||
| 27 | //! caller having to learn a new envelope. | ||
| 28 | |||
| 29 | use std::io::Write; | ||
| 30 | use std::sync::atomic::{AtomicBool, Ordering}; | ||
| 31 | |||
| 32 | /// Set while an auto-sync triggered by a write command is running. | ||
| 33 | /// | ||
| 34 | /// A plain atomic rather than anything scoped because the CLI is a single | ||
| 35 | /// process running one command to completion, and the alternative — threading | ||
| 36 | /// a writer through every function in `sync` — buys nothing here. | ||
| 37 | static IN_AUTO_SYNC: AtomicBool = AtomicBool::new(false); | ||
| 38 | |||
| 39 | /// Marks every line auto-sync emits, so a reader can tell at a glance which | ||
| 40 | /// output came from the command they ran and which came from the network | ||
| 41 | /// operation it triggered. | ||
| 42 | pub const AUTO_SYNC_PREFIX: &str = "auto-sync: "; | ||
| 43 | |||
| 44 | /// Run `f` with sync's narration redirected to stderr and prefixed. | ||
| 45 | /// | ||
| 46 | /// Restores the previous channel afterwards, including on the error paths | ||
| 47 | /// inside `f`, so a failed sync cannot leave the process redirecting. | ||
| 48 | pub fn during_auto_sync<T>(f: impl FnOnce() -> T) -> T { | ||
| 49 | IN_AUTO_SYNC.store(true, Ordering::Relaxed); | ||
| 50 | let result = f(); | ||
| 51 | IN_AUTO_SYNC.store(false, Ordering::Relaxed); | ||
| 52 | result | ||
| 53 | } | ||
| 54 | |||
| 55 | pub fn in_auto_sync() -> bool { | ||
| 56 | IN_AUTO_SYNC.load(Ordering::Relaxed) | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Emit a line of progress: stdout normally, prefixed stderr under auto-sync. | ||
| 60 | /// | ||
| 61 | /// Multi-line text is prefixed per line rather than as a block, so no line of | ||
| 62 | /// an auto-sync ever appears unlabelled. Blank lines stay blank — a prefix on | ||
| 63 | /// its own reads as a truncated message rather than as the separator it is. | ||
| 64 | pub fn emit(text: &str) { | ||
| 65 | if in_auto_sync() { | ||
| 66 | emit_prefixed(&mut std::io::stderr().lock(), text); | ||
| 67 | } else { | ||
| 68 | let _ = writeln!(std::io::stdout().lock(), "{}", text); | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | /// Emit a warning or error. Always stderr; prefixed under auto-sync so a | ||
| 73 | /// warning from the push cannot be mistaken for one from the command. | ||
| 74 | pub fn emit_err(text: &str) { | ||
| 75 | let mut err = std::io::stderr().lock(); | ||
| 76 | if in_auto_sync() { | ||
| 77 | emit_prefixed(&mut err, text); | ||
| 78 | } else { | ||
| 79 | let _ = writeln!(err, "{}", text); | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | fn emit_prefixed(w: &mut impl Write, text: &str) { | ||
| 84 | for line in text.split('\n') { | ||
| 85 | if line.is_empty() { | ||
| 86 | let _ = writeln!(w); | ||
| 87 | } else { | ||
| 88 | let _ = writeln!(w, "{}{}", AUTO_SYNC_PREFIX, line); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | /// `println!` for progress that auto-sync must be able to redirect. | ||
| 94 | #[macro_export] | ||
| 95 | macro_rules! outln { | ||
| 96 | () => { $crate::output::emit("") }; | ||
| 97 | ($($arg:tt)*) => { $crate::output::emit(&format!($($arg)*)) }; | ||
| 98 | } | ||
| 99 | |||
| 100 | /// `eprintln!` for warnings that auto-sync must be able to label. | ||
| 101 | #[macro_export] | ||
| 102 | macro_rules! errln { | ||
| 103 | () => { $crate::output::emit_err("") }; | ||
| 104 | ($($arg:tt)*) => { $crate::output::emit_err(&format!($($arg)*)) }; | ||
| 105 | } | ||
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -1230,7 +1230,7 @@ pub fn patch_log(repo: &Repository, id_prefix: &str) -> Result<PatchState, Error | |||
| 1230 | /// them in full. | 1230 | /// them in full. |
| 1231 | /// | 1231 | /// |
| 1232 | /// Truncating by chars rather than bytes keeps this total for any input. | 1232 | /// Truncating by chars rather than bytes keeps this total for any input. |
| 1233 | fn summarize_body(body: &str) -> String { | 1233 | pub(crate) fn summarize_body(body: &str) -> String { |
| 1234 | const WIDTH: usize = 60; | 1234 | const WIDTH: usize = 60; |
| 1235 | let first_line = body.lines().next().unwrap_or_default().trim(); | 1235 | let first_line = body.lines().next().unwrap_or_default().trim(); |
| 1236 | let more = body.lines().nth(1).is_some(); | 1236 | let more = body.lines().nth(1).is_some(); |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ use crate::signing; | |||
| 12 | use crate::state; | 12 | use crate::state; |
| 13 | use crate::sync_lock::SyncLock; | 13 | use crate::sync_lock::SyncLock; |
| 14 | use crate::trust; | 14 | use crate::trust; |
| 15 | use crate::{errln, outln}; | ||
| 15 | 16 | ||
| 16 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 17 | // Ref name validation | 18 | // Ref name validation |
| @@ -114,7 +115,7 @@ impl SyncState { | |||
| 114 | Ok(contents) => match serde_json::from_str(&contents) { | 115 | Ok(contents) => match serde_json::from_str(&contents) { |
| 115 | Ok(state) => Some(state), | 116 | Ok(state) => Some(state), |
| 116 | Err(e) => { | 117 | Err(e) => { |
| 117 | eprintln!( | 118 | errln!( |
| 118 | "warning: corrupted sync state file ({}); ignoring and proceeding with full sync", | 119 | "warning: corrupted sync state file ({}); ignoring and proceeding with full sync", |
| 119 | e | 120 | e |
| 120 | ); | 121 | ); |
| @@ -302,11 +303,11 @@ fn print_refname_conflict_advice(remote: &str, ids: &[String]) { | |||
| 302 | .map(|id| format!("{}{}", PATCH_REF_PREFIX, id)) | 303 | .map(|id| format!("{}{}", PATCH_REF_PREFIX, id)) |
| 303 | .collect(); | 304 | .collect(); |
| 304 | 305 | ||
| 305 | eprintln!( | 306 | errln!( |
| 306 | "\nRefname conflict: '{}' still has the pre-migration patch ref layout.", | 307 | "\nRefname conflict: '{}' still has the pre-migration patch ref layout.", |
| 307 | remote | 308 | remote |
| 308 | ); | 309 | ); |
| 309 | eprintln!( | 310 | errln!( |
| 310 | "\nPatch refs moved from a single `{}<id>` to `<id>/events` plus\n\ | 311 | "\nPatch refs moved from a single `{}<id>` to `<id>/events` plus\n\ |
| 311 | `<id>/rev/<oid>`. Git cannot hold both a ref and a directory at the same\n\ | 312 | `<id>/rev/<oid>`. Git cannot hold both a ref and a directory at the same\n\ |
| 312 | path, so while the old ref is still on the remote every push of the new\n\ | 313 | path, so while the old ref is still on the remote every push of the new\n\ |
| @@ -328,14 +329,14 @@ fn print_refname_conflict_advice(remote: &str, ids: &[String]) { | |||
| 328 | ) | 329 | ) |
| 329 | }; | 330 | }; |
| 330 | 331 | ||
| 331 | eprintln!("\nBlocked by {}:", count); | 332 | errln!("\nBlocked by {}:", count); |
| 332 | for name in &stale { | 333 | for name in &stale { |
| 333 | eprintln!(" {}", name); | 334 | errln!(" {}", name); |
| 334 | } | 335 | } |
| 335 | 336 | ||
| 336 | eprintln!("\nTo fix, delete {} on the remote and sync again:\n", them); | 337 | errln!("\nTo fix, delete {} on the remote and sync again:\n", them); |
| 337 | eprintln!(" git push {} --delete {}", remote, stale.join(" ")); | 338 | errln!(" git push {} --delete {}", remote, stale.join(" ")); |
| 338 | eprintln!( | 339 | errln!( |
| 339 | "\nThat deletes only {} listed above, and only on '{}'. Nothing local is\n\ | 340 | "\nThat deletes only {} listed above, and only on '{}'. Nothing local is\n\ |
| 340 | touched: the next sync republishes the same events under `<id>/events`,\n\ | 341 | touched: the next sync republishes the same events under `<id>/events`,\n\ |
| 341 | which is where every current version reads them from.", | 342 | which is where every current version reads them from.", |
| @@ -349,18 +350,18 @@ fn print_sync_summary(result: &SyncResult) { | |||
| 349 | let total = result.results.len(); | 350 | let total = result.results.len(); |
| 350 | 351 | ||
| 351 | if succeeded == 0 { | 352 | if succeeded == 0 { |
| 352 | eprintln!("\nSync failed: {} of {} refs pushed.", succeeded, total); | 353 | errln!("\nSync failed: {} of {} refs pushed.", succeeded, total); |
| 353 | } else { | 354 | } else { |
| 354 | eprintln!( | 355 | errln!( |
| 355 | "\nSync partially failed: {} of {} refs pushed.", | 356 | "\nSync partially failed: {} of {} refs pushed.", |
| 356 | succeeded, total | 357 | succeeded, total |
| 357 | ); | 358 | ); |
| 358 | } | 359 | } |
| 359 | 360 | ||
| 360 | let failed = result.failed(); | 361 | let failed = result.failed(); |
| 361 | eprintln!("Failed refs:"); | 362 | errln!("Failed refs:"); |
| 362 | for f in &failed { | 363 | for f in &failed { |
| 363 | eprintln!( | 364 | errln!( |
| 364 | " {}: {}", | 365 | " {}: {}", |
| 365 | f.ref_name, | 366 | f.ref_name, |
| 366 | f.error.as_deref().unwrap_or("unknown error") | 367 | f.error.as_deref().unwrap_or("unknown error") |
| @@ -394,7 +395,7 @@ fn print_sync_summary(result: &SyncResult) { | |||
| 394 | // only the retryable refs keeps this line honest — retrying the conflicted | 395 | // only the retryable refs keeps this line honest — retrying the conflicted |
| 395 | // ones is exactly what the advice above says not to do. | 396 | // ones is exactly what the advice above says not to do. |
| 396 | if retryable > 0 { | 397 | if retryable > 0 { |
| 397 | eprintln!( | 398 | errln!( |
| 398 | "\nRun `git-collab sync --remote {}` again to retry {} failed ref(s).", | 399 | "\nRun `git-collab sync --remote {}` again to retry {} failed ref(s).", |
| 399 | result.remote, retryable | 400 | result.remote, retryable |
| 400 | ); | 401 | ); |
| @@ -458,19 +459,19 @@ pub fn init(repo: &Repository) -> Result<(), Error> { | |||
| 458 | fn init_refspecs(repo: &Repository) -> Result<(), Error> { | 459 | fn init_refspecs(repo: &Repository) -> Result<(), Error> { |
| 459 | let remotes = repo.remotes()?; | 460 | let remotes = repo.remotes()?; |
| 460 | if remotes.is_empty() { | 461 | if remotes.is_empty() { |
| 461 | println!("No remotes configured."); | 462 | outln!("No remotes configured."); |
| 462 | return Ok(()); | 463 | return Ok(()); |
| 463 | } | 464 | } |
| 464 | for remote_name in remotes.iter().flatten() { | 465 | for remote_name in remotes.iter().flatten() { |
| 465 | if has_collab_refspec(repo, remote_name)? { | 466 | if has_collab_refspec(repo, remote_name)? { |
| 466 | println!("Remote '{}' already configured", remote_name); | 467 | outln!("Remote '{}' already configured", remote_name); |
| 467 | continue; | 468 | continue; |
| 468 | } | 469 | } |
| 469 | let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name); | 470 | let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name); |
| 470 | repo.remote_add_fetch(remote_name, &fetch_spec)?; | 471 | repo.remote_add_fetch(remote_name, &fetch_spec)?; |
| 471 | println!("Configured remote '{}'", remote_name); | 472 | outln!("Configured remote '{}'", remote_name); |
| 472 | } | 473 | } |
| 473 | println!("Collab refspecs initialized."); | 474 | outln!("Collab refspecs initialized."); |
| 474 | Ok(()) | 475 | Ok(()) |
| 475 | } | 476 | } |
| 476 | 477 | ||
| @@ -480,7 +481,7 @@ fn init_refspecs(repo: &Repository) -> Result<(), Error> { | |||
| 480 | fn init_hook(repo: &Repository) { | 481 | fn init_hook(repo: &Repository) { |
| 481 | match crate::hooks::install(repo) { | 482 | match crate::hooks::install(repo) { |
| 482 | Ok(outcome) => crate::hooks::report_install(&outcome), | 483 | Ok(outcome) => crate::hooks::report_install(&outcome), |
| 483 | Err(e) => eprintln!("warning: could not install the commit-msg hook: {}", e), | 484 | Err(e) => errln!("warning: could not install the commit-msg hook: {}", e), |
| 484 | } | 485 | } |
| 485 | } | 486 | } |
| 486 | 487 | ||
| @@ -528,8 +529,10 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 528 | } | 529 | } |
| 529 | 530 | ||
| 530 | let multiple = remotes.len() > 1; | 531 | let multiple = remotes.len() > 1; |
| 531 | if multiple { | 532 | // Auto-sync has already named the same remotes in its own opening line, so |
| 532 | println!( | 533 | // repeating them here would be the second banner in two lines. |
| 534 | if multiple && !crate::output::in_auto_sync() { | ||
| 535 | outln!( | ||
| 533 | "Syncing {} remotes: {}", | 536 | "Syncing {} remotes: {}", |
| 534 | remotes.len(), | 537 | remotes.len(), |
| 535 | format_remote_list(&remotes) | 538 | format_remote_list(&remotes) |
| @@ -540,12 +543,12 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 540 | let mut failed = Vec::new(); | 543 | let mut failed = Vec::new(); |
| 541 | for remote_name in &remotes { | 544 | for remote_name in &remotes { |
| 542 | if multiple { | 545 | if multiple { |
| 543 | println!(); | 546 | outln!(); |
| 544 | } | 547 | } |
| 545 | match sync(repo, remote_name) { | 548 | match sync(repo, remote_name) { |
| 546 | Ok(()) => succeeded.push(remote_name.clone()), | 549 | Ok(()) => succeeded.push(remote_name.clone()), |
| 547 | Err(e) => { | 550 | Err(e) => { |
| 548 | eprintln!("error: sync with '{}' failed: {}", remote_name, e); | 551 | errln!("error: sync with '{}' failed: {}", remote_name, e); |
| 549 | failed.push(remote_name.clone()); | 552 | failed.push(remote_name.clone()); |
| 550 | } | 553 | } |
| 551 | } | 554 | } |
| @@ -553,7 +556,7 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 553 | 556 | ||
| 554 | if failed.is_empty() { | 557 | if failed.is_empty() { |
| 555 | if multiple { | 558 | if multiple { |
| 556 | println!( | 559 | outln!( |
| 557 | "\nAll {} remotes synced: {}", | 560 | "\nAll {} remotes synced: {}", |
| 558 | remotes.len(), | 561 | remotes.len(), |
| 559 | format_remote_list(&remotes) | 562 | format_remote_list(&remotes) |
| @@ -561,14 +564,14 @@ pub fn sync_all(repo: &Repository) -> Result<(), Error> { | |||
| 561 | } | 564 | } |
| 562 | Ok(()) | 565 | Ok(()) |
| 563 | } else { | 566 | } else { |
| 564 | eprintln!( | 567 | errln!( |
| 565 | "\nSync incomplete: {} of {} remote(s) failed.", | 568 | "\nSync incomplete: {} of {} remote(s) failed.", |
| 566 | failed.len(), | 569 | failed.len(), |
| 567 | remotes.len() | 570 | remotes.len() |
| 568 | ); | 571 | ); |
| 569 | eprintln!(" Failed: {}", format_remote_list(&failed)); | 572 | errln!(" Failed: {}", format_remote_list(&failed)); |
| 570 | if !succeeded.is_empty() { | 573 | if !succeeded.is_empty() { |
| 571 | eprintln!(" Succeeded: {}", format_remote_list(&succeeded)); | 574 | errln!(" Succeeded: {}", format_remote_list(&succeeded)); |
| 572 | } | 575 | } |
| 573 | Err(Error::MultiRemoteSync { | 576 | Err(Error::MultiRemoteSync { |
| 574 | failed: failed.len(), | 577 | failed: failed.len(), |
| @@ -598,17 +601,17 @@ fn run_local_scans( | |||
| 598 | ) { | 601 | ) { |
| 599 | // Scan local branches for Issue: trailers and emit link events. | 602 | // Scan local branches for Issue: trailers and emit link events. |
| 600 | match crate::commit_link::scan_and_link(repo, author, sk) { | 603 | match crate::commit_link::scan_and_link(repo, author, sk) { |
| 601 | Ok(n) if n > 0 => println!("Linked {} commit(s) to issues.", n), | 604 | Ok(n) if n > 0 => outln!("Linked {} commit(s) to issues.", n), |
| 602 | Ok(_) => {} | 605 | Ok(_) => {} |
| 603 | Err(e) => eprintln!("warning: commit link scan failed: {}", e), | 606 | Err(e) => errln!("warning: commit link scan failed: {}", e), |
| 604 | } | 607 | } |
| 605 | 608 | ||
| 606 | // Scan each open patch's own base branch for Patch: trailers and record | 609 | // Scan each open patch's own base branch for Patch: trailers and record |
| 607 | // the merges they name. | 610 | // the merges they name. |
| 608 | match crate::merge_scan::scan_and_record_merges(repo, author, sk) { | 611 | match crate::merge_scan::scan_and_record_merges(repo, author, sk) { |
| 609 | Ok(n) if n > 0 => println!("Recorded {} merged patch(es).", n), | 612 | Ok(n) if n > 0 => outln!("Recorded {} merged patch(es).", n), |
| 610 | Ok(_) => {} | 613 | Ok(_) => {} |
| 611 | Err(e) => eprintln!("warning: merge scan failed: {}", e), | 614 | Err(e) => errln!("warning: merge scan failed: {}", e), |
| 612 | } | 615 | } |
| 613 | } | 616 | } |
| 614 | 617 | ||
| @@ -625,7 +628,7 @@ fn sync_local_only(repo: &Repository) -> Result<(), Error> { | |||
| 625 | // the case the lock exists for, and having no remote does not change it. | 628 | // the case the lock exists for, and having no remote does not change it. |
| 626 | let _lock = SyncLock::acquire(repo)?; | 629 | let _lock = SyncLock::acquire(repo)?; |
| 627 | 630 | ||
| 628 | println!("No remotes with collab refspecs configured — scanning local history only."); | 631 | outln!("No remotes with collab refspecs configured — scanning local history only."); |
| 629 | 632 | ||
| 630 | let author = get_author(repo)?; | 633 | let author = get_author(repo)?; |
| 631 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 634 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| @@ -637,7 +640,7 @@ fn sync_local_only(repo: &Repository) -> Result<(), Error> { | |||
| 637 | 640 | ||
| 638 | run_local_scans(repo, &author, &sk); | 641 | run_local_scans(repo, &author, &sk); |
| 639 | 642 | ||
| 640 | println!("Local scan complete. Add a remote and run `git-collab init` to share these events."); | 643 | outln!("Local scan complete. Add a remote and run `git-collab init` to share these events."); |
| 641 | 644 | ||
| 642 | // The same closing hint the remote path prints, and on the same terms: a | 645 | // The same closing hint the remote path prints, and on the same terms: a |
| 643 | // suggestion drawn from reachability, which cannot see a squash — so its | 646 | // suggestion drawn from reachability, which cannot see a squash — so its |
| @@ -664,7 +667,7 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 664 | let author = get_author(repo)?; | 667 | let author = get_author(repo)?; |
| 665 | 668 | ||
| 666 | // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) | 669 | // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) |
| 667 | println!("Fetching from '{}'...", remote_name); | 670 | outln!("Fetching from '{}'...", remote_name); |
| 668 | let fetch_status = Command::new("git") | 671 | let fetch_status = Command::new("git") |
| 669 | .args([ | 672 | .args([ |
| 670 | "fetch", | 673 | "fetch", |
| @@ -713,11 +716,11 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 713 | run_local_scans(&repo, &author, &sk); | 716 | run_local_scans(&repo, &author, &sk); |
| 714 | 717 | ||
| 715 | // Step 3: Push collab refs individually | 718 | // Step 3: Push collab refs individually |
| 716 | println!("Pushing to '{}'...", remote_name); | 719 | outln!("Pushing to '{}'...", remote_name); |
| 717 | let refs_to_push = collect_push_refs(&repo)?; | 720 | let refs_to_push = collect_push_refs(&repo)?; |
| 718 | 721 | ||
| 719 | if refs_to_push.is_empty() { | 722 | if refs_to_push.is_empty() { |
| 720 | println!("Nothing to push."); | 723 | outln!("Nothing to push."); |
| 721 | } else { | 724 | } else { |
| 722 | let sync_result = push_refs(&workdir, remote_name, &refs_to_push); | 725 | let sync_result = push_refs(&workdir, remote_name, &refs_to_push); |
| 723 | 726 | ||
| @@ -757,7 +760,7 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 757 | // Step 4: Clean up sync refs | 760 | // Step 4: Clean up sync refs |
| 758 | cleanup_sync_refs(&repo)?; | 761 | cleanup_sync_refs(&repo)?; |
| 759 | 762 | ||
| 760 | println!("Sync complete for '{}'.", remote_name); | 763 | outln!("Sync complete for '{}'.", remote_name); |
| 761 | 764 | ||
| 762 | // A closing hint, not a decision: patches whose head is reachable from | 765 | // A closing hint, not a decision: patches whose head is reachable from |
| 763 | // their base tip but which carry no `PatchMerge`. Reachability cannot see a | 766 | // their base tip but which carry no `PatchMerge`. Reachability cannot see a |
| @@ -773,7 +776,7 @@ fn sync_resume( | |||
| 773 | workdir: &Path, | 776 | workdir: &Path, |
| 774 | state: &SyncState, | 777 | state: &SyncState, |
| 775 | ) -> Result<(), Error> { | 778 | ) -> Result<(), Error> { |
| 776 | println!( | 779 | outln!( |
| 777 | "Resuming sync to '{}' ({} refs pending from previous failure)...", | 780 | "Resuming sync to '{}' ({} refs pending from previous failure)...", |
| 778 | remote_name, | 781 | remote_name, |
| 779 | state.pending_refs.len() | 782 | state.pending_refs.len() |
| @@ -781,7 +784,7 @@ fn sync_resume( | |||
| 781 | 784 | ||
| 782 | // Print pending refs with their last error | 785 | // Print pending refs with their last error |
| 783 | for (ref_name, last_error) in &state.pending_refs { | 786 | for (ref_name, last_error) in &state.pending_refs { |
| 784 | println!(" Pending: {} (last error: {})", ref_name, last_error); | 787 | outln!(" Pending: {} (last error: {})", ref_name, last_error); |
| 785 | } | 788 | } |
| 786 | 789 | ||
| 787 | // Clean up stale sync refs (T018b) | 790 | // Clean up stale sync refs (T018b) |
| @@ -795,14 +798,14 @@ fn sync_resume( | |||
| 795 | // All pending refs pushed successfully | 798 | // All pending refs pushed successfully |
| 796 | SyncState::clear(repo)?; | 799 | SyncState::clear(repo)?; |
| 797 | if sync_result.results.is_empty() { | 800 | if sync_result.results.is_empty() { |
| 798 | println!("All pending refs are already up to date. Clearing stale sync state."); | 801 | outln!("All pending refs are already up to date. Clearing stale sync state."); |
| 799 | } else { | 802 | } else { |
| 800 | println!( | 803 | outln!( |
| 801 | "\nSync complete for '{}'. All previously-failed refs pushed.", | 804 | "\nSync complete for '{}'. All previously-failed refs pushed.", |
| 802 | remote_name | 805 | remote_name |
| 803 | ); | 806 | ); |
| 804 | } | 807 | } |
| 805 | println!("Sync complete for '{}'.", remote_name); | 808 | outln!("Sync complete for '{}'.", remote_name); |
| 806 | Ok(()) | 809 | Ok(()) |
| 807 | } else { | 810 | } else { |
| 808 | // Some refs still failing - update state | 811 | // Some refs still failing - update state |
| @@ -827,7 +830,7 @@ fn sync_resume( | |||
| 827 | new_state.save(repo)?; | 830 | new_state.save(repo)?; |
| 828 | 831 | ||
| 829 | print_sync_summary(&sync_result); | 832 | print_sync_summary(&sync_result); |
| 830 | eprintln!("To force a full sync, delete .git/collab/sync-state.json"); | 833 | errln!("To force a full sync, delete .git/collab/sync-state.json"); |
| 831 | 834 | ||
| 832 | let succeeded = sync_result.succeeded().len(); | 835 | let succeeded = sync_result.succeeded().len(); |
| 833 | let total = sync_result.results.len(); | 836 | let total = sync_result.results.len(); |
| @@ -840,9 +843,9 @@ fn push_refs(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult { | |||
| 840 | let results = push_refs_batched(workdir, remote_name, refs); | 843 | let results = push_refs_batched(workdir, remote_name, refs); |
| 841 | for result in &results { | 844 | for result in &results { |
| 842 | match &result.status { | 845 | match &result.status { |
| 843 | PushStatus::Pushed => println!(" Pushed {}", result.ref_name), | 846 | PushStatus::Pushed => outln!(" Pushed {}", result.ref_name), |
| 844 | PushStatus::Failed => { | 847 | PushStatus::Failed => { |
| 845 | eprintln!( | 848 | errln!( |
| 846 | " FAILED {}: {}", | 849 | " FAILED {}: {}", |
| 847 | result.ref_name, | 850 | result.ref_name, |
| 848 | result.error.as_deref().unwrap_or("unknown error") | 851 | result.error.as_deref().unwrap_or("unknown error") |
| @@ -930,7 +933,7 @@ fn reconcile_refs( | |||
| 930 | for (remote_ref, id, classified) in &sync_refs { | 933 | for (remote_ref, id, classified) in &sync_refs { |
| 931 | // Validate the ref ID format before processing | 934 | // Validate the ref ID format before processing |
| 932 | if let Err(e) = validate_collab_ref_id(id) { | 935 | if let Err(e) = validate_collab_ref_id(id) { |
| 933 | eprintln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e); | 936 | errln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e); |
| 934 | continue; | 937 | continue; |
| 935 | } | 938 | } |
| 936 | 939 | ||
| @@ -948,7 +951,7 @@ fn reconcile_refs( | |||
| 948 | 951 | ||
| 949 | if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured | 952 | if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured |
| 950 | { | 953 | { |
| 951 | eprintln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start."); | 954 | errln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start."); |
| 952 | warned_unconfigured = true; | 955 | warned_unconfigured = true; |
| 953 | } | 956 | } |
| 954 | 957 | ||
| @@ -958,7 +961,7 @@ fn reconcile_refs( | |||
| 958 | .collect(); | 961 | .collect(); |
| 959 | if !failures.is_empty() { | 962 | if !failures.is_empty() { |
| 960 | for f in &failures { | 963 | for f in &failures { |
| 961 | eprintln!( | 964 | errln!( |
| 962 | " Rejecting {} {:.8}: commit {} — {}", | 965 | " Rejecting {} {:.8}: commit {} — {}", |
| 963 | kind, | 966 | kind, |
| 964 | id, | 967 | id, |
| @@ -970,7 +973,7 @@ fn reconcile_refs( | |||
| 970 | } | 973 | } |
| 971 | } | 974 | } |
| 972 | Err(e) => { | 975 | Err(e) => { |
| 973 | eprintln!(" Failed to verify {} {:.8}: {}", kind, id, e); | 976 | errln!(" Failed to verify {} {:.8}: {}", kind, id, e); |
| 974 | continue; | 977 | continue; |
| 975 | } | 978 | } |
| 976 | } | 979 | } |
| @@ -987,14 +990,14 @@ fn reconcile_refs( | |||
| 987 | dag::ReconcileOutcome::FastForward => "fast-forwarded", | 990 | dag::ReconcileOutcome::FastForward => "fast-forwarded", |
| 988 | dag::ReconcileOutcome::Merge => "merged", | 991 | dag::ReconcileOutcome::Merge => "merged", |
| 989 | }; | 992 | }; |
| 990 | println!(" Reconciled {} {:.8} ({})", kind, id, action); | 993 | outln!(" Reconciled {} {:.8} ({})", kind, id, action); |
| 991 | } | 994 | } |
| 992 | Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), | 995 | Err(e) => errln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), |
| 993 | } | 996 | } |
| 994 | } else { | 997 | } else { |
| 995 | let oid = repo.refname_to_id(remote_ref)?; | 998 | let oid = repo.refname_to_id(remote_ref)?; |
| 996 | repo.reference(local_ref, oid, false, "sync: new from remote")?; | 999 | repo.reference(local_ref, oid, false, "sync: new from remote")?; |
| 997 | println!(" New {} {:.8} from remote", kind, id); | 1000 | outln!(" New {} {:.8} from remote", kind, id); |
| 998 | } | 1001 | } |
| 999 | 1002 | ||
| 1000 | // Pin every revision the DAG we just reconciled lists, while the | 1003 | // Pin every revision the DAG we just reconciled lists, while the |
| @@ -1003,7 +1006,7 @@ fn reconcile_refs( | |||
| 1003 | // without a ref of its own becomes gc-eligible at that point. | 1006 | // without a ref of its own becomes gc-eligible at that point. |
| 1004 | if kind == "patches" { | 1007 | if kind == "patches" { |
| 1005 | if let Err(e) = state::pin_dag_revisions(repo, local_ref, id) { | 1008 | if let Err(e) = state::pin_dag_revisions(repo, local_ref, id) { |
| 1006 | eprintln!(" Failed to pin revisions for patch {:.8}: {}", id, e); | 1009 | errln!(" Failed to pin revisions for patch {:.8}: {}", id, e); |
| 1007 | } | 1010 | } |
| 1008 | } | 1011 | } |
| 1009 | } | 1012 | } |
src/timeline.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,468 @@ | |||
| 1 | //! The unified event timeline for a patch: one sequence, every kind of event. | ||
| 2 | //! | ||
| 3 | //! `patch log` lists revisions. `patch show` lists comments and reviews. Between | ||
| 4 | //! them they hold everything that happened to a patch and answer, separately, | ||
| 5 | //! two questions nobody asks. The question a reviewer actually has is *what | ||
| 6 | //! answered what* — a comment on revision 1 followed by revision 2 is a causal | ||
| 7 | //! chain, and it is the chain this project's revision anchoring exists to | ||
| 8 | //! record. Reconstructing it meant reading two commands' output and interleaving | ||
| 9 | //! them by timestamp by eye. | ||
| 10 | //! | ||
| 11 | //! # Where the ordering comes from | ||
| 12 | //! | ||
| 13 | //! Events are ordered by the DAG walk, then stable-sorted by Lamport clock. | ||
| 14 | //! Both halves of that matter: | ||
| 15 | //! | ||
| 16 | //! - The walk is `Sort::TOPOLOGICAL | Sort::REVERSE`, the same order | ||
| 17 | //! `PatchState::from_ref_uncached` folds in, which is what guarantees this | ||
| 18 | //! view can never number a revision differently from `patch log`. | ||
| 19 | //! - Clocks are monotonic along parent links, so for the linear DAG every patch | ||
| 20 | //! in the wild actually has, sorting by clock changes nothing. Where it earns | ||
| 21 | //! its keep is a DAG joined from a fork: topological order emits one branch | ||
| 22 | //! then the other, and the clock interleaves them back into the order things | ||
| 23 | //! happened. | ||
| 24 | //! | ||
| 25 | //! Sorting by `(clock, oid)` — the total order the status fold resolves | ||
| 26 | //! conflicts with — was the obvious alternative and is wrong here. Events | ||
| 27 | //! written before clocks existed all carry clock 0, so the oid tiebreak would | ||
| 28 | //! sort a legacy patch's whole history into hash order. A *stable* sort keyed on | ||
| 29 | //! the clock alone leaves those events in the only order that is meaningful for | ||
| 30 | //! them, the one the DAG's parent links record. | ||
| 31 | //! | ||
| 32 | //! # Where the content comes from | ||
| 33 | //! | ||
| 34 | //! From `PatchState`, not from the raw events: revision numbering, the | ||
| 35 | //! per-`(author, revision)` vote rule, and every correction have already been | ||
| 36 | //! resolved there. Re-deriving any of it here would be a second implementation | ||
| 37 | //! of the same rules, free to drift from the first — and the first is the one | ||
| 38 | //! `patch show` prints. The walk supplies ordering and the correction events; | ||
| 39 | //! everything a reader sees is joined onto it by event OID. | ||
| 40 | //! | ||
| 41 | //! # Reads do not write | ||
| 42 | //! | ||
| 43 | //! Building a timeline appends no event and moves no ref. It resolves the | ||
| 44 | //! patch's ref, walks it, and folds it. (`resolve_patch_ref` runs the one-time | ||
| 45 | //! `migrate_patch_layout`, exactly as `patch show` and `patch log` already do; | ||
| 46 | //! that is a pre-existing ref-layout migration, not a collab event, and this | ||
| 47 | //! view adds nothing to it. Note `patch show` additionally moves a | ||
| 48 | //! `refs/collab/local/seen/` read-marker — the timeline deliberately does *not* | ||
| 49 | //! adopt that pattern and marks nothing read.) | ||
| 50 | |||
| 51 | use git2::Repository; | ||
| 52 | use serde::Serialize; | ||
| 53 | |||
| 54 | use crate::error::Error; | ||
| 55 | use crate::event::{Action, Author}; | ||
| 56 | use crate::state::{self, PatchState}; | ||
| 57 | |||
| 58 | /// One thing that happened to a patch. | ||
| 59 | /// | ||
| 60 | /// `revision` is the anchor that makes the sequence causal rather than merely | ||
| 61 | /// chronological: the revision a comment or review was made against, or the | ||
| 62 | /// revision a revision entry *is*. `None` only where the event is not about a | ||
| 63 | /// revision at all (a label, a correction, the merge). | ||
| 64 | #[derive(Debug, Clone, Serialize)] | ||
| 65 | pub struct Entry { | ||
| 66 | #[serde(flatten)] | ||
| 67 | pub kind: Kind, | ||
| 68 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 69 | pub revision: Option<u32>, | ||
| 70 | pub author: Author, | ||
| 71 | pub timestamp: String, | ||
| 72 | /// The event's own OID, so a caller can name it to `edit-comment`. | ||
| 73 | pub id: String, | ||
| 74 | } | ||
| 75 | |||
| 76 | #[derive(Debug, Clone, Serialize)] | ||
| 77 | #[serde(tag = "type", rename_all = "snake_case")] | ||
| 78 | pub enum Kind { | ||
| 79 | Revision { | ||
| 80 | commit: String, | ||
| 81 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 82 | body: Option<String>, | ||
| 83 | edited: bool, | ||
| 84 | }, | ||
| 85 | Comment { | ||
| 86 | body: String, | ||
| 87 | edited: bool, | ||
| 88 | deleted: bool, | ||
| 89 | }, | ||
| 90 | InlineComment { | ||
| 91 | file: String, | ||
| 92 | line: u32, | ||
| 93 | body: String, | ||
| 94 | edited: bool, | ||
| 95 | deleted: bool, | ||
| 96 | }, | ||
| 97 | Review { | ||
| 98 | verdict: String, | ||
| 99 | body: String, | ||
| 100 | edited: bool, | ||
| 101 | }, | ||
| 102 | Label { | ||
| 103 | label: String, | ||
| 104 | }, | ||
| 105 | Unlabel { | ||
| 106 | label: String, | ||
| 107 | }, | ||
| 108 | /// A `BodyEdit` that the fold honoured, naming what it corrected. | ||
| 109 | Edited { | ||
| 110 | target: String, | ||
| 111 | }, | ||
| 112 | /// A `CommentDelete` that the fold honoured. | ||
| 113 | Deleted { | ||
| 114 | target: String, | ||
| 115 | }, | ||
| 116 | Closed { | ||
| 117 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 118 | reason: Option<String>, | ||
| 119 | }, | ||
| 120 | Merged { | ||
| 121 | #[serde(skip_serializing_if = "Option::is_none")] | ||
| 122 | commit: Option<String>, | ||
| 123 | }, | ||
| 124 | } | ||
| 125 | |||
| 126 | impl Kind { | ||
| 127 | /// The fixed-width label opening a rendered line. | ||
| 128 | fn label(&self, revision: Option<u32>) -> String { | ||
| 129 | match self { | ||
| 130 | Kind::Revision { .. } => format!("r{}", revision.unwrap_or(0)), | ||
| 131 | Kind::Comment { .. } => "comment".to_string(), | ||
| 132 | Kind::InlineComment { .. } => "inline".to_string(), | ||
| 133 | Kind::Review { .. } => "review".to_string(), | ||
| 134 | Kind::Label { .. } => "label".to_string(), | ||
| 135 | Kind::Unlabel { .. } => "unlabel".to_string(), | ||
| 136 | Kind::Edited { .. } => "edited".to_string(), | ||
| 137 | Kind::Deleted { .. } => "deleted".to_string(), | ||
| 138 | Kind::Closed { .. } => "closed".to_string(), | ||
| 139 | Kind::Merged { .. } => "merged".to_string(), | ||
| 140 | } | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | /// Build the timeline for the patch `id_prefix` resolves to. | ||
| 145 | pub fn build(repo: &Repository, id_prefix: &str) -> Result<(PatchState, Vec<Entry>), Error> { | ||
| 146 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | ||
| 147 | let patch = PatchState::from_ref(repo, &ref_name, &id)?; | ||
| 148 | let events = crate::dag::walk_events(repo, &ref_name)?; | ||
| 149 | |||
| 150 | // Who wrote each body-carrying event, so a correction naming one can be | ||
| 151 | // held to the same rule the fold applies: only the author of a body may | ||
| 152 | // correct it. Everything else is dropped in silence, exactly as | ||
| 153 | // `BodyOverrides::authorized` drops it — a timeline that listed a refused | ||
| 154 | // edit would be reporting a rewrite of someone else's words that never | ||
| 155 | // took effect. | ||
| 156 | let mut owners: std::collections::HashMap<String, String> = std::collections::HashMap::new(); | ||
| 157 | for (oid, event) in &events { | ||
| 158 | if carries_a_body(&event.action) { | ||
| 159 | owners.insert(oid.to_string(), event.author.email.clone()); | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | // Derived content, indexed by the event that produced it. Joining on the | ||
| 164 | // OID is what keeps this view's text identical to `patch show`'s. | ||
| 165 | let comments: std::collections::HashMap<String, &state::Comment> = patch | ||
| 166 | .comments | ||
| 167 | .iter() | ||
| 168 | .map(|c| (c.commit_id.to_string(), c)) | ||
| 169 | .collect(); | ||
| 170 | let inline: std::collections::HashMap<String, &state::InlineComment> = patch | ||
| 171 | .inline_comments | ||
| 172 | .iter() | ||
| 173 | .map(|c| (c.commit_id.to_string(), c)) | ||
| 174 | .collect(); | ||
| 175 | let reviews: std::collections::HashMap<String, &state::Review> = patch | ||
| 176 | .reviews | ||
| 177 | .iter() | ||
| 178 | .map(|r| (r.commit_id.to_string(), r)) | ||
| 179 | .collect(); | ||
| 180 | let revisions: std::collections::HashMap<String, &state::Revision> = patch | ||
| 181 | .revisions | ||
| 182 | .iter() | ||
| 183 | .filter(|r| !r.event_id.is_empty()) | ||
| 184 | .map(|r| (r.event_id.clone(), r)) | ||
| 185 | .collect(); | ||
| 186 | |||
| 187 | let mut entries: Vec<(u64, Entry)> = Vec::new(); | ||
| 188 | |||
| 189 | for (oid, event) in &events { | ||
| 190 | let oid_hex = oid.to_string(); | ||
| 191 | let author = event.author.clone(); | ||
| 192 | let timestamp = event.timestamp.clone(); | ||
| 193 | |||
| 194 | let (kind, revision) = match &event.action { | ||
| 195 | Action::PatchCreate { .. } | Action::PatchRevision { .. } => { | ||
| 196 | // A revision the fold discarded as a duplicate commit has no | ||
| 197 | // entry here, which is right: it added nothing to the patch. | ||
| 198 | let Some(rev) = revisions.get(&oid_hex) else { | ||
| 199 | continue; | ||
| 200 | }; | ||
| 201 | ( | ||
| 202 | Kind::Revision { | ||
| 203 | commit: rev.commit.clone(), | ||
| 204 | body: rev.body.clone(), | ||
| 205 | edited: rev.edited, | ||
| 206 | }, | ||
| 207 | Some(rev.number), | ||
| 208 | ) | ||
| 209 | } | ||
| 210 | Action::PatchComment { .. } => { | ||
| 211 | let Some(c) = comments.get(&oid_hex) else { | ||
| 212 | continue; | ||
| 213 | }; | ||
| 214 | ( | ||
| 215 | Kind::Comment { | ||
| 216 | body: c.body.clone(), | ||
| 217 | edited: c.edited, | ||
| 218 | deleted: c.deleted, | ||
| 219 | }, | ||
| 220 | // Anchored below, once the sequence is in order: a thread | ||
| 221 | // comment carries no revision of its own. | ||
| 222 | None, | ||
| 223 | ) | ||
| 224 | } | ||
| 225 | Action::PatchInlineComment { .. } => { | ||
| 226 | let Some(c) = inline.get(&oid_hex) else { | ||
| 227 | continue; | ||
| 228 | }; | ||
| 229 | ( | ||
| 230 | Kind::InlineComment { | ||
| 231 | file: c.file.clone(), | ||
| 232 | line: c.line, | ||
| 233 | body: c.body.clone(), | ||
| 234 | edited: c.edited, | ||
| 235 | deleted: c.deleted, | ||
| 236 | }, | ||
| 237 | c.revision, | ||
| 238 | ) | ||
| 239 | } | ||
| 240 | Action::PatchReview { .. } => { | ||
| 241 | // A superseded vote is gone from `patch show`, so it is gone | ||
| 242 | // from here too; the two must not disagree about what stands. | ||
| 243 | let Some(r) = reviews.get(&oid_hex) else { | ||
| 244 | continue; | ||
| 245 | }; | ||
| 246 | ( | ||
| 247 | Kind::Review { | ||
| 248 | verdict: r.verdict.as_str().to_string(), | ||
| 249 | body: r.body.clone(), | ||
| 250 | edited: r.edited, | ||
| 251 | }, | ||
| 252 | r.revision, | ||
| 253 | ) | ||
| 254 | } | ||
| 255 | Action::PatchLabel { label } => ( | ||
| 256 | Kind::Label { | ||
| 257 | label: label.clone(), | ||
| 258 | }, | ||
| 259 | None, | ||
| 260 | ), | ||
| 261 | Action::PatchUnlabel { label } => ( | ||
| 262 | Kind::Unlabel { | ||
| 263 | label: label.clone(), | ||
| 264 | }, | ||
| 265 | None, | ||
| 266 | ), | ||
| 267 | Action::BodyEdit { target, .. } => { | ||
| 268 | if !authorized(&owners, target, &author) { | ||
| 269 | continue; | ||
| 270 | } | ||
| 271 | ( | ||
| 272 | Kind::Edited { | ||
| 273 | target: target.clone(), | ||
| 274 | }, | ||
| 275 | None, | ||
| 276 | ) | ||
| 277 | } | ||
| 278 | Action::CommentDelete { target } => { | ||
| 279 | if !authorized(&owners, target, &author) { | ||
| 280 | continue; | ||
| 281 | } | ||
| 282 | ( | ||
| 283 | Kind::Deleted { | ||
| 284 | target: target.clone(), | ||
| 285 | }, | ||
| 286 | None, | ||
| 287 | ) | ||
| 288 | } | ||
| 289 | Action::PatchClose { reason } => ( | ||
| 290 | Kind::Closed { | ||
| 291 | reason: reason.clone(), | ||
| 292 | }, | ||
| 293 | None, | ||
| 294 | ), | ||
| 295 | Action::PatchMerge { commit } => ( | ||
| 296 | Kind::Merged { | ||
| 297 | commit: (!commit.is_empty()).then(|| commit.clone()), | ||
| 298 | }, | ||
| 299 | None, | ||
| 300 | ), | ||
| 301 | _ => continue, | ||
| 302 | }; | ||
| 303 | |||
| 304 | entries.push(( | ||
| 305 | event.clock, | ||
| 306 | Entry { | ||
| 307 | kind, | ||
| 308 | revision, | ||
| 309 | author, | ||
| 310 | timestamp, | ||
| 311 | id: oid_hex, | ||
| 312 | }, | ||
| 313 | )); | ||
| 314 | } | ||
| 315 | |||
| 316 | // Stable, so events sharing a clock — every event on a pre-clock patch — | ||
| 317 | // keep the DAG's own order rather than being shuffled by the sort. | ||
| 318 | entries.sort_by_key(|(clock, _)| *clock); | ||
| 319 | let mut entries: Vec<Entry> = entries.into_iter().map(|(_, e)| e).collect(); | ||
| 320 | |||
| 321 | // Anchor thread comments to the revision that was current where they sit in | ||
| 322 | // the sequence — which is the revision their author was in fact looking at. | ||
| 323 | // | ||
| 324 | // By position, not by timestamp: timestamps come from whichever machine | ||
| 325 | // wrote the event, so comparing two authors' clocks decides nothing, and a | ||
| 326 | // skewed clock would file a comment under the wrong revision. Position in | ||
| 327 | // this sequence is derived from the DAG, which is the same basis the fold | ||
| 328 | // uses to attribute a revision-less review. | ||
| 329 | let mut current = 1; | ||
| 330 | for entry in &mut entries { | ||
| 331 | match (&entry.kind, entry.revision) { | ||
| 332 | (Kind::Revision { .. }, Some(n)) => current = n, | ||
| 333 | (Kind::Comment { .. }, None) => entry.revision = Some(current), | ||
| 334 | _ => {} | ||
| 335 | } | ||
| 336 | } | ||
| 337 | |||
| 338 | Ok((patch, entries)) | ||
| 339 | } | ||
| 340 | |||
| 341 | /// Whether `action` carries a body a correction could name. | ||
| 342 | fn carries_a_body(action: &Action) -> bool { | ||
| 343 | matches!( | ||
| 344 | action, | ||
| 345 | Action::PatchCreate { .. } | ||
| 346 | | Action::PatchRevision { .. } | ||
| 347 | | Action::PatchComment { .. } | ||
| 348 | | Action::PatchInlineComment { .. } | ||
| 349 | | Action::PatchReview { .. } | ||
| 350 | ) | ||
| 351 | } | ||
| 352 | |||
| 353 | fn authorized( | ||
| 354 | owners: &std::collections::HashMap<String, String>, | ||
| 355 | target: &str, | ||
| 356 | author: &Author, | ||
| 357 | ) -> bool { | ||
| 358 | owners.get(target).is_some_and(|owner| *owner == author.email) | ||
| 359 | } | ||
| 360 | |||
| 361 | /// Render the timeline as one line per event. | ||
| 362 | /// | ||
| 363 | /// One line each, because the value of the view is the shape of the sequence; | ||
| 364 | /// a full body per entry would bury it. `patch show` prints bodies in full. | ||
| 365 | pub fn to_writer(entries: &[Entry], writer: &mut dyn std::io::Write) -> Result<(), Error> { | ||
| 366 | if entries.is_empty() { | ||
| 367 | writeln!(writer, "No events recorded.")?; | ||
| 368 | return Ok(()); | ||
| 369 | } | ||
| 370 | |||
| 371 | for entry in entries { | ||
| 372 | let label = entry.kind.label(entry.revision); | ||
| 373 | // The anchor. `patch log` already opens a revision line with `rN`, so | ||
| 374 | // repeating it as `@rN` there would be noise; everything else needs it | ||
| 375 | // to say which revision it is about. | ||
| 376 | let anchor = match (&entry.kind, entry.revision) { | ||
| 377 | (Kind::Revision { .. }, _) | (_, None) => String::new(), | ||
| 378 | (_, Some(n)) => format!("@r{} ", n), | ||
| 379 | }; | ||
| 380 | |||
| 381 | // Built as parts and joined, so no arm has to reason about whether the | ||
| 382 | // one before it left a separator behind. | ||
| 383 | let mut parts: Vec<String> = Vec::new(); | ||
| 384 | match &entry.kind { | ||
| 385 | Kind::Revision { | ||
| 386 | commit, | ||
| 387 | body, | ||
| 388 | edited, | ||
| 389 | } => { | ||
| 390 | parts.push(if commit.is_empty() { | ||
| 391 | state::UNKNOWN_COMMIT.to_string() | ||
| 392 | } else { | ||
| 393 | commit.chars().take(8).collect() | ||
| 394 | }); | ||
| 395 | if entry.revision == Some(1) { | ||
| 396 | parts.push("(initial)".to_string()); | ||
| 397 | } | ||
| 398 | parts.extend(body_part(body.as_deref(), *edited, false)); | ||
| 399 | } | ||
| 400 | Kind::Comment { | ||
| 401 | body, | ||
| 402 | edited, | ||
| 403 | deleted, | ||
| 404 | } => parts.extend(body_part(Some(body), *edited, *deleted)), | ||
| 405 | Kind::InlineComment { | ||
| 406 | file, | ||
| 407 | line, | ||
| 408 | body, | ||
| 409 | edited, | ||
| 410 | deleted, | ||
| 411 | } => { | ||
| 412 | parts.push(format!("{}:{}", file, line)); | ||
| 413 | parts.extend(body_part(Some(body), *edited, *deleted)); | ||
| 414 | } | ||
| 415 | Kind::Review { | ||
| 416 | verdict, | ||
| 417 | body, | ||
| 418 | edited, | ||
| 419 | } => { | ||
| 420 | parts.push(verdict.clone()); | ||
| 421 | parts.extend(body_part(Some(body), *edited, false)); | ||
| 422 | } | ||
| 423 | Kind::Label { label } => parts.push(format!("+{}", label)), | ||
| 424 | Kind::Unlabel { label } => parts.push(format!("-{}", label)), | ||
| 425 | Kind::Edited { target } | Kind::Deleted { target } => { | ||
| 426 | parts.push(format!("{:.8}", target)) | ||
| 427 | } | ||
| 428 | Kind::Closed { reason } => parts.extend(reason.clone()), | ||
| 429 | Kind::Merged { commit } => { | ||
| 430 | parts.extend(commit.as_deref().map(|c| format!("{:.8}", c))) | ||
| 431 | } | ||
| 432 | } | ||
| 433 | |||
| 434 | writeln!( | ||
| 435 | writer, | ||
| 436 | "{} {:<8} {} {}{}", | ||
| 437 | entry.timestamp, | ||
| 438 | label, | ||
| 439 | entry.author.name, | ||
| 440 | anchor, | ||
| 441 | parts.join(" ") | ||
| 442 | )?; | ||
| 443 | } | ||
| 444 | Ok(()) | ||
| 445 | } | ||
| 446 | |||
| 447 | /// A body reduced to one line, with `(edited)` following the words it changed, | ||
| 448 | /// or the tombstone in place of a deleted one. `None` when there is nothing to | ||
| 449 | /// show — an empty body contributes no trailing whitespace. | ||
| 450 | fn body_part(body: Option<&str>, edited: bool, deleted: bool) -> Option<String> { | ||
| 451 | if deleted { | ||
| 452 | return Some(crate::TOMBSTONE.to_string()); | ||
| 453 | } | ||
| 454 | let marker = if edited { " (edited)" } else { "" }; | ||
| 455 | match body { | ||
| 456 | Some(b) if !b.trim().is_empty() => { | ||
| 457 | Some(format!("{}{}", crate::patch::summarize_body(b), marker)) | ||
| 458 | } | ||
| 459 | // A body edited down to nothing still happened; say so rather than | ||
| 460 | // rendering an entry that looks like it never had one. | ||
| 461 | _ if edited => Some(marker.trim().to_string()), | ||
| 462 | _ => None, | ||
| 463 | } | ||
| 464 | } | ||
| 465 | |||
| 466 | pub fn to_json(entries: &[Entry]) -> Result<String, Error> { | ||
| 467 | Ok(serde_json::to_string_pretty(entries)?) | ||
| 468 | } | ||
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -422,7 +422,7 @@ fn test_auto_sync_after_write_covers_every_configured_remote() { | |||
| 422 | assert!(output.status.success()); | 422 | assert!(output.status.success()); |
| 423 | let stderr = String::from_utf8(output.stderr).unwrap(); | 423 | let stderr = String::from_utf8(output.stderr).unwrap(); |
| 424 | assert!( | 424 | assert!( |
| 425 | stderr.contains("Auto-syncing with 'origin', 'second'..."), | 425 | stderr.contains("auto-sync: publishing to 'origin', 'second'..."), |
| 426 | "stderr: {}", | 426 | "stderr: {}", |
| 427 | stderr | 427 | stderr |
| 428 | ); | 428 | ); |
| @@ -463,7 +463,7 @@ fn test_auto_sync_remote_config_pins_to_single_remote() { | |||
| 463 | assert!(output.status.success()); | 463 | assert!(output.status.success()); |
| 464 | let stderr = String::from_utf8(output.stderr).unwrap(); | 464 | let stderr = String::from_utf8(output.stderr).unwrap(); |
| 465 | assert!( | 465 | assert!( |
| 466 | stderr.contains("Auto-syncing with 'second'..."), | 466 | stderr.contains("auto-sync: publishing to 'second'..."), |
| 467 | "stderr: {}", | 467 | "stderr: {}", |
| 468 | stderr | 468 | stderr |
| 469 | ); | 469 | ); |
| @@ -486,6 +486,148 @@ fn test_auto_sync_remote_config_pins_to_single_remote() { | |||
| 486 | assert!(second_bare_repo.refname_to_id(&issue_ref).is_ok()); | 486 | assert!(second_bare_repo.refname_to_id(&issue_ref).is_ok()); |
| 487 | } | 487 | } |
| 488 | 488 | ||
| 489 | // --------------------------------------------------------------------------- | ||
| 490 | // Auto-sync is a separate operation from the command that triggers it | ||
| 491 | // --------------------------------------------------------------------------- | ||
| 492 | // | ||
| 493 | // A write command records an event locally and then, by default, tries to | ||
| 494 | // publish it. Those are two facts with two different failure modes, and they | ||
| 495 | // used to arrive as one interleaved stream on two streams at once: the banner | ||
| 496 | // on stderr, the fetch/push progress on stdout, mixed into the command's own | ||
| 497 | // result. A reader could not tell which part had failed, or that a network | ||
| 498 | // operation had happened at all. | ||
| 499 | |||
| 500 | /// Register `remote_name` pointing at a path that does not exist, so any | ||
| 501 | /// attempt to reach it fails. Never a real network remote. | ||
| 502 | fn add_unreachable_remote(repo_dir: &std::path::Path, remote_name: &str) { | ||
| 503 | let missing = repo_dir.join("no-such-remote.git"); | ||
| 504 | Command::new("git") | ||
| 505 | .args([ | ||
| 506 | "remote", | ||
| 507 | "add", | ||
| 508 | remote_name, | ||
| 509 | missing.to_str().unwrap(), | ||
| 510 | ]) | ||
| 511 | .current_dir(repo_dir) | ||
| 512 | .status() | ||
| 513 | .unwrap(); | ||
| 514 | } | ||
| 515 | |||
| 516 | /// The load-bearing one. The event *was* recorded; only publishing it failed. | ||
| 517 | /// Reporting that as a failed command would be a lie, and a caller checking the | ||
| 518 | /// exit code would retry a write that already happened. | ||
| 519 | #[test] | ||
| 520 | fn test_failed_auto_sync_does_not_fail_the_local_write() { | ||
| 521 | let cluster = TestCluster::new_without_collab_init(); | ||
| 522 | add_unreachable_remote(cluster.alice_dir.path(), "broken"); | ||
| 523 | cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); | ||
| 524 | |||
| 525 | // Pin auto-sync to the unreachable remote so the sync can only fail. | ||
| 526 | Command::new("git") | ||
| 527 | .args(["config", "collab.autoSyncRemote", "broken"]) | ||
| 528 | .current_dir(cluster.alice_dir.path()) | ||
| 529 | .status() | ||
| 530 | .unwrap(); | ||
| 531 | |||
| 532 | let output = cluster.run_collab( | ||
| 533 | cluster.alice_dir.path(), | ||
| 534 | &["issue", "open", "-t", "Recorded but not published"], | ||
| 535 | ); | ||
| 536 | let stdout = String::from_utf8(output.stdout).unwrap(); | ||
| 537 | let stderr = String::from_utf8(output.stderr).unwrap(); | ||
| 538 | |||
| 539 | assert_eq!( | ||
| 540 | output.status.code(), | ||
| 541 | Some(0), | ||
| 542 | "a local write that succeeded must exit 0 even when publishing it failed\ | ||
| 543 | \nstdout: {}\nstderr: {}", | ||
| 544 | stdout, | ||
| 545 | stderr | ||
| 546 | ); | ||
| 547 | |||
| 548 | // The write really did land locally. | ||
| 549 | let issue = state::list_issues(&cluster.alice_repo()) | ||
| 550 | .unwrap() | ||
| 551 | .into_iter() | ||
| 552 | .find(|issue| issue.title == "Recorded but not published"); | ||
| 553 | assert!(issue.is_some(), "the event must be recorded locally"); | ||
| 554 | |||
| 555 | // And the user is told, in as many words, both facts: it is recorded here, | ||
| 556 | // and it did not reach the remote. | ||
| 557 | assert!( | ||
| 558 | stderr.contains("recorded locally"), | ||
| 559 | "a failed auto-sync must say the write is safe locally:\n{}", | ||
| 560 | stderr | ||
| 561 | ); | ||
| 562 | assert!( | ||
| 563 | stderr.contains("git-collab sync"), | ||
| 564 | "a failed auto-sync must say how to publish the write:\n{}", | ||
| 565 | stderr | ||
| 566 | ); | ||
| 567 | } | ||
| 568 | |||
| 569 | /// Auto-sync narrates itself on stderr, so stdout carries the command's own | ||
| 570 | /// result and nothing else. `--json` exists only on read commands today, which | ||
| 571 | /// never auto-sync — keeping the network chatter off stdout is what stops that | ||
| 572 | /// gap from widening the day a write command grows `--json`. | ||
| 573 | #[test] | ||
| 574 | fn test_auto_sync_output_stays_off_stdout() { | ||
| 575 | let cluster = TestCluster::new_without_collab_init(); | ||
| 576 | cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]); | ||
| 577 | |||
| 578 | let output = cluster.run_collab( | ||
| 579 | cluster.alice_dir.path(), | ||
| 580 | &["issue", "open", "-t", "Clean stdout"], | ||
| 581 | ); | ||
| 582 | assert!(output.status.success()); | ||
| 583 | let stdout = String::from_utf8(output.stdout).unwrap(); | ||
| 584 | let stderr = String::from_utf8(output.stderr).unwrap(); | ||
| 585 | |||
| 586 | // stdout is the command's own result, whole and alone. | ||
| 587 | assert!( | ||
| 588 | stdout.starts_with("Opened issue "), | ||
| 589 | "stdout: {:?}", | ||
| 590 | stdout | ||
| 591 | ); | ||
| 592 | assert_eq!( | ||
| 593 | stdout.lines().count(), | ||
| 594 | 1, | ||
| 595 | "no network output belongs on stdout:\nstdout: {}\nstderr: {}", | ||
| 596 | stdout, | ||
| 597 | stderr | ||
| 598 | ); | ||
| 599 | for noise in ["Fetching from", "Pushing to", "Sync complete", "Pushed refs/"] { | ||
| 600 | assert!( | ||
| 601 | !stdout.contains(noise), | ||
| 602 | "{:?} is auto-sync's output, not the command's:\nstdout: {}", | ||
| 603 | noise, | ||
| 604 | stdout | ||
| 605 | ); | ||
| 606 | } | ||
| 607 | |||
| 608 | // ...and the sync is announced as its own operation, naming the remote it | ||
| 609 | // reaches, before it reaches it. | ||
| 610 | assert!( | ||
| 611 | stderr.contains("auto-sync:"), | ||
| 612 | "auto-sync must label itself as a separate operation:\n{}", | ||
| 613 | stderr | ||
| 614 | ); | ||
| 615 | assert!( | ||
| 616 | stderr.contains("'origin'"), | ||
| 617 | "auto-sync must name the remote it pushes to:\n{}", | ||
| 618 | stderr | ||
| 619 | ); | ||
| 620 | let announce = stderr | ||
| 621 | .find("auto-sync:") | ||
| 622 | .expect("auto-sync section must be present"); | ||
| 623 | let push = stderr.find("Pushing to").unwrap_or(usize::MAX); | ||
| 624 | assert!( | ||
| 625 | announce < push, | ||
| 626 | "the remote must be named before anything is sent to it:\n{}", | ||
| 627 | stderr | ||
| 628 | ); | ||
| 629 | } | ||
| 630 | |||
| 489 | #[test] | 631 | #[test] |
| 490 | fn test_bob_comments_on_alice_issue_then_sync() { | 632 | fn test_bob_comments_on_alice_issue_then_sync() { |
| 491 | let cluster = TestCluster::new(); | 633 | let cluster = TestCluster::new(); |
tests/timeline_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,444 @@ | |||
| 1 | //! `patch log --timeline`: the unified event timeline for a patch. | ||
| 2 | //! | ||
| 3 | //! `patch log` lists revisions; `patch show` lists comments and reviews. Neither | ||
| 4 | //! answers the question a reviewer actually asks — *what answered what*. A | ||
| 5 | //! comment on revision 1 followed by revision 2 is the causal chain this | ||
| 6 | //! project's revision anchoring exists to record, and reconstructing it by | ||
| 7 | //! interleaving two commands' output by eye is exactly the work the tool should | ||
| 8 | //! be doing. | ||
| 9 | //! | ||
| 10 | //! These tests pin three things: that the sequence is rendered in order with | ||
| 11 | //! every event kind in it, that each entry names the revision it is anchored to, | ||
| 12 | //! and that rendering it writes nothing. | ||
| 13 | |||
| 14 | mod common; | ||
| 15 | |||
| 16 | use common::TestRepo; | ||
| 17 | |||
| 18 | /// Build a patch with a full review round-trip on it: | ||
| 19 | /// | ||
| 20 | /// r1 -> comment -> request-changes -> r2 -> approve -> merged | ||
| 21 | /// | ||
| 22 | /// Returns the patch's short ID. | ||
| 23 | fn patch_with_a_review_round(repo: &TestRepo) -> String { | ||
| 24 | repo.git(&["checkout", "-b", "feat-timeline"]); | ||
| 25 | repo.commit_file("a.txt", "first", "v1"); | ||
| 26 | let out = repo.run_ok(&["patch", "create", "-t", "Timeline patch", "-B", "feat-timeline"]); | ||
| 27 | let id = out | ||
| 28 | .trim() | ||
| 29 | .strip_prefix("Created patch ") | ||
| 30 | .unwrap() | ||
| 31 | .to_string(); | ||
| 32 | |||
| 33 | repo.run_ok(&["patch", "comment", &id, "-b", "needs a test"]); | ||
| 34 | repo.run_ok(&[ | ||
| 35 | "patch", | ||
| 36 | "review", | ||
| 37 | &id, | ||
| 38 | "-v", | ||
| 39 | "request-changes", | ||
| 40 | "-b", | ||
| 41 | "not yet", | ||
| 42 | ]); | ||
| 43 | |||
| 44 | repo.git(&["checkout", "feat-timeline"]); | ||
| 45 | repo.commit_file("b.txt", "second", "v2"); | ||
| 46 | repo.run_ok(&["patch", "revise", &id, "-b", "added the test"]); | ||
| 47 | |||
| 48 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "looks good"]); | ||
| 49 | repo.git(&["checkout", "main"]); | ||
| 50 | |||
| 51 | id | ||
| 52 | } | ||
| 53 | |||
| 54 | /// The whole point: one command, every event kind, in the order they happened. | ||
| 55 | #[test] | ||
| 56 | fn timeline_interleaves_revisions_comments_and_reviews_in_order() { | ||
| 57 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 58 | let id = patch_with_a_review_round(&repo); | ||
| 59 | |||
| 60 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 61 | |||
| 62 | // Every kind of event is present. `patch log` alone shows only the | ||
| 63 | // revisions; `patch show` alone shows only the comment and the reviews. | ||
| 64 | assert!(out.contains("r1"), "timeline must show revision 1: {}", out); | ||
| 65 | assert!(out.contains("needs a test"), "timeline must show the comment: {}", out); | ||
| 66 | assert!( | ||
| 67 | out.contains("request-changes"), | ||
| 68 | "timeline must show the review verdict: {}", | ||
| 69 | out | ||
| 70 | ); | ||
| 71 | assert!(out.contains("r2"), "timeline must show revision 2: {}", out); | ||
| 72 | assert!(out.contains("approve"), "timeline must show the approval: {}", out); | ||
| 73 | |||
| 74 | // ...and in the order they happened. This is the assertion that makes it a | ||
| 75 | // timeline rather than two lists printed one after the other. | ||
| 76 | let at = |needle: &str| { | ||
| 77 | out.find(needle) | ||
| 78 | .unwrap_or_else(|| panic!("{:?} missing from timeline:\n{}", needle, out)) | ||
| 79 | }; | ||
| 80 | assert!( | ||
| 81 | at("needs a test") < at("request-changes"), | ||
| 82 | "the comment came before the review:\n{}", | ||
| 83 | out | ||
| 84 | ); | ||
| 85 | assert!( | ||
| 86 | at("request-changes") < at("added the test"), | ||
| 87 | "revision 2 answered the request for changes and must follow it:\n{}", | ||
| 88 | out | ||
| 89 | ); | ||
| 90 | assert!( | ||
| 91 | at("added the test") < at("looks good"), | ||
| 92 | "the approval came after revision 2:\n{}", | ||
| 93 | out | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | |||
| 97 | /// A timeline that does not say which revision a comment was made against | ||
| 98 | /// cannot answer "what answered what" — it is just a sorted list. | ||
| 99 | #[test] | ||
| 100 | fn timeline_anchors_each_event_to_its_revision() { | ||
| 101 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 102 | let id = patch_with_a_review_round(&repo); | ||
| 103 | |||
| 104 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 105 | |||
| 106 | let line = |needle: &str| { | ||
| 107 | out.lines() | ||
| 108 | .find(|l| l.contains(needle)) | ||
| 109 | .unwrap_or_else(|| panic!("{:?} missing from timeline:\n{}", needle, out)) | ||
| 110 | }; | ||
| 111 | |||
| 112 | assert!( | ||
| 113 | line("not yet").contains("@r1"), | ||
| 114 | "the request-changes review was made against r1: {:?}", | ||
| 115 | line("not yet") | ||
| 116 | ); | ||
| 117 | assert!( | ||
| 118 | line("looks good").contains("@r2"), | ||
| 119 | "the approval was made against r2: {:?}", | ||
| 120 | line("looks good") | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | |||
| 124 | /// A thread comment carries no revision of its own, so it is anchored to the | ||
| 125 | /// revision that was current where it sits in the sequence. Without that, the | ||
| 126 | /// view answers "what answered what" for reviews only, and a thread comment — | ||
| 127 | /// the most common kind — floats free of the round it belongs to. | ||
| 128 | #[test] | ||
| 129 | fn timeline_anchors_a_thread_comment_to_the_revision_it_was_written_on() { | ||
| 130 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 131 | let id = patch_with_a_review_round(&repo); | ||
| 132 | |||
| 133 | // Written after r2 exists, so it belongs to r2, not to r1. | ||
| 134 | repo.run_ok(&["patch", "comment", &id, "-b", "one more thought"]); | ||
| 135 | |||
| 136 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 137 | let line = out | ||
| 138 | .lines() | ||
| 139 | .find(|l| l.contains("one more thought")) | ||
| 140 | .unwrap_or_else(|| panic!("comment missing:\n{}", out)); | ||
| 141 | assert!( | ||
| 142 | line.contains("@r2"), | ||
| 143 | "a comment written after r2 is anchored to r2: {:?}", | ||
| 144 | line | ||
| 145 | ); | ||
| 146 | |||
| 147 | // And the earlier one is still anchored to r1 — the anchor tracks position | ||
| 148 | // in the sequence, not simply the latest revision. | ||
| 149 | let earlier = out | ||
| 150 | .lines() | ||
| 151 | .find(|l| l.contains("needs a test")) | ||
| 152 | .unwrap_or_else(|| panic!("earlier comment missing:\n{}", out)); | ||
| 153 | assert!( | ||
| 154 | earlier.contains("@r1"), | ||
| 155 | "a comment written before r2 stays anchored to r1: {:?}", | ||
| 156 | earlier | ||
| 157 | ); | ||
| 158 | } | ||
| 159 | |||
| 160 | /// The merge is the last beat of the story, and for a squash or a rebase-merge | ||
| 161 | /// the recorded commit is the only route from the patch back to the code. | ||
| 162 | #[test] | ||
| 163 | fn timeline_ends_with_the_recorded_merge() { | ||
| 164 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 165 | let id = patch_with_a_review_round(&repo); | ||
| 166 | |||
| 167 | repo.git(&["merge", "--no-ff", "-m", "land it", "feat-timeline"]); | ||
| 168 | let landed = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 169 | repo.run_ok(&["patch", "merge", &id]); | ||
| 170 | |||
| 171 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 172 | let last = out.lines().rfind(|l| !l.trim().is_empty()).unwrap(); | ||
| 173 | assert!( | ||
| 174 | last.contains("merged"), | ||
| 175 | "the timeline ends with the merge: {:?}", | ||
| 176 | last | ||
| 177 | ); | ||
| 178 | assert!( | ||
| 179 | last.contains(&landed[..8]), | ||
| 180 | "the merge entry names the commit that landed the patch: {:?}", | ||
| 181 | last | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | |||
| 185 | /// Scripted callers need the same sequence without parsing columns. | ||
| 186 | #[test] | ||
| 187 | fn timeline_json_carries_the_same_sequence() { | ||
| 188 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 189 | let id = patch_with_a_review_round(&repo); | ||
| 190 | |||
| 191 | let out = repo.run_ok(&["patch", "log", &id, "--timeline", "--json"]); | ||
| 192 | let entries: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap(); | ||
| 193 | |||
| 194 | let kinds: Vec<&str> = entries | ||
| 195 | .iter() | ||
| 196 | .map(|e| e["type"].as_str().unwrap()) | ||
| 197 | .collect(); | ||
| 198 | assert_eq!( | ||
| 199 | kinds, | ||
| 200 | vec!["revision", "comment", "review", "revision", "review"], | ||
| 201 | "timeline JSON: {}", | ||
| 202 | out | ||
| 203 | ); | ||
| 204 | |||
| 205 | assert_eq!(entries[0]["revision"], 1); | ||
| 206 | assert_eq!(entries[1]["revision"], 1, "the comment was made against r1"); | ||
| 207 | assert_eq!(entries[2]["revision"], 1, "the review was made against r1"); | ||
| 208 | assert_eq!(entries[3]["revision"], 2); | ||
| 209 | assert_eq!(entries[4]["revision"], 2, "the approval was made against r2"); | ||
| 210 | |||
| 211 | assert_eq!(entries[2]["verdict"], "request-changes"); | ||
| 212 | assert_eq!(entries[4]["verdict"], "approve"); | ||
| 213 | assert_eq!(entries[1]["author"]["email"], "alice@example.com"); | ||
| 214 | } | ||
| 215 | |||
| 216 | /// The governing constraint of this project: surfacing recorded facts is a pure | ||
| 217 | /// read. A view that appends an event or moves a ref corrupts the record it | ||
| 218 | /// claims to be showing. | ||
| 219 | /// | ||
| 220 | /// Note `patch show` deliberately moves `refs/collab/local/seen/patches/<id>`, | ||
| 221 | /// a purely local read-marker that is not a collab event. The timeline does | ||
| 222 | /// *not* extend that pattern: it moves nothing at all, which is what the | ||
| 223 | /// `refs/collab/` sweep below pins. | ||
| 224 | #[test] | ||
| 225 | fn rendering_a_timeline_writes_nothing() { | ||
| 226 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 227 | let id = patch_with_a_review_round(&repo); | ||
| 228 | repo.git(&["merge", "--no-ff", "-m", "land it", "feat-timeline"]); | ||
| 229 | repo.run_ok(&["patch", "merge", &id]); | ||
| 230 | |||
| 231 | let refs_before = repo.git(&["for-each-ref", "--format=%(refname) %(objectname)"]); | ||
| 232 | |||
| 233 | repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 234 | repo.run_ok(&["patch", "log", &id, "--timeline", "--json"]); | ||
| 235 | |||
| 236 | let refs_after = repo.git(&["for-each-ref", "--format=%(refname) %(objectname)"]); | ||
| 237 | assert_eq!( | ||
| 238 | refs_before, refs_after, | ||
| 239 | "rendering a timeline must not create, move or delete any ref" | ||
| 240 | ); | ||
| 241 | } | ||
| 242 | |||
| 243 | // --------------------------------------------------------------------------- | ||
| 244 | // Corrections | ||
| 245 | // --------------------------------------------------------------------------- | ||
| 246 | // | ||
| 247 | // A `BodyEdit` supersedes an earlier body and a `CommentDelete` tombstones one. | ||
| 248 | // Both are events in the DAG, appended rather than applied in place, precisely | ||
| 249 | // so the record stays an audit trail rather than a summary. A timeline that | ||
| 250 | // quietly showed the corrected text at the original position and nothing else | ||
| 251 | // would turn it back into a summary — so a correction gets its own slot, at the | ||
| 252 | // point in the sequence where it actually happened. | ||
| 253 | |||
| 254 | /// The 8-char event ID `patch show` prints for the first thread comment. | ||
| 255 | fn first_comment_id(repo: &TestRepo, patch: &str) -> String { | ||
| 256 | let show = repo.run_ok(&["patch", "show", patch]); | ||
| 257 | let comments = show | ||
| 258 | .split_once("--- Comments ---") | ||
| 259 | .unwrap_or_else(|| panic!("no comments section in:\n{}", show)) | ||
| 260 | .1; | ||
| 261 | let start = comments.find('[').unwrap() + 1; | ||
| 262 | let end = comments[start..].find(']').unwrap() + start; | ||
| 263 | comments[start..end].to_string() | ||
| 264 | } | ||
| 265 | |||
| 266 | #[test] | ||
| 267 | fn timeline_records_an_edit_as_its_own_event() { | ||
| 268 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 269 | let id = patch_with_a_review_round(&repo); | ||
| 270 | |||
| 271 | let comment_id = first_comment_id(&repo, &id); | ||
| 272 | repo.run_ok(&[ | ||
| 273 | "patch", | ||
| 274 | "edit-comment", | ||
| 275 | &id, | ||
| 276 | &comment_id, | ||
| 277 | "-b", | ||
| 278 | "needs a test, and a changelog entry", | ||
| 279 | ]); | ||
| 280 | |||
| 281 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 282 | |||
| 283 | // The comment keeps its original slot, carrying the corrected text and | ||
| 284 | // saying that it was corrected. | ||
| 285 | let comment_line = out | ||
| 286 | .lines() | ||
| 287 | .find(|l| l.contains("needs a test, and a changelog entry")) | ||
| 288 | .unwrap_or_else(|| panic!("corrected comment missing:\n{}", out)); | ||
| 289 | assert!( | ||
| 290 | comment_line.contains("(edited)"), | ||
| 291 | "a corrected comment must say so: {:?}", | ||
| 292 | comment_line | ||
| 293 | ); | ||
| 294 | |||
| 295 | // ...and the correction is its own entry, later in the sequence, naming | ||
| 296 | // the comment it corrected. | ||
| 297 | let edit_line = out | ||
| 298 | .lines() | ||
| 299 | .find(|l| l.contains("edited") && l.contains(&comment_id)) | ||
| 300 | .unwrap_or_else(|| panic!("the edit itself is missing from the timeline:\n{}", out)); | ||
| 301 | assert!( | ||
| 302 | out.find(comment_line).unwrap() < out.find(edit_line).unwrap(), | ||
| 303 | "the edit happened after the comment it corrected:\n{}", | ||
| 304 | out | ||
| 305 | ); | ||
| 306 | } | ||
| 307 | |||
| 308 | #[test] | ||
| 309 | fn timeline_keeps_a_deleted_comments_slot_and_records_the_deletion() { | ||
| 310 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 311 | let id = patch_with_a_review_round(&repo); | ||
| 312 | |||
| 313 | let comment_id = first_comment_id(&repo, &id); | ||
| 314 | repo.run_ok(&["patch", "delete-comment", &id, &comment_id]); | ||
| 315 | |||
| 316 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 317 | |||
| 318 | assert!( | ||
| 319 | !out.contains("needs a test"), | ||
| 320 | "a tombstone drops the text, it does not merely hide it:\n{}", | ||
| 321 | out | ||
| 322 | ); | ||
| 323 | assert!( | ||
| 324 | out.contains("[deleted]"), | ||
| 325 | "the deleted comment keeps its slot, marked as a tombstone:\n{}", | ||
| 326 | out | ||
| 327 | ); | ||
| 328 | let delete_line = out | ||
| 329 | .lines() | ||
| 330 | .find(|l| l.contains("deleted") && l.contains(&comment_id)) | ||
| 331 | .unwrap_or_else(|| panic!("the deletion itself is missing:\n{}", out)); | ||
| 332 | assert!( | ||
| 333 | out.find("[deleted]").unwrap() < out.find(delete_line).unwrap(), | ||
| 334 | "the deletion happened after the comment it removed:\n{}", | ||
| 335 | out | ||
| 336 | ); | ||
| 337 | } | ||
| 338 | |||
| 339 | /// The fold ignores a correction whose author is not the author of the event it | ||
| 340 | /// names — anyone holding the DAG can append anything to it, so the rule that | ||
| 341 | /// nobody rewrites somebody else's words holds where state is derived. | ||
| 342 | /// | ||
| 343 | /// The timeline has to honour the same rule. Listing every `BodyEdit` event it | ||
| 344 | /// finds would show a forged edit to someone else's words as though it had | ||
| 345 | /// happened, which is worse than not having a timeline at all. | ||
| 346 | #[test] | ||
| 347 | fn timeline_ignores_a_correction_the_fold_refused() { | ||
| 348 | use git_collab::dag; | ||
| 349 | use git_collab::event::Action; | ||
| 350 | |||
| 351 | // The patch's comments are Bob's, so an edit authored by Alice — which is | ||
| 352 | // what `write_raw_event` signs — is one author correcting another. | ||
| 353 | let repo = TestRepo::new("Bob", "bob@example.com"); | ||
| 354 | let id = patch_with_a_review_round(&repo); | ||
| 355 | |||
| 356 | let git_repo = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 357 | let events_ref = format!( | ||
| 358 | "refs/collab/patches/{}/events", | ||
| 359 | git_repo | ||
| 360 | .references_glob("refs/collab/patches/*/events") | ||
| 361 | .unwrap() | ||
| 362 | .filter_map(|r| r.ok()) | ||
| 363 | .find_map(|r| { | ||
| 364 | let name = r.name()?.to_string(); | ||
| 365 | let full = name.strip_prefix("refs/collab/patches/")?.strip_suffix("/events")?; | ||
| 366 | full.starts_with(&id).then(|| full.to_string()) | ||
| 367 | }) | ||
| 368 | .expect("patch events ref") | ||
| 369 | ); | ||
| 370 | |||
| 371 | // The OID of Bob's thread comment: what a forged edit would name. | ||
| 372 | let target = dag::walk_events(&git_repo, &events_ref) | ||
| 373 | .unwrap() | ||
| 374 | .into_iter() | ||
| 375 | .find(|(_, e)| matches!(e.action, Action::PatchComment { .. })) | ||
| 376 | .map(|(oid, _)| oid.to_string()) | ||
| 377 | .expect("thread comment event"); | ||
| 378 | |||
| 379 | let tip = git_repo.refname_to_id(&events_ref).unwrap(); | ||
| 380 | let forged = common::write_raw_event( | ||
| 381 | &git_repo, | ||
| 382 | Some(tip), | ||
| 383 | serde_json::json!({ | ||
| 384 | "type": "body.edit", | ||
| 385 | "target": target, | ||
| 386 | "body": "words Bob never wrote", | ||
| 387 | }), | ||
| 388 | 99, | ||
| 389 | ); | ||
| 390 | git_repo | ||
| 391 | .reference(&events_ref, forged, true, "forged edit") | ||
| 392 | .unwrap(); | ||
| 393 | |||
| 394 | let out = repo.run_ok(&["patch", "log", &id, "--timeline"]); | ||
| 395 | assert!( | ||
| 396 | !out.contains("words Bob never wrote"), | ||
| 397 | "a refused correction must not appear in the timeline:\n{}", | ||
| 398 | out | ||
| 399 | ); | ||
| 400 | assert!( | ||
| 401 | out.contains("needs a test"), | ||
| 402 | "the original comment stands unchanged:\n{}", | ||
| 403 | out | ||
| 404 | ); | ||
| 405 | assert!( | ||
| 406 | !out.contains("(edited)"), | ||
| 407 | "nothing was in fact edited:\n{}", | ||
| 408 | out | ||
| 409 | ); | ||
| 410 | } | ||
| 411 | |||
| 412 | /// `patch log` and `patch log --json` are consumed by scripts and by the | ||
| 413 | /// existing suite. The timeline is strictly additive: without the flag, nothing | ||
| 414 | /// about either changes. | ||
| 415 | #[test] | ||
| 416 | fn timeline_flag_does_not_change_default_patch_log_output() { | ||
| 417 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 418 | let id = patch_with_a_review_round(&repo); | ||
| 419 | |||
| 420 | let plain = repo.run_ok(&["patch", "log", &id]); | ||
| 421 | assert!(plain.contains("r1"), "{}", plain); | ||
| 422 | assert!(plain.contains("r2"), "{}", plain); | ||
| 423 | assert!( | ||
| 424 | !plain.contains("needs a test"), | ||
| 425 | "default patch log stays a revision log, with no comments in it: {}", | ||
| 426 | plain | ||
| 427 | ); | ||
| 428 | assert!( | ||
| 429 | !plain.contains("request-changes"), | ||
| 430 | "default patch log stays a revision log, with no reviews in it: {}", | ||
| 431 | plain | ||
| 432 | ); | ||
| 433 | |||
| 434 | // Default --json is still the bare revision array. | ||
| 435 | let json = repo.run_ok(&["patch", "log", &id, "--json"]); | ||
| 436 | let revisions: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap(); | ||
| 437 | assert_eq!(revisions.len(), 2); | ||
| 438 | assert_eq!(revisions[0]["number"], 1); | ||
| 439 | assert!( | ||
| 440 | revisions[0].get("type").is_none(), | ||
| 441 | "default --json keeps the revision shape, not the timeline shape: {}", | ||
| 442 | json | ||
| 443 | ); | ||
| 444 | } | ||