diff --git a/CLAUDE.md b/CLAUDE.md index 86bc604..3e3fed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ - Author struct: name, email, timestamp with Display implementation - CommitMessage: subject and optional body parsing - CommitDetails: full commit info including file changes and diff stats -- **Core types**: Hash (in src/types.rs), IndexStatus, WorktreeStatus, FileEntry (in src/commands/status.rs), Branch, BranchList, BranchType (in src/commands/branch.rs), Commit, CommitLog, Author, CommitMessage, CommitDetails, LogOptions (in src/commands/log.rs), RepoConfig (in src/commands/config.rs), Remote, RemoteList, FetchOptions, PushOptions (in src/commands/remote.rs), RestoreOptions, RemoveOptions, MoveOptions (in src/commands/files.rs), DiffOutput, FileDiff, DiffStatus, DiffOptions, DiffStats, DiffChunk, DiffLine, DiffLineType (in src/commands/diff.rs), Tag, TagList, TagType, TagOptions (in src/commands/tag.rs), Stash, StashList, StashOptions, StashApplyOptions (in src/commands/stash.rs) +- **Core types**: Hash (in src/types.rs), IndexStatus, WorktreeStatus, FileEntry (in src/commands/status.rs), Branch, BranchList, BranchType (in src/commands/branch.rs), Commit, CommitLog, Author, CommitMessage, CommitDetails, LogOptions (in src/commands/log.rs), RepoConfig (in src/commands/config.rs), Remote, RemoteList, FetchOptions, PushOptions (in src/commands/remote.rs), RestoreOptions, RemoveOptions, MoveOptions (in src/commands/files.rs), DiffOutput, FileDiff, DiffStatus, DiffOptions, DiffStats, DiffChunk, DiffLine, DiffLineType (in src/commands/diff.rs), Tag, TagList, TagType, TagOptions (in src/commands/tag.rs), Stash, StashList, StashOptions, StashApplyOptions (in src/commands/stash.rs), ResetMode (in src/commands/reset.rs), MergeStatus, MergeOptions, FastForwardMode, MergeStrategy (in src/commands/merge.rs) - **Utility functions**: git(args, working_dir) -> Result, git_raw(args, working_dir) -> Result - **Remote management**: Full remote operations with network support - Repository::add_remote(name, url) -> Result<()> - add remote repository @@ -123,8 +123,26 @@ - StashList: Box<[Stash]> with iterator methods (iter), search (find_containing, for_branch), access (latest, get), counting (len, is_empty) - StashOptions builder: untracked, keep_index, patch, staged_only, paths with builder pattern (with_untracked, with_keep_index, with_patch, with_staged_only, with_paths) - StashApplyOptions builder: restore_index, quiet with builder pattern (with_index, with_quiet) -- **Command modules**: status.rs, add.rs, commit.rs, branch.rs, log.rs, config.rs, remote.rs, files.rs, diff.rs, tag.rs, stash.rs (in src/commands/) -- **Testing**: 161+ tests covering all functionality with comprehensive edge cases +- **Reset operations**: Complete reset functionality with type-safe API + - Repository::reset_soft(commit) -> Result<()> - move HEAD, keep index and working tree + - Repository::reset_mixed(commit) -> Result<()> - move HEAD, reset index, keep working tree (default) + - Repository::reset_hard(commit) -> Result<()> - reset HEAD, index, and working tree to commit state + - Repository::reset_with_mode(commit, mode) -> Result<()> - flexible reset with explicit ResetMode + - Repository::reset_file(path) -> Result<()> - unstage specific file (already exists in files.rs) + - ResetMode enum: Soft, Mixed, Hard with const as_str() methods + - Complete error handling for invalid commits and references +- **Merge operations**: Complete merge functionality with comprehensive conflict handling + - Repository::merge(branch) -> Result - merge branch into current branch + - Repository::merge_with_options(branch, options) -> Result - merge with advanced options + - Repository::merge_in_progress() -> Result - check if merge is currently in progress + - Repository::abort_merge() -> Result<()> - cancel ongoing merge operation + - MergeStatus enum: Success(Hash), FastForward(Hash), UpToDate, Conflicts(Vec) with comprehensive status tracking + - MergeOptions builder: fast_forward, strategy, commit_message, no_commit with builder pattern (with_fast_forward, with_strategy, with_message, with_no_commit) + - FastForwardMode enum: Auto, Only, Never with const as_str() methods + - MergeStrategy enum: Recursive, Ours, Theirs with const as_str() methods + - Complete conflict detection with file-level granularity +- **Command modules**: status.rs, add.rs, commit.rs, branch.rs, log.rs, config.rs, remote.rs, files.rs, diff.rs, tag.rs, stash.rs, reset.rs, merge.rs (in src/commands/) +- **Testing**: 187+ tests covering all functionality with comprehensive edge cases - Run `cargo fmt && cargo build && cargo test && cargo clippy --all-targets --all-features -- -D warnings` after code changes - Make sure all examples are running @@ -144,6 +162,8 @@ The `examples/` directory contains comprehensive demonstrations of library funct - **diff_operations.rs**: Comprehensive diff operations showcase - unstaged/staged diffs, commit comparisons, advanced options (whitespace handling, path filtering), output formats (name-only, stat, numstat), and change analysis - **tag_operations.rs**: Complete tag management - create/delete/list tags, lightweight vs annotated tags, TagOptions builder, tag filtering and search, comprehensive tag workflows - **stash_operations.rs**: Complete stash management - save/apply/pop/list stashes, advanced options (untracked files, keep index, specific paths), stash filtering and search, comprehensive stash workflows +- **reset_operations.rs**: Complete reset management - soft/mixed/hard resets, commit targeting, file-specific resets, error handling for invalid commits, comprehensive reset workflows +- **merge_operations.rs**: Complete merge management - fast-forward/no-fast-forward merges, conflict detection and handling, merge status checking, abort operations, comprehensive merge workflows - **error_handling.rs**: Comprehensive error handling patterns - GitError variants, recovery strategies Run examples with: `cargo run --example ` diff --git a/PLAN.md b/PLAN.md index dbba110..bf542be 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,7 +2,7 @@ ## Current Status -✅ **Completed Core Features** +**Completed Core Features** - Repository initialization and opening - Enhanced file status checking with staged/unstaged tracking - Staging operations (add, add_all, add_update) @@ -18,96 +18,122 @@ - **Diff operations with multi-level API and comprehensive options** - **Tag management with comprehensive operations and filtering** - **Stash operations with comprehensive management and filtering** - -## ✅ Phase 1: Essential Remote Operations (COMPLETED) - -### ✅ Remote Management -- [x] `repo.add_remote(name, url)` - Add remote repository -- [x] `repo.remove_remote(name)` - Remove remote -- [x] `repo.list_remotes()` - List all remotes with URLs -- [x] `repo.rename_remote(old_name, new_name)` - Rename remote -- [x] `repo.get_remote_url(name)` - Get remote URL - -### ✅ Network Operations -- [x] `repo.fetch(remote)` / `repo.fetch_with_options()` - Fetch from remotes -- [x] `repo.push(remote, branch)` / `repo.push_with_options()` - Push changes -- [x] `repo.clone(url, path)` - Clone repository (static method) -- [x] Advanced options with FetchOptions and PushOptions -- [x] Type-safe builder patterns for network operations - -## ✅ Phase 2: File Lifecycle Operations (COMPLETED) - -### ✅ File Management -- [x] `repo.checkout_file(path)` - Restore file from HEAD -- [x] `repo.reset_file(path)` - Unstage specific file -- [x] `repo.rm(paths)` - Remove files from repository -- [x] `repo.rm_with_options(paths, options)` - Remove with advanced options -- [x] `repo.mv(from, to)` - Move/rename files in repository -- [x] `repo.mv_with_options(source, dest, options)` - Move with advanced options -- [x] `repo.restore(paths, options)` - Restore files from specific commit with advanced options - -### ✅ Ignore Management -- [x] `repo.ignore_add(patterns)` - Add patterns to .gitignore -- [x] `repo.ignore_check(file)` - Check if file is ignored -- [x] `repo.ignore_list()` - List current ignore patterns - -### ✅ Advanced File Operations -- [x] RestoreOptions with source, staged, and worktree control -- [x] RemoveOptions with force, recursive, cached, and ignore-unmatch -- [x] MoveOptions with force, verbose, and dry-run modes -- [x] Type-safe builder patterns for all file operations - -### 🔄 Remote Branch Tracking (Future Enhancement) +- **Reset operations with comprehensive reset modes and error handling** + +##Phase 1: Essential Remote Operations (COMPLETED) + +###Remote Management +- [x]`repo.add_remote(name, url)` - Add remote repository +- [x]`repo.remove_remote(name)` - Remove remote +- [x]`repo.list_remotes()` - List all remotes with URLs +- [x]`repo.rename_remote(old_name, new_name)` - Rename remote +- [x]`repo.get_remote_url(name)` - Get remote URL + +###Network Operations +- [x]`repo.fetch(remote)` / `repo.fetch_with_options()` - Fetch from remotes +- [x]`repo.push(remote, branch)` / `repo.push_with_options()` - Push changes +- [x]`repo.clone(url, path)` - Clone repository (static method) +- [x]Advanced options with FetchOptions and PushOptions +- [x]Type-safe builder patterns for network operations + +##Phase 2: File Lifecycle Operations (COMPLETED) + +###File Management +- [x]`repo.checkout_file(path)` - Restore file from HEAD +- [x]`repo.reset_file(path)` - Unstage specific file +- [x]`repo.rm(paths)` - Remove files from repository +- [x]`repo.rm_with_options(paths, options)` - Remove with advanced options +- [x]`repo.mv(from, to)` - Move/rename files in repository +- [x]`repo.mv_with_options(source, dest, options)` - Move with advanced options +- [x]`repo.restore(paths, options)` - Restore files from specific commit with advanced options + +###Ignore Management +- [x]`repo.ignore_add(patterns)` - Add patterns to .gitignore +- [x]`repo.ignore_check(file)` - Check if file is ignored +- [x]`repo.ignore_list()` - List current ignore patterns + +###Advanced File Operations +- [x]RestoreOptions with source, staged, and worktree control +- [x]RemoveOptions with force, recursive, cached, and ignore-unmatch +- [x]MoveOptions with force, verbose, and dry-run modes +- [x]Type-safe builder patterns for all file operations + +### Remote Branch Tracking (Future Enhancement) - [ ] `repo.branch_set_upstream(branch, remote_branch)` - Set tracking - [ ] `repo.branch_track(local, remote)` - Track remote branch - [ ] Remote branch listing and status - [ ] Pull operations (fetch + merge) -## ✅ Phase 3: Tag Operations (COMPLETED) - -### ✅ Tag Management -- [x] `repo.tags()` - List all tags with comprehensive filtering -- [x] `repo.create_tag(name, target)` - Create lightweight tag -- [x] `repo.create_tag_with_options(name, target, options)` - Create tag with options -- [x] `repo.delete_tag(name)` - Delete tag -- [x] `repo.show_tag(name)` - Get detailed tag information -- [x] TagList with filtering (lightweight, annotated, find_containing, for_commit) -- [x] TagOptions builder with force, message, sign, annotated options -- [x] Type-safe TagType enum (Lightweight, Annotated) -- [x] Complete tag metadata support (message, tagger, timestamp) - -## Phase 5: Release Management (Medium Priority) +##Phase 3: Tag Operations (COMPLETED) + +###Tag Management +- [x]`repo.tags()` - List all tags with comprehensive filtering +- [x]`repo.create_tag(name, target)` - Create lightweight tag +- [x]`repo.create_tag_with_options(name, target, options)` - Create tag with options +- [x]`repo.delete_tag(name)` - Delete tag +- [x]`repo.show_tag(name)` - Get detailed tag information +- [x]TagList with filtering (lightweight, annotated, find_containing, for_commit) +- [x]TagOptions builder with force, message, sign, annotated options +- [x]Type-safe TagType enum (Lightweight, Annotated) +- [x]Complete tag metadata support (message, tagger, timestamp) + +##Phase 4: Stash Operations (COMPLETED) + +###Stash Management +- [x]`repo.stash_save(message)` - Save current changes +- [x]`repo.stash_push(message, options)` - Stash with advanced options +- [x]`repo.stash_list()` - List all stashes with comprehensive filtering +- [x]`repo.stash_apply(index, options)` - Apply stash without removing it +- [x]`repo.stash_pop(index, options)` - Apply and remove stash +- [x]`repo.stash_drop(index)` / `repo.stash_clear()` - Remove stashes +- [x]`repo.stash_show(index)` - Show stash contents +- [x]StashList with filtering (find_containing, for_branch, latest, get) +- [x]StashOptions builder with untracked, keep_index, patch, staged_only, paths +- [x]StashApplyOptions builder with restore_index, quiet options +- [x]Complete stash metadata support (index, message, hash, branch, timestamp) + +##Phase 5: Reset Operations (COMPLETED) + +###Reset Management +- [x]`repo.reset_soft(commit)` - Move HEAD, keep index and working tree +- [x]`repo.reset_mixed(commit)` - Move HEAD, reset index, keep working tree (default) +- [x]`repo.reset_hard(commit)` - Reset HEAD, index, and working tree to commit state +- [x]`repo.reset_with_mode(commit, mode)` - Flexible reset with explicit ResetMode +- [x]`repo.reset_file(path)` - Unstage specific file (already exists in files.rs) +- [x]ResetMode enum with type-safe mode selection (Soft, Mixed, Hard) +- [x]Complete error handling for invalid commits and references +- [x]Comprehensive reset workflows with file-specific operations +- [x]Cross-platform temporary directory handling for tests + +##Phase 6: Merge Operations (COMPLETED) + +###Merge Management +- [x]`repo.merge(branch)` - Merge branch into current branch +- [x]`repo.merge_with_options(branch, options)` - Merge with advanced options +- [x]`repo.merge_in_progress()` - Check if merge is currently in progress +- [x]`repo.abort_merge()` - Cancel ongoing merge operation +- [x]MergeStatus enum with Success, FastForward, UpToDate, Conflicts variants +- [x]MergeOptions builder with fast_forward, strategy, commit_message, no_commit options +- [x]FastForwardMode enum: Auto, Only, Never with const as_str() methods +- [x]MergeStrategy enum: Recursive, Ours, Theirs with const as_str() methods +- [x]Complete conflict detection with file-level granularity +- [x]Comprehensive merge workflows with error handling + +## Phase 7: Release Management (Medium Priority) ### Archive & Export - [ ] `repo.archive(format, output_path)` - Create repository archive - [ ] `repo.export_commit(hash, path)` - Export specific commit -## ✅ Phase 4: Stash Operations (COMPLETED) - -### ✅ Stash Management -- [x] `repo.stash_save(message)` - Save current changes -- [x] `repo.stash_push(message, options)` - Stash with advanced options -- [x] `repo.stash_list()` - List all stashes with comprehensive filtering -- [x] `repo.stash_apply(index, options)` - Apply stash without removing it -- [x] `repo.stash_pop(index, options)` - Apply and remove stash -- [x] `repo.stash_drop(index)` / `repo.stash_clear()` - Remove stashes -- [x] `repo.stash_show(index)` - Show stash contents -- [x] StashList with filtering (find_containing, for_branch, latest, get) -- [x] StashOptions builder with untracked, keep_index, patch, staged_only, paths -- [x] StashApplyOptions builder with restore_index, quiet options -- [x] Complete stash metadata support (index, message, hash, branch, timestamp) - -## Phase 6: Development Workflow (Medium Priority) - -### Merge & Rebase -- [ ] `repo.merge(branch)` / `repo.merge_commit(hash)` - Merge operations +## Phase 8: Development Workflow (Medium Priority) + +### Rebase & Cherry-pick - [ ] `repo.rebase(onto_branch)` - Rebase current branch - [ ] `repo.cherry_pick(hash)` - Cherry-pick commit -- [ ] Conflict resolution helpers and status -- [ ] `repo.abort_merge()` / `repo.abort_rebase()` - Abort operations +- [ ] `repo.abort_rebase()` - Abort rebase operations -## Phase 7: Advanced Configuration (Medium Priority) +## Phase 9: Advanced Configuration (Medium Priority) ### Enhanced Configuration - [ ] `Config::global()` - Global git configuration @@ -122,7 +148,7 @@ - [ ] `repo.hooks().remove(hook_type)` - Remove hooks - [ ] Pre-built common hooks (pre-commit, pre-push, etc.) -## Phase 8: Repository Analysis (Low Priority) +## Phase 10: Repository Analysis (Low Priority) ### History & Inspection - [ ] `repo.show(hash)` - Show commit with full diff @@ -136,7 +162,7 @@ - [ ] `repo.size_analysis()` - Large files, repository size analysis - [ ] `repo.gc()` / `repo.fsck()` - Maintenance operations -## Phase 9: Advanced Features (Low Priority) +## Phase 11: Advanced Features (Low Priority) ### Worktree Support - [ ] `repo.worktree_add(path, branch)` - Add worktree diff --git a/README.md b/README.md index e45bbe0..8c836bf 100644 --- a/README.md +++ b/README.md @@ -16,29 +16,33 @@ Rustic Git provides a simple, ergonomic interface for common Git operations. It ## Features -- ✅ Repository initialization and opening -- ✅ **Enhanced file status checking** with separate staged/unstaged tracking -- ✅ **Precise Git state representation** using IndexStatus and WorktreeStatus enums -- ✅ File staging (add files, add all, add updates) -- ✅ Commit creation with hash return -- ✅ **Complete branch operations** with type-safe Branch API -- ✅ **Branch management** (create, delete, checkout, list) -- ✅ **Commit history & log operations** with multi-level API -- ✅ **Advanced commit querying** with filtering and analysis -- ✅ **Repository configuration management** with type-safe API -- ✅ **Remote management** with full CRUD operations and network support -- ✅ **Network operations** (fetch, push, clone) with advanced options -- ✅ **File lifecycle operations** (restore, reset, remove, move, .gitignore management) -- ✅ **Diff operations** with multi-level API and comprehensive options -- ✅ **Tag management** with comprehensive operations and filtering -- ✅ **Lightweight and annotated tags** with type-safe API -- ✅ **Stash operations** with comprehensive stash management and filtering -- ✅ **Advanced stash options** (untracked files, keep index, specific paths) -- ✅ Type-safe error handling with custom GitError enum -- ✅ Universal `Hash` type for Git objects -- ✅ **Immutable collections** (Box<[T]>) for memory efficiency -- ✅ **Const enum conversions** with zero runtime cost -- ✅ Comprehensive test coverage (161+ tests) +-Repository initialization and opening +-**Enhanced file status checking** with separate staged/unstaged tracking +-**Precise Git state representation** using IndexStatus and WorktreeStatus enums +-File staging (add files, add all, add updates) +-Commit creation with hash return +-**Complete branch operations** with type-safe Branch API +-**Branch management** (create, delete, checkout, list) +-**Commit history & log operations** with multi-level API +-**Advanced commit querying** with filtering and analysis +-**Repository configuration management** with type-safe API +-**Remote management** with full CRUD operations and network support +-**Network operations** (fetch, push, clone) with advanced options +-**File lifecycle operations** (restore, reset, remove, move, .gitignore management) +-**Diff operations** with multi-level API and comprehensive options +-**Tag management** with comprehensive operations and filtering +-**Lightweight and annotated tags** with type-safe API +-**Stash operations** with comprehensive stash management and filtering +-**Advanced stash options** (untracked files, keep index, specific paths) +-**Reset operations** with comprehensive soft/mixed/hard reset support +-**Repository history management** with type-safe ResetMode API +-**Merge operations** with comprehensive branch merging and conflict handling +-**Advanced merge options** (fast-forward control, merge strategies, conflict detection) +-Type-safe error handling with custom GitError enum +-Universal `Hash` type for Git objects +-**Immutable collections** (Box<[T]>) for memory efficiency +-**Const enum conversions** with zero runtime cost +-Comprehensive test coverage (187+ tests) ## Installation diff --git a/examples/merge_operations.rs b/examples/merge_operations.rs new file mode 100644 index 0000000..a7f4bf9 --- /dev/null +++ b/examples/merge_operations.rs @@ -0,0 +1,296 @@ +use rustic_git::{FastForwardMode, MergeOptions, MergeStatus, Repository, Result}; +use std::{env, fs}; + +/// Comprehensive demonstration of merge operations in rustic-git +/// +/// This example showcases: +/// - Simple branch merging +/// - Fast-forward vs non-fast-forward merges +/// - Merge conflict detection and handling +/// - Different merge strategies and options +/// - Merge status checking and abort operations +/// +/// Merge operations are fundamental for collaborative development workflows. +fn main() -> Result<()> { + println!("=== Merge Operations Demo ===\n"); + + // Create a temporary directory for our example + let temp_dir = env::temp_dir().join("rustic_git_merge_demo"); + + // Clean up if exists + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir)?; + } + + println!("Working in temporary directory: {:?}\n", temp_dir); + + // Initialize a new repository + let repo = Repository::init(&temp_dir, false)?; + + // Configure user for commits + repo.config().set_user("Example User", "example@test.com")?; + + demonstrate_fast_forward_merge(&repo, &temp_dir)?; + demonstrate_no_fast_forward_merge(&repo, &temp_dir)?; + demonstrate_merge_conflicts(&repo, &temp_dir)?; + demonstrate_merge_status_and_abort(&repo, &temp_dir)?; + + println!("\n=== Merge Operations Demo Complete ==="); + + // Clean up + fs::remove_dir_all(&temp_dir)?; + Ok(()) +} + +fn demonstrate_fast_forward_merge(repo: &Repository, temp_dir: &std::path::Path) -> Result<()> { + println!("--- Demonstrating Fast-Forward Merge ---\n"); + + // Create initial commit + println!("1. Creating initial commit on master..."); + let file1_path = temp_dir.join("README.md"); + fs::write(&file1_path, "# Project\n\nInitial content")?; + repo.add(&["README.md"])?; + let initial_commit = repo.commit("Initial commit")?; + println!(" Created commit: {}", initial_commit); + + // Create feature branch and add commits + println!("\n2. Creating feature branch and adding commits..."); + repo.checkout_new("feature/fast-forward", None)?; + + let file2_path = temp_dir.join("feature.txt"); + fs::write(&file2_path, "New feature implementation")?; + repo.add(&["feature.txt"])?; + let feature_commit = repo.commit("Add new feature")?; + println!(" Feature commit: {}", feature_commit); + + // Switch back to master + println!("\n3. Switching back to master..."); + let branches = repo.branches()?; + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch)?; + println!(" Switched to master"); + + // Perform fast-forward merge + println!("\n4. Performing fast-forward merge..."); + let merge_status = repo.merge("feature/fast-forward")?; + + match merge_status { + MergeStatus::FastForward(hash) => { + println!(" ✓ Fast-forward merge completed!"); + println!(" New HEAD: {}", hash); + println!(" Both files are now present on master"); + } + _ => println!(" Unexpected merge result: {:?}", merge_status), + } + + println!(" Files in repository:"); + for file in ["README.md", "feature.txt"] { + if temp_dir.join(file).exists() { + println!(" ✓ {}", file); + } + } + + Ok(()) +} + +fn demonstrate_no_fast_forward_merge(repo: &Repository, temp_dir: &std::path::Path) -> Result<()> { + println!("\n--- Demonstrating No-Fast-Forward Merge ---\n"); + + // Add a commit to master to prevent fast-forward + println!("1. Adding commit to master..."); + let readme_path = temp_dir.join("README.md"); + fs::write( + &readme_path, + "# Project\n\nInitial content\n\n## Updates\nAdded documentation", + )?; + repo.add(&["README.md"])?; + let master_commit = repo.commit("Update documentation")?; + println!(" Master commit: {}", master_commit); + + // Create another feature branch + println!("\n2. Creating another feature branch..."); + repo.checkout_new("feature/no-ff", None)?; + + let config_path = temp_dir.join("config.yaml"); + fs::write(&config_path, "app:\n name: example\n version: 1.0")?; + repo.add(&["config.yaml"])?; + let config_commit = repo.commit("Add configuration file")?; + println!(" Config commit: {}", config_commit); + + // Switch back to master + println!("\n3. Switching back to master..."); + let branches = repo.branches()?; + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch)?; + + // Perform no-fast-forward merge + println!("\n4. Performing no-fast-forward merge..."); + let options = MergeOptions::new() + .with_fast_forward(FastForwardMode::Never) + .with_message("Merge feature/no-ff into master".to_string()); + + let merge_status = repo.merge_with_options("feature/no-ff", options)?; + + match merge_status { + MergeStatus::Success(hash) => { + println!(" ✓ Merge commit created!"); + println!(" Merge commit: {}", hash); + println!(" Created explicit merge commit preserving branch history"); + } + _ => println!(" Unexpected merge result: {:?}", merge_status), + } + + // Show the commit history + println!("\n5. Recent commit history:"); + let commits = repo.recent_commits(3)?; + for (i, commit) in commits.iter().enumerate() { + println!( + " {}: {} - {}", + i + 1, + commit.hash.short(), + commit.message.subject + ); + } + + Ok(()) +} + +fn demonstrate_merge_conflicts(repo: &Repository, temp_dir: &std::path::Path) -> Result<()> { + println!("\n--- Demonstrating Merge Conflicts ---\n"); + + // Create conflicting branch + println!("1. Creating branch with conflicting changes..."); + repo.checkout_new("feature/conflict", None)?; + + // Modify the same file differently + let readme_path = temp_dir.join("README.md"); + fs::write( + &readme_path, + "# Project\n\nFeature branch changes\n\n## Updates\nAdded documentation", + )?; + repo.add(&["README.md"])?; + let feature_commit = repo.commit("Update README from feature branch")?; + println!(" Feature commit: {}", feature_commit); + + // Switch back to master and make conflicting change + println!("\n2. Making conflicting change on master..."); + let branches = repo.branches()?; + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch)?; + + fs::write( + &readme_path, + "# Project\n\nMaster branch changes\n\n## Updates\nAdded documentation", + )?; + repo.add(&["README.md"])?; + let master_conflict_commit = repo.commit("Update README from master")?; + println!(" Master commit: {}", master_conflict_commit); + + // Attempt merge (will have conflicts) + println!("\n3. Attempting merge (will have conflicts)..."); + let merge_status = repo.merge("feature/conflict")?; + + match merge_status { + MergeStatus::Conflicts(files) => { + println!(" ⚠️ Merge conflicts detected!"); + println!(" Conflicted files:"); + for file in &files { + println!(" - {}", file.display()); + } + + // Check merge in progress + if repo.merge_in_progress()? { + println!(" ✓ Merge in progress status detected"); + } + + // Show conflict markers in file + println!("\n4. Conflict markers in README.md:"); + let content = fs::read_to_string(&readme_path)?; + for (i, line) in content.lines().enumerate() { + if line.starts_with("<<<<<<< ") + || line.starts_with("======= ") + || line.starts_with(">>>>>>> ") + { + println!(" {}: {} <-- conflict marker", i + 1, line); + } else { + println!(" {}: {}", i + 1, line); + } + } + + // Abort the merge + println!("\n5. Aborting merge..."); + repo.abort_merge()?; + println!(" ✓ Merge aborted successfully"); + + // Verify merge is no longer in progress + if !repo.merge_in_progress()? { + println!(" ✓ Repository is back to clean state"); + } + } + _ => println!(" Unexpected merge result: {:?}", merge_status), + } + + Ok(()) +} + +fn demonstrate_merge_status_and_abort(repo: &Repository, temp_dir: &std::path::Path) -> Result<()> { + println!("\n--- Demonstrating Merge Status and Options ---\n"); + + // Create a simple feature branch + println!("1. Creating simple feature branch..."); + repo.checkout_new("feature/simple", None)?; + + let simple_path = temp_dir.join("simple.txt"); + fs::write(&simple_path, "Simple feature content")?; + repo.add(&["simple.txt"])?; + repo.commit("Add simple feature")?; + + // Switch back to master + let branches = repo.branches()?; + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch)?; + + // Test merge with different options + println!("\n2. Testing merge with custom options..."); + let options = MergeOptions::new() + .with_fast_forward(FastForwardMode::Auto) + .with_message("Integrate simple feature".to_string()); + + let merge_status = repo.merge_with_options("feature/simple", options)?; + + match merge_status { + MergeStatus::FastForward(hash) => { + println!(" ✓ Fast-forward merge completed: {}", hash); + } + MergeStatus::Success(hash) => { + println!(" ✓ Merge commit created: {}", hash); + } + MergeStatus::UpToDate => { + println!(" ✓ Already up to date"); + } + MergeStatus::Conflicts(_) => { + println!(" ⚠️ Unexpected conflicts"); + } + } + + // Show final repository state + println!("\n3. Final repository state:"); + let status = repo.status()?; + println!( + " Working directory clean: {}", + status.staged_files().count() == 0 && status.unstaged_files().count() == 0 + ); + + let commits = repo.recent_commits(5)?; + println!(" Recent commits:"); + for (i, commit) in commits.iter().enumerate() { + println!( + " {}: {} - {}", + i + 1, + commit.hash.short(), + commit.message.subject + ); + } + + Ok(()) +} diff --git a/examples/reset_operations.rs b/examples/reset_operations.rs new file mode 100644 index 0000000..91a7712 --- /dev/null +++ b/examples/reset_operations.rs @@ -0,0 +1,209 @@ +use rustic_git::{Repository, ResetMode, Result}; +use std::{env, fs}; + +/// Comprehensive demonstration of reset operations in rustic-git +/// +/// This example showcases: +/// - Different reset modes (soft, mixed, hard) +/// - Reset to specific commits +/// - File-specific resets +/// - Error handling for invalid commits +/// +/// Reset operations are essential for managing git history and staging area. +fn main() -> Result<()> { + println!("=== Reset Operations Demo ===\n"); + + // Create a temporary directory for our example + let temp_dir = env::temp_dir().join("rustic_git_reset_demo"); + + // Clean up if exists + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir)?; + } + + println!("Working in temporary directory: {:?}\n", temp_dir); + + // Initialize a new repository + let repo = Repository::init(&temp_dir, false)?; + + // Configure user for commits + repo.config().set_user("Example User", "example@test.com")?; + + demonstrate_reset_modes(&repo, &temp_dir)?; + demonstrate_file_resets(&repo, &temp_dir)?; + demonstrate_error_handling(&repo)?; + + println!("\n=== Reset Operations Demo Complete ==="); + + // Clean up + fs::remove_dir_all(&temp_dir)?; + Ok(()) +} + +fn demonstrate_reset_modes(repo: &Repository, temp_dir: &std::path::Path) -> Result<()> { + println!("--- Demonstrating Reset Modes ---\n"); + + // Create initial commits + println!("1. Creating initial commits..."); + + // First commit + let file1_path = temp_dir.join("file1.txt"); + fs::write(&file1_path, "Initial content")?; + repo.add(&["file1.txt"])?; + let first_commit = repo.commit("Initial commit")?; + println!(" Created first commit: {}", first_commit); + + // Second commit + let file2_path = temp_dir.join("file2.txt"); + fs::write(&file2_path, "Second file content")?; + repo.add(&["file2.txt"])?; + let second_commit = repo.commit("Add file2.txt")?; + println!(" Created second commit: {}", second_commit); + + // Third commit + fs::write(&file1_path, "Modified content")?; + repo.add(&["file1.txt"])?; + let third_commit = repo.commit("Modify file1.txt")?; + println!(" Created third commit: {}", third_commit); + + // Show current status + println!("\n2. Current repository state:"); + show_repo_state(repo)?; + + // Demonstrate soft reset + println!("\n3. Performing soft reset to second commit..."); + repo.reset_soft(&second_commit.to_string())?; + + println!(" After soft reset:"); + show_repo_state(repo)?; + println!(" Note: Changes are still staged, working directory unchanged"); + + // Reset back to third commit for next demonstration + repo.reset_hard(&third_commit.to_string())?; + + // Demonstrate mixed reset (default) + println!("\n4. Performing mixed reset to second commit..."); + repo.reset_mixed(&second_commit.to_string())?; + + println!(" After mixed reset:"); + show_repo_state(repo)?; + println!(" Note: Changes are unstaged but preserved in working directory"); + + // Reset back to third commit for next demonstration + repo.reset_hard(&third_commit.to_string())?; + + // Demonstrate hard reset + println!("\n5. Performing hard reset to first commit..."); + repo.reset_hard(&first_commit.to_string())?; + + println!(" After hard reset:"); + show_repo_state(repo)?; + println!(" Note: All changes discarded, working directory matches commit"); + + // Demonstrate reset_with_mode for flexibility + println!("\n6. Using reset_with_mode for explicit control..."); + + // Recreate second commit for demo + fs::write(&file2_path, "Recreated second file")?; + repo.add(&["file2.txt"])?; + let _new_commit = repo.commit("Recreate file2.txt")?; + + repo.reset_with_mode(&first_commit.to_string(), ResetMode::Mixed)?; + println!(" Used ResetMode::Mixed explicitly"); + show_repo_state(repo)?; + + Ok(()) +} + +fn demonstrate_file_resets(repo: &Repository, temp_dir: &std::path::Path) -> Result<()> { + println!("\n--- Demonstrating File-Specific Resets ---\n"); + + // Create some files and stage them + println!("1. Creating and staging multiple files..."); + + let file_a = temp_dir.join("fileA.txt"); + let file_b = temp_dir.join("fileB.txt"); + + fs::write(&file_a, "Content A")?; + fs::write(&file_b, "Content B")?; + + repo.add(&["fileA.txt", "fileB.txt"])?; + println!(" Staged fileA.txt and fileB.txt"); + + show_repo_state(repo)?; + + // Reset a single file (using existing reset_file from files.rs) + println!("\n2. Resetting single file (fileA.txt)..."); + repo.reset_file("fileA.txt")?; + + println!(" After resetting fileA.txt:"); + show_repo_state(repo)?; + println!(" Note: fileA.txt is unstaged, fileB.txt remains staged"); + + // Demonstrate HEAD reset (unstage all changes) + println!("\n3. Performing mixed reset to HEAD (unstage all)..."); + repo.reset_mixed("HEAD")?; + + println!(" After reset HEAD:"); + show_repo_state(repo)?; + println!(" Note: All staged changes are now unstaged"); + + Ok(()) +} + +fn demonstrate_error_handling(repo: &Repository) -> Result<()> { + println!("\n--- Demonstrating Error Handling ---\n"); + + // Try to reset to invalid commit + println!("1. Attempting reset to invalid commit hash..."); + match repo.reset_mixed("invalid_commit_hash") { + Ok(_) => println!(" Unexpected success!"), + Err(e) => println!(" Expected error: {}", e), + } + + // Try to reset to non-existent reference + println!("\n2. Attempting reset to non-existent reference..."); + match repo.reset_soft("nonexistent-branch") { + Ok(_) => println!(" Unexpected success!"), + Err(e) => println!(" Expected error: {}", e), + } + + println!("\n Error handling works correctly!"); + Ok(()) +} + +fn show_repo_state(repo: &Repository) -> Result<()> { + let status = repo.status()?; + + let staged_count = status.staged_files().count(); + let unstaged_count = status.unstaged_files().count(); + let untracked_count = status.untracked_entries().count(); + + println!(" Repository state:"); + println!(" - Staged files: {}", staged_count); + println!(" - Modified files: {}", unstaged_count); + println!(" - Untracked files: {}", untracked_count); + + if staged_count > 0 { + println!(" - Staged:"); + for file in status.staged_files().take(5) { + println!(" * {}", file.path.display()); + } + } + + if unstaged_count > 0 { + println!(" - Modified:"); + for file in status.unstaged_files().take(5) { + println!(" * {}", file.path.display()); + } + } + + if untracked_count > 0 { + println!(" - Untracked:"); + for file in status.untracked_entries().take(5) { + println!(" * {}", file.path.display()); + } + } + + Ok(()) +} diff --git a/src/commands/merge.rs b/src/commands/merge.rs new file mode 100644 index 0000000..2193a33 --- /dev/null +++ b/src/commands/merge.rs @@ -0,0 +1,636 @@ +//! Git merge operations +//! +//! This module provides functionality for merging branches and handling merge conflicts. +//! It supports different merge strategies and fast-forward modes with comprehensive type safety. +//! +//! # Examples +//! +//! ```rust,no_run +//! use rustic_git::{Repository, MergeOptions, MergeStatus, FastForwardMode}; +//! +//! let repo = Repository::open(".")?; +//! +//! // Simple merge +//! let status = repo.merge("feature-branch")?; +//! match status { +//! MergeStatus::Success(hash) => println!("Merge commit: {}", hash), +//! MergeStatus::FastForward(hash) => println!("Fast-forwarded to: {}", hash), +//! MergeStatus::UpToDate => println!("Already up to date"), +//! MergeStatus::Conflicts(files) => { +//! println!("Conflicts in files: {:?}", files); +//! // Resolve conflicts manually, then commit +//! } +//! } +//! +//! // Merge with options +//! let options = MergeOptions::new() +//! .with_fast_forward(FastForwardMode::Never) +//! .with_message("Merge feature branch".to_string()); +//! let status = repo.merge_with_options("feature-branch", options)?; +//! +//! # Ok::<(), rustic_git::GitError>(()) +//! ``` + +use crate::error::{GitError, Result}; +use crate::repository::Repository; +use crate::types::Hash; +use crate::utils::{git, git_raw}; +use std::path::{Path, PathBuf}; + +/// The result of a merge operation +#[derive(Debug, Clone, PartialEq)] +pub enum MergeStatus { + /// Merge completed successfully with a new merge commit + Success(Hash), + /// Fast-forward merge completed (no merge commit created) + FastForward(Hash), + /// Already up to date, no changes needed + UpToDate, + /// Merge has conflicts that need manual resolution + Conflicts(Vec), +} + +/// Fast-forward merge behavior +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FastForwardMode { + /// Allow fast-forward when possible (default) + Auto, + /// Only fast-forward, fail if merge commit would be needed + Only, + /// Never fast-forward, always create merge commit + Never, +} + +impl FastForwardMode { + pub const fn as_str(&self) -> &'static str { + match self { + FastForwardMode::Auto => "", + FastForwardMode::Only => "--ff-only", + FastForwardMode::Never => "--no-ff", + } + } +} + +/// Merge strategy options +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MergeStrategy { + /// Default recursive strategy + Recursive, + /// Ours strategy (favor our changes) + Ours, + /// Theirs strategy (favor their changes) + Theirs, +} + +impl MergeStrategy { + pub const fn as_str(&self) -> &'static str { + match self { + MergeStrategy::Recursive => "recursive", + MergeStrategy::Ours => "ours", + MergeStrategy::Theirs => "theirs", + } + } +} + +/// Options for merge operations +#[derive(Debug, Clone)] +pub struct MergeOptions { + fast_forward: FastForwardMode, + strategy: Option, + commit_message: Option, + no_commit: bool, +} + +impl MergeOptions { + /// Create new MergeOptions with default settings + pub fn new() -> Self { + Self { + fast_forward: FastForwardMode::Auto, + strategy: None, + commit_message: None, + no_commit: false, + } + } + + /// Set the fast-forward mode + pub fn with_fast_forward(mut self, mode: FastForwardMode) -> Self { + self.fast_forward = mode; + self + } + + /// Set the merge strategy + pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self { + self.strategy = Some(strategy); + self + } + + /// Set a custom commit message for the merge + pub fn with_message(mut self, message: String) -> Self { + self.commit_message = Some(message); + self + } + + /// Perform merge but don't automatically commit + pub fn with_no_commit(mut self) -> Self { + self.no_commit = true; + self + } +} + +impl Default for MergeOptions { + fn default() -> Self { + Self::new() + } +} + +/// Perform a merge operation +pub fn merge>( + repo_path: P, + branch: &str, + options: &MergeOptions, +) -> Result { + let mut args = vec!["merge"]; + + // Add fast-forward option if not auto + let ff_option = options.fast_forward.as_str(); + if !ff_option.is_empty() { + args.push(ff_option); + } + + // Add strategy if specified + if let Some(strategy) = options.strategy { + args.push("-s"); + args.push(strategy.as_str()); + } + + // Add no-commit option if specified + if options.no_commit { + args.push("--no-commit"); + } + + // Add custom commit message if specified + if let Some(ref message) = options.commit_message { + args.push("-m"); + args.push(message); + } + + // Add the branch to merge + args.push(branch); + + let output = git_raw(&args, Some(repo_path.as_ref()))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + // Parse the output to determine merge status + if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") { + Ok(MergeStatus::UpToDate) + } else if stdout.contains("Fast-forward") { + // Extract the hash from fast-forward output + if let Some(hash_line) = stdout.lines().find(|line| line.contains("..")) + && let Some(hash_part) = hash_line.split("..").nth(1) + && let Some(hash_str) = hash_part.split_whitespace().next() + { + let hash = Hash::from(hash_str); + return Ok(MergeStatus::FastForward(hash)); + } + // Fallback: get current HEAD + let head_output = git(&["rev-parse", "HEAD"], Some(repo_path.as_ref()))?; + let hash = Hash::from(head_output.trim()); + Ok(MergeStatus::FastForward(hash)) + } else { + // Regular merge success - get the merge commit hash + let head_output = git(&["rev-parse", "HEAD"], Some(repo_path.as_ref()))?; + let hash = Hash::from(head_output.trim()); + Ok(MergeStatus::Success(hash)) + } + } else if stderr.contains("CONFLICT") + || stderr.contains("Automatic merge failed") + || stdout.contains("CONFLICT") + || stdout.contains("Automatic merge failed") + { + // Merge has conflicts + let conflicts = extract_conflicted_files(repo_path.as_ref())?; + Ok(MergeStatus::Conflicts(conflicts)) + } else { + // Other error + Err(GitError::CommandFailed(format!( + "git {} failed: stdout='{}' stderr='{}'", + args.join(" "), + stdout, + stderr + ))) + } +} + +/// Extract list of files with conflicts +fn extract_conflicted_files>(repo_path: P) -> Result> { + let output = git( + &["diff", "--name-only", "--diff-filter=U"], + Some(repo_path.as_ref()), + )?; + + let conflicts: Vec = output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| PathBuf::from(line.trim())) + .collect(); + + Ok(conflicts) +} + +/// Check if a merge is currently in progress +pub fn merge_in_progress>(repo_path: P) -> Result { + let git_dir = repo_path.as_ref().join(".git"); + let merge_head = git_dir.join("MERGE_HEAD"); + Ok(merge_head.exists()) +} + +/// Abort an in-progress merge +pub fn abort_merge>(repo_path: P) -> Result<()> { + git(&["merge", "--abort"], Some(repo_path.as_ref()))?; + Ok(()) +} + +impl Repository { + /// Merge the specified branch into the current branch. + /// + /// Performs a merge using default options (allow fast-forward, no custom message). + /// + /// # Arguments + /// + /// * `branch` - The name of the branch to merge into the current branch + /// + /// # Returns + /// + /// A `Result` containing the `MergeStatus` which indicates the outcome of the merge. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustic_git::{Repository, MergeStatus}; + /// + /// let repo = Repository::open(".")?; + /// match repo.merge("feature-branch")? { + /// MergeStatus::Success(hash) => println!("Merge commit: {}", hash), + /// MergeStatus::FastForward(hash) => println!("Fast-forwarded to: {}", hash), + /// MergeStatus::UpToDate => println!("Already up to date"), + /// MergeStatus::Conflicts(files) => println!("Conflicts in: {:?}", files), + /// } + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn merge(&self, branch: &str) -> Result { + Self::ensure_git()?; + merge(self.repo_path(), branch, &MergeOptions::new()) + } + + /// Merge the specified branch with custom options. + /// + /// Provides full control over merge behavior including fast-forward mode, + /// merge strategy, and commit message. + /// + /// # Arguments + /// + /// * `branch` - The name of the branch to merge into the current branch + /// * `options` - Merge options controlling the merge behavior + /// + /// # Returns + /// + /// A `Result` containing the `MergeStatus` which indicates the outcome of the merge. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustic_git::{Repository, MergeOptions, FastForwardMode}; + /// + /// let repo = Repository::open(".")?; + /// let options = MergeOptions::new() + /// .with_fast_forward(FastForwardMode::Never) + /// .with_message("Merge feature into main".to_string()); + /// + /// let status = repo.merge_with_options("feature-branch", options)?; + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn merge_with_options(&self, branch: &str, options: MergeOptions) -> Result { + Self::ensure_git()?; + merge(self.repo_path(), branch, &options) + } + + /// Check if a merge is currently in progress. + /// + /// Returns `true` if there is an ongoing merge that needs to be completed or aborted. + /// + /// # Returns + /// + /// A `Result` containing a boolean indicating whether a merge is in progress. + pub fn merge_in_progress(&self) -> Result { + Self::ensure_git()?; + merge_in_progress(self.repo_path()) + } + + /// Abort an in-progress merge. + /// + /// Cancels the current merge operation and restores the repository to the state + /// before the merge was started. This is useful when merge conflicts occur and + /// you want to cancel the merge instead of resolving conflicts. + /// + /// # Returns + /// + /// A `Result` indicating success or failure of the abort operation. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// if repo.merge_in_progress()? { + /// repo.abort_merge()?; + /// println!("Merge aborted"); + /// } + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn abort_merge(&self) -> Result<()> { + Self::ensure_git()?; + abort_merge(self.repo_path()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Repository; + use std::path::PathBuf; + use std::{env, fs}; + + fn create_test_repo(test_name: &str) -> (PathBuf, Repository) { + let temp_dir = env::temp_dir().join(format!("rustic_git_merge_test_{}", test_name)); + + // Clean up if exists + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir).unwrap(); + } + + let repo = Repository::init(&temp_dir, false).unwrap(); + + // Configure git user for testing + repo.config() + .set_user("Test User", "test@example.com") + .unwrap(); + + (temp_dir, repo) + } + + fn create_file_and_commit( + repo: &Repository, + temp_dir: &Path, + filename: &str, + content: &str, + message: &str, + ) -> String { + let file_path = temp_dir.join(filename); + fs::write(&file_path, content).unwrap(); + repo.add(&[filename]).unwrap(); + repo.commit(message).unwrap().to_string() + } + + #[test] + fn test_fast_forward_mode_as_str() { + assert_eq!(FastForwardMode::Auto.as_str(), ""); + assert_eq!(FastForwardMode::Only.as_str(), "--ff-only"); + assert_eq!(FastForwardMode::Never.as_str(), "--no-ff"); + } + + #[test] + fn test_merge_strategy_as_str() { + assert_eq!(MergeStrategy::Recursive.as_str(), "recursive"); + assert_eq!(MergeStrategy::Ours.as_str(), "ours"); + assert_eq!(MergeStrategy::Theirs.as_str(), "theirs"); + } + + #[test] + fn test_merge_options_builder() { + let options = MergeOptions::new() + .with_fast_forward(FastForwardMode::Never) + .with_strategy(MergeStrategy::Ours) + .with_message("Custom merge message".to_string()) + .with_no_commit(); + + assert_eq!(options.fast_forward, FastForwardMode::Never); + assert_eq!(options.strategy, Some(MergeStrategy::Ours)); + assert_eq!( + options.commit_message, + Some("Custom merge message".to_string()) + ); + assert!(options.no_commit); + } + + #[test] + fn test_merge_fast_forward() { + let (temp_dir, repo) = create_test_repo("merge_fast_forward"); + + // Create initial commit on master + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit"); + + // Create and switch to feature branch + repo.checkout_new("feature", None).unwrap(); + + // Add commit to feature branch + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit"); + + // Switch back to master + let branches = repo.branches().unwrap(); + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch).unwrap(); + + // Merge feature branch (should fast-forward) + let status = repo.merge("feature").unwrap(); + + match status { + MergeStatus::FastForward(_) => { + // Verify file2.txt exists + assert!(temp_dir.join("file2.txt").exists()); + } + _ => panic!("Expected fast-forward merge, got: {:?}", status), + } + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_merge_no_fast_forward() { + let (temp_dir, repo) = create_test_repo("merge_no_ff"); + + // Create initial commit on master + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit"); + + // Create and switch to feature branch + repo.checkout_new("feature", None).unwrap(); + + // Add commit to feature branch + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit"); + + // Switch back to master + let branches = repo.branches().unwrap(); + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch).unwrap(); + + // Merge feature branch with no fast-forward + let options = MergeOptions::new().with_fast_forward(FastForwardMode::Never); + let status = repo.merge_with_options("feature", options).unwrap(); + + match status { + MergeStatus::Success(_) => { + // Verify merge commit was created and both files exist + assert!(temp_dir.join("file1.txt").exists()); + assert!(temp_dir.join("file2.txt").exists()); + } + _ => panic!("Expected merge commit, got: {:?}", status), + } + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_merge_up_to_date() { + let (temp_dir, repo) = create_test_repo("merge_up_to_date"); + + // Create initial commit + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit"); + + // Create feature branch but don't add commits + repo.checkout_new("feature", None).unwrap(); + let branches = repo.branches().unwrap(); + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch).unwrap(); + + // Try to merge feature (should be up to date) + let status = repo.merge("feature").unwrap(); + + assert_eq!(status, MergeStatus::UpToDate); + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_merge_in_progress_false() { + let (temp_dir, repo) = create_test_repo("merge_in_progress_false"); + + // Create initial commit + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit"); + + // Check merge in progress (should be false) + assert!(!repo.merge_in_progress().unwrap()); + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_merge_conflicts() { + let (temp_dir, repo) = create_test_repo("merge_conflicts"); + + // Create initial commit + create_file_and_commit( + &repo, + &temp_dir, + "file1.txt", + "line1\nline2\nline3", + "Initial commit", + ); + + // Create and switch to feature branch + repo.checkout_new("feature", None).unwrap(); + + // Modify file in feature branch + create_file_and_commit( + &repo, + &temp_dir, + "file1.txt", + "line1\nfeature_line\nline3", + "Feature changes", + ); + + // Switch back to master and modify same file + let branches = repo.branches().unwrap(); + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch).unwrap(); + create_file_and_commit( + &repo, + &temp_dir, + "file1.txt", + "line1\nmaster_line\nline3", + "Master changes", + ); + + // Try to merge feature branch (should have conflicts) + let status = repo.merge("feature").unwrap(); + + match status { + MergeStatus::Conflicts(files) => { + assert!(!files.is_empty()); + assert!(files.iter().any(|f| f.file_name().unwrap() == "file1.txt")); + + // Verify merge is in progress + assert!(repo.merge_in_progress().unwrap()); + + // Abort the merge + repo.abort_merge().unwrap(); + + // Verify merge is no longer in progress + assert!(!repo.merge_in_progress().unwrap()); + } + _ => panic!("Expected conflicts, got: {:?}", status), + } + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_merge_with_custom_message() { + let (temp_dir, repo) = create_test_repo("merge_custom_message"); + + // Create initial commit on master + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit"); + + // Create and switch to feature branch + repo.checkout_new("feature", None).unwrap(); + + // Add commit to feature branch + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit"); + + // Switch back to master + let branches = repo.branches().unwrap(); + let master_branch = branches.find("master").unwrap(); + repo.checkout(master_branch).unwrap(); + + // Merge with custom message and no fast-forward + let options = MergeOptions::new() + .with_fast_forward(FastForwardMode::Never) + .with_message("Custom merge commit message".to_string()); + + let status = repo.merge_with_options("feature", options).unwrap(); + + match status { + MergeStatus::Success(_) => { + // Get the latest commit message + let commits = repo.recent_commits(1).unwrap(); + let latest_commit = commits.iter().next().unwrap(); + assert!( + latest_commit + .message + .subject + .contains("Custom merge commit message") + ); + } + _ => panic!("Expected successful merge, got: {:?}", status), + } + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1b32da5..5527f53 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,7 +5,9 @@ pub mod config; pub mod diff; pub mod files; pub mod log; +pub mod merge; pub mod remote; +pub mod reset; pub mod stash; pub mod status; pub mod tag; @@ -17,7 +19,9 @@ pub use diff::{ }; pub use files::{MoveOptions, RemoveOptions, RestoreOptions}; pub use log::{Author, Commit, CommitDetails, CommitLog, CommitMessage, LogOptions}; +pub use merge::{FastForwardMode, MergeOptions, MergeStatus, MergeStrategy}; pub use remote::{FetchOptions, PushOptions, Remote, RemoteList}; +pub use reset::ResetMode; pub use stash::{Stash, StashApplyOptions, StashList, StashOptions}; pub use status::{FileEntry, GitStatus, IndexStatus, WorktreeStatus}; pub use tag::{Tag, TagList, TagOptions, TagType}; diff --git a/src/commands/reset.rs b/src/commands/reset.rs new file mode 100644 index 0000000..917edad --- /dev/null +++ b/src/commands/reset.rs @@ -0,0 +1,359 @@ +use crate::utils::git; +use crate::{Repository, Result}; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResetMode { + Soft, + Mixed, + Hard, +} + +impl ResetMode { + pub const fn as_str(&self) -> &'static str { + match self { + ResetMode::Soft => "--soft", + ResetMode::Mixed => "--mixed", + ResetMode::Hard => "--hard", + } + } +} + +pub fn reset>(repo_path: P, mode: ResetMode, commit: &str) -> Result<()> { + let args = vec!["reset", mode.as_str(), commit]; + git(&args, Some(repo_path.as_ref()))?; + Ok(()) +} + +impl Repository { + /// Perform a soft reset to the specified commit. + /// + /// Moves HEAD to the specified commit but keeps both the index and working directory unchanged. + /// Previously staged changes remain staged. + /// + /// # Arguments + /// + /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to + /// + /// # Returns + /// + /// A `Result` indicating success or a `GitError` if the operation fails. + pub fn reset_soft(&self, commit: &str) -> Result<()> { + Self::ensure_git()?; + reset(self.repo_path(), ResetMode::Soft, commit)?; + Ok(()) + } + + /// Perform a mixed reset to the specified commit (default reset behavior). + /// + /// Moves HEAD to the specified commit and resets the index to match, but leaves the working directory unchanged. + /// Previously staged changes become unstaged but remain in the working directory. + /// + /// # Arguments + /// + /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to + /// + /// # Returns + /// + /// A `Result` indicating success or a `GitError` if the operation fails. + pub fn reset_mixed(&self, commit: &str) -> Result<()> { + Self::ensure_git()?; + reset(self.repo_path(), ResetMode::Mixed, commit)?; + Ok(()) + } + + /// Perform a hard reset to the specified commit. + /// + /// Moves HEAD to the specified commit and resets both the index and working directory to match. + /// **WARNING**: This discards all uncommitted changes permanently. + /// + /// # Arguments + /// + /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to + /// + /// # Returns + /// + /// A `Result` indicating success or a `GitError` if the operation fails. + pub fn reset_hard(&self, commit: &str) -> Result<()> { + Self::ensure_git()?; + reset(self.repo_path(), ResetMode::Hard, commit)?; + Ok(()) + } + + /// Perform a reset with the specified mode. + /// + /// This is a flexible method that allows you to specify the reset mode explicitly. + /// + /// # Arguments + /// + /// * `commit` - The commit hash, reference, or "HEAD~N" to reset to + /// * `mode` - The reset mode (Soft, Mixed, or Hard) + /// + /// # Returns + /// + /// A `Result` indicating success or a `GitError` if the operation fails. + pub fn reset_with_mode(&self, commit: &str, mode: ResetMode) -> Result<()> { + Self::ensure_git()?; + reset(self.repo_path(), mode, commit)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Repository; + use std::path::PathBuf; + use std::{env, fs}; + + fn create_test_repo(test_name: &str) -> (PathBuf, Repository) { + let temp_dir = env::temp_dir().join(format!("rustic_git_reset_test_{}", test_name)); + + // Clean up if exists + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir).unwrap(); + } + + let repo = Repository::init(&temp_dir, false).unwrap(); + + // Configure git user for testing + repo.config() + .set_user("Test User", "test@example.com") + .unwrap(); + + (temp_dir, repo) + } + + fn create_file_and_commit( + repo: &Repository, + temp_dir: &Path, + filename: &str, + content: &str, + message: &str, + ) -> String { + let file_path = temp_dir.join(filename); + fs::write(&file_path, content).unwrap(); + repo.add(&[filename]).unwrap(); + repo.commit(message).unwrap().to_string() + } + + #[test] + fn test_reset_mode_as_str() { + assert_eq!(ResetMode::Soft.as_str(), "--soft"); + assert_eq!(ResetMode::Mixed.as_str(), "--mixed"); + assert_eq!(ResetMode::Hard.as_str(), "--hard"); + } + + #[test] + fn test_reset_soft() { + let (temp_dir, repo) = create_test_repo("reset_soft"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset soft to first commit + reset(&temp_dir, ResetMode::Soft, &first_commit).unwrap(); + + // Check that index still has file2.txt staged + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 1); + assert!( + status + .staged_files() + .any(|f| f.path.file_name().unwrap() == "file2.txt") + ); + + // Check that file2.txt still exists in working directory + assert!(temp_dir.join("file2.txt").exists()); + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_reset_mixed() { + let (temp_dir, repo) = create_test_repo("reset_mixed"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset mixed to first commit + reset(&temp_dir, ResetMode::Mixed, &first_commit).unwrap(); + + // Check that index is clean (no staged files) + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 0); + + // Check that file2.txt still exists in working directory as untracked + assert!(temp_dir.join("file2.txt").exists()); + assert!( + status + .untracked_entries() + .any(|f| f.path.file_name().unwrap() == "file2.txt") + ); + } + + #[test] + fn test_reset_hard() { + let (temp_dir, repo) = create_test_repo("reset_hard"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset hard to first commit + reset(&temp_dir, ResetMode::Hard, &first_commit).unwrap(); + + // Check that index is clean + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 0); + + // Check that file2.txt no longer exists in working directory + assert!(!temp_dir.join("file2.txt").exists()); + assert_eq!(status.untracked_entries().count(), 0); + } + + #[test] + fn test_reset_invalid_commit() { + let (temp_dir, _repo) = create_test_repo("reset_invalid_commit"); + + let result = reset(&temp_dir, ResetMode::Mixed, "invalid_commit_hash"); + assert!(result.is_err()); + } + + #[test] + fn test_reset_head() { + let (temp_dir, repo) = create_test_repo("reset_head"); + + // Create initial commit + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit"); + + // Modify file and stage it + fs::write(temp_dir.join("file1.txt"), "modified").unwrap(); + repo.add(&["file1.txt"]).unwrap(); + + // Reset to HEAD (should unstage changes) + reset(temp_dir, ResetMode::Mixed, "HEAD").unwrap(); + + // Verify file is no longer staged but working directory is modified + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 0); + assert_eq!(status.unstaged_files().count(), 1); + } + + // Tests for Repository methods + #[test] + fn test_repository_reset_soft() { + let (temp_dir, repo) = create_test_repo("repository_reset_soft"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset soft to first commit using Repository method + repo.reset_soft(&first_commit).unwrap(); + + // Check that index still has file2.txt staged + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 1); + assert!( + status + .staged_files() + .any(|f| f.path.file_name().unwrap() == "file2.txt") + ); + } + + #[test] + fn test_repository_reset_mixed() { + let (temp_dir, repo) = create_test_repo("repository_reset_mixed"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset mixed to first commit using Repository method + repo.reset_mixed(&first_commit).unwrap(); + + // Check that index is clean but file exists as untracked + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 0); + assert!(temp_dir.join("file2.txt").exists()); + assert!( + status + .untracked_entries() + .any(|f| f.path.file_name().unwrap() == "file2.txt") + ); + } + + #[test] + fn test_repository_reset_hard() { + let (temp_dir, repo) = create_test_repo("repository_reset_hard"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset hard to first commit using Repository method + repo.reset_hard(&first_commit).unwrap(); + + // Check that everything is reset + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 0); + assert!(!temp_dir.join("file2.txt").exists()); + assert_eq!(status.untracked_entries().count(), 0); + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_repository_reset_with_mode() { + let (temp_dir, repo) = create_test_repo("repository_reset_with_mode"); + + // Create initial commit + let first_commit = + create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "First commit"); + + // Create second commit + let _second_commit = + create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Second commit"); + + // Reset using reset_with_mode + repo.reset_with_mode(&first_commit, ResetMode::Mixed) + .unwrap(); + + // Check same behavior as reset_mixed + let status = repo.status().unwrap(); + assert_eq!(status.staged_files().count(), 0); + assert!(temp_dir.join("file2.txt").exists()); + + // Clean up + fs::remove_dir_all(&temp_dir).unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3a5d986..fe07cd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,9 +7,10 @@ mod utils; pub use commands::{ Author, Branch, BranchList, BranchType, Commit, CommitDetails, CommitLog, CommitMessage, DiffChunk, DiffLine, DiffLineType, DiffOptions, DiffOutput, DiffStats, DiffStatus, - FetchOptions, FileDiff, FileEntry, GitStatus, IndexStatus, LogOptions, MoveOptions, - PushOptions, Remote, RemoteList, RemoveOptions, RepoConfig, RestoreOptions, Stash, - StashApplyOptions, StashList, StashOptions, Tag, TagList, TagOptions, TagType, WorktreeStatus, + FastForwardMode, FetchOptions, FileDiff, FileEntry, GitStatus, IndexStatus, LogOptions, + MergeOptions, MergeStatus, MergeStrategy, MoveOptions, PushOptions, Remote, RemoteList, + RemoveOptions, RepoConfig, ResetMode, RestoreOptions, Stash, StashApplyOptions, StashList, + StashOptions, Tag, TagList, TagOptions, TagType, WorktreeStatus, }; pub use error::{GitError, Result}; pub use repository::Repository;