diff --git a/CLAUDE.md b/CLAUDE.md index 4613853..292b8e2 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) +- **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) - **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 @@ -99,8 +99,32 @@ - DiffOptions: context_lines, whitespace handling, path filtering, output formats (name-only, stat, numstat) - DiffStats: files_changed, insertions, deletions with aggregate statistics - Complete filtering: files_with_status(), iter(), is_empty(), len() for result analysis -- **Command modules**: status.rs, add.rs, commit.rs, branch.rs, log.rs, config.rs, remote.rs, files.rs, diff.rs (in src/commands/) -- **Testing**: 144+ tests covering all functionality with comprehensive edge cases +- **Tag operations**: Complete tag management with type-safe API + - Repository::tags() -> Result - list all tags with comprehensive filtering + - Repository::create_tag(name, target) -> Result - create lightweight tag + - Repository::create_tag_with_options(name, target, options) -> Result - create tag with options + - Repository::delete_tag(name) -> Result<()> - delete tag + - Repository::show_tag(name) -> Result - detailed tag information + - Tag struct: name, hash, tag_type, message, tagger, timestamp + - TagType enum: Lightweight, Annotated + - TagList: Box<[Tag]> with iterator methods (iter, lightweight, annotated), search (find, find_containing, for_commit), counting (len, lightweight_count, annotated_count) + - TagOptions builder: annotated, force, message, sign with builder pattern (with_annotated, with_force, with_message, with_sign) + - Author struct: name, email, timestamp for annotated tag metadata +- **Stash operations**: Complete stash management with type-safe API + - Repository::stash_list() -> Result - list all stashes with comprehensive filtering + - Repository::stash_save(message) -> Result - create simple stash + - Repository::stash_push(message, options) -> Result - create stash with options + - Repository::stash_apply(index, options) -> Result<()> - apply stash without removing it + - Repository::stash_pop(index, options) -> Result<()> - apply and remove stash + - Repository::stash_show(index) -> Result - show stash contents + - Repository::stash_drop(index) -> Result<()> - remove specific stash + - Repository::stash_clear() -> Result<()> - remove all stashes + - Stash struct: index, message, hash, branch, timestamp + - 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 - Run `cargo fmt && cargo build && cargo test && cargo clippy --all-targets --all-features -- -D warnings` after code changes - Make sure all examples are running @@ -118,6 +142,8 @@ The `examples/` directory contains comprehensive demonstrations of library funct - **remote_operations.rs**: Complete remote management - add/remove/rename remotes, fetch/push operations with options, network operations, error handling - **file_lifecycle_operations.rs**: Comprehensive file management - restore/reset/remove/move operations, .gitignore management, advanced file lifecycle workflows, staging area manipulation - **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 - **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 c60fad3..dbba110 100644 --- a/PLAN.md +++ b/PLAN.md @@ -15,6 +15,9 @@ - **Remote management with full CRUD operations** - **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** +- **Stash operations with comprehensive management and filtering** ## ✅ Phase 1: Essential Remote Operations (COMPLETED) @@ -61,29 +64,41 @@ - [ ] Pull operations (fetch + merge) -## Phase 3: Release Management (Medium Priority) +## ✅ Phase 3: Tag Operations (COMPLETED) -### Tag Operations -- [ ] `repo.create_tag(name, message)` - Create annotated tag -- [ ] `repo.create_lightweight_tag(name)` - Create lightweight tag -- [ ] `repo.list_tags()` - List tags with filtering options -- [ ] `repo.delete_tag(name)` - Delete tag -- [ ] `repo.tag_info(name)` - Get tag details -- [ ] `repo.push_tags()` - Push tags to remote +### ✅ 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) ### Archive & Export - [ ] `repo.archive(format, output_path)` - Create repository archive - [ ] `repo.export_commit(hash, path)` - Export specific commit -## Phase 4: Development Workflow (Medium Priority) +## ✅ 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) -### Stash Management -- [ ] `repo.stash_save(message)` - Save current changes -- [ ] `repo.stash_push(files, message)` - Stash specific files -- [ ] `repo.stash_list()` - List all stashes -- [ ] `repo.stash_pop()` / `repo.stash_apply(index)` - Apply stashes -- [ ] `repo.stash_drop(index)` / `repo.stash_clear()` - Remove stashes -- [ ] `repo.stash_show(index)` - Show stash contents +## Phase 6: Development Workflow (Medium Priority) ### Merge & Rebase - [ ] `repo.merge(branch)` / `repo.merge_commit(hash)` - Merge operations @@ -92,7 +107,7 @@ - [ ] Conflict resolution helpers and status - [ ] `repo.abort_merge()` / `repo.abort_rebase()` - Abort operations -## Phase 5: Advanced Configuration (Medium Priority) +## Phase 7: Advanced Configuration (Medium Priority) ### Enhanced Configuration - [ ] `Config::global()` - Global git configuration @@ -107,7 +122,7 @@ - [ ] `repo.hooks().remove(hook_type)` - Remove hooks - [ ] Pre-built common hooks (pre-commit, pre-push, etc.) -## Phase 6: Repository Analysis (Low Priority) +## Phase 8: Repository Analysis (Low Priority) ### History & Inspection - [ ] `repo.show(hash)` - Show commit with full diff @@ -121,7 +136,7 @@ - [ ] `repo.size_analysis()` - Large files, repository size analysis - [ ] `repo.gc()` / `repo.fsck()` - Maintenance operations -## Phase 7: Advanced Features (Low Priority) +## Phase 9: Advanced Features (Low Priority) ### Worktree Support - [ ] `repo.worktree_add(path, branch)` - Add worktree diff --git a/README.md b/README.md index 2511b88..e45bbe0 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,15 @@ Rustic Git provides a simple, ergonomic interface for common Git operations. It - ✅ **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 (144+ tests) +- ✅ Comprehensive test coverage (161+ tests) ## Installation @@ -54,7 +58,7 @@ cargo add rustic-git ## Quick Start ```rust -use rustic_git::{Repository, Result, IndexStatus, WorktreeStatus, LogOptions, FetchOptions, PushOptions, RestoreOptions, RemoveOptions, MoveOptions, DiffOptions, DiffOutput, DiffStatus}; +use rustic_git::{Repository, Result, IndexStatus, WorktreeStatus, LogOptions, FetchOptions, PushOptions, RestoreOptions, RemoveOptions, MoveOptions, DiffOptions, DiffOutput, DiffStatus, TagOptions, TagType, StashOptions, StashApplyOptions}; fn main() -> Result<()> { // Initialize a new repository @@ -200,6 +204,60 @@ fn main() -> Result<()> { let modified_files: Vec<_> = detailed_diff.files_with_status(DiffStatus::Modified).collect(); println!("Added: {} files, Modified: {} files", added_files.len(), modified_files.len()); + // Tag management + // Create a lightweight tag + let tag = repo.create_tag("v1.0.0", None)?; + println!("Created tag: {} -> {}", tag.name, tag.hash.short()); + + // Create an annotated tag + let tag_options = TagOptions::new() + .with_message("Release version 1.1.0".to_string()); + let annotated_tag = repo.create_tag_with_options("v1.1.0", None, tag_options)?; + println!("Created annotated tag: {} ({})", annotated_tag.name, annotated_tag.tag_type); + + // List and filter tags + let tags = repo.tags()?; + println!("Repository has {} tags", tags.len()); + + // Filter by tag type + for tag in tags.lightweight() { + println!("Lightweight tag: {} -> {}", tag.name, tag.hash.short()); + } + + for tag in tags.annotated() { + println!("Annotated tag: {} -> {}", tag.name, tag.hash.short()); + if let Some(message) = &tag.message { + println!(" Message: {}", message); + } + } + + // Stash operations + // Save current changes to a stash + let stash = repo.stash_save("WIP: working on new feature")?; + println!("Created stash: {} -> {}", stash.message, stash.hash.short()); + + // Stash with advanced options + let stash_options = StashOptions::new() + .with_untracked() // Include untracked files + .with_keep_index(); // Keep staged changes in index + let advanced_stash = repo.stash_push("WIP: with untracked files", stash_options)?; + + // List and filter stashes + let stashes = repo.stash_list()?; + println!("Repository has {} stashes", stashes.len()); + + // Filter stashes by message content + let wip_stashes: Vec<_> = stashes.find_containing("WIP").collect(); + println!("Found {} WIP stashes", wip_stashes.len()); + + // Apply latest stash (keeps it in list) + repo.stash_apply(0, StashApplyOptions::new())?; + println!("Applied latest stash"); + + // Pop stash (applies and removes from list) + repo.stash_pop(0, StashApplyOptions::new().with_index())?; + println!("Popped latest stash with index restoration"); + Ok(()) } ``` @@ -1375,6 +1433,257 @@ println!("Files: {}, +{} insertions, -{} deletions", diff.stats.deletions); ``` +### Stash Operations + +The stash operations provide a comprehensive API for temporarily saving and managing work-in-progress changes. All stash operations return structured data with type-safe filtering capabilities. + +#### `Repository::stash_save(message) -> Result` + +Save current changes to a new stash with a message. + +```rust +// Save current changes +let stash = repo.stash_save("WIP: working on authentication feature")?; +println!("Created stash: {} -> {}", stash.message, stash.hash.short()); +println!("Stash index: {}, Branch: {}", stash.index, stash.branch); +``` + +#### `Repository::stash_push(message, options) -> Result` + +Create a stash with advanced options. + +```rust +// Stash including untracked files +let options = StashOptions::new() + .with_untracked() // Include untracked files + .with_keep_index(); // Keep staged changes in index + +let stash = repo.stash_push("WIP: with untracked files", options)?; + +// Stash specific paths only +let path_options = StashOptions::new() + .with_paths(vec!["src/main.rs".into(), "tests/".into()]); +let partial_stash = repo.stash_push("WIP: specific files only", path_options)?; +``` + +#### `Repository::stash_list() -> Result` + +List all stashes with filtering capabilities. + +```rust +let stashes = repo.stash_list()?; +println!("Repository has {} stashes", stashes.len()); + +// Iterate over all stashes +for stash in stashes.iter() { + println!("[{}] {} -> {} ({})", + stash.index, + stash.message, + stash.hash.short(), + stash.timestamp.format("%Y-%m-%d %H:%M:%S") + ); +} + +// Filter by message content +let wip_stashes: Vec<_> = stashes.find_containing("WIP").collect(); +let feature_stashes: Vec<_> = stashes.find_containing("feature").collect(); +println!("Found {} WIP stashes, {} feature stashes", + wip_stashes.len(), feature_stashes.len()); + +// Get specific stashes +if let Some(latest) = stashes.latest() { + println!("Latest stash: {}", latest.message); +} + +if let Some(second) = stashes.get(1) { + println!("Second stash: {}", second.message); +} + +// Filter by branch +let main_stashes: Vec<_> = stashes.for_branch("main").collect(); +println!("Stashes from main branch: {}", main_stashes.len()); +``` + +#### `Repository::stash_apply(index, options) -> Result<()>` + +Apply a stash without removing it from the stash list. + +```rust +// Apply latest stash +repo.stash_apply(0, StashApplyOptions::new())?; + +// Apply with index restoration (restore staging state) +let apply_options = StashApplyOptions::new() + .with_index() // Restore staged state + .with_quiet(); // Suppress output +repo.stash_apply(0, apply_options)?; +``` + +#### `Repository::stash_pop(index, options) -> Result<()>` + +Apply a stash and remove it from the stash list. + +```rust +// Pop latest stash +repo.stash_pop(0, StashApplyOptions::new())?; + +// Pop with options +let pop_options = StashApplyOptions::new().with_index(); +repo.stash_pop(0, pop_options)?; +``` + +#### `Repository::stash_show(index) -> Result` + +Show the contents of a stash. + +```rust +// Show latest stash contents +let stash_contents = repo.stash_show(0)?; +println!("Latest stash changes:\n{}", stash_contents); + +// Show specific stash +let stash_contents = repo.stash_show(2)?; +println!("Stash@{2} contents:\n{}", stash_contents); +``` + +#### `Repository::stash_drop(index) -> Result<()>` + +Remove a specific stash from the stash list. + +```rust +// Drop a specific stash +repo.stash_drop(1)?; // Remove second stash +println!("Dropped stash@{1}"); +``` + +#### `Repository::stash_clear() -> Result<()>` + +Remove all stashes from the repository. + +```rust +// Clear all stashes +repo.stash_clear()?; +println!("Cleared all stashes"); +``` + +#### Stash Types and Data Structures + +```rust +// Individual stash representation +pub struct Stash { + pub index: usize, // Stash index (0 is most recent) + pub message: String, // Stash message + pub hash: Hash, // Stash commit hash + pub branch: String, // Branch where stash was created + pub timestamp: DateTime, // When stash was created +} + +// Collection of stashes with filtering methods +pub struct StashList { + // Methods: + // - iter() -> iterator over all stashes + // - find_containing(text) -> filter by message content + // - for_branch(branch) -> filter by branch name + // - latest() -> get most recent stash + // - get(index) -> get stash by index + // - len(), is_empty() -> collection info +} + +// Options for creating stashes +pub struct StashOptions { + pub include_untracked: bool, // Include untracked files + pub include_all: bool, // Include all files (untracked + ignored) + pub keep_index: bool, // Keep staged changes in index + pub patch: bool, // Interactive patch mode + pub staged_only: bool, // Only stash staged changes + pub paths: Vec, // Specific paths to stash +} + +// Options for applying stashes +pub struct StashApplyOptions { + pub restore_index: bool, // Restore staged state + pub quiet: bool, // Suppress output messages +} +``` + +#### Stash Options Builder + +```rust +// Build custom stash options +let stash_options = StashOptions::new() + .with_untracked() // Include untracked files (-u) + .with_keep_index() // Keep staged changes (--keep-index) + .with_patch() // Interactive mode (-p) + .with_staged_only() // Only staged changes (--staged) + .with_paths(vec![ // Specific paths only + "src/main.rs".into(), + "tests/".into() + ]); + +let stash = repo.stash_push("Custom stash", stash_options)?; + +// Build apply options +let apply_options = StashApplyOptions::new() + .with_index() // Restore index state (--index) + .with_quiet(); // Quiet mode (-q) + +repo.stash_apply(0, apply_options)?; +``` + +#### Working with Stash Results + +```rust +let stashes = repo.stash_list()?; + +// Check if any stashes exist +if stashes.is_empty() { + println!("No stashes found"); + return Ok(()); +} + +// Work with latest stash +if let Some(latest) = stashes.latest() { + println!("Latest stash: {}", latest.message); + println!("Created on: {}", latest.timestamp.format("%Y-%m-%d %H:%M:%S")); + println!("On branch: {}", latest.branch); + + // Show stash contents + let contents = repo.stash_show(latest.index)?; + println!("Changes:\n{}", contents); + + // Apply the stash + repo.stash_apply(latest.index, StashApplyOptions::new())?; +} + +// Filter and process stashes +let work_stashes: Vec<_> = stashes + .find_containing("WIP") + .filter(|s| s.branch == "main") + .collect(); + +for stash in work_stashes { + println!("Work stash on main: [{}] {}", stash.index, stash.message); +} + +// Manage stash stack +println!("Stash stack summary:"); +for stash in stashes.iter().take(5) { // Show top 5 stashes + println!(" [{}] {} ({})", + stash.index, + stash.message, + stash.timestamp.format("%m/%d %H:%M") + ); +} + +// Clean up old stashes (example: keep only recent 10) +if stashes.len() > 10 { + for i in (10..stashes.len()).rev() { + repo.stash_drop(i)?; + println!("Dropped old stash@{{{}}}", i); + } +} +``` + ## Examples The `examples/` directory contains comprehensive demonstrations of library functionality: @@ -1415,6 +1724,12 @@ cargo run --example file_lifecycle_operations # Diff operations with multi-level API and comprehensive options cargo run --example diff_operations +# Tag operations (create, list, delete, filter) +cargo run --example tag_operations + +# Stash operations (save, apply, pop, list, manage) +cargo run --example stash_operations + # Error handling patterns and recovery strategies cargo run --example error_handling ``` @@ -1432,6 +1747,8 @@ cargo run --example error_handling - **`remote_operations.rs`** - Complete remote management demonstration: add, remove, rename remotes, fetch/push operations with options, and network operations - **`diff_operations.rs`** - Comprehensive diff operations showcase: unstaged/staged diffs, commit comparisons, advanced options, filtering, and output formats - **`file_lifecycle_operations.rs`** - Comprehensive file management demonstration: restore, reset, remove, move operations, .gitignore management, and advanced file lifecycle workflows +- **`tag_operations.rs`** - Complete tag management demonstration: create, list, delete, filter tags, lightweight vs annotated tags, tag options, and comprehensive tag workflows +- **`stash_operations.rs`** - Complete stash management demonstration: save, apply, pop, list stashes, advanced options (untracked files, keep index, specific paths), filtering, and comprehensive stash workflows - **`error_handling.rs`** - Comprehensive error handling patterns showing GitError variants, recovery strategies, and best practices All examples use OS-appropriate temporary directories and include automatic cleanup for safe execution. @@ -1495,6 +1812,9 @@ cargo run --example config_operations cargo run --example commit_history cargo run --example remote_operations cargo run --example file_lifecycle_operations +cargo run --example diff_operations +cargo run --example tag_operations +cargo run --example stash_operations cargo run --example error_handling ``` @@ -1510,12 +1830,11 @@ cargo run --example error_handling ## Roadmap Future planned features: -- [ ] Tag operations (create, list, delete, push tags) -- [ ] Stash operations (save, apply, pop, list) +- [x] Tag operations (create, list, delete, push tags) +- [x] Stash operations (save, apply, pop, list, manage) - [ ] Merge and rebase operations -- [ ] Diff operations - [ ] Repository analysis (blame, statistics, health check) ## Status -rustic-git provides a complete git workflow including repository management, status checking, staging operations, commits, branch operations, commit history analysis, remote management, network operations, and comprehensive file lifecycle management. +rustic-git provides a complete git workflow including repository management, status checking, staging operations, commits, branch operations, commit history analysis, remote management, network operations, comprehensive file lifecycle management, tag operations, and stash management. diff --git a/examples/stash_operations.rs b/examples/stash_operations.rs new file mode 100644 index 0000000..7b1f753 --- /dev/null +++ b/examples/stash_operations.rs @@ -0,0 +1,370 @@ +//! Stash Operations Example +//! +//! This example demonstrates comprehensive stash management functionality in rustic-git: +//! - Creating and saving stashes with various options +//! - Listing and filtering stashes +//! - Applying and popping stashes +//! - Showing stash contents +//! - Dropping and clearing stashes +//! - Working with stash metadata and options +//! +//! Run with: cargo run --example stash_operations + +use rustic_git::{Repository, Result, StashApplyOptions, StashOptions}; +use std::env; +use std::fs; + +fn main() -> Result<()> { + println!("Rustic Git - Stash Operations Example\n"); + + // Use a temporary directory for this example + let repo_path = env::temp_dir().join("rustic_git_stash_example"); + + // Clean up any previous run + if repo_path.exists() { + fs::remove_dir_all(&repo_path).expect("Failed to clean up previous example"); + } + + println!("Initializing repository at: {}", repo_path.display()); + + // Initialize repository and configure user + let repo = Repository::init(&repo_path, false)?; + repo.config() + .set_user("Stash Demo User", "stash@example.com")?; + + // Create initial commit to have a base + println!("\nCreating initial commit..."); + fs::write( + repo_path.join("README.md"), + "# Stash Demo Project\n\nDemonstrating Git stash operations.\n", + )?; + repo.add(&["README.md"])?; + let initial_commit = repo.commit("Initial commit: Add README")?; + println!("Created initial commit: {}", initial_commit.short()); + + // Create some work to stash + println!("\n=== Creating Work to Stash ==="); + + // Create tracked file modifications + fs::write( + repo_path.join("README.md"), + "# Stash Demo Project\n\nDemonstrating Git stash operations.\n\nAdded some new content!\n", + )?; + + // Create new tracked files + fs::create_dir_all(repo_path.join("src"))?; + fs::write( + repo_path.join("src/main.rs"), + "fn main() {\n println!(\"Hello, stash!\");\n}\n", + )?; + + // Create untracked files + fs::write( + repo_path.join("untracked.txt"), + "This file is not tracked by git\n", + )?; + fs::write(repo_path.join("temp.log"), "Temporary log file\n")?; + + // Stage some changes + repo.add(&["src/main.rs"])?; + + println!("Created various types of changes:"); + println!(" - Modified tracked file (README.md)"); + println!(" - Added new file and staged it (src/main.rs)"); + println!(" - Created untracked files (untracked.txt, temp.log)"); + + // Check repository status before stashing + let status = repo.status()?; + println!("\nRepository status before stashing:"); + println!(" Staged files: {}", status.staged_files().count()); + println!(" Unstaged files: {}", status.unstaged_files().count()); + println!(" Untracked files: {}", status.untracked_entries().count()); + + // Demonstrate stash creation + println!("\n=== Creating Stashes ==="); + + // 1. Simple stash save + println!("\n1. Creating simple stash:"); + let simple_stash = repo.stash_save("WIP: working on main function")?; + println!("Created stash: {}", simple_stash); + println!(" Index: {}", simple_stash.index); + println!(" Branch: {}", simple_stash.branch); + println!(" Hash: {}", simple_stash.hash.short()); + + // Check status after stash + let status_after_stash = repo.status()?; + println!("\nStatus after simple stash:"); + println!( + " Staged files: {}", + status_after_stash.staged_files().count() + ); + println!( + " Unstaged files: {}", + status_after_stash.unstaged_files().count() + ); + println!( + " Untracked files: {}", + status_after_stash.untracked_entries().count() + ); + + // 2. Make more changes and create stash with untracked files + println!("\n2. Creating stash with untracked files:"); + + // Modify file again + fs::write( + repo_path.join("README.md"), + "# Stash Demo Project\n\nDemonstrating Git stash operations.\n\nSecond round of changes!\n", + )?; + + // Create more untracked files + fs::write(repo_path.join("config.json"), "{\"debug\": true}\n")?; + + let untracked_options = StashOptions::new().with_untracked().with_keep_index(); + let untracked_stash = repo.stash_push( + "WIP: config changes with untracked files", + untracked_options, + )?; + println!("Created stash with untracked files: {}", untracked_stash); + + // 3. Create stash with specific paths + println!("\n3. Creating stash with specific paths:"); + + // Make changes to multiple files and add them to git + fs::write(repo_path.join("file1.txt"), "Content for file 1\n")?; + fs::write(repo_path.join("file2.txt"), "Content for file 2\n")?; + fs::write(repo_path.join("file3.txt"), "Content for file 3\n")?; + + // Add all files so they're tracked + repo.add(&["file1.txt", "file2.txt", "file3.txt"])?; + + // Now modify them so there are changes to stash + fs::write(repo_path.join("file1.txt"), "Modified content for file 1\n")?; + fs::write(repo_path.join("file2.txt"), "Modified content for file 2\n")?; + fs::write(repo_path.join("file3.txt"), "Modified content for file 3\n")?; + + let path_options = StashOptions::new().with_paths(vec!["file1.txt".into(), "file2.txt".into()]); + let path_stash = repo.stash_push("WIP: specific files only", path_options)?; + println!("Created stash with specific paths: {}", path_stash); + + // Demonstrate stash listing and filtering + println!("\n=== Stash Listing and Filtering ==="); + + let stashes = repo.stash_list()?; + println!("\nAll stashes ({} total):", stashes.len()); + for stash in stashes.iter() { + println!( + " [{}] {} -> {}", + stash.index, + stash.message, + stash.hash.short() + ); + println!( + " Branch: {} | Created: {}", + stash.branch, + stash.timestamp.format("%Y-%m-%d %H:%M:%S") + ); + } + + // Test filtering + println!("\nFiltering examples:"); + + // Find stashes containing specific text + let wip_stashes: Vec<_> = stashes.find_containing("WIP").collect(); + println!("Stashes containing 'WIP': {} found", wip_stashes.len()); + for stash in &wip_stashes { + println!(" - {}", stash.message); + } + + let config_stashes: Vec<_> = stashes.find_containing("config").collect(); + println!( + "Stashes containing 'config': {} found", + config_stashes.len() + ); + + // Get latest stash + if let Some(latest) = stashes.latest() { + println!("Latest stash: {}", latest.message); + } + + // Get specific stash by index + if let Some(second_stash) = stashes.get(1) { + println!("Second stash: {}", second_stash.message); + } + + // Demonstrate stash content viewing + println!("\n=== Viewing Stash Contents ==="); + + println!("\nShowing contents of latest stash:"); + let stash_contents = repo.stash_show(0)?; + println!("{}", stash_contents); + + // Demonstrate stash application + println!("\n=== Applying and Popping Stashes ==="); + + println!("\n1. Testing stash apply (keeps stash in list):"); + let stashes_before_apply = repo.stash_list()?; + println!("Stashes before apply: {}", stashes_before_apply.len()); + + // Apply the latest stash + repo.stash_apply(0, StashApplyOptions::new())?; + println!("Applied stash@{{0}}"); + + let stashes_after_apply = repo.stash_list()?; + println!("Stashes after apply: {}", stashes_after_apply.len()); + + // Check what was restored + let status_after_apply = repo.status()?; + println!("Status after apply:"); + println!( + " Staged files: {}", + status_after_apply.staged_files().count() + ); + println!( + " Unstaged files: {}", + status_after_apply.unstaged_files().count() + ); + println!( + " Untracked files: {}", + status_after_apply.untracked_entries().count() + ); + + println!("\n2. Testing stash pop (removes stash from list):"); + + // First, stash current changes again to have something to pop + repo.stash_save("Temporary stash for pop test")?; + + let stashes_before_pop = repo.stash_list()?; + println!("Stashes before pop: {}", stashes_before_pop.len()); + + // Pop the latest stash + repo.stash_pop(0, StashApplyOptions::new().with_quiet())?; + println!("Popped stash@{{0}}"); + + let stashes_after_pop = repo.stash_list()?; + println!("Stashes after pop: {}", stashes_after_pop.len()); + + // Demonstrate advanced apply options + println!("\n3. Testing apply with index restoration:"); + + // Create a stash with staged changes + fs::write(repo_path.join("staged_file.txt"), "This will be staged\n")?; + repo.add(&["staged_file.txt"])?; + + fs::write( + repo_path.join("unstaged_file.txt"), + "This will be unstaged\n", + )?; + + repo.stash_save("Stash with staged and unstaged changes")?; + + // Apply with index restoration + let apply_options = StashApplyOptions::new().with_index(); + repo.stash_apply(0, apply_options)?; + println!("Applied stash with index restoration"); + + let final_status = repo.status()?; + println!("Final status after index restoration:"); + println!(" Staged files: {}", final_status.staged_files().count()); + println!( + " Unstaged files: {}", + final_status.unstaged_files().count() + ); + + // Demonstrate stash management + println!("\n=== Stash Management ==="); + + // Create a few test stashes + for i in 1..=3 { + fs::write( + repo_path.join(format!("test{}.txt", i)), + format!("Test content {}\n", i), + )?; + repo.stash_save(&format!("Test stash {}", i))?; + } + + let management_stashes = repo.stash_list()?; + println!( + "\nCreated {} test stashes for management demo", + management_stashes.len() + ); + + // Drop a specific stash + println!("\n1. Dropping middle stash:"); + println!("Before drop: {} stashes", management_stashes.len()); + + repo.stash_drop(1)?; // Drop second stash (index 1) + println!("Dropped stash@{{1}}"); + + let after_drop = repo.stash_list()?; + println!("After drop: {} stashes", after_drop.len()); + + // Show remaining stashes + println!("Remaining stashes:"); + for stash in after_drop.iter() { + println!(" [{}] {}", stash.index, stash.message); + } + + // Clear all stashes + println!("\n2. Clearing all stashes:"); + repo.stash_clear()?; + println!("Cleared all stashes"); + + let final_stashes = repo.stash_list()?; + println!("Stashes after clear: {}", final_stashes.len()); + + // Demonstrate error handling + println!("\n=== Error Handling ==="); + + println!("\n1. Testing operations on empty stash list:"); + + // Try to apply non-existent stash + match repo.stash_apply(0, StashApplyOptions::new()) { + Ok(_) => println!("ERROR: Should have failed to apply non-existent stash"), + Err(e) => println!("Expected error applying non-existent stash: {}", e), + } + + // Try to show non-existent stash + match repo.stash_show(0) { + Ok(_) => println!("ERROR: Should have failed to show non-existent stash"), + Err(e) => println!("Expected error showing non-existent stash: {}", e), + } + + // Try to drop non-existent stash + match repo.stash_drop(0) { + Ok(_) => println!("ERROR: Should have failed to drop non-existent stash"), + Err(e) => println!("Expected error dropping non-existent stash: {}", e), + } + + // Summary + println!("\n=== Summary ==="); + println!("\nStash operations demonstrated:"); + println!(" ✓ Basic stash save and push with options"); + println!(" ✓ Stash with untracked files and keep-index"); + println!(" ✓ Stash specific paths only"); + println!(" ✓ Comprehensive stash listing and filtering"); + println!(" ✓ Stash content viewing"); + println!(" ✓ Apply vs pop operations"); + println!(" ✓ Index restoration during apply"); + println!(" ✓ Stash dropping and clearing"); + println!(" ✓ Error handling for edge cases"); + + println!("\nStash options demonstrated:"); + println!(" ✓ with_untracked() - Include untracked files"); + println!(" ✓ with_keep_index() - Keep staged changes"); + println!(" ✓ with_paths() - Stash specific files only"); + println!(" ✓ with_index() - Restore staged state on apply"); + println!(" ✓ with_quiet() - Suppress output messages"); + + println!("\nStash filtering demonstrated:"); + println!(" ✓ find_containing() - Search by message content"); + println!(" ✓ latest() - Get most recent stash"); + println!(" ✓ get() - Get stash by index"); + println!(" ✓ for_branch() - Filter by branch name"); + + // Clean up + println!("\nCleaning up example repository..."); + fs::remove_dir_all(&repo_path)?; + println!("Stash operations example completed successfully!"); + + Ok(()) +} diff --git a/examples/tag_operations.rs b/examples/tag_operations.rs new file mode 100644 index 0000000..66e2fc2 --- /dev/null +++ b/examples/tag_operations.rs @@ -0,0 +1,299 @@ +//! Tag Operations Example +//! +//! This example demonstrates comprehensive tag management functionality in rustic-git: +//! - Creating lightweight and annotated tags +//! - Listing and filtering tags +//! - Deleting tags +//! - Tag options and configuration +//! - Working with tag metadata +//! +//! Run with: cargo run --example tag_operations + +use rustic_git::{Repository, Result, TagOptions, TagType}; +use std::env; +use std::fs; + +fn main() -> Result<()> { + println!("Rustic Git - Tag Operations Example\n"); + + // Use a temporary directory for this example + let repo_path = env::temp_dir().join("rustic_git_tag_example"); + + // Clean up any previous run + if repo_path.exists() { + fs::remove_dir_all(&repo_path).expect("Failed to clean up previous example"); + } + + println!("Initializing repository at: {}", repo_path.display()); + + // Initialize repository and configure user + let repo = Repository::init(&repo_path, false)?; + repo.config() + .set_user("Tag Demo User", "tags@example.com")?; + + // Create some commits to tag + println!("\nCreating initial commits..."); + + // First commit + fs::write( + repo_path.join("README.md"), + "# Tag Demo Project\n\nDemonstrating Git tag operations.\n", + )?; + repo.add(&["README.md"])?; + let first_commit_hash = repo.commit("Initial commit: Add README")?; + println!("Created commit: {}", first_commit_hash.short()); + + // Second commit + fs::create_dir_all(repo_path.join("src"))?; + fs::write( + repo_path.join("src/main.rs"), + "fn main() {\n println!(\"Hello, tags!\");\n}\n", + )?; + repo.add(&["src/main.rs"])?; + let second_commit_hash = repo.commit("Add main.rs with hello world")?; + println!("Created commit: {}", second_commit_hash.short()); + + // Third commit + fs::write( + repo_path.join("src/lib.rs"), + "//! Tag demo library\n\npub fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n", + )?; + repo.add(&["src/lib.rs"])?; + let third_commit_hash = repo.commit("Add library with greet function")?; + println!("Created commit: {}", third_commit_hash.short()); + + // Demonstrate tag creation + println!("\n=== Creating Tags ==="); + + // Create lightweight tags + println!("\n1. Creating lightweight tags:"); + + let v0_1_0 = repo.create_tag("v0.1.0", Some(&first_commit_hash))?; + println!( + "Created lightweight tag: {} -> {} ({})", + v0_1_0.name, + v0_1_0.hash.short(), + v0_1_0.tag_type + ); + + let v0_2_0 = repo.create_tag("v0.2.0", Some(&second_commit_hash))?; + println!( + "Created lightweight tag: {} -> {} ({})", + v0_2_0.name, + v0_2_0.hash.short(), + v0_2_0.tag_type + ); + + // Create annotated tags + println!("\n2. Creating annotated tags:"); + + let options = + TagOptions::new().with_message("First stable release with basic functionality".to_string()); + let v1_0_0 = repo.create_tag_with_options("v1.0.0", Some(&third_commit_hash), options)?; + println!( + "Created annotated tag: {} -> {} ({})", + v1_0_0.name, + v1_0_0.hash.short(), + v1_0_0.tag_type + ); + if let Some(message) = &v1_0_0.message { + println!(" Message: {}", message); + } + + // Tag current HEAD + let latest_options = TagOptions::new().with_message("Latest development version".to_string()); + let latest_tag = repo.create_tag_with_options("latest", None, latest_options)?; + println!( + "Created annotated tag on HEAD: {} -> {} ({})", + latest_tag.name, + latest_tag.hash.short(), + latest_tag.tag_type + ); + + // Create some feature tags + println!("\n3. Creating feature and release candidate tags:"); + + let feature_options = TagOptions::new().with_message("Feature branch snapshot".to_string()); + repo.create_tag_with_options("feature/demo", None, feature_options)?; + + let rc_options = TagOptions::new().with_message("Release candidate for v1.1.0".to_string()); + repo.create_tag_with_options("v1.1.0-rc1", None, rc_options)?; + + // Create a couple more version tags + repo.create_tag("v0.3.0", None)?; + repo.create_tag("v0.9.0", None)?; + + // Demonstrate tag listing and filtering + println!("\n=== Tag Listing and Filtering ==="); + + let tags = repo.tags()?; + println!("\nAll tags ({} total):", tags.len()); + for tag in tags.iter() { + let type_marker = match tag.tag_type { + TagType::Lightweight => "L", + TagType::Annotated => "A", + }; + println!(" [{}] {} -> {}", type_marker, tag.name, tag.hash.short()); + if let Some(message) = &tag.message { + println!(" Message: {}", message.lines().next().unwrap_or("")); + } + } + + // Filter by type + println!("\nLightweight tags ({} total):", tags.lightweight_count()); + for tag in tags.lightweight() { + println!(" {} -> {}", tag.name, tag.hash.short()); + } + + println!("\nAnnotated tags ({} total):", tags.annotated_count()); + for tag in tags.annotated() { + println!(" {} -> {}", tag.name, tag.hash.short()); + if let Some(message) = &tag.message { + println!(" Message: {}", message.lines().next().unwrap_or("")); + } + } + + // Search and filtering + println!("\n=== Tag Searching ==="); + + // Find specific tag + if let Some(tag) = tags.find("v1.0.0") { + println!("\nFound tag 'v1.0.0':"); + println!(" Type: {}", tag.tag_type); + println!(" Hash: {}", tag.hash.short()); + if let Some(message) = &tag.message { + println!(" Message: {}", message); + } + } + + // Find version tags + let version_tags: Vec<_> = tags.find_containing("v").collect(); + println!( + "\nVersion tags (containing 'v'): {} found", + version_tags.len() + ); + for tag in &version_tags { + println!(" {}", tag.name); + } + + // Find release candidates + let rc_tags: Vec<_> = tags.find_containing("rc").collect(); + println!("\nRelease candidate tags: {} found", rc_tags.len()); + for tag in &rc_tags { + println!(" {}", tag.name); + } + + // Find tags for specific commit + let tags_for_third_commit: Vec<_> = tags.for_commit(&third_commit_hash).collect(); + println!( + "\nTags pointing to commit {}: {} found", + third_commit_hash.short(), + tags_for_third_commit.len() + ); + for tag in &tags_for_third_commit { + println!(" {}", tag.name); + } + + // Demonstrate tag details + println!("\n=== Tag Details ==="); + + let detailed_tag = repo.show_tag("v1.0.0")?; + println!("\nDetailed information for 'v1.0.0':"); + println!(" Name: {}", detailed_tag.name); + println!(" Type: {}", detailed_tag.tag_type); + println!(" Commit: {}", detailed_tag.hash); + println!(" Short hash: {}", detailed_tag.hash.short()); + + if let Some(message) = &detailed_tag.message { + println!(" Message: {}", message); + } + + if let Some(tagger) = &detailed_tag.tagger { + println!(" Tagger: {}", tagger); + println!( + " Tagged at: {}", + tagger.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ); + } + + if let Some(timestamp) = &detailed_tag.timestamp { + println!(" Timestamp: {}", timestamp.format("%Y-%m-%d %H:%M:%S UTC")); + } + + // Demonstrate tag operations + println!("\n=== Tag Operations ==="); + + // Create and force overwrite a tag + println!("\n1. Testing tag overwrite:"); + + // This should fail (tag already exists) + match repo.create_tag("latest", None) { + Ok(_) => println!(" ERROR: Should have failed to create existing tag"), + Err(e) => println!(" Expected error creating existing tag: {}", e), + } + + // Force overwrite + let force_options = TagOptions::new() + .with_force() + .with_message("Forcefully updated latest tag".to_string()); + + match repo.create_tag_with_options("latest", None, force_options) { + Ok(tag) => println!(" Successfully force-created tag: {}", tag.name), + Err(e) => println!(" Error force-creating tag: {}", e), + } + + // Tag deletion + println!("\n2. Testing tag deletion:"); + + // Create a temporary tag to delete + repo.create_tag("temp-tag", None)?; + println!(" Created temporary tag: temp-tag"); + + // Verify it exists + let tags_before = repo.tags()?; + let temp_exists_before = tags_before.find("temp-tag").is_some(); + println!(" Temp tag exists before deletion: {}", temp_exists_before); + + // Delete it + repo.delete_tag("temp-tag")?; + println!(" Deleted temp-tag"); + + // Verify it's gone + let tags_after = repo.tags()?; + let temp_exists_after = tags_after.find("temp-tag").is_some(); + println!(" Temp tag exists after deletion: {}", temp_exists_after); + + // Summary + println!("\n=== Summary ==="); + let final_tags = repo.tags()?; + println!("\nFinal repository state:"); + println!(" Total tags: {}", final_tags.len()); + println!(" Lightweight tags: {}", final_tags.lightweight_count()); + println!(" Annotated tags: {}", final_tags.annotated_count()); + + println!("\nTag creation options demonstrated:"); + println!(" ✓ Lightweight tags (simple references)"); + println!(" ✓ Annotated tags (with messages and metadata)"); + println!(" ✓ Tags on specific commits"); + println!(" ✓ Tags on current HEAD"); + println!(" ✓ Force tag creation/overwrite"); + + println!("\nTag listing and filtering demonstrated:"); + println!(" ✓ List all tags"); + println!(" ✓ Filter by tag type (lightweight/annotated)"); + println!(" ✓ Search by name patterns"); + println!(" ✓ Find tags by commit hash"); + println!(" ✓ Show detailed tag information"); + + println!("\nTag management demonstrated:"); + println!(" ✓ Tag creation with options"); + println!(" ✓ Tag deletion"); + println!(" ✓ Error handling for duplicate tags"); + + // Clean up + println!("\nCleaning up example repository..."); + fs::remove_dir_all(&repo_path)?; + println!("Tag operations example completed successfully!"); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 04e5703..1b32da5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,7 +6,9 @@ pub mod diff; pub mod files; pub mod log; pub mod remote; +pub mod stash; pub mod status; +pub mod tag; pub use branch::{Branch, BranchList, BranchType}; pub use config::RepoConfig; @@ -16,4 +18,6 @@ pub use diff::{ pub use files::{MoveOptions, RemoveOptions, RestoreOptions}; pub use log::{Author, Commit, CommitDetails, CommitLog, CommitMessage, LogOptions}; pub use remote::{FetchOptions, PushOptions, Remote, RemoteList}; +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/stash.rs b/src/commands/stash.rs new file mode 100644 index 0000000..314b74b --- /dev/null +++ b/src/commands/stash.rs @@ -0,0 +1,795 @@ +//! Git stash operations +//! +//! This module provides functionality for stashing, listing, applying, and managing Git stashes. +//! It supports comprehensive stash management with type-safe operations. +//! +//! # Examples +//! +//! ```rust,no_run +//! use rustic_git::{Repository, StashOptions, StashApplyOptions}; +//! +//! let repo = Repository::open(".")?; +//! +//! // Save current changes to stash +//! let stash = repo.stash_save("Work in progress")?; +//! println!("Stashed: {}", stash.message); +//! +//! // List all stashes +//! let stashes = repo.stash_list()?; +//! for stash in stashes.iter() { +//! println!("{}: {}", stash.index, stash.message); +//! } +//! +//! // Apply most recent stash +//! if let Some(latest) = stashes.latest() { +//! repo.stash_apply(latest.index, StashApplyOptions::new())?; +//! } +//! +//! # Ok::<(), rustic_git::GitError>(()) +//! ``` + +use crate::error::{GitError, Result}; +use crate::repository::Repository; +use crate::types::Hash; +use crate::utils::git; +use chrono::{DateTime, Utc}; +use std::fmt; +use std::path::PathBuf; + +/// Represents a Git stash entry +#[derive(Debug, Clone, PartialEq)] +pub struct Stash { + /// The stash index (0 is most recent) + pub index: usize, + /// The stash message + pub message: String, + /// The commit hash of the stash + pub hash: Hash, + /// The branch name when stash was created + pub branch: String, + /// When the stash was created + pub timestamp: DateTime, +} + +impl fmt::Display for Stash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "stash@{{{}}}: {}", self.index, self.message) + } +} + +/// A collection of stashes with efficient iteration and filtering methods +#[derive(Debug, Clone)] +pub struct StashList { + stashes: Box<[Stash]>, +} + +impl StashList { + /// Create a new StashList from a vector of stashes + pub fn new(stashes: Vec) -> Self { + Self { + stashes: stashes.into_boxed_slice(), + } + } + + /// Get an iterator over all stashes + pub fn iter(&self) -> impl Iterator + '_ { + self.stashes.iter() + } + + /// Get the most recent stash (index 0) + pub fn latest(&self) -> Option<&Stash> { + self.stashes.first() + } + + /// Get stash by index + pub fn get(&self, index: usize) -> Option<&Stash> { + self.stashes.iter().find(|stash| stash.index == index) + } + + /// Find stashes whose messages contain the given substring + pub fn find_containing<'a>( + &'a self, + substring: &'a str, + ) -> impl Iterator + 'a { + self.stashes + .iter() + .filter(move |stash| stash.message.contains(substring)) + } + + /// Get stashes created on a specific branch + pub fn for_branch<'a>(&'a self, branch: &'a str) -> impl Iterator + 'a { + self.stashes + .iter() + .filter(move |stash| stash.branch == branch) + } + + /// Get the total number of stashes + pub fn len(&self) -> usize { + self.stashes.len() + } + + /// Check if the stash list is empty + pub fn is_empty(&self) -> bool { + self.stashes.is_empty() + } +} + +/// Options for creating stashes +#[derive(Debug, Clone, Default)] +pub struct StashOptions { + /// Include untracked files in the stash + pub include_untracked: bool, + /// Include ignored files in the stash + pub include_all: bool, + /// Keep staged changes in the index + pub keep_index: bool, + /// Create a patch-mode stash (interactive) + pub patch: bool, + /// Only stash staged changes + pub staged_only: bool, + /// Paths to specifically stash + pub paths: Vec, +} + +impl StashOptions { + /// Create new default stash options + pub fn new() -> Self { + Self::default() + } + + /// Include untracked files in the stash + pub fn with_untracked(mut self) -> Self { + self.include_untracked = true; + self + } + + /// Include all files (untracked and ignored) in the stash + pub fn with_all(mut self) -> Self { + self.include_all = true; + self.include_untracked = true; // --all implies --include-untracked + self + } + + /// Keep staged changes in the index after stashing + pub fn with_keep_index(mut self) -> Self { + self.keep_index = true; + self + } + + /// Create an interactive patch-mode stash + pub fn with_patch(mut self) -> Self { + self.patch = true; + self + } + + /// Only stash staged changes + pub fn with_staged_only(mut self) -> Self { + self.staged_only = true; + self + } + + /// Specify paths to stash + pub fn with_paths(mut self, paths: Vec) -> Self { + self.paths = paths; + self + } +} + +/// Options for applying stashes +#[derive(Debug, Clone, Default)] +pub struct StashApplyOptions { + /// Restore staged changes to the index + pub restore_index: bool, + /// Suppress output messages + pub quiet: bool, +} + +impl StashApplyOptions { + /// Create new default apply options + pub fn new() -> Self { + Self::default() + } + + /// Restore staged changes to the index when applying + pub fn with_index(mut self) -> Self { + self.restore_index = true; + self + } + + /// Suppress output messages + pub fn with_quiet(mut self) -> Self { + self.quiet = true; + self + } +} + +impl Repository { + /// List all stashes in the repository + /// + /// Returns a `StashList` containing all stashes sorted by recency (most recent first). + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// let stashes = repo.stash_list()?; + /// + /// println!("Found {} stashes:", stashes.len()); + /// for stash in stashes.iter() { + /// println!(" {}: {}", stash.index, stash.message); + /// } + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_list(&self) -> Result { + Self::ensure_git()?; + + let output = git( + &["stash", "list", "--format=%gd %H %gs"], + Some(self.repo_path()), + )?; + + if output.trim().is_empty() { + return Ok(StashList::new(vec![])); + } + + let mut stashes = Vec::new(); + + for (index, line) in output.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Ok(stash) = parse_stash_line(index, line) { + stashes.push(stash); + } + } + + Ok(StashList::new(stashes)) + } + + /// Save current changes to a new stash with a message + /// + /// This is equivalent to `git stash push -m "message"`. + /// + /// # Arguments + /// + /// * `message` - A descriptive message for the stash + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// let stash = repo.stash_save("Work in progress on feature X")?; + /// println!("Created stash: {}", stash.message); + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_save(&self, message: &str) -> Result { + let options = StashOptions::new(); + self.stash_push(message, options) + } + + /// Create a stash with advanced options + /// + /// # Arguments + /// + /// * `message` - A descriptive message for the stash + /// * `options` - Stash creation options + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::{Repository, StashOptions}; + /// + /// let repo = Repository::open(".")?; + /// + /// // Stash including untracked files + /// let options = StashOptions::new() + /// .with_untracked() + /// .with_keep_index(); + /// let stash = repo.stash_push("WIP with untracked files", options)?; + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_push(&self, message: &str, options: StashOptions) -> Result { + Self::ensure_git()?; + + let mut args = vec!["stash", "push"]; + + if options.include_all { + args.push("--all"); + } else if options.include_untracked { + args.push("--include-untracked"); + } + + if options.keep_index { + args.push("--keep-index"); + } + + if options.patch { + args.push("--patch"); + } + + if options.staged_only { + args.push("--staged"); + } + + args.extend(&["-m", message]); + + // Add paths if specified + if !options.paths.is_empty() { + args.push("--"); + for path in &options.paths { + if let Some(path_str) = path.to_str() { + args.push(path_str); + } + } + } + + git(&args, Some(self.repo_path()))?; + + // Get the newly created stash (it will be at index 0) + let stashes = self.stash_list()?; + stashes.latest().cloned().ok_or_else(|| { + GitError::CommandFailed( + "Failed to create stash or retrieve stash information".to_string(), + ) + }) + } + + /// Apply a stash without removing it from the stash list + /// + /// # Arguments + /// + /// * `index` - The stash index to apply (0 is most recent) + /// * `options` - Apply options + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::{Repository, StashApplyOptions}; + /// + /// let repo = Repository::open(".")?; + /// let options = StashApplyOptions::new().with_index(); + /// repo.stash_apply(0, options)?; // Apply most recent stash + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_apply(&self, index: usize, options: StashApplyOptions) -> Result<()> { + Self::ensure_git()?; + + let mut args = vec!["stash", "apply"]; + + if options.restore_index { + args.push("--index"); + } + + if options.quiet { + args.push("--quiet"); + } + + let stash_ref = format!("stash@{{{}}}", index); + args.push(&stash_ref); + + git(&args, Some(self.repo_path()))?; + Ok(()) + } + + /// Apply a stash and remove it from the stash list + /// + /// # Arguments + /// + /// * `index` - The stash index to pop (0 is most recent) + /// * `options` - Apply options + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::{Repository, StashApplyOptions}; + /// + /// let repo = Repository::open(".")?; + /// repo.stash_pop(0, StashApplyOptions::new())?; // Pop most recent stash + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_pop(&self, index: usize, options: StashApplyOptions) -> Result<()> { + Self::ensure_git()?; + + let mut args = vec!["stash", "pop"]; + + if options.restore_index { + args.push("--index"); + } + + if options.quiet { + args.push("--quiet"); + } + + let stash_ref = format!("stash@{{{}}}", index); + args.push(&stash_ref); + + git(&args, Some(self.repo_path()))?; + Ok(()) + } + + /// Show the contents of a stash + /// + /// # Arguments + /// + /// * `index` - The stash index to show (0 is most recent) + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// let stash_info = repo.stash_show(0)?; + /// println!("Stash contents:\n{}", stash_info); + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_show(&self, index: usize) -> Result { + Self::ensure_git()?; + + let output = git( + &["stash", "show", &format!("stash@{{{}}}", index)], + Some(self.repo_path()), + )?; + + Ok(output) + } + + /// Delete a specific stash + /// + /// # Arguments + /// + /// * `index` - The stash index to delete + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// repo.stash_drop(1)?; // Delete second most recent stash + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_drop(&self, index: usize) -> Result<()> { + Self::ensure_git()?; + + git( + &["stash", "drop", &format!("stash@{{{}}}", index)], + Some(self.repo_path()), + )?; + + Ok(()) + } + + /// Clear all stashes + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// repo.stash_clear()?; // Remove all stashes + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn stash_clear(&self) -> Result<()> { + Self::ensure_git()?; + + git(&["stash", "clear"], Some(self.repo_path()))?; + Ok(()) + } +} + +/// Parse a stash list line into a Stash struct +fn parse_stash_line(index: usize, line: &str) -> Result { + // Format: "stash@{0} hash On branch: message" + let parts: Vec<&str> = line.splitn(4, ' ').collect(); + + if parts.len() < 4 { + return Err(GitError::CommandFailed( + "Invalid stash list format".to_string(), + )); + } + + let hash = Hash::from(parts[1]); + + // Extract branch name and message from parts[3] (should be "On branch: message") + let remainder = parts[3]; + let (branch, message) = if let Some(colon_pos) = remainder.find(':') { + let branch_part = &remainder[..colon_pos]; + let message_part = &remainder[colon_pos + 1..].trim(); + + // Extract branch name from "On branch_name" or "WIP on branch_name" + let branch = if let Some(stripped) = branch_part.strip_prefix("On ") { + stripped.to_string() + } else if let Some(stripped) = branch_part.strip_prefix("WIP on ") { + stripped.to_string() + } else { + "unknown".to_string() + }; + + (branch, message_part.to_string()) + } else { + ("unknown".to_string(), remainder.to_string()) + }; + + Ok(Stash { + index, + message, + hash, + branch, + timestamp: Utc::now(), // Simplified for now + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + fn create_test_repo() -> (Repository, std::path::PathBuf) { + use std::thread; + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let thread_id = format!("{:?}", thread::current().id()); + let test_path = env::temp_dir().join(format!( + "rustic_git_stash_test_{}_{}_{}", + std::process::id(), + timestamp, + thread_id.replace("ThreadId(", "").replace(")", "") + )); + + // Ensure clean state + if test_path.exists() { + fs::remove_dir_all(&test_path).unwrap(); + } + + let repo = Repository::init(&test_path, false).unwrap(); + + // Configure git user for commits + repo.config() + .set_user("Test User", "test@example.com") + .unwrap(); + + (repo, test_path) + } + + fn create_test_commit( + repo: &Repository, + test_path: &std::path::Path, + filename: &str, + content: &str, + ) { + fs::write(test_path.join(filename), content).unwrap(); + repo.add(&[filename]).unwrap(); + repo.commit(&format!("Add {}", filename)).unwrap(); + } + + #[test] + fn test_stash_list_empty_repository() { + let (repo, test_path) = create_test_repo(); + + let stashes = repo.stash_list().unwrap(); + assert!(stashes.is_empty()); + assert_eq!(stashes.len(), 0); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_save_and_list() { + let (repo, test_path) = create_test_repo(); + + // Create initial commit + create_test_commit(&repo, &test_path, "initial.txt", "initial content"); + + // Make some changes (modify existing tracked file) + fs::write(test_path.join("initial.txt"), "modified content").unwrap(); + + // Stash the changes + let stash = repo.stash_save("Test stash message").unwrap(); + assert_eq!(stash.message, "Test stash message"); + assert_eq!(stash.index, 0); + + // Verify stash exists in list + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 1); + assert!(stashes.latest().is_some()); + assert_eq!(stashes.latest().unwrap().message, "Test stash message"); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_push_with_options() { + let (repo, test_path) = create_test_repo(); + + // Create initial commit + create_test_commit(&repo, &test_path, "initial.txt", "initial content"); + + // Make some changes + fs::write(test_path.join("initial.txt"), "modified initial").unwrap(); // Modify tracked file + fs::write(test_path.join("tracked.txt"), "tracked content").unwrap(); + fs::write(test_path.join("untracked.txt"), "untracked content").unwrap(); + + // Stage the files + repo.add(&["tracked.txt"]).unwrap(); + + // Stash with options + let options = StashOptions::new().with_untracked().with_keep_index(); + let stash = repo.stash_push("Stash with options", options).unwrap(); + + assert_eq!(stash.message, "Stash with options"); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_apply_and_pop() { + let (repo, test_path) = create_test_repo(); + + // Create initial commit + create_test_commit(&repo, &test_path, "initial.txt", "initial content"); + + // Make and stash changes (modify existing tracked file) + fs::write(test_path.join("initial.txt"), "modified content").unwrap(); + repo.stash_save("Test stash").unwrap(); + + // Verify file content is reverted after stash + let content = fs::read_to_string(test_path.join("initial.txt")).unwrap(); + assert_eq!(content, "initial content"); + + // Apply stash + repo.stash_apply(0, StashApplyOptions::new()).unwrap(); + + // Verify file content is back to modified + let content = fs::read_to_string(test_path.join("initial.txt")).unwrap(); + assert_eq!(content, "modified content"); + + // Stash should still exist + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 1); + + // Reset working tree and pop + fs::write(test_path.join("initial.txt"), "initial content").unwrap(); // Reset to original + repo.stash_pop(0, StashApplyOptions::new()).unwrap(); + + // File content should be modified again and stash should be gone + let content = fs::read_to_string(test_path.join("initial.txt")).unwrap(); + assert_eq!(content, "modified content"); + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 0); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_drop_and_clear() { + let (repo, test_path) = create_test_repo(); + + // Create initial commit + create_test_commit(&repo, &test_path, "initial.txt", "initial content"); + + // Create multiple stashes by modifying the tracked file + for i in 1..=3 { + fs::write(test_path.join("initial.txt"), format!("content {}", i)).unwrap(); + repo.stash_save(&format!("Stash {}", i)).unwrap(); + } + + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 3); + + // Drop middle stash + repo.stash_drop(1).unwrap(); + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 2); + + // Clear all stashes + repo.stash_clear().unwrap(); + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 0); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_show() { + let (repo, test_path) = create_test_repo(); + + // Create initial commit + create_test_commit(&repo, &test_path, "initial.txt", "initial content"); + + // Make changes and stash (modify existing tracked file) + fs::write(test_path.join("initial.txt"), "modified content").unwrap(); + repo.stash_save("Test stash").unwrap(); + + // Show stash contents + let show_output = repo.stash_show(0).unwrap(); + assert!(!show_output.is_empty()); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_list_filtering() { + let (repo, test_path) = create_test_repo(); + + // Create initial commit + create_test_commit(&repo, &test_path, "initial.txt", "initial content"); + + // Create stashes with different messages (modify existing tracked file) + fs::write(test_path.join("initial.txt"), "content1").unwrap(); + repo.stash_save("feature work in progress").unwrap(); + + fs::write(test_path.join("initial.txt"), "content2").unwrap(); + repo.stash_save("bugfix temporary save").unwrap(); + + fs::write(test_path.join("initial.txt"), "content3").unwrap(); + repo.stash_save("feature enhancement").unwrap(); + + let stashes = repo.stash_list().unwrap(); + assert_eq!(stashes.len(), 3); + + // Test filtering + let feature_stashes: Vec<_> = stashes.find_containing("feature").collect(); + assert_eq!(feature_stashes.len(), 2); + + let bugfix_stashes: Vec<_> = stashes.find_containing("bugfix").collect(); + assert_eq!(bugfix_stashes.len(), 1); + + // Test get by index + assert!(stashes.get(0).is_some()); + assert!(stashes.get(10).is_none()); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_stash_options_builder() { + let options = StashOptions::new() + .with_untracked() + .with_keep_index() + .with_paths(vec!["file1.txt".into(), "file2.txt".into()]); + + assert!(options.include_untracked); + assert!(options.keep_index); + assert_eq!(options.paths.len(), 2); + + let apply_options = StashApplyOptions::new().with_index().with_quiet(); + + assert!(apply_options.restore_index); + assert!(apply_options.quiet); + } + + #[test] + fn test_stash_display() { + let stash = Stash { + index: 0, + message: "Test stash message".to_string(), + hash: Hash::from("abc123"), + branch: "main".to_string(), + timestamp: Utc::now(), + }; + + let display_str = format!("{}", stash); + assert!(display_str.contains("stash@{0}")); + assert!(display_str.contains("Test stash message")); + } +} diff --git a/src/commands/tag.rs b/src/commands/tag.rs new file mode 100644 index 0000000..e1cac94 --- /dev/null +++ b/src/commands/tag.rs @@ -0,0 +1,695 @@ +//! Git tag operations +//! +//! This module provides functionality for creating, listing, deleting, and managing Git tags. +//! It supports both lightweight and annotated tags with comprehensive type safety. +//! +//! # Examples +//! +//! ```rust,no_run +//! use rustic_git::{Repository, TagType, TagOptions}; +//! +//! let repo = Repository::open(".")?; +//! +//! // List all tags +//! let tags = repo.tags()?; +//! for tag in tags.iter() { +//! println!("{} -> {}", tag.name, tag.hash.short()); +//! } +//! +//! // Create a lightweight tag +//! let tag = repo.create_tag("v1.0.0", None)?; +//! +//! // Create an annotated tag +//! let options = TagOptions::new() +//! .with_message("Release version 1.0.0".to_string()) +//! .with_annotated(); +//! let tag = repo.create_tag_with_options("v1.0.0-rc1", None, options)?; +//! +//! # Ok::<(), rustic_git::GitError>(()) +//! ``` + +use crate::error::{GitError, Result}; +use crate::repository::Repository; +use crate::types::Hash; +use crate::utils::git; +use chrono::{DateTime, Utc}; +use std::fmt; + +/// Represents a Git tag +#[derive(Debug, Clone, PartialEq)] +pub struct Tag { + /// The name of the tag + pub name: String, + /// The commit hash this tag points to + pub hash: Hash, + /// The type of tag (lightweight or annotated) + pub tag_type: TagType, + /// The tag message (only for annotated tags) + pub message: Option, + /// The tagger information (only for annotated tags) + pub tagger: Option, + /// The tag creation timestamp (only for annotated tags) + pub timestamp: Option>, +} + +/// Type of Git tag +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TagType { + /// Lightweight tag - just a reference to a commit + Lightweight, + /// Annotated tag - full object with message, author, and date + Annotated, +} + +impl fmt::Display for TagType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TagType::Lightweight => write!(f, "lightweight"), + TagType::Annotated => write!(f, "annotated"), + } + } +} + +/// Author information for annotated tags +#[derive(Debug, Clone, PartialEq)] +pub struct Author { + /// Author name + pub name: String, + /// Author email + pub email: String, + /// Author timestamp + pub timestamp: DateTime, +} + +impl fmt::Display for Author { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} <{}>", self.name, self.email) + } +} + +/// A collection of tags with efficient iteration and filtering methods +#[derive(Debug, Clone)] +pub struct TagList { + tags: Box<[Tag]>, +} + +impl TagList { + /// Create a new TagList from a vector of tags + pub fn new(mut tags: Vec) -> Self { + // Sort tags by name for consistent ordering + tags.sort_by(|a, b| a.name.cmp(&b.name)); + Self { + tags: tags.into_boxed_slice(), + } + } + + /// Get an iterator over all tags + pub fn iter(&self) -> impl Iterator + '_ { + self.tags.iter() + } + + /// Get an iterator over lightweight tags only + pub fn lightweight(&self) -> impl Iterator + '_ { + self.tags + .iter() + .filter(|tag| tag.tag_type == TagType::Lightweight) + } + + /// Get an iterator over annotated tags only + pub fn annotated(&self) -> impl Iterator + '_ { + self.tags + .iter() + .filter(|tag| tag.tag_type == TagType::Annotated) + } + + /// Find a tag by exact name + pub fn find(&self, name: &str) -> Option<&Tag> { + self.tags.iter().find(|tag| tag.name == name) + } + + /// Find tags whose names contain the given substring + pub fn find_containing<'a>(&'a self, substring: &'a str) -> impl Iterator + 'a { + self.tags + .iter() + .filter(move |tag| tag.name.contains(substring)) + } + + /// Get the total number of tags + pub fn len(&self) -> usize { + self.tags.len() + } + + /// Check if the tag list is empty + pub fn is_empty(&self) -> bool { + self.tags.is_empty() + } + + /// Get the number of lightweight tags + pub fn lightweight_count(&self) -> usize { + self.lightweight().count() + } + + /// Get the number of annotated tags + pub fn annotated_count(&self) -> usize { + self.annotated().count() + } + + /// Get tags that point to a specific commit + pub fn for_commit<'a>(&'a self, hash: &'a Hash) -> impl Iterator + 'a { + self.tags.iter().filter(move |tag| &tag.hash == hash) + } +} + +/// Options for creating tags +#[derive(Debug, Clone, Default)] +pub struct TagOptions { + /// Create an annotated tag (default: false - lightweight) + pub annotated: bool, + /// Force tag creation (overwrite existing tag) + pub force: bool, + /// Tag message (for annotated tags) + pub message: Option, + /// Sign the tag with GPG (requires annotated) + pub sign: bool, +} + +impl TagOptions { + /// Create new default tag options + pub fn new() -> Self { + Self::default() + } + + /// Create an annotated tag instead of lightweight + pub fn with_annotated(mut self) -> Self { + self.annotated = true; + self + } + + /// Force tag creation (overwrite existing) + pub fn with_force(mut self) -> Self { + self.force = true; + self + } + + /// Set the tag message (implies annotated) + pub fn with_message(mut self, message: String) -> Self { + self.message = Some(message); + self.annotated = true; // Message implies annotated tag + self + } + + /// Sign the tag with GPG (implies annotated) + pub fn with_sign(mut self) -> Self { + self.sign = true; + self.annotated = true; // Signing implies annotated tag + self + } +} + +impl Repository { + /// List all tags in the repository + /// + /// Returns a `TagList` containing all tags sorted by name. + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// let tags = repo.tags()?; + /// + /// println!("Found {} tags:", tags.len()); + /// for tag in tags.iter() { + /// println!(" {} ({}) -> {}", tag.name, tag.tag_type, tag.hash.short()); + /// } + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn tags(&self) -> Result { + Self::ensure_git()?; + + // Get list of tag names + let output = git(&["tag", "-l"], Some(self.repo_path()))?; + + if output.trim().is_empty() { + return Ok(TagList::new(vec![])); + } + + let mut tags = Vec::new(); + + for tag_name in output.lines() { + let tag_name = tag_name.trim(); + if tag_name.is_empty() { + continue; + } + + // Get tag information + let show_output = git( + &["show", "--format=fuller", tag_name], + Some(self.repo_path()), + )?; + + // Parse tag information + if let Ok(tag) = parse_tag_info(tag_name, &show_output) { + tags.push(tag); + } + } + + Ok(TagList::new(tags)) + } + + /// Create a lightweight tag pointing to the current HEAD or specified commit + /// + /// # Arguments + /// + /// * `name` - The name of the tag to create + /// * `target` - Optional commit hash to tag (defaults to HEAD) + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// + /// // Tag current HEAD + /// let tag = repo.create_tag("v1.0.0", None)?; + /// + /// // Tag specific commit + /// let commits = repo.recent_commits(1)?; + /// if let Some(commit) = commits.iter().next() { + /// let tag = repo.create_tag("v0.9.0", Some(&commit.hash))?; + /// } + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn create_tag(&self, name: &str, target: Option<&Hash>) -> Result { + self.create_tag_with_options(name, target, TagOptions::new()) + } + + /// Create a tag with custom options + /// + /// # Arguments + /// + /// * `name` - The name of the tag to create + /// * `target` - Optional commit hash to tag (defaults to HEAD) + /// * `options` - Tag creation options + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::{Repository, TagOptions}; + /// + /// let repo = Repository::open(".")?; + /// + /// // Create annotated tag with message + /// let options = TagOptions::new() + /// .with_message("Release version 1.0.0".to_string()); + /// let tag = repo.create_tag_with_options("v1.0.0", None, options)?; + /// + /// // Create and force overwrite existing tag + /// let options = TagOptions::new().with_force(); + /// let tag = repo.create_tag_with_options("latest", None, options)?; + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn create_tag_with_options( + &self, + name: &str, + target: Option<&Hash>, + options: TagOptions, + ) -> Result { + Self::ensure_git()?; + + let mut args = vec!["tag"]; + + if options.annotated || options.message.is_some() { + args.push("-a"); + } + + if options.force { + args.push("-f"); + } + + if options.sign { + args.push("-s"); + } + + if let Some(ref message) = options.message { + args.push("-m"); + args.push(message); + } + + args.push(name); + + if let Some(target_hash) = target { + args.push(target_hash.as_str()); + } + + git(&args, Some(self.repo_path()))?; + + // Get the created tag information + let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?; + parse_tag_info(name, &show_output) + } + + /// Delete a tag + /// + /// # Arguments + /// + /// * `name` - The name of the tag to delete + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// repo.delete_tag("v0.1.0")?; + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn delete_tag(&self, name: &str) -> Result<()> { + Self::ensure_git()?; + + git(&["tag", "-d", name], Some(self.repo_path()))?; + Ok(()) + } + + /// Show detailed information about a specific tag + /// + /// # Arguments + /// + /// * `name` - The name of the tag to show + /// + /// # Example + /// + /// ```rust,no_run + /// use rustic_git::Repository; + /// + /// let repo = Repository::open(".")?; + /// let tag = repo.show_tag("v1.0.0")?; + /// + /// println!("Tag: {} ({})", tag.name, tag.tag_type); + /// println!("Commit: {}", tag.hash.short()); + /// if let Some(message) = &tag.message { + /// println!("Message: {}", message); + /// } + /// # Ok::<(), rustic_git::GitError>(()) + /// ``` + pub fn show_tag(&self, name: &str) -> Result { + Self::ensure_git()?; + + let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?; + parse_tag_info(name, &show_output) + } +} + +/// Parse tag information from git show output +fn parse_tag_info(tag_name: &str, show_output: &str) -> Result { + let lines: Vec<&str> = show_output.lines().collect(); + + // Determine if this is an annotated tag or lightweight tag + let is_annotated = show_output.contains("tag ") && show_output.contains("Tagger:"); + + if is_annotated { + parse_annotated_tag(tag_name, &lines) + } else { + parse_lightweight_tag(tag_name, &lines) + } +} + +/// Parse annotated tag information +fn parse_annotated_tag(tag_name: &str, lines: &[&str]) -> Result { + let mut hash = None; + let mut tagger = None; + let mut collecting_message = false; + let mut message_lines = Vec::new(); + + for line in lines { + if line.starts_with("commit ") { + if let Some(hash_str) = line.split_whitespace().nth(1) { + hash = Some(Hash::from(hash_str)); + } + } else if let Some(stripped) = line.strip_prefix("Tagger: ") { + tagger = parse_author_line(stripped); + } else if line.trim().is_empty() && !collecting_message { + collecting_message = true; + } else if collecting_message && !line.starts_with("commit ") && !line.starts_with("Author:") + { + message_lines.push(line.trim()); + } + } + + let message_text = if message_lines.is_empty() { + None + } else { + Some(message_lines.join("\n").trim().to_string()) + }; + + let timestamp = tagger.as_ref().map(|t| t.timestamp); + + Ok(Tag { + name: tag_name.to_string(), + hash: hash.ok_or_else(|| { + GitError::CommandFailed("Could not parse tag commit hash".to_string()) + })?, + tag_type: TagType::Annotated, + message: message_text, + tagger, + timestamp, + }) +} + +/// Parse lightweight tag information +fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result { + let mut hash = None; + + for line in lines { + if line.starts_with("commit ") + && let Some(hash_str) = line.split_whitespace().nth(1) + { + hash = Some(Hash::from(hash_str)); + break; + } + } + + Ok(Tag { + name: tag_name.to_string(), + hash: hash.ok_or_else(|| { + GitError::CommandFailed("Could not parse tag commit hash".to_string()) + })?, + tag_type: TagType::Lightweight, + message: None, + tagger: None, + timestamp: None, + }) +} + +/// Parse author information from a git log line +fn parse_author_line(line: &str) -> Option { + // Parse format: "Name timestamp timezone" + if let Some(email_start) = line.find('<') + && let Some(email_end) = line.find('>') + { + let name = line[..email_start].trim().to_string(); + let email = line[email_start + 1..email_end].to_string(); + + // Parse timestamp (simplified - just use current time for now) + let timestamp = Utc::now(); + + return Some(Author { + name, + email, + timestamp, + }); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + fn create_test_repo() -> (Repository, std::path::PathBuf) { + use std::thread; + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let thread_id = format!("{:?}", thread::current().id()); + let test_path = env::temp_dir().join(format!( + "rustic_git_tag_test_{}_{}_{}", + std::process::id(), + timestamp, + thread_id.replace("ThreadId(", "").replace(")", "") + )); + + // Ensure clean state + if test_path.exists() { + fs::remove_dir_all(&test_path).unwrap(); + } + + let repo = Repository::init(&test_path, false).unwrap(); + + // Configure git user for commits + repo.config() + .set_user("Test User", "test@example.com") + .unwrap(); + + (repo, test_path) + } + + fn create_test_commit(repo: &Repository, test_path: &std::path::Path) { + fs::write(test_path.join("test.txt"), "test content").unwrap(); + repo.add(&["test.txt"]).unwrap(); + repo.commit("Test commit").unwrap(); + } + + #[test] + fn test_tag_list_empty_repository() { + let (repo, test_path) = create_test_repo(); + + let tags = repo.tags().unwrap(); + assert!(tags.is_empty()); + assert_eq!(tags.len(), 0); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_create_lightweight_tag() { + let (repo, test_path) = create_test_repo(); + create_test_commit(&repo, &test_path); + + let tag = repo.create_tag("v1.0.0", None).unwrap(); + assert_eq!(tag.name, "v1.0.0"); + assert_eq!(tag.tag_type, TagType::Lightweight); + assert!(tag.message.is_none()); + assert!(tag.tagger.is_none()); + + // Verify tag exists in list + let tags = repo.tags().unwrap(); + assert_eq!(tags.len(), 1); + assert!(tags.find("v1.0.0").is_some()); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_create_annotated_tag() { + let (repo, test_path) = create_test_repo(); + create_test_commit(&repo, &test_path); + + let options = TagOptions::new().with_message("Release version 1.0.0".to_string()); + let tag = repo + .create_tag_with_options("v1.0.0", None, options) + .unwrap(); + + assert_eq!(tag.name, "v1.0.0"); + assert_eq!(tag.tag_type, TagType::Annotated); + assert!(tag.message.is_some()); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_delete_tag() { + let (repo, test_path) = create_test_repo(); + create_test_commit(&repo, &test_path); + + // Create a tag + repo.create_tag("to-delete", None).unwrap(); + + // Verify it exists + let tags = repo.tags().unwrap(); + assert_eq!(tags.len(), 1); + + // Delete it + repo.delete_tag("to-delete").unwrap(); + + // Verify it's gone + let tags = repo.tags().unwrap(); + assert_eq!(tags.len(), 0); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_tag_list_filtering() { + let (repo, test_path) = create_test_repo(); + create_test_commit(&repo, &test_path); + + // Create multiple tags + repo.create_tag("v1.0.0", None).unwrap(); + repo.create_tag("v1.1.0", None).unwrap(); + let options = TagOptions::new().with_message("Annotated".to_string()); + repo.create_tag_with_options("v2.0.0", None, options) + .unwrap(); + + let tags = repo.tags().unwrap(); + assert_eq!(tags.len(), 3); + assert_eq!(tags.lightweight_count(), 2); + assert_eq!(tags.annotated_count(), 1); + + // Test filtering + let v1_tags: Vec<_> = tags.find_containing("v1").collect(); + assert_eq!(v1_tags.len(), 2); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_tag_options_builder() { + let options = TagOptions::new() + .with_annotated() + .with_force() + .with_message("Test message".to_string()); + + assert!(options.annotated); + assert!(options.force); + assert_eq!(options.message, Some("Test message".to_string())); + } + + #[test] + fn test_show_tag() { + let (repo, test_path) = create_test_repo(); + create_test_commit(&repo, &test_path); + + repo.create_tag("show-test", None).unwrap(); + let tag = repo.show_tag("show-test").unwrap(); + + assert_eq!(tag.name, "show-test"); + assert_eq!(tag.tag_type, TagType::Lightweight); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } + + #[test] + fn test_tag_force_overwrite() { + let (repo, test_path) = create_test_repo(); + create_test_commit(&repo, &test_path); + + // Create initial tag + repo.create_tag("overwrite-test", None).unwrap(); + + // Try to create again without force (should fail) + let result = repo.create_tag("overwrite-test", None); + assert!(result.is_err()); + + // Create with force (should succeed) + let options = TagOptions::new().with_force(); + let result = repo.create_tag_with_options("overwrite-test", None, options); + assert!(result.is_ok()); + + // Clean up + fs::remove_dir_all(&test_path).unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index e061aa6..3a5d986 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,8 @@ 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, WorktreeStatus, + PushOptions, Remote, RemoteList, RemoveOptions, RepoConfig, RestoreOptions, Stash, + StashApplyOptions, StashList, StashOptions, Tag, TagList, TagOptions, TagType, WorktreeStatus, }; pub use error::{GitError, Result}; pub use repository::Repository;