8127cbdf
Carry patches as revision refs instead of branches
a73x 2026-08-09 19:28
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -41,9 +41,15 @@ $ 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 | Merging is not a command. A patch is a branch; when its commits become reachable | 44 | Each revision is an immutable ref in the patch's own namespace |
| 45 | from the base branch, `git-collab` notices and marks the patch merged. Merge | 45 | (`refs/collab/patches/<id>/r/<n>`), so `git-collab sync` carries the patch and |
| 46 | however you already merge. | 46 | every revision it ever had. Contributing needs no `git push` of a branch and no |
| 47 | write access to `refs/heads/*`, and rebasing your branch cannot strip an earlier | ||
| 48 | revision of the objects a reviewer is looking at. | ||
| 49 | |||
| 50 | Merging is not a command. When a patch's commits become reachable from the base | ||
| 51 | branch, `git-collab` notices and marks the patch merged. Merge however you | ||
| 52 | already merge. | ||
| 47 | 53 | ||
| 48 | ## Install | 54 | ## Install |
| 49 | 55 | ||
| @@ -68,6 +74,10 @@ $ ...work, commit... | |||
| 68 | $ git-collab patch create -t "Fix trailing newline in parser" | 74 | $ git-collab patch create -t "Fix trailing newline in parser" |
| 69 | 75 | ||
| 70 | $ git-collab sync # fetch, reconcile, push | 76 | $ git-collab sync # fetch, reconcile, push |
| 77 | |||
| 78 | $ ...address review, commit... | ||
| 79 | $ git-collab patch revise a1b2c3d4 -b "addressed review" # record revision 2 | ||
| 80 | $ git-collab sync | ||
| 71 | ``` | 81 | ``` |
| 72 | 82 | ||
| 73 | Reviewing someone else's patch: | 83 | Reviewing someone else's patch: |
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -403,6 +403,9 @@ pub enum PatchCmd { | |||
| 403 | /// Revision description | 403 | /// Revision description |
| 404 | #[arg(short, long)] | 404 | #[arg(short, long)] |
| 405 | body: Option<String>, | 405 | body: Option<String>, |
| 406 | /// Source branch to snapshot (defaults to HEAD) | ||
| 407 | #[arg(short = 'B', long)] | ||
| 408 | branch: Option<String>, | ||
| 406 | }, | 409 | }, |
| 407 | /// Show revision log for a patch | 410 | /// Show revision log for a patch |
| 408 | Log { | 411 | Log { |
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -77,7 +77,10 @@ pub enum Action { | |||
| 77 | commit: String, | 77 | commit: String, |
| 78 | #[serde(default, skip_serializing_if = "String::is_empty")] | 78 | #[serde(default, skip_serializing_if = "String::is_empty")] |
| 79 | tree: String, | 79 | tree: String, |
| 80 | /// Base branch tip OID at creation time (for merge detection). | 80 | /// Where revision 1 branched off `base_ref`: the merge-base of the |
| 81 | /// base branch and `commit`, recorded when the patch is created. Named | ||
| 82 | /// `base_commit` because it is part of the signed payload of events | ||
| 83 | /// already in the wild; `PatchRevision` calls the same thing `base`. | ||
| 81 | #[serde(default, skip_serializing_if = "Option::is_none")] | 84 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 82 | base_commit: Option<String>, | 85 | base_commit: Option<String>, |
| 83 | }, | 86 | }, |
| @@ -94,6 +97,12 @@ pub enum Action { | |||
| 94 | tree: String, | 97 | tree: String, |
| 95 | #[serde(default, skip_serializing_if = "Option::is_none")] | 98 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 96 | body: Option<String>, | 99 | body: Option<String>, |
| 100 | /// Merge-base of the base branch and `commit` when this revision was | ||
| 101 | /// recorded. `None` on revisions written before it was stored, and | ||
| 102 | /// skipped on the way out so those events still round-trip byte for | ||
| 103 | /// byte for signature verification. | ||
| 104 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 105 | base: Option<String>, | ||
| 97 | }, | 106 | }, |
| 98 | /// `revision` is `None` on reviews written before reviews were scoped to a | 107 | /// `revision` is `None` on reviews written before reviews were scoped to a |
| 99 | /// revision. It is not the same as revision 1: `PatchState` attributes | 108 | /// revision. It is not the same as revision 1: `PatchState` attributes |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -395,8 +395,11 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 395 | println!("Title: {}", p.title); | 395 | println!("Title: {}", p.title); |
| 396 | println!("Author: {} <{}>", p.author.name, p.author.email); | 396 | println!("Author: {} <{}>", p.author.name, p.author.email); |
| 397 | match p.resolve_head(repo) { | 397 | match p.resolve_head(repo) { |
| 398 | Ok(_) => { | 398 | Ok(head) => { |
| 399 | println!("Branch: {} -> {}", p.branch, p.base_ref); | 399 | // The head is the latest recorded revision, not a |
| 400 | // branch tip; the branch is shown for provenance only. | ||
| 401 | println!("Head: {:.8} -> {}", head, p.base_ref); | ||
| 402 | println!("Branch: {}", p.branch); | ||
| 400 | if let Ok((ahead, behind)) = p.staleness(repo) { | 403 | if let Ok((ahead, behind)) = p.staleness(repo) { |
| 401 | let freshness = if behind == 0 { | 404 | let freshness = if behind == 0 { |
| 402 | "up-to-date" | 405 | "up-to-date" |
| @@ -410,7 +413,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 410 | } | 413 | } |
| 411 | } | 414 | } |
| 412 | Err(_) => { | 415 | Err(_) => { |
| 413 | println!("Branch: {} (not found)", p.branch); | 416 | println!("Head: unknown -> {}", p.base_ref); |
| 417 | println!("Branch: {}", p.branch); | ||
| 414 | } | 418 | } |
| 415 | } | 419 | } |
| 416 | println!("Created: {}", p.created_at); | 420 | println!("Created: {}", p.created_at); |
| @@ -541,8 +545,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 541 | println!("Review submitted."); | 545 | println!("Review submitted."); |
| 542 | Ok(()) | 546 | Ok(()) |
| 543 | } | 547 | } |
| 544 | PatchCmd::Revise { id, body } => { | 548 | PatchCmd::Revise { id, body, branch } => { |
| 545 | patch::revise(repo, &id, body.as_deref())?; | 549 | patch::revise(repo, &id, body.as_deref(), branch.as_deref())?; |
| 546 | println!("Patch revised."); | 550 | println!("Patch revised."); |
| 547 | Ok(()) | 551 | Ok(()) |
| 548 | } | 552 | } |
src/log.rs
| Old | New | ||
|---|---|---|---|
| @@ -43,13 +43,15 @@ pub fn collect_events(repo: &Repository, limit: Option<usize>) -> Result<Vec<Log | |||
| 43 | } | 43 | } |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | // Walk patches | 46 | // Walk patches. Only the events ref of each patch is a DAG — the revision |
| 47 | let patch_refs = repo.references_glob("refs/collab/patches/*")?; | 47 | // refs beside it point at ordinary source commits. |
| 48 | let patch_refs = repo.references_glob("refs/collab/patches/*/events")?; | ||
| 48 | for r in patch_refs { | 49 | for r in patch_refs { |
| 49 | let r = r?; | 50 | let r = r?; |
| 50 | let ref_name = r.name().unwrap_or_default().to_string(); | 51 | let ref_name = r.name().unwrap_or_default().to_string(); |
| 51 | let id = ref_name | 52 | let id = ref_name |
| 52 | .strip_prefix("refs/collab/patches/") | 53 | .strip_prefix("refs/collab/patches/") |
| 54 | .and_then(|rest| rest.strip_suffix("/events")) | ||
| 53 | .unwrap_or_default() | 55 | .unwrap_or_default() |
| 54 | .to_string(); | 56 | .to_string(); |
| 55 | if let Ok(events) = dag::walk_events(repo, &ref_name) { | 57 | if let Ok(events) = dag::walk_events(repo, &ref_name) { |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -6,79 +6,40 @@ 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, Revision}; | 9 | use crate::state::{self, PatchState, PatchStatus}; |
| 10 | 10 | ||
| 11 | /// Auto-detect whether the branch tip has changed since the last recorded revision. | 11 | /// Where a revision branched off its base branch: the merge-base of the base |
| 12 | /// If it has, append a PatchRevision event and return the new `Revision` so callers | 12 | /// tip and the revision's commit, falling back to the base tip when there is |
| 13 | /// can update their in-memory `PatchState` without re-walking the DAG. | 13 | /// no merge-base. |
| 14 | fn auto_detect_revision( | 14 | /// |
| 15 | repo: &Repository, | 15 | /// Merge-base rather than branch tip is what makes the value stable. If the |
| 16 | ref_name: &str, | 16 | /// base branch advances and the author does not rebase, the merge-base does not |
| 17 | patch: &PatchState, | 17 | /// move, so a change in this value means the author actually rebased. |
| 18 | sk: &ed25519_dalek::SigningKey, | 18 | fn revision_base(repo: &Repository, base_ref: &str, commit: Oid) -> Result<Oid, Error> { |
| 19 | ) -> Result<Option<Revision>, Error> { | 19 | let base_oid = repo.refname_to_id(&format!("refs/heads/{}", base_ref))?; |
| 20 | let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0); | 20 | Ok(repo.merge_base(base_oid, commit).unwrap_or(base_oid)) |
| 21 | |||
| 22 | let last_commit = patch | ||
| 23 | .revisions | ||
| 24 | .last() | ||
| 25 | .map(|r| r.commit.as_str()) | ||
| 26 | .unwrap_or(""); | ||
| 27 | |||
| 28 | // Legacy patches recorded no commit on their revisions, so there is | ||
| 29 | // nothing to compare the branch tip against. Every tip would look like a | ||
| 30 | // change and append a revision that never happened. | ||
| 31 | if last_commit.is_empty() { | ||
| 32 | return Ok(None); | ||
| 33 | } | ||
| 34 | |||
| 35 | // Try to resolve the branch tip | ||
| 36 | let tip_oid = match patch.resolve_head(repo) { | ||
| 37 | Ok(oid) => oid, | ||
| 38 | Err(_) => return Ok(None), // Branch deleted or unavailable | ||
| 39 | }; | ||
| 40 | |||
| 41 | let tip_hex = tip_oid.to_string(); | ||
| 42 | |||
| 43 | if tip_hex == last_commit { | ||
| 44 | return Ok(None); | ||
| 45 | } | ||
| 46 | |||
| 47 | // Branch tip changed — insert a PatchRevision event | ||
| 48 | let commit = repo.find_commit(tip_oid)?; | ||
| 49 | let tree_oid = commit.tree()?.id(); | ||
| 50 | let author = get_author(repo)?; | ||
| 51 | let timestamp = chrono::Utc::now().to_rfc3339(); | ||
| 52 | let event = Event { | ||
| 53 | timestamp: timestamp.clone(), | ||
| 54 | author, | ||
| 55 | action: Action::PatchRevision { | ||
| 56 | commit: tip_hex.clone(), | ||
| 57 | tree: tree_oid.to_string(), | ||
| 58 | body: None, | ||
| 59 | }, | ||
| 60 | clock: 0, | ||
| 61 | }; | ||
| 62 | dag::append_event(repo, ref_name, &event, sk)?; | ||
| 63 | Ok(Some(Revision { | ||
| 64 | number: current_rev + 1, | ||
| 65 | commit: tip_hex, | ||
| 66 | tree: tree_oid.to_string(), | ||
| 67 | body: None, | ||
| 68 | timestamp, | ||
| 69 | })) | ||
| 70 | } | 21 | } |
| 71 | 22 | ||
| 72 | /// Auto-detect revision changes and update the in-memory PatchState in place. | 23 | /// Record revision `number`'s commit as a write-once ref beside the patch's |
| 73 | fn auto_detect_and_update( | 24 | /// event DAG. This is what keeps the objects behind a revision reachable once |
| 25 | /// the branch that produced them has been rebased away. | ||
| 26 | fn write_revision_ref( | ||
| 74 | repo: &Repository, | 27 | repo: &Repository, |
| 75 | ref_name: &str, | 28 | events_ref: &str, |
| 76 | patch: &mut PatchState, | 29 | number: u32, |
| 77 | sk: &ed25519_dalek::SigningKey, | 30 | commit: Oid, |
| 78 | ) -> Result<(), Error> { | 31 | ) -> Result<(), Error> { |
| 79 | if let Some(rev) = auto_detect_revision(repo, ref_name, patch, sk)? { | 32 | let name = state::patch_revision_ref(events_ref, number); |
| 80 | patch.revisions.push(rev); | 33 | if let Ok(existing) = repo.refname_to_id(&name) { |
| 34 | if existing != commit { | ||
| 35 | return Err(Error::Cmd(format!( | ||
| 36 | "revision {} already points at {} — revisions are write-once", | ||
| 37 | number, existing | ||
| 38 | ))); | ||
| 39 | } | ||
| 40 | return Ok(()); | ||
| 81 | } | 41 | } |
| 42 | repo.reference(&name, commit, false, "record revision")?; | ||
| 82 | Ok(()) | 43 | Ok(()) |
| 83 | } | 44 | } |
| 84 | 45 | ||
| @@ -103,13 +64,19 @@ pub fn create( | |||
| 103 | .refname_to_id(&branch_ref) | 64 | .refname_to_id(&branch_ref) |
| 104 | .map_err(|e| crate::error::Error::Cmd(format!("branch '{}' not found: {}", branch, e)))?; | 65 | .map_err(|e| crate::error::Error::Cmd(format!("branch '{}' not found: {}", branch, e)))?; |
| 105 | 66 | ||
| 106 | // Check for duplicate: scan open patches for matching branch | 67 | // Duplicate detection is by commit, not by branch name. Patch identity is |
| 68 | // declared in the event DAG, so a branch name means nothing: two worktrees | ||
| 69 | // on the same generated name are not the same patch, and one worktree that | ||
| 70 | // renamed its branch has not stopped being one. | ||
| 107 | let patches = state::list_patches(repo)?; | 71 | let patches = state::list_patches(repo)?; |
| 108 | for p in &patches { | 72 | for p in &patches { |
| 109 | if p.status == PatchStatus::Open && p.branch == branch { | 73 | if p.status != PatchStatus::Open { |
| 74 | continue; | ||
| 75 | } | ||
| 76 | if p.revisions.last().map(|r| r.commit.as_str()) == Some(tip_oid.to_string().as_str()) { | ||
| 110 | return Err(crate::error::Error::Cmd(format!( | 77 | return Err(crate::error::Error::Cmd(format!( |
| 111 | "patch already exists for branch '{}'", | 78 | "patch {:.8} already stands at commit {:.8}; use `patch revise` to add a revision", |
| 112 | branch | 79 | p.id, tip_oid |
| 113 | ))); | 80 | ))); |
| 114 | } | 81 | } |
| 115 | } | 82 | } |
| @@ -117,7 +84,7 @@ pub fn create( | |||
| 117 | // Get commit and tree OIDs for revision 1 | 84 | // Get commit and tree OIDs for revision 1 |
| 118 | let commit = repo.find_commit(tip_oid)?; | 85 | let commit = repo.find_commit(tip_oid)?; |
| 119 | let tree_oid = commit.tree()?.id(); | 86 | let tree_oid = commit.tree()?.id(); |
| 120 | let base_oid = repo.refname_to_id(&format!("refs/heads/{}", base_ref))?; | 87 | let base_oid = revision_base(repo, base_ref, tip_oid)?; |
| 121 | 88 | ||
| 122 | let oid = dag::create_root_action( | 89 | let oid = dag::create_root_action( |
| 123 | repo, | 90 | repo, |
| @@ -133,8 +100,9 @@ pub fn create( | |||
| 133 | }, | 100 | }, |
| 134 | )?; | 101 | )?; |
| 135 | let id = oid.to_string(); | 102 | let id = oid.to_string(); |
| 136 | let ref_name = format!("refs/collab/patches/{}", id); | 103 | let events_ref = state::patch_events_ref(&id); |
| 137 | repo.reference(&ref_name, oid, false, "patch create")?; | 104 | repo.reference(&events_ref, oid, false, "patch create")?; |
| 105 | write_revision_ref(repo, &events_ref, 1, tip_oid)?; | ||
| 138 | Ok(id) | 106 | Ok(id) |
| 139 | } | 107 | } |
| 140 | 108 | ||
| @@ -147,8 +115,7 @@ pub struct ListEntry { | |||
| 147 | fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { | 115 | fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { |
| 148 | let seen_ref = format!("refs/collab/local/seen/patches/{}", id); | 116 | let seen_ref = format!("refs/collab/local/seen/patches/{}", id); |
| 149 | let seen_oid = repo.refname_to_id(&seen_ref).ok()?; | 117 | let seen_oid = repo.refname_to_id(&seen_ref).ok()?; |
| 150 | let ref_name = format!("refs/collab/patches/{}", id); | 118 | let tip = repo.refname_to_id(&state::patch_events_ref(id)).ok()?; |
| 151 | let tip = repo.refname_to_id(&ref_name).ok()?; | ||
| 152 | 119 | ||
| 153 | if seen_oid == tip { | 120 | if seen_oid == tip { |
| 154 | return Some(0); | 121 | return Some(0); |
| @@ -262,10 +229,7 @@ pub fn comment( | |||
| 262 | ) -> Result<(), crate::error::Error> { | 229 | ) -> Result<(), crate::error::Error> { |
| 263 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 230 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 264 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | 231 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 265 | let mut patch = PatchState::from_ref(repo, &ref_name, &id)?; | 232 | let patch = PatchState::from_ref(repo, &ref_name, &id)?; |
| 266 | |||
| 267 | // Auto-detect revision (runs for all comment types to record branch changes) | ||
| 268 | auto_detect_and_update(repo, &ref_name, &mut patch, &sk)?; | ||
| 269 | 233 | ||
| 270 | let author = get_author(repo)?; | 234 | let author = get_author(repo)?; |
| 271 | 235 | ||
| @@ -326,10 +290,7 @@ pub fn review( | |||
| 326 | ) -> Result<(), crate::error::Error> { | 290 | ) -> Result<(), crate::error::Error> { |
| 327 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 291 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 328 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | 292 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 329 | let mut patch = PatchState::from_ref(repo, &ref_name, &id)?; | 293 | let patch = PatchState::from_ref(repo, &ref_name, &id)?; |
| 330 | |||
| 331 | // Auto-detect revision | ||
| 332 | auto_detect_and_update(repo, &ref_name, &mut patch, &sk)?; | ||
| 333 | 294 | ||
| 334 | let rev = if let Some(target) = target_revision { | 295 | let rev = if let Some(target) = target_revision { |
| 335 | if !patch.revisions.iter().any(|r| r.number == target) { | 296 | if !patch.revisions.iter().any(|r| r.number == target) { |
| @@ -380,8 +341,8 @@ pub fn review( | |||
| 380 | clock: 0, | 341 | clock: 0, |
| 381 | }; | 342 | }; |
| 382 | dag::append_event(repo, &ref_name, &close_event, &sk)?; | 343 | dag::append_event(repo, &ref_name, &close_event, &sk)?; |
| 383 | // Archive the ref | 344 | // Archive the whole subtree |
| 384 | if ref_name.starts_with("refs/collab/patches/") { | 345 | if ref_name.starts_with(state::PATCH_PREFIX) { |
| 385 | state::archive_patch_ref(repo, &id)?; | 346 | state::archive_patch_ref(repo, &id)?; |
| 386 | } | 347 | } |
| 387 | } | 348 | } |
| @@ -389,17 +350,31 @@ pub fn review( | |||
| 389 | Ok(()) | 350 | Ok(()) |
| 390 | } | 351 | } |
| 391 | 352 | ||
| 353 | /// Record a new revision from `branch`, or from `HEAD` when none is named. | ||
| 354 | /// | ||
| 355 | /// `patch revise` is now the only way a revision is recorded. It used to share | ||
| 356 | /// the job with an auto-detection pass that fired as a side effect of `patch | ||
| 357 | /// comment` and `patch review`, which manufactured revisions the user never | ||
| 358 | /// asked for, timestamped at the comment rather than at the code change. | ||
| 392 | pub fn revise( | 359 | pub fn revise( |
| 393 | repo: &Repository, | 360 | repo: &Repository, |
| 394 | id_prefix: &str, | 361 | id_prefix: &str, |
| 395 | body: Option<&str>, | 362 | body: Option<&str>, |
| 363 | branch: Option<&str>, | ||
| 396 | ) -> Result<(), crate::error::Error> { | 364 | ) -> Result<(), crate::error::Error> { |
| 397 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 365 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 398 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | 366 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 399 | let patch = PatchState::from_ref(repo, &ref_name, &id)?; | 367 | let patch = PatchState::from_ref(repo, &ref_name, &id)?; |
| 400 | 368 | ||
| 401 | // Resolve current branch tip | 369 | let tip_oid = match branch { |
| 402 | let tip_oid = patch.resolve_head(repo)?; | 370 | Some(name) => repo |
| 371 | .refname_to_id(&format!("refs/heads/{}", name)) | ||
| 372 | .map_err(|e| Error::Cmd(format!("branch '{}' not found: {}", name, e)))?, | ||
| 373 | None => repo | ||
| 374 | .head()? | ||
| 375 | .target() | ||
| 376 | .ok_or_else(|| Error::Cmd("cannot determine HEAD OID".to_string()))?, | ||
| 377 | }; | ||
| 403 | let tip_hex = tip_oid.to_string(); | 378 | let tip_hex = tip_oid.to_string(); |
| 404 | let last_commit = patch | 379 | let last_commit = patch |
| 405 | .revisions | 380 | .revisions |
| @@ -417,6 +392,8 @@ pub fn revise( | |||
| 417 | 392 | ||
| 418 | let commit = repo.find_commit(tip_oid)?; | 393 | let commit = repo.find_commit(tip_oid)?; |
| 419 | let tree_oid = commit.tree()?.id(); | 394 | let tree_oid = commit.tree()?.id(); |
| 395 | let base_oid = revision_base(repo, &patch.base_ref, tip_oid)?; | ||
| 396 | let number = patch.revisions.last().map(|r| r.number).unwrap_or(0) + 1; | ||
| 420 | let author = get_author(repo)?; | 397 | let author = get_author(repo)?; |
| 421 | let event = Event { | 398 | let event = Event { |
| 422 | timestamp: chrono::Utc::now().to_rfc3339(), | 399 | timestamp: chrono::Utc::now().to_rfc3339(), |
| @@ -425,10 +402,12 @@ pub fn revise( | |||
| 425 | commit: tip_hex, | 402 | commit: tip_hex, |
| 426 | tree: tree_oid.to_string(), | 403 | tree: tree_oid.to_string(), |
| 427 | body: body.map(|s| s.to_string()), | 404 | body: body.map(|s| s.to_string()), |
| 405 | base: Some(base_oid.to_string()), | ||
| 428 | }, | 406 | }, |
| 429 | clock: 0, | 407 | clock: 0, |
| 430 | }; | 408 | }; |
| 431 | dag::append_event(repo, &ref_name, &event, &sk)?; | 409 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 410 | write_revision_ref(repo, &ref_name, number, tip_oid)?; | ||
| 432 | Ok(()) | 411 | Ok(()) |
| 433 | } | 412 | } |
| 434 | 413 | ||
| @@ -678,8 +657,10 @@ pub fn checkout(repo: &Repository, id_prefix: &str) -> Result<(), crate::error:: | |||
| 678 | } | 657 | } |
| 679 | 658 | ||
| 680 | pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { | 659 | pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { |
| 681 | let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; | 660 | let (_ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; |
| 682 | repo.find_reference(&ref_name)?.delete()?; | 661 | // A patch is a subtree — its events ref plus every revision ref — so |
| 662 | // deleting one ref would leave the revisions behind. | ||
| 663 | state::delete_patch_refs(repo, &id)?; | ||
| 683 | Ok(id) | 664 | Ok(id) |
| 684 | } | 665 | } |
| 685 | 666 | ||
| @@ -696,8 +677,8 @@ pub fn close( | |||
| 696 | reason: reason.map(|s| s.to_string()), | 677 | reason: reason.map(|s| s.to_string()), |
| 697 | }, | 678 | }, |
| 698 | )?; | 679 | )?; |
| 699 | // Archive the ref (move to refs/collab/archive/patches/) | 680 | // Archive the whole subtree (move to refs/collab/archive/patches/) |
| 700 | if ref_name.starts_with("refs/collab/patches/") { | 681 | if ref_name.starts_with(state::PATCH_PREFIX) { |
| 701 | state::archive_patch_ref(repo, &id)?; | 682 | state::archive_patch_ref(repo, &id)?; |
| 702 | } | 683 | } |
| 703 | Ok(()) | 684 | Ok(()) |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -190,6 +190,13 @@ pub struct Revision { | |||
| 190 | pub tree: String, | 190 | pub tree: String, |
| 191 | pub body: Option<String>, | 191 | pub body: Option<String>, |
| 192 | pub timestamp: String, | 192 | pub timestamp: String, |
| 193 | /// Merge-base of the patch's base branch and `commit` when this revision | ||
| 194 | /// was recorded. `None` for revisions written before it was stored; those | ||
| 195 | /// fall back to recomputing it against the base branch as it stands now, | ||
| 196 | /// which is what every patch used to do. "Did the author rebase between | ||
| 197 | /// these two revisions" is exactly "did this change". | ||
| 198 | #[serde(default)] | ||
| 199 | pub base: Option<String>, | ||
| 193 | } | 200 | } |
| 194 | 201 | ||
| 195 | impl Revision { | 202 | impl Revision { |
| @@ -226,10 +233,10 @@ pub struct PatchState { | |||
| 226 | pub status: PatchStatus, | 233 | pub status: PatchStatus, |
| 227 | pub base_ref: String, | 234 | pub base_ref: String, |
| 228 | pub fixes: Option<String>, | 235 | pub fixes: Option<String>, |
| 236 | /// The branch the patch was created from, recorded for provenance only. | ||
| 237 | /// Nothing resolves through it any more: a patch is addressed by its own | ||
| 238 | /// revision refs, so an ephemeral or rewritten branch costs it nothing. | ||
| 229 | pub branch: String, | 239 | pub branch: String, |
| 230 | /// Base branch tip OID at patch creation time (None for old patches). | ||
| 231 | #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| 232 | pub base_commit: Option<String>, | ||
| 233 | pub comments: Vec<Comment>, | 240 | pub comments: Vec<Comment>, |
| 234 | pub inline_comments: Vec<InlineComment>, | 241 | pub inline_comments: Vec<InlineComment>, |
| 235 | pub reviews: Vec<Review>, | 242 | pub reviews: Vec<Review>, |
| @@ -468,21 +475,47 @@ impl IssueState { | |||
| 468 | } | 475 | } |
| 469 | 476 | ||
| 470 | impl PatchState { | 477 | impl PatchState { |
| 471 | /// Resolve the current head commit OID for this patch by looking up `refs/heads/{branch}`. | 478 | /// The commit the patch currently stands at: the latest revision whose |
| 479 | /// commit was recorded and whose objects are still present. A revision ref | ||
| 480 | /// keeps those objects reachable, so this no longer depends on any | ||
| 481 | /// `refs/heads/*` ref surviving a rebase. | ||
| 482 | /// | ||
| 483 | /// The branch fallback is for patches old enough to have recorded no | ||
| 484 | /// commits at all — either a branch name or, in the oldest shape, a raw OID | ||
| 485 | /// stored where the branch name now lives. | ||
| 472 | pub fn resolve_head(&self, repo: &Repository) -> Result<Oid, crate::error::Error> { | 486 | pub fn resolve_head(&self, repo: &Repository) -> Result<Oid, crate::error::Error> { |
| 473 | // Try parsing as a hex OID first (for patches created with head_commit) | 487 | for rev in self.revisions.iter().rev() { |
| 488 | if let Ok(oid) = Oid::from_str(&rev.commit) { | ||
| 489 | if repo.find_commit(oid).is_ok() { | ||
| 490 | return Ok(oid); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | } | ||
| 474 | if let Ok(oid) = Oid::from_str(&self.branch) { | 494 | if let Ok(oid) = Oid::from_str(&self.branch) { |
| 475 | if repo.find_commit(oid).is_ok() { | 495 | if repo.find_commit(oid).is_ok() { |
| 476 | return Ok(oid); | 496 | return Ok(oid); |
| 477 | } | 497 | } |
| 478 | } | 498 | } |
| 479 | // Fall back to branch name lookup | ||
| 480 | let ref_name = format!("refs/heads/{}", self.branch); | 499 | let ref_name = format!("refs/heads/{}", self.branch); |
| 481 | repo.refname_to_id(&ref_name).map_err(|e| { | 500 | repo.refname_to_id(&ref_name).map_err(|e| { |
| 482 | crate::error::Error::Cmd(format!("branch '{}' not found: {}", self.branch, e)) | 501 | crate::error::Error::Cmd(format!( |
| 502 | "patch has no recorded revision commit and branch '{}' was not found: {}", | ||
| 503 | self.branch, e | ||
| 504 | )) | ||
| 483 | }) | 505 | }) |
| 484 | } | 506 | } |
| 485 | 507 | ||
| 508 | /// Where the latest revision branched off the base branch. Revisions | ||
| 509 | /// written before `base` was stored fall back to recomputing the | ||
| 510 | /// merge-base against the base branch as it stands now, which is what | ||
| 511 | /// every patch used to do at display time. | ||
| 512 | fn latest_base(&self, repo: &Repository, base_tip: Oid, head: Oid) -> Option<Oid> { | ||
| 513 | match self.revisions.last().and_then(|r| r.base.as_deref()) { | ||
| 514 | Some(base) => Oid::from_str(base).ok(), | ||
| 515 | None => repo.merge_base(base_tip, head).ok(), | ||
| 516 | } | ||
| 517 | } | ||
| 518 | |||
| 486 | /// Compute staleness: how many commits the branch is ahead of base, | 519 | /// Compute staleness: how many commits the branch is ahead of base, |
| 487 | /// and how many commits the base is ahead of the branch. | 520 | /// and how many commits the base is ahead of the branch. |
| 488 | /// Returns (ahead, behind). | 521 | /// Returns (ahead, behind). |
| @@ -496,9 +529,15 @@ impl PatchState { | |||
| 496 | 529 | ||
| 497 | /// Auto-detect merge: if the patch is still Open and its head is | 530 | /// Auto-detect merge: if the patch is still Open and its head is |
| 498 | /// reachable from the base branch tip, the user merged it outside of | 531 | /// reachable from the base branch tip, the user merged it outside of |
| 499 | /// git-collab. We compare the current base tip to the base_commit | 532 | /// git-collab. We compare the current base tip to where the *latest* |
| 500 | /// recorded at creation time — if it has moved to include the patch | 533 | /// revision stood — if the base has moved to include the patch head, the |
| 501 | /// head, the patch is merged. | 534 | /// patch is merged. |
| 535 | /// | ||
| 536 | /// Anchoring this to the latest revision rather than to creation matters | ||
| 537 | /// twice over. It survives a rebase, which moves the base out from under | ||
| 538 | /// an earlier revision; and it no longer routes through | ||
| 539 | /// `refs/heads/<branch>`, which used to make detection no-op silently | ||
| 540 | /// whenever the source branch had been deleted or was never pushed. | ||
| 502 | fn check_auto_merge(&mut self, repo: &Repository) { | 541 | fn check_auto_merge(&mut self, repo: &Repository) { |
| 503 | if self.status != PatchStatus::Open { | 542 | if self.status != PatchStatus::Open { |
| 504 | return; | 543 | return; |
| @@ -510,11 +549,10 @@ impl PatchState { | |||
| 510 | let Ok(base_tip) = repo.refname_to_id(&base_ref) else { | 549 | let Ok(base_tip) = repo.refname_to_id(&base_ref) else { |
| 511 | return; | 550 | return; |
| 512 | }; | 551 | }; |
| 513 | let base_moved = self | 552 | let Some(base_at_revision) = self.latest_base(repo, base_tip, patch_head) else { |
| 514 | .base_commit | 553 | return; |
| 515 | .as_ref() | 554 | }; |
| 516 | .map(|bc| bc != &base_tip.to_string()) | 555 | let base_moved = base_at_revision != base_tip; |
| 517 | .unwrap_or(false); | ||
| 518 | let reachable = base_tip == patch_head | 556 | let reachable = base_tip == patch_head |
| 519 | || repo | 557 | || repo |
| 520 | .graph_descendant_of(base_tip, patch_head) | 558 | .graph_descendant_of(base_tip, patch_head) |
| @@ -587,6 +625,7 @@ impl PatchState { | |||
| 587 | tree, | 625 | tree, |
| 588 | body: None, | 626 | body: None, |
| 589 | timestamp: event.timestamp.clone(), | 627 | timestamp: event.timestamp.clone(), |
| 628 | base: base_commit, | ||
| 590 | }]; | 629 | }]; |
| 591 | state = Some(PatchState { | 630 | state = Some(PatchState { |
| 592 | id: id.to_string(), | 631 | id: id.to_string(), |
| @@ -596,7 +635,6 @@ impl PatchState { | |||
| 596 | base_ref, | 635 | base_ref, |
| 597 | fixes, | 636 | fixes, |
| 598 | branch, | 637 | branch, |
| 599 | base_commit, | ||
| 600 | comments: Vec::new(), | 638 | comments: Vec::new(), |
| 601 | inline_comments: Vec::new(), | 639 | inline_comments: Vec::new(), |
| 602 | reviews: Vec::new(), | 640 | reviews: Vec::new(), |
| @@ -606,7 +644,12 @@ impl PatchState { | |||
| 606 | author: event.author.clone(), | 644 | author: event.author.clone(), |
| 607 | }); | 645 | }); |
| 608 | } | 646 | } |
| 609 | Action::PatchRevision { commit, tree, body } => { | 647 | Action::PatchRevision { |
| 648 | commit, | ||
| 649 | tree, | ||
| 650 | body, | ||
| 651 | base, | ||
| 652 | } => { | ||
| 610 | if let Some(ref mut s) = state { | 653 | if let Some(ref mut s) = state { |
| 611 | // Dedup by commit OID — skip if already seen. Legacy | 654 | // Dedup by commit OID — skip if already seen. Legacy |
| 612 | // revisions recorded no commit, and collapsing those | 655 | // revisions recorded no commit, and collapsing those |
| @@ -622,6 +665,7 @@ impl PatchState { | |||
| 622 | tree, | 665 | tree, |
| 623 | body, | 666 | body, |
| 624 | timestamp: event.timestamp.clone(), | 667 | timestamp: event.timestamp.clone(), |
| 668 | base, | ||
| 625 | }); | 669 | }); |
| 626 | } | 670 | } |
| 627 | } | 671 | } |
| @@ -744,6 +788,39 @@ impl PatchState { | |||
| 744 | } | 788 | } |
| 745 | } | 789 | } |
| 746 | 790 | ||
| 791 | /// A patch owns a subtree of refs, not a single ref: | ||
| 792 | /// | ||
| 793 | /// ```text | ||
| 794 | /// refs/collab/patches/<id>/events the event DAG | ||
| 795 | /// refs/collab/patches/<id>/r/<n> revision n's commit, write-once | ||
| 796 | /// ``` | ||
| 797 | /// | ||
| 798 | /// The `events` suffix is forced — git will not let `<id>` be both a ref and a | ||
| 799 | /// directory — and the split is what lets a revision's objects stay reachable | ||
| 800 | /// once the branch that produced them has been rebased away. | ||
| 801 | pub const PATCH_PREFIX: &str = "refs/collab/patches/"; | ||
| 802 | pub const ARCHIVE_PATCH_PREFIX: &str = "refs/collab/archive/patches/"; | ||
| 803 | |||
| 804 | /// The event DAG ref for a patch in the active namespace. | ||
| 805 | pub fn patch_events_ref(id: &str) -> String { | ||
| 806 | format!("{}{}/events", PATCH_PREFIX, id) | ||
| 807 | } | ||
| 808 | |||
| 809 | /// The ref holding revision `n`'s commit, under whichever namespace the | ||
| 810 | /// patch's events ref lives in. | ||
| 811 | pub fn patch_revision_ref(events_ref: &str, n: u32) -> String { | ||
| 812 | let base = events_ref.strip_suffix("/events").unwrap_or(events_ref); | ||
| 813 | format!("{}/r/{}", base, n) | ||
| 814 | } | ||
| 815 | |||
| 816 | /// Split a ref name under a patch prefix into (id, suffix), where suffix is | ||
| 817 | /// `events` or `r/<n>`. Returns `None` for the pre-migration single-ref layout, | ||
| 818 | /// which has no suffix at all. | ||
| 819 | fn split_patch_ref<'a>(ref_name: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> { | ||
| 820 | let rest = ref_name.strip_prefix(prefix)?; | ||
| 821 | rest.split_once('/') | ||
| 822 | } | ||
| 823 | |||
| 747 | /// Enumerate collab refs under a given prefix, returning (ref_name, id) pairs. | 824 | /// Enumerate collab refs under a given prefix, returning (ref_name, id) pairs. |
| 748 | fn refs_under( | 825 | fn refs_under( |
| 749 | repo: &Repository, | 826 | repo: &Repository, |
| @@ -764,18 +841,48 @@ fn refs_under( | |||
| 764 | Ok(result) | 841 | Ok(result) |
| 765 | } | 842 | } |
| 766 | 843 | ||
| 844 | /// Enumerate the event DAG ref of every patch under a prefix, returning | ||
| 845 | /// (ref_name, id) pairs. Only `<id>/events` counts as a patch; the revision | ||
| 846 | /// refs beside it are commits, not DAGs. | ||
| 847 | fn patch_refs_under( | ||
| 848 | repo: &Repository, | ||
| 849 | prefix: &str, | ||
| 850 | ) -> Result<Vec<(String, String)>, crate::error::Error> { | ||
| 851 | let refs = repo.references_glob(&format!("{}*/events", prefix))?; | ||
| 852 | let mut result = Vec::new(); | ||
| 853 | for r in refs { | ||
| 854 | let r = r?; | ||
| 855 | let ref_name = r.name().unwrap_or_default().to_string(); | ||
| 856 | if let Some((id, "events")) = split_patch_ref(&ref_name, prefix) { | ||
| 857 | let id = id.to_string(); | ||
| 858 | result.push((ref_name, id)); | ||
| 859 | } | ||
| 860 | } | ||
| 861 | Ok(result) | ||
| 862 | } | ||
| 863 | |||
| 767 | fn collab_refs( | 864 | fn collab_refs( |
| 768 | repo: &Repository, | 865 | repo: &Repository, |
| 769 | kind: &str, | 866 | kind: &str, |
| 770 | ) -> Result<Vec<(String, String)>, crate::error::Error> { | 867 | ) -> Result<Vec<(String, String)>, crate::error::Error> { |
| 771 | refs_under(repo, &format!("refs/collab/{}/", kind)) | 868 | let prefix = format!("refs/collab/{}/", kind); |
| 869 | if kind == "patches" { | ||
| 870 | patch_refs_under(repo, &prefix) | ||
| 871 | } else { | ||
| 872 | refs_under(repo, &prefix) | ||
| 873 | } | ||
| 772 | } | 874 | } |
| 773 | 875 | ||
| 774 | fn collab_archive_refs( | 876 | fn collab_archive_refs( |
| 775 | repo: &Repository, | 877 | repo: &Repository, |
| 776 | kind: &str, | 878 | kind: &str, |
| 777 | ) -> Result<Vec<(String, String)>, crate::error::Error> { | 879 | ) -> Result<Vec<(String, String)>, crate::error::Error> { |
| 778 | refs_under(repo, &format!("refs/collab/archive/{}/", kind)) | 880 | let prefix = format!("refs/collab/archive/{}/", kind); |
| 881 | if kind == "patches" { | ||
| 882 | patch_refs_under(repo, &prefix) | ||
| 883 | } else { | ||
| 884 | refs_under(repo, &prefix) | ||
| 885 | } | ||
| 779 | } | 886 | } |
| 780 | 887 | ||
| 781 | /// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces. | 888 | /// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces. |
| @@ -839,6 +946,7 @@ pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::E | |||
| 839 | 946 | ||
| 840 | /// List active patch refs, excluding any that also have an archived ref. | 947 | /// List active patch refs, excluding any that also have an archived ref. |
| 841 | pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { | 948 | pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { |
| 949 | migrate_patch_layout(repo); | ||
| 842 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "patches")? | 950 | let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "patches")? |
| 843 | .into_iter() | 951 | .into_iter() |
| 844 | .map(|(_, id)| id) | 952 | .map(|(_, id)| id) |
| @@ -888,6 +996,7 @@ pub fn list_issues_with_archived( | |||
| 888 | pub fn list_patches_with_archived( | 996 | pub fn list_patches_with_archived( |
| 889 | repo: &Repository, | 997 | repo: &Repository, |
| 890 | ) -> Result<Vec<PatchState>, crate::error::Error> { | 998 | ) -> Result<Vec<PatchState>, crate::error::Error> { |
| 999 | migrate_patch_layout(repo); | ||
| 891 | let mut seen = std::collections::HashSet::new(); | 1000 | let mut seen = std::collections::HashSet::new(); |
| 892 | let mut items = Vec::new(); | 1001 | let mut items = Vec::new(); |
| 893 | 1002 | ||
| @@ -930,13 +1039,121 @@ pub fn unarchive_issue_ref(repo: &Repository, id: &str) -> Result<(), crate::err | |||
| 930 | Ok(()) | 1039 | Ok(()) |
| 931 | } | 1040 | } |
| 932 | 1041 | ||
| 933 | /// Move a patch ref from active to archive namespace. | 1042 | /// Every ref in a patch's subtree under `prefix`, as (ref_name, suffix) pairs. |
| 1043 | fn patch_subtree( | ||
| 1044 | repo: &Repository, | ||
| 1045 | prefix: &str, | ||
| 1046 | id: &str, | ||
| 1047 | ) -> Result<Vec<(String, String)>, crate::error::Error> { | ||
| 1048 | let refs = repo.references_glob(&format!("{}{}/*", prefix, id))?; | ||
| 1049 | let mut result = Vec::new(); | ||
| 1050 | for r in refs { | ||
| 1051 | let r = r?; | ||
| 1052 | let ref_name = r.name().unwrap_or_default().to_string(); | ||
| 1053 | if let Some((found, suffix)) = split_patch_ref(&ref_name, prefix) { | ||
| 1054 | if found == id { | ||
| 1055 | let suffix = suffix.to_string(); | ||
| 1056 | result.push((ref_name, suffix)); | ||
| 1057 | } | ||
| 1058 | } | ||
| 1059 | } | ||
| 1060 | Ok(result) | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | /// Move a patch's whole subtree from active to archive namespace. A patch is | ||
| 1064 | /// `<id>/events` plus every `<id>/r/<n>`, and moving only the events ref would | ||
| 1065 | /// leave a closed patch with no revisions to review. | ||
| 934 | pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { | 1066 | pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { |
| 935 | let old_ref = format!("refs/collab/patches/{}", id); | 1067 | for (old_ref, suffix) in patch_subtree(repo, PATCH_PREFIX, id)? { |
| 936 | let oid = repo.refname_to_id(&old_ref)?; | 1068 | let oid = repo.refname_to_id(&old_ref)?; |
| 937 | let new_ref = format!("refs/collab/archive/patches/{}", id); | 1069 | let new_ref = format!("{}{}/{}", ARCHIVE_PATCH_PREFIX, id, suffix); |
| 938 | repo.reference(&new_ref, oid, false, "archive patch")?; | 1070 | repo.reference(&new_ref, oid, true, "archive patch")?; |
| 939 | repo.find_reference(&old_ref)?.delete()?; | 1071 | repo.find_reference(&old_ref)?.delete()?; |
| 1072 | } | ||
| 1073 | Ok(()) | ||
| 1074 | } | ||
| 1075 | |||
| 1076 | /// Delete every ref belonging to a patch, in either namespace. | ||
| 1077 | pub fn delete_patch_refs(repo: &Repository, id: &str) -> Result<(), crate::error::Error> { | ||
| 1078 | for prefix in [PATCH_PREFIX, ARCHIVE_PATCH_PREFIX] { | ||
| 1079 | for (ref_name, _) in patch_subtree(repo, prefix, id)? { | ||
| 1080 | repo.find_reference(&ref_name)?.delete()?; | ||
| 1081 | } | ||
| 1082 | } | ||
| 1083 | Ok(()) | ||
| 1084 | } | ||
| 1085 | |||
| 1086 | /// Bring patches written in the pre-revision-refs layout — a single ref at | ||
| 1087 | /// `refs/collab/patches/<id>` — up to the current one. Called from every entry | ||
| 1088 | /// point that enumerates or resolves a patch, so an old repository migrates on | ||
| 1089 | /// first use rather than breaking. | ||
| 1090 | /// | ||
| 1091 | /// Failures are reported and skipped rather than propagated: one unmigratable | ||
| 1092 | /// patch must not make the whole list unreadable. | ||
| 1093 | pub fn migrate_patch_layout(repo: &Repository) { | ||
| 1094 | for prefix in [PATCH_PREFIX, ARCHIVE_PATCH_PREFIX] { | ||
| 1095 | let Ok(refs) = repo.references_glob(&format!("{}*", prefix)) else { | ||
| 1096 | continue; | ||
| 1097 | }; | ||
| 1098 | // Collect first: the migration rewrites refs, and mutating the ref | ||
| 1099 | // store while iterating it is not sound. | ||
| 1100 | let old_layout: Vec<String> = refs | ||
| 1101 | .filter_map(|r| { | ||
| 1102 | let name = r.ok()?.name()?.to_string(); | ||
| 1103 | // The new layout always has a suffix; the old one never does. | ||
| 1104 | match split_patch_ref(&name, prefix) { | ||
| 1105 | Some(_) => None, | ||
| 1106 | None => Some(name), | ||
| 1107 | } | ||
| 1108 | }) | ||
| 1109 | .collect(); | ||
| 1110 | for old_ref in old_layout { | ||
| 1111 | let id = old_ref.strip_prefix(prefix).unwrap_or_default().to_string(); | ||
| 1112 | if let Err(e) = migrate_one_patch(repo, prefix, &id, &old_ref) { | ||
| 1113 | eprintln!("warning: could not migrate patch {:.8}: {}", id, e); | ||
| 1114 | } | ||
| 1115 | } | ||
| 1116 | } | ||
| 1117 | } | ||
| 1118 | |||
| 1119 | fn migrate_one_patch( | ||
| 1120 | repo: &Repository, | ||
| 1121 | prefix: &str, | ||
| 1122 | id: &str, | ||
| 1123 | old_ref: &str, | ||
| 1124 | ) -> Result<(), crate::error::Error> { | ||
| 1125 | let oid = repo.refname_to_id(old_ref)?; | ||
| 1126 | |||
| 1127 | // Read the revision list off the old ref before touching anything, so a | ||
| 1128 | // patch whose DAG will not materialize is left exactly as it was found. | ||
| 1129 | let state = PatchState::from_ref_uncached(repo, old_ref, id)?; | ||
| 1130 | |||
| 1131 | // The old ref and the new subtree cannot coexist: git refuses to have | ||
| 1132 | // `<id>` be both a ref and a directory. Park the tip somewhere outside the | ||
| 1133 | // patch namespace so an interrupted migration is still recoverable, then | ||
| 1134 | // do the delete-and-recreate. | ||
| 1135 | let parked = format!("refs/collab/local/migrating/patches/{}", id); | ||
| 1136 | repo.reference(&parked, oid, true, "migrate patch: park tip")?; | ||
| 1137 | repo.find_reference(old_ref)?.delete()?; | ||
| 1138 | |||
| 1139 | let events_ref = format!("{}{}/events", prefix, id); | ||
| 1140 | repo.reference(&events_ref, oid, true, "migrate patch: events")?; | ||
| 1141 | |||
| 1142 | // Give every revision whose commit is still present a ref of its own. | ||
| 1143 | // Revisions whose objects were already lost to a force-push cannot be | ||
| 1144 | // recovered and keep the `""`-means-unknown convention. | ||
| 1145 | for rev in &state.revisions { | ||
| 1146 | let Ok(commit) = Oid::from_str(&rev.commit) else { | ||
| 1147 | continue; | ||
| 1148 | }; | ||
| 1149 | if repo.find_commit(commit).is_err() { | ||
| 1150 | continue; | ||
| 1151 | } | ||
| 1152 | let name = patch_revision_ref(&events_ref, rev.number); | ||
| 1153 | repo.reference(&name, commit, true, "migrate patch: revision")?; | ||
| 1154 | } | ||
| 1155 | |||
| 1156 | repo.find_reference(&parked)?.delete()?; | ||
| 940 | Ok(()) | 1157 | Ok(()) |
| 941 | } | 1158 | } |
| 942 | 1159 | ||
| @@ -953,6 +1170,7 @@ pub fn resolve_patch_ref( | |||
| 953 | repo: &Repository, | 1170 | repo: &Repository, |
| 954 | prefix: &str, | 1171 | prefix: &str, |
| 955 | ) -> Result<(String, String), crate::error::Error> { | 1172 | ) -> Result<(String, String), crate::error::Error> { |
| 1173 | migrate_patch_layout(repo); | ||
| 956 | resolve_ref(repo, "patches", "patch", prefix) | 1174 | resolve_ref(repo, "patches", "patch", prefix) |
| 957 | } | 1175 | } |
| 958 | 1176 | ||
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -612,6 +612,46 @@ fn push_refs(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult { | |||
| 612 | } | 612 | } |
| 613 | } | 613 | } |
| 614 | 614 | ||
| 615 | /// What a fetched sync ref is, and where it belongs locally. | ||
| 616 | enum SyncRef { | ||
| 617 | /// An event DAG. Verified, then reconciled against the local ref. | ||
| 618 | Events { local_ref: String }, | ||
| 619 | /// A patch revision: an ordinary source commit, not an event, so it | ||
| 620 | /// carries no signature to verify and is never merged. Write-once. | ||
| 621 | Revision { local_ref: String }, | ||
| 622 | } | ||
| 623 | |||
| 624 | /// Classify a fetched sync ref by the part of its name after the kind prefix, | ||
| 625 | /// returning the patch/issue id alongside it. Patches own a subtree — | ||
| 626 | /// `<id>/events` and `<id>/r/<n>` — while issues remain a single ref. | ||
| 627 | /// | ||
| 628 | /// A bare `<id>` under `patches` is the pre-revision-refs layout, still pushed | ||
| 629 | /// by peers that have not migrated; adopting it as the events ref migrates it | ||
| 630 | /// on the way in. | ||
| 631 | fn classify_sync_ref(kind: &str, rest: &str) -> Option<(String, SyncRef)> { | ||
| 632 | if kind != "patches" { | ||
| 633 | return Some(( | ||
| 634 | rest.to_string(), | ||
| 635 | SyncRef::Events { | ||
| 636 | local_ref: format!("refs/collab/{}/{}", kind, rest), | ||
| 637 | }, | ||
| 638 | )); | ||
| 639 | } | ||
| 640 | let (id, suffix) = match rest.split_once('/') { | ||
| 641 | Some((id, suffix)) => (id, suffix), | ||
| 642 | None => (rest, "events"), | ||
| 643 | }; | ||
| 644 | let local_ref = format!("refs/collab/patches/{}/{}", id, suffix); | ||
| 645 | let classified = if suffix == "events" { | ||
| 646 | SyncRef::Events { local_ref } | ||
| 647 | } else if suffix.starts_with("r/") { | ||
| 648 | SyncRef::Revision { local_ref } | ||
| 649 | } else { | ||
| 650 | return None; | ||
| 651 | }; | ||
| 652 | Some((id.to_string(), classified)) | ||
| 653 | } | ||
| 654 | |||
| 615 | /// Reconcile all refs of a given kind (issues or patches) from sync refs. | 655 | /// Reconcile all refs of a given kind (issues or patches) from sync refs. |
| 616 | fn reconcile_refs( | 656 | fn reconcile_refs( |
| 617 | repo: &Repository, | 657 | repo: &Repository, |
| @@ -620,13 +660,14 @@ fn reconcile_refs( | |||
| 620 | signing_key: &ed25519_dalek::SigningKey, | 660 | signing_key: &ed25519_dalek::SigningKey, |
| 621 | ) -> Result<(), Error> { | 661 | ) -> Result<(), Error> { |
| 622 | let sync_prefix = format!("refs/collab/sync/{}/", kind); | 662 | let sync_prefix = format!("refs/collab/sync/{}/", kind); |
| 623 | let sync_refs: Vec<(String, String)> = { | 663 | let sync_refs: Vec<(String, String, SyncRef)> = { |
| 624 | let refs = repo.references_glob(&format!("{}*", sync_prefix))?; | 664 | let refs = repo.references_glob(&format!("{}*", sync_prefix))?; |
| 625 | refs.filter_map(|r| { | 665 | refs.filter_map(|r| { |
| 626 | let r = r.ok()?; | 666 | let r = r.ok()?; |
| 627 | let name = r.name()?.to_string(); | 667 | let name = r.name()?.to_string(); |
| 628 | let id = name.strip_prefix(&sync_prefix)?.to_string(); | 668 | let rest = name.strip_prefix(&sync_prefix)?; |
| 629 | Some((name, id)) | 669 | let (id, classified) = classify_sync_ref(kind, rest)?; |
| 670 | Some((name, id, classified)) | ||
| 630 | }) | 671 | }) |
| 631 | .collect() | 672 | .collect() |
| 632 | }; | 673 | }; |
| @@ -635,13 +676,31 @@ fn reconcile_refs( | |||
| 635 | let trust_policy = trust::load_trust_policy(repo)?; | 676 | let trust_policy = trust::load_trust_policy(repo)?; |
| 636 | let mut warned_unconfigured = false; | 677 | let mut warned_unconfigured = false; |
| 637 | 678 | ||
| 638 | for (remote_ref, id) in &sync_refs { | 679 | for (remote_ref, id, classified) in &sync_refs { |
| 639 | // Validate the ref ID format before processing | 680 | // Validate the ref ID format before processing |
| 640 | if let Err(e) = validate_collab_ref_id(id) { | 681 | if let Err(e) = validate_collab_ref_id(id) { |
| 641 | eprintln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e); | 682 | eprintln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e); |
| 642 | continue; | 683 | continue; |
| 643 | } | 684 | } |
| 644 | 685 | ||
| 686 | // A revision ref points at the author's source commit. It carries no | ||
| 687 | // event signature, and it is write-once: an existing local revision is | ||
| 688 | // never overwritten by a remote one claiming the same number. | ||
| 689 | if let SyncRef::Revision { local_ref } = classified { | ||
| 690 | let oid = repo.refname_to_id(remote_ref)?; | ||
| 691 | match repo.refname_to_id(local_ref) { | ||
| 692 | Ok(existing) if existing != oid => eprintln!( | ||
| 693 | " Keeping local {} (remote claims {}); revisions are write-once", | ||
| 694 | local_ref, oid | ||
| 695 | ), | ||
| 696 | Ok(_) => {} | ||
| 697 | Err(_) => { | ||
| 698 | repo.reference(local_ref, oid, false, "sync: new revision from remote")?; | ||
| 699 | } | ||
| 700 | } | ||
| 701 | continue; | ||
| 702 | } | ||
| 703 | |||
| 645 | // Verify all commits on the remote ref before reconciling | 704 | // Verify all commits on the remote ref before reconciling |
| 646 | match signing::verify_ref(repo, remote_ref) { | 705 | match signing::verify_ref(repo, remote_ref) { |
| 647 | Ok(results) => { | 706 | Ok(results) => { |
| @@ -677,9 +736,11 @@ fn reconcile_refs( | |||
| 677 | } | 736 | } |
| 678 | } | 737 | } |
| 679 | 738 | ||
| 680 | let local_ref = format!("refs/collab/{}/{}", kind, id); | 739 | let SyncRef::Events { local_ref } = classified else { |
| 681 | if repo.refname_to_id(&local_ref).is_ok() { | 740 | continue; |
| 682 | match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) { | 741 | }; |
| 742 | if repo.refname_to_id(local_ref).is_ok() { | ||
| 743 | match dag::reconcile(repo, local_ref, remote_ref, author, signing_key) { | ||
| 683 | Ok((_oid, outcome)) => { | 744 | Ok((_oid, outcome)) => { |
| 684 | let action = match outcome { | 745 | let action = match outcome { |
| 685 | dag::ReconcileOutcome::AlreadyCurrent => "already current", | 746 | dag::ReconcileOutcome::AlreadyCurrent => "already current", |
| @@ -693,7 +754,7 @@ fn reconcile_refs( | |||
| 693 | } | 754 | } |
| 694 | } else { | 755 | } else { |
| 695 | let oid = repo.refname_to_id(remote_ref)?; | 756 | let oid = repo.refname_to_id(remote_ref)?; |
| 696 | repo.reference(&local_ref, oid, false, "sync: new from remote")?; | 757 | repo.reference(local_ref, oid, false, "sync: new from remote")?; |
| 697 | println!(" New {} {:.8} from remote", kind, id); | 758 | println!(" New {} {:.8} from remote", kind, id); |
| 698 | } | 759 | } |
| 699 | } | 760 | } |
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -80,7 +80,6 @@ mod tests { | |||
| 80 | base_ref: "main".into(), | 80 | base_ref: "main".into(), |
| 81 | fixes: None, | 81 | fixes: None, |
| 82 | branch: format!("feature/{}", id), | 82 | branch: format!("feature/{}", id), |
| 83 | base_commit: None, | ||
| 84 | comments: vec![], | 83 | comments: vec![], |
| 85 | inline_comments: vec![], | 84 | inline_comments: vec![], |
| 86 | reviews: vec![], | 85 | reviews: vec![], |
| @@ -277,7 +276,6 @@ mod tests { | |||
| 277 | base_ref: "main".to_string(), | 276 | base_ref: "main".to_string(), |
| 278 | fixes: None, | 277 | fixes: None, |
| 279 | branch: format!("feature/p{:07x}", i), | 278 | branch: format!("feature/p{:07x}", i), |
| 280 | base_commit: None, | ||
| 281 | comments: Vec::new(), | 279 | comments: Vec::new(), |
| 282 | inline_comments: Vec::new(), | 280 | inline_comments: Vec::new(), |
| 283 | reviews: Vec::new(), | 281 | reviews: Vec::new(), |
| @@ -1034,7 +1032,6 @@ mod tests { | |||
| 1034 | base_ref: "main".into(), | 1032 | base_ref: "main".into(), |
| 1035 | fixes: Some("i1".into()), | 1033 | fixes: Some("i1".into()), |
| 1036 | branch: "feature/fix-thing".into(), | 1034 | branch: "feature/fix-thing".into(), |
| 1037 | base_commit: None, | ||
| 1038 | comments: vec![crate::state::Comment { | 1035 | comments: vec![crate::state::Comment { |
| 1039 | author: make_author(), | 1036 | author: make_author(), |
| 1040 | body: "Thread comment".into(), | 1037 | body: "Thread comment".into(), |
| @@ -1063,6 +1060,7 @@ mod tests { | |||
| 1063 | tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(), | 1060 | tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(), |
| 1064 | body: None, | 1061 | body: None, |
| 1065 | timestamp: "2026-01-01T00:00:00Z".into(), | 1062 | timestamp: "2026-01-01T00:00:00Z".into(), |
| 1063 | base: None, | ||
| 1066 | }, | 1064 | }, |
| 1067 | Revision { | 1065 | Revision { |
| 1068 | number: 2, | 1066 | number: 2, |
| @@ -1070,6 +1068,7 @@ mod tests { | |||
| 1070 | tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(), | 1068 | tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(), |
| 1071 | body: Some("Addressed review comments".into()), | 1069 | body: Some("Addressed review comments".into()), |
| 1072 | timestamp: "2026-01-02T00:00:00Z".into(), | 1070 | timestamp: "2026-01-02T00:00:00Z".into(), |
| 1071 | base: None, | ||
| 1073 | }, | 1072 | }, |
| 1074 | ], | 1073 | ], |
| 1075 | created_at: "2026-01-01T00:00:00Z".into(), | 1074 | created_at: "2026-01-01T00:00:00Z".into(), |
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -71,7 +71,9 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str | |||
| 71 | detail.push_str(&format!("\n{}\n", body)); | 71 | detail.push_str(&format!("\n{}\n", body)); |
| 72 | } | 72 | } |
| 73 | } | 73 | } |
| 74 | Action::PatchRevision { commit, tree, body } => { | 74 | Action::PatchRevision { |
| 75 | commit, tree, body, .. | ||
| 76 | } => { | ||
| 75 | detail.push_str(&format!("\nCommit: {}\n", commit)); | 77 | detail.push_str(&format!("\nCommit: {}\n", commit)); |
| 76 | detail.push_str(&format!("Tree: {}\n", tree)); | 78 | detail.push_str(&format!("Tree: {}\n", tree)); |
| 77 | if let Some(b) = body { | 79 | if let Some(b) = body { |
tests/adversarial_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -550,6 +550,7 @@ fn arb_action() -> impl Strategy<Value = Action> { | |||
| 550 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), | 550 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), |
| 551 | tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), | 551 | tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), |
| 552 | body, | 552 | body, |
| 553 | base: None, | ||
| 553 | }), | 554 | }), |
| 554 | (".*",).prop_map(|(body,)| Action::PatchComment { body }), | 555 | (".*",).prop_map(|(body,)| Action::PatchComment { body }), |
| 555 | Just(Action::PatchMerge), | 556 | Just(Action::PatchMerge), |
tests/archive_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -142,8 +142,8 @@ fn test_close_patch_moves_ref_to_archive() { | |||
| 142 | // Old ref should be gone | 142 | // Old ref should be gone |
| 143 | assert!(repo.refname_to_id(&ref_name).is_err()); | 143 | assert!(repo.refname_to_id(&ref_name).is_err()); |
| 144 | 144 | ||
| 145 | // New archive ref should exist | 145 | // New archive ref should exist, under the patch's own subtree |
| 146 | let archive_ref = format!("refs/collab/archive/patches/{}", id); | 146 | let archive_ref = format!("refs/collab/archive/patches/{}/events", id); |
| 147 | assert!(repo.refname_to_id(&archive_ref).is_ok()); | 147 | assert!(repo.refname_to_id(&archive_ref).is_ok()); |
| 148 | 148 | ||
| 149 | // Should still be able to materialize state from archive ref | 149 | // Should still be able to materialize state from archive ref |
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -1088,7 +1088,15 @@ fn test_full_patch_review_cycle() { | |||
| 1088 | ); | 1088 | ); |
| 1089 | repo.git(&["checkout", "main"]); | 1089 | repo.git(&["checkout", "main"]); |
| 1090 | 1090 | ||
| 1091 | repo.run_ok(&["patch", "revise", &id, "-b", "Added documentation"]); | 1091 | repo.run_ok(&[ |
| 1092 | "patch", | ||
| 1093 | "revise", | ||
| 1094 | &id, | ||
| 1095 | "-b", | ||
| 1096 | "Added documentation", | ||
| 1097 | "-B", | ||
| 1098 | "feature", | ||
| 1099 | ]); | ||
| 1092 | 1100 | ||
| 1093 | // Approve | 1101 | // Approve |
| 1094 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]); | 1102 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]); |
tests/collab_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -340,6 +340,7 @@ fn test_patch_review_workflow() { | |||
| 340 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), | 340 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), |
| 341 | tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), | 341 | tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), |
| 342 | body: Some("Updated implementation".to_string()), | 342 | body: Some("Updated implementation".to_string()), |
| 343 | base: None, | ||
| 343 | }, | 344 | }, |
| 344 | clock: 0, | 345 | clock: 0, |
| 345 | }; | 346 | }; |
| @@ -659,7 +660,10 @@ fn add_commit_on_branch( | |||
| 659 | .unwrap() | 660 | .unwrap() |
| 660 | } | 661 | } |
| 661 | 662 | ||
| 662 | /// Create a branch-based patch using DAG primitives. | 663 | /// Create a branch-based patch using DAG primitives. The recorded commit is a |
| 664 | /// placeholder that is not in the object database, so these patches exercise | ||
| 665 | /// `resolve_head`'s legacy branch fallback — the path taken by patches old | ||
| 666 | /// enough to have recorded no usable revision commit. | ||
| 663 | fn create_branch_patch( | 667 | fn create_branch_patch( |
| 664 | repo: &git2::Repository, | 668 | repo: &git2::Repository, |
| 665 | author: &Author, | 669 | author: &Author, |
| @@ -685,7 +689,7 @@ fn create_branch_patch( | |||
| 685 | }; | 689 | }; |
| 686 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | 690 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); |
| 687 | let id = oid.to_string(); | 691 | let id = oid.to_string(); |
| 688 | let patch_ref = format!("refs/collab/patches/{}", id); | 692 | let patch_ref = git_collab::state::patch_events_ref(&id); |
| 689 | repo.reference(&patch_ref, oid, false, "test branch patch") | 693 | repo.reference(&patch_ref, oid, false, "test branch patch") |
| 690 | .unwrap(); | 694 | .unwrap(); |
| 691 | (patch_ref, id) | 695 | (patch_ref, id) |
| @@ -697,6 +701,8 @@ fn create_branch_patch( | |||
| 697 | 701 | ||
| 698 | #[test] | 702 | #[test] |
| 699 | fn test_resolve_head_branch_based() { | 703 | fn test_resolve_head_branch_based() { |
| 704 | // Legacy fallback: with no usable revision commit recorded, the head is | ||
| 705 | // still read off `refs/heads/<branch>` and still follows it. | ||
| 700 | let tmp = TempDir::new().unwrap(); | 706 | let tmp = TempDir::new().unwrap(); |
| 701 | let repo = init_repo(tmp.path(), &alice()); | 707 | let repo = init_repo(tmp.path(), &alice()); |
| 702 | 708 | ||
| @@ -727,6 +733,8 @@ fn test_resolve_head_branch_based() { | |||
| 727 | 733 | ||
| 728 | #[test] | 734 | #[test] |
| 729 | fn test_resolve_head_deleted_branch_error() { | 735 | fn test_resolve_head_deleted_branch_error() { |
| 736 | // Only for patches on the legacy fallback: one with a revision ref has | ||
| 737 | // somewhere else to look, and no longer cares that the branch is gone. | ||
| 730 | let tmp = TempDir::new().unwrap(); | 738 | let tmp = TempDir::new().unwrap(); |
| 731 | let repo = init_repo(tmp.path(), &alice()); | 739 | let repo = init_repo(tmp.path(), &alice()); |
| 732 | 740 | ||
| @@ -815,7 +823,7 @@ fn test_create_patch_from_branch_populates_branch_field() { | |||
| 815 | add_commit_on_branch(&repo, "feature/foo", "feat.rs", b"feat"); | 823 | add_commit_on_branch(&repo, "feature/foo", "feat.rs", b"feat"); |
| 816 | 824 | ||
| 817 | let id = patch::create(&repo, "My patch", "desc", "main", "feature/foo", None).unwrap(); | 825 | let id = patch::create(&repo, "My patch", "desc", "main", "feature/foo", None).unwrap(); |
| 818 | let patch_ref = format!("refs/collab/patches/{}", id); | 826 | let patch_ref = git_collab::state::patch_events_ref(&id); |
| 819 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | 827 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); |
| 820 | 828 | ||
| 821 | assert_eq!(state.branch, "feature/foo"); | 829 | assert_eq!(state.branch, "feature/foo"); |
| @@ -825,8 +833,10 @@ fn test_create_patch_from_branch_populates_branch_field() { | |||
| 825 | } | 833 | } |
| 826 | 834 | ||
| 827 | #[test] | 835 | #[test] |
| 828 | fn test_create_duplicate_patch_for_same_branch_returns_error() { | 836 | fn test_create_duplicate_patch_for_same_commit_returns_error() { |
| 829 | // T011: creating a duplicate patch for same branch returns error | 837 | // T011: a second patch for a commit an open patch already stands at is a |
| 838 | // duplicate. Detection is by commit, not by branch name — a branch name is | ||
| 839 | // no longer part of a patch's identity. | ||
| 830 | let cfg = ScopedTestConfig::new(); | 840 | let cfg = ScopedTestConfig::new(); |
| 831 | cfg.ensure_signing_key(); | 841 | cfg.ensure_signing_key(); |
| 832 | let tmp = TempDir::new().unwrap(); | 842 | let tmp = TempDir::new().unwrap(); |
| @@ -835,17 +845,20 @@ fn test_create_duplicate_patch_for_same_branch_returns_error() { | |||
| 835 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | 845 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); |
| 836 | repo.branch("feature/dup", &repo.find_commit(tip).unwrap(), false) | 846 | repo.branch("feature/dup", &repo.find_commit(tip).unwrap(), false) |
| 837 | .unwrap(); | 847 | .unwrap(); |
| 848 | // A second branch name for the very same commit, as two worktrees produce. | ||
| 849 | repo.branch("worktree-agent-dup", &repo.find_commit(tip).unwrap(), false) | ||
| 850 | .unwrap(); | ||
| 838 | 851 | ||
| 839 | // First creation should succeed | 852 | // First creation should succeed |
| 840 | patch::create(&repo, "First", "", "main", "feature/dup", None).unwrap(); | 853 | let id = patch::create(&repo, "First", "", "main", "feature/dup", None).unwrap(); |
| 841 | 854 | ||
| 842 | // Second creation for same branch should fail | 855 | let result = patch::create(&repo, "Second", "", "main", "worktree-agent-dup", None); |
| 843 | let result = patch::create(&repo, "Second", "", "main", "feature/dup", None); | 856 | assert!(result.is_err(), "duplicate commit patch should fail"); |
| 844 | assert!(result.is_err(), "duplicate branch patch should fail"); | ||
| 845 | let err_msg = result.unwrap_err().to_string(); | 857 | let err_msg = result.unwrap_err().to_string(); |
| 846 | assert!( | 858 | assert!( |
| 847 | err_msg.contains("feature/dup"), | 859 | err_msg.contains(&id[..8]), |
| 848 | "error should mention the branch name" | 860 | "error should name the existing patch, got: {}", |
| 861 | err_msg | ||
| 849 | ); | 862 | ); |
| 850 | } | 863 | } |
| 851 | 864 | ||
| @@ -991,7 +1004,7 @@ fn test_resolve_head_with_oid_string() { | |||
| 991 | }; | 1004 | }; |
| 992 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | 1005 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); |
| 993 | let id = oid.to_string(); | 1006 | let id = oid.to_string(); |
| 994 | let ref_name = format!("refs/collab/patches/{}", id); | 1007 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 995 | repo.reference(&ref_name, oid, false, "test").unwrap(); | 1008 | repo.reference(&ref_name, oid, false, "test").unwrap(); |
| 996 | 1009 | ||
| 997 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | 1010 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); |
| @@ -1023,7 +1036,7 @@ fn test_auto_detect_merged_patch_via_git_merge() { | |||
| 1023 | let id = patch::create(&repo, "Auto-detect test", "", "main", "feat", None).unwrap(); | 1036 | let id = patch::create(&repo, "Auto-detect test", "", "main", "feat", None).unwrap(); |
| 1024 | 1037 | ||
| 1025 | // Verify it's open | 1038 | // Verify it's open |
| 1026 | let ref_name = format!("refs/collab/patches/{}", id); | 1039 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 1027 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | 1040 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); |
| 1028 | assert_eq!(state.status, PatchStatus::Open); | 1041 | assert_eq!(state.status, PatchStatus::Open); |
| 1029 | 1042 | ||
| @@ -1065,7 +1078,7 @@ fn test_auto_detect_merged_patch_deleted_branch() { | |||
| 1065 | .unwrap(); | 1078 | .unwrap(); |
| 1066 | 1079 | ||
| 1067 | // Should not crash, patch stays Open (can't verify merge without the branch) | 1080 | // Should not crash, patch stays Open (can't verify merge without the branch) |
| 1068 | let ref_name = format!("refs/collab/patches/{}", id); | 1081 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 1069 | let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); | 1082 | let state = PatchState::from_ref_uncached(&repo, &ref_name, &id).unwrap(); |
| 1070 | assert_eq!(state.status, PatchStatus::Open); | 1083 | assert_eq!(state.status, PatchStatus::Open); |
| 1071 | } | 1084 | } |
| @@ -1088,7 +1101,7 @@ fn test_cache_does_not_defeat_auto_detect_merge() { | |||
| 1088 | 1101 | ||
| 1089 | // Create the patch via the high-level API (records base_commit) | 1102 | // Create the patch via the high-level API (records base_commit) |
| 1090 | let id = patch::create(&repo, "Cache merge test", "", "main", "feat", None).unwrap(); | 1103 | let id = patch::create(&repo, "Cache merge test", "", "main", "feat", None).unwrap(); |
| 1091 | let ref_name = format!("refs/collab/patches/{}", id); | 1104 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 1092 | 1105 | ||
| 1093 | // First call: populates cache, status should be Open | 1106 | // First call: populates cache, status should be Open |
| 1094 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | 1107 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); |
| @@ -1109,33 +1122,40 @@ fn test_cache_does_not_defeat_auto_detect_merge() { | |||
| 1109 | } | 1122 | } |
| 1110 | 1123 | ||
| 1111 | // --------------------------------------------------------------------------- | 1124 | // --------------------------------------------------------------------------- |
| 1112 | // Phase 6: US4 — Implicit Revision (T027) | 1125 | // The head is pinned to the recorded revision, not to the branch |
| 1113 | // --------------------------------------------------------------------------- | 1126 | // --------------------------------------------------------------------------- |
| 1114 | 1127 | ||
| 1115 | #[test] | 1128 | #[test] |
| 1116 | fn test_branch_push_auto_reflects_in_patch() { | 1129 | fn test_branch_movement_does_not_move_the_patch_head() { |
| 1117 | // T027: after pushing a commit to the branch, patch show reflects the new commit | 1130 | // A patch stands at the revision its author recorded. Commits landing on |
| 1131 | // the branch afterwards are not part of it until `patch revise` says so — | ||
| 1132 | // which is also what keeps a reviewer looking at what they were shown. | ||
| 1133 | let cfg = ScopedTestConfig::new(); | ||
| 1134 | cfg.ensure_signing_key(); | ||
| 1118 | let tmp = TempDir::new().unwrap(); | 1135 | let tmp = TempDir::new().unwrap(); |
| 1119 | let repo = init_repo(tmp.path(), &alice()); | 1136 | let repo = init_repo(tmp.path(), &alice()); |
| 1120 | make_initial_commit(&repo, "main"); | 1137 | make_initial_commit(&repo, "main"); |
| 1121 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | 1138 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); |
| 1122 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false) | 1139 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false) |
| 1123 | .unwrap(); | 1140 | .unwrap(); |
| 1124 | add_commit_on_branch(&repo, "feat", "v1.rs", b"version 1"); | 1141 | let recorded = add_commit_on_branch(&repo, "feat", "v1.rs", b"version 1"); |
| 1125 | 1142 | ||
| 1126 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Auto revise", "feat", "main"); | 1143 | let id = patch::create(&repo, "Pinned head", "", "main", "feat", None).unwrap(); |
| 1144 | let patch_ref = git_collab::state::patch_events_ref(&id); | ||
| 1127 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | 1145 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); |
| 1128 | let head1 = state.resolve_head(&repo).unwrap(); | 1146 | assert_eq!(state.resolve_head(&repo).unwrap(), recorded); |
| 1129 | 1147 | ||
| 1130 | // Add another commit to the branch (simulating a push) | 1148 | // Add another commit to the branch. The patch must not silently absorb it. |
| 1131 | let new_tip = add_commit_on_branch(&repo, "feat", "v2.rs", b"version 2"); | 1149 | let new_tip = add_commit_on_branch(&repo, "feat", "v2.rs", b"version 2"); |
| 1150 | assert_ne!(new_tip, recorded); | ||
| 1132 | 1151 | ||
| 1133 | // Re-read the state and resolve head — should see the new commit | 1152 | let state2 = PatchState::from_ref_uncached(&repo, &patch_ref, &id).unwrap(); |
| 1134 | let state2 = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | 1153 | assert_eq!( |
| 1135 | let head2 = state2.resolve_head(&repo).unwrap(); | 1154 | state2.resolve_head(&repo).unwrap(), |
| 1136 | 1155 | recorded, | |
| 1137 | assert_ne!(head1, head2, "head should change after branch update"); | 1156 | "the head follows the recorded revision, not the branch" |
| 1138 | assert_eq!(head2, new_tip, "head should be the new tip"); | 1157 | ); |
| 1158 | assert_eq!(state2.revisions.len(), 1); | ||
| 1139 | } | 1159 | } |
| 1140 | 1160 | ||
| 1141 | // --------------------------------------------------------------------------- | 1161 | // --------------------------------------------------------------------------- |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -359,7 +359,7 @@ pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, | |||
| 359 | }; | 359 | }; |
| 360 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | 360 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); |
| 361 | let id = oid.to_string(); | 361 | let id = oid.to_string(); |
| 362 | let ref_name = format!("refs/collab/patches/{}", id); | 362 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 363 | repo.reference(&ref_name, oid, false, "test patch").unwrap(); | 363 | repo.reference(&ref_name, oid, false, "test patch").unwrap(); |
| 364 | (ref_name, id) | 364 | (ref_name, id) |
| 365 | } | 365 | } |
tests/legacy_patch_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -144,9 +144,15 @@ fn legacy_patch_revise_deserializes_as_a_revision() { | |||
| 144 | .expect("legacy patch.revise must deserialize"); | 144 | .expect("legacy patch.revise must deserialize"); |
| 145 | 145 | ||
| 146 | match event.action { | 146 | match event.action { |
| 147 | Action::PatchRevision { commit, tree, body } => { | 147 | Action::PatchRevision { |
| 148 | commit, | ||
| 149 | tree, | ||
| 150 | body, | ||
| 151 | base, | ||
| 152 | } => { | ||
| 148 | assert!(commit.is_empty()); | 153 | assert!(commit.is_empty()); |
| 149 | assert!(tree.is_empty()); | 154 | assert!(tree.is_empty()); |
| 155 | assert!(base.is_none(), "this generation recorded no base"); | ||
| 150 | assert_eq!( | 156 | assert_eq!( |
| 151 | body.as_deref(), | 157 | body.as_deref(), |
| 152 | Some("Address review: extract auto_detect_and_update helper to eliminate 3x repeated pattern") | 158 | Some("Address review: extract auto_detect_and_update helper to eliminate 3x repeated pattern") |
tests/review_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -311,9 +311,11 @@ fn cli_re_approving_new_revision_is_allowed() { | |||
| 311 | 311 | ||
| 312 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM rev 1"]); | 312 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM rev 1"]); |
| 313 | 313 | ||
| 314 | // New commit → new revision auto-detected on next review | 314 | // New commit, recorded as a revision by the author — nothing else records |
| 315 | // one on their behalf. | ||
| 315 | repo.git(&["checkout", "feat-reapprove"]); | 316 | repo.git(&["checkout", "feat-reapprove"]); |
| 316 | repo.commit_file("v2.txt", "v2", "second commit"); | 317 | repo.commit_file("v2.txt", "v2", "second commit"); |
| 318 | repo.run_ok(&["patch", "revise", &id]); | ||
| 317 | repo.git(&["checkout", "main"]); | 319 | repo.git(&["checkout", "main"]); |
| 318 | 320 | ||
| 319 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM rev 2"]); | 321 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM rev 2"]); |
tests/revision_refs_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,630 @@ | |||
| 1 | //! Patches carried as revision refs rather than branches. | ||
| 2 | //! | ||
| 3 | //! Each revision is an immutable ref in the patch's own namespace: | ||
| 4 | //! | ||
| 5 | //! refs/collab/patches/<id>/events the event DAG | ||
| 6 | //! refs/collab/patches/<id>/r/<n> revision n's commit, write-once | ||
| 7 | //! | ||
| 8 | //! so a patch and every revision it ever had travel under the refspecs `sync` | ||
| 9 | //! already uses, and nothing depends on a `refs/heads/*` ref surviving. | ||
| 10 | |||
| 11 | mod common; | ||
| 12 | |||
| 13 | use common::{write_raw_event, TestRepo}; | ||
| 14 | use serde_json::json; | ||
| 15 | use tempfile::TempDir; | ||
| 16 | |||
| 17 | /// Every ref under the patch namespace, sorted, as `git for-each-ref` sees it. | ||
| 18 | fn patch_refs(repo: &TestRepo) -> Vec<String> { | ||
| 19 | let mut refs: Vec<String> = repo | ||
| 20 | .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]) | ||
| 21 | .lines() | ||
| 22 | .map(str::to_string) | ||
| 23 | .collect(); | ||
| 24 | refs.sort(); | ||
| 25 | refs | ||
| 26 | } | ||
| 27 | |||
| 28 | fn ref_target(repo: &TestRepo, name: &str) -> Option<String> { | ||
| 29 | let out = repo.git(&["for-each-ref", "--format=%(objectname)", name]); | ||
| 30 | let trimmed = out.trim(); | ||
| 31 | if trimmed.is_empty() { | ||
| 32 | None | ||
| 33 | } else { | ||
| 34 | Some(trimmed.to_string()) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | fn show_json(repo: &TestRepo, id: &str) -> serde_json::Value { | ||
| 39 | let out = repo.run_ok(&["patch", "show", id, "--json"]); | ||
| 40 | serde_json::from_str(&out).unwrap() | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Create a patch on a fresh branch holding one commit. Returns (short id, full | ||
| 44 | /// id, tip commit). | ||
| 45 | fn patch_on_branch(repo: &TestRepo, branch: &str, file: &str) -> (String, String, String) { | ||
| 46 | repo.git(&["checkout", "-b", branch]); | ||
| 47 | let tip = repo.commit_file(file, "v1", &format!("add {}", file)); | ||
| 48 | let out = repo.run_ok(&["patch", "create", "-t", branch, "-B", branch]); | ||
| 49 | let short = out | ||
| 50 | .trim() | ||
| 51 | .strip_prefix("Created patch ") | ||
| 52 | .unwrap_or_else(|| panic!("unexpected create output: {}", out)) | ||
| 53 | .to_string(); | ||
| 54 | let full = full_id(repo, &short); | ||
| 55 | (short, full, tip) | ||
| 56 | } | ||
| 57 | |||
| 58 | /// Expand a short patch id to the full 40-char id via the ref listing. | ||
| 59 | fn full_id(repo: &TestRepo, short: &str) -> String { | ||
| 60 | for name in patch_refs(repo) { | ||
| 61 | if let Some(rest) = name.strip_prefix("refs/collab/patches/") { | ||
| 62 | let id = rest.split('/').next().unwrap_or_default(); | ||
| 63 | if id.starts_with(short) { | ||
| 64 | return id.to_string(); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | panic!("no patch ref matching {}", short); | ||
| 69 | } | ||
| 70 | |||
| 71 | // =========================================================================== | ||
| 72 | // Ref layout | ||
| 73 | // =========================================================================== | ||
| 74 | |||
| 75 | #[test] | ||
| 76 | fn patch_create_writes_an_events_ref_and_revision_one() { | ||
| 77 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 78 | let (_short, id, tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 79 | |||
| 80 | let refs = patch_refs(&repo); | ||
| 81 | assert!( | ||
| 82 | refs.contains(&format!("refs/collab/patches/{}/events", id)), | ||
| 83 | "expected an events ref, got {:?}", | ||
| 84 | refs | ||
| 85 | ); | ||
| 86 | assert!( | ||
| 87 | refs.contains(&format!("refs/collab/patches/{}/r/1", id)), | ||
| 88 | "expected r/1, got {:?}", | ||
| 89 | refs | ||
| 90 | ); | ||
| 91 | assert!( | ||
| 92 | !refs.contains(&format!("refs/collab/patches/{}", id)), | ||
| 93 | "the bare <id> ref must be gone — git cannot have it be both a ref and \ | ||
| 94 | a directory: {:?}", | ||
| 95 | refs | ||
| 96 | ); | ||
| 97 | assert_eq!( | ||
| 98 | ref_target(&repo, &format!("refs/collab/patches/{}/r/1", id)).as_deref(), | ||
| 99 | Some(tip.as_str()), | ||
| 100 | "r/1 must point at the commit the patch was created from" | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | #[test] | ||
| 105 | fn revise_writes_the_next_revision_ref_and_leaves_earlier_ones_alone() { | ||
| 106 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 107 | let (short, id, r1_commit) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 108 | |||
| 109 | let r2_commit = repo.commit_file("b.txt", "v2", "second commit"); | ||
| 110 | repo.run_ok(&["patch", "revise", &short, "-b", "addressed review"]); | ||
| 111 | |||
| 112 | assert_eq!( | ||
| 113 | ref_target(&repo, &format!("refs/collab/patches/{}/r/1", id)).as_deref(), | ||
| 114 | Some(r1_commit.as_str()), | ||
| 115 | "r/1 is write-once and must not move" | ||
| 116 | ); | ||
| 117 | assert_eq!( | ||
| 118 | ref_target(&repo, &format!("refs/collab/patches/{}/r/2", id)).as_deref(), | ||
| 119 | Some(r2_commit.as_str()) | ||
| 120 | ); | ||
| 121 | |||
| 122 | let json = show_json(&repo, &short); | ||
| 123 | assert_eq!(json["revisions"].as_array().unwrap().len(), 2); | ||
| 124 | } | ||
| 125 | |||
| 126 | #[test] | ||
| 127 | fn a_revision_ref_keeps_its_commit_alive_after_the_branch_is_rewritten() { | ||
| 128 | // The live defect: a rebase-and-force-push used to leave revision 1's | ||
| 129 | // commit reachable from no ref at all, so `git gc` was entitled to delete | ||
| 130 | // the objects that `patch log`, `patch diff --revision 1` and every | ||
| 131 | // revision-anchored inline comment point at. | ||
| 132 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 133 | let (short, id, r1_commit) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 134 | |||
| 135 | // Rewrite the branch out from under the patch, exactly as a rebase does. | ||
| 136 | repo.git(&["commit", "--amend", "-m", "rewritten"]); | ||
| 137 | let rewritten = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 138 | assert_ne!(rewritten, r1_commit); | ||
| 139 | |||
| 140 | let containing = repo.git(&[ | ||
| 141 | "for-each-ref", | ||
| 142 | "--format=%(refname)", | ||
| 143 | "--contains", | ||
| 144 | &r1_commit, | ||
| 145 | "refs/heads/", | ||
| 146 | ]); | ||
| 147 | assert!( | ||
| 148 | containing.trim().is_empty(), | ||
| 149 | "the rewrite should have detached r1's commit from every branch, got {}", | ||
| 150 | containing | ||
| 151 | ); | ||
| 152 | |||
| 153 | assert_eq!( | ||
| 154 | ref_target(&repo, &format!("refs/collab/patches/{}/r/1", id)).as_deref(), | ||
| 155 | Some(r1_commit.as_str()), | ||
| 156 | "the revision ref is what keeps the commit reachable" | ||
| 157 | ); | ||
| 158 | // And it is still usable: a historical diff resolves the objects. | ||
| 159 | let diff = repo.run_ok(&["patch", "diff", &short, "--revision", "1"]); | ||
| 160 | assert!(diff.contains("a.txt"), "r1 diff lost its content: {}", diff); | ||
| 161 | } | ||
| 162 | |||
| 163 | #[test] | ||
| 164 | fn nested_revision_refs_are_visible_to_the_patch_glob() { | ||
| 165 | // The design's central claim is that the existing `refs/collab/patches/*` | ||
| 166 | // patterns carry revision refs unchanged. That only holds if the glob | ||
| 167 | // crosses `/`, which is what enumeration and `sync`'s push list rely on. | ||
| 168 | let tmp = TempDir::new().unwrap(); | ||
| 169 | let repo = git2::Repository::init(tmp.path()).unwrap(); | ||
| 170 | let sig = git2::Signature::now("A", "a@example.com").unwrap(); | ||
| 171 | let tree_oid = repo.treebuilder(None).unwrap().write().unwrap(); | ||
| 172 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 173 | let oid = repo | ||
| 174 | .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[]) | ||
| 175 | .unwrap(); | ||
| 176 | repo.reference("refs/collab/patches/abc/events", oid, false, "t") | ||
| 177 | .unwrap(); | ||
| 178 | repo.reference("refs/collab/patches/abc/r/1", oid, false, "t") | ||
| 179 | .unwrap(); | ||
| 180 | |||
| 181 | let names: Vec<String> = repo | ||
| 182 | .references_glob("refs/collab/patches/*") | ||
| 183 | .unwrap() | ||
| 184 | .filter_map(|r| r.ok()?.name().map(str::to_string)) | ||
| 185 | .collect(); | ||
| 186 | assert!( | ||
| 187 | names.contains(&"refs/collab/patches/abc/r/1".to_string()), | ||
| 188 | "refs/collab/patches/* must match nested refs, got {:?}", | ||
| 189 | names | ||
| 190 | ); | ||
| 191 | } | ||
| 192 | |||
| 193 | // =========================================================================== | ||
| 194 | // Revision recording is explicit | ||
| 195 | // =========================================================================== | ||
| 196 | |||
| 197 | #[test] | ||
| 198 | fn commenting_does_not_manufacture_a_revision() { | ||
| 199 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 200 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 201 | repo.commit_file("b.txt", "v2", "work the user has not submitted"); | ||
| 202 | |||
| 203 | repo.run_ok(&["patch", "comment", &short, "-b", "a thought"]); | ||
| 204 | |||
| 205 | let json = show_json(&repo, &short); | ||
| 206 | assert_eq!( | ||
| 207 | json["revisions"].as_array().unwrap().len(), | ||
| 208 | 1, | ||
| 209 | "only `patch revise` records a revision" | ||
| 210 | ); | ||
| 211 | } | ||
| 212 | |||
| 213 | #[test] | ||
| 214 | fn reviewing_does_not_manufacture_a_revision() { | ||
| 215 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 216 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 217 | repo.commit_file("b.txt", "v2", "work the reviewer cannot see"); | ||
| 218 | |||
| 219 | repo.run_ok(&["patch", "review", &short, "-v", "comment", "-b", "noted"]); | ||
| 220 | |||
| 221 | let json = show_json(&repo, &short); | ||
| 222 | assert_eq!(json["revisions"].as_array().unwrap().len(), 1); | ||
| 223 | assert_eq!(json["reviews"][0]["revision"], 1); | ||
| 224 | } | ||
| 225 | |||
| 226 | #[test] | ||
| 227 | fn revise_reads_head_by_default_and_a_named_branch_on_request() { | ||
| 228 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 229 | let (short, _id, _tip) = patch_on_branch(&repo, "wt-1", "a.txt"); | ||
| 230 | |||
| 231 | // A second worktree-style branch, with a name the patch has never heard of. | ||
| 232 | repo.git(&["checkout", "-b", "wt-2"]); | ||
| 233 | let r2 = repo.commit_file("b.txt", "v2", "more work"); | ||
| 234 | repo.git(&["checkout", "main"]); | ||
| 235 | |||
| 236 | repo.run_ok(&["patch", "revise", &short, "-B", "wt-2"]); | ||
| 237 | |||
| 238 | let json = show_json(&repo, &short); | ||
| 239 | let revisions = json["revisions"].as_array().unwrap(); | ||
| 240 | assert_eq!(revisions.len(), 2); | ||
| 241 | assert_eq!(revisions[1]["commit"], r2); | ||
| 242 | } | ||
| 243 | |||
| 244 | // =========================================================================== | ||
| 245 | // Identity is declared, not derived from a branch name | ||
| 246 | // =========================================================================== | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn a_second_patch_for_the_same_commit_is_rejected_by_id_not_by_branch_name() { | ||
| 250 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 251 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 252 | |||
| 253 | let err = repo.run_err(&["patch", "create", "-t", "again", "-B", "feat"]); | ||
| 254 | assert!( | ||
| 255 | err.contains(&short), | ||
| 256 | "the duplicate error should name the existing patch, got {}", | ||
| 257 | err | ||
| 258 | ); | ||
| 259 | } | ||
| 260 | |||
| 261 | #[test] | ||
| 262 | fn two_branches_with_generated_names_get_their_own_patches() { | ||
| 263 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 264 | let (first, _id, _tip) = patch_on_branch(&repo, "worktree-agent-aaaa", "a.txt"); | ||
| 265 | |||
| 266 | repo.git(&["checkout", "main"]); | ||
| 267 | let (second, _id2, _tip2) = patch_on_branch(&repo, "worktree-agent-bbbb", "b.txt"); | ||
| 268 | |||
| 269 | assert_ne!(first, second); | ||
| 270 | let out = repo.run_ok(&["patch", "list"]); | ||
| 271 | assert!(out.contains(&first) && out.contains(&second), "{}", out); | ||
| 272 | } | ||
| 273 | |||
| 274 | // =========================================================================== | ||
| 275 | // Merge detection, anchored to the latest revision's base | ||
| 276 | // =========================================================================== | ||
| 277 | |||
| 278 | #[test] | ||
| 279 | fn merging_the_base_branch_forward_marks_the_patch_merged() { | ||
| 280 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 281 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 282 | |||
| 283 | repo.git(&["checkout", "main"]); | ||
| 284 | repo.git(&["merge", "--ff-only", "feat"]); | ||
| 285 | |||
| 286 | assert_eq!(show_json(&repo, &short)["status"], "merged"); | ||
| 287 | } | ||
| 288 | |||
| 289 | #[test] | ||
| 290 | fn an_unmerged_patch_stays_open() { | ||
| 291 | // Guards the failure mode where dropping `base_commit` leaves `base_moved` | ||
| 292 | // permanently false — or permanently true — and merge detection silently | ||
| 293 | // stops telling the truth. | ||
| 294 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 295 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 296 | |||
| 297 | repo.git(&["checkout", "main"]); | ||
| 298 | repo.commit_file("unrelated.txt", "x", "main moves on its own"); | ||
| 299 | |||
| 300 | assert_eq!(show_json(&repo, &short)["status"], "open"); | ||
| 301 | } | ||
| 302 | |||
| 303 | #[test] | ||
| 304 | fn merge_detection_survives_deleting_the_source_branch() { | ||
| 305 | // Merge detection used to go through `refs/heads/<branch>` and no-op | ||
| 306 | // silently when it was absent, so deleting the branch after merging left | ||
| 307 | // the patch Open forever with no diagnostic. | ||
| 308 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 309 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 310 | |||
| 311 | repo.git(&["checkout", "main"]); | ||
| 312 | repo.git(&["merge", "--ff-only", "feat"]); | ||
| 313 | repo.git(&["branch", "-D", "feat"]); | ||
| 314 | |||
| 315 | assert_eq!(show_json(&repo, &short)["status"], "merged"); | ||
| 316 | } | ||
| 317 | |||
| 318 | #[test] | ||
| 319 | fn merge_detection_reads_the_latest_revisions_base_not_the_first() { | ||
| 320 | // After a rebase the patch's base moves. Detection must compare against | ||
| 321 | // where the *latest* revision stood, not where revision 1 did. | ||
| 322 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 323 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 324 | |||
| 325 | repo.git(&["checkout", "main"]); | ||
| 326 | repo.commit_file("upstream.txt", "u", "upstream work"); | ||
| 327 | repo.git(&["checkout", "feat"]); | ||
| 328 | repo.git(&["rebase", "main"]); | ||
| 329 | repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]); | ||
| 330 | |||
| 331 | assert_eq!( | ||
| 332 | show_json(&repo, &short)["status"], "open", | ||
| 333 | "rebasing is not merging" | ||
| 334 | ); | ||
| 335 | |||
| 336 | repo.git(&["checkout", "main"]); | ||
| 337 | repo.git(&["merge", "--ff-only", "feat"]); | ||
| 338 | assert_eq!(show_json(&repo, &short)["status"], "merged"); | ||
| 339 | } | ||
| 340 | |||
| 341 | #[test] | ||
| 342 | fn each_revision_records_the_base_it_was_written_against() { | ||
| 343 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 344 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 345 | let base1 = repo.git(&["rev-parse", "main"]).trim().to_string(); | ||
| 346 | |||
| 347 | repo.git(&["checkout", "main"]); | ||
| 348 | repo.commit_file("upstream.txt", "u", "upstream work"); | ||
| 349 | let base2 = repo.git(&["rev-parse", "main"]).trim().to_string(); | ||
| 350 | repo.git(&["checkout", "feat"]); | ||
| 351 | repo.git(&["rebase", "main"]); | ||
| 352 | repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]); | ||
| 353 | |||
| 354 | let json = show_json(&repo, &short); | ||
| 355 | let revisions = json["revisions"].as_array().unwrap(); | ||
| 356 | assert_eq!(revisions[0]["base"], base1); | ||
| 357 | assert_eq!(revisions[1]["base"], base2); | ||
| 358 | } | ||
| 359 | |||
| 360 | #[test] | ||
| 361 | fn a_revision_recorded_without_a_rebase_keeps_the_same_base() { | ||
| 362 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 363 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 364 | let base = repo.git(&["rev-parse", "main"]).trim().to_string(); | ||
| 365 | |||
| 366 | repo.commit_file("b.txt", "v2", "more work, same base"); | ||
| 367 | repo.run_ok(&["patch", "revise", &short]); | ||
| 368 | |||
| 369 | let json = show_json(&repo, &short); | ||
| 370 | let revisions = json["revisions"].as_array().unwrap(); | ||
| 371 | assert_eq!(revisions[0]["base"], base); | ||
| 372 | assert_eq!( | ||
| 373 | revisions[1]["base"], base, | ||
| 374 | "no rebase happened, so the base must not move" | ||
| 375 | ); | ||
| 376 | } | ||
| 377 | |||
| 378 | // =========================================================================== | ||
| 379 | // Revision-anchored review data outlives a rebase | ||
| 380 | // =========================================================================== | ||
| 381 | |||
| 382 | #[test] | ||
| 383 | fn an_inline_comment_on_revision_one_still_resolves_after_a_rebase() { | ||
| 384 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 385 | let (short, _id, _tip) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 386 | repo.run_ok(&[ | ||
| 387 | "patch", "comment", &short, "-b", "here", "--file", "a.txt", "--line", "1", | ||
| 388 | ]); | ||
| 389 | |||
| 390 | repo.git(&["checkout", "main"]); | ||
| 391 | repo.commit_file("upstream.txt", "u", "upstream work"); | ||
| 392 | repo.git(&["checkout", "feat"]); | ||
| 393 | repo.git(&["rebase", "main"]); | ||
| 394 | repo.run_ok(&["patch", "revise", &short, "-b", "rebased"]); | ||
| 395 | |||
| 396 | let json = show_json(&repo, &short); | ||
| 397 | assert_eq!(json["inline_comments"][0]["revision"], 1); | ||
| 398 | |||
| 399 | let diff = repo.run_ok(&["patch", "diff", &short, "--revision", "1"]); | ||
| 400 | assert!( | ||
| 401 | diff.contains("a.txt"), | ||
| 402 | "revision 1's objects must still resolve: {}", | ||
| 403 | diff | ||
| 404 | ); | ||
| 405 | } | ||
| 406 | |||
| 407 | // =========================================================================== | ||
| 408 | // Ref lifecycle: the whole subtree moves | ||
| 409 | // =========================================================================== | ||
| 410 | |||
| 411 | #[test] | ||
| 412 | fn closing_a_patch_archives_every_revision_ref() { | ||
| 413 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 414 | let (short, id, r1) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 415 | repo.commit_file("b.txt", "v2", "second commit"); | ||
| 416 | repo.run_ok(&["patch", "revise", &short]); | ||
| 417 | |||
| 418 | repo.run_ok(&["patch", "close", &short, "-r", "not now"]); | ||
| 419 | |||
| 420 | let refs = patch_refs(&repo); | ||
| 421 | assert!( | ||
| 422 | refs.iter() | ||
| 423 | .all(|r| !r.starts_with(&format!("refs/collab/patches/{}/", id))), | ||
| 424 | "nothing may be left in the active namespace: {:?}", | ||
| 425 | refs | ||
| 426 | ); | ||
| 427 | for suffix in ["events", "r/1", "r/2"] { | ||
| 428 | let name = format!("refs/collab/archive/patches/{}/{}", id, suffix); | ||
| 429 | assert!(refs.contains(&name), "missing {} in {:?}", name, refs); | ||
| 430 | } | ||
| 431 | assert_eq!( | ||
| 432 | ref_target(&repo, &format!("refs/collab/archive/patches/{}/r/1", id)).as_deref(), | ||
| 433 | Some(r1.as_str()) | ||
| 434 | ); | ||
| 435 | |||
| 436 | // And the closed patch is still reviewable. | ||
| 437 | let json = show_json(&repo, &short); | ||
| 438 | assert_eq!(json["status"], "closed"); | ||
| 439 | assert_eq!(json["revisions"].as_array().unwrap().len(), 2); | ||
| 440 | } | ||
| 441 | |||
| 442 | #[test] | ||
| 443 | fn deleting_a_patch_removes_every_ref_in_its_namespace() { | ||
| 444 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 445 | let (short, id, _r1) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 446 | repo.commit_file("b.txt", "v2", "second commit"); | ||
| 447 | repo.run_ok(&["patch", "revise", &short]); | ||
| 448 | |||
| 449 | repo.run_ok(&["patch", "delete", &short]); | ||
| 450 | |||
| 451 | let refs = patch_refs(&repo); | ||
| 452 | assert!( | ||
| 453 | refs.iter().all(|r| !r.contains(&id)), | ||
| 454 | "patch namespace not fully removed: {:?}", | ||
| 455 | refs | ||
| 456 | ); | ||
| 457 | } | ||
| 458 | |||
| 459 | // =========================================================================== | ||
| 460 | // Migration from the single-ref layout | ||
| 461 | // =========================================================================== | ||
| 462 | |||
| 463 | /// Rewrite a patch back into the pre-revision-refs layout: one ref at | ||
| 464 | /// `refs/collab/patches/<id>` and no revision refs at all. | ||
| 465 | fn demote_to_old_layout(repo: &TestRepo, id: &str) { | ||
| 466 | let events = format!("refs/collab/patches/{}/events", id); | ||
| 467 | let tip = ref_target(repo, &events).expect("events ref"); | ||
| 468 | for name in patch_refs(repo) { | ||
| 469 | if name.starts_with(&format!("refs/collab/patches/{}/", id)) { | ||
| 470 | repo.git(&["update-ref", "-d", &name]); | ||
| 471 | } | ||
| 472 | } | ||
| 473 | repo.git(&["update-ref", &format!("refs/collab/patches/{}", id), &tip]); | ||
| 474 | } | ||
| 475 | |||
| 476 | #[test] | ||
| 477 | fn an_old_layout_patch_is_migrated_on_first_use() { | ||
| 478 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 479 | let (short, id, r1) = patch_on_branch(&repo, "feat", "a.txt"); | ||
| 480 | repo.commit_file("b.txt", "v2", "second commit"); | ||
| 481 | repo.run_ok(&["patch", "revise", &short]); | ||
| 482 | let r2 = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 483 | demote_to_old_layout(&repo, &id); | ||
| 484 | |||
| 485 | // Any read is "first use". | ||
| 486 | let out = repo.run_ok(&["patch", "list"]); | ||
| 487 | assert!(out.contains(&short), "{}", out); | ||
| 488 | |||
| 489 | let refs = patch_refs(&repo); | ||
| 490 | assert!( | ||
| 491 | refs.contains(&format!("refs/collab/patches/{}/events", id)), | ||
| 492 | "{:?}", | ||
| 493 | refs | ||
| 494 | ); | ||
| 495 | assert!( | ||
| 496 | !refs.contains(&format!("refs/collab/patches/{}", id)), | ||
| 497 | "the old ref must be gone: {:?}", | ||
| 498 | refs | ||
| 499 | ); | ||
| 500 | assert_eq!( | ||
| 501 | ref_target(&repo, &format!("refs/collab/patches/{}/r/1", id)).as_deref(), | ||
| 502 | Some(r1.as_str()) | ||
| 503 | ); | ||
| 504 | assert_eq!( | ||
| 505 | ref_target(&repo, &format!("refs/collab/patches/{}/r/2", id)).as_deref(), | ||
| 506 | Some(r2.as_str()) | ||
| 507 | ); | ||
| 508 | |||
| 509 | // The migrated patch still diffs. | ||
| 510 | let diff = repo.run_ok(&["patch", "diff", &short, "--revision", "1"]); | ||
| 511 | assert!(diff.contains("a.txt"), "{}", diff); | ||
| 512 | } | ||
| 513 | |||
| 514 | #[test] | ||
| 515 | fn migration_skips_a_revision_whose_commit_is_already_lost() { | ||
| 516 | // Revisions whose objects were stripped by a force-push before the patch | ||
| 517 | // was migrated cannot be recovered. Migration must leave them without a | ||
| 518 | // ref rather than failing and taking the whole patch down with it. | ||
| 519 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 520 | let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 521 | let git_repo = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 522 | let tree = git_repo | ||
| 523 | .find_commit(git2::Oid::from_str(&head).unwrap()) | ||
| 524 | .unwrap() | ||
| 525 | .tree() | ||
| 526 | .unwrap() | ||
| 527 | .id() | ||
| 528 | .to_string(); | ||
| 529 | let missing = "1111111111111111111111111111111111111111"; | ||
| 530 | |||
| 531 | let root = write_raw_event( | ||
| 532 | &git_repo, | ||
| 533 | None, | ||
| 534 | json!({ | ||
| 535 | "type": "patch.create", | ||
| 536 | "title": "Patch whose r1 was force-pushed away", | ||
| 537 | "body": "", | ||
| 538 | "base_ref": "main", | ||
| 539 | "branch": "gone", | ||
| 540 | "commit": missing, | ||
| 541 | "tree": tree, | ||
| 542 | }), | ||
| 543 | 1, | ||
| 544 | ); | ||
| 545 | let tip = write_raw_event( | ||
| 546 | &git_repo, | ||
| 547 | Some(root), | ||
| 548 | json!({ | ||
| 549 | "type": "patch.revision", | ||
| 550 | "commit": head, | ||
| 551 | "tree": tree, | ||
| 552 | "body": "still here", | ||
| 553 | }), | ||
| 554 | 2, | ||
| 555 | ); | ||
| 556 | let id = root.to_string(); | ||
| 557 | git_repo | ||
| 558 | .reference( | ||
| 559 | &format!("refs/collab/patches/{}", id), | ||
| 560 | tip, | ||
| 561 | false, | ||
| 562 | "old layout", | ||
| 563 | ) | ||
| 564 | .unwrap(); | ||
| 565 | drop(git_repo); | ||
| 566 | |||
| 567 | let out = repo.run_ok(&["patch", "list", "--all"]); | ||
| 568 | assert!(out.contains("force-pushed away"), "{}", out); | ||
| 569 | |||
| 570 | let refs = patch_refs(&repo); | ||
| 571 | assert!( | ||
| 572 | refs.contains(&format!("refs/collab/patches/{}/events", id)), | ||
| 573 | "{:?}", | ||
| 574 | refs | ||
| 575 | ); | ||
| 576 | assert!( | ||
| 577 | !refs.contains(&format!("refs/collab/patches/{}/r/1", id)), | ||
| 578 | "a lost commit must not get a ref: {:?}", | ||
| 579 | refs | ||
| 580 | ); | ||
| 581 | assert_eq!( | ||
| 582 | ref_target(&repo, &format!("refs/collab/patches/{}/r/2", id)).as_deref(), | ||
| 583 | Some(head.as_str()), | ||
| 584 | "the surviving revision still gets one" | ||
| 585 | ); | ||
| 586 | } | ||
| 587 | |||
| 588 | #[test] | ||
| 589 | fn a_fully_legacy_patch_with_no_recorded_commits_still_materializes() { | ||
| 590 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 591 | let git_repo = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 592 | let root = write_raw_event( | ||
| 593 | &git_repo, | ||
| 594 | None, | ||
| 595 | json!({ | ||
| 596 | "type": "patch.create", | ||
| 597 | "title": "Ancient patch", | ||
| 598 | "body": "", | ||
| 599 | "base_ref": "main", | ||
| 600 | "branch": "long-gone", | ||
| 601 | }), | ||
| 602 | 1, | ||
| 603 | ); | ||
| 604 | let id = root.to_string(); | ||
| 605 | git_repo | ||
| 606 | .reference( | ||
| 607 | &format!("refs/collab/patches/{}", id), | ||
| 608 | root, | ||
| 609 | false, | ||
| 610 | "old layout", | ||
| 611 | ) | ||
| 612 | .unwrap(); | ||
| 613 | drop(git_repo); | ||
| 614 | |||
| 615 | let out = repo.run_ok(&["patch", "list", "--all"]); | ||
| 616 | assert!(out.contains("Ancient patch"), "{}", out); | ||
| 617 | |||
| 618 | let refs = patch_refs(&repo); | ||
| 619 | assert!( | ||
| 620 | refs.contains(&format!("refs/collab/patches/{}/events", id)), | ||
| 621 | "{:?}", | ||
| 622 | refs | ||
| 623 | ); | ||
| 624 | assert!( | ||
| 625 | refs.iter() | ||
| 626 | .all(|r| !r.starts_with(&format!("refs/collab/patches/{}/r/", id))), | ||
| 627 | "no commits were ever recorded, so there is nothing to point a ref at: {:?}", | ||
| 628 | refs | ||
| 629 | ); | ||
| 630 | } | ||
tests/revision_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -28,51 +28,14 @@ fn test_patch_create_records_revision_1() { | |||
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | // =========================================================================== | 30 | // =========================================================================== |
| 31 | // Auto-detect revision on comment | 31 | // Revisions are recorded only by `patch revise` |
| 32 | // | ||
| 33 | // Comments and reviews used to infer one from the branch tip as a side effect, | ||
| 34 | // which manufactured revisions the user never asked for; that path is gone and | ||
| 35 | // its replacement is asserted in tests/revision_refs_test.rs. | ||
| 32 | // =========================================================================== | 36 | // =========================================================================== |
| 33 | 37 | ||
| 34 | #[test] | 38 | #[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(&[ | ||
| 42 | "patch", | ||
| 43 | "create", | ||
| 44 | "-t", | ||
| 45 | "Auto-detect test", | ||
| 46 | "-B", | ||
| 47 | "feat-auto", | ||
| 48 | ]); | ||
| 49 | let id = out | ||
| 50 | .trim() | ||
| 51 | .strip_prefix("Created patch ") | ||
| 52 | .unwrap() | ||
| 53 | .to_string(); | ||
| 54 | |||
| 55 | // Push a new commit to the branch | ||
| 56 | repo.git(&["checkout", "feat-auto"]); | ||
| 57 | repo.commit_file("v2.txt", "v2", "second commit"); | ||
| 58 | repo.git(&["checkout", "main"]); | ||
| 59 | |||
| 60 | // Comment should auto-insert a PatchRevision first | ||
| 61 | repo.run_ok(&["patch", "comment", &id, "-b", "Looks interesting"]); | ||
| 62 | |||
| 63 | let out = repo.run_ok(&["patch", "show", &id, "--json"]); | ||
| 64 | let json: serde_json::Value = serde_json::from_str(&out).unwrap(); | ||
| 65 | let revisions = json["revisions"].as_array().unwrap(); | ||
| 66 | assert_eq!( | ||
| 67 | revisions.len(), | ||
| 68 | 2, | ||
| 69 | "should have 2 revisions (create + auto-detect)" | ||
| 70 | ); | ||
| 71 | assert_eq!(revisions[0]["number"], 1); | ||
| 72 | assert_eq!(revisions[1]["number"], 2); | ||
| 73 | } | ||
| 74 | |||
| 75 | #[test] | ||
| 76 | fn test_no_revision_when_branch_unchanged() { | 39 | fn test_no_revision_when_branch_unchanged() { |
| 77 | let repo = TestRepo::new("Alice", "alice@example.com"); | 40 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 78 | 41 | ||
| @@ -101,12 +64,8 @@ fn test_no_revision_when_branch_unchanged() { | |||
| 101 | assert_eq!(revisions.len(), 1, "should still have only 1 revision"); | 64 | assert_eq!(revisions.len(), 1, "should still have only 1 revision"); |
| 102 | } | 65 | } |
| 103 | 66 | ||
| 104 | // =========================================================================== | ||
| 105 | // Auto-detect revision on review | ||
| 106 | // =========================================================================== | ||
| 107 | |||
| 108 | #[test] | 67 | #[test] |
| 109 | fn test_auto_detect_revision_on_review() { | 68 | fn test_review_anchors_to_the_latest_recorded_revision() { |
| 110 | let repo = TestRepo::new("Alice", "alice@example.com"); | 69 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 111 | 70 | ||
| 112 | repo.git(&["checkout", "-b", "feat-review"]); | 71 | repo.git(&["checkout", "-b", "feat-review"]); |
| @@ -118,12 +77,10 @@ fn test_auto_detect_revision_on_review() { | |||
| 118 | .unwrap() | 77 | .unwrap() |
| 119 | .to_string(); | 78 | .to_string(); |
| 120 | 79 | ||
| 121 | // Push a new commit | ||
| 122 | repo.git(&["checkout", "feat-review"]); | ||
| 123 | repo.commit_file("v2.txt", "v2", "second commit"); | 80 | repo.commit_file("v2.txt", "v2", "second commit"); |
| 81 | repo.run_ok(&["patch", "revise", &id]); | ||
| 124 | repo.git(&["checkout", "main"]); | 82 | repo.git(&["checkout", "main"]); |
| 125 | 83 | ||
| 126 | // Review should auto-insert a PatchRevision | ||
| 127 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM"]); | 84 | repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM"]); |
| 128 | 85 | ||
| 129 | let out = repo.run_ok(&["patch", "show", &id, "--json"]); | 86 | let out = repo.run_ok(&["patch", "show", &id, "--json"]); |
| @@ -334,9 +291,10 @@ fn test_show_revision_filter() { | |||
| 334 | "1", | 291 | "1", |
| 335 | ]); | 292 | ]); |
| 336 | 293 | ||
| 337 | // Push v2 and comment on r2 | 294 | // Record r2 and comment on it |
| 338 | repo.git(&["checkout", "feat-show-rev"]); | 295 | repo.git(&["checkout", "feat-show-rev"]); |
| 339 | repo.commit_file("b.txt", "b", "v2"); | 296 | repo.commit_file("b.txt", "b", "v2"); |
| 297 | repo.run_ok(&["patch", "revise", &id]); | ||
| 340 | repo.git(&["checkout", "main"]); | 298 | repo.git(&["checkout", "main"]); |
| 341 | repo.run_ok(&[ | 299 | repo.run_ok(&[ |
| 342 | "patch", | 300 | "patch", |
| @@ -385,13 +343,11 @@ fn test_interdiff_between_revisions() { | |||
| 385 | .unwrap() | 343 | .unwrap() |
| 386 | .to_string(); | 344 | .to_string(); |
| 387 | 345 | ||
| 388 | // Push v2 | 346 | // Push v2 and record it as revision 2 |
| 389 | repo.git(&["checkout", "feat-interdiff"]); | 347 | repo.git(&["checkout", "feat-interdiff"]); |
| 390 | repo.commit_file("b.txt", "world", "v2"); | 348 | repo.commit_file("b.txt", "world", "v2"); |
| 391 | repo.git(&["checkout", "main"]); | ||
| 392 | |||
| 393 | // Create revision 2 via revise | ||
| 394 | repo.run_ok(&["patch", "revise", &id]); | 349 | repo.run_ok(&["patch", "revise", &id]); |
| 350 | repo.git(&["checkout", "main"]); | ||
| 395 | 351 | ||
| 396 | // Interdiff between r1 and r2 should show b.txt added | 352 | // Interdiff between r1 and r2 should show b.txt added |
| 397 | let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]); | 353 | let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]); |
| @@ -421,8 +377,8 @@ fn test_interdiff_single_arg_means_n_to_latest() { | |||
| 421 | // Push v2 | 377 | // Push v2 |
| 422 | repo.git(&["checkout", "feat-between-single"]); | 378 | repo.git(&["checkout", "feat-between-single"]); |
| 423 | repo.commit_file("c.txt", "new", "v2"); | 379 | repo.commit_file("c.txt", "new", "v2"); |
| 424 | repo.git(&["checkout", "main"]); | ||
| 425 | repo.run_ok(&["patch", "revise", &id]); | 380 | repo.run_ok(&["patch", "revise", &id]); |
| 381 | repo.git(&["checkout", "main"]); | ||
| 426 | 382 | ||
| 427 | // --between 1 (single arg) = 1..latest | 383 | // --between 1 (single arg) = 1..latest |
| 428 | let out = repo.run_ok(&["patch", "diff", &id, "--between", "1"]); | 384 | let out = repo.run_ok(&["patch", "diff", &id, "--between", "1"]); |
| @@ -515,14 +471,12 @@ fn test_patch_log() { | |||
| 515 | // Push v2 | 471 | // Push v2 |
| 516 | repo.git(&["checkout", "feat-log"]); | 472 | repo.git(&["checkout", "feat-log"]); |
| 517 | repo.commit_file("b.txt", "world", "v2"); | 473 | repo.commit_file("b.txt", "world", "v2"); |
| 518 | repo.git(&["checkout", "main"]); | ||
| 519 | repo.run_ok(&["patch", "revise", &id, "-b", "added b.txt"]); | 474 | repo.run_ok(&["patch", "revise", &id, "-b", "added b.txt"]); |
| 520 | 475 | ||
| 521 | // Push v3 | 476 | // Push v3 |
| 522 | repo.git(&["checkout", "feat-log"]); | ||
| 523 | repo.commit_file("c.txt", "!", "v3"); | 477 | repo.commit_file("c.txt", "!", "v3"); |
| 524 | repo.git(&["checkout", "main"]); | ||
| 525 | repo.run_ok(&["patch", "revise", &id]); | 478 | repo.run_ok(&["patch", "revise", &id]); |
| 479 | repo.git(&["checkout", "main"]); | ||
| 526 | 480 | ||
| 527 | let out = repo.run_ok(&["patch", "log", &id]); | 481 | let out = repo.run_ok(&["patch", "log", &id]); |
| 528 | assert!(out.contains("r1")); | 482 | assert!(out.contains("r1")); |
| @@ -640,8 +594,9 @@ fn test_review_shows_revision_context_in_show() { | |||
| 640 | // =========================================================================== | 594 | // =========================================================================== |
| 641 | 595 | ||
| 642 | #[test] | 596 | #[test] |
| 643 | fn test_revision_dedup_by_commit_oid() { | 597 | fn test_revising_the_same_commit_twice_is_refused() { |
| 644 | // Two comments with no branch change should not create duplicate revisions | 598 | // Revising a tip already recorded is a no-op the CLI rejects, so the |
| 599 | // revision list cannot grow without the code actually moving. | ||
| 645 | let repo = TestRepo::new("Alice", "alice@example.com"); | 600 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 646 | 601 | ||
| 647 | repo.git(&["checkout", "-b", "feat-dedup"]); | 602 | repo.git(&["checkout", "-b", "feat-dedup"]); |
| @@ -653,12 +608,12 @@ fn test_revision_dedup_by_commit_oid() { | |||
| 653 | .unwrap() | 608 | .unwrap() |
| 654 | .to_string(); | 609 | .to_string(); |
| 655 | 610 | ||
| 656 | // Push v2 | ||
| 657 | repo.git(&["checkout", "feat-dedup"]); | ||
| 658 | repo.commit_file("b.txt", "b", "v2"); | 611 | repo.commit_file("b.txt", "b", "v2"); |
| 659 | repo.git(&["checkout", "main"]); | 612 | repo.run_ok(&["patch", "revise", &id]); |
| 613 | |||
| 614 | let err = repo.run_err(&["patch", "revise", &id]); | ||
| 615 | assert!(err.contains("no changes since revision 2"), "{}", err); | ||
| 660 | 616 | ||
| 661 | // Two comments — first should auto-detect revision, second should not create another | ||
| 662 | repo.run_ok(&["patch", "comment", &id, "-b", "first"]); | 617 | repo.run_ok(&["patch", "comment", &id, "-b", "first"]); |
| 663 | repo.run_ok(&["patch", "comment", &id, "-b", "second"]); | 618 | repo.run_ok(&["patch", "comment", &id, "-b", "second"]); |
| 664 | 619 | ||
| @@ -727,7 +682,7 @@ fn test_concurrent_revision_dedup_after_reconcile() { | |||
| 727 | }; | 682 | }; |
| 728 | let patch_oid = dag::create_root_event(&repo, &create_event, &sk).unwrap(); | 683 | let patch_oid = dag::create_root_event(&repo, &create_event, &sk).unwrap(); |
| 729 | let id = patch_oid.to_string(); | 684 | let id = patch_oid.to_string(); |
| 730 | let ref_name = format!("refs/collab/patches/{}", id); | 685 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 731 | repo.reference(&ref_name, patch_oid, false, "test").unwrap(); | 686 | repo.reference(&ref_name, patch_oid, false, "test").unwrap(); |
| 732 | 687 | ||
| 733 | // Push a new commit on the branch | 688 | // Push a new commit on the branch |
| @@ -760,6 +715,7 @@ fn test_concurrent_revision_dedup_after_reconcile() { | |||
| 760 | commit: new_commit.to_string(), | 715 | commit: new_commit.to_string(), |
| 761 | tree: new_tree_oid.to_string(), | 716 | tree: new_tree_oid.to_string(), |
| 762 | body: None, | 717 | body: None, |
| 718 | base: None, | ||
| 763 | }, | 719 | }, |
| 764 | clock: 0, | 720 | clock: 0, |
| 765 | }; | 721 | }; |
| @@ -776,6 +732,7 @@ fn test_concurrent_revision_dedup_after_reconcile() { | |||
| 776 | commit: new_commit.to_string(), | 732 | commit: new_commit.to_string(), |
| 777 | tree: new_tree_oid.to_string(), | 733 | tree: new_tree_oid.to_string(), |
| 778 | body: None, | 734 | body: None, |
| 735 | base: None, | ||
| 779 | }, | 736 | }, |
| 780 | clock: 0, | 737 | clock: 0, |
| 781 | }; | 738 | }; |
tests/sort_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -73,7 +73,7 @@ fn create_patch_at( | |||
| 73 | }; | 73 | }; |
| 74 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | 74 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); |
| 75 | let id = oid.to_string(); | 75 | let id = oid.to_string(); |
| 76 | let ref_name = format!("refs/collab/patches/{}", id); | 76 | let ref_name = git_collab::state::patch_events_ref(&id); |
| 77 | repo.reference(&ref_name, oid, false, "test patch").unwrap(); | 77 | repo.reference(&ref_name, oid, false, "test patch").unwrap(); |
| 78 | (ref_name, id) | 78 | (ref_name, id) |
| 79 | } | 79 | } |
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -662,7 +662,7 @@ fn test_patch_review_across_repos() { | |||
| 662 | let sk = test_signing_key(); | 662 | let sk = test_signing_key(); |
| 663 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); | 663 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); |
| 664 | let id = oid.to_string(); | 664 | let id = oid.to_string(); |
| 665 | let alice_ref = format!("refs/collab/patches/{}", id); | 665 | let alice_ref = git_collab::state::patch_events_ref(&id); |
| 666 | alice_repo | 666 | alice_repo |
| 667 | .reference(&alice_ref, oid, false, "patch create") | 667 | .reference(&alice_ref, oid, false, "patch create") |
| 668 | .unwrap(); | 668 | .unwrap(); |
| @@ -670,7 +670,7 @@ fn test_patch_review_across_repos() { | |||
| 670 | sync::sync(&alice_repo, "origin").unwrap(); | 670 | sync::sync(&alice_repo, "origin").unwrap(); |
| 671 | 671 | ||
| 672 | sync::sync(&bob_repo, "origin").unwrap(); | 672 | sync::sync(&bob_repo, "origin").unwrap(); |
| 673 | let bob_ref = format!("refs/collab/patches/{}", id); | 673 | let bob_ref = git_collab::state::patch_events_ref(&id); |
| 674 | let review_event = Event { | 674 | let review_event = Event { |
| 675 | timestamp: now(), | 675 | timestamp: now(), |
| 676 | author: bob(), | 676 | author: bob(), |
| @@ -715,7 +715,7 @@ fn test_concurrent_review_and_revise() { | |||
| 715 | let sk = test_signing_key(); | 715 | let sk = test_signing_key(); |
| 716 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); | 716 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); |
| 717 | let id = oid.to_string(); | 717 | let id = oid.to_string(); |
| 718 | let alice_ref = format!("refs/collab/patches/{}", id); | 718 | let alice_ref = git_collab::state::patch_events_ref(&id); |
| 719 | alice_repo | 719 | alice_repo |
| 720 | .reference(&alice_ref, oid, false, "patch") | 720 | .reference(&alice_ref, oid, false, "patch") |
| 721 | .unwrap(); | 721 | .unwrap(); |
| @@ -729,12 +729,13 @@ fn test_concurrent_review_and_revise() { | |||
| 729 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), | 729 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), |
| 730 | tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), | 730 | tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), |
| 731 | body: Some("Updated description".to_string()), | 731 | body: Some("Updated description".to_string()), |
| 732 | base: None, | ||
| 732 | }, | 733 | }, |
| 733 | clock: 0, | 734 | clock: 0, |
| 734 | }; | 735 | }; |
| 735 | dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); | 736 | dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); |
| 736 | 737 | ||
| 737 | let bob_ref = format!("refs/collab/patches/{}", id); | 738 | let bob_ref = git_collab::state::patch_events_ref(&id); |
| 738 | let review_event = Event { | 739 | let review_event = Event { |
| 739 | timestamp: now(), | 740 | timestamp: now(), |
| 740 | author: bob(), | 741 | author: bob(), |
| @@ -1818,3 +1819,71 @@ fn cli_issue_show_renders_linked_commits_section() { | |||
| 1818 | show | 1819 | show |
| 1819 | ); | 1820 | ); |
| 1820 | } | 1821 | } |
| 1822 | |||
| 1823 | // --------------------------------------------------------------------------- | ||
| 1824 | // Patches travel without pushing a branch | ||
| 1825 | // --------------------------------------------------------------------------- | ||
| 1826 | |||
| 1827 | #[test] | ||
| 1828 | fn patch_revisions_reach_a_second_clone_without_pushing_any_branch() { | ||
| 1829 | // The whole point of revision refs: a contributor needs no write access to | ||
| 1830 | // refs/heads/*, so `sync` alone must carry the patch, its revisions and the | ||
| 1831 | // objects behind them. | ||
| 1832 | let cluster = TestCluster::new(); | ||
| 1833 | let alice_repo = cluster.alice_repo(); | ||
| 1834 | |||
| 1835 | let base = make_commit_with_message(&alice_repo, "base"); | ||
| 1836 | let feat = { | ||
| 1837 | let parent = alice_repo.find_commit(base).unwrap(); | ||
| 1838 | let sig = git2::Signature::now("Alice", "alice@example.com").unwrap(); | ||
| 1839 | let blob = alice_repo.blob(b"feature work").unwrap(); | ||
| 1840 | let mut tb = alice_repo | ||
| 1841 | .treebuilder(Some(&parent.tree().unwrap())) | ||
| 1842 | .unwrap(); | ||
| 1843 | tb.insert("feature.txt", blob, 0o100644).unwrap(); | ||
| 1844 | let tree_oid = tb.write().unwrap(); | ||
| 1845 | let tree = alice_repo.find_tree(tree_oid).unwrap(); | ||
| 1846 | alice_repo | ||
| 1847 | .commit( | ||
| 1848 | Some("refs/heads/feat"), | ||
| 1849 | &sig, | ||
| 1850 | &sig, | ||
| 1851 | "feature", | ||
| 1852 | &tree, | ||
| 1853 | &[&parent], | ||
| 1854 | ) | ||
| 1855 | .unwrap() | ||
| 1856 | }; | ||
| 1857 | |||
| 1858 | cluster.run_collab_ok( | ||
| 1859 | cluster.alice_dir.path(), | ||
| 1860 | &["patch", "create", "-t", "No branch push", "-B", "feat"], | ||
| 1861 | ); | ||
| 1862 | sync::sync(&alice_repo, "origin").unwrap(); | ||
| 1863 | |||
| 1864 | let bob_repo = cluster.bob_repo(); | ||
| 1865 | sync::sync(&bob_repo, "origin").unwrap(); | ||
| 1866 | |||
| 1867 | // Reopen: git2 caches the ref list at open time, and sync fetched via git. | ||
| 1868 | let bob_repo = Repository::open(cluster.bob_dir.path()).unwrap(); | ||
| 1869 | let patches = state::list_patches(&bob_repo).unwrap(); | ||
| 1870 | assert_eq!(patches.len(), 1, "bob should see alice's patch"); | ||
| 1871 | let patch = &patches[0]; | ||
| 1872 | assert_eq!(patch.title, "No branch push"); | ||
| 1873 | |||
| 1874 | let revision_ref = format!("refs/collab/patches/{}/r/1", patch.id); | ||
| 1875 | assert_eq!( | ||
| 1876 | bob_repo.refname_to_id(&revision_ref).ok(), | ||
| 1877 | Some(feat), | ||
| 1878 | "revision 1's ref must have travelled" | ||
| 1879 | ); | ||
| 1880 | assert!( | ||
| 1881 | bob_repo.find_commit(feat).is_ok(), | ||
| 1882 | "and so must the objects behind it" | ||
| 1883 | ); | ||
| 1884 | assert!( | ||
| 1885 | bob_repo.find_reference("refs/heads/feat").is_err(), | ||
| 1886 | "no branch was ever pushed" | ||
| 1887 | ); | ||
| 1888 | assert_eq!(patch.resolve_head(&bob_repo).unwrap(), feat); | ||
| 1889 | } | ||