a73x

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);
}