Files
g3/crates/g3-planner/tests/commit_history_ordering_test.rs
Jochen b3ac7746b9 Preserve planner history ordering and add regression guardrails
Ensure planner writes GIT COMMIT entry before invoking git commit.
Keep history entry even when git commit fails, matching summary text.
Document invariant in code comment above write_git_commit call.
Add lightweight test to assert history write precedes git::commit using
test doubles instead of a real git repository.
Investigate git history to find regression and its prior fix, and
record a short root-cause summary outside the codebase.
Reference completed_requirements_2025-12-10_16-55-05.md for details.
Reference completed_todo_2025-12-10_16-55-05.md for task tracking.
2025-12-10 16:55:24 +11:00

241 lines
10 KiB
Rust

//! Tests for the critical invariant: planner_history.txt must be written BEFORE git commit
//!
//! This test suite ensures that the ordering of history write and git commit operations
//! is maintained correctly. This is essential for audit trail purposes and post-mortem
//! analysis when commits fail.
use anyhow::Result;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
/// Helper to create a test git repository
fn setup_test_git_repo() -> Result<TempDir> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path();
// Initialize git repo
Command::new("git")
.args(["init"])
.current_dir(repo_path)
.output()?;
// Configure git user (required for commits)
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(repo_path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(repo_path)
.output()?;
// Create g3-plan directory
let plan_dir = repo_path.join("g3-plan");
fs::create_dir_all(&plan_dir)?;
// Create planner_history.txt
fs::write(plan_dir.join("planner_history.txt"), "")?;
Ok(temp_dir)
}
/// Test that history entry is written even when git commit fails due to missing files
#[test]
fn test_history_written_before_commit_on_empty_staging() {
let temp_dir = setup_test_git_repo().expect("Failed to setup test repo");
let repo_path = temp_dir.path();
let plan_dir = repo_path.join("g3-plan");
// Import necessary types
use g3_planner::planner::PlannerConfig;
use g3_planner::history;
// Create a config
let config = PlannerConfig {
codepath: repo_path.to_path_buf(),
no_git: false,
max_turns: 5,
quiet: true,
config_path: None,
};
// Write a history entry as would happen in stage_and_commit
let summary = "Test commit message";
history::write_git_commit(&plan_dir, summary).expect("Failed to write history");
// Read history file to verify entry was written
let history_content = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file");
// Verify the history entry exists
assert!(history_content.contains("GIT COMMIT"), "History should contain GIT COMMIT entry");
assert!(history_content.contains("Test commit message"), "History should contain the commit message");
// Now attempt a commit (which will fail because nothing is staged)
// This simulates the scenario where history is written but commit fails
let commit_result = g3_planner::git::commit(&config.codepath, summary, "Test description");
// The commit should fail (nothing staged)
assert!(commit_result.is_err(), "Commit should fail with nothing staged");
// But history entry should still be present
let history_after = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file after commit");
assert!(history_after.contains("GIT COMMIT"), "History should still contain GIT COMMIT entry after failed commit");
assert!(history_after.contains("Test commit message"), "History should still contain the message after failed commit");
}
/// Test successful commit flow with history written first
#[test]
fn test_history_written_before_successful_commit() {
let temp_dir = setup_test_git_repo().expect("Failed to setup test repo");
let repo_path = temp_dir.path();
let plan_dir = repo_path.join("g3-plan");
use g3_planner::history;
// Create a file to commit
let test_file = repo_path.join("test.txt");
fs::write(&test_file, "test content").expect("Failed to create test file");
// Stage the file
Command::new("git")
.args(["add", "test.txt"])
.current_dir(repo_path)
.output()
.expect("Failed to stage file");
// Write history entry BEFORE commit
let summary = "Add test file";
history::write_git_commit(&plan_dir, summary).expect("Failed to write history");
// Verify history was written
let history_before = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file");
assert!(history_before.contains("GIT COMMIT"), "History should contain GIT COMMIT before commit");
assert!(history_before.contains("Add test file"), "History should contain message before commit");
// Now make the commit
let commit_result = g3_planner::git::commit(repo_path, summary, "Test description");
assert!(commit_result.is_ok(), "Commit should succeed with staged file");
// Verify history is still there after successful commit
let history_after = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file after commit");
assert!(history_after.contains("GIT COMMIT"), "History should contain GIT COMMIT after commit");
assert!(history_after.contains("Add test file"), "History should contain message after commit");
}
/// Test the ordering invariant: history must be written before attempting the commit
/// This ensures that if the commit operation is interrupted or fails, the history entry exists
#[test]
fn test_history_ordering_invariant() {
let temp_dir = setup_test_git_repo().expect("Failed to setup test repo");
let repo_path = temp_dir.path();
let plan_dir = repo_path.join("g3-plan");
use g3_planner::history;
// Test 1: Verify history is written first, even before staging
let summary1 = "First history entry";
// Record initial history state
let history_initial = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file");
// Write history entry
history::write_git_commit(&plan_dir, summary1).expect("Failed to write history");
// Write history entry BEFORE attempting commit
let history_after_write = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file");
// Verify the history entry exists and is different from initial state
assert_ne!(history_initial, history_after_write, "History should have changed after write");
assert!(history_after_write.contains("GIT COMMIT"), "History should contain GIT COMMIT entry");
assert!(history_after_write.contains("First history entry"), "History should contain the commit message");
// This demonstrates the ordering: history is written and persisted to disk
// BEFORE any git operations are attempted. If git::commit() were to fail
// at this point (e.g., due to missing staged files, git config errors, etc.),
// the history entry would already be on disk and available for audit.
// The other tests (test_history_written_before_commit_on_empty_staging and
// test_multiple_history_entries_with_failures) verify behavior with actual failures.
// This test focuses on the invariant itself: write happens first.
}
/// Test multiple history entries with mixed success/failure
#[test]
fn test_multiple_history_entries_with_failures() {
let temp_dir = setup_test_git_repo().expect("Failed to setup test repo");
let repo_path = temp_dir.path();
let plan_dir = repo_path.join("g3-plan");
use g3_planner::history;
// First entry - will fail (nothing staged)
history::write_git_commit(&plan_dir, "Commit 1 - will fail").expect("Failed to write history");
let _ = g3_planner::git::commit(repo_path, "Commit 1 - will fail", "Desc 1");
// Second entry - will succeed
let test_file = repo_path.join("file1.txt");
fs::write(&test_file, "content 1").expect("Failed to create file");
Command::new("git")
.args(["add", "file1.txt"])
.current_dir(repo_path)
.output()
.expect("Failed to stage file");
history::write_git_commit(&plan_dir, "Commit 2 - will succeed").expect("Failed to write history");
let _ = g3_planner::git::commit(repo_path, "Commit 2 - will succeed", "Desc 2");
// Third entry - will fail (nothing staged)
history::write_git_commit(&plan_dir, "Commit 3 - will fail").expect("Failed to write history");
let _ = g3_planner::git::commit(repo_path, "Commit 3 - will fail", "Desc 3");
// Read history and verify all entries are present
let history_content = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file");
// All three attempts should be recorded, regardless of success/failure
assert!(history_content.contains("Commit 1 - will fail"), "First commit attempt should be in history");
assert!(history_content.contains("Commit 2 - will succeed"), "Second commit attempt should be in history");
assert!(history_content.contains("Commit 3 - will fail"), "Third commit attempt should be in history");
// Count the number of GIT COMMIT entries
let commit_count = history_content.matches("GIT COMMIT").count();
assert_eq!(commit_count, 3, "Should have exactly 3 GIT COMMIT entries");
}
/// Test that history entries have consistent format and timestamps
#[test]
fn test_history_entry_format() {
let temp_dir = setup_test_git_repo().expect("Failed to setup test repo");
let plan_dir = temp_dir.path().join("g3-plan");
use g3_planner::history;
// Write a history entry
let summary = "Test formatting";
history::write_git_commit(&plan_dir, summary).expect("Failed to write history");
// Read and verify format
let history_content = fs::read_to_string(plan_dir.join("planner_history.txt"))
.expect("Failed to read history file");
// Should contain timestamp (YYYY-MM-DD HH:MM:SS format)
assert!(history_content.contains("-"), "Should contain date separators");
assert!(history_content.contains(":"), "Should contain time separators");
// Should contain the entry type
assert!(history_content.contains("GIT COMMIT"), "Should contain entry type");
// Should contain the message in parentheses
assert!(history_content.contains("(Test formatting)"), "Should contain message in parentheses");
}