444be52d
Add patch import command for format-patch contributions
a73x 2026-03-21 09:42
Adds `git collab patch import <file.patch>` to import contributions from git format-patch output without needing push access. Supports single and multi-file series import with rollback on failure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/cli.rs b/src/cli.rs index a676fc7..ac6a484 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -156,4 +156,9 @@ pub enum PatchCmd { #[arg(short, long)] reason: Option<String>, }, /// Import patches from format-patch files Import { /// One or more .patch files to import files: Vec<std::path::PathBuf>, }, } diff --git a/src/error.rs b/src/error.rs index 61b936c..ce381f5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,4 +13,10 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), #[error("malformed patch: {0}")] MalformedPatch(String), #[error("patch apply failed: {0}")] PatchApplyFailed(String), } diff --git a/src/main.rs b/src/main.rs index 81f1568..37a4b88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,13 @@ fn run(cli: Cli) -> Result<(), error::Error> { } PatchCmd::Merge { id } => patch::merge(&repo, &id), PatchCmd::Close { id, reason } => patch::close(&repo, &id, reason.as_deref()), PatchCmd::Import { files } => { let ids = patch::import_series(&repo, &files)?; for id in &ids { println!("Imported patch {:.8}", id); } Ok(()) } }, Commands::Dashboard => tui::run(&repo), Commands::Sync { remote } => sync::sync(&repo, &remote), diff --git a/src/patch.rs b/src/patch.rs index fca1add..6a30759 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -1,6 +1,9 @@ use git2::Repository; use std::path::Path; use git2::{Diff, Repository}; use crate::dag; use crate::error::Error; use crate::event::{Action, Event, ReviewVerdict}; use crate::identity::get_author; use crate::state::{self, PatchStatus}; @@ -263,3 +266,186 @@ pub fn close( println!("Patch closed."); Ok(()) } // --------------------------------------------------------------------------- // Patch import from format-patch files // --------------------------------------------------------------------------- /// Parsed metadata from a git format-patch mbox header. struct PatchHeader { subject: String, body: String, } /// Parse a format-patch file into its mbox header metadata and the raw diff portion. fn parse_format_patch(content: &str) -> Result<(PatchHeader, String), Error> { // Find the "---" separator that divides the commit message from the diffstat/diff. // The diff starts at the first line matching "diff --git". let diff_start = content .find("\ndiff --git ") .map(|i| i + 1) // skip the leading newline .ok_or_else(|| Error::MalformedPatch("no 'diff --git' found in patch file".to_string()))?; let header_section = &content[..diff_start]; let diff_section = &content[diff_start..]; // Extract Subject line let subject_line = header_section .lines() .find(|l| l.starts_with("Subject:")) .ok_or_else(|| Error::MalformedPatch("no Subject header found".to_string()))?; // Strip "Subject: " prefix and optional "[PATCH] " or "[PATCH n/m] " prefix let subject = subject_line .strip_prefix("Subject:") .unwrap() .trim(); let subject = if let Some(rest) = subject.strip_prefix("[PATCH") { // Skip to the "] " closing bracket if let Some(idx) = rest.find("] ") { rest[idx + 2..].to_string() } else { subject.to_string() } } else { subject.to_string() }; // Extract body: everything between the blank line after headers and the "---" separator let body = extract_body(header_section); // Trim trailing "-- \n2.xx.x\n" signature from diff let diff_clean = trim_patch_signature(diff_section); Ok((PatchHeader { subject, body }, diff_clean)) } /// Extract the commit message body from the header section. /// The body is between the first blank line after headers and the "---" line. fn extract_body(header_section: &str) -> String { let lines: Vec<&str> = header_section.lines().collect(); let mut body_start = None; let mut body_end = None; // Find first blank line (end of mail headers) for (i, line) in lines.iter().enumerate() { if line.is_empty() && body_start.is_none() { body_start = Some(i + 1); } } // Find the "---" separator line (start of diffstat) for (i, line) in lines.iter().enumerate().rev() { if *line == "---" { body_end = Some(i); break; } } match (body_start, body_end) { (Some(start), Some(end)) if start < end => { lines[start..end].join("\n").trim().to_string() } (Some(start), None) => { // No "---" separator, take everything after headers lines[start..].join("\n").trim().to_string() } _ => String::new(), } } /// Remove trailing git patch signature ("-- \n2.xx.x\n") from diff content. fn trim_patch_signature(diff: &str) -> String { if let Some(idx) = diff.rfind("\n-- \n") { diff[..idx + 1].to_string() // keep the trailing newline } else { diff.to_string() } } /// Import a single format-patch file, creating a commit and DAG entry. /// Returns the patch ID. pub fn import(repo: &Repository, patch_path: &Path) -> Result<String, Error> { let content = std::fs::read_to_string(patch_path)?; let (header, diff_text) = parse_format_patch(&content)?; // Parse the diff with git2 let diff = Diff::from_buffer(diff_text.as_bytes()) .map_err(|e| Error::MalformedPatch(format!("invalid diff: {}", e)))?; // Get the base (HEAD) commit and its tree let head_ref = repo .head() .map_err(|e| Error::PatchApplyFailed(format!("cannot resolve HEAD: {}", e)))?; let head_commit = head_ref .peel_to_commit() .map_err(|e| Error::PatchApplyFailed(format!("HEAD is not a commit: {}", e)))?; let base_tree = head_commit.tree()?; // Apply the diff to the base tree in-memory let new_index = repo .apply_to_tree(&base_tree, &diff, None) .map_err(|e| Error::PatchApplyFailed(format!("apply failed: {}", e)))?; // Write the index to a tree let tree_oid = { let mut idx = new_index; idx.write_tree_to(repo)? }; let new_tree = repo.find_tree(tree_oid)?; // Create a commit on a detached ref (no branch update) let author = get_author(repo)?; let sig = crate::identity::author_signature(&author)?; let commit_msg = format!("imported: {}", header.subject); let commit_oid = repo.commit( None, // don't update any ref &sig, &sig, &commit_msg, &new_tree, &[&head_commit], )?; // Determine the base branch name from HEAD let base_ref = repo .head()? .shorthand() .unwrap_or("main") .to_string(); // Create a DAG entry using the existing patch create infrastructure let id = create( repo, &header.subject, &header.body, &base_ref, &commit_oid.to_string(), )?; Ok(id) } /// Import a series of format-patch files. If any fails, rolls back all /// previously imported patches from this series. pub fn import_series(repo: &Repository, files: &[impl AsRef<Path>]) -> Result<Vec<String>, Error> { let mut imported_ids: Vec<String> = Vec::new(); for file in files { match import(repo, file.as_ref()) { Ok(id) => imported_ids.push(id), Err(e) => { // Rollback: delete all refs created in this series for id in &imported_ids { let ref_name = format!("refs/collab/patches/{}", id); if let Ok(mut reference) = repo.find_reference(&ref_name) { let _ = reference.delete(); } } return Err(e); } } } Ok(imported_ids) } diff --git a/tests/patch_import_test.rs b/tests/patch_import_test.rs new file mode 100644 index 0000000..0dafb6d --- /dev/null +++ b/tests/patch_import_test.rs @@ -0,0 +1,298 @@ use git2::Repository; use std::path::{Path, PathBuf}; use tempfile::TempDir; use git_collab::event::Author; use git_collab::patch; use git_collab::state::{self, PatchStatus}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn alice() -> Author { Author { name: "Alice".to_string(), email: "alice@example.com".to_string(), } } /// Create a repo with an initial commit so we have a valid HEAD and tree. fn init_repo_with_commit(dir: &Path, author: &Author) -> Repository { let repo = Repository::init(dir).expect("init repo"); { let mut config = repo.config().unwrap(); config.set_str("user.name", &author.name).unwrap(); config.set_str("user.email", &author.email).unwrap(); } // Create an initial commit with a file so we have a valid tree/HEAD let sig = git2::Signature::now(&author.name, &author.email).unwrap(); let tree_oid = { let blob_oid = repo.blob(b"initial content\n").unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("README", blob_oid, 0o100644).unwrap(); tb.write().unwrap() }; { let tree = repo.find_tree(tree_oid).unwrap(); repo.commit(Some("refs/heads/main"), &sig, &sig, "Initial commit", &tree, &[]) .unwrap(); } // Set HEAD to point to main repo.set_head("refs/heads/main").unwrap(); repo } /// Generate a valid git format-patch style .patch file content. /// This creates a patch that adds a new file called `filename` with `content`. fn make_format_patch( from_name: &str, from_email: &str, subject: &str, body: &str, filename: &str, content: &str, ) -> String { let date = "Thu, 19 Mar 2026 10:30:00 +0000"; // Build the diff portion let lines: Vec<&str> = content.lines().collect(); let mut diff_lines = String::new(); for line in &lines { diff_lines.push_str(&format!("+{}\n", line)); } let line_count = lines.len(); format!( "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\ From: {} <{}>\n\ Date: {}\n\ Subject: [PATCH] {}\n\ \n\ {}\n\ ---\n\ {filename} | {line_count} +\n\ 1 file changed, {line_count} insertions(+)\n\ create mode 100644 {filename}\n\ \n\ diff --git a/{filename} b/{filename}\n\ new file mode 100644\n\ index 0000000..1234567\n\ --- /dev/null\n\ +++ b/{filename}\n\ @@ -0,0 +1,{line_count} @@\n\ {diff_lines}\ -- \n\ 2.40.0\n", from_name, from_email, date, subject, body, filename = filename, line_count = line_count, diff_lines = diff_lines, ) } /// Write patch content to a file and return its path. fn write_patch_file(dir: &Path, name: &str, content: &str) -> PathBuf { let path = dir.join(name); std::fs::write(&path, content).unwrap(); path } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] fn test_import_single_patch_success() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); let patch_content = make_format_patch( "Bob", "bob@example.com", "Add hello.txt", "This patch adds a hello file.", "hello.txt", "Hello, world!\n", ); let patch_dir = TempDir::new().unwrap(); let patch_file = write_patch_file(patch_dir.path(), "0001-add-hello.patch", &patch_content); let id = patch::import(&repo, &patch_file).unwrap(); // Verify the patch was created in the DAG let ref_name = format!("refs/collab/patches/{}", id); let patch_state = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(patch_state.title, "Add hello.txt"); assert_eq!(patch_state.status, PatchStatus::Open); assert_eq!(patch_state.author.name, "Alice"); // importer is the author in the DAG assert!(!patch_state.head_commit.is_empty()); assert_eq!(patch_state.base_ref, "main"); } #[test] fn test_import_file_not_found() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); let nonexistent = PathBuf::from("/tmp/does-not-exist-12345.patch"); let result = patch::import(&repo, &nonexistent); assert!(result.is_err()); } #[test] fn test_import_malformed_patch() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); let patch_dir = TempDir::new().unwrap(); let patch_file = write_patch_file( patch_dir.path(), "bad.patch", "This is not a valid patch file at all.\nJust random text.\n", ); let result = patch::import(&repo, &patch_file); assert!(result.is_err()); } #[test] fn test_import_creates_dag_entry_readable_by_show() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); let patch_content = make_format_patch( "Charlie", "charlie@example.com", "Fix bug in parser", "Fixes an off-by-one error in the parser module.", "parser.txt", "fixed parser code\n", ); let patch_dir = TempDir::new().unwrap(); let patch_file = write_patch_file(patch_dir.path(), "0001-fix-bug.patch", &patch_content); let id = patch::import(&repo, &patch_file).unwrap(); // Verify it can be resolved and read back through state infrastructure let (ref_name, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); assert_eq!(resolved_id, id); let patch_state = state::PatchState::from_ref(&repo, &ref_name, &resolved_id).unwrap(); assert_eq!(patch_state.title, "Fix bug in parser"); assert!( patch_state.body.contains("off-by-one"), "body should contain the patch description" ); } #[test] fn test_import_series_multiple_files() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); let patch1 = make_format_patch( "Bob", "bob@example.com", "Add file one", "First patch in series.", "one.txt", "one\n", ); let patch2 = make_format_patch( "Bob", "bob@example.com", "Add file two", "Second patch in series.", "two.txt", "two\n", ); let patch_dir = TempDir::new().unwrap(); let f1 = write_patch_file(patch_dir.path(), "0001-add-one.patch", &patch1); let f2 = write_patch_file(patch_dir.path(), "0002-add-two.patch", &patch2); let ids = patch::import_series(&repo, &[f1, f2]).unwrap(); assert_eq!(ids.len(), 2); // Both should be valid patches in the DAG for id in &ids { let (ref_name, _) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); let ps = state::PatchState::from_ref(&repo, &ref_name, id).unwrap(); assert_eq!(ps.status, PatchStatus::Open); } } #[test] fn test_import_series_rollback_on_failure() { let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); let good_patch = make_format_patch( "Bob", "bob@example.com", "Add good file", "A good patch.", "good.txt", "good\n", ); let patch_dir = TempDir::new().unwrap(); let f1 = write_patch_file(patch_dir.path(), "0001-good.patch", &good_patch); let f2 = PathBuf::from("/tmp/nonexistent-bad-patch-12345.patch"); let result = patch::import_series(&repo, &[f1, f2]); assert!(result.is_err()); // After rollback, no patches should exist let patches = state::list_patches(&repo).unwrap(); assert_eq!(patches.len(), 0, "rollback should remove all imported patches"); } #[test] fn test_import_patch_with_modification() { // Test importing a patch that modifies an existing file (not just new files) let tmp = TempDir::new().unwrap(); let repo = init_repo_with_commit(tmp.path(), &alice()); // Create a patch that modifies README (which exists in our initial commit) let date = "Thu, 19 Mar 2026 10:30:00 +0000"; let patch_content = format!( "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\ From: Bob <bob@example.com>\n\ Date: {}\n\ Subject: [PATCH] Update README\n\ \n\ Updated the README with more info.\n\ ---\n\ README | 2 +-\n\ 1 file changed, 1 insertion(+), 1 deletion(-)\n\ \n\ diff --git a/README b/README\n\ index 1234567..abcdef0 100644\n\ --- a/README\n\ +++ b/README\n\ @@ -1 +1 @@\n\ -initial content\n\ +updated content\n\ -- \n\ 2.40.0\n", date, ); let patch_dir = TempDir::new().unwrap(); let patch_file = write_patch_file(patch_dir.path(), "0001-update-readme.patch", &patch_content); let id = patch::import(&repo, &patch_file).unwrap(); let ref_name = format!("refs/collab/patches/{}", id); let ps = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(ps.title, "Update README"); assert_eq!(ps.status, PatchStatus::Open); }