a73x

specs/010-email-patch-import/plan.md

Ref:   Size: 8.8 KiB

# Implementation Plan: Email/Format-Patch Import

**Branch**: `010-email-patch-import` | **Date**: 2026-03-21 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/010-email-patch-import/spec.md`

## Summary

Enable mailing-list-style contributions by adding a `git collab patch import <file>` subcommand. The command parses a `git format-patch` output file, applies it to a temporary branch using git2, captures the resulting commit OID, and creates a patch DAG entry via the existing `patch::create()` infrastructure. Review, comments, and merge flow work unchanged.

## Technical Context

**Language/Version**: Rust 2021 edition
**Primary Dependencies**: git2 0.19, clap 4 (derive), serde/serde_json 1, chrono 0.4, ed25519-dalek 2, thiserror 2
**Storage**: Git refs under `.git/refs/collab/patches/` (existing), temp branches under `refs/heads/collab/imported/`
**Testing**: `cargo test`, `cargo clippy`
**Target Platform**: Linux/macOS CLI
**Project Type**: CLI tool
**Constraints**: Must not modify working tree or current branch during import

## Existing Code Analysis

### `src/patch.rs` - Patch operations module
- `patch::create(repo, title, body, base_ref, head_commit)` creates a patch DAG entry. Takes a `head_commit` string (OID) and `base_ref` string. Returns the patch ID. This is the function the import command will call after applying the patch.
- `patch::merge()`, `patch::review()`, `patch::comment()`, `patch::show()`, `patch::list()` all operate on the DAG entry. No changes needed for imported patches -- they will work as-is if `create()` produces a valid entry.

### `src/event.rs` - Event types
- `Action::PatchCreate { title, body, base_ref, head_commit }` is the event type used by `patch::create()`. The import command produces exactly these fields.
- No new `Action` variants are needed.

### `src/dag.rs` - DAG operations
- `dag::create_root_event()` creates an orphan commit with a signed event blob. Used by `patch::create()`.
- `dag::append_event()` appends to an existing DAG ref. Used by review/comment/merge.
- Signing uses `ed25519_dalek::SigningKey`. The import command will use the maintainer's signing key (same as any other patch create).

### `src/cli.rs` - CLI definitions
- `PatchCmd` enum defines subcommands under `git collab patch`. The `Import` variant will be added here.
- Pattern follows existing subcommands: struct fields map to clap args.

## Project Structure

### Documentation (this feature)

```text
specs/010-email-patch-import/
├── spec.md
├── plan.md
└── checklists/
    └── requirements.md
```

### Source Code (repository root)

```text
src/
├── cli.rs          # Add Import variant to PatchCmd enum
├── main.rs         # Add match arm for PatchCmd::Import
├── patch.rs        # Add import() and import_series() functions
├── event.rs        # No changes needed
├── dag.rs          # No changes needed
└── error.rs        # Add import-specific error variants if needed

