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.