a73x

b23a3d5e

Reference branches from patches instead of commit OIDs

a73x   2026-03-21 11:50

Commit message
Reference branches from patches instead of commit OIDs

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]