diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ab8d5b7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + description: Format files with rustfmt. + entry: cargo fmt --all + language: system + types: [rust] + pass_filenames: false + + - id: cargo-clippy + name: cargo clippy + description: Lint rust sources + entry: cargo clippy --all-targets --all-features -- -D warnings + language: system + types: [rust] + pass_filenames: false + + - id: cargo-test + name: cargo test + description: Run cargo test + entry: cargo test --all + language: system + types: [rust] + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 75383a9..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,36 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -## [2.0.0] - 2024-09-27 - -### Added -- Interactive tabular editor for range mode with background highlighting -- Cross-platform git config integration for default email/name values -- Interactive prompts with faded git config defaults -- Background color highlighting for selection cursor -- Terminal formatting fixes for range mode -- Default CLI behavior changed to run full rewrite mode instead of help -- Comprehensive simulation mode with detailed change preview -- Support for selective field editing in range mode - -### Changed -- Default behavior: `cargo run` now executes full history rewrite instead of showing help -- Range mode UI: replaced bracket `[]` indicators with colored backgrounds -- Terminal handling: fixed raw mode timing issues -- Prompts: git config values shown in faded color with Enter to accept - -### Fixed -- Terminal display corruption in range mode -- Cross-platform git config file reading -- Merge conflicts resolution -- Cross-compilation issues with OpenSSL dependency - -### Technical -- Added 64 unit tests + 15 integration tests -- Improved cross-platform compatibility -- Enhanced error handling and validation -- Updated dependencies for better performance - -## [1.8.0] - Previous releases -See Git history for previous release notes. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f20f93e..85e058f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,17 @@ rustup component add clippy rustfmt cargo fetch ``` +### Install Pre-Commit Hooks + +We use [`pre-commit`](https://pre-commit.com/) to automatically run formatters, linters, and tests before each commit. This prevents failing builds in CI. + +1. Ensure `pre-commit` is installed on your system (`pip install pre-commit`, `brew install pre-commit`, etc.) +2. Install the git hook scripts: + +```bash +pre-commit install +``` + ### Build the Project ```bash diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 65af83e..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -# MIT License - -Copyright (c) 2023 Git-Editor - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index e776eaa..2527cb9 100644 --- a/README.md +++ b/README.md @@ -185,44 +185,6 @@ The tool ensures that: ## Development -### Project Structure - -``` -git-editor/ -├── src/ -│ ├── main.rs # Entry point and operation mode handling -│ ├── args.rs # Command line argument parsing and Git URL cloning -│ ├── rewrite/ # Git history rewriting logic -│ │ ├── mod.rs # Module definition -│ │ ├── rewrite_all.rs # Full repository history rewriting -│ │ ├── rewrite_specific.rs # Interactive commit selection -│ │ └── rewrite_range.rs # Interactive range selection and editing -│ ├── utils/ # Utility modules -│ │ ├── mod.rs # Module definition -│ │ ├── types.rs # Type definitions and custom Result -│ │ ├── validator.rs # Input validation for all modes -│ │ ├── datetime.rs # Date and time functions -│ │ ├── commit_history.rs # Git commit operations -│ │ ├── prompt.rs # Interactive user prompts -│ │ ├── git_clone.rs # Git URL detection and repository cloning -│ │ ├── git_config.rs # Git configuration reading (cross-platform) -│ │ └── simulation.rs # Simulation mode and preview functionality -│ └── lib.rs # Library interface -├── tests/ -│ └── integration_tests.rs # Comprehensive integration tests (15 tests) -├── .github/workflows/ # CI/CD pipelines -│ ├── ci-cd.yaml # Main build and test pipeline -│ ├── release.yaml # Multi-platform release automation -│ ├── coverage.yml # Code coverage reporting -│ └── multi-platform-test.yml # Cross-platform testing -├── Cargo.toml # Project dependencies and metadata -├── Dockerfile # Docker configuration -├── Makefile # Build automation and development commands -├── CLAUDE.md # Development guidance for AI assistants -├── CHANGELOG.md # Version history and release notes -└── LICENSE # MIT license -``` - ### Testing The project includes comprehensive test coverage with both unit and integration tests: diff --git a/src/args.rs b/src/args.rs index abfd3e8..894f3a3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -80,6 +80,12 @@ pub struct Args { #[arg(long = "time", help = "Edit only timestamps in range mode (-x)")] pub edit_time: bool, + #[arg( + long = "skip-range-check", + help = "Skip the minimum date range check (allows tightly packed commit timestamps)" + )] + pub skip_range_check: bool, + #[arg( long = "docs", help = "Open comprehensive documentation in the browser" @@ -217,6 +223,7 @@ impl Args { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -297,6 +304,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -327,6 +335,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -352,6 +361,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -377,6 +387,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -404,6 +415,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -430,6 +442,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -454,6 +467,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -478,6 +492,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -506,6 +521,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: true, _temp_dir: None, }; @@ -533,6 +549,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: true, _temp_dir: None, }; diff --git a/src/rewrite/rewrite_range.rs b/src/rewrite/rewrite_range.rs index d39f5a0..6a63467 100644 --- a/src/rewrite/rewrite_range.rs +++ b/src/rewrite/rewrite_range.rs @@ -295,15 +295,11 @@ impl InteractiveTable { fn handle_navigation_key_input(&mut self, key: KeyCode) -> Result { match key { - KeyCode::Up => { - if self.current_row > 0 { - self.current_row -= 1; - } + KeyCode::Up if self.current_row > 0 => { + self.current_row -= 1; } - KeyCode::Down => { - if self.current_row < self.commits.len() - 1 { - self.current_row += 1; - } + KeyCode::Down if self.current_row < self.commits.len() - 1 => { + self.current_row += 1; } KeyCode::Left => { self.move_to_prev_editable_column(); @@ -319,17 +315,13 @@ impl InteractiveTable { // Right (vim-style) self.move_to_next_editable_column(); } - KeyCode::Char('k') => { + KeyCode::Char('k') if self.current_row > 0 => { // Up (vim-style) - if self.current_row > 0 { - self.current_row -= 1; - } + self.current_row -= 1; } - KeyCode::Char('j') => { + KeyCode::Char('j') if self.current_row < self.commits.len() - 1 => { // Down (vim-style) - if self.current_row < self.commits.len() - 1 { - self.current_row += 1; - } + self.current_row += 1; } KeyCode::Enter => { self.start_editing(); @@ -919,13 +911,18 @@ fn apply_interactive_range_changes( revwalk.push_head()?; revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?; let mut orig_oids: Vec<_> = revwalk.filter_map(|id| id.ok()).collect(); + let total_commits = orig_oids.len(); orig_oids.reverse(); - // Create a map for quick lookup of edited commits + // Create a map for quick lookup of edited commits. + // commit_edit.index is in display order (newest-first, from get_commit_history), + // but orig_oids is now in chronological order (oldest-first) after the reverse. + // Convert: chronological_idx = total_commits - 1 - display_idx let mut edit_map: HashMap = HashMap::new(); for commit_edit in edited_commits { if commit_edit.is_modified { - edit_map.insert(commit_edit.index, commit_edit); + let chronological_idx = total_commits - 1 - commit_edit.index; + edit_map.insert(chronological_idx, commit_edit); } } @@ -1171,6 +1168,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -1192,4 +1190,86 @@ mod tests { let timestamps = generate_range_timestamps(start_time, end_time, 3); assert_eq!(timestamps.len(), 3); } + + #[test] + fn test_apply_range_changes_correct_commit_ordering() { + // This test verifies that editing commit at display index 0 (newest) + // actually modifies the newest commit, not the oldest. + let (_temp_dir, repo_path) = create_test_repo_with_commits(); + let args = Args { + repo_path: Some(repo_path.clone()), + email: None, + name: None, + start: None, + end: None, + show_history: false, + pick_specific_commits: false, + range: true, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: false, + docs: false, + _temp_dir: None, + }; + + // get_commit_history returns newest-first + let commits = get_commit_history(&args, false).unwrap(); + assert_eq!(commits.len(), 5); + assert_eq!(commits[0].message, "Commit 5"); // newest + assert_eq!(commits[4].message, "Commit 1"); // oldest + + // Simulate editing only the newest commit (display index 0) + let new_timestamp = + NaiveDateTime::parse_from_str("2099-06-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + + let mut edited_commits: Vec = commits + .iter() + .enumerate() + .map(|(i, c)| CommitEdit { + index: i, + original: c.clone(), + author_name: c.author_name.clone(), + author_email: c.author_email.clone(), + timestamp: c.timestamp, + message: c.message.clone(), + is_modified: false, + modifications: ModificationFlags::default(), + }) + .collect(); + + // Mark only index 0 (newest = "Commit 5") as modified + edited_commits[0].timestamp = new_timestamp; + edited_commits[0].is_modified = true; + edited_commits[0].modifications.timestamp_changed = true; + + // Apply changes + apply_interactive_range_changes(&args, &commits, &edited_commits).unwrap(); + + // Re-read and verify + let updated_commits = get_commit_history(&args, false).unwrap(); + assert_eq!(updated_commits.len(), 5); + + // The newest commit (index 0, "Commit 5") should have the new timestamp + assert_eq!( + updated_commits[0].timestamp, new_timestamp, + "Newest commit should have the edited timestamp" + ); + + // The oldest commit (index 4, "Commit 1") should NOT have the new timestamp + assert_ne!( + updated_commits[4].timestamp, new_timestamp, + "Oldest commit should NOT have the edited timestamp" + ); + + // All other commits should retain their original timestamps + for (i, commit) in updated_commits.iter().enumerate().skip(1) { + assert_ne!( + commit.timestamp, new_timestamp, + "Commit at index {i} should not have the edited timestamp" + ); + } + } } diff --git a/src/rewrite/rewrite_specific.rs b/src/rewrite/rewrite_specific.rs index 4daca83..7c4a992 100644 --- a/src/rewrite/rewrite_specific.rs +++ b/src/rewrite/rewrite_specific.rs @@ -457,6 +457,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -540,6 +541,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -570,6 +572,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; diff --git a/src/utils/commit_history.rs b/src/utils/commit_history.rs index 35b7555..bda1bed 100644 --- a/src/utils/commit_history.rs +++ b/src/utils/commit_history.rs @@ -194,6 +194,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -227,6 +228,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -255,6 +257,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -296,6 +299,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -321,6 +325,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -346,6 +351,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; diff --git a/src/utils/datetime.rs b/src/utils/datetime.rs index 0bbbd58..a5a89c0 100644 --- a/src/utils/datetime.rs +++ b/src/utils/datetime.rs @@ -42,14 +42,51 @@ pub fn generate_timestamps(args: &mut Args) -> Result> { let min_span = Duration::hours(3 * (total_commits as i64 - 1)); let total_span = end_dt - start_dt; - if total_span < min_span { + if total_span < min_span && !args.skip_range_check { return Err(format!( - "Date range too small for {} commits. Need at least {} hours between start and end dates.", + "Date range too small for {} commits. Need at least {} hours between start and end dates.\n\ + Tip: Pass --skip-range-check to skip this validation and distribute commits evenly across the given range.", total_commits, min_span.num_hours() ).into()); } + if total_span < min_span { + // --skip-range-check: randomly distribute timestamps with 5-min minimum gap + let mut timestamps = Vec::with_capacity(total_commits); + if total_commits == 1 { + timestamps.push(start_dt); + } else { + let min_gap = Duration::minutes(5); + let min_total = min_gap * (total_commits as i32 - 1); + if total_span < min_total { + // Not enough room even for 5-min gaps - fall back to even spacing + let step = total_span / (total_commits as i32 - 1); + for i in 0..total_commits { + timestamps.push(start_dt + step * i as i32); + } + } else { + // Random distribution with 5-min minimum gap + let slack = total_span - min_total; + let mut rng = rand::rng(); + let mut weights: Vec = + (0..(total_commits - 1)).map(|_| rng.random()).collect(); + let sum: f64 = weights.iter().sum(); + for w in &mut weights { + *w = (*w / sum) * slack.num_seconds() as f64; + } + let mut current = start_dt; + timestamps.push(current); + for w in &weights { + let secs = w.round() as i64 + min_gap.num_seconds(); + current += Duration::seconds(secs); + timestamps.push(current); + } + } + } + return Ok(timestamps); + } + let slack = total_span - min_span; let mut rng = rand::rng(); let mut weights: Vec = (0..(total_commits - 1)).map(|_| rng.random()).collect(); @@ -141,6 +178,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -166,6 +204,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -202,6 +241,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -216,4 +256,198 @@ mod tests { assert!(timestamps[i] >= timestamps[i - 1]); } } + + fn create_test_repo_with_n_commits(n: usize) -> (TempDir, String) { + let temp_dir = TempDir::new().unwrap(); + let repo_path = temp_dir.path().to_str().unwrap().to_string(); + let repo = git2::Repository::init(&repo_path).unwrap(); + + for i in 1..=n { + let file_path = temp_dir.path().join(format!("file{i}.txt")); + fs::write(&file_path, format!("content {i}")).unwrap(); + + let mut index = repo.index().unwrap(); + index + .add_path(std::path::Path::new(&format!("file{i}.txt"))) + .unwrap(); + index.write().unwrap(); + + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + + let sig = git2::Signature::new( + "Test User", + "test@example.com", + &git2::Time::new(1234567890 + i as i64 * 3600, 0), + ) + .unwrap(); + + let parents = if i == 1 { + vec![] + } else { + let head = repo.head().unwrap(); + let parent_commit = head.peel_to_commit().unwrap(); + vec![parent_commit] + }; + + repo.commit( + Some("HEAD"), + &sig, + &sig, + &format!("Commit {i}"), + &tree, + &parents.iter().collect::>(), + ) + .unwrap(); + } + + (temp_dir, repo_path) + } + + #[test] + fn test_small_range_error_mentions_skip_flag() { + let (_temp_dir, repo_path) = create_test_repo_with_n_commits(5); + let mut args = Args { + repo_path: Some(repo_path), + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + start: Some("2023-01-01 00:00:00".to_string()), + end: Some("2023-01-01 01:00:00".to_string()), // 1 hour for 5 commits + show_history: false, + pick_specific_commits: false, + range: false, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: false, + docs: false, + _temp_dir: None, + }; + + let result = generate_timestamps(&mut args); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("--skip-range-check"), + "Error message should mention --skip-range-check flag, got: {err_msg}" + ); + } + + #[test] + fn test_skip_range_check_produces_correct_count() { + let (_temp_dir, repo_path) = create_test_repo_with_n_commits(5); + let mut args = Args { + repo_path: Some(repo_path), + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + start: Some("2023-01-01 00:00:00".to_string()), + end: Some("2023-01-01 01:00:00".to_string()), // 1 hour for 5 commits + show_history: false, + pick_specific_commits: false, + range: false, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: true, + docs: false, + _temp_dir: None, + }; + + let result = generate_timestamps(&mut args); + assert!( + result.is_ok(), + "skip_range_check should succeed: {:?}", + result.err() + ); + + let timestamps = result.unwrap(); + assert_eq!(timestamps.len(), 5); + } + + #[test] + fn test_skip_range_check_respects_5min_gap_and_bounds() { + let (_temp_dir, repo_path) = create_test_repo_with_n_commits(3); + let mut args = Args { + repo_path: Some(repo_path), + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + start: Some("2023-01-01 00:00:00".to_string()), + end: Some("2023-01-01 02:00:00".to_string()), // 2 hours for 3 commits (enough for 5-min gaps) + show_history: false, + pick_specific_commits: false, + range: false, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: true, + docs: false, + _temp_dir: None, + }; + + let result = generate_timestamps(&mut args); + assert!(result.is_ok()); + + let timestamps = result.unwrap(); + let start_dt = + NaiveDateTime::parse_from_str("2023-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + let end_dt = + NaiveDateTime::parse_from_str("2023-01-01 02:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + + // All timestamps should be within bounds + for ts in ×tamps { + assert!(*ts >= start_dt, "Timestamp {ts} is before start"); + assert!(*ts <= end_dt, "Timestamp {ts} is after end"); + } + + // Each consecutive gap should be >= 5 minutes + for i in 1..timestamps.len() { + let gap = timestamps[i] - timestamps[i - 1]; + assert!( + gap >= Duration::minutes(5), + "Gap between timestamps[{i}] and [{prev}] is {gap_min} mins, expected >= 5", + prev = i - 1, + gap_min = gap.num_minutes() + ); + } + + // Timestamps should be in ascending order + for i in 1..timestamps.len() { + assert!(timestamps[i] >= timestamps[i - 1]); + } + } + + #[test] + fn test_skip_range_check_single_commit() { + let (_temp_dir, repo_path) = create_test_repo(); // single commit + let mut args = Args { + repo_path: Some(repo_path), + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + start: Some("2023-01-01 00:00:00".to_string()), + end: Some("2023-01-01 00:01:00".to_string()), // 1 minute + show_history: false, + pick_specific_commits: false, + range: false, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: true, + docs: false, + _temp_dir: None, + }; + + let result = generate_timestamps(&mut args); + assert!(result.is_ok()); + + let timestamps = result.unwrap(); + assert_eq!(timestamps.len(), 1); + } } diff --git a/src/utils/simulation.rs b/src/utils/simulation.rs index 71b69fe..5381266 100644 --- a/src/utils/simulation.rs +++ b/src/utils/simulation.rs @@ -540,6 +540,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; diff --git a/src/utils/validator.rs b/src/utils/validator.rs index 3999984..99ff4a0 100644 --- a/src/utils/validator.rs +++ b/src/utils/validator.rs @@ -132,6 +132,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -157,6 +158,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -182,6 +184,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -207,6 +210,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -235,6 +239,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -260,6 +265,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -371,6 +377,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -396,6 +403,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: true, _temp_dir: None, }; @@ -421,6 +429,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: true, _temp_dir: None, }; @@ -446,6 +455,7 @@ mod tests { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: true, // Docs mode should skip all validation _temp_dir: None, }; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 141d902..8053d5a 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -75,6 +75,7 @@ fn test_show_history_mode_integration() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -115,6 +116,7 @@ fn test_pick_specific_commits_mode_integration() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -158,6 +160,7 @@ fn test_full_rewrite_mode_integration() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -211,6 +214,7 @@ fn test_mode_flag_precedence() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -239,6 +243,7 @@ fn test_invalid_repo_path_all_modes() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -261,6 +266,7 @@ fn test_invalid_repo_path_all_modes() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -283,6 +289,7 @@ fn test_invalid_repo_path_all_modes() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -311,6 +318,7 @@ fn test_full_rewrite_mode_insufficient_date_range() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -318,19 +326,109 @@ fn test_full_rewrite_mode_insufficient_date_range() { let validation_result = validate_inputs(&args); assert!(validation_result.is_ok()); - // This test would normally call process::exit(1) due to insufficient date range - // We can't easily test this without capturing the exit, so we'll test the - // logic leading up to it by checking that the date range calculation would fail - use chrono::{Duration, NaiveDateTime}; + // The error message should mention --skip-range-check + let mut args_mut = args; + let timestamp_result = generate_timestamps(&mut args_mut); + assert!(timestamp_result.is_err()); + let err_msg = timestamp_result.unwrap_err().to_string(); + assert!( + err_msg.contains("--skip-range-check"), + "Error should mention --skip-range-check, got: {err_msg}" + ); +} +#[test] +#[serial] +fn test_skip_range_check_succeeds_with_small_range() { + let (_temp_dir, repo_path) = create_test_repo_with_commits(); + + let mut args = Args { + repo_path: Some(repo_path), + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + start: Some("2023-01-01 00:00:00".to_string()), + end: Some("2023-01-01 01:00:00".to_string()), // Only 1 hour for 3 commits + show_history: false, + pick_specific_commits: false, + range: false, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: true, + docs: false, + _temp_dir: None, + }; + + let timestamp_result = generate_timestamps(&mut args); + assert!( + timestamp_result.is_ok(), + "Should succeed with --skip-range-check: {:?}", + timestamp_result.err() + ); + + let timestamps = timestamp_result.unwrap(); + assert_eq!(timestamps.len(), 3); + + // Timestamps should be in chronological order + for i in 1..timestamps.len() { + assert!(timestamps[i] >= timestamps[i - 1]); + } + + // Timestamps should be within the specified range let start_dt = - NaiveDateTime::parse_from_str("2023-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); - let end_dt = NaiveDateTime::parse_from_str("2023-01-01 01:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); - let total_span = end_dt - start_dt; - let min_span = Duration::hours(3 * (3 - 1)); // 3 commits need minimum 6 hours + chrono::NaiveDateTime::parse_from_str("2023-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + let end_dt = + chrono::NaiveDateTime::parse_from_str("2023-01-01 01:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + for ts in ×tamps { + assert!(*ts >= start_dt); + assert!(*ts <= end_dt); + } +} + +#[test] +#[serial] +fn test_skip_range_check_with_5min_minimum_gap() { + let (_temp_dir, repo_path) = create_test_repo_with_commits(); + + // 2 hours for 3 commits - enough room for 5-min gaps with random distribution + let mut args = Args { + repo_path: Some(repo_path), + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + start: Some("2023-01-01 00:00:00".to_string()), + end: Some("2023-01-01 02:00:00".to_string()), + show_history: false, + pick_specific_commits: false, + range: false, + simulate: false, + show_diff: false, + edit_message: false, + edit_author: false, + edit_time: false, + skip_range_check: true, + docs: false, + _temp_dir: None, + }; - // Verify that the date range is indeed too small - assert!(total_span < min_span); + let timestamp_result = generate_timestamps(&mut args); + assert!(timestamp_result.is_ok()); + + let timestamps = timestamp_result.unwrap(); + assert_eq!(timestamps.len(), 3); + + // Each gap should be >= 5 minutes + for i in 1..timestamps.len() { + let gap = timestamps[i] - timestamps[i - 1]; + assert!( + gap >= chrono::Duration::minutes(5), + "Gap between commit {} and {} is {} mins, expected >= 5", + i - 1, + i, + gap.num_minutes() + ); + } } #[test] @@ -352,6 +450,7 @@ fn test_full_rewrite_mode_invalid_date_format() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -380,6 +479,7 @@ fn test_workflow_show_history_then_pick_commits() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -404,6 +504,7 @@ fn test_workflow_show_history_then_pick_commits() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -436,6 +537,7 @@ fn test_simulation_mode_complete_args() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -473,6 +575,7 @@ fn test_simulation_mode_incomplete_args() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -509,6 +612,7 @@ fn test_simulation_mode_with_show_diff() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -540,6 +644,7 @@ fn test_show_diff_without_simulate_fails() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -573,6 +678,7 @@ fn test_cli_execution_simulate_incomplete_args_no_panic() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -616,6 +722,7 @@ fn test_cli_execution_simulate_complete_args_success() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, }; @@ -651,6 +758,7 @@ fn test_simulation_execution_function_missing_args() { edit_message: false, edit_author: false, edit_time: false, + skip_range_check: false, docs: false, _temp_dir: None, };