diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2563e6f..e5b8062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,12 @@ -name: Rust +name: CI on: push: - branches: [ "master" ] + branches: [ "master", "develop" ] paths-ignore: - '**.md' # Ignores all Markdown files pull_request: - branches: [ "master" ] + branches: [ "master", "develop" ] paths-ignore: - '**.md' # Ignores all Markdown files @@ -38,6 +38,11 @@ jobs: restore-keys: | ${{ runner.os }}-cargo- + - name: Configure git for tests + run: | + git config --global user.name "Test User" + git config --global user.email "test@example.com" + - name: Check formatting run: cargo fmt -- --check @@ -49,3 +54,12 @@ jobs: - name: Run tests run: cargo test --verbose + + - name: Test examples compile and run + run: | + echo "Testing examples..." + for example in examples/*.rs; do + example_name=$(basename "$example" .rs) + echo "Building example: $example_name" + cargo build --example "$example_name" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fdd65cb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release to crates.io + +on: + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt, clippy + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Verify release tag matches Cargo.toml version + run: | + # Extract version from Cargo.toml + CARGO_VERSION=$(grep '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/') + # Extract version from GitHub release tag (remove 'v' prefix if present) + RELEASE_VERSION=$(echo "${{ github.event.release.tag_name }}" | sed 's/^v//') + + echo "Cargo.toml version: $CARGO_VERSION" + echo "Release tag version: $RELEASE_VERSION" + + if [ "$CARGO_VERSION" != "$RELEASE_VERSION" ]; then + echo "Version mismatch! Cargo.toml version ($CARGO_VERSION) does not match release tag ($RELEASE_VERSION)" + exit 1 + fi + + echo "Version verification passed" + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy check + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test --all-features + + - name: Verify examples compile and run + run: | + echo "Testing examples..." + for example in examples/*.rs; do + example_name=$(basename "$example" .rs) + echo "Building example: $example_name" + cargo build --example "$example_name" + done + + # Test a few key examples to make sure they work + echo "Running basic_usage example..." + timeout 30s cargo run --example basic_usage || echo "Example completed or timed out" + + echo "Running repository_operations example..." + timeout 30s cargo run --example repository_operations || echo "Example completed or timed out" + + - name: Build release + run: cargo build --release --all-features + + - name: Verify package contents + run: | + cargo package --list + echo "Package contents verified" + + - name: Dry run publish + run: cargo publish --dry-run --all-features + + - name: Publish to crates.io + run: cargo publish --all-features + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + + - name: Create publish summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo " **Published to crates.io**: \`rustic-git v${{ github.event.release.tag_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Package Details" >> $GITHUB_STEP_SUMMARY + echo "- **Version**: ${{ github.event.release.tag_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Registry**: [crates.io](https://crates.io/crates/rustic-git)" >> $GITHUB_STEP_SUMMARY + echo "- **Documentation**: [docs.rs](https://docs.rs/rustic-git)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Quality Checks Passed" >> $GITHUB_STEP_SUMMARY + echo "- Code formatting (rustfmt)" >> $GITHUB_STEP_SUMMARY + echo "- Linting (clippy with zero warnings)" >> $GITHUB_STEP_SUMMARY + echo "- All tests passing" >> $GITHUB_STEP_SUMMARY + echo "- Examples compile and run" >> $GITHUB_STEP_SUMMARY + echo "- Version verification" >> $GITHUB_STEP_SUMMARY diff --git a/CLAUDE.md b/CLAUDE.md index 865bd83..b2d68cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,14 +18,42 @@ - **Command execution**: Use std::process::Command with proper error handling and stderr capture ## Implementation -- Available methods: Repository::init(path, bare), Repository::open(path), Repository::status(), Repository::add(paths), Repository::add_all(), Repository::add_update(), Repository::commit(message), Repository::commit_with_author(message, author) -- Status functionality: GitStatus with FileStatus enum, files as Box<[(FileStatus, String)]> -- Add functionality: Stage specific files, all changes, or tracked file updates -- Commit functionality: Create commits and return Hash of created commit -- Hash type: Universal git object hash representation with short() and Display methods -- Utility functions: git(args, working_dir) -> Result, git_raw(args, working_dir) -> Result -- Command modules: status.rs, add.rs, commit.rs (in src/commands/) -- Core types: Hash (in src/types.rs) +- **Repository lifecycle**: Repository::init(path, bare), Repository::open(path) +- **Status functionality**: Enhanced GitStatus API with separate staged/unstaged file tracking + - GitStatus with entries as Box<[FileEntry]> for immutable, efficient storage + - FileEntry contains PathBuf, IndexStatus, and WorktreeStatus for precise Git state representation + - IndexStatus enum: Clean, Modified, Added, Deleted, Renamed, Copied (with const from_char/to_char methods) + - WorktreeStatus enum: Clean, Modified, Deleted, Untracked, Ignored (with const from_char/to_char methods) + - API methods: staged_files(), unstaged_files(), untracked_entries(), files_with_index_status(), files_with_worktree_status() +- **Staging functionality**: Repository::add(paths), Repository::add_all(), Repository::add_update() +- **Commit functionality**: Repository::commit(message), Repository::commit_with_author(message, author) - return Hash of created commit +- **Branch functionality**: Complete branch operations with type-safe API + - Repository::branches() -> Result - list all branches with comprehensive filtering + - Repository::current_branch() -> Result> - get currently checked out branch + - Repository::create_branch(name, start_point) -> Result - create new branch + - Repository::delete_branch(branch, force) -> Result<()> - delete branch with safety checks + - Repository::checkout(branch) -> Result<()> - switch to existing branch + - Repository::checkout_new(name, start_point) -> Result - create and checkout branch + - Branch struct: name, branch_type, is_current, commit_hash, upstream tracking + - BranchType enum: Local, RemoteTracking + - BranchList: Box<[Branch]> with iterator methods (iter, local, remote), search (find, find_by_short_name), counting (len, local_count, remote_count) +- **Commit history & log operations**: Multi-level API for comprehensive commit analysis + - Repository::log() -> Result - get all commits with simple API + - Repository::recent_commits(count) -> Result - get recent N commits + - Repository::log_with_options(options) -> Result - advanced queries with filters + - Repository::log_range(from, to) -> Result - commits between two points + - Repository::log_for_paths(paths) -> Result - commits affecting specific paths + - Repository::show_commit(hash) -> Result - detailed commit information + - Commit struct: hash, author, committer, message, timestamp, parents + - CommitLog: Box<[Commit]> with iterator-based filtering (with_message_containing, since, until, merges_only, no_merges, find_by_hash) + - LogOptions builder: max_count, since/until dates, author/committer filters, grep, paths, merge filtering + - 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) +- **Utility functions**: git(args, working_dir) -> Result, git_raw(args, working_dir) -> Result +- **Command modules**: status.rs, add.rs, commit.rs, branch.rs, log.rs (in src/commands/) +- **Testing**: 101+ 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 @@ -34,9 +62,11 @@ The `examples/` directory contains comprehensive demonstrations of library funct - **basic_usage.rs**: Complete workflow from init to commit - demonstrates fundamental rustic-git usage - **repository_operations.rs**: Repository lifecycle - init regular/bare repos, open existing repos, error handling -- **status_checking.rs**: GitStatus and FileStatus usage - all status query methods and file state filtering +- **status_checking.rs**: Enhanced GitStatus API usage - staged/unstaged file queries, IndexStatus/WorktreeStatus filtering, comprehensive status analysis - **staging_operations.rs**: Staging operations - add(), add_all(), add_update() with before/after comparisons - **commit_workflows.rs**: Commit operations and Hash type - commit(), commit_with_author(), Hash methods +- **branch_operations.rs**: Complete branch management - create/delete/checkout branches, BranchList filtering, branch type handling, search operations +- **commit_history.rs**: Comprehensive commit history & log operations - demonstrates all commit querying APIs, filtering, analysis, and advanced LogOptions usage - **error_handling.rs**: Comprehensive error handling patterns - GitError variants, recovery strategies Run examples with: `cargo run --example ` diff --git a/Cargo.lock b/Cargo.lock index 694942e..d6c22b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,324 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "js-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rustic-git" -version = "0.1.0" +version = "0.2.0" +dependencies = [ + "chrono", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wasm-bindgen" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] diff --git a/Cargo.toml b/Cargo.toml index f48ed26..9cbb5c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,14 @@ [package] name = "rustic-git" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" description = "A Rustic Git - clean type-safe API over git cli" homepage = "https://github.com/eugener/rustic-git" repository = "https://github.com/eugener/rustic-git" readme = "README.md" +keywords = ["git", "vcs", "repository", "cli", "rust"] +categories = ["command-line-utilities", "development-tools"] [dependencies] +chrono = { version = "0.4", features = ["serde"] } diff --git a/README.md b/README.md index 4bb4086..b03462b 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,19 @@ Rustic Git provides a simple, ergonomic interface for common Git operations. It ## Features - ✅ Repository initialization and opening -- ✅ File status checking with detailed parsing +- ✅ **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 -- ✅ Type-safe error handling +- ✅ **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 +- ✅ Type-safe error handling with custom GitError enum - ✅ Universal `Hash` type for Git objects -- ✅ Comprehensive test coverage +- ✅ **Immutable collections** (Box<[T]>) for memory efficiency +- ✅ **Const enum conversions** with zero runtime cost +- ✅ Comprehensive test coverage (101+ tests) ## Installation @@ -22,13 +29,19 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -rustic-git = "0.1.0" +rustic-git = "*" +``` + +Or use `cargo add` to automatically add the latest version: + +```bash +cargo add rustic-git ``` ## Quick Start ```rust -use rustic_git::{Repository, Result}; +use rustic_git::{Repository, Result, IndexStatus, WorktreeStatus, LogOptions}; fn main() -> Result<()> { // Initialize a new repository @@ -37,11 +50,24 @@ fn main() -> Result<()> { // Or open an existing repository let repo = Repository::open("/path/to/existing/repo")?; - // Check repository status + // Check repository status with enhanced API let status = repo.status()?; if !status.is_clean() { - println!("Modified files: {:?}", status.modified_files()); - println!("Untracked files: {:?}", status.untracked_files()); + // Get files by staging state + let staged_count = status.staged_files().count(); + let unstaged_count = status.unstaged_files().count(); + let untracked_count = status.untracked_entries().count(); + + println!("Repository status:"); + println!(" Staged: {} files", staged_count); + println!(" Unstaged: {} files", unstaged_count); + println!(" Untracked: {} files", untracked_count); + + // Filter by specific status types + let modified_files: Vec<_> = status + .files_with_worktree_status(WorktreeStatus::Modified) + .collect(); + println!(" Modified files: {:?}", modified_files); } // Stage files @@ -53,6 +79,31 @@ fn main() -> Result<()> { let hash = repo.commit("Add new features")?; println!("Created commit: {}", hash.short()); + // Branch operations + let branches = repo.branches()?; + println!("Current branch: {:?}", repo.current_branch()?.map(|b| b.name)); + + // Create and switch to new branch + let feature_branch = repo.checkout_new("feature/new-api", None)?; + println!("Created and switched to: {}", feature_branch.name); + + // Commit history operations + let commits = repo.log()?; + println!("Total commits: {}", commits.len()); + + // Get recent commits + let recent = repo.recent_commits(5)?; + for commit in recent.iter() { + println!("{} - {}", commit.hash.short(), commit.message.subject); + } + + // Advanced commit queries + let opts = LogOptions::new() + .max_count(10) + .grep("fix".to_string()); + let bug_fixes = repo.log_with_options(&opts)?; + println!("Found {} bug fixes", bug_fixes.len()); + Ok(()) } ``` @@ -85,7 +136,7 @@ let repo = Repository::open("/path/to/existing/repo")?; #### `Repository::status() -> Result` -Get the current repository status. +Get the current repository status with enhanced staged/unstaged file tracking. ```rust let status = repo.status()?; @@ -97,38 +148,83 @@ if status.is_clean() { println!("Repository has changes"); } -// Get files by status -let modified = status.modified_files(); -let untracked = status.untracked_files(); - -// Or work with all files directly -for (file_status, filename) in &status.files { - println!("{:?}: {}", file_status, filename); +// Get files by staging state +let staged_files: Vec<_> = status.staged_files().collect(); +let unstaged_files: Vec<_> = status.unstaged_files().collect(); +let untracked_files: Vec<_> = status.untracked_entries().collect(); + +// Filter by specific status types +let modified_in_index: Vec<_> = status + .files_with_index_status(IndexStatus::Modified) + .collect(); +let modified_in_worktree: Vec<_> = status + .files_with_worktree_status(WorktreeStatus::Modified) + .collect(); + +// Work with all file entries directly +for entry in status.entries() { + println!("[{}][{}] {}", + entry.index_status.to_char(), + entry.worktree_status.to_char(), + entry.path.display() + ); } ``` The `GitStatus` struct contains: -- `files: Box<[(FileStatus, String)]>` - All files with their status +- `entries: Box<[FileEntry]>` - Immutable collection of file entries - `is_clean()` - Returns true if no changes - `has_changes()` - Returns true if any changes exist -- `modified_files()` - Get all modified files -- `untracked_files()` - Get all untracked files -- `files_with_status(status)` - Get files with specific status +- `staged_files()` - Iterator over files with index changes (staged) +- `unstaged_files()` - Iterator over files with worktree changes (unstaged) +- `untracked_entries()` - Iterator over untracked files +- `ignored_files()` - Iterator over ignored files +- `files_with_index_status(status)` - Filter by specific index status +- `files_with_worktree_status(status)` - Filter by specific worktree status #### File Status Types +The enhanced status API uses separate enums for index (staged) and worktree (unstaged) states: + ```rust -pub enum FileStatus { - Modified, // File has been modified - Added, // File has been added to index - Deleted, // File has been deleted - Renamed, // File has been renamed - Copied, // File has been copied - Untracked, // File is not tracked by git - Ignored, // File is ignored by git +// Index (staging area) status +pub enum IndexStatus { + Clean, // No changes in index + Modified, // File modified in index + Added, // File added to index + Deleted, // File deleted in index + Renamed, // File renamed in index + Copied, // File copied in index +} + +// Worktree (working directory) status +pub enum WorktreeStatus { + Clean, // No changes in worktree + Modified, // File modified in worktree + Deleted, // File deleted in worktree + Untracked, // File not tracked by git + Ignored, // File ignored by git +} + +// File entry combining both states +pub struct FileEntry { + pub path: PathBuf, + pub index_status: IndexStatus, + pub worktree_status: WorktreeStatus, } ``` +Both enums support const character conversion: +```rust +// Convert to/from git porcelain characters +let status = IndexStatus::from_char('M'); // IndexStatus::Modified +let char = status.to_char(); // 'M' + +// Display formatting +println!("{}", IndexStatus::Modified); // Prints: M +println!("{}", WorktreeStatus::Untracked); // Prints: ? +``` + ### Staging Operations #### `Repository::add(paths) -> Result<()>` @@ -186,6 +282,358 @@ let hash = repo.commit_with_author( )?; ``` +### Branch Operations + +#### `Repository::branches() -> Result` + +List all branches in the repository. + +```rust +let branches = repo.branches()?; + +// Check total count +println!("Total branches: {}", branches.len()); +println!("Local branches: {}", branches.local_count()); +println!("Remote branches: {}", branches.remote_count()); + +// Iterate over all branches +for branch in branches.iter() { + let marker = if branch.is_current { "*" } else { " " }; + println!(" {}{} ({})", marker, branch.name, branch.commit_hash.short()); +} + +// Filter by type +let local_branches: Vec<_> = branches.local().collect(); +let remote_branches: Vec<_> = branches.remote().collect(); +``` + +#### `Repository::current_branch() -> Result>` + +Get the currently checked out branch. + +```rust +if let Some(current) = repo.current_branch()? { + println!("On branch: {}", current.name); + println!("Last commit: {}", current.commit_hash.short()); + if let Some(upstream) = ¤t.upstream { + println!("Tracking: {}", upstream); + } +} +``` + +#### `Repository::create_branch(name, start_point) -> Result` + +Create a new branch. + +```rust +// Create branch from current HEAD +let branch = repo.create_branch("feature/new-api", None)?; + +// Create branch from specific commit/branch +let branch = repo.create_branch("hotfix/bug-123", Some("main"))?; +let branch = repo.create_branch("release/v1.0", Some("develop"))?; +``` + +#### `Repository::checkout(branch) -> Result<()>` + +Switch to an existing branch. + +```rust +let branches = repo.branches()?; +if let Some(branch) = branches.find("develop") { + repo.checkout(&branch)?; + println!("Switched to: {}", branch.name); +} +``` + +#### `Repository::checkout_new(name, start_point) -> Result` + +Create a new branch and switch to it immediately. + +```rust +// Create and checkout new branch from current HEAD +let branch = repo.checkout_new("feature/auth", None)?; + +// Create and checkout from specific starting point +let branch = repo.checkout_new("feature/api", Some("develop"))?; +println!("Created and switched to: {}", branch.name); +``` + +#### `Repository::delete_branch(branch, force) -> Result<()>` + +Delete a branch. + +```rust +let branches = repo.branches()?; +if let Some(branch) = branches.find("old-feature") { + // Safe delete (fails if unmerged) + repo.delete_branch(&branch, false)?; + + // Force delete + // repo.delete_branch(&branch, true)?; +} +``` + +#### Branch Types + +The branch API uses structured types for type safety: + +```rust +// Branch represents a single branch +pub struct Branch { + pub name: String, + pub branch_type: BranchType, + pub is_current: bool, + pub commit_hash: Hash, + pub upstream: Option, +} + +// Branch type enumeration +pub enum BranchType { + Local, // Local branch + RemoteTracking, // Remote-tracking branch +} + +// BranchList contains all branches with efficient methods +pub struct BranchList { + // Methods: + // - iter() -> iterator over all branches + // - local() -> iterator over local branches + // - remote() -> iterator over remote branches + // - current() -> get current branch + // - find(name) -> find branch by exact name + // - find_by_short_name(name) -> find by short name + // - len(), is_empty() -> collection info +} +``` + +#### Branch Search and Filtering + +```rust +let branches = repo.branches()?; + +// Find specific branches +if let Some(main) = branches.find("main") { + println!("Found main branch: {}", main.commit_hash.short()); +} + +// Find by short name (useful for remote branches) +if let Some(feature) = branches.find_by_short_name("feature") { + println!("Found feature branch: {}", feature.name); +} + +// Filter by type +println!("Local branches:"); +for branch in branches.local() { + println!(" - {}", branch.name); +} + +if branches.remote_count() > 0 { + println!("Remote branches:"); + for branch in branches.remote() { + println!(" - {}", branch.name); + } +} + +// Get current branch +if let Some(current) = branches.current() { + println!("Currently on: {}", current.name); +} +``` + +### Commit History Operations + +#### `Repository::log() -> Result` + +Get all commits in the repository. + +```rust +let commits = repo.log()?; +println!("Total commits: {}", commits.len()); + +for commit in commits.iter() { + println!("{} - {} by {} at {}", + commit.hash.short(), + commit.message.subject, + commit.author.name, + commit.timestamp.format("%Y-%m-%d %H:%M:%S") + ); +} +``` + +#### `Repository::recent_commits(count) -> Result` + +Get the most recent N commits. + +```rust +let recent = repo.recent_commits(10)?; +for commit in recent.iter() { + println!("{} - {}", commit.hash.short(), commit.message.subject); + if let Some(body) = &commit.message.body { + println!(" {}", body); + } +} +``` + +#### `Repository::log_with_options(options) -> Result` + +Advanced commit queries with filtering options. + +```rust +use chrono::{Utc, Duration}; + +// Search commits with message containing "fix" +let bug_fixes = repo.log_with_options(&LogOptions::new() + .max_count(20) + .grep("fix".to_string()))?; + +// Get commits by specific author +let author_commits = repo.log_with_options(&LogOptions::new() + .author("jane@example.com".to_string()))?; + +// Get commits from date range +let since = Utc::now() - Duration::days(30); +let recent_commits = repo.log_with_options(&LogOptions::new() + .since(since) + .no_merges(true))?; + +// Get commits affecting specific paths +let file_commits = repo.log_with_options(&LogOptions::new() + .paths(vec!["src/main.rs".into(), "docs/".into()]))?; +``` + +#### `Repository::log_range(from, to) -> Result` + +Get commits between two specific commits. + +```rust +// Get all commits between two hashes +let range_commits = repo.log_range(&from_hash, &to_hash)?; +println!("Commits in range: {}", range_commits.len()); +``` + +#### `Repository::log_for_paths(paths) -> Result` + +Get commits that affected specific files or directories. + +```rust +// Get commits that modified specific files +let file_commits = repo.log_for_paths(&["src/main.rs", "Cargo.toml"])?; + +// Get commits that affected a directory +let dir_commits = repo.log_for_paths(&["src/"])?; +``` + +#### `Repository::show_commit(hash) -> Result` + +Get detailed information about a specific commit including file changes. + +```rust +let details = repo.show_commit(&commit_hash)?; +println!("Commit: {}", details.commit.hash); +println!("Author: {} <{}>", details.commit.author.name, details.commit.author.email); +println!("Date: {}", details.commit.timestamp); +println!("Message: {}", details.commit.message.subject); + +if let Some(body) = &details.commit.message.body { + println!("Body: {}", body); +} + +println!("Files changed: {}", details.files_changed.len()); +for file in &details.files_changed { + println!(" - {}", file.display()); +} + +println!("Changes: +{} -{}", details.insertions, details.deletions); +``` + +#### Commit Types and Filtering + +The commit API provides rich types for working with commit data: + +```rust +// Commit represents a single commit +pub struct Commit { + pub hash: Hash, + pub author: Author, + pub committer: Author, + pub message: CommitMessage, + pub timestamp: DateTime, + pub parents: Box<[Hash]>, +} + +// Author information with timestamp +pub struct Author { + pub name: String, + pub email: String, + pub timestamp: DateTime, +} + +// Parsed commit message +pub struct CommitMessage { + pub subject: String, + pub body: Option, +} + +// Detailed commit information +pub struct CommitDetails { + pub commit: Commit, + pub files_changed: Box<[PathBuf]>, + pub insertions: u32, + pub deletions: u32, +} +``` + +#### CommitLog Filtering + +`CommitLog` provides iterator-based filtering methods: + +```rust +let commits = repo.log()?; + +// Filter by message content +let bug_fixes: Vec<_> = commits.with_message_containing("fix").collect(); +let features: Vec<_> = commits.with_message_containing("feat").collect(); + +// Filter by date +use chrono::{Utc, Duration}; +let last_week = Utc::now() - Duration::weeks(1); +let recent: Vec<_> = commits.since(last_week).collect(); + +// Filter by commit type +let merge_commits: Vec<_> = commits.merges_only().collect(); +let regular_commits: Vec<_> = commits.no_merges().collect(); + +// Search by hash +if let Some(commit) = commits.find_by_hash(&target_hash) { + println!("Found: {}", commit.message.subject); +} + +if let Some(commit) = commits.find_by_short_hash("abc1234") { + println!("Found by short hash: {}", commit.message.subject); +} +``` + +#### LogOptions Builder + +`LogOptions` provides a builder pattern for advanced queries: + +```rust +let options = LogOptions::new() + .max_count(50) // Limit number of commits + .since(Utc::now() - Duration::days(30)) // Since date + .until(Utc::now()) // Until date + .author("jane@example.com".to_string()) // Filter by author + .committer("john@example.com".to_string()) // Filter by committer + .grep("important".to_string()) // Search in commit messages + .follow_renames(true) // Follow file renames + .merges_only(true) // Only merge commits + .no_merges(true) // Exclude merge commits + .paths(vec!["src/".into()]); // Filter by paths + +let filtered_commits = repo.log_with_options(&options)?; +``` + ### Hash Type The `Hash` type represents Git object hashes (commits, trees, blobs, etc.). @@ -220,7 +668,7 @@ match repo.commit("message") { ## Complete Workflow Example ```rust -use rustic_git::{Repository, FileStatus}; +use rustic_git::{Repository, IndexStatus, WorktreeStatus}; use std::fs; fn main() -> rustic_git::Result<()> { @@ -229,32 +677,76 @@ fn main() -> rustic_git::Result<()> { // Create some files fs::write("./my-project/README.md", "# My Project")?; - fs::write("./my-project/src/main.rs", "fn main() { println!(\"Hello!\"); }")?; fs::create_dir_all("./my-project/src")?; + fs::write("./my-project/src/main.rs", "fn main() { println!(\"Hello!\"); }")?; - // Check status + // Check status with enhanced API let status = repo.status()?; - println!("Found {} untracked files", status.untracked_files().len()); + let untracked_count = status.untracked_entries().count(); + println!("Found {} untracked files", untracked_count); + + // Display detailed status + for entry in status.entries() { + println!("[{}][{}] {}", + entry.index_status.to_char(), + entry.worktree_status.to_char(), + entry.path.display() + ); + } // Stage all files repo.add_all()?; - // Verify staging + // Verify staging with enhanced API let status = repo.status()?; - let added_files: Vec<_> = status.files.iter() - .filter(|(s, _)| matches!(s, FileStatus::Added)) - .map(|(_, f)| f) + let staged_files: Vec<_> = status.staged_files().collect(); + println!("Staged {} files", staged_files.len()); + + // Show specifically added files + let added_files: Vec<_> = status + .files_with_index_status(IndexStatus::Added) .collect(); - println!("Staged files: {:?}", added_files); + println!("Added files: {:?}", added_files); // Create initial commit let hash = repo.commit("Initial commit with project structure")?; println!("Created commit: {}", hash.short()); + // Branch operations workflow + let branches = repo.branches()?; + println!("Current branch: {:?}", repo.current_branch()?.map(|b| b.name)); + + // Create a feature branch + let feature_branch = repo.checkout_new("feature/user-auth", None)?; + println!("Created and switched to: {}", feature_branch.name); + + // Make changes on the feature branch + fs::write("./my-project/src/auth.rs", "pub fn authenticate() { /* TODO */ }")?; + repo.add(&["src/auth.rs"])?; + let feature_commit = repo.commit("Add authentication module")?; + println!("Feature commit: {}", feature_commit.short()); + + // Switch back to main and create another branch + if let Some(main_branch) = branches.find("main") { + repo.checkout(&main_branch)?; + println!("Switched back to main"); + } + + let doc_branch = repo.create_branch("docs/api", None)?; + println!("Created documentation branch: {}", doc_branch.name); + + // List all branches + let final_branches = repo.branches()?; + println!("\nFinal branch summary:"); + for branch in final_branches.iter() { + let marker = if branch.is_current { "*" } else { " " }; + println!(" {}{} ({})", marker, branch.name, branch.commit_hash.short()); + } + // Verify clean state let status = repo.status()?; assert!(status.is_clean()); - println!("Repository is now clean!"); + println!("Repository is clean!"); Ok(()) } @@ -273,7 +765,7 @@ cargo run --example basic_usage # Repository lifecycle operations cargo run --example repository_operations -# Status checking and file state filtering +# Enhanced status API with staged/unstaged tracking cargo run --example status_checking # Staging operations (add, add_all, add_update) @@ -282,6 +774,12 @@ cargo run --example staging_operations # Commit workflows and Hash type usage cargo run --example commit_workflows +# Branch operations (create, delete, checkout, list) +cargo run --example branch_operations + +# Commit history and log operations with advanced querying +cargo run --example commit_history + # Error handling patterns and recovery strategies cargo run --example error_handling ``` @@ -293,6 +791,8 @@ cargo run --example error_handling - **`status_checking.rs`** - Comprehensive demonstration of GitStatus and FileStatus usage with all query methods and filtering capabilities - **`staging_operations.rs`** - Shows all staging methods (add, add_all, add_update) with before/after status comparisons - **`commit_workflows.rs`** - Demonstrates commit operations and Hash type methods, including custom authors and hash management +- **`branch_operations.rs`** - Complete branch management demonstration: create, checkout, delete branches, and BranchList filtering +- **`commit_history.rs`** - Comprehensive commit history & log operations showing all querying APIs, filtering, analysis, and advanced LogOptions usage - **`error_handling.rs`** - Comprehensive error handling patterns showing GitError variants, recovery strategies, and best practices All examples use temporary directories in `/tmp/` and include automatic cleanup for safe execution. @@ -351,6 +851,8 @@ cargo run --example repository_operations cargo run --example status_checking cargo run --example staging_operations cargo run --example commit_workflows +cargo run --example branch_operations +cargo run --example commit_history cargo run --example error_handling ``` @@ -366,14 +868,12 @@ cargo run --example error_handling ## Roadmap Future planned features: -- [ ] Commit history and log operations - [ ] Diff operations -- [ ] Branch operations - [ ] Remote operations (clone, push, pull) - [ ] Merge and rebase operations - [ ] Tag operations - [ ] Stash operations -## Version +## Status -Current version: 0.1.0 - Basic git workflow (init, status, add, commit) +rustic-git provides a complete git workflow including repository management, status checking, staging operations, commits, branch operations, and commit history analysis. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 6e9bde6..9d8f2e6 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -62,12 +62,12 @@ fn main() -> Result<()> { println!(" Repository is clean (no changes)"); } else { println!(" Repository has changes:"); - println!(" Modified files: {}", status.modified_files().len()); - println!(" Untracked files: {}", status.untracked_files().len()); + println!(" Unstaged files: {}", status.unstaged_files().count()); + println!(" Untracked files: {}", status.untracked_entries().count()); // Show untracked files - for filename in status.untracked_files() { - println!(" - {}", filename); + for entry in status.untracked_entries() { + println!(" - {}", entry.path.display()); } } println!(); @@ -91,10 +91,15 @@ fn main() -> Result<()> { } else { println!( " Files staged for commit: {}", - status_after_staging.files.len() + status_after_staging.entries.len() ); - for (file_status, filename) in &status_after_staging.files { - println!(" {:?}: {}", file_status, filename); + for entry in &status_after_staging.entries { + println!( + " Index {:?}, Worktree {:?}: {}", + entry.index_status, + entry.worktree_status, + entry.path.display() + ); } } println!(); diff --git a/examples/branch_operations.rs b/examples/branch_operations.rs new file mode 100644 index 0000000..5b8424a --- /dev/null +++ b/examples/branch_operations.rs @@ -0,0 +1,146 @@ +use rustic_git::{Repository, Result}; +use std::fs; +use std::path::Path; + +fn main() -> Result<()> { + let test_path = "/tmp/rustic_git_branch_example"; + + // Clean up if exists + if Path::new(test_path).exists() { + fs::remove_dir_all(test_path).unwrap(); + } + + // Create a test repository + let repo = Repository::init(test_path, false)?; + println!("Created repository at: {}", test_path); + + // Create initial commit so we have a valid HEAD + fs::write( + format!("{}/README.md", test_path), + "# Branch Operations Demo\n", + ) + .unwrap(); + repo.add(&["README.md"])?; + repo.commit("Initial commit")?; + println!("Created initial commit"); + + // List all branches + let branches = repo.branches()?; + println!("\n=== Initial Branches ==="); + for branch in branches.iter() { + println!(" {}", branch); + } + + // Get current branch + if let Some(current) = repo.current_branch()? { + println!( + "\nCurrent branch: {} ({})", + current.name, + current.commit_hash.short() + ); + } + + // Create new branches + println!("\n=== Creating Branches ==="); + let feature_branch = repo.create_branch("feature/new-api", None)?; + println!("Created branch: {}", feature_branch.name); + + let bugfix_branch = repo.create_branch("bugfix/issue-123", Some("HEAD"))?; + println!("Created branch: {}", bugfix_branch.name); + + // List branches again + let branches = repo.branches()?; + println!("\n=== After Creating Branches ==="); + for branch in branches.local() { + println!(" {} (local)", branch); + } + + // Create and checkout a new branch + println!("\n=== Creating and Checking Out Branch ==="); + let dev_branch = repo.checkout_new("develop", None)?; + println!("Created and checked out: {}", dev_branch.name); + + // Make a commit on the new branch + fs::write(format!("{}/feature.txt", test_path), "New feature code\n").unwrap(); + repo.add(&["feature.txt"])?; + repo.commit("Add new feature")?; + println!("Made commit on develop branch"); + + // Show current branch after checkout + if let Some(current) = repo.current_branch()? { + println!( + "Now on branch: {} ({})", + current.name, + current.commit_hash.short() + ); + } + + // Switch back to master branch + let main_branch = branches.find("master").unwrap().clone(); + repo.checkout(&main_branch)?; + println!("\nSwitched back to master branch"); + + // List all branches with details + let final_branches = repo.branches()?; + println!("\n=== Final Branch List ==="); + println!("Total branches: {}", final_branches.len()); + println!("Local branches: {}", final_branches.local_count()); + + for branch in final_branches.iter() { + let marker = if branch.is_current { "*" } else { " " }; + let branch_type = if branch.is_local() { "local" } else { "remote" }; + println!( + " {}{} ({}) {}", + marker, + branch.name, + branch_type, + branch.commit_hash.short() + ); + + if let Some(upstream) = &branch.upstream { + println!(" └── tracks: {}", upstream); + } + } + + // Demonstrate branch searching + println!("\n=== Branch Search Examples ==="); + + if let Some(branch) = final_branches.find("develop") { + println!("Found branch by name: {}", branch.name); + } + + if let Some(branch) = final_branches.find_by_short_name("new-api") { + println!("Found branch by short name: {}", branch.name); + } + + // Demonstrate branch filtering + println!("\n=== Branch Filtering ==="); + + println!("Local branches:"); + for branch in final_branches.local() { + println!(" - {}", branch.name); + } + + if final_branches.remote_count() > 0 { + println!("Remote branches:"); + for branch in final_branches.remote() { + println!(" - {}", branch.name); + } + } + + // Delete a branch (switch away first if it's current) + println!("\n=== Branch Deletion ==="); + let bugfix = final_branches.find("bugfix/issue-123").unwrap().clone(); + repo.delete_branch(&bugfix, false)?; + println!("Deleted branch: {}", bugfix.name); + + // Show final state + let final_branches = repo.branches()?; + println!("\nFinal branch count: {}", final_branches.len()); + + // Clean up + fs::remove_dir_all(test_path).unwrap(); + println!("\nCleaned up test repository"); + + Ok(()) +} diff --git a/examples/commit_history.rs b/examples/commit_history.rs new file mode 100644 index 0000000..60f4790 --- /dev/null +++ b/examples/commit_history.rs @@ -0,0 +1,308 @@ +use chrono::{Duration, Utc}; +use rustic_git::{LogOptions, Repository, Result}; +use std::fs; +use std::path::Path; + +fn main() -> Result<()> { + let test_path = "/tmp/rustic_git_commit_history_example"; + + // Clean up if exists + if Path::new(test_path).exists() { + fs::remove_dir_all(test_path).unwrap(); + } + + // Create a test repository + let repo = Repository::init(test_path, false)?; + println!("Created repository at: {}", test_path); + + // Create several commits to build history + println!("\n=== Building Commit History ==="); + + // First commit + fs::write( + format!("{}/README.md", test_path), + "# Commit History Demo\n\nA demonstration of rustic-git log functionality.", + ) + .unwrap(); + repo.add(&["README.md"])?; + let commit1 = repo.commit("Initial commit - add README")?; + println!("Created commit 1: {} - Initial commit", commit1.short()); + + // Second commit + fs::create_dir_all(format!("{}/src", test_path)).unwrap(); + fs::write( + format!("{}/src/main.rs", test_path), + "fn main() {\n println!(\"Hello, world!\");\n}", + ) + .unwrap(); + repo.add(&["src/main.rs"])?; + let commit2 = repo.commit("Add main.rs with Hello World")?; + println!("Created commit 2: {} - Add main.rs", commit2.short()); + + // Third commit + fs::write( + format!("{}/src/lib.rs", test_path), + "pub fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}", + ) + .unwrap(); + repo.add(&["src/lib.rs"])?; + let commit3 = repo.commit("Add library module with greet function")?; + println!("Created commit 3: {} - Add lib.rs", commit3.short()); + + // Fourth commit + fs::write( + format!("{}/Cargo.toml", test_path), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"", + ) + .unwrap(); + repo.add(&["Cargo.toml"])?; + let commit4 = repo.commit("Add Cargo.toml configuration")?; + println!("Created commit 4: {} - Add Cargo.toml", commit4.short()); + + // Fifth commit - bug fix + fs::write( + format!("{}/src/main.rs", test_path), + "fn main() {\n println!(\"Hello, rustic-git!\");\n}", + ) + .unwrap(); + repo.add(&["src/main.rs"])?; + let commit5 = repo.commit("Fix greeting message in main")?; + println!("Created commit 5: {} - Fix greeting", commit5.short()); + + // Sixth commit - documentation + fs::write(format!("{}/README.md", test_path), "# Commit History Demo\n\nA demonstration of rustic-git log functionality.\n\n## Features\n\n- Greeting functionality\n- Command line interface\n").unwrap(); + repo.add(&["README.md"])?; + let commit6 = repo.commit("Update README with features section")?; + println!("Created commit 6: {} - Update README", commit6.short()); + + println!("Built commit history with 6 commits"); + + // Basic log operations + println!("\n=== Basic Log Operations ==="); + + let all_commits = repo.log()?; + println!("Total commits in repository: {}", all_commits.len()); + + println!("\nAll commits (most recent first):"); + for (i, commit) in all_commits.iter().enumerate() { + println!(" {}. {}", i + 1, commit); + } + + // Recent commits + println!("\n=== Recent Commits ==="); + let recent = repo.recent_commits(3)?; + println!("Last 3 commits:"); + for commit in recent.iter() { + println!(" {} - {}", commit.hash.short(), commit.message.subject); + if let Some(body) = &commit.message.body { + println!(" Body: {}", body); + } + } + + // Advanced filtering with LogOptions + println!("\n=== Advanced Filtering ==="); + + // Filter by message content + let fix_commits = all_commits.with_message_containing("fix"); + println!("Commits with 'fix' in message:"); + for commit in fix_commits { + println!(" {} - {}", commit.hash.short(), commit.message.subject); + } + + // Filter by date (recent commits) + let now = Utc::now(); + let recent_commits = all_commits.since(now - Duration::minutes(5)); + println!("\nCommits from last 5 minutes: {}", recent_commits.count()); + + // Using LogOptions for advanced queries + println!("\n=== LogOptions Advanced Queries ==="); + + // Get commits with grep + let opts = LogOptions::new().max_count(10).grep("README".to_string()); + let readme_commits = repo.log_with_options(&opts)?; + println!("Commits mentioning 'README': {}", readme_commits.len()); + for commit in readme_commits.iter() { + println!(" {} - {}", commit.hash.short(), commit.message.subject); + } + + // Get commits affecting specific paths + println!("\n=== Path-Specific History ==="); + let src_commits = repo.log_for_paths(&["src/"])?; + println!("Commits affecting src/ directory: {}", src_commits.len()); + for commit in src_commits.iter() { + println!(" {} - {}", commit.hash.short(), commit.message.subject); + } + + // Show detailed commit information + println!("\n=== Detailed Commit Information ==="); + + let commit_details = repo.show_commit(&commit3)?; + println!("Detailed info for commit {}:", commit3.short()); + println!(" Author: {}", commit_details.commit.author); + println!(" Committer: {}", commit_details.commit.committer); + println!( + " Timestamp: {}", + commit_details + .commit + .timestamp + .format("%Y-%m-%d %H:%M:%S UTC") + ); + println!(" Message: {}", commit_details.commit.message.subject); + println!(" Parents: {}", commit_details.commit.parents.len()); + for parent in commit_details.commit.parents.iter() { + println!(" - {}", parent.short()); + } + println!(" Files changed: {}", commit_details.files_changed.len()); + for file in &commit_details.files_changed { + println!(" - {}", file.display()); + } + println!( + " Changes: +{} -{}", + commit_details.insertions, commit_details.deletions + ); + + // Commit analysis + println!("\n=== Commit Analysis ==="); + + let merge_commits: Vec<_> = all_commits.merges_only().collect(); + let regular_commits: Vec<_> = all_commits.no_merges().collect(); + + println!("Repository statistics:"); + println!(" Total commits: {}", all_commits.len()); + println!(" Merge commits: {}", merge_commits.len()); + println!(" Regular commits: {}", regular_commits.len()); + + if let Some(first_commit) = all_commits.first() { + println!( + " Most recent: {} ({})", + first_commit.hash.short(), + first_commit.message.subject + ); + } + + if let Some(last_commit) = all_commits.last() { + println!( + " Oldest: {} ({})", + last_commit.hash.short(), + last_commit.message.subject + ); + } + + // Search operations + println!("\n=== Search Operations ==="); + + // Find by hash + if let Some(found) = all_commits.find_by_hash(&commit2) { + println!("Found commit by full hash: {}", found.message.subject); + } + + // Find by short hash + if let Some(found) = all_commits.find_by_short_hash(commit4.short()) { + println!("Found commit by short hash: {}", found.message.subject); + } + + // Commit range operations + println!("\n=== Commit Range Operations ==="); + + let range_commits = repo.log_range(&commit2, &commit5)?; + println!( + "Commits in range {}..{}: {}", + commit2.short(), + commit5.short(), + range_commits.len() + ); + for commit in range_commits.iter() { + println!(" {} - {}", commit.hash.short(), commit.message.subject); + } + + // Advanced LogOptions demonstration + println!("\n=== Advanced LogOptions Usage ==="); + + let advanced_opts = LogOptions::new() + .max_count(5) + .no_merges(true) + .paths(vec!["src/main.rs".into()]); + + let filtered_commits = repo.log_with_options(&advanced_opts)?; + println!( + "Non-merge commits affecting src/main.rs (max 5): {}", + filtered_commits.len() + ); + for commit in filtered_commits.iter() { + println!(" {} - {}", commit.hash.short(), commit.message.subject); + } + + // Commit message analysis + println!("\n=== Commit Message Analysis ==="); + + let total_commits = all_commits.len(); + let commits_with_body: Vec<_> = all_commits + .iter() + .filter(|c| c.message.body.is_some()) + .collect(); + + println!("Message statistics:"); + println!(" Total commits: {}", total_commits); + println!(" Commits with body text: {}", commits_with_body.len()); + println!( + " Commits with subject only: {}", + total_commits - commits_with_body.len() + ); + + // Display commit types by analyzing subjects + let fix_count = all_commits + .iter() + .filter(|c| c.message.subject.to_lowercase().contains("fix")) + .count(); + let add_count = all_commits + .iter() + .filter(|c| c.message.subject.to_lowercase().contains("add")) + .count(); + let update_count = all_commits + .iter() + .filter(|c| c.message.subject.to_lowercase().contains("update")) + .count(); + + println!(" Commit types:"); + println!(" - Fix commits: {}", fix_count); + println!(" - Add commits: {}", add_count); + println!(" - Update commits: {}", update_count); + println!( + " - Other commits: {}", + total_commits - fix_count - add_count - update_count + ); + + // Timeline view + println!("\n=== Timeline View ==="); + + println!("Commit timeline (oldest to newest):"); + let commits: Vec<_> = all_commits.iter().collect(); + for commit in commits.iter().rev() { + let commit_type = if commit.is_merge() { "MERGE" } else { "COMMIT" }; + println!( + " {} {} {} - {}", + commit.timestamp.format("%H:%M:%S"), + commit_type, + commit.hash.short(), + commit.message.subject + ); + } + + // Summary + println!("\n=== Summary ==="); + + println!("Commit history demonstration completed!"); + println!(" Repository: {}", test_path); + println!(" Total commits analyzed: {}", all_commits.len()); + println!(" Hash examples:"); + for commit in all_commits.iter().take(3) { + println!(" - Full: {}", commit.hash.as_str()); + println!(" Short: {}", commit.hash.short()); + } + + // Clean up + fs::remove_dir_all(test_path).unwrap(); + println!("\nCleaned up test repository"); + + Ok(()) +} diff --git a/examples/commit_workflows.rs b/examples/commit_workflows.rs index d20209c..b6fe60c 100644 --- a/examples/commit_workflows.rs +++ b/examples/commit_workflows.rs @@ -324,7 +324,7 @@ See CHANGELOG.md for version history. } else { println!( "Repository has {} uncommitted changes", - final_status.files.len() + final_status.entries.len() ); } diff --git a/examples/error_handling.rs b/examples/error_handling.rs index 524be47..6d8f6f8 100644 --- a/examples/error_handling.rs +++ b/examples/error_handling.rs @@ -131,7 +131,7 @@ fn demonstrate_file_operation_errors(repo_path: &str) -> Result<()> { println!(" Partially succeeded - some Git versions allow this"); // Check what actually got staged let status = repo.status()?; - println!(" {} files staged despite error", status.files.len()); + println!(" {} files staged despite error", status.entries.len()); } Err(GitError::CommandFailed(msg)) => { println!(" CommandFailed caught: {}", msg); @@ -228,7 +228,7 @@ fn demonstrate_error_recovery_patterns(repo_path: &str) -> Result<()> { let status = repo.status()?; println!( " add_all() succeeded, {} files staged", - status.files.len() + status.entries.len() ); } Err(fallback_error) => { @@ -290,11 +290,16 @@ fn demonstrate_error_recovery_patterns(repo_path: &str) -> Result<()> { if status.is_clean() { println!(" Repository is clean - no commit needed"); } else { - println!(" Repository has {} changes", status.files.len()); + println!(" Repository has {} changes", status.entries.len()); // Show what would be committed - for (file_status, filename) in &status.files { - println!(" {:?}: {}", file_status, filename); + for entry in &status.entries { + println!( + " Index {:?}, Worktree {:?}: {}", + entry.index_status, + entry.worktree_status, + entry.path.display() + ); } // Safe commit since we know there are changes diff --git a/examples/repository_operations.rs b/examples/repository_operations.rs index b437e91..4f3da27 100644 --- a/examples/repository_operations.rs +++ b/examples/repository_operations.rs @@ -66,7 +66,7 @@ fn main() -> Result<()> { // Test that we can perform operations on the opened repo let status = opened_repo.status()?; - println!(" Repository status: {} files", status.files.len()); + println!(" Repository status: {} files", status.entries.len()); } Err(e) => { println!("Failed to open regular repository: {:?}", e); @@ -83,7 +83,7 @@ fn main() -> Result<()> { // Note: status operations might behave differently on bare repos match opened_bare.status() { - Ok(status) => println!(" Bare repository status: {} files", status.files.len()), + Ok(status) => println!(" Bare repository status: {} files", status.entries.len()), Err(e) => println!( " Note: Status check on bare repo failed (expected): {:?}", e diff --git a/examples/staging_operations.rs b/examples/staging_operations.rs index 688e62e..026d2a1 100644 --- a/examples/staging_operations.rs +++ b/examples/staging_operations.rs @@ -8,7 +8,7 @@ //! //! Run with: cargo run --example staging_operations -use rustic_git::{FileStatus, Repository, Result}; +use rustic_git::{IndexStatus, Repository, Result, WorktreeStatus}; use std::fs; use std::path::Path; @@ -194,11 +194,11 @@ fn main() -> Result<()> { ); // Verify that untracked files are still untracked - let remaining_untracked = status_after_add_update.untracked_files(); + let remaining_untracked: Vec<_> = status_after_add_update.untracked_entries().collect(); if !remaining_untracked.is_empty() { println!(" Untracked files remain untracked (as expected):"); - for filename in remaining_untracked { - println!(" - {}", filename); + for entry in remaining_untracked { + println!(" - {}", entry.path.display()); } } @@ -230,12 +230,8 @@ fn main() -> Result<()> { display_status_breakdown(&final_status); if final_status.has_changes() { - let staged_count = final_status - .files - .iter() - .filter(|(status, _)| matches!(status, FileStatus::Added | FileStatus::Modified)) - .count(); - let untracked_count = final_status.untracked_files().len(); + let staged_count = final_status.staged_files().count(); + let untracked_count = final_status.untracked_entries().count(); println!("\nRepository state:"); println!(" {} files staged and ready to commit", staged_count); @@ -261,23 +257,41 @@ fn display_status_breakdown(status: &rustic_git::GitStatus) { return; } - let mut counts = std::collections::HashMap::new(); - for (file_status, _) in &status.files { - *counts.entry(file_status).or_insert(0) += 1; + let mut index_counts = std::collections::HashMap::new(); + let mut worktree_counts = std::collections::HashMap::new(); + + for entry in &status.entries { + if !matches!(entry.index_status, IndexStatus::Clean) { + *index_counts.entry(&entry.index_status).or_insert(0) += 1; + } + if !matches!(entry.worktree_status, WorktreeStatus::Clean) { + *worktree_counts.entry(&entry.worktree_status).or_insert(0) += 1; + } } - println!(" Files by status:"); - for (file_status, count) in &counts { - let marker = match file_status { - FileStatus::Modified => "[M]", - FileStatus::Added => "[A]", - FileStatus::Deleted => "[D]", - FileStatus::Renamed => "[R]", - FileStatus::Copied => "[C]", - FileStatus::Untracked => "[?]", - FileStatus::Ignored => "[I]", + println!(" Index status:"); + for (index_status, count) in &index_counts { + let marker = match index_status { + IndexStatus::Modified => "[M]", + IndexStatus::Added => "[A]", + IndexStatus::Deleted => "[D]", + IndexStatus::Renamed => "[R]", + IndexStatus::Copied => "[C]", + IndexStatus::Clean => "[ ]", }; - println!(" {} {:?}: {} files", marker, file_status, count); + println!(" {} {:?}: {} files", marker, index_status, count); + } + + println!(" Worktree status:"); + for (worktree_status, count) in &worktree_counts { + let marker = match worktree_status { + WorktreeStatus::Modified => "[M]", + WorktreeStatus::Deleted => "[D]", + WorktreeStatus::Untracked => "[?]", + WorktreeStatus::Ignored => "[I]", + WorktreeStatus::Clean => "[ ]", + }; + println!(" {} {:?}: {} files", marker, worktree_status, count); } } @@ -289,8 +303,8 @@ fn display_status_changes( ) { println!("\n Status changes {}:", description); - let before_count = before.files.len(); - let after_count = after.files.len(); + let before_count = before.entries.len(); + let after_count = after.entries.len(); if before_count == after_count { println!(" Total files unchanged ({} files)", after_count); @@ -303,34 +317,27 @@ fn display_status_changes( ); } - // Count status types in both states - let mut before_counts = std::collections::HashMap::new(); - let mut after_counts = std::collections::HashMap::new(); + // Show status summary + let before_staged = before.staged_files().count(); + let after_staged = after.staged_files().count(); + let before_untracked = before.untracked_entries().count(); + let after_untracked = after.untracked_entries().count(); - for (status, _) in &before.files { - *before_counts.entry(format!("{:?}", status)).or_insert(0) += 1; - } - - for (status, _) in &after.files { - *after_counts.entry(format!("{:?}", status)).or_insert(0) += 1; + if before_staged != after_staged { + println!( + " Staged files: {} → {} ({:+})", + before_staged, + after_staged, + after_staged as i32 - before_staged as i32 + ); } - // Show changes for each status type - let all_statuses: std::collections::HashSet<_> = - before_counts.keys().chain(after_counts.keys()).collect(); - - for status in all_statuses { - let before_val = before_counts.get(status).unwrap_or(&0); - let after_val = after_counts.get(status).unwrap_or(&0); - - if before_val != after_val { - println!( - " {}: {} → {} ({:+})", - status, - before_val, - after_val, - *after_val - *before_val - ); - } + if before_untracked != after_untracked { + println!( + " Untracked files: {} → {} ({:+})", + before_untracked, + after_untracked, + after_untracked as i32 - before_untracked as i32 + ); } } diff --git a/examples/status_checking.rs b/examples/status_checking.rs index 2c1f13d..5367cd7 100644 --- a/examples/status_checking.rs +++ b/examples/status_checking.rs @@ -8,7 +8,7 @@ //! //! Run with: cargo run --example status_checking -use rustic_git::{FileStatus, Repository, Result}; +use rustic_git::{IndexStatus, Repository, Result, WorktreeStatus}; use std::fs; use std::path::Path; @@ -128,34 +128,41 @@ fn main() -> Result<()> { // Demonstrate different query methods println!("\nUsing different status query methods:"); - println!(" All files ({} total):", status_mixed.files.len()); - for (file_status, filename) in &status_mixed.files { - println!(" {:?}: {}", file_status, filename); + println!(" All files ({} total):", status_mixed.entries.len()); + for entry in &status_mixed.entries { + println!( + " Index {:?}, Worktree {:?}: {}", + entry.index_status, + entry.worktree_status, + entry.path.display() + ); } // Query by specific status - let modified_files = status_mixed.modified_files(); - if !modified_files.is_empty() { - println!("\n Modified files ({}):", modified_files.len()); - for filename in &modified_files { - println!(" - {}", filename); + let unstaged_files: Vec<_> = status_mixed.unstaged_files().collect(); + if !unstaged_files.is_empty() { + println!("\n Unstaged files ({}):", unstaged_files.len()); + for entry in &unstaged_files { + println!(" - {}", entry.path.display()); } } - let untracked_files = status_mixed.untracked_files(); + let untracked_files: Vec<_> = status_mixed.untracked_entries().collect(); if !untracked_files.is_empty() { println!("\n Untracked files ({}):", untracked_files.len()); - for filename in &untracked_files { - println!(" - {}", filename); + for entry in &untracked_files { + println!(" - {}", entry.path.display()); } } - // Query by FileStatus enum - let added_files = status_mixed.files_with_status(FileStatus::Added); + // Query by IndexStatus enum + let added_files: Vec<_> = status_mixed + .files_with_index_status(IndexStatus::Added) + .collect(); if !added_files.is_empty() { println!("\n Added files ({}):", added_files.len()); - for filename in &added_files { - println!(" - {}", filename); + for entry in &added_files { + println!(" - {}", entry.path.display()); } } @@ -167,29 +174,48 @@ fn main() -> Result<()> { println!("Filtering examples:"); // Count files by status - let mut status_counts = std::collections::HashMap::new(); - for (file_status, _) in &status_mixed.files { - *status_counts - .entry(format!("{:?}", file_status)) - .or_insert(0) += 1; + let mut index_status_counts = std::collections::HashMap::new(); + let mut worktree_status_counts = std::collections::HashMap::new(); + + for entry in &status_mixed.entries { + if !matches!(entry.index_status, IndexStatus::Clean) { + *index_status_counts + .entry(format!("{:?}", entry.index_status)) + .or_insert(0) += 1; + } + if !matches!(entry.worktree_status, WorktreeStatus::Clean) { + *worktree_status_counts + .entry(format!("{:?}", entry.worktree_status)) + .or_insert(0) += 1; + } + } + + println!(" Index status counts:"); + for (status, count) in &index_status_counts { + println!(" {}: {} files", status, count); } - println!(" Files by status:"); - for (status, count) in &status_counts { + println!(" Worktree status counts:"); + for (status, count) in &worktree_status_counts { println!(" {}: {} files", status, count); } // Filter for specific patterns let txt_files: Vec<_> = status_mixed - .files + .entries .iter() - .filter(|(_, filename)| filename.ends_with(".txt")) + .filter(|entry| entry.path.to_string_lossy().ends_with(".txt")) .collect(); if !txt_files.is_empty() { println!("\n .txt files:"); - for (file_status, filename) in txt_files { - println!(" {:?}: {}", file_status, filename); + for entry in txt_files { + println!( + " Index {:?}, Worktree {:?}: {}", + entry.index_status, + entry.worktree_status, + entry.path.display() + ); } } @@ -198,33 +224,24 @@ fn main() -> Result<()> { println!("=== Repository State Checking ===\n"); println!("Repository state summary:"); - println!(" Total files tracked: {}", status_mixed.files.len()); + println!(" Total files tracked: {}", status_mixed.entries.len()); println!(" Is clean: {}", status_mixed.is_clean()); println!(" Has changes: {}", status_mixed.has_changes()); if status_mixed.has_changes() { println!(" Repository needs attention!"); - if !status_mixed.modified_files().is_empty() { - println!( - " - {} files need to be staged", - status_mixed.modified_files().len() - ); + let unstaged_count = status_mixed.unstaged_files().count(); + if unstaged_count > 0 { + println!(" - {} files need to be staged", unstaged_count); } - if !status_mixed.untracked_files().is_empty() { - println!( - " - {} untracked files to consider", - status_mixed.untracked_files().len() - ); + let untracked_count = status_mixed.untracked_entries().count(); + if untracked_count > 0 { + println!(" - {} untracked files to consider", untracked_count); } - let staged_count = status_mixed - .files - .iter() - .filter(|(status, _)| matches!(status, FileStatus::Added)) - .count(); - + let staged_count = status_mixed.staged_files().count(); if staged_count > 0 { println!(" - {} files ready to commit", staged_count); } @@ -243,27 +260,40 @@ fn display_status_summary(status: &rustic_git::GitStatus) { if status.is_clean() { println!(" Repository is clean (no changes)"); } else { - println!(" Repository has {} changes", status.files.len()); - println!(" Modified: {}", status.modified_files().len()); - println!(" Untracked: {}", status.untracked_files().len()); + println!(" Repository has {} changes", status.entries.len()); + println!(" Unstaged: {}", status.unstaged_files().count()); + println!(" Untracked: {}", status.untracked_entries().count()); } } /// Display detailed status information fn display_detailed_status(status: &rustic_git::GitStatus) { - if !status.files.is_empty() { + if !status.entries.is_empty() { println!(" Detailed file status:"); - for (file_status, filename) in &status.files { - let marker = match file_status { - FileStatus::Modified => "[M]", - FileStatus::Added => "[A]", - FileStatus::Deleted => "[D]", - FileStatus::Renamed => "[R]", - FileStatus::Copied => "[C]", - FileStatus::Untracked => "[?]", - FileStatus::Ignored => "[I]", + for entry in &status.entries { + let index_marker = match entry.index_status { + IndexStatus::Modified => "[M]", + IndexStatus::Added => "[A]", + IndexStatus::Deleted => "[D]", + IndexStatus::Renamed => "[R]", + IndexStatus::Copied => "[C]", + IndexStatus::Clean => "[ ]", + }; + let worktree_marker = match entry.worktree_status { + WorktreeStatus::Modified => "[M]", + WorktreeStatus::Deleted => "[D]", + WorktreeStatus::Untracked => "[?]", + WorktreeStatus::Ignored => "[I]", + WorktreeStatus::Clean => "[ ]", }; - println!(" {} {:?}: {}", marker, file_status, filename); + println!( + " {}{} Index {:?}, Worktree {:?}: {}", + index_marker, + worktree_marker, + entry.index_status, + entry.worktree_status, + entry.path.display() + ); } } } diff --git a/src/commands/add.rs b/src/commands/add.rs index a54ce70..f4fb753 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -93,10 +93,8 @@ mod tests { // Verify file1.txt is staged by checking status let status = repo.status().unwrap(); let added_files: Vec<_> = status - .files - .iter() - .filter(|(s, _)| matches!(s, crate::FileStatus::Added)) - .map(|(_, f)| f.as_str()) + .staged_files() + .map(|entry| entry.path.to_str().unwrap()) .collect(); assert!(added_files.contains(&"file1.txt")); @@ -122,10 +120,8 @@ mod tests { // Verify files are staged let status = repo.status().unwrap(); let added_files: Vec<_> = status - .files - .iter() - .filter(|(s, _)| matches!(s, crate::FileStatus::Added)) - .map(|(_, f)| f.as_str()) + .staged_files() + .map(|entry| entry.path.to_str().unwrap()) .collect(); assert!(added_files.contains(&"file1.txt")); @@ -154,10 +150,8 @@ mod tests { // Verify all files are staged let status = repo.status().unwrap(); let added_files: Vec<_> = status - .files - .iter() - .filter(|(s, _)| matches!(s, crate::FileStatus::Added)) - .map(|(_, f)| f.as_str()) + .staged_files() + .map(|entry| entry.path.to_str().unwrap()) .collect(); assert!(added_files.contains(&"file1.txt")); diff --git a/src/commands/branch.rs b/src/commands/branch.rs new file mode 100644 index 0000000..dfaf21b --- /dev/null +++ b/src/commands/branch.rs @@ -0,0 +1,570 @@ +use crate::types::Hash; +use crate::utils::git; +use crate::{Repository, Result}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BranchType { + Local, + RemoteTracking, +} + +impl fmt::Display for BranchType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BranchType::Local => write!(f, "local"), + BranchType::RemoteTracking => write!(f, "remote-tracking"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Branch { + pub name: String, + pub branch_type: BranchType, + pub is_current: bool, + pub commit_hash: Hash, + pub upstream: Option, +} + +impl Branch { + /// Check if this is a local branch + pub fn is_local(&self) -> bool { + matches!(self.branch_type, BranchType::Local) + } + + /// Check if this is a remote-tracking branch + pub fn is_remote(&self) -> bool { + matches!(self.branch_type, BranchType::RemoteTracking) + } + + /// Get the short name of the branch (without remote prefix for remote branches) + pub fn short_name(&self) -> &str { + if self.is_remote() && self.name.contains('/') { + self.name.split('/').nth(1).unwrap_or(&self.name) + } else { + &self.name + } + } +} + +impl fmt::Display for Branch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let marker = if self.is_current { "*" } else { " " }; + write!(f, "{} {}", marker, self.name) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BranchList { + branches: Box<[Branch]>, +} + +impl BranchList { + /// Create a new BranchList from a vector of branches + pub fn new(branches: Vec) -> Self { + Self { + branches: branches.into_boxed_slice(), + } + } + + /// Get all branches + pub fn all(&self) -> &[Branch] { + &self.branches + } + + /// Get an iterator over all branches + pub fn iter(&self) -> impl Iterator { + self.branches.iter() + } + + /// Get an iterator over local branches + pub fn local(&self) -> impl Iterator { + self.branches.iter().filter(|b| b.is_local()) + } + + /// Get an iterator over remote-tracking branches + pub fn remote(&self) -> impl Iterator { + self.branches.iter().filter(|b| b.is_remote()) + } + + /// Get the current branch + pub fn current(&self) -> Option<&Branch> { + self.branches.iter().find(|b| b.is_current) + } + + /// Find a branch by name + pub fn find(&self, name: &str) -> Option<&Branch> { + self.branches.iter().find(|b| b.name == name) + } + + /// Find a branch by short name (useful for remote branches) + pub fn find_by_short_name(&self, short_name: &str) -> Option<&Branch> { + self.branches.iter().find(|b| b.short_name() == short_name) + } + + /// Check if the list is empty + pub fn is_empty(&self) -> bool { + self.branches.is_empty() + } + + /// Get the count of branches + pub fn len(&self) -> usize { + self.branches.len() + } + + /// Get count of local branches + pub fn local_count(&self) -> usize { + self.local().count() + } + + /// Get count of remote-tracking branches + pub fn remote_count(&self) -> usize { + self.remote().count() + } +} + +impl fmt::Display for BranchList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for branch in &self.branches { + writeln!(f, "{}", branch)?; + } + Ok(()) + } +} + +impl Repository { + /// List all branches in the repository + pub fn branches(&self) -> Result { + Self::ensure_git()?; + + // Use git branch -vv --all for comprehensive branch information + let stdout = git(&["branch", "-vv", "--all"], Some(self.repo_path()))?; + + let branches = parse_branch_output(&stdout)?; + Ok(BranchList::new(branches)) + } + + /// Get the current branch + pub fn current_branch(&self) -> Result> { + Self::ensure_git()?; + + let stdout = git(&["branch", "--show-current"], Some(self.repo_path()))?; + let current_name = stdout.trim(); + + if current_name.is_empty() { + // Might be in detached HEAD state + return Ok(None); + } + + // Get detailed info about the current branch + let branches = self.branches()?; + Ok(branches.current().cloned()) + } + + /// Create a new branch + pub fn create_branch(&self, name: &str, start_point: Option<&str>) -> Result { + Self::ensure_git()?; + + let mut args = vec!["branch", name]; + if let Some(start) = start_point { + args.push(start); + } + + let _stdout = git(&args, Some(self.repo_path()))?; + + // Get information about the newly created branch + let branches = self.branches()?; + branches.find(name).cloned().ok_or_else(|| { + crate::error::GitError::CommandFailed(format!("Failed to create branch: {}", name)) + }) + } + + /// Delete a branch + pub fn delete_branch(&self, branch: &Branch, force: bool) -> Result<()> { + Self::ensure_git()?; + + if branch.is_current { + return Err(crate::error::GitError::CommandFailed( + "Cannot delete the current branch".to_string(), + )); + } + + let flag = if force { "-D" } else { "-d" }; + let args = vec!["branch", flag, &branch.name]; + + let _stdout = git(&args, Some(self.repo_path()))?; + Ok(()) + } + + /// Switch to an existing branch + pub fn checkout(&self, branch: &Branch) -> Result<()> { + Self::ensure_git()?; + + let branch_name = if branch.is_remote() { + branch.short_name() + } else { + &branch.name + }; + + let _stdout = git(&["checkout", branch_name], Some(self.repo_path()))?; + Ok(()) + } + + /// Create a new branch and switch to it + pub fn checkout_new(&self, name: &str, start_point: Option<&str>) -> Result { + Self::ensure_git()?; + + let mut args = vec!["checkout", "-b", name]; + if let Some(start) = start_point { + args.push(start); + } + + let _stdout = git(&args, Some(self.repo_path()))?; + + // Get information about the newly created and checked out branch + self.current_branch()?.ok_or_else(|| { + crate::error::GitError::CommandFailed(format!( + "Failed to create and checkout branch: {}", + name + )) + }) + } +} + +/// Parse the output of `git branch -vv --all` +fn parse_branch_output(output: &str) -> Result> { + let mut branches = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Skip the line that shows HEAD -> branch mapping for remotes + if line.contains("->") { + continue; + } + + let is_current = line.starts_with('*'); + let line = if is_current { + line[1..].trim() // Skip the '*' and trim + } else { + line.trim() // Just trim whitespace for non-current branches + }; + + // Parse branch name (first word) + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.is_empty() { + continue; + } + + let name = parts[0].to_string(); + + // Determine branch type + let branch_type = if name.starts_with("remotes/") { + BranchType::RemoteTracking + } else { + BranchType::Local + }; + + // Extract commit hash (second part if available) + let commit_hash = if parts.len() > 1 { + Hash::from(parts[1].to_string()) + } else { + Hash::from("0000000000000000000000000000000000000000".to_string()) + }; + + // Extract upstream information (look for [upstream] pattern) + let upstream = if let Some(bracket_start) = line.find('[') { + if let Some(bracket_end) = line.find(']') { + let upstream_info = &line[bracket_start + 1..bracket_end]; + // Extract just the upstream branch name, ignore ahead/behind info + let upstream_branch = upstream_info + .split(':') + .next() + .unwrap_or(upstream_info) + .trim(); + if upstream_branch.is_empty() { + None + } else { + Some(upstream_branch.to_string()) + } + } else { + None + } + } else { + None + }; + + // Clean up remote branch names + let clean_name = if branch_type == BranchType::RemoteTracking { + name.strip_prefix("remotes/").unwrap_or(&name).to_string() + } else { + name + }; + + let branch = Branch { + name: clean_name, + branch_type, + is_current, + commit_hash, + upstream, + }; + + branches.push(branch); + } + + Ok(branches) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + #[test] + fn test_branch_type_display() { + assert_eq!(format!("{}", BranchType::Local), "local"); + assert_eq!(format!("{}", BranchType::RemoteTracking), "remote-tracking"); + } + + #[test] + fn test_branch_is_local() { + let branch = Branch { + name: "main".to_string(), + branch_type: BranchType::Local, + is_current: true, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }; + + assert!(branch.is_local()); + assert!(!branch.is_remote()); + } + + #[test] + fn test_branch_is_remote() { + let branch = Branch { + name: "origin/main".to_string(), + branch_type: BranchType::RemoteTracking, + is_current: false, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }; + + assert!(branch.is_remote()); + assert!(!branch.is_local()); + } + + #[test] + fn test_branch_short_name() { + let local_branch = Branch { + name: "feature".to_string(), + branch_type: BranchType::Local, + is_current: false, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }; + + let remote_branch = Branch { + name: "origin/feature".to_string(), + branch_type: BranchType::RemoteTracking, + is_current: false, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }; + + assert_eq!(local_branch.short_name(), "feature"); + assert_eq!(remote_branch.short_name(), "feature"); + } + + #[test] + fn test_branch_display() { + let current_branch = Branch { + name: "main".to_string(), + branch_type: BranchType::Local, + is_current: true, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }; + + let other_branch = Branch { + name: "feature".to_string(), + branch_type: BranchType::Local, + is_current: false, + commit_hash: Hash::from("def456".to_string()), + upstream: None, + }; + + assert_eq!(format!("{}", current_branch), "* main"); + assert_eq!(format!("{}", other_branch), " feature"); + } + + #[test] + fn test_branch_list_creation() { + let branches = vec![ + Branch { + name: "main".to_string(), + branch_type: BranchType::Local, + is_current: true, + commit_hash: Hash::from("abc123".to_string()), + upstream: Some("origin/main".to_string()), + }, + Branch { + name: "origin/main".to_string(), + branch_type: BranchType::RemoteTracking, + is_current: false, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }, + ]; + + let branch_list = BranchList::new(branches); + + assert_eq!(branch_list.len(), 2); + assert_eq!(branch_list.local_count(), 1); + assert_eq!(branch_list.remote_count(), 1); + assert!(!branch_list.is_empty()); + } + + #[test] + fn test_branch_list_find() { + let branches = vec![ + Branch { + name: "main".to_string(), + branch_type: BranchType::Local, + is_current: true, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }, + Branch { + name: "origin/feature".to_string(), + branch_type: BranchType::RemoteTracking, + is_current: false, + commit_hash: Hash::from("def456".to_string()), + upstream: None, + }, + ]; + + let branch_list = BranchList::new(branches); + + assert!(branch_list.find("main").is_some()); + assert!(branch_list.find("origin/feature").is_some()); + assert!(branch_list.find("nonexistent").is_none()); + + assert!(branch_list.find_by_short_name("main").is_some()); + assert!(branch_list.find_by_short_name("feature").is_some()); + } + + #[test] + fn test_branch_list_current() { + let branches = vec![ + Branch { + name: "main".to_string(), + branch_type: BranchType::Local, + is_current: true, + commit_hash: Hash::from("abc123".to_string()), + upstream: None, + }, + Branch { + name: "feature".to_string(), + branch_type: BranchType::Local, + is_current: false, + commit_hash: Hash::from("def456".to_string()), + upstream: None, + }, + ]; + + let branch_list = BranchList::new(branches); + let current = branch_list.current().unwrap(); + + assert_eq!(current.name, "main"); + assert!(current.is_current); + } + + #[test] + fn test_parse_branch_output() { + let output = r#" +* main abc1234 [origin/main] Initial commit + feature def5678 Feature branch + remotes/origin/main abc1234 Initial commit +"#; + + let branches = parse_branch_output(output).unwrap(); + + assert_eq!(branches.len(), 3); + + // Check main branch + let main_branch = branches.iter().find(|b| b.name == "main"); + assert!(main_branch.is_some()); + let main_branch = main_branch.unwrap(); + assert!(main_branch.is_current); + assert_eq!(main_branch.branch_type, BranchType::Local); + assert_eq!(main_branch.upstream, Some("origin/main".to_string())); + + // Check feature branch + let feature_branch = branches.iter().find(|b| b.name == "feature").unwrap(); + assert!(!feature_branch.is_current); + assert_eq!(feature_branch.branch_type, BranchType::Local); + assert_eq!(feature_branch.upstream, None); + + // Check remote branch + let remote_branch = branches.iter().find(|b| b.name == "origin/main").unwrap(); + assert!(!remote_branch.is_current); + assert_eq!(remote_branch.branch_type, BranchType::RemoteTracking); + } + + #[test] + fn test_repository_current_branch() { + let test_path = "/tmp/test_current_branch_repo"; + + // Clean up if exists + if Path::new(test_path).exists() { + fs::remove_dir_all(test_path).unwrap(); + } + + // Create a repository and test current branch + let repo = Repository::init(test_path, false).unwrap(); + + // In a new repo, there might not be a current branch until first commit + let _current = repo.current_branch().unwrap(); + // This might be None in a fresh repository with no commits + + // Clean up + fs::remove_dir_all(test_path).unwrap(); + } + + #[test] + fn test_repository_create_branch() { + let test_path = "/tmp/test_create_branch_repo"; + + // Clean up if exists + if Path::new(test_path).exists() { + fs::remove_dir_all(test_path).unwrap(); + } + + // Create a repository with an initial commit + let repo = Repository::init(test_path, false).unwrap(); + + // Create a test file and commit to have a valid HEAD + std::fs::write(format!("{}/test.txt", test_path), "test content").unwrap(); + repo.add(&["test.txt"]).unwrap(); + repo.commit("Initial commit").unwrap(); + + // Create a new branch + let branch = repo.create_branch("feature", None).unwrap(); + assert_eq!(branch.name, "feature"); + assert_eq!(branch.branch_type, BranchType::Local); + assert!(!branch.is_current); + + // Verify the branch exists in the branch list + let branches = repo.branches().unwrap(); + assert!(branches.find("feature").is_some()); + + // Clean up + fs::remove_dir_all(test_path).unwrap(); + } +} diff --git a/src/commands/commit.rs b/src/commands/commit.rs index ad343fb..0ae2cca 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -22,12 +22,7 @@ impl Repository { // Check if there are staged changes let status = self.status()?; - let has_staged = status.files.iter().any(|(file_status, _)| { - matches!( - file_status, - crate::FileStatus::Added | crate::FileStatus::Modified | crate::FileStatus::Deleted - ) - }); + let has_staged = status.staged_files().count() > 0; if !has_staged { return Err(crate::error::GitError::CommandFailed( @@ -80,12 +75,7 @@ impl Repository { // Check if there are staged changes let status = self.status()?; - let has_staged = status.files.iter().any(|(file_status, _)| { - matches!( - file_status, - crate::FileStatus::Added | crate::FileStatus::Modified | crate::FileStatus::Deleted - ) - }); + let has_staged = status.staged_files().count() > 0; if !has_staged { return Err(crate::error::GitError::CommandFailed( diff --git a/src/commands/log.rs b/src/commands/log.rs new file mode 100644 index 0000000..313534a --- /dev/null +++ b/src/commands/log.rs @@ -0,0 +1,834 @@ +use crate::types::Hash; +use crate::utils::git; +use crate::{Repository, Result}; +use chrono::{DateTime, Utc}; +use std::fmt; +use std::path::PathBuf; + +/// Git log format string for parsing commit information +/// Format: hash|author_name|author_email|author_timestamp|committer_name|committer_email|committer_timestamp|parent_hashes|subject|body +const GIT_LOG_FORMAT: &str = "--pretty=format:%H|%an|%ae|%at|%cn|%ce|%ct|%P|%s|%b"; + +/// Date format for git date filters +const DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Author { + pub name: String, + pub email: String, + pub timestamp: DateTime, +} + +impl fmt::Display for Author { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} <{}>", self.name, self.email) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitMessage { + pub subject: String, + pub body: Option, +} + +impl CommitMessage { + pub fn new(subject: String, body: Option) -> Self { + Self { subject, body } + } + + /// Get the full message (subject + body if present) + pub fn full(&self) -> String { + match &self.body { + Some(body) => format!("{}\n\n{}", self.subject, body), + None => self.subject.clone(), + } + } + + /// Check if message is empty + pub fn is_empty(&self) -> bool { + self.subject.is_empty() + } +} + +impl fmt::Display for CommitMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.full()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Commit { + pub hash: Hash, + pub author: Author, + pub committer: Author, + pub message: CommitMessage, + pub timestamp: DateTime, + pub parents: Box<[Hash]>, +} + +impl Commit { + /// Check if this is a merge commit (has multiple parents) + pub fn is_merge(&self) -> bool { + self.parents.len() > 1 + } + + /// Check if this is a root commit (has no parents) + pub fn is_root(&self) -> bool { + self.parents.is_empty() + } + + /// Get the main parent commit hash (first parent for merges) + pub fn main_parent(&self) -> Option<&Hash> { + self.parents.first() + } + + /// Check if commit matches author + pub fn is_authored_by(&self, author: &str) -> bool { + self.author.name.contains(author) || self.author.email.contains(author) + } + + /// Check if commit message contains text + pub fn message_contains(&self, text: &str) -> bool { + self.message + .subject + .to_lowercase() + .contains(&text.to_lowercase()) + || self + .message + .body + .as_ref() + .is_some_and(|body| body.to_lowercase().contains(&text.to_lowercase())) + } +} + +impl fmt::Display for Commit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} by {} at {}", + self.hash.short(), + self.message.subject, + self.author.name, + self.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CommitLog { + commits: Box<[Commit]>, +} + +impl CommitLog { + /// Create a new CommitLog from a vector of commits + pub fn new(commits: Vec) -> Self { + Self { + commits: commits.into_boxed_slice(), + } + } + + /// Get all commits + pub fn all(&self) -> &[Commit] { + &self.commits + } + + /// Get an iterator over all commits + pub fn iter(&self) -> impl Iterator { + self.commits.iter() + } + + /// Get commits by a specific author + pub fn by_author(&self, author: &str) -> impl Iterator { + self.commits + .iter() + .filter(move |c| c.is_authored_by(author)) + } + + /// Get commits since a specific date + pub fn since(&self, date: DateTime) -> impl Iterator { + self.commits.iter().filter(move |c| c.timestamp >= date) + } + + /// Get commits until a specific date + pub fn until(&self, date: DateTime) -> impl Iterator { + self.commits.iter().filter(move |c| c.timestamp <= date) + } + + /// Get commits with message containing text + pub fn with_message_containing(&self, text: &str) -> impl Iterator { + let text = text.to_lowercase(); + self.commits + .iter() + .filter(move |c| c.message_contains(&text)) + } + + /// Get only merge commits + pub fn merges_only(&self) -> impl Iterator { + self.commits.iter().filter(|c| c.is_merge()) + } + + /// Get commits excluding merges + pub fn no_merges(&self) -> impl Iterator { + self.commits.iter().filter(|c| !c.is_merge()) + } + + /// Find commit by full hash + pub fn find_by_hash(&self, hash: &Hash) -> Option<&Commit> { + self.commits.iter().find(|c| &c.hash == hash) + } + + /// Find commit by short hash + pub fn find_by_short_hash(&self, short: &str) -> Option<&Commit> { + self.commits.iter().find(|c| c.hash.short() == short) + } + + /// Check if the log is empty + pub fn is_empty(&self) -> bool { + self.commits.is_empty() + } + + /// Get the count of commits + pub fn len(&self) -> usize { + self.commits.len() + } + + /// Get the first (most recent) commit + pub fn first(&self) -> Option<&Commit> { + self.commits.first() + } + + /// Get the last (oldest) commit + pub fn last(&self) -> Option<&Commit> { + self.commits.last() + } +} + +impl fmt::Display for CommitLog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for commit in &self.commits { + writeln!(f, "{}", commit)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct LogOptions { + pub max_count: Option, + pub since: Option>, + pub until: Option>, + pub author: Option, + pub committer: Option, + pub grep: Option, + pub paths: Vec, + pub follow_renames: bool, + pub merges_only: bool, + pub no_merges: bool, +} + +impl LogOptions { + pub fn new() -> Self { + Self::default() + } + + /// Set maximum number of commits to retrieve + pub fn max_count(mut self, count: usize) -> Self { + self.max_count = Some(count); + self + } + + /// Filter commits since a date + pub fn since(mut self, date: DateTime) -> Self { + self.since = Some(date); + self + } + + /// Filter commits until a date + pub fn until(mut self, date: DateTime) -> Self { + self.until = Some(date); + self + } + + /// Filter by author name or email + pub fn author(mut self, author: String) -> Self { + self.author = Some(author); + self + } + + /// Filter by committer name or email + pub fn committer(mut self, committer: String) -> Self { + self.committer = Some(committer); + self + } + + /// Filter by commit message content + pub fn grep(mut self, pattern: String) -> Self { + self.grep = Some(pattern); + self + } + + /// Filter by file paths + pub fn paths(mut self, paths: Vec) -> Self { + self.paths = paths; + self + } + + /// Follow file renames + pub fn follow_renames(mut self, follow: bool) -> Self { + self.follow_renames = follow; + self + } + + /// Show only merge commits + pub fn merges_only(mut self, only: bool) -> Self { + self.merges_only = only; + self + } + + /// Exclude merge commits + pub fn no_merges(mut self, exclude: bool) -> Self { + self.no_merges = exclude; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitDetails { + pub commit: Commit, + pub files_changed: Vec, + pub insertions: usize, + pub deletions: usize, +} + +impl CommitDetails { + /// Get total changes (insertions + deletions) + pub fn total_changes(&self) -> usize { + self.insertions + self.deletions + } + + /// Check if any files were changed + pub fn has_changes(&self) -> bool { + !self.files_changed.is_empty() + } +} + +impl fmt::Display for CommitDetails { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.commit)?; + writeln!(f, "Files changed: {}", self.files_changed.len())?; + writeln!(f, "Insertions: +{}", self.insertions)?; + writeln!(f, "Deletions: -{}", self.deletions)?; + + if !self.files_changed.is_empty() { + writeln!(f, "\nFiles:")?; + for file in &self.files_changed { + writeln!(f, " {}", file.display())?; + } + } + + Ok(()) + } +} + +/// Parse git log output with our custom format +fn parse_log_output(output: &str) -> Result> { + let mut commits = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse format: hash|author_name|author_email|author_timestamp|committer_name|committer_email|committer_timestamp|parent_hashes|subject|body + let parts: Vec<&str> = line.splitn(10, '|').collect(); + if parts.len() < 9 { + continue; // Skip malformed lines + } + + let hash = Hash::from(parts[0].to_string()); + let author_name = parts[1].to_string(); + let author_email = parts[2].to_string(); + let author_timestamp = parse_timestamp(parts[3])?; + let committer_name = parts[4].to_string(); + let committer_email = parts[5].to_string(); + let committer_timestamp = parse_timestamp(parts[6])?; + let parent_hashes = parse_parent_hashes(parts[7]); + let subject = parts[8].to_string(); + let body = if parts.len() > 9 && !parts[9].is_empty() { + Some(parts[9].to_string()) + } else { + None + }; + + let author = Author { + name: author_name, + email: author_email, + timestamp: author_timestamp, + }; + + let committer = Author { + name: committer_name, + email: committer_email, + timestamp: committer_timestamp, + }; + + let message = CommitMessage::new(subject, body); + + let commit = Commit { + hash, + author, + committer, + message, + timestamp: author_timestamp, // Use author timestamp for commit timestamp + parents: parent_hashes, + }; + + commits.push(commit); + } + + Ok(commits) +} + +/// Parse Unix timestamp to DateTime +fn parse_timestamp(timestamp_str: &str) -> Result> { + let timestamp: i64 = timestamp_str.parse().map_err(|_| { + crate::error::GitError::CommandFailed(format!("Invalid timestamp: {}", timestamp_str)) + })?; + + DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { + crate::error::GitError::CommandFailed(format!("Invalid timestamp value: {}", timestamp)) + }) +} + +/// Parse parent hashes from space-separated string +fn parse_parent_hashes(parents_str: &str) -> Box<[Hash]> { + if parents_str.is_empty() { + return Box::new([]); + } + + parents_str + .split_whitespace() + .map(|hash| Hash::from(hash.to_string())) + .collect::>() + .into_boxed_slice() +} + +impl Repository { + /// Get commit history with default options + pub fn log(&self) -> Result { + self.log_with_options(&LogOptions::new().max_count(100)) + } + + /// Get recent N commits + pub fn recent_commits(&self, count: usize) -> Result { + self.log_with_options(&LogOptions::new().max_count(count)) + } + + /// Get commit history with custom options + pub fn log_with_options(&self, options: &LogOptions) -> Result { + Self::ensure_git()?; + + // Build all formatted arguments first + let mut args_vec: Vec = vec![ + "log".to_string(), + GIT_LOG_FORMAT.to_string(), + "--no-show-signature".to_string(), + ]; + + // Add options to git command + if let Some(count) = options.max_count { + args_vec.push("-n".to_string()); + args_vec.push(count.to_string()); + } + + if let Some(since) = &options.since { + args_vec.push(format!("--since={}", since.format(DATE_FORMAT))); + } + + if let Some(until) = &options.until { + args_vec.push(format!("--until={}", until.format(DATE_FORMAT))); + } + + if let Some(author) = &options.author { + args_vec.push(format!("--author={}", author)); + } + + if let Some(committer) = &options.committer { + args_vec.push(format!("--committer={}", committer)); + } + + if let Some(grep) = &options.grep { + args_vec.push(format!("--grep={}", grep)); + } + + // Add boolean flags + if options.follow_renames { + args_vec.push("--follow".to_string()); + } + + if options.merges_only { + args_vec.push("--merges".to_string()); + } + + if options.no_merges { + args_vec.push("--no-merges".to_string()); + } + + // Add path filters at the end + if !options.paths.is_empty() { + args_vec.push("--".to_string()); + for path in &options.paths { + args_vec.push(path.to_string_lossy().to_string()); + } + } + + // Convert to &str slice for git function + let all_args: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); + + let stdout = git(&all_args, Some(self.repo_path()))?; + let commits = parse_log_output(&stdout)?; + Ok(CommitLog::new(commits)) + } + + /// Get commits in a range between two commits + pub fn log_range(&self, from: &Hash, to: &Hash) -> Result { + Self::ensure_git()?; + + let range = format!("{}..{}", from.as_str(), to.as_str()); + let args = vec!["log", GIT_LOG_FORMAT, "--no-show-signature", &range]; + + let stdout = git(&args, Some(self.repo_path()))?; + let commits = parse_log_output(&stdout)?; + Ok(CommitLog::new(commits)) + } + + /// Get commits that affected specific paths + pub fn log_for_paths(&self, paths: &[impl AsRef]) -> Result { + let path_bufs: Vec = paths.iter().map(|p| p.as_ref().to_path_buf()).collect(); + let options = LogOptions::new().paths(path_bufs); + self.log_with_options(&options) + } + + /// Get detailed information about a specific commit + pub fn show_commit(&self, hash: &Hash) -> Result { + Self::ensure_git()?; + + // Get commit info + let commit_args = vec![ + "log", + GIT_LOG_FORMAT, + "--no-show-signature", + "-n", + "1", + hash.as_str(), + ]; + + let commit_output = git(&commit_args, Some(self.repo_path()))?; + let mut commits = parse_log_output(&commit_output)?; + + if commits.is_empty() { + return Err(crate::error::GitError::CommandFailed(format!( + "Commit not found: {}", + hash + ))); + } + + let commit = commits.remove(0); + + // Get diff stats + let stats_args = vec!["show", "--stat", "--format=", hash.as_str()]; + + let stats_output = git(&stats_args, Some(self.repo_path()))?; + let (files_changed, insertions, deletions) = parse_diff_stats(&stats_output); + + Ok(CommitDetails { + commit, + files_changed, + insertions, + deletions, + }) + } +} + +/// Parse diff stats from git show --stat output +fn parse_diff_stats(output: &str) -> (Vec, usize, usize) { + let mut files_changed = Vec::new(); + let mut total_insertions = 0; + let mut total_deletions = 0; + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse lines like: "src/main.rs | 15 +++++++++------" + if let Some(pipe_pos) = line.find(" | ") { + let filename = line[..pipe_pos].trim(); + files_changed.push(PathBuf::from(filename)); + + // Parse insertions/deletions from the rest of the line + let stats_part = &line[pipe_pos + 3..]; + if let Some(space_pos) = stats_part.find(' ') + && let Ok(changes) = stats_part[..space_pos].parse::() + { + let symbols = &stats_part[space_pos + 1..]; + let plus_count = symbols.chars().filter(|&c| c == '+').count(); + let minus_count = symbols.chars().filter(|&c| c == '-').count(); + + // Distribute changes based on +/- ratio + let total_symbols = plus_count + minus_count; + if total_symbols > 0 { + let insertions = (changes * plus_count) / total_symbols; + let deletions = changes - insertions; + total_insertions += insertions; + total_deletions += deletions; + } + } + } + // Parse summary line like: "2 files changed, 15 insertions(+), 8 deletions(-)" + else if line.contains("files changed") || line.contains("file changed") { + if let Some(insertions_pos) = line.find(" insertions(+)") + && let Some(start) = line[..insertions_pos].rfind(' ') + && let Ok(insertions) = line[start + 1..insertions_pos].parse::() + { + total_insertions = insertions; + } + if let Some(deletions_pos) = line.find(" deletions(-)") + && let Some(start) = line[..deletions_pos].rfind(' ') + && let Ok(deletions) = line[start + 1..deletions_pos].parse::() + { + total_deletions = deletions; + } + } + } + + (files_changed, total_insertions, total_deletions) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + #[test] + fn test_author_display() { + let author = Author { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(), + }; + assert_eq!(format!("{}", author), "John Doe "); + } + + #[test] + fn test_commit_message_creation() { + let msg = CommitMessage::new("Initial commit".to_string(), None); + assert_eq!(msg.subject, "Initial commit"); + assert!(msg.body.is_none()); + assert_eq!(msg.full(), "Initial commit"); + + let msg_with_body = CommitMessage::new( + "Add feature".to_string(), + Some("This adds a new feature\nwith multiple lines".to_string()), + ); + assert_eq!( + msg_with_body.full(), + "Add feature\n\nThis adds a new feature\nwith multiple lines" + ); + } + + #[test] + fn test_commit_is_merge() { + let commit = Commit { + hash: Hash::from("abc123".to_string()), + author: Author { + name: "Test".to_string(), + email: "test@example.com".to_string(), + timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(), + }, + committer: Author { + name: "Test".to_string(), + email: "test@example.com".to_string(), + timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(), + }, + message: CommitMessage::new("Test commit".to_string(), None), + timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(), + parents: vec![ + Hash::from("parent1".to_string()), + Hash::from("parent2".to_string()), + ] + .into_boxed_slice(), + }; + + assert!(commit.is_merge()); + assert!(!commit.is_root()); + } + + #[test] + fn test_commit_log_filtering() { + let commits = vec![ + create_test_commit( + "abc123", + "John Doe", + "john@example.com", + "Fix bug", + 1640995200, + ), + create_test_commit( + "def456", + "Jane Smith", + "jane@example.com", + "Add feature", + 1640995300, + ), + create_test_commit( + "ghi789", + "John Doe", + "john@example.com", + "Update docs", + 1640995400, + ), + ]; + + let log = CommitLog::new(commits); + + // Test by author + let john_commits: Vec<_> = log.by_author("John Doe").collect(); + assert_eq!(john_commits.len(), 2); + + // Test message search + let fix_commits: Vec<_> = log.with_message_containing("fix").collect(); + assert_eq!(fix_commits.len(), 1); + assert_eq!(fix_commits[0].message.subject, "Fix bug"); + } + + #[test] + fn test_parse_timestamp() { + let timestamp = parse_timestamp("1640995200").unwrap(); + assert_eq!(timestamp.timestamp(), 1640995200); + } + + #[test] + fn test_parse_parent_hashes() { + let parents = parse_parent_hashes("abc123 def456 ghi789"); + assert_eq!(parents.len(), 3); + assert_eq!(parents[0].as_str(), "abc123"); + assert_eq!(parents[1].as_str(), "def456"); + assert_eq!(parents[2].as_str(), "ghi789"); + + let no_parents = parse_parent_hashes(""); + assert_eq!(no_parents.len(), 0); + } + + #[test] + fn test_log_options_builder() { + let options = LogOptions::new() + .max_count(50) + .author("john@example.com".to_string()) + .follow_renames(true); + + assert_eq!(options.max_count, Some(50)); + assert_eq!(options.author, Some("john@example.com".to_string())); + assert!(options.follow_renames); + } + + #[test] + fn test_parse_diff_stats() { + let output = "src/main.rs | 15 +++++++++------\nREADME.md | 3 +++\n 2 files changed, 18 insertions(+), 6 deletions(-)"; + let (files, insertions, deletions) = parse_diff_stats(output); + + assert_eq!(files.len(), 2); + assert_eq!(files[0], PathBuf::from("src/main.rs")); + assert_eq!(files[1], PathBuf::from("README.md")); + assert_eq!(insertions, 18); + assert_eq!(deletions, 6); + } + + #[test] + fn test_commit_details_display() { + let commit = create_test_commit( + "abc123", + "John Doe", + "john@example.com", + "Test commit", + 1640995200, + ); + let details = CommitDetails { + commit, + files_changed: vec![PathBuf::from("src/main.rs"), PathBuf::from("README.md")], + insertions: 15, + deletions: 8, + }; + + assert_eq!(details.total_changes(), 23); + assert!(details.has_changes()); + + let display_output = format!("{}", details); + assert!(display_output.contains("Files changed: 2")); + assert!(display_output.contains("Insertions: +15")); + assert!(display_output.contains("Deletions: -8")); + } + + // Helper function to create test commits + fn create_test_commit( + hash: &str, + author_name: &str, + author_email: &str, + subject: &str, + timestamp: i64, + ) -> Commit { + Commit { + hash: Hash::from(hash.to_string()), + author: Author { + name: author_name.to_string(), + email: author_email.to_string(), + timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(), + }, + committer: Author { + name: author_name.to_string(), + email: author_email.to_string(), + timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(), + }, + message: CommitMessage::new(subject.to_string(), None), + timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(), + parents: Box::new([]), + } + } + + #[test] + fn test_repository_log() { + let test_path = "/tmp/test_log_repo"; + + // Clean up if exists + if Path::new(test_path).exists() { + fs::remove_dir_all(test_path).unwrap(); + } + + // Create a repository with some commits + let repo = Repository::init(test_path, false).unwrap(); + + // Create initial commit + std::fs::write(format!("{}/test1.txt", test_path), "content1").unwrap(); + repo.add(&["test1.txt"]).unwrap(); + let _hash1 = repo.commit("First commit").unwrap(); + + // Create second commit + std::fs::write(format!("{}/test2.txt", test_path), "content2").unwrap(); + repo.add(&["test2.txt"]).unwrap(); + let _hash2 = repo.commit("Second commit").unwrap(); + + // Test log functionality + let log = repo.log().unwrap(); + assert_eq!(log.len(), 2); + + let recent = repo.recent_commits(1).unwrap(); + assert_eq!(recent.len(), 1); + assert_eq!(recent.first().unwrap().message.subject, "Second commit"); + + // Clean up + fs::remove_dir_all(test_path).unwrap(); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5affd7f..983d230 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,9 @@ pub mod add; +pub mod branch; pub mod commit; +pub mod log; pub mod status; -pub use status::{FileStatus, GitStatus}; +pub use branch::{Branch, BranchList, BranchType}; +pub use log::{Author, Commit, CommitDetails, CommitLog, CommitMessage, LogOptions}; +pub use status::{FileEntry, GitStatus, IndexStatus, WorktreeStatus}; diff --git a/src/commands/status.rs b/src/commands/status.rs index 43a210b..024d0d2 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,79 +1,198 @@ use crate::utils::git; use crate::{Repository, Result}; +use std::fmt; +use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum FileStatus { +pub enum IndexStatus { + Clean, Modified, Added, Deleted, Renamed, Copied, +} + +impl IndexStatus { + /// Convert a git porcelain index character to IndexStatus + pub const fn from_char(c: char) -> Self { + match c { + 'M' => Self::Modified, + 'A' => Self::Added, + 'D' => Self::Deleted, + 'R' => Self::Renamed, + 'C' => Self::Copied, + _ => Self::Clean, + } + } + + /// Convert IndexStatus to its git porcelain character representation + pub const fn to_char(&self) -> char { + match self { + Self::Clean => ' ', + Self::Modified => 'M', + Self::Added => 'A', + Self::Deleted => 'D', + Self::Renamed => 'R', + Self::Copied => 'C', + } + } +} + +impl fmt::Display for IndexStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_char()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WorktreeStatus { + Clean, + Modified, + Deleted, Untracked, Ignored, } +impl WorktreeStatus { + /// Convert a git porcelain worktree character to WorktreeStatus + pub const fn from_char(c: char) -> Self { + match c { + 'M' => Self::Modified, + 'D' => Self::Deleted, + '?' => Self::Untracked, + '!' => Self::Ignored, + _ => Self::Clean, + } + } + + /// Convert WorktreeStatus to its git porcelain character representation + pub const fn to_char(&self) -> char { + match self { + Self::Clean => ' ', + Self::Modified => 'M', + Self::Deleted => 'D', + Self::Untracked => '?', + Self::Ignored => '!', + } + } +} + +impl fmt::Display for WorktreeStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_char()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FileEntry { + pub path: PathBuf, + pub index_status: IndexStatus, + pub worktree_status: WorktreeStatus, +} + #[derive(Debug, Clone, PartialEq)] pub struct GitStatus { - pub files: Box<[(FileStatus, String)]>, + pub entries: Box<[FileEntry]>, } impl GitStatus { pub fn is_clean(&self) -> bool { - self.files.is_empty() + self.entries.is_empty() } pub fn has_changes(&self) -> bool { !self.is_clean() } - /// Get all files with a specific status - pub fn files_with_status(&self, status: FileStatus) -> Vec<&String> { - self.files + // New API methods for staged/unstaged files + /// Get all files that have changes in the index (staged) + pub fn staged_files(&self) -> impl Iterator + '_ { + self.entries .iter() - .filter_map(|(s, f)| if *s == status { Some(f) } else { None }) - .collect() + .filter(|entry| !matches!(entry.index_status, IndexStatus::Clean)) } - /// Get all modified files - pub fn modified_files(&self) -> Vec<&String> { - self.files_with_status(FileStatus::Modified) + /// Get all files that have changes in the working tree (unstaged) + pub fn unstaged_files(&self) -> impl Iterator + '_ { + self.entries + .iter() + .filter(|entry| !matches!(entry.worktree_status, WorktreeStatus::Clean)) + } + + /// Get all untracked files (new API) + pub fn untracked_entries(&self) -> impl Iterator + '_ { + self.entries + .iter() + .filter(|entry| matches!(entry.worktree_status, WorktreeStatus::Untracked)) + } + + /// Get all ignored files + pub fn ignored_files(&self) -> impl Iterator + '_ { + self.entries + .iter() + .filter(|entry| matches!(entry.worktree_status, WorktreeStatus::Ignored)) } - /// Get all untracked files - pub fn untracked_files(&self) -> Vec<&String> { - self.files_with_status(FileStatus::Untracked) + /// Get files with specific index status + pub fn files_with_index_status( + &self, + status: IndexStatus, + ) -> impl Iterator + '_ { + self.entries + .iter() + .filter(move |entry| entry.index_status == status) + } + + /// Get files with specific worktree status + pub fn files_with_worktree_status( + &self, + status: WorktreeStatus, + ) -> impl Iterator + '_ { + self.entries + .iter() + .filter(move |entry| entry.worktree_status == status) + } + + /// Get all file entries + pub fn entries(&self) -> &[FileEntry] { + &self.entries } fn parse_porcelain_output(output: &str) -> Self { - let mut files = Vec::new(); + let mut entries = Vec::new(); for line in output.lines() { if line.len() < 3 { continue; } - let index_status = line.chars().nth(0).unwrap_or(' '); - let worktree_status = line.chars().nth(1).unwrap_or(' '); + let index_char = line.chars().nth(0).unwrap_or(' '); + let worktree_char = line.chars().nth(1).unwrap_or(' '); let filename = line[3..].to_string(); + let path = PathBuf::from(&filename); - let file_status = match (index_status, worktree_status) { - ('M', _) | (_, 'M') => Some(FileStatus::Modified), - ('A', _) => Some(FileStatus::Added), - ('D', _) => Some(FileStatus::Deleted), - ('R', _) => Some(FileStatus::Renamed), - ('C', _) => Some(FileStatus::Copied), - ('?', '?') => Some(FileStatus::Untracked), - ('!', '!') => Some(FileStatus::Ignored), - _ => None, - }; + let index_status = IndexStatus::from_char(index_char); + let worktree_status = WorktreeStatus::from_char(worktree_char); - if let Some(fs) = file_status { - files.push((fs, filename)); + // Skip entries that are completely clean + if matches!(index_status, IndexStatus::Clean) + && matches!(worktree_status, WorktreeStatus::Clean) + { + continue; } + + let entry = FileEntry { + path, + index_status, + worktree_status, + }; + + entries.push(entry); } Self { - files: files.into_boxed_slice(), + entries: entries.into_boxed_slice(), } } } @@ -103,30 +222,48 @@ mod tests { let output = "M modified.txt\nA added.txt\nD deleted.txt\n?? untracked.txt\n"; let status = GitStatus::parse_porcelain_output(output); - assert_eq!(status.files.len(), 4); - assert!( - status - .files - .contains(&(FileStatus::Modified, "modified.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Added, "added.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Deleted, "deleted.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Untracked, "untracked.txt".to_string())) - ); - - assert_eq!(status.modified_files(), vec![&"modified.txt".to_string()]); - assert_eq!(status.untracked_files(), vec![&"untracked.txt".to_string()]); + assert_eq!(status.entries.len(), 4); + + // Find entries by path for testing + let modified_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("modified.txt")) + .unwrap(); + assert_eq!(modified_entry.index_status, IndexStatus::Modified); + assert_eq!(modified_entry.worktree_status, WorktreeStatus::Clean); + + let added_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("added.txt")) + .unwrap(); + assert_eq!(added_entry.index_status, IndexStatus::Added); + assert_eq!(added_entry.worktree_status, WorktreeStatus::Clean); + + let deleted_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("deleted.txt")) + .unwrap(); + assert_eq!(deleted_entry.index_status, IndexStatus::Deleted); + assert_eq!(deleted_entry.worktree_status, WorktreeStatus::Clean); + + let untracked_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("untracked.txt")) + .unwrap(); + assert_eq!(untracked_entry.index_status, IndexStatus::Clean); + assert_eq!(untracked_entry.worktree_status, WorktreeStatus::Untracked); + + // Test new API methods + let staged_files: Vec<_> = status.staged_files().collect(); + assert_eq!(staged_files.len(), 3); // modified, added, deleted are staged + + let untracked_files: Vec<_> = status.untracked_entries().collect(); + assert_eq!(untracked_files.len(), 1); + assert_eq!(untracked_files[0].path.to_str(), Some("untracked.txt")); assert!(!status.is_clean()); assert!(status.has_changes()); @@ -139,9 +276,9 @@ mod tests { assert!(status.is_clean()); assert!(!status.has_changes()); - assert_eq!(status.files.len(), 0); - assert!(status.modified_files().is_empty()); - assert!(status.untracked_files().is_empty()); + assert_eq!(status.entries.len(), 0); + assert_eq!(status.staged_files().count(), 0); + assert_eq!(status.untracked_entries().count(), 0); } #[test] @@ -170,17 +307,21 @@ mod tests { let output = "\n\nM valid.txt\nXX\n \nA another.txt\n"; let status = GitStatus::parse_porcelain_output(output); - assert_eq!(status.files.len(), 2); - assert!( - status - .files - .contains(&(FileStatus::Modified, "valid.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Added, "another.txt".to_string())) - ); + assert_eq!(status.entries.len(), 2); + + let valid_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("valid.txt")) + .unwrap(); + assert_eq!(valid_entry.index_status, IndexStatus::Modified); + + let another_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("another.txt")) + .unwrap(); + assert_eq!(another_entry.index_status, IndexStatus::Added); } #[test] @@ -188,42 +329,56 @@ mod tests { let output = "M modified.txt\nA added.txt\nD deleted.txt\nR renamed.txt\nC copied.txt\n?? untracked.txt\n!! ignored.txt\n"; let status = GitStatus::parse_porcelain_output(output); - assert_eq!(status.files.len(), 7); - assert!( - status - .files - .contains(&(FileStatus::Modified, "modified.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Added, "added.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Deleted, "deleted.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Renamed, "renamed.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Copied, "copied.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Untracked, "untracked.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Ignored, "ignored.txt".to_string())) - ); + assert_eq!(status.entries.len(), 7); + + let modified = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("modified.txt")) + .unwrap(); + assert_eq!(modified.index_status, IndexStatus::Modified); + + let added = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("added.txt")) + .unwrap(); + assert_eq!(added.index_status, IndexStatus::Added); + + let deleted = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("deleted.txt")) + .unwrap(); + assert_eq!(deleted.index_status, IndexStatus::Deleted); + + let renamed = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("renamed.txt")) + .unwrap(); + assert_eq!(renamed.index_status, IndexStatus::Renamed); + + let copied = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("copied.txt")) + .unwrap(); + assert_eq!(copied.index_status, IndexStatus::Copied); + + let untracked = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("untracked.txt")) + .unwrap(); + assert_eq!(untracked.worktree_status, WorktreeStatus::Untracked); + + let ignored = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("ignored.txt")) + .unwrap(); + assert_eq!(ignored.worktree_status, WorktreeStatus::Ignored); } #[test] @@ -231,12 +386,11 @@ mod tests { let output = " M worktree_modified.txt\n"; let status = GitStatus::parse_porcelain_output(output); - assert_eq!(status.files.len(), 1); - assert!( - status - .files - .contains(&(FileStatus::Modified, "worktree_modified.txt".to_string())) - ); + assert_eq!(status.entries.len(), 1); + let entry = &status.entries[0]; + assert_eq!(entry.path.to_str(), Some("worktree_modified.txt")); + assert_eq!(entry.index_status, IndexStatus::Clean); + assert_eq!(entry.worktree_status, WorktreeStatus::Modified); } #[test] @@ -244,51 +398,152 @@ mod tests { let output = "XY unknown.txt\nZ another_unknown.txt\n"; let status = GitStatus::parse_porcelain_output(output); - // Unknown statuses should be ignored - assert_eq!(status.files.len(), 0); + // Unknown statuses should be treated as clean/clean and ignored + assert_eq!(status.entries.len(), 0); } #[test] - fn test_file_status_equality() { - assert_eq!(FileStatus::Modified, FileStatus::Modified); - assert_ne!(FileStatus::Modified, FileStatus::Added); - assert_eq!(FileStatus::Untracked, FileStatus::Untracked); + fn test_index_status_equality() { + assert_eq!(IndexStatus::Modified, IndexStatus::Modified); + assert_ne!(IndexStatus::Modified, IndexStatus::Added); + assert_eq!(IndexStatus::Clean, IndexStatus::Clean); } #[test] - fn test_file_status_clone() { - let status = FileStatus::Modified; - let cloned = status.clone(); - assert_eq!(status, cloned); + fn test_worktree_status_equality() { + assert_eq!(WorktreeStatus::Modified, WorktreeStatus::Modified); + assert_ne!(WorktreeStatus::Modified, WorktreeStatus::Untracked); + assert_eq!(WorktreeStatus::Clean, WorktreeStatus::Clean); } #[test] - fn test_file_status_debug() { - let status = FileStatus::Modified; - let debug_str = format!("{:?}", status); - assert_eq!(debug_str, "Modified"); + fn test_index_status_char_conversion() { + // Test from_char + assert_eq!(IndexStatus::from_char('M'), IndexStatus::Modified); + assert_eq!(IndexStatus::from_char('A'), IndexStatus::Added); + assert_eq!(IndexStatus::from_char('D'), IndexStatus::Deleted); + assert_eq!(IndexStatus::from_char('R'), IndexStatus::Renamed); + assert_eq!(IndexStatus::from_char('C'), IndexStatus::Copied); + assert_eq!(IndexStatus::from_char(' '), IndexStatus::Clean); + assert_eq!(IndexStatus::from_char('X'), IndexStatus::Clean); // unknown char + + // Test to_char + assert_eq!(IndexStatus::Modified.to_char(), 'M'); + assert_eq!(IndexStatus::Added.to_char(), 'A'); + assert_eq!(IndexStatus::Deleted.to_char(), 'D'); + assert_eq!(IndexStatus::Renamed.to_char(), 'R'); + assert_eq!(IndexStatus::Copied.to_char(), 'C'); + assert_eq!(IndexStatus::Clean.to_char(), ' '); + } + + #[test] + fn test_worktree_status_char_conversion() { + // Test from_char + assert_eq!(WorktreeStatus::from_char('M'), WorktreeStatus::Modified); + assert_eq!(WorktreeStatus::from_char('D'), WorktreeStatus::Deleted); + assert_eq!(WorktreeStatus::from_char('?'), WorktreeStatus::Untracked); + assert_eq!(WorktreeStatus::from_char('!'), WorktreeStatus::Ignored); + assert_eq!(WorktreeStatus::from_char(' '), WorktreeStatus::Clean); + assert_eq!(WorktreeStatus::from_char('X'), WorktreeStatus::Clean); // unknown char + + // Test to_char + assert_eq!(WorktreeStatus::Modified.to_char(), 'M'); + assert_eq!(WorktreeStatus::Deleted.to_char(), 'D'); + assert_eq!(WorktreeStatus::Untracked.to_char(), '?'); + assert_eq!(WorktreeStatus::Ignored.to_char(), '!'); + assert_eq!(WorktreeStatus::Clean.to_char(), ' '); + } + + #[test] + fn test_bidirectional_char_conversion() { + // Test that from_char(to_char(x)) == x for IndexStatus + for status in [ + IndexStatus::Clean, + IndexStatus::Modified, + IndexStatus::Added, + IndexStatus::Deleted, + IndexStatus::Renamed, + IndexStatus::Copied, + ] { + assert_eq!(IndexStatus::from_char(status.to_char()), status); + } + + // Test that from_char(to_char(x)) == x for WorktreeStatus + for status in [ + WorktreeStatus::Clean, + WorktreeStatus::Modified, + WorktreeStatus::Deleted, + WorktreeStatus::Untracked, + WorktreeStatus::Ignored, + ] { + assert_eq!(WorktreeStatus::from_char(status.to_char()), status); + } + } + + #[test] + fn test_status_display() { + // Test IndexStatus Display + assert_eq!(format!("{}", IndexStatus::Modified), "M"); + assert_eq!(format!("{}", IndexStatus::Added), "A"); + assert_eq!(format!("{}", IndexStatus::Clean), " "); + + // Test WorktreeStatus Display + assert_eq!(format!("{}", WorktreeStatus::Modified), "M"); + assert_eq!(format!("{}", WorktreeStatus::Untracked), "?"); + assert_eq!(format!("{}", WorktreeStatus::Clean), " "); + } + + #[test] + fn test_file_entry_equality() { + let entry1 = FileEntry { + path: PathBuf::from("test.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }; + let entry2 = FileEntry { + path: PathBuf::from("test.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }; + let entry3 = FileEntry { + path: PathBuf::from("other.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }; + + assert_eq!(entry1, entry2); + assert_ne!(entry1, entry3); } #[test] fn test_git_status_equality() { - let files1 = vec![ - (FileStatus::Modified, "file1.txt".to_string()), - (FileStatus::Added, "file2.txt".to_string()), - ]; - let files2 = vec![ - (FileStatus::Modified, "file1.txt".to_string()), - (FileStatus::Added, "file2.txt".to_string()), + let entries1 = vec![ + FileEntry { + path: PathBuf::from("file1.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }, + FileEntry { + path: PathBuf::from("file2.txt"), + index_status: IndexStatus::Added, + worktree_status: WorktreeStatus::Clean, + }, ]; - let files3 = vec![(FileStatus::Modified, "different.txt".to_string())]; + let entries2 = entries1.clone(); + let entries3 = vec![FileEntry { + path: PathBuf::from("different.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }]; let status1 = GitStatus { - files: files1.into_boxed_slice(), + entries: entries1.into_boxed_slice(), }; let status2 = GitStatus { - files: files2.into_boxed_slice(), + entries: entries2.into_boxed_slice(), }; let status3 = GitStatus { - files: files3.into_boxed_slice(), + entries: entries3.into_boxed_slice(), }; assert_eq!(status1, status2); @@ -297,9 +552,13 @@ mod tests { #[test] fn test_git_status_clone() { - let files = vec![(FileStatus::Modified, "file1.txt".to_string())]; + let entries = vec![FileEntry { + path: PathBuf::from("file1.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }]; let status1 = GitStatus { - files: files.into_boxed_slice(), + entries: entries.into_boxed_slice(), }; let status2 = status1.clone(); @@ -308,9 +567,13 @@ mod tests { #[test] fn test_git_status_debug() { - let files = vec![(FileStatus::Modified, "file1.txt".to_string())]; + let entries = vec![FileEntry { + path: PathBuf::from("file1.txt"), + index_status: IndexStatus::Modified, + worktree_status: WorktreeStatus::Clean, + }]; let status = GitStatus { - files: files.into_boxed_slice(), + entries: entries.into_boxed_slice(), }; let debug_str = format!("{:?}", status); @@ -320,27 +583,34 @@ mod tests { } #[test] - fn test_files_with_status_multiple_same_status() { - let output = "M file1.txt\nM file2.txt\nA file3.txt\n"; + fn test_new_api_methods() { + let output = "M file1.txt\nMM file2.txt\nA file3.txt\n D file4.txt\n?? file5.txt\n"; let status = GitStatus::parse_porcelain_output(output); - let modified = status.files_with_status(FileStatus::Modified); - assert_eq!(modified.len(), 2); - assert!(modified.contains(&&"file1.txt".to_string())); - assert!(modified.contains(&&"file2.txt".to_string())); - - let added = status.files_with_status(FileStatus::Added); - assert_eq!(added.len(), 1); - assert!(added.contains(&&"file3.txt".to_string())); - } - - #[test] - fn test_files_with_status_no_matches() { - let output = "M file1.txt\nA file2.txt\n"; - let status = GitStatus::parse_porcelain_output(output); - - let deleted = status.files_with_status(FileStatus::Deleted); - assert!(deleted.is_empty()); + // Test staged files (index changes) + let staged: Vec<_> = status.staged_files().collect(); + assert_eq!(staged.len(), 3); // M, MM, A (not D since it has clean index status) + + // Test unstaged files (worktree changes) + let unstaged: Vec<_> = status.unstaged_files().collect(); + assert_eq!(unstaged.len(), 3); // MM, D, ?? + + // Test untracked files + let untracked: Vec<_> = status.untracked_entries().collect(); + assert_eq!(untracked.len(), 1); + assert_eq!(untracked[0].path.to_str(), Some("file5.txt")); + + // Test index status filtering + let modified_in_index: Vec<_> = status + .files_with_index_status(IndexStatus::Modified) + .collect(); + assert_eq!(modified_in_index.len(), 2); // file1.txt, file2.txt + + // Test worktree status filtering + let modified_in_worktree: Vec<_> = status + .files_with_worktree_status(WorktreeStatus::Modified) + .collect(); + assert_eq!(modified_in_worktree.len(), 1); // file2.txt } #[test] @@ -348,17 +618,21 @@ mod tests { let output = "M file with spaces.txt\nA another file.txt\n"; let status = GitStatus::parse_porcelain_output(output); - assert_eq!(status.files.len(), 2); - assert!( - status - .files - .contains(&(FileStatus::Modified, "file with spaces.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Added, "another file.txt".to_string())) - ); + assert_eq!(status.entries.len(), 2); + + let spaced_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("file with spaces.txt")) + .unwrap(); + assert_eq!(spaced_entry.index_status, IndexStatus::Modified); + + let another_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("another file.txt")) + .unwrap(); + assert_eq!(another_entry.index_status, IndexStatus::Added); } #[test] @@ -366,16 +640,20 @@ mod tests { let output = "M 测试文件.txt\nA 🚀rocket.txt\n"; let status = GitStatus::parse_porcelain_output(output); - assert_eq!(status.files.len(), 2); - assert!( - status - .files - .contains(&(FileStatus::Modified, "测试文件.txt".to_string())) - ); - assert!( - status - .files - .contains(&(FileStatus::Added, "🚀rocket.txt".to_string())) - ); + assert_eq!(status.entries.len(), 2); + + let chinese_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("测试文件.txt")) + .unwrap(); + assert_eq!(chinese_entry.index_status, IndexStatus::Modified); + + let rocket_entry = status + .entries + .iter() + .find(|e| e.path.to_str() == Some("🚀rocket.txt")) + .unwrap(); + assert_eq!(rocket_entry.index_status, IndexStatus::Added); } } diff --git a/src/lib.rs b/src/lib.rs index 37ce96f..9908609 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,10 @@ mod repository; mod types; mod utils; -pub use commands::{FileStatus, GitStatus}; +pub use commands::{ + Author, Branch, BranchList, BranchType, Commit, CommitDetails, CommitLog, CommitMessage, + FileEntry, GitStatus, IndexStatus, LogOptions, WorktreeStatus, +}; pub use error::{GitError, Result}; pub use repository::Repository; pub use types::Hash; diff --git a/src/types.rs b/src/types.rs index ad42669..6fb7df4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,5 @@ /// Represents a Git object hash (commit, tree, blob, etc.). -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Hash(pub String); impl Hash {