tests/
└── patch_import.rs # Integration tests for import functionality
```

**Structure Decision**: All new logic lives in `src/patch.rs` as new public functions alongside existing patch operations. No new modules needed -- the import function is a composition of file parsing + git2 apply + existing `patch::create()`.

## Implementation Phases

### Phase 1: CLI wiring and patch file parsing (P1 foundation)

**Files modified**: `src/cli.rs`, `src/main.rs`, `src/patch.rs`

1. Add `Import` variant to `PatchCmd` in `src/cli.rs`:
   ```rust
   Import {
       /// Path to .patch file
       file: PathBuf,
       /// Base branch ref (default: main)
       #[arg(long, default_value = "main")]
       base: String,
       /// Import multiple patches as a single series
       #[arg(long)]
       series: bool,
   }
   ```

2. Add `import()` function to `src/patch.rs`:
   - Read the patch file from disk (`std::fs::read_to_string`).
   - Validate it looks like a `git format-patch` mbox file (check for `From ` line prefix and `Subject:` header).
   - Parse subject line to extract title (strip `[PATCH]` prefix and number markers).
   - Parse body: everything between the end of headers and the `---` separator before the diff.
   - Extract the raw diff content (everything from `diff --git` onward).

3. Add match arm in `src/main.rs` for `PatchCmd::Import` that calls `patch::import()`.

### Phase 2: Apply patch and create DAG entry (P1 core)

**Files modified**: `src/patch.rs`

1. Resolve the base branch OID using `repo.revparse_single(&format!("refs/heads/{}", base))`.
2. Get the base commit's tree.
3. Parse the diff using `git2::Diff::from_buffer(diff_bytes)`.
4. Apply the diff to the base tree using `repo.apply(&diff, git2::ApplyLocation::Index, None)` or by building a new index via `repo.apply_to_tree()`.
5. Write the resulting index as a tree (`index.write_tree_to(repo)`).
6. Create a commit on a temp branch:
   - Branch name: `collab/imported/<short-oid>` (use first 8 chars of tree OID as a placeholder, or the commit OID after creation).
   - Parent: the base commit.
   - Author signature: extracted from the patch file `From:` / `Date:` headers (preserving the original contributor's identity).
   - Committer signature: the maintainer (from `get_author(repo)`).
   - Message: the original commit message from the patch.
7. Create the temp branch ref pointing to the new commit.
8. Call `patch::create(repo, &title, &body, &base, &commit_oid_str)` to create the DAG entry.
9. Print the patch ID to stdout.

### Phase 3: Error handling and validation

**Files modified**: `src/patch.rs`, `src/error.rs`

1. Add error variants to the project's error type:
   - `PatchFileNotFound(PathBuf)`
   - `InvalidPatchFile(String)` -- for malformed files
   - `PatchApplyConflict(String)` -- when the diff cannot apply cleanly
   - `BaseBranchNotFound(String)`
2. Validate the file exists before reading.
3. Validate the base branch exists before attempting apply.
4. If `apply_to_tree()` fails, return `PatchApplyConflict` with details.
5. Ensure no partial state is left behind on any error path (no orphan branches, no partial DAG).

### Phase 4: Multi-patch series import (P3)

**Files modified**: `src/patch.rs`, `src/cli.rs`

1. When `--series` is set and multiple files are provided, change `file: PathBuf` to accept multiple files (use `Vec<PathBuf>` or positional args).
2. Add `import_series()` function:
   - Sort patch files by name (convention: `0001-*.patch`, `0002-*.patch`, ...).
   - Detect cover letter (`0000-cover-letter.patch`) and extract title/body from it.
   - Apply patches sequentially, each one's parent being the previous commit.
   - If any patch fails to apply, delete the temp branch (rollback) and return error.
   - On success, create a single DAG entry with head_commit = final commit OID.
   - Title comes from cover letter subject, or first patch subject if no cover letter.

### Phase 5: Tests

**Files created**: `tests/patch_import.rs`

1. **test_import_single_patch**: Generate a patch file in a temp repo, import it, verify DAG entry exists with correct title and OID.
2. **test_import_malformed_file**: Try importing a non-patch file, verify error.
3. **test_import_conflict**: Create a patch against a diverged base, verify conflict error.
4. **test_import_missing_file**: Try importing a nonexistent path, verify error.
5. **test_import_custom_base**: Import with `--base develop`, verify base_ref in DAG.
6. **test_import_series**: Import a 3-patch series, verify single DAG entry with correct final OID (P3).
7. **test_import_series_rollback**: Import a series where patch 2 fails, verify no branches or DAG entries remain (P3).
8. **test_imported_patch_reviewable**: Import a patch, then run review and comment operations on it.

## Key Technical Decisions

1. **Patch parsing**: Use simple string parsing for mbox format rather than adding a mail-parsing crate. The `git format-patch` output format is well-defined and stable.

2. **Apply mechanism**: Use `git2::apply_to_tree()` to apply the diff to the base tree in-memory, then write the tree and create a commit. This avoids touching the working directory or index.

3. **Author preservation**: The commit created in the temp branch preserves the original contributor's author identity from the patch `From:` header. The committer is set to the maintainer. This matches standard `git am` behavior.

4. **Temp branch naming**: Use `collab/imported/<short-oid>` where short-oid is the first 8 characters of the created commit OID. This provides uniqueness and traceability.

5. **No new Action variant**: The imported patch uses the existing `Action::PatchCreate` event. There is no need to distinguish imported patches from locally-created ones in the DAG -- the review flow is identical.

## Complexity Tracking

No constitution violations. The feature adds a single new subcommand and two new functions to an existing module. No new crates, no new abstractions, no new persistence mechanisms.