b23a3d5e
Reference branches from patches instead of commit OIDs
a73x 2026-03-21 11:50
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ Auto-generated from all feature plans. Last updated: 2026-03-21 | |||
| 7 | - Git refs under `.git/refs/collab/`, trusted keys file at `.git/collab/trusted-keys` (plain text, not a git object) (003-key-trust-allowlist) | 7 | - Git refs under `.git/refs/collab/`, trusted keys file at `.git/collab/trusted-keys` (plain text, not a git object) (003-key-trust-allowlist) |
| 8 | - Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 (004-dashboard-filtering) | 8 | - Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 (004-dashboard-filtering) |
| 9 | - N/A (ephemeral filter state, no persistence) (004-dashboard-filtering) | 9 | - N/A (ephemeral filter state, no persistence) (004-dashboard-filtering) |
| 10 | - Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2 (012-patch-branch-refactor) | ||
| 10 | 11 | ||
| 11 | - Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` (001-gpg-event-signing) | 12 | - Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` (001-gpg-event-signing) |
| 12 | 13 | ||
| @@ -26,10 +27,10 @@ cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECH | |||
| 26 | Rust 2021 edition: Follow standard conventions | 27 | Rust 2021 edition: Follow standard conventions |
| 27 | 28 | ||
| 28 | ## Recent Changes | 29 | ## Recent Changes |
| 30 | - 012-patch-branch-refactor: Added Rust 2021 edition + git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2 | ||
| 29 | - 004-dashboard-filtering: Added Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 | 31 | - 004-dashboard-filtering: Added Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 |
| 30 | - 003-key-trust-allowlist: Added Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2 | 32 | - 003-key-trust-allowlist: Added Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2 |
| 31 | 33 | ||
| 32 | - 001-gpg-event-signing: Added Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` | ||
| 33 | 34 | ||
| 34 | <!-- MANUAL ADDITIONS START --> | 35 | <!-- MANUAL ADDITIONS START --> |
| 35 | <!-- MANUAL ADDITIONS END --> | 36 | <!-- MANUAL ADDITIONS END --> |
docs/design-debt.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,44 @@ | |||
| 1 | # Design Debt | ||
| 2 | |||
| 3 | ## Timestamp-wins conflict resolution | ||
| 4 | |||
| 5 | **Location:** `src/state.rs` -- `IssueState::from_ref` and `PatchState::from_ref` | ||
| 6 | |||
| 7 | ### Current approach | ||
| 8 | |||
| 9 | When replaying the event DAG to materialize issue/patch state, concurrent | ||
| 10 | status-changing events (e.g. a close and a reopen that share the same parent | ||
| 11 | commit) are resolved by comparing ISO-8601 timestamps. The event with the | ||
| 12 | later (lexicographically greater) timestamp wins. | ||
| 13 | |||
| 14 | ### Why it is acceptable for now | ||
| 15 | |||
| 16 | - **Single-writer typical.** In practice most issues and patches have one | ||
| 17 | active writer at a time; true concurrent status changes are rare. | ||
| 18 | - **Simple.** No additional data structures (vector clocks, CRDTs) or | ||
| 19 | consensus protocols are needed. | ||
| 20 | - **No DAG complexity.** Avoids the need for a full three-way merge or | ||
| 21 | topological ordering of concurrent branches in the event chain. | ||
| 22 | |||
| 23 | ### Known risks | ||
| 24 | |||
| 25 | - **Clock skew.** Two machines with drifting clocks can produce an ordering | ||
| 26 | that does not match wall-clock reality. | ||
| 27 | - **Forgeable timestamps.** A malicious or buggy writer can backdate or | ||
| 28 | future-date an event to force a desired outcome. | ||
| 29 | - **Non-determinism across replays.** If two events have identical timestamps, | ||
| 30 | the result depends on DAG walk order, which may vary. | ||
| 31 | |||
| 32 | ### Alternatives to evaluate later | ||
| 33 | |||
| 34 | | Alternative | Trade-off | | ||
| 35 | |---|---| | ||
| 36 | | **Commit-hash tiebreaker** | Use the lexicographic commit OID as a secondary sort key when timestamps are equal. Deterministic but still timestamp-primary. | | ||
| 37 | | **Vector clocks** | Each writer maintains a logical clock. Correct ordering regardless of wall clock, but adds per-event overhead and requires writer identity tracking. | | ||
| 38 | | **Radicle-style CRDT COBs** | Model issues/patches as Conflict-free Replicated Data Types (Collaborative Objects). Robust multi-writer semantics but significantly more complex to implement. | | ||
| 39 | |||
| 40 | ### When to revisit | ||
| 41 | |||
| 42 | Before multi-writer sync becomes a common workflow -- specifically, before | ||
| 43 | supporting `git-collab push/pull` across multiple independent clones that | ||
| 44 | routinely race on status changes. | ||
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -165,9 +165,9 @@ pub enum PatchCmd { | |||
| 165 | /// Base branch ref | 165 | /// Base branch ref |
| 166 | #[arg(long, default_value = "main")] | 166 | #[arg(long, default_value = "main")] |
| 167 | base: String, | 167 | base: String, |
| 168 | /// Head commit to review (defaults to HEAD) | 168 | /// Source branch (defaults to current branch) |
| 169 | #[arg(long)] | 169 | #[arg(short = 'B', long)] |
| 170 | head: Option<String>, | 170 | branch: Option<String>, |
| 171 | /// Issue ID this patch fixes (auto-closes on merge) | 171 | /// Issue ID this patch fixes (auto-closes on merge) |
| 172 | #[arg(long)] | 172 | #[arg(long)] |
| 173 | fixes: Option<String>, | 173 | fixes: Option<String>, |
| @@ -213,13 +213,10 @@ pub enum PatchCmd { | |||
| 213 | #[arg(short, long)] | 213 | #[arg(short, long)] |
| 214 | body: String, | 214 | body: String, |
| 215 | }, | 215 | }, |
| 216 | /// Revise a patch with a new head commit | 216 | /// Revise a patch (record a revision note) |
| 217 | Revise { | 217 | Revise { |
| 218 | /// Patch ID (prefix match) | 218 | /// Patch ID (prefix match) |
| 219 | id: String, | 219 | id: String, |
| 220 | /// New head commit (defaults to HEAD) | ||
| 221 | #[arg(long)] | ||
| 222 | head: Option<String>, | ||
| 223 | /// Updated description | 220 | /// Updated description |
| 224 | #[arg(short, long)] | 221 | #[arg(short, long)] |
| 225 | body: Option<String>, | 222 | body: Option<String>, |
| @@ -237,9 +234,4 @@ pub enum PatchCmd { | |||
| 237 | #[arg(short, long)] | 234 | #[arg(short, long)] |
| 238 | reason: Option<String>, | 235 | reason: Option<String>, |
| 239 | }, | 236 | }, |
| 240 | /// Import patches from format-patch files | ||
| 241 | Import { | ||
| 242 | /// One or more .patch files to import | ||
| 243 | files: Vec<std::path::PathBuf>, | ||
| 244 | }, | ||
| 245 | } | 237 | } |
src/error.rs
| Old | New | ||
|---|---|---|---|
| @@ -25,10 +25,4 @@ pub enum Error { | |||
| 25 | 25 | ||
| 26 | #[error("untrusted key: {0}")] | 26 | #[error("untrusted key: {0}")] |
| 27 | UntrustedKey(String), | 27 | UntrustedKey(String), |
| 28 | |||
| 29 | #[error("malformed patch: {0}")] | ||
| 30 | MalformedPatch(String), | ||
| 31 | |||
| 32 | #[error("patch apply failed: {0}")] | ||
| 33 | PatchApplyFailed(String), | ||
| 34 | } | 28 | } |
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -47,13 +47,12 @@ pub enum Action { | |||
| 47 | title: String, | 47 | title: String, |
| 48 | body: String, | 48 | body: String, |
| 49 | base_ref: String, | 49 | base_ref: String, |
| 50 | head_commit: String, | 50 | branch: String, |
| 51 | #[serde(default, skip_serializing_if = "Option::is_none")] | 51 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 52 | fixes: Option<String>, | 52 | fixes: Option<String>, |
| 53 | }, | 53 | }, |
| 54 | PatchRevise { | 54 | PatchRevise { |
| 55 | body: Option<String>, | 55 | body: Option<String>, |
| 56 | head_commit: String, | ||
| 57 | }, | 56 | }, |
| 58 | PatchReview { | 57 | PatchReview { |
| 59 | verdict: ReviewVerdict, | 58 | verdict: ReviewVerdict, |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -131,14 +131,40 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 131 | title, | 131 | title, |
| 132 | body, | 132 | body, |
| 133 | base, | 133 | base, |
| 134 | head, | 134 | branch, |
| 135 | fixes, | 135 | fixes, |
| 136 | } => { | 136 | } => { |
| 137 | let head = match head { | 137 | // Resolve branch name: --branch takes priority, then current branch |
| 138 | Some(h) => h, | 138 | let branch_name = if let Some(b) = branch { |
| 139 | None => repo.head()?.peel_to_commit()?.id().to_string(), | 139 | b |
| 140 | } else { | ||
| 141 | // Default to current branch | ||
| 142 | let head_ref = repo.head().map_err(|_| { | ||
| 143 | error::Error::Cmd("cannot determine current branch (detached HEAD?)".to_string()) | ||
| 144 | })?; | ||
| 145 | if head_ref.is_branch() { | ||
| 146 | let name = head_ref.shorthand().ok_or_else(|| { | ||
| 147 | error::Error::Cmd("cannot determine branch name".to_string()) | ||
| 148 | })?; | ||
| 149 | if name == base { | ||
| 150 | return Err(error::Error::Cmd( | ||
| 151 | "cannot create patch from base branch; switch to a feature branch first".to_string(), | ||
| 152 | )); | ||
| 153 | } | ||
| 154 | name.to_string() | ||
| 155 | } else { | ||
| 156 | // Detached HEAD — auto-create branch | ||
| 157 | let oid = head_ref.target().ok_or_else(|| { | ||
| 158 | error::Error::Cmd("cannot determine HEAD OID".to_string()) | ||
| 159 | })?; | ||
| 160 | let commit = repo.find_commit(oid)?; | ||
| 161 | let short_oid = &oid.to_string()[..8]; | ||
| 162 | let auto_branch = format!("collab/patch/{}", short_oid); | ||
| 163 | repo.branch(&auto_branch, &commit, false)?; | ||
| 164 | auto_branch | ||
| 165 | } | ||
| 140 | }; | 166 | }; |
| 141 | let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?; | 167 | let id = patch::create(repo, &title, &body, &base, &branch_name, fixes.as_deref())?; |
| 142 | println!("Created patch {:.8}", id); | 168 | println!("Created patch {:.8}", id); |
| 143 | Ok(()) | 169 | Ok(()) |
| 144 | } | 170 | } |
| @@ -171,7 +197,18 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 171 | println!("Patch {} [{}]", &p.id[..8], status); | 197 | println!("Patch {} [{}]", &p.id[..8], status); |
| 172 | println!("Title: {}", p.title); | 198 | println!("Title: {}", p.title); |
| 173 | println!("Author: {} <{}>", p.author.name, p.author.email); | 199 | println!("Author: {} <{}>", p.author.name, p.author.email); |
| 174 | println!("Base: {} Head: {:.8}", p.base_ref, p.head_commit); | 200 | match p.resolve_head(repo) { |
| 201 | Ok(_) => { | ||
| 202 | println!("Branch: {} -> {}", p.branch, p.base_ref); | ||
| 203 | if let Ok((ahead, behind)) = p.staleness(repo) { | ||
| 204 | let freshness = if behind == 0 { "up-to-date" } else { "outdated" }; | ||
| 205 | println!("Commits: {} ahead, {} behind ({})", ahead, behind, freshness); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | Err(_) => { | ||
| 209 | println!("Branch: {} (not found)", p.branch); | ||
| 210 | } | ||
| 211 | } | ||
| 175 | println!("Created: {}", p.created_at); | 212 | println!("Created: {}", p.created_at); |
| 176 | if let Some(ref fixes) = p.fixes { | 213 | if let Some(ref fixes) = p.fixes { |
| 177 | println!("Fixes: {:.8}", fixes); | 214 | println!("Fixes: {:.8}", fixes); |
| @@ -240,12 +277,8 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 240 | println!("Review submitted."); | 277 | println!("Review submitted."); |
| 241 | Ok(()) | 278 | Ok(()) |
| 242 | } | 279 | } |
| 243 | PatchCmd::Revise { id, head, body } => { | 280 | PatchCmd::Revise { id, body } => { |
| 244 | let head = match head { | 281 | patch::revise(repo, &id, body.as_deref())?; |
| 245 | Some(h) => h, | ||
| 246 | None => repo.head()?.peel_to_commit()?.id().to_string(), | ||
| 247 | }; | ||
| 248 | patch::revise(repo, &id, &head, body.as_deref())?; | ||
| 249 | println!("Patch revised."); | 282 | println!("Patch revised."); |
| 250 | Ok(()) | 283 | Ok(()) |
| 251 | } | 284 | } |
| @@ -259,13 +292,6 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 259 | println!("Patch closed."); | 292 | println!("Patch closed."); |
| 260 | Ok(()) | 293 | Ok(()) |
| 261 | } | 294 | } |
| 262 | PatchCmd::Import { files } => { | ||
| 263 | let ids = patch::import_series(repo, &files)?; | ||
| 264 | for id in &ids { | ||
| 265 | println!("Imported patch {:.8}", id); | ||
| 266 | } | ||
| 267 | Ok(()) | ||
| 268 | } | ||
| 269 | }, | 295 | }, |
| 270 | Commands::Dashboard => tui::run(repo), | 296 | Commands::Dashboard => tui::run(repo), |
| 271 | Commands::Sync { remote } => sync::sync(repo, &remote), | 297 | Commands::Sync { remote } => sync::sync(repo, &remote), |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,4 @@ | |||
| 1 | use std::path::Path; | 1 | use git2::{DiffFormat, Repository}; |
| 2 | |||
| 3 | use git2::{Diff, DiffFormat, Repository}; | ||
| 4 | 2 | ||
| 5 | use crate::dag; | 3 | use crate::dag; |
| 6 | use crate::error::Error; | 4 | use crate::error::Error; |
| @@ -14,9 +12,32 @@ pub fn create( | |||
| 14 | title: &str, | 12 | title: &str, |
| 15 | body: &str, | 13 | body: &str, |
| 16 | base_ref: &str, | 14 | base_ref: &str, |
| 17 | head_commit: &str, | 15 | branch: &str, |
| 18 | fixes: Option<&str>, | 16 | fixes: Option<&str>, |
| 19 | ) -> Result<String, crate::error::Error> { | 17 | ) -> Result<String, crate::error::Error> { |
| 18 | // Reject creating a patch from the base branch | ||
| 19 | if branch == base_ref { | ||
| 20 | return Err(crate::error::Error::Cmd( | ||
| 21 | "cannot create patch from base branch".to_string(), | ||
| 22 | )); | ||
| 23 | } | ||
| 24 | |||
| 25 | // Verify branch exists | ||
| 26 | let branch_ref = format!("refs/heads/{}", branch); | ||
| 27 | repo.refname_to_id(&branch_ref) | ||
| 28 | .map_err(|e| crate::error::Error::Cmd(format!("branch '{}' not found: {}", branch, e)))?; | ||
| 29 | |||
| 30 | // Check for duplicate: scan open patches for matching branch | ||
| 31 | let patches = state::list_patches(repo)?; | ||
| 32 | for p in &patches { | ||
| 33 | if p.status == PatchStatus::Open && p.branch == branch { | ||
| 34 | return Err(crate::error::Error::Cmd(format!( | ||
| 35 | "patch already exists for branch '{}'", | ||
| 36 | branch | ||
| 37 | ))); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 20 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 41 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| 21 | let author = get_author(repo)?; | 42 | let author = get_author(repo)?; |
| 22 | let event = Event { | 43 | let event = Event { |
| @@ -26,7 +47,7 @@ pub fn create( | |||
| 26 | title: title.to_string(), | 47 | title: title.to_string(), |
| 27 | body: body.to_string(), | 48 | body: body.to_string(), |
| 28 | base_ref: base_ref.to_string(), | 49 | base_ref: base_ref.to_string(), |
| 29 | head_commit: head_commit.to_string(), | 50 | branch: branch.to_string(), |
| 30 | fixes: fixes.map(|s| s.to_string()), | 51 | fixes: fixes.map(|s| s.to_string()), |
| 31 | }, | 52 | }, |
| 32 | }; | 53 | }; |
| @@ -112,7 +133,6 @@ pub fn review( | |||
| 112 | pub fn revise( | 133 | pub fn revise( |
| 113 | repo: &Repository, | 134 | repo: &Repository, |
| 114 | id_prefix: &str, | 135 | id_prefix: &str, |
| 115 | head_commit: &str, | ||
| 116 | body: Option<&str>, | 136 | body: Option<&str>, |
| 117 | ) -> Result<(), crate::error::Error> { | 137 | ) -> Result<(), crate::error::Error> { |
| 118 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; | 138 | let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; |
| @@ -123,7 +143,6 @@ pub fn revise( | |||
| 123 | author, | 143 | author, |
| 124 | action: Action::PatchRevise { | 144 | action: Action::PatchRevise { |
| 125 | body: body.map(|s| s.to_string()), | 145 | body: body.map(|s| s.to_string()), |
| 126 | head_commit: head_commit.to_string(), | ||
| 127 | }, | 146 | }, |
| 128 | }; | 147 | }; |
| 129 | dag::append_event(repo, &ref_name, &event, &sk)?; | 148 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| @@ -143,13 +162,10 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er | |||
| 143 | .into()); | 162 | .into()); |
| 144 | } | 163 | } |
| 145 | 164 | ||
| 146 | // Resolve the head commit (supports short OIDs via revparse) | 165 | // Resolve the head commit: use branch tip for branch-based patches, stored OID otherwise |
| 147 | let head_obj = repo | 166 | let head_oid = p.resolve_head(repo)?; |
| 148 | .revparse_single(&p.head_commit) | 167 | let head_commit = repo.find_commit(head_oid) |
| 149 | .map_err(|_| git2::Error::from_str("cannot resolve head commit in patch"))?; | 168 | .map_err(|_| git2::Error::from_str("cannot resolve head commit in patch"))?; |
| 150 | let head_commit = head_obj | ||
| 151 | .into_commit() | ||
| 152 | .map_err(|_| git2::Error::from_str("head ref is not a commit"))?; | ||
| 153 | 169 | ||
| 154 | let base_ref = format!("refs/heads/{}", p.base_ref); | 170 | let base_ref = format!("refs/heads/{}", p.base_ref); |
| 155 | let base_oid = repo.refname_to_id(&base_ref)?; | 171 | let base_oid = repo.refname_to_id(&base_ref)?; |
| @@ -218,20 +234,22 @@ pub fn diff(repo: &Repository, id_prefix: &str) -> Result<String, Error> { | |||
| 218 | generate_diff(repo, &p) | 234 | generate_diff(repo, &p) |
| 219 | } | 235 | } |
| 220 | 236 | ||
| 221 | /// Generate a diff string from a patch's base and head. | 237 | /// Generate a diff string from a patch's base and head using three-dot (merge-base) diff. |
| 222 | pub fn generate_diff(repo: &Repository, patch: &PatchState) -> Result<String, Error> { | 238 | pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<String, Error> { |
| 223 | let head_obj = repo | 239 | let head_oid = patch.resolve_head(repo)?; |
| 224 | .revparse_single(&patch.head_commit) | 240 | let head_commit = repo.find_commit(head_oid) |
| 225 | .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; | 241 | .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; |
| 226 | let head_commit = head_obj | ||
| 227 | .into_commit() | ||
| 228 | .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?; | ||
| 229 | let head_tree = head_commit.tree()?; | 242 | let head_tree = head_commit.tree()?; |
| 230 | 243 | ||
| 231 | let base_ref = format!("refs/heads/{}", patch.base_ref); | 244 | let base_ref = format!("refs/heads/{}", patch.base_ref); |
| 232 | let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { | 245 | let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { |
| 233 | let base_commit = repo.find_commit(base_oid)?; | 246 | if let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) { |
| 234 | Some(base_commit.tree()?) | 247 | let merge_base_commit = repo.find_commit(merge_base_oid)?; |
| 248 | Some(merge_base_commit.tree()?) | ||
| 249 | } else { | ||
| 250 | let base_commit = repo.find_commit(base_oid)?; | ||
| 251 | Some(base_commit.tree()?) | ||
| 252 | } | ||
| 235 | } else { | 253 | } else { |
| 236 | None | 254 | None |
| 237 | }; | 255 | }; |
| @@ -284,186 +302,3 @@ pub fn close( | |||
| 284 | Ok(()) | 302 | Ok(()) |
| 285 | } | 303 | } |
| 286 | 304 | ||
| 287 | // --------------------------------------------------------------------------- | ||
| 288 | // Patch import from format-patch files | ||
| 289 | // --------------------------------------------------------------------------- | ||
| 290 | |||
| 291 | /// Parsed metadata from a git format-patch mbox header. | ||
| 292 | struct PatchHeader { | ||
| 293 | subject: String, | ||
| 294 | body: String, | ||
| 295 | } | ||
| 296 | |||
| 297 | /// Parse a format-patch file into its mbox header metadata and the raw diff portion. | ||
| 298 | fn parse_format_patch(content: &str) -> Result<(PatchHeader, String), Error> { | ||
| 299 | // Find the "---" separator that divides the commit message from the diffstat/diff. | ||
| 300 | // The diff starts at the first line matching "diff --git". | ||
| 301 | let diff_start = content | ||
| 302 | .find("\ndiff --git ") | ||
| 303 | .map(|i| i + 1) // skip the leading newline | ||
| 304 | .ok_or_else(|| Error::MalformedPatch("no 'diff --git' found in patch file".to_string()))?; | ||
| 305 | |||
| 306 | let header_section = &content[..diff_start]; | ||
| 307 | let diff_section = &content[diff_start..]; | ||
| 308 | |||
| 309 | // Extract Subject line | ||
| 310 | let subject_line = header_section | ||
| 311 | .lines() | ||
| 312 | .find(|l| l.starts_with("Subject:")) | ||
| 313 | .ok_or_else(|| Error::MalformedPatch("no Subject header found".to_string()))?; | ||
| 314 | |||
| 315 | // Strip "Subject: " prefix and optional "[PATCH] " or "[PATCH n/m] " prefix | ||
| 316 | let subject = subject_line | ||
| 317 | .strip_prefix("Subject:") | ||
| 318 | .unwrap() | ||
| 319 | .trim(); | ||
| 320 | let subject = if let Some(rest) = subject.strip_prefix("[PATCH") { | ||
| 321 | // Skip to the "] " closing bracket | ||
| 322 | if let Some(idx) = rest.find("] ") { | ||
| 323 | rest[idx + 2..].to_string() | ||
| 324 | } else { | ||
| 325 | subject.to_string() | ||
| 326 | } | ||
| 327 | } else { | ||
| 328 | subject.to_string() | ||
| 329 | }; | ||
| 330 | |||
| 331 | // Extract body: everything between the blank line after headers and the "---" separator | ||
| 332 | let body = extract_body(header_section); | ||
| 333 | |||
| 334 | // Trim trailing "-- \n2.xx.x\n" signature from diff | ||
| 335 | let diff_clean = trim_patch_signature(diff_section); | ||
| 336 | |||
| 337 | Ok((PatchHeader { subject, body }, diff_clean)) | ||
| 338 | } | ||
| 339 | |||
| 340 | /// Extract the commit message body from the header section. | ||
| 341 | /// The body is between the first blank line after headers and the "---" line. | ||
| 342 | fn extract_body(header_section: &str) -> String { | ||
| 343 | let lines: Vec<&str> = header_section.lines().collect(); | ||
| 344 | let mut body_start = None; | ||
| 345 | let mut body_end = None; | ||
| 346 | |||
| 347 | // Find first blank line (end of mail headers) | ||
| 348 | for (i, line) in lines.iter().enumerate() { | ||
| 349 | if line.is_empty() && body_start.is_none() { | ||
| 350 | body_start = Some(i + 1); | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | // Find the "---" separator line (start of diffstat) | ||
| 355 | for (i, line) in lines.iter().enumerate().rev() { | ||
| 356 | if *line == "---" { | ||
| 357 | body_end = Some(i); | ||
| 358 | break; | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | match (body_start, body_end) { | ||
| 363 | (Some(start), Some(end)) if start < end => { | ||
| 364 | lines[start..end].join("\n").trim().to_string() | ||
| 365 | } | ||
| 366 | (Some(start), None) => { | ||
| 367 | // No "---" separator, take everything after headers | ||
| 368 | lines[start..].join("\n").trim().to_string() | ||
| 369 | } | ||
| 370 | _ => String::new(), | ||
| 371 | } | ||
| 372 | } | ||
| 373 | |||
| 374 | /// Remove trailing git patch signature ("-- \n2.xx.x\n") from diff content. | ||
| 375 | fn trim_patch_signature(diff: &str) -> String { | ||
| 376 | if let Some(idx) = diff.rfind("\n-- \n") { | ||
| 377 | diff[..idx + 1].to_string() // keep the trailing newline | ||
| 378 | } else { | ||
| 379 | diff.to_string() | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | /// Import a single format-patch file, creating a commit and DAG entry. | ||
| 384 | /// Returns the patch ID. | ||
| 385 | pub fn import(repo: &Repository, patch_path: &Path) -> Result<String, Error> { | ||
| 386 | let content = std::fs::read_to_string(patch_path)?; | ||
| 387 | let (header, diff_text) = parse_format_patch(&content)?; | ||
| 388 | |||
| 389 | // Parse the diff with git2 | ||
| 390 | let diff = Diff::from_buffer(diff_text.as_bytes()) | ||
| 391 | .map_err(|e| Error::MalformedPatch(format!("invalid diff: {}", e)))?; | ||
| 392 | |||
| 393 | // Get the base (HEAD) commit and its tree | ||
| 394 | let head_ref = repo | ||
| 395 | .head() | ||
| 396 | .map_err(|e| Error::PatchApplyFailed(format!("cannot resolve HEAD: {}", e)))?; | ||
| 397 | let head_commit = head_ref | ||
| 398 | .peel_to_commit() | ||
| 399 | .map_err(|e| Error::PatchApplyFailed(format!("HEAD is not a commit: {}", e)))?; | ||
| 400 | let base_tree = head_commit.tree()?; | ||
| 401 | |||
| 402 | // Apply the diff to the base tree in-memory | ||
| 403 | let new_index = repo | ||
| 404 | .apply_to_tree(&base_tree, &diff, None) | ||
| 405 | .map_err(|e| Error::PatchApplyFailed(format!("apply failed: {}", e)))?; | ||
| 406 | |||
| 407 | // Write the index to a tree | ||
| 408 | let tree_oid = { | ||
| 409 | let mut idx = new_index; | ||
| 410 | idx.write_tree_to(repo)? | ||
| 411 | }; | ||
| 412 | let new_tree = repo.find_tree(tree_oid)?; | ||
| 413 | |||
| 414 | // Create a commit on a detached ref (no branch update) | ||
| 415 | let author = get_author(repo)?; | ||
| 416 | let sig = crate::identity::author_signature(&author)?; | ||
| 417 | let commit_msg = format!("imported: {}", header.subject); | ||
| 418 | let commit_oid = repo.commit( | ||
| 419 | None, // don't update any ref | ||
| 420 | &sig, | ||
| 421 | &sig, | ||
| 422 | &commit_msg, | ||
| 423 | &new_tree, | ||
| 424 | &[&head_commit], | ||
| 425 | )?; | ||
| 426 | |||
| 427 | // Determine the base branch name from HEAD | ||
| 428 | let base_ref = repo | ||
| 429 | .head()? | ||
| 430 | .shorthand() | ||
| 431 | .unwrap_or("main") | ||
| 432 | .to_string(); | ||
| 433 | |||
| 434 | // Create a DAG entry using the existing patch create infrastructure | ||
| 435 | let id = create( | ||
| 436 | repo, | ||
| 437 | &header.subject, | ||
| 438 | &header.body, | ||
| 439 | &base_ref, | ||
| 440 | &commit_oid.to_string(), | ||
| 441 | None, | ||
| 442 | )?; | ||
| 443 | |||
| 444 | Ok(id) | ||
| 445 | } | ||
| 446 | |||
| 447 | /// Import a series of format-patch files. If any fails, rolls back all | ||
| 448 | /// previously imported patches from this series. | ||
| 449 | pub fn import_series(repo: &Repository, files: &[impl AsRef<Path>]) -> Result<Vec<String>, Error> { | ||
| 450 | let mut imported_ids: Vec<String> = Vec::new(); | ||
| 451 | |||
| 452 | for file in files { | ||
| 453 | match import(repo, file.as_ref()) { | ||
| 454 | Ok(id) => imported_ids.push(id), | ||
| 455 | Err(e) => { | ||
| 456 | // Rollback: delete all refs created in this series | ||
| 457 | for id in &imported_ids { | ||
| 458 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 459 | if let Ok(mut reference) = repo.find_reference(&ref_name) { | ||
| 460 | let _ = reference.delete(); | ||
| 461 | } | ||
| 462 | } | ||
| 463 | return Err(e); | ||
| 464 | } | ||
| 465 | } | ||
| 466 | } | ||
| 467 | |||
| 468 | Ok(imported_ids) | ||
| 469 | } | ||
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -65,8 +65,8 @@ pub struct PatchState { | |||
| 65 | pub body: String, | 65 | pub body: String, |
| 66 | pub status: PatchStatus, | 66 | pub status: PatchStatus, |
| 67 | pub base_ref: String, | 67 | pub base_ref: String, |
| 68 | pub head_commit: String, | ||
| 69 | pub fixes: Option<String>, | 68 | pub fixes: Option<String>, |
| 69 | pub branch: String, | ||
| 70 | pub comments: Vec<Comment>, | 70 | pub comments: Vec<Comment>, |
| 71 | pub inline_comments: Vec<InlineComment>, | 71 | pub inline_comments: Vec<InlineComment>, |
| 72 | pub reviews: Vec<Review>, | 72 | pub reviews: Vec<Review>, |
| @@ -179,6 +179,28 @@ impl IssueState { | |||
| 179 | } | 179 | } |
| 180 | 180 | ||
| 181 | impl PatchState { | 181 | impl PatchState { |
| 182 | /// Resolve the current head commit OID for this patch by looking up `refs/heads/{branch}`. | ||
| 183 | pub fn resolve_head(&self, repo: &Repository) -> Result<Oid, crate::error::Error> { | ||
| 184 | let ref_name = format!("refs/heads/{}", self.branch); | ||
| 185 | repo.refname_to_id(&ref_name).map_err(|e| { | ||
| 186 | crate::error::Error::Cmd(format!( | ||
| 187 | "branch '{}' not found: {}", | ||
| 188 | self.branch, e | ||
| 189 | )) | ||
| 190 | }) | ||
| 191 | } | ||
| 192 | |||
| 193 | /// Compute staleness: how many commits the branch is ahead of base, | ||
| 194 | /// and how many commits the base is ahead of the branch. | ||
| 195 | /// Returns (ahead, behind). | ||
| 196 | pub fn staleness(&self, repo: &Repository) -> Result<(usize, usize), crate::error::Error> { | ||
| 197 | let branch_tip = self.resolve_head(repo)?; | ||
| 198 | let base_ref = format!("refs/heads/{}", self.base_ref); | ||
| 199 | let base_tip = repo.refname_to_id(&base_ref)?; | ||
| 200 | let (ahead, behind) = repo.graph_ahead_behind(branch_tip, base_tip)?; | ||
| 201 | Ok((ahead, behind)) | ||
| 202 | } | ||
| 203 | |||
| 182 | pub fn from_ref( | 204 | pub fn from_ref( |
| 183 | repo: &Repository, | 205 | repo: &Repository, |
| 184 | ref_name: &str, | 206 | ref_name: &str, |
| @@ -195,7 +217,7 @@ impl PatchState { | |||
| 195 | title, | 217 | title, |
| 196 | body, | 218 | body, |
| 197 | base_ref, | 219 | base_ref, |
| 198 | head_commit, | 220 | branch, |
| 199 | fixes, | 221 | fixes, |
| 200 | } => { | 222 | } => { |
| 201 | state = Some(PatchState { | 223 | state = Some(PatchState { |
| @@ -204,8 +226,8 @@ impl PatchState { | |||
| 204 | body, | 226 | body, |
| 205 | status: PatchStatus::Open, | 227 | status: PatchStatus::Open, |
| 206 | base_ref, | 228 | base_ref, |
| 207 | head_commit, | ||
| 208 | fixes, | 229 | fixes, |
| 230 | branch, | ||
| 209 | comments: Vec::new(), | 231 | comments: Vec::new(), |
| 210 | inline_comments: Vec::new(), | 232 | inline_comments: Vec::new(), |
| 211 | reviews: Vec::new(), | 233 | reviews: Vec::new(), |
| @@ -213,9 +235,8 @@ impl PatchState { | |||
| 213 | author: event.author.clone(), | 235 | author: event.author.clone(), |
| 214 | }); | 236 | }); |
| 215 | } | 237 | } |
| 216 | Action::PatchRevise { body, head_commit } => { | 238 | Action::PatchRevise { body } => { |
| 217 | if let Some(ref mut s) = state { | 239 | if let Some(ref mut s) = state { |
| 218 | s.head_commit = head_commit; | ||
| 219 | if let Some(b) = body { | 240 | if let Some(b) = body { |
| 220 | s.body = b; | 241 | s.body = b; |
| 221 | } | 242 | } |
src/tui.rs
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,6 @@ use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tab | |||
| 12 | use crate::error::Error; | 12 | use crate::error::Error; |
| 13 | use crate::event::Action; | 13 | use crate::event::Action; |
| 14 | use crate::issue as issue_mod; | 14 | use crate::issue as issue_mod; |
| 15 | use crate::patch as patch_mod; | ||
| 16 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 15 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 17 | 16 | ||
| 18 | #[derive(Debug, PartialEq)] | 17 | #[derive(Debug, PartialEq)] |
| @@ -76,12 +75,20 @@ enum InputMode { | |||
| 76 | CreateBody, | 75 | CreateBody, |
| 77 | } | 76 | } |
| 78 | 77 | ||
| 78 | /// Cached staleness info for a patch. | ||
| 79 | #[derive(Clone)] | ||
| 80 | struct PatchBranchInfo { | ||
| 81 | staleness: Option<(usize, usize)>, | ||
| 82 | branch_exists: bool, | ||
| 83 | } | ||
| 84 | |||
| 79 | struct App { | 85 | struct App { |
| 80 | tab: Tab, | 86 | tab: Tab, |
| 81 | issues: Vec<IssueState>, | 87 | issues: Vec<IssueState>, |
| 82 | patches: Vec<PatchState>, | 88 | patches: Vec<PatchState>, |
| 83 | list_state: ListState, | 89 | list_state: ListState, |
| 84 | diff_cache: HashMap<String, String>, | 90 | diff_cache: HashMap<String, String>, |
| 91 | branch_info_cache: HashMap<String, PatchBranchInfo>, | ||
| 85 | scroll: u16, | 92 | scroll: u16, |
| 86 | pane: Pane, | 93 | pane: Pane, |
| 87 | mode: ViewMode, | 94 | mode: ViewMode, |
| @@ -107,6 +114,7 @@ impl App { | |||
| 107 | patches, | 114 | patches, |
| 108 | list_state, | 115 | list_state, |
| 109 | diff_cache: HashMap::new(), | 116 | diff_cache: HashMap::new(), |
| 117 | branch_info_cache: HashMap::new(), | ||
| 110 | scroll: 0, | 118 | scroll: 0, |
| 111 | pane: Pane::ItemList, | 119 | pane: Pane::ItemList, |
| 112 | mode: ViewMode::Details, | 120 | mode: ViewMode::Details, |
| @@ -482,6 +490,71 @@ fn action_type_label(action: &Action) -> &str { | |||
| 482 | } | 490 | } |
| 483 | } | 491 | } |
| 484 | 492 | ||
| 493 | fn generate_diff(repo: &Repository, patch: &PatchState) -> String { | ||
| 494 | let result = (|| -> Result<String, Error> { | ||
| 495 | let head_oid = patch.resolve_head(repo)?; | ||
| 496 | let head_commit = repo.find_commit(head_oid) | ||
| 497 | .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; | ||
| 498 | let head_tree = head_commit.tree()?; | ||
| 499 | |||
| 500 | let base_ref = format!("refs/heads/{}", patch.base_ref); | ||
| 501 | |||
| 502 | let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { | ||
| 503 | if let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) { | ||
| 504 | let merge_base_commit = repo.find_commit(merge_base_oid)?; | ||
| 505 | Some(merge_base_commit.tree()?) | ||
| 506 | } else { | ||
| 507 | let base_commit = repo.find_commit(base_oid)?; | ||
| 508 | Some(base_commit.tree()?) | ||
| 509 | } | ||
| 510 | } else { | ||
| 511 | None | ||
| 512 | }; | ||
| 513 | |||
| 514 | let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; | ||
| 515 | |||
| 516 | let mut output = String::new(); | ||
| 517 | let mut lines = 0usize; | ||
| 518 | diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { | ||
| 519 | if lines >= 5000 { | ||
| 520 | return false; | ||
| 521 | } | ||
| 522 | let prefix = match line.origin() { | ||
| 523 | '+' => "+", | ||
| 524 | '-' => "-", | ||
| 525 | ' ' => " ", | ||
| 526 | 'H' | 'F' => "", | ||
| 527 | _ => "", | ||
| 528 | }; | ||
| 529 | if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') { | ||
| 530 | output.push_str(prefix); | ||
| 531 | } | ||
| 532 | if let Ok(content) = std::str::from_utf8(line.content()) { | ||
| 533 | output.push_str(content); | ||
| 534 | } | ||
| 535 | lines += 1; | ||
| 536 | true | ||
| 537 | })?; | ||
| 538 | |||
| 539 | if lines >= 5000 { | ||
| 540 | output.push_str("\n[truncated at 5000 lines]"); | ||
| 541 | } | ||
| 542 | |||
| 543 | Ok(output) | ||
| 544 | })(); | ||
| 545 | |||
| 546 | match result { | ||
| 547 | Ok(diff) => { | ||
| 548 | if diff.is_empty() { | ||
| 549 | "No diff available (commits may be identical)".to_string() | ||
| 550 | } else { | ||
| 551 | diff | ||
| 552 | } | ||
| 553 | } | ||
| 554 | Err(e) => format!("Diff unavailable: {}", e), | ||
| 555 | } | ||
| 556 | } | ||
| 557 | |||
| 485 | fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | 558 | fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { |
| 486 | let short_oid = &oid.to_string()[..7]; | 559 | let short_oid = &oid.to_string()[..7]; |
| 487 | let action_label = action_type_label(&event.action); | 560 | let action_label = action_type_label(&event.action); |
| @@ -511,18 +584,17 @@ fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | |||
| 511 | title, | 584 | title, |
| 512 | body, | 585 | body, |
| 513 | base_ref, | 586 | base_ref, |
| 514 | head_commit, | 587 | branch, |
| 515 | .. | 588 | .. |
| 516 | } => { | 589 | } => { |
| 517 | detail.push_str(&format!("\nTitle: {}\n", title)); | 590 | detail.push_str(&format!("\nTitle: {}\n", title)); |
| 518 | detail.push_str(&format!("Base: {}\n", base_ref)); | 591 | detail.push_str(&format!("Base: {}\n", base_ref)); |
| 519 | detail.push_str(&format!("Head: {}\n", head_commit)); | 592 | detail.push_str(&format!("Branch: {}\n", branch)); |
| 520 | if !body.is_empty() { | 593 | if !body.is_empty() { |
| 521 | detail.push_str(&format!("\n{}\n", body)); | 594 | detail.push_str(&format!("\n{}\n", body)); |
| 522 | } | 595 | } |
| 523 | } | 596 | } |
| 524 | Action::PatchRevise { body, head_commit } => { | 597 | Action::PatchRevise { body } => { |
| 525 | detail.push_str(&format!("\nHead: {}\n", head_commit)); | ||
| 526 | if let Some(b) = body { | 598 | if let Some(b) = body { |
| 527 | if !b.is_empty() { | 599 | if !b.is_empty() { |
| 528 | detail.push_str(&format!("\n{}\n", b)); | 600 | detail.push_str(&format!("\n{}\n", b)); |
| @@ -593,21 +665,47 @@ fn run_loop( | |||
| 593 | repo: &Repository, | 665 | repo: &Repository, |
| 594 | ) -> Result<(), Error> { | 666 | ) -> Result<(), Error> { |
| 595 | loop { | 667 | loop { |
| 596 | // Cache diff for selected patch if needed | 668 | // Cache diff and branch info for selected patch if needed |
| 597 | if app.tab == Tab::Patches && app.mode == ViewMode::Diff { | 669 | if app.tab == Tab::Patches { |
| 598 | if let Some(idx) = app.list_state.selected() { | 670 | if let Some(idx) = app.list_state.selected() { |
| 599 | let visible = app.visible_patches(); | 671 | // Collect info we need without holding the borrow |
| 600 | if let Some(patch) = visible.get(idx) { | 672 | let patch_data = { |
| 601 | if !app.diff_cache.contains_key(&patch.id) { | 673 | let visible = app.visible_patches(); |
| 674 | visible.get(idx).map(|patch| { | ||
| 602 | let id = patch.id.clone(); | 675 | let id = patch.id.clone(); |
| 603 | let diff = match patch_mod::generate_diff(repo, patch) { | 676 | let needs_branch_info = !app.branch_info_cache.contains_key(&id); |
| 604 | Ok(d) if d.is_empty() => { | 677 | let needs_diff = app.mode == ViewMode::Diff && !app.diff_cache.contains_key(&id); |
| 605 | "No diff available (commits may be identical)".to_string() | 678 | let branch_info = if needs_branch_info { |
| 606 | } | 679 | Some({ |
| 607 | Ok(d) => d, | 680 | let branch_exists = patch.resolve_head(repo).is_ok(); |
| 608 | Err(e) => format!("Diff unavailable: {}", e), | 681 | let staleness = if branch_exists { |
| 682 | patch.staleness(repo).ok() | ||
| 683 | } else { | ||
| 684 | None | ||
| 685 | }; | ||
| 686 | PatchBranchInfo { | ||
| 687 | staleness, | ||
| 688 | branch_exists, | ||
| 689 | } | ||
| 690 | }) | ||
| 691 | } else { | ||
| 692 | None | ||
| 693 | }; | ||
| 694 | let diff = if needs_diff { | ||
| 695 | Some(generate_diff(repo, patch)) | ||
| 696 | } else { | ||
| 697 | None | ||
| 609 | }; | 698 | }; |
| 610 | app.diff_cache.insert(id, diff); | 699 | (id, branch_info, diff) |
| 700 | }) | ||
| 701 | }; | ||
| 702 | |||
| 703 | if let Some((id, branch_info, diff)) = patch_data { | ||
| 704 | if let Some(info) = branch_info { | ||
| 705 | app.branch_info_cache.insert(id.clone(), info); | ||
| 706 | } | ||
| 707 | if let Some(d) = diff { | ||
| 708 | app.diff_cache.insert(id, d); | ||
| 611 | } | 709 | } |
| 612 | } | 710 | } |
| 613 | } | 711 | } |
| @@ -756,10 +854,10 @@ fn run_loop( | |||
| 756 | app.list_state | 854 | app.list_state |
| 757 | .selected() | 855 | .selected() |
| 758 | .and_then(|idx| visible.get(idx)) | 856 | .and_then(|idx| visible.get(idx)) |
| 759 | .map(|p| p.head_commit.clone()) | 857 | .map(|p| p.branch.clone()) |
| 760 | } | 858 | } |
| 761 | Tab::Issues => { | 859 | Tab::Issues => { |
| 762 | // Find linked patch's head commit, or fall back to closing commit | 860 | // Find linked patch's branch, or fall back to closing commit |
| 763 | let visible = app.visible_issues(); | 861 | let visible = app.visible_issues(); |
| 764 | app.list_state | 862 | app.list_state |
| 765 | .selected() | 863 | .selected() |
| @@ -769,7 +867,7 @@ fn run_loop( | |||
| 769 | app.patches | 867 | app.patches |
| 770 | .iter() | 868 | .iter() |
| 771 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | 869 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) |
| 772 | .map(|p| p.head_commit.clone()) | 870 | .map(|p| p.branch.clone()) |
| 773 | // Fall back to closing commit | 871 | // Fall back to closing commit |
| 774 | .or_else(|| { | 872 | .or_else(|| { |
| 775 | issue.closed_by.map(|oid| oid.to_string()) | 873 | issue.closed_by.map(|oid| oid.to_string()) |
| @@ -1044,7 +1142,10 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 1044 | let selected_idx = app.list_state.selected().unwrap_or(0); | 1142 | let selected_idx = app.list_state.selected().unwrap_or(0); |
| 1045 | match visible.get(selected_idx) { | 1143 | match visible.get(selected_idx) { |
| 1046 | Some(patch) => match app.mode { | 1144 | Some(patch) => match app.mode { |
| 1047 | ViewMode::Details => build_patch_detail(patch), | 1145 | ViewMode::Details => { |
| 1146 | let branch_info = app.branch_info_cache.get(&patch.id).cloned(); | ||
| 1147 | build_patch_detail(patch, branch_info.as_ref()) | ||
| 1148 | } | ||
| 1048 | ViewMode::Diff => { | 1149 | ViewMode::Diff => { |
| 1049 | let diff_text = app | 1150 | let diff_text = app |
| 1050 | .diff_cache | 1151 | .diff_cache |
| @@ -1202,7 +1303,7 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati | |||
| 1202 | Text::from(lines) | 1303 | Text::from(lines) |
| 1203 | } | 1304 | } |
| 1204 | 1305 | ||
| 1205 | fn build_patch_detail(patch: &PatchState) -> Text<'static> { | 1306 | fn build_patch_detail(patch: &PatchState, branch_info: Option<&PatchBranchInfo>) -> Text<'static> { |
| 1206 | let status = match patch.status { | 1307 | let status = match patch.status { |
| 1207 | PatchStatus::Open => "open", | 1308 | PatchStatus::Open => "open", |
| 1208 | PatchStatus::Closed => "closed", | 1309 | PatchStatus::Closed => "closed", |
| @@ -1228,22 +1329,48 @@ fn build_patch_detail(patch: &PatchState) -> Text<'static> { | |||
| 1228 | Span::styled("Author: ", Style::default().fg(Color::DarkGray)), | 1329 | Span::styled("Author: ", Style::default().fg(Color::DarkGray)), |
| 1229 | Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)), | 1330 | Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)), |
| 1230 | ]), | 1331 | ]), |
| 1231 | Line::from(vec![ | ||
| 1232 | Span::styled("Base: ", Style::default().fg(Color::DarkGray)), | ||
| 1233 | Span::raw(patch.base_ref.clone()), | ||
| 1234 | Span::raw(" "), | ||
| 1235 | Span::styled("Head: ", Style::default().fg(Color::DarkGray)), | ||
| 1236 | Span::styled( | ||
| 1237 | format!("{:.8}", patch.head_commit), | ||
| 1238 | Style::default().fg(Color::Cyan), | ||
| 1239 | ), | ||
| 1240 | ]), | ||
| 1241 | Line::from(vec![ | ||
| 1242 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), | ||
| 1243 | Span::raw(patch.created_at.clone()), | ||
| 1244 | ]), | ||
| 1245 | ]; | 1332 | ]; |
| 1246 | 1333 | ||
| 1334 | if let Some(info) = branch_info { | ||
| 1335 | if info.branch_exists { | ||
| 1336 | lines.push(Line::from(vec![ | ||
| 1337 | Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), | ||
| 1338 | Span::styled( | ||
| 1339 | patch.branch.clone(), | ||
| 1340 | Style::default().fg(Color::Cyan), | ||
| 1341 | ), | ||
| 1342 | Span::raw(format!(" -> {}", patch.base_ref)), | ||
| 1343 | ])); | ||
| 1344 | if let Some((ahead, behind)) = info.staleness { | ||
| 1345 | let freshness = if behind == 0 { "up-to-date" } else { "outdated" }; | ||
| 1346 | lines.push(Line::from(vec![ | ||
| 1347 | Span::styled("Commits: ", Style::default().fg(Color::DarkGray)), | ||
| 1348 | Span::raw(format!("{} ahead, {} behind ({})", ahead, behind, freshness)), | ||
| 1349 | ])); | ||
| 1350 | } | ||
| 1351 | } else { | ||
| 1352 | lines.push(Line::from(vec![ | ||
| 1353 | Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), | ||
| 1354 | Span::styled( | ||
| 1355 | patch.branch.clone(), | ||
| 1356 | Style::default().fg(Color::Red), | ||
| 1357 | ), | ||
| 1358 | Span::styled(" (not found)", Style::default().fg(Color::Red)), | ||
| 1359 | ])); | ||
| 1360 | } | ||
| 1361 | } else { | ||
| 1362 | lines.push(Line::from(vec![ | ||
| 1363 | Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), | ||
| 1364 | Span::raw(patch.branch.clone()), | ||
| 1365 | Span::raw(format!(" -> {}", patch.base_ref)), | ||
| 1366 | ])); | ||
| 1367 | } | ||
| 1368 | |||
| 1369 | lines.push(Line::from(vec![ | ||
| 1370 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), | ||
| 1371 | Span::raw(patch.created_at.clone()), | ||
| 1372 | ])); | ||
| 1373 | |||
| 1247 | if let Some(ref fixes) = patch.fixes { | 1374 | if let Some(ref fixes) = patch.fixes { |
| 1248 | lines.push(Line::from(vec![ | 1375 | lines.push(Line::from(vec![ |
| 1249 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), | 1376 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), |
| @@ -1586,8 +1713,8 @@ mod tests { | |||
| 1586 | body: String::new(), | 1713 | body: String::new(), |
| 1587 | status, | 1714 | status, |
| 1588 | base_ref: "main".into(), | 1715 | base_ref: "main".into(), |
| 1589 | head_commit: "abc123".into(), | ||
| 1590 | fixes: None, | 1716 | fixes: None, |
| 1717 | branch: format!("feature/{}", id), | ||
| 1591 | comments: vec![], | 1718 | comments: vec![], |
| 1592 | inline_comments: vec![], | 1719 | inline_comments: vec![], |
| 1593 | reviews: vec![], | 1720 | reviews: vec![], |
| @@ -1803,8 +1930,8 @@ mod tests { | |||
| 1803 | PatchStatus::Closed | 1930 | PatchStatus::Closed |
| 1804 | }, | 1931 | }, |
| 1805 | base_ref: "main".to_string(), | 1932 | base_ref: "main".to_string(), |
| 1806 | head_commit: format!("h{:07x}", i), | ||
| 1807 | fixes: None, | 1933 | fixes: None, |
| 1934 | branch: format!("feature/p{:07x}", i), | ||
| 1808 | comments: Vec::new(), | 1935 | comments: Vec::new(), |
| 1809 | inline_comments: Vec::new(), | 1936 | inline_comments: Vec::new(), |
| 1810 | reviews: Vec::new(), | 1937 | reviews: Vec::new(), |
| @@ -1928,7 +2055,7 @@ mod tests { | |||
| 1928 | title: "t".to_string(), | 2055 | title: "t".to_string(), |
| 1929 | body: "b".to_string(), | 2056 | body: "b".to_string(), |
| 1930 | base_ref: "main".to_string(), | 2057 | base_ref: "main".to_string(), |
| 1931 | head_commit: "abc".to_string(), | 2058 | branch: "feature/test".to_string(), |
| 1932 | fixes: None, | 2059 | fixes: None, |
| 1933 | }; | 2060 | }; |
| 1934 | assert_eq!(action_type_label(&action), "Patch Create"); | 2061 | assert_eq!(action_type_label(&action), "Patch Create"); |
tests/cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -431,21 +431,24 @@ fn test_patch_revise() { | |||
| 431 | let repo = TestRepo::new("Alice", "alice@example.com"); | 431 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 432 | let id = repo.patch_create("WIP feature"); | 432 | let id = repo.patch_create("WIP feature"); |
| 433 | 433 | ||
| 434 | let new_head = repo.commit_file("v2.txt", "v2", "version 2"); | 434 | // Switch to the patch's branch and add a new commit |
| 435 | repo.git(&["checkout", "test/wip-feature"]); | ||
| 436 | repo.commit_file("v2.txt", "v2", "version 2"); | ||
| 437 | repo.git(&["checkout", "main"]); | ||
| 438 | |||
| 435 | let out = repo.run_ok(&[ | 439 | let out = repo.run_ok(&[ |
| 436 | "patch", | 440 | "patch", |
| 437 | "revise", | 441 | "revise", |
| 438 | &id, | 442 | &id, |
| 439 | "--head", | ||
| 440 | &new_head, | ||
| 441 | "-b", | 443 | "-b", |
| 442 | "Updated implementation", | 444 | "Updated implementation", |
| 443 | ]); | 445 | ]); |
| 444 | assert!(out.contains("Patch revised")); | 446 | assert!(out.contains("Patch revised")); |
| 445 | 447 | ||
| 446 | let out = repo.run_ok(&["patch", "show", &id]); | 448 | let out = repo.run_ok(&["patch", "show", &id]); |
| 447 | assert!(out.contains(&new_head[..8])); | ||
| 448 | assert!(out.contains("Updated implementation")); | 449 | assert!(out.contains("Updated implementation")); |
| 450 | // Branch-based patch show displays the branch name, not the raw head OID | ||
| 451 | assert!(out.contains("test/wip-feature")); | ||
| 449 | } | 452 | } |
| 450 | 453 | ||
| 451 | #[test] | 454 | #[test] |
| @@ -459,10 +462,9 @@ fn test_patch_diff() { | |||
| 459 | "fn main() {\n println!(\"hello\");\n}\n", | 462 | "fn main() {\n println!(\"hello\");\n}\n", |
| 460 | "add hello", | 463 | "add hello", |
| 461 | ); | 464 | ); |
| 462 | let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 463 | repo.git(&["checkout", "main"]); | 465 | repo.git(&["checkout", "main"]); |
| 464 | 466 | ||
| 465 | let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "--head", &head]); | 467 | let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "-B", "feature"]); |
| 466 | let id = out.trim().strip_prefix("Created patch ").unwrap(); | 468 | let id = out.trim().strip_prefix("Created patch ").unwrap(); |
| 467 | 469 | ||
| 468 | let out = repo.run_ok(&["patch", "diff", id]); | 470 | let out = repo.run_ok(&["patch", "diff", id]); |
| @@ -503,11 +505,11 @@ fn test_patch_merge_fast_forward() { | |||
| 503 | 505 | ||
| 504 | // Create a feature branch ahead of main | 506 | // Create a feature branch ahead of main |
| 505 | repo.git(&["checkout", "-b", "feature"]); | 507 | repo.git(&["checkout", "-b", "feature"]); |
| 506 | let head = repo.commit_file("feature.txt", "new feature", "add feature"); | 508 | repo.commit_file("feature.txt", "new feature", "add feature"); |
| 507 | repo.git(&["checkout", "main"]); | 509 | repo.git(&["checkout", "main"]); |
| 508 | 510 | ||
| 509 | // Create patch pointing at the feature commit | 511 | // Create patch pointing at the feature branch |
| 510 | let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "--head", &head]); | 512 | let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "-B", "feature"]); |
| 511 | let id = out.trim().strip_prefix("Created patch ").unwrap(); | 513 | let id = out.trim().strip_prefix("Created patch ").unwrap(); |
| 512 | 514 | ||
| 513 | let out = repo.run_ok(&["patch", "merge", id]); | 515 | let out = repo.run_ok(&["patch", "merge", id]); |
| @@ -515,7 +517,8 @@ fn test_patch_merge_fast_forward() { | |||
| 515 | 517 | ||
| 516 | // Main should now point at the feature commit | 518 | // Main should now point at the feature commit |
| 517 | let main_head = repo.git(&["rev-parse", "main"]).trim().to_string(); | 519 | let main_head = repo.git(&["rev-parse", "main"]).trim().to_string(); |
| 518 | assert_eq!(main_head, head); | 520 | let feature_head = repo.git(&["rev-parse", "feature"]).trim().to_string(); |
| 521 | assert_eq!(main_head, feature_head); | ||
| 519 | 522 | ||
| 520 | let out = repo.run_ok(&["patch", "show", id]); | 523 | let out = repo.run_ok(&["patch", "show", id]); |
| 521 | assert!(out.contains("[merged]")); | 524 | assert!(out.contains("[merged]")); |
| @@ -526,10 +529,10 @@ fn test_patch_cannot_merge_closed() { | |||
| 526 | let repo = TestRepo::new("Alice", "alice@example.com"); | 529 | let repo = TestRepo::new("Alice", "alice@example.com"); |
| 527 | 530 | ||
| 528 | repo.git(&["checkout", "-b", "feature"]); | 531 | repo.git(&["checkout", "-b", "feature"]); |
| 529 | let head = repo.commit_file("f.txt", "f", "feature commit"); | 532 | repo.commit_file("f.txt", "f", "feature commit"); |
| 530 | repo.git(&["checkout", "main"]); | 533 | repo.git(&["checkout", "main"]); |
| 531 | 534 | ||
| 532 | let out = repo.run_ok(&["patch", "create", "-t", "Will close", "--head", &head]); | 535 | let out = repo.run_ok(&["patch", "create", "-t", "Will close", "-B", "feature"]); |
| 533 | let id = out.trim().strip_prefix("Created patch ").unwrap(); | 536 | let id = out.trim().strip_prefix("Created patch ").unwrap(); |
| 534 | 537 | ||
| 535 | repo.run_ok(&["patch", "close", id]); | 538 | repo.run_ok(&["patch", "close", id]); |
| @@ -557,13 +560,13 @@ fn test_patch_create_with_fixes() { | |||
| 557 | let issue_id = repo.issue_open("Login bug"); | 560 | let issue_id = repo.issue_open("Login bug"); |
| 558 | 561 | ||
| 559 | repo.git(&["checkout", "-b", "fix"]); | 562 | repo.git(&["checkout", "-b", "fix"]); |
| 560 | let head = repo.commit_file("fix.rs", "fixed", "fix login"); | 563 | repo.commit_file("fix.rs", "fixed", "fix login"); |
| 561 | repo.git(&["checkout", "main"]); | 564 | repo.git(&["checkout", "main"]); |
| 562 | 565 | ||
| 563 | let out = repo.run_ok(&[ | 566 | let out = repo.run_ok(&[ |
| 564 | "patch", "create", | 567 | "patch", "create", |
| 565 | "-t", "Fix login bug", | 568 | "-t", "Fix login bug", |
| 566 | "--head", &head, | 569 | "-B", "fix", |
| 567 | "--fixes", &issue_id, | 570 | "--fixes", &issue_id, |
| 568 | ]); | 571 | ]); |
| 569 | let patch_id = out.trim().strip_prefix("Created patch ").unwrap(); | 572 | let patch_id = out.trim().strip_prefix("Created patch ").unwrap(); |
| @@ -581,13 +584,13 @@ fn test_patch_merge_auto_closes_linked_issue() { | |||
| 581 | let issue_id = repo.issue_open("Crash on startup"); | 584 | let issue_id = repo.issue_open("Crash on startup"); |
| 582 | 585 | ||
| 583 | repo.git(&["checkout", "-b", "fix"]); | 586 | repo.git(&["checkout", "-b", "fix"]); |
| 584 | let head = repo.commit_file("fix.rs", "fixed", "fix crash"); | 587 | repo.commit_file("fix.rs", "fixed", "fix crash"); |
| 585 | repo.git(&["checkout", "main"]); | 588 | repo.git(&["checkout", "main"]); |
| 586 | 589 | ||
| 587 | let out = repo.run_ok(&[ | 590 | let out = repo.run_ok(&[ |
| 588 | "patch", "create", | 591 | "patch", "create", |
| 589 | "-t", "Fix crash", | 592 | "-t", "Fix crash", |
| 590 | "--head", &head, | 593 | "-B", "fix", |
| 591 | "--fixes", &issue_id, | 594 | "--fixes", &issue_id, |
| 592 | ]); | 595 | ]); |
| 593 | let patch_id = out.trim().strip_prefix("Created patch ").unwrap(); | 596 | let patch_id = out.trim().strip_prefix("Created patch ").unwrap(); |
| @@ -712,10 +715,10 @@ fn test_full_patch_review_cycle() { | |||
| 712 | 715 | ||
| 713 | // Create feature branch with v1 | 716 | // Create feature branch with v1 |
| 714 | repo.git(&["checkout", "-b", "feature"]); | 717 | repo.git(&["checkout", "-b", "feature"]); |
| 715 | let v1 = repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature"); | 718 | repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature"); |
| 716 | repo.git(&["checkout", "main"]); | 719 | repo.git(&["checkout", "main"]); |
| 717 | 720 | ||
| 718 | let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "--head", &v1]); | 721 | let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "-B", "feature"]); |
| 719 | let id = out | 722 | let id = out |
| 720 | .trim() | 723 | .trim() |
| 721 | .strip_prefix("Created patch ") | 724 | .strip_prefix("Created patch ") |
| @@ -751,7 +754,7 @@ fn test_full_patch_review_cycle() { | |||
| 751 | 754 | ||
| 752 | // Revise with updated code | 755 | // Revise with updated code |
| 753 | repo.git(&["checkout", "feature"]); | 756 | repo.git(&["checkout", "feature"]); |
| 754 | let v2 = repo.commit_file( | 757 | repo.commit_file( |
| 755 | "feature.rs", | 758 | "feature.rs", |
| 756 | "/// Says hello\nfn hello() {}", | 759 | "/// Says hello\nfn hello() {}", |
| 757 | "v2: add docs", | 760 | "v2: add docs", |
| @@ -762,8 +765,6 @@ fn test_full_patch_review_cycle() { | |||
| 762 | "patch", | 765 | "patch", |
| 763 | "revise", | 766 | "revise", |
| 764 | &id, | 767 | &id, |
| 765 | "--head", | ||
| 766 | &v2, | ||
| 767 | "-b", | 768 | "-b", |
| 768 | "Added documentation", | 769 | "Added documentation", |
| 769 | ]); | 770 | ]); |
tests/collab_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -328,7 +328,6 @@ fn test_patch_review_workflow() { | |||
| 328 | author: alice(), | 328 | author: alice(), |
| 329 | action: Action::PatchRevise { | 329 | action: Action::PatchRevise { |
| 330 | body: Some("Updated implementation".to_string()), | 330 | body: Some("Updated implementation".to_string()), |
| 331 | head_commit: "def456".to_string(), | ||
| 332 | }, | 331 | }, |
| 333 | }; | 332 | }; |
| 334 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | 333 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); |
| @@ -337,7 +336,6 @@ fn test_patch_review_workflow() { | |||
| 337 | 336 | ||
| 338 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | 337 | let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); |
| 339 | assert_eq!(state.reviews.len(), 2); | 338 | assert_eq!(state.reviews.len(), 2); |
| 340 | assert_eq!(state.head_commit, "def456"); | ||
| 341 | assert_eq!(state.body, "Updated implementation"); | 339 | assert_eq!(state.body, "Updated implementation"); |
| 342 | } | 340 | } |
| 343 | 341 | ||
| @@ -553,3 +551,340 @@ fn test_issue_open_without_signing_key_returns_key_not_found() { | |||
| 553 | other => panic!("expected KeyNotFound error, got: {:?}", other), | 551 | other => panic!("expected KeyNotFound error, got: {:?}", other), |
| 554 | } | 552 | } |
| 555 | } | 553 | } |
| 554 | |||
| 555 | // --------------------------------------------------------------------------- | ||
| 556 | // Helpers for branch-based patch tests | ||
| 557 | // --------------------------------------------------------------------------- | ||
| 558 | |||
| 559 | /// Create an initial commit on the given branch so the repo is not empty. | ||
| 560 | fn make_initial_commit(repo: &git2::Repository, branch: &str) -> git2::Oid { | ||
| 561 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 562 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 563 | let blob = repo.blob(b"init").unwrap(); | ||
| 564 | tb.insert("README.md", blob, 0o100644).unwrap(); | ||
| 565 | let tree_oid = tb.write().unwrap(); | ||
| 566 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 567 | let oid = repo.commit(None, &sig, &sig, "initial commit", &tree, &[]).unwrap(); | ||
| 568 | let ref_name = format!("refs/heads/{}", branch); | ||
| 569 | repo.reference(&ref_name, oid, true, "init branch").unwrap(); | ||
| 570 | // Set HEAD to this branch | ||
| 571 | repo.set_head(&ref_name).unwrap(); | ||
| 572 | oid | ||
| 573 | } | ||
| 574 | |||
| 575 | /// Add a commit on the given branch, returns the new OID. | ||
| 576 | fn add_commit_on_branch(repo: &git2::Repository, branch: &str, filename: &str, content: &[u8]) -> git2::Oid { | ||
| 577 | let ref_name = format!("refs/heads/{}", branch); | ||
| 578 | let parent_oid = repo.refname_to_id(&ref_name).unwrap(); | ||
| 579 | let parent = repo.find_commit(parent_oid).unwrap(); | ||
| 580 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 581 | |||
| 582 | // Build a tree with the new file added to parent's tree | ||
| 583 | let parent_tree = parent.tree().unwrap(); | ||
| 584 | let mut tb = repo.treebuilder(Some(&parent_tree)).unwrap(); | ||
| 585 | let blob = repo.blob(content).unwrap(); | ||
| 586 | tb.insert(filename, blob, 0o100644).unwrap(); | ||
| 587 | let tree_oid = tb.write().unwrap(); | ||
| 588 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 589 | |||
| 590 | repo.commit(Some(&ref_name), &sig, &sig, &format!("add {}", filename), &tree, &[&parent]).unwrap() | ||
| 591 | } | ||
| 592 | |||
| 593 | /// Create a branch-based patch using DAG primitives. | ||
| 594 | fn create_branch_patch( | ||
| 595 | repo: &git2::Repository, | ||
| 596 | author: &Author, | ||
| 597 | title: &str, | ||
| 598 | branch: &str, | ||
| 599 | base_ref: &str, | ||
| 600 | ) -> (String, String) { | ||
| 601 | let sk = test_signing_key(); | ||
| 602 | let event = Event { | ||
| 603 | timestamp: now(), | ||
| 604 | author: author.clone(), | ||
| 605 | action: Action::PatchCreate { | ||
| 606 | title: title.to_string(), | ||
| 607 | body: "".to_string(), | ||
| 608 | base_ref: base_ref.to_string(), | ||
| 609 | branch: branch.to_string(), | ||
| 610 | fixes: None, | ||
| 611 | }, | ||
| 612 | }; | ||
| 613 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | ||
| 614 | let id = oid.to_string(); | ||
| 615 | let patch_ref = format!("refs/collab/patches/{}", id); | ||
| 616 | repo.reference(&patch_ref, oid, false, "test branch patch").unwrap(); | ||
| 617 | (patch_ref, id) | ||
| 618 | } | ||
| 619 | |||
| 620 | // --------------------------------------------------------------------------- | ||
| 621 | // Phase 2: Branch resolution infrastructure tests (T007, T008) | ||
| 622 | // --------------------------------------------------------------------------- | ||
| 623 | |||
| 624 | #[test] | ||
| 625 | fn test_resolve_head_branch_based() { | ||
| 626 | let tmp = TempDir::new().unwrap(); | ||
| 627 | let repo = init_repo(tmp.path(), &alice()); | ||
| 628 | |||
| 629 | // Create main branch and feature branch | ||
| 630 | make_initial_commit(&repo, "main"); | ||
| 631 | let feature_tip = add_commit_on_branch(&repo, "main", "feature.rs", b"fn feature() {}"); | ||
| 632 | // Create the feature branch at the current tip | ||
| 633 | repo.branch("feature/test", &repo.find_commit(feature_tip).unwrap(), false).unwrap(); | ||
| 634 | |||
| 635 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Test patch", "feature/test", "main"); | ||
| 636 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 637 | |||
| 638 | // resolve_head should return the branch tip | ||
| 639 | let resolved = state.resolve_head(&repo).unwrap(); | ||
| 640 | assert_eq!(resolved, feature_tip); | ||
| 641 | |||
| 642 | // Now add a commit to the branch — resolve_head should return the new tip | ||
| 643 | let new_tip = add_commit_on_branch(&repo, "feature/test", "more.rs", b"more code"); | ||
| 644 | let resolved2 = state.resolve_head(&repo).unwrap(); | ||
| 645 | assert_eq!(resolved2, new_tip); | ||
| 646 | } | ||
| 647 | |||
| 648 | #[test] | ||
| 649 | fn test_resolve_head_deleted_branch_error() { | ||
| 650 | let tmp = TempDir::new().unwrap(); | ||
| 651 | let repo = init_repo(tmp.path(), &alice()); | ||
| 652 | |||
| 653 | make_initial_commit(&repo, "main"); | ||
| 654 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 655 | repo.branch("ephemeral", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 656 | |||
| 657 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Ephemeral patch", "ephemeral", "main"); | ||
| 658 | |||
| 659 | // Delete the branch | ||
| 660 | let mut branch = repo.find_branch("ephemeral", git2::BranchType::Local).unwrap(); | ||
| 661 | branch.delete().unwrap(); | ||
| 662 | |||
| 663 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 664 | let result = state.resolve_head(&repo); | ||
| 665 | assert!(result.is_err(), "resolve_head should error when branch is deleted"); | ||
| 666 | } | ||
| 667 | |||
| 668 | #[test] | ||
| 669 | fn test_staleness_up_to_date() { | ||
| 670 | let tmp = TempDir::new().unwrap(); | ||
| 671 | let repo = init_repo(tmp.path(), &alice()); | ||
| 672 | |||
| 673 | make_initial_commit(&repo, "main"); | ||
| 674 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 675 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 676 | // Add one commit on the feature branch | ||
| 677 | add_commit_on_branch(&repo, "feat", "feat.rs", b"feature code"); | ||
| 678 | |||
| 679 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Up-to-date", "feat", "main"); | ||
| 680 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 681 | |||
| 682 | let (ahead, behind) = state.staleness(&repo).unwrap(); | ||
| 683 | assert_eq!(ahead, 1, "feature branch has 1 commit ahead of main"); | ||
| 684 | assert_eq!(behind, 0, "main has not moved, so 0 behind"); | ||
| 685 | } | ||
| 686 | |||
| 687 | #[test] | ||
| 688 | fn test_staleness_outdated() { | ||
| 689 | let tmp = TempDir::new().unwrap(); | ||
| 690 | let repo = init_repo(tmp.path(), &alice()); | ||
| 691 | |||
| 692 | make_initial_commit(&repo, "main"); | ||
| 693 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 694 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 695 | // Add commit on feature branch | ||
| 696 | add_commit_on_branch(&repo, "feat", "feat.rs", b"feature code"); | ||
| 697 | // Advance main by 2 commits | ||
| 698 | add_commit_on_branch(&repo, "main", "main1.rs", b"main work 1"); | ||
| 699 | add_commit_on_branch(&repo, "main", "main2.rs", b"main work 2"); | ||
| 700 | |||
| 701 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Outdated", "feat", "main"); | ||
| 702 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 703 | |||
| 704 | let (ahead, behind) = state.staleness(&repo).unwrap(); | ||
| 705 | assert_eq!(ahead, 1, "feature has 1 commit ahead"); | ||
| 706 | assert_eq!(behind, 2, "main has moved 2 commits ahead"); | ||
| 707 | } | ||
| 708 | |||
| 709 | // --------------------------------------------------------------------------- | ||
| 710 | // Phase 3: US1 — Create Patch from Branch (T009-T012) | ||
| 711 | // --------------------------------------------------------------------------- | ||
| 712 | |||
| 713 | use git_collab::patch; | ||
| 714 | |||
| 715 | #[test] | ||
| 716 | fn test_create_patch_from_branch_populates_branch_field() { | ||
| 717 | // T009: creating a patch from current branch populates `branch` field | ||
| 718 | let tmp = TempDir::new().unwrap(); | ||
| 719 | let repo = init_repo(tmp.path(), &alice()); | ||
| 720 | make_initial_commit(&repo, "main"); | ||
| 721 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 722 | repo.branch("feature/foo", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 723 | add_commit_on_branch(&repo, "feature/foo", "feat.rs", b"feat"); | ||
| 724 | |||
| 725 | let id = patch::create(&repo, "My patch", "desc", "main", "feature/foo", None).unwrap(); | ||
| 726 | let patch_ref = format!("refs/collab/patches/{}", id); | ||
| 727 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 728 | |||
| 729 | assert_eq!(state.branch, "feature/foo"); | ||
| 730 | // resolve_head should return the branch tip | ||
| 731 | let branch_tip = repo.refname_to_id("refs/heads/feature/foo").unwrap(); | ||
| 732 | assert_eq!(state.resolve_head(&repo).unwrap(), branch_tip); | ||
| 733 | } | ||
| 734 | |||
| 735 | #[test] | ||
| 736 | fn test_create_duplicate_patch_for_same_branch_returns_error() { | ||
| 737 | // T011: creating a duplicate patch for same branch returns error | ||
| 738 | let tmp = TempDir::new().unwrap(); | ||
| 739 | let repo = init_repo(tmp.path(), &alice()); | ||
| 740 | make_initial_commit(&repo, "main"); | ||
| 741 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 742 | repo.branch("feature/dup", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 743 | |||
| 744 | // First creation should succeed | ||
| 745 | patch::create(&repo, "First", "", "main", "feature/dup", None).unwrap(); | ||
| 746 | |||
| 747 | // Second creation for same branch should fail | ||
| 748 | let result = patch::create(&repo, "Second", "", "main", "feature/dup", None); | ||
| 749 | assert!(result.is_err(), "duplicate branch patch should fail"); | ||
| 750 | let err_msg = result.unwrap_err().to_string(); | ||
| 751 | assert!(err_msg.contains("feature/dup"), "error should mention the branch name"); | ||
| 752 | } | ||
| 753 | |||
| 754 | #[test] | ||
| 755 | fn test_create_patch_from_base_branch_returns_error() { | ||
| 756 | // T012: creating a patch when on base branch returns error | ||
| 757 | let tmp = TempDir::new().unwrap(); | ||
| 758 | let repo = init_repo(tmp.path(), &alice()); | ||
| 759 | make_initial_commit(&repo, "main"); | ||
| 760 | |||
| 761 | let result = patch::create(&repo, "Bad patch", "", "main", "main", None); | ||
| 762 | assert!(result.is_err(), "creating patch from base branch should fail"); | ||
| 763 | let err_msg = result.unwrap_err().to_string(); | ||
| 764 | assert!(err_msg.contains("base branch"), "error should mention base branch"); | ||
| 765 | } | ||
| 766 | |||
| 767 | // --------------------------------------------------------------------------- | ||
| 768 | // Phase 4: US2 — Staleness and Diff (T019-T021) | ||
| 769 | // --------------------------------------------------------------------------- | ||
| 770 | |||
| 771 | #[test] | ||
| 772 | fn test_patch_show_up_to_date_staleness() { | ||
| 773 | // T019: patch show on up-to-date patch shows "0 behind" | ||
| 774 | let tmp = TempDir::new().unwrap(); | ||
| 775 | let repo = init_repo(tmp.path(), &alice()); | ||
| 776 | make_initial_commit(&repo, "main"); | ||
| 777 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 778 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 779 | add_commit_on_branch(&repo, "feat", "feat.rs", b"code"); | ||
| 780 | |||
| 781 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Up to date", "feat", "main"); | ||
| 782 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 783 | let (_, behind) = state.staleness(&repo).unwrap(); | ||
| 784 | assert_eq!(behind, 0); | ||
| 785 | } | ||
| 786 | |||
| 787 | #[test] | ||
| 788 | fn test_patch_show_outdated_staleness() { | ||
| 789 | // T020: patch show on outdated patch shows correct behind count | ||
| 790 | let tmp = TempDir::new().unwrap(); | ||
| 791 | let repo = init_repo(tmp.path(), &alice()); | ||
| 792 | make_initial_commit(&repo, "main"); | ||
| 793 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 794 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 795 | add_commit_on_branch(&repo, "feat", "feat.rs", b"code"); | ||
| 796 | // Advance main | ||
| 797 | add_commit_on_branch(&repo, "main", "m1.rs", b"m1"); | ||
| 798 | add_commit_on_branch(&repo, "main", "m2.rs", b"m2"); | ||
| 799 | add_commit_on_branch(&repo, "main", "m3.rs", b"m3"); | ||
| 800 | |||
| 801 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Outdated", "feat", "main"); | ||
| 802 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 803 | let (ahead, behind) = state.staleness(&repo).unwrap(); | ||
| 804 | assert_eq!(ahead, 1); | ||
| 805 | assert_eq!(behind, 3); | ||
| 806 | } | ||
| 807 | |||
| 808 | // --------------------------------------------------------------------------- | ||
| 809 | // Phase 5: US3 — Merge (T024-T025) | ||
| 810 | // --------------------------------------------------------------------------- | ||
| 811 | |||
| 812 | #[test] | ||
| 813 | fn test_merge_branch_based_patch() { | ||
| 814 | // T024: merging a branch-based patch performs git merge and updates DAG | ||
| 815 | let tmp = TempDir::new().unwrap(); | ||
| 816 | let repo = init_repo(tmp.path(), &alice()); | ||
| 817 | make_initial_commit(&repo, "main"); | ||
| 818 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 819 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 820 | add_commit_on_branch(&repo, "feat", "feat.rs", b"feature code"); | ||
| 821 | |||
| 822 | let id = patch::create(&repo, "Merge test", "", "main", "feat", None).unwrap(); | ||
| 823 | |||
| 824 | // Merge the patch | ||
| 825 | patch::merge(&repo, &id).unwrap(); | ||
| 826 | |||
| 827 | // Check that the patch is now merged | ||
| 828 | let patch_ref = format!("refs/collab/patches/{}", id); | ||
| 829 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 830 | assert_eq!(state.status, PatchStatus::Merged); | ||
| 831 | |||
| 832 | // Check that the base branch now has the feature commit | ||
| 833 | let base_tip = repo.refname_to_id("refs/heads/main").unwrap(); | ||
| 834 | let feat_tip = repo.refname_to_id("refs/heads/feat").unwrap(); | ||
| 835 | assert!( | ||
| 836 | repo.graph_descendant_of(base_tip, feat_tip).unwrap() | ||
| 837 | || base_tip == feat_tip, | ||
| 838 | "base should contain the feature branch" | ||
| 839 | ); | ||
| 840 | } | ||
| 841 | |||
| 842 | #[test] | ||
| 843 | fn test_merge_conflicting_patch_reports_error() { | ||
| 844 | // T025: merging a conflicting patch reports error | ||
| 845 | let tmp = TempDir::new().unwrap(); | ||
| 846 | let repo = init_repo(tmp.path(), &alice()); | ||
| 847 | make_initial_commit(&repo, "main"); | ||
| 848 | let tip = add_commit_on_branch(&repo, "main", "conflict.rs", b"original"); | ||
| 849 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 850 | |||
| 851 | // Both branches modify the same file differently | ||
| 852 | add_commit_on_branch(&repo, "feat", "conflict.rs", b"feature version"); | ||
| 853 | add_commit_on_branch(&repo, "main", "conflict.rs", b"main version"); | ||
| 854 | |||
| 855 | let id = patch::create(&repo, "Conflict test", "", "main", "feat", None).unwrap(); | ||
| 856 | let result = patch::merge(&repo, &id); | ||
| 857 | assert!(result.is_err(), "conflicting merge should fail"); | ||
| 858 | let err_msg = result.unwrap_err().to_string(); | ||
| 859 | assert!(err_msg.contains("conflict"), "error should mention conflicts"); | ||
| 860 | } | ||
| 861 | |||
| 862 | // --------------------------------------------------------------------------- | ||
| 863 | // Phase 6: US4 — Implicit Revision (T027) | ||
| 864 | // --------------------------------------------------------------------------- | ||
| 865 | |||
| 866 | #[test] | ||
| 867 | fn test_branch_push_auto_reflects_in_patch() { | ||
| 868 | // T027: after pushing a commit to the branch, patch show reflects the new commit | ||
| 869 | let tmp = TempDir::new().unwrap(); | ||
| 870 | let repo = init_repo(tmp.path(), &alice()); | ||
| 871 | make_initial_commit(&repo, "main"); | ||
| 872 | let tip = add_commit_on_branch(&repo, "main", "f.rs", b"x"); | ||
| 873 | repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap(); | ||
| 874 | add_commit_on_branch(&repo, "feat", "v1.rs", b"version 1"); | ||
| 875 | |||
| 876 | let (patch_ref, id) = create_branch_patch(&repo, &alice(), "Auto revise", "feat", "main"); | ||
| 877 | let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 878 | let head1 = state.resolve_head(&repo).unwrap(); | ||
| 879 | |||
| 880 | // Add another commit to the branch (simulating a push) | ||
| 881 | let new_tip = add_commit_on_branch(&repo, "feat", "v2.rs", b"version 2"); | ||
| 882 | |||
| 883 | // Re-read the state and resolve head — should see the new commit | ||
| 884 | let state2 = PatchState::from_ref(&repo, &patch_ref, &id).unwrap(); | ||
| 885 | let head2 = state2.resolve_head(&repo).unwrap(); | ||
| 886 | |||
| 887 | assert_ne!(head1, head2, "head should change after branch update"); | ||
| 888 | assert_eq!(head2, new_tip, "head should be the new tip"); | ||
| 889 | } | ||
| 890 | |||
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -119,7 +119,7 @@ pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, | |||
| 119 | title: title.to_string(), | 119 | title: title.to_string(), |
| 120 | body: "".to_string(), | 120 | body: "".to_string(), |
| 121 | base_ref: "main".to_string(), | 121 | base_ref: "main".to_string(), |
| 122 | head_commit: "abc123".to_string(), | 122 | branch: "test-branch".to_string(), |
| 123 | fixes: None, | 123 | fixes: None, |
| 124 | }, | 124 | }, |
| 125 | }; | 125 | }; |
| @@ -223,10 +223,20 @@ impl TestRepo { | |||
| 223 | .to_string() | 223 | .to_string() |
| 224 | } | 224 | } |
| 225 | 225 | ||
| 226 | /// Create a patch with HEAD as the head commit. Returns the 8-char short ID. | 226 | /// Create a patch from a new branch. Returns the 8-char short ID. |
| 227 | pub fn patch_create(&self, title: &str) -> String { | 227 | pub fn patch_create(&self, title: &str) -> String { |
| 228 | let head = self.git(&["rev-parse", "HEAD"]).trim().to_string(); | 228 | // Create a unique branch for this patch |
| 229 | let out = self.run_ok(&["patch", "create", "-t", title, "--head", &head]); | 229 | let sanitized = title.replace(|c: char| !c.is_alphanumeric(), "-").to_lowercase(); |
| 230 | let branch_name = format!("test/{}", sanitized); | ||
| 231 | self.git(&["checkout", "-b", &branch_name]); | ||
| 232 | self.commit_file( | ||
| 233 | &format!("{}.txt", sanitized), | ||
| 234 | &format!("content for {}", title), | ||
| 235 | &format!("commit for {}", title), | ||
| 236 | ); | ||
| 237 | let out = self.run_ok(&["patch", "create", "-t", title, "-B", &branch_name]); | ||
| 238 | // Go back to main for subsequent operations | ||
| 239 | self.git(&["checkout", "main"]); | ||
| 230 | out.trim() | 240 | out.trim() |
| 231 | .strip_prefix("Created patch ") | 241 | .strip_prefix("Created patch ") |
| 232 | .unwrap_or_else(|| panic!("unexpected patch create output: {}", out)) | 242 | .unwrap_or_else(|| panic!("unexpected patch create output: {}", out)) |
tests/patch_import_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,298 +0,0 @@ | |||
| 1 | use git2::Repository; | ||
| 2 | use std::path::{Path, PathBuf}; | ||
| 3 | use tempfile::TempDir; | ||
| 4 | |||
| 5 | use git_collab::event::Author; | ||
| 6 | use git_collab::patch; | ||
| 7 | use git_collab::state::{self, PatchStatus}; | ||
| 8 | |||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | // Helpers | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | fn alice() -> Author { | ||
| 14 | Author { | ||
| 15 | name: "Alice".to_string(), | ||
| 16 | email: "alice@example.com".to_string(), | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Create a repo with an initial commit so we have a valid HEAD and tree. | ||
| 21 | fn init_repo_with_commit(dir: &Path, author: &Author) -> Repository { | ||
| 22 | let repo = Repository::init(dir).expect("init repo"); | ||
| 23 | { | ||
| 24 | let mut config = repo.config().unwrap(); | ||
| 25 | config.set_str("user.name", &author.name).unwrap(); | ||
| 26 | config.set_str("user.email", &author.email).unwrap(); | ||
| 27 | } | ||
| 28 | |||
| 29 | // Create an initial commit with a file so we have a valid tree/HEAD | ||
| 30 | let sig = git2::Signature::now(&author.name, &author.email).unwrap(); | ||
| 31 | let tree_oid = { | ||
| 32 | let blob_oid = repo.blob(b"initial content\n").unwrap(); | ||
| 33 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 34 | tb.insert("README", blob_oid, 0o100644).unwrap(); | ||
| 35 | tb.write().unwrap() | ||
| 36 | }; | ||
| 37 | { | ||
| 38 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 39 | repo.commit(Some("refs/heads/main"), &sig, &sig, "Initial commit", &tree, &[]) | ||
| 40 | .unwrap(); | ||
| 41 | } | ||
| 42 | |||
| 43 | // Set HEAD to point to main | ||
| 44 | repo.set_head("refs/heads/main").unwrap(); | ||
| 45 | |||
| 46 | repo | ||
| 47 | } | ||
| 48 | |||
| 49 | /// Generate a valid git format-patch style .patch file content. | ||
| 50 | /// This creates a patch that adds a new file called `filename` with `content`. | ||
| 51 | fn make_format_patch( | ||
| 52 | from_name: &str, | ||
| 53 | from_email: &str, | ||
| 54 | subject: &str, | ||
| 55 | body: &str, | ||
| 56 | filename: &str, | ||
| 57 | content: &str, | ||
| 58 | ) -> String { | ||
| 59 | let date = "Thu, 19 Mar 2026 10:30:00 +0000"; | ||
| 60 | // Build the diff portion | ||
| 61 | let lines: Vec<&str> = content.lines().collect(); | ||
| 62 | let mut diff_lines = String::new(); | ||
| 63 | for line in &lines { | ||
| 64 | diff_lines.push_str(&format!("+{}\n", line)); | ||
| 65 | } | ||
| 66 | let line_count = lines.len(); | ||
| 67 | |||
| 68 | format!( | ||
| 69 | "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\ | ||
| 70 | From: {} <{}>\n\ | ||
| 71 | Date: {}\n\ | ||
| 72 | Subject: [PATCH] {}\n\ | ||
| 73 | \n\ | ||
| 74 | {}\n\ | ||
| 75 | ---\n\ | ||
| 76 | {filename} | {line_count} +\n\ | ||
| 77 | 1 file changed, {line_count} insertions(+)\n\ | ||
| 78 | create mode 100644 {filename}\n\ | ||
| 79 | \n\ | ||
| 80 | diff --git a/{filename} b/{filename}\n\ | ||
| 81 | new file mode 100644\n\ | ||
| 82 | index 0000000..1234567\n\ | ||
| 83 | --- /dev/null\n\ | ||
| 84 | +++ b/{filename}\n\ | ||
| 85 | @@ -0,0 +1,{line_count} @@\n\ | ||
| 86 | {diff_lines}\ | ||
| 87 | -- \n\ | ||
| 88 | 2.40.0\n", | ||
| 89 | from_name, from_email, date, subject, body, | ||
| 90 | filename = filename, | ||
| 91 | line_count = line_count, | ||
| 92 | diff_lines = diff_lines, | ||
| 93 | ) | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Write patch content to a file and return its path. | ||
| 97 | fn write_patch_file(dir: &Path, name: &str, content: &str) -> PathBuf { | ||
| 98 | let path = dir.join(name); | ||
| 99 | std::fs::write(&path, content).unwrap(); | ||
| 100 | path | ||
| 101 | } | ||
| 102 | |||
| 103 | // --------------------------------------------------------------------------- | ||
| 104 | // Tests | ||
| 105 | // --------------------------------------------------------------------------- | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn test_import_single_patch_success() { | ||
| 109 | let tmp = TempDir::new().unwrap(); | ||
| 110 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 111 | |||
| 112 | let patch_content = make_format_patch( | ||
| 113 | "Bob", | ||
| 114 | "bob@example.com", | ||
| 115 | "Add hello.txt", | ||
| 116 | "This patch adds a hello file.", | ||
| 117 | "hello.txt", | ||
| 118 | "Hello, world!\n", | ||
| 119 | ); | ||
| 120 | |||
| 121 | let patch_dir = TempDir::new().unwrap(); | ||
| 122 | let patch_file = write_patch_file(patch_dir.path(), "0001-add-hello.patch", &patch_content); | ||
| 123 | |||
| 124 | let id = patch::import(&repo, &patch_file).unwrap(); | ||
| 125 | |||
| 126 | // Verify the patch was created in the DAG | ||
| 127 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 128 | let patch_state = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 129 | |||
| 130 | assert_eq!(patch_state.title, "Add hello.txt"); | ||
| 131 | assert_eq!(patch_state.status, PatchStatus::Open); | ||
| 132 | assert_eq!(patch_state.author.name, "Alice"); // importer is the author in the DAG | ||
| 133 | assert!(!patch_state.head_commit.is_empty()); | ||
| 134 | assert_eq!(patch_state.base_ref, "main"); | ||
| 135 | } | ||
| 136 | |||
| 137 | #[test] | ||
| 138 | fn test_import_file_not_found() { | ||
| 139 | let tmp = TempDir::new().unwrap(); | ||
| 140 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 141 | |||
| 142 | let nonexistent = PathBuf::from("/tmp/does-not-exist-12345.patch"); | ||
| 143 | let result = patch::import(&repo, &nonexistent); | ||
| 144 | assert!(result.is_err()); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn test_import_malformed_patch() { | ||
| 149 | let tmp = TempDir::new().unwrap(); | ||
| 150 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 151 | |||
| 152 | let patch_dir = TempDir::new().unwrap(); | ||
| 153 | let patch_file = write_patch_file( | ||
| 154 | patch_dir.path(), | ||
| 155 | "bad.patch", | ||
| 156 | "This is not a valid patch file at all.\nJust random text.\n", | ||
| 157 | ); | ||
| 158 | |||
| 159 | let result = patch::import(&repo, &patch_file); | ||
| 160 | assert!(result.is_err()); | ||
| 161 | } | ||
| 162 | |||
| 163 | #[test] | ||
| 164 | fn test_import_creates_dag_entry_readable_by_show() { | ||
| 165 | let tmp = TempDir::new().unwrap(); | ||
| 166 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 167 | |||
| 168 | let patch_content = make_format_patch( | ||
| 169 | "Charlie", | ||
| 170 | "charlie@example.com", | ||
| 171 | "Fix bug in parser", | ||
| 172 | "Fixes an off-by-one error in the parser module.", | ||
| 173 | "parser.txt", | ||
| 174 | "fixed parser code\n", | ||
| 175 | ); | ||
| 176 | |||
| 177 | let patch_dir = TempDir::new().unwrap(); | ||
| 178 | let patch_file = write_patch_file(patch_dir.path(), "0001-fix-bug.patch", &patch_content); | ||
| 179 | |||
| 180 | let id = patch::import(&repo, &patch_file).unwrap(); | ||
| 181 | |||
| 182 | // Verify it can be resolved and read back through state infrastructure | ||
| 183 | let (ref_name, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); | ||
| 184 | assert_eq!(resolved_id, id); | ||
| 185 | |||
| 186 | let patch_state = state::PatchState::from_ref(&repo, &ref_name, &resolved_id).unwrap(); | ||
| 187 | assert_eq!(patch_state.title, "Fix bug in parser"); | ||
| 188 | assert!( | ||
| 189 | patch_state.body.contains("off-by-one"), | ||
| 190 | "body should contain the patch description" | ||
| 191 | ); | ||
| 192 | } | ||
| 193 | |||
| 194 | #[test] | ||
| 195 | fn test_import_series_multiple_files() { | ||
| 196 | let tmp = TempDir::new().unwrap(); | ||
| 197 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 198 | |||
| 199 | let patch1 = make_format_patch( | ||
| 200 | "Bob", | ||
| 201 | "bob@example.com", | ||
| 202 | "Add file one", | ||
| 203 | "First patch in series.", | ||
| 204 | "one.txt", | ||
| 205 | "one\n", | ||
| 206 | ); | ||
| 207 | |||
| 208 | let patch2 = make_format_patch( | ||
| 209 | "Bob", | ||
| 210 | "bob@example.com", | ||
| 211 | "Add file two", | ||
| 212 | "Second patch in series.", | ||
| 213 | "two.txt", | ||
| 214 | "two\n", | ||
| 215 | ); | ||
| 216 | |||
| 217 | let patch_dir = TempDir::new().unwrap(); | ||
| 218 | let f1 = write_patch_file(patch_dir.path(), "0001-add-one.patch", &patch1); | ||
| 219 | let f2 = write_patch_file(patch_dir.path(), "0002-add-two.patch", &patch2); | ||
| 220 | |||
| 221 | let ids = patch::import_series(&repo, &[f1, f2]).unwrap(); | ||
| 222 | assert_eq!(ids.len(), 2); | ||
| 223 | |||
| 224 | // Both should be valid patches in the DAG | ||
| 225 | for id in &ids { | ||
| 226 | let (ref_name, _) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); | ||
| 227 | let ps = state::PatchState::from_ref(&repo, &ref_name, id).unwrap(); | ||
| 228 | assert_eq!(ps.status, PatchStatus::Open); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | #[test] | ||
| 233 | fn test_import_series_rollback_on_failure() { | ||
| 234 | let tmp = TempDir::new().unwrap(); | ||
| 235 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 236 | |||
| 237 | let good_patch = make_format_patch( | ||
| 238 | "Bob", | ||
| 239 | "bob@example.com", | ||
| 240 | "Add good file", | ||
| 241 | "A good patch.", | ||
| 242 | "good.txt", | ||
| 243 | "good\n", | ||
| 244 | ); | ||
| 245 | |||
| 246 | let patch_dir = TempDir::new().unwrap(); | ||
| 247 | let f1 = write_patch_file(patch_dir.path(), "0001-good.patch", &good_patch); | ||
| 248 | let f2 = PathBuf::from("/tmp/nonexistent-bad-patch-12345.patch"); | ||
| 249 | |||
| 250 | let result = patch::import_series(&repo, &[f1, f2]); | ||
| 251 | assert!(result.is_err()); | ||
| 252 | |||
| 253 | // After rollback, no patches should exist | ||
| 254 | let patches = state::list_patches(&repo).unwrap(); | ||
| 255 | assert_eq!(patches.len(), 0, "rollback should remove all imported patches"); | ||
| 256 | } | ||
| 257 | |||
| 258 | #[test] | ||
| 259 | fn test_import_patch_with_modification() { | ||
| 260 | // Test importing a patch that modifies an existing file (not just new files) | ||
| 261 | let tmp = TempDir::new().unwrap(); | ||
| 262 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 263 | |||
| 264 | // Create a patch that modifies README (which exists in our initial commit) | ||
| 265 | let date = "Thu, 19 Mar 2026 10:30:00 +0000"; | ||
| 266 | let patch_content = format!( | ||
| 267 | "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\ | ||
| 268 | From: Bob <bob@example.com>\n\ | ||
| 269 | Date: {}\n\ | ||
| 270 | Subject: [PATCH] Update README\n\ | ||
| 271 | \n\ | ||
| 272 | Updated the README with more info.\n\ | ||
| 273 | ---\n\ | ||
| 274 | README | 2 +-\n\ | ||
| 275 | 1 file changed, 1 insertion(+), 1 deletion(-)\n\ | ||
| 276 | \n\ | ||
| 277 | diff --git a/README b/README\n\ | ||
| 278 | index 1234567..abcdef0 100644\n\ | ||
| 279 | --- a/README\n\ | ||
| 280 | +++ b/README\n\ | ||
| 281 | @@ -1 +1 @@\n\ | ||
| 282 | -initial content\n\ | ||
| 283 | +updated content\n\ | ||
| 284 | -- \n\ | ||
| 285 | 2.40.0\n", | ||
| 286 | date, | ||
| 287 | ); | ||
| 288 | |||
| 289 | let patch_dir = TempDir::new().unwrap(); | ||
| 290 | let patch_file = write_patch_file(patch_dir.path(), "0001-update-readme.patch", &patch_content); | ||
| 291 | |||
| 292 | let id = patch::import(&repo, &patch_file).unwrap(); | ||
| 293 | |||
| 294 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 295 | let ps = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 296 | assert_eq!(ps.title, "Update README"); | ||
| 297 | assert_eq!(ps.status, PatchStatus::Open); | ||
| 298 | } | ||
tests/signing_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -211,7 +211,7 @@ fn signed_event_flatten_round_trip_with_tagged_enum() { | |||
| 211 | title: "Fix bug".to_string(), | 211 | title: "Fix bug".to_string(), |
| 212 | body: "Fixes #42".to_string(), | 212 | body: "Fixes #42".to_string(), |
| 213 | base_ref: "main".to_string(), | 213 | base_ref: "main".to_string(), |
| 214 | head_commit: "abc123".to_string(), | 214 | branch: "feature/fix-bug".to_string(), |
| 215 | fixes: Some("deadbeef".to_string()), | 215 | fixes: Some("deadbeef".to_string()), |
| 216 | }, | 216 | }, |
| 217 | }; | 217 | }; |
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -283,7 +283,7 @@ fn test_patch_review_across_repos() { | |||
| 283 | title: "Add feature X".to_string(), | 283 | title: "Add feature X".to_string(), |
| 284 | body: "Please review".to_string(), | 284 | body: "Please review".to_string(), |
| 285 | base_ref: "main".to_string(), | 285 | base_ref: "main".to_string(), |
| 286 | head_commit: "abc123".to_string(), | 286 | branch: "feature/x".to_string(), |
| 287 | fixes: None, | 287 | fixes: None, |
| 288 | }, | 288 | }, |
| 289 | }; | 289 | }; |
| @@ -330,7 +330,7 @@ fn test_concurrent_review_and_revise() { | |||
| 330 | title: "WIP feature".to_string(), | 330 | title: "WIP feature".to_string(), |
| 331 | body: "".to_string(), | 331 | body: "".to_string(), |
| 332 | base_ref: "main".to_string(), | 332 | base_ref: "main".to_string(), |
| 333 | head_commit: "v1".to_string(), | 333 | branch: "feature/wip".to_string(), |
| 334 | fixes: None, | 334 | fixes: None, |
| 335 | }, | 335 | }, |
| 336 | }; | 336 | }; |
| @@ -349,7 +349,6 @@ fn test_concurrent_review_and_revise() { | |||
| 349 | author: alice(), | 349 | author: alice(), |
| 350 | action: Action::PatchRevise { | 350 | action: Action::PatchRevise { |
| 351 | body: Some("Updated description".to_string()), | 351 | body: Some("Updated description".to_string()), |
| 352 | head_commit: "v2".to_string(), | ||
| 353 | }, | 352 | }, |
| 354 | }; | 353 | }; |
| 355 | dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); | 354 | dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); |
| @@ -374,7 +373,7 @@ fn test_concurrent_review_and_revise() { | |||
| 374 | 373 | ||
| 375 | assert_eq!(alice_state.reviews.len(), 1); | 374 | assert_eq!(alice_state.reviews.len(), 1); |
| 376 | assert_eq!(bob_state.reviews.len(), 1); | 375 | assert_eq!(bob_state.reviews.len(), 1); |
| 377 | assert_eq!(alice_state.head_commit, bob_state.head_commit); | 376 | assert_eq!(alice_state.body, bob_state.body); |
| 378 | } | 377 | } |
| 379 | 378 | ||
| 380 | #[test] | 379 | #[test] |