diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4bb6911d..0e21cdad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,6 @@ jobs: ${{ runner.os }}-cargo- - name: Run tests - run: cargo test -- --test-threads=1 + run: cargo test env: CARGO_INCREMENTAL: 0 diff --git a/src/authorship/post_commit.rs b/src/authorship/post_commit.rs index c8ff18da..1132702f 100644 --- a/src/authorship/post_commit.rs +++ b/src/authorship/post_commit.rs @@ -54,6 +54,7 @@ pub fn post_commit( .flat_map(|cp| cp.entries.iter().map(|e| e.file.clone())) .collect(); + // Split VirtualAttributions into committed (authorship log) and uncommitted (INITIAL) let (mut authorship_log, initial_attributions) = working_va .to_authorship_log_and_initial_working_log( diff --git a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-2.snap b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-2.snap index 4413ab4a..89ba6ac2 100644 --- a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-2.snap +++ b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-2.snap @@ -2,4 +2,4 @@ source: src/authorship/stats.rs expression: ai_only_output --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\nšŸ¤– ai ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 95%\n```\n\n
\nMore stats\n\n- 1.1 lines generated for every 1 accepted\n- 45 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\nšŸ¤– ai ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 95%\n```\n\n
\nMore stats\n\n- 1.1 lines generated for every 1 accepted\n- 45 seconds waiting for AI \n\n
" diff --git a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-3.snap b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-3.snap index a42a3e75..4b379051 100644 --- a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-3.snap +++ b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-3.snap @@ -2,4 +2,4 @@ source: src/authorship/stats.rs expression: human_only_output --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 100%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\n```\n\n
\nMore stats\n\n- 0.0 lines generated for every 1 accepted\n- 0 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 100%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\n```\n\n
\nMore stats\n\n- 0.0 lines generated for every 1 accepted\n- 0 seconds waiting for AI \n\n
" diff --git a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-4.snap b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-4.snap index eee72b8e..a000284e 100644 --- a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-4.snap +++ b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display-4.snap @@ -2,4 +2,4 @@ source: src/authorship/stats.rs expression: minimal_human_output --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 2%\nšŸ¤– ai ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 93%\n```\n\n
\nMore stats\n\n- 1.1 lines generated for every 1 accepted\n- 30 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 2%\nšŸ¤– ai ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 93%\n```\n\n
\nMore stats\n\n- 1.1 lines generated for every 1 accepted\n- 30 seconds waiting for AI \n\n
" diff --git a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display.snap b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display.snap index 5889e75a..671cb092 100644 --- a/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display.snap +++ b/src/authorship/snapshots/git_ai__authorship__stats__tests__markdown_stats_display.snap @@ -2,4 +2,4 @@ source: src/authorship/stats.rs expression: mixed_output --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 63%\nšŸ¤ mixed ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 50%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 31%\n```\n\n
\nMore stats\n\n- 4.0 lines generated for every 1 accepted\n- 1200 mins 9 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 63%\nšŸ¤ mixed ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 50%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 31%\n```\n\n
\nMore stats\n\n- 4.0 lines generated for every 1 accepted\n- 1200 minutes waiting for AI \n\n
" diff --git a/src/commands/blame.rs b/src/commands/blame.rs index 69795efb..ce024af7 100644 --- a/src/commands/blame.rs +++ b/src/commands/blame.rs @@ -5,6 +5,8 @@ use crate::error::GitAiError; use crate::git::refs::get_reference_as_authorship_log_v3; use crate::git::repository::Repository; use crate::git::repository::exec_git; +#[cfg(windows)] +use crate::utils::normalize_to_posix; use chrono::{DateTime, FixedOffset, TimeZone, Utc}; use std::collections::HashMap; use std::fs; @@ -194,6 +196,26 @@ impl Repository { file_path.to_string() }; + // Normalize the file path before use + #[cfg(windows)] + let relative_file_path = { + let normalized = normalize_to_posix(&relative_file_path); + // Strip leading ./ or .\ if present + normalized + .strip_prefix("./") + .unwrap_or(&normalized) + .to_string() + }; + + #[cfg(not(windows))] + let relative_file_path = { + // Also strip leading ./ on non-Windows for consistency + relative_file_path + .strip_prefix("./") + .unwrap_or(&relative_file_path) + .to_string() + }; + // Read file content either from a specific commit or from working directory let (file_content, total_lines) = if let Some(ref commit) = options.newest_commit { // Read file content from the specified commit diff --git a/src/commands/checkpoint.rs b/src/commands/checkpoint.rs index eb065ef5..2ba7390d 100644 --- a/src/commands/checkpoint.rs +++ b/src/commands/checkpoint.rs @@ -7,7 +7,7 @@ use crate::error::GitAiError; use crate::git::repo_storage::{PersistedWorkingLog, RepoStorage}; use crate::git::repository::Repository; use crate::git::status::{EntryKind, StatusCode}; -use crate::utils::debug_log; +use crate::utils::{debug_log, normalize_to_posix}; use sha2::{Digest, Sha256}; use similar::{ChangeTag, TextDiff}; use std::collections::{HashMap, HashSet}; @@ -73,45 +73,45 @@ pub fn run( paths.and_then(|p| { let repo_workdir = repo.workdir().ok()?; + let filtered: Vec = p .iter() .filter_map(|path| { - // Check if path is absolute and outside repo - if std::path::Path::new(path).is_absolute() { - // For absolute paths, check if they start with repo_workdir - if !std::path::Path::new(path).starts_with(&repo_workdir) { - return None; - } + let path_buf = if std::path::Path::new(path).is_absolute() { + // Absolute path - check directly + std::path::PathBuf::from(path) } else { - // For relative paths, join with workdir and canonicalize to check - let joined = repo_workdir.join(path); - // Try to canonicalize to resolve .. and . components - if let Ok(canonical) = joined.canonicalize() { - if !canonical.starts_with(&repo_workdir) { - return None; + // Relative path - join with workdir + repo_workdir.join(path) + }; + + // Use centralized path comparison (handles Windows canonical paths correctly) + if repo.path_is_in_workdir(&path_buf) { + // Convert to relative path for git operations + if std::path::Path::new(path).is_absolute() { + if let Ok(relative) = path_buf.strip_prefix(&repo_workdir) { + // Normalize path separators to forward slashes for git + Some(normalize_to_posix(&relative.to_string_lossy())) + } else { + // Fallback: try with canonical paths + let canonical_workdir = repo_workdir.canonicalize().ok()?; + let canonical_path = path_buf.canonicalize().ok()?; + if let Ok(relative) = + canonical_path.strip_prefix(&canonical_workdir) + { + // Normalize path separators to forward slashes for git + Some(normalize_to_posix(&relative.to_string_lossy())) + } else { + None + } } } else { - // If we can't canonicalize (file doesn't exist), check the joined path - // Convert both to canonical form if possible, otherwise use as-is - let normalized_joined = joined.components().fold( - std::path::PathBuf::new(), - |mut acc, component| { - match component { - std::path::Component::ParentDir => { - acc.pop(); - } - std::path::Component::CurDir => {} - _ => acc.push(component), - } - acc - }, - ); - if !normalized_joined.starts_with(&repo_workdir) { - return None; - } + // Normalize path separators to forward slashes for git + Some(normalize_to_posix(path)) } + } else { + None } - Some(path.clone()) }) .collect(); @@ -372,18 +372,22 @@ fn get_all_tracked_files( .unwrap_or_default(); for file in working_log.read_initial_attributions().files.keys() { - if is_text_file(working_log, &file) { - files.insert(file.clone()); + // Normalize path separators to forward slashes + let normalized_path = normalize_to_posix(file); + if is_text_file(working_log, &normalized_path) { + files.insert(normalized_path); } } if let Ok(working_log_data) = working_log.read_all_checkpoints() { for checkpoint in &working_log_data { for entry in &checkpoint.entries { - if !files.contains(&entry.file) { + // Normalize path separators to forward slashes + let normalized_path = normalize_to_posix(&entry.file); + if !files.contains(&normalized_path) { // Check if it's a text file before adding - if is_text_file(working_log, &entry.file) { - files.insert(entry.file.clone()); + if is_text_file(working_log, &normalized_path) { + files.insert(normalized_path); } } } @@ -407,11 +411,13 @@ fn get_all_tracked_files( // Ensure to always include all dirty files if let Some(ref dirty_files) = working_log.dirty_files { for file_path in dirty_files.keys() { + // Normalize path separators to forward slashes + let normalized_path = normalize_to_posix(file_path); // Only add if not already in the files list - if !results_for_tracked_files.contains(&file_path) { + if !results_for_tracked_files.contains(&normalized_path) { // Check if it's a text file before adding - if is_text_file(working_log, &file_path) { - results_for_tracked_files.push(file_path.clone()); + if is_text_file(working_log, &normalized_path) { + results_for_tracked_files.push(normalized_path); } } } @@ -453,7 +459,9 @@ fn get_checkpoint_entry_for_file( initial_attributions: Arc>>, ts: u128, ) -> Result, GitAiError> { - let current_content = working_log.read_current_file_content(&file_path).unwrap_or_default(); + let current_content = working_log + .read_current_file_content(&file_path) + .unwrap_or_default(); // Try to get previous state from checkpoints first let from_checkpoint = previous_checkpoints.iter().rev().find_map(|checkpoint| { @@ -1237,14 +1245,17 @@ mod tests { } fn is_text_file(working_log: &PersistedWorkingLog, path: &str) -> bool { + // Normalize path for dirty_files lookup + let normalized_path = normalize_to_posix(path); let skip_metadata_check = working_log .dirty_files .as_ref() - .map(|m| m.contains_key(path)) + .map(|m| m.contains_key(&normalized_path)) .unwrap_or(false); if !skip_metadata_check { - if let Ok(metadata) = std::fs::metadata(working_log.to_repo_absolute_path(path)) { + if let Ok(metadata) = std::fs::metadata(working_log.to_repo_absolute_path(&normalized_path)) + { if !metadata.is_file() { return false; } @@ -1254,7 +1265,7 @@ fn is_text_file(working_log: &PersistedWorkingLog, path: &str) -> bool { } working_log - .read_current_file_content(path) + .read_current_file_content(&normalized_path) .map(|content| !content.chars().any(|c| c == '\0')) .unwrap_or(false) } diff --git a/src/git/repo_storage.rs b/src/git/repo_storage.rs index 8368e8e6..d83f6687 100644 --- a/src/git/repo_storage.rs +++ b/src/git/repo_storage.rs @@ -3,7 +3,7 @@ use crate::authorship::authorship_log::PromptRecord; use crate::authorship::working_log::{CHECKPOINT_API_VERSION, Checkpoint, CheckpointKind}; use crate::error::GitAiError; use crate::git::rewrite_log::{RewriteLogEvent, append_event_to_file}; -use crate::utils::debug_log; +use crate::utils::{debug_log, normalize_to_posix}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -73,7 +73,17 @@ impl RepoStorage { pub fn working_log_for_base_commit(&self, sha: &str) -> PersistedWorkingLog { let working_log_dir = self.working_logs.join(sha); fs::create_dir_all(&working_log_dir).unwrap(); - PersistedWorkingLog::new(working_log_dir, sha, self.repo_workdir.clone(), None) + let canonical_workdir = self + .repo_workdir + .canonicalize() + .unwrap_or_else(|_| self.repo_workdir.clone()); + PersistedWorkingLog::new( + working_log_dir, + sha, + self.repo_workdir.clone(), + canonical_workdir, + None, + ) } #[allow(dead_code)] @@ -123,6 +133,9 @@ pub struct PersistedWorkingLog { #[allow(dead_code)] pub base_commit: String, pub repo_workdir: PathBuf, + /// Canonical (absolute, resolved) version of workdir for reliable path comparisons + /// On Windows, this uses the \\?\ UNC prefix format + pub canonical_workdir: PathBuf, pub dirty_files: Option>, } @@ -131,22 +144,30 @@ impl PersistedWorkingLog { dir: PathBuf, base_commit: &str, repo_root: PathBuf, + canonical_workdir: PathBuf, dirty_files: Option>, ) -> Self { Self { dir, base_commit: base_commit.to_string(), repo_workdir: repo_root, + canonical_workdir, dirty_files, } } pub fn set_dirty_files(&mut self, dirty_files: Option>) { - self.dirty_files = dirty_files.map(|map| { + let normalized_dirty_files = dirty_files.map(|map| { map.into_iter() - .map(|(file_path, content)| (self.to_repo_relative_path(&file_path), content)) - .collect() + .map(|(file_path, content)| { + let relative_path = self.to_repo_relative_path(&file_path); + let normalized_path = normalize_to_posix(&relative_path); + (normalized_path, content) + }) + .collect::>() }); + + self.dirty_files = normalized_dirty_files; } pub fn reset_working_log(&self) -> Result<(), GitAiError> { @@ -212,14 +233,31 @@ impl PersistedWorkingLog { } // If we couldn't match yet, try canonicalizing both repo_workdir and the input path + // On Windows, this uses the canonical_workdir that was pre-computed + #[cfg(windows)] + let canonical_workdir = &self.canonical_workdir; + + #[cfg(not(windows))] let canonical_workdir = match self.repo_workdir.canonicalize() { Ok(p) => p, Err(_) => self.repo_workdir.clone(), }; + let canonical_path = match path.canonicalize() { Ok(p) => p, Err(_) => path.to_path_buf(), }; + + #[cfg(windows)] + if canonical_path.starts_with(canonical_workdir) { + return canonical_path + .strip_prefix(canonical_workdir) + .unwrap() + .to_string_lossy() + .to_string(); + } + + #[cfg(not(windows))] if canonical_path.starts_with(&canonical_workdir) { return canonical_path .strip_prefix(&canonical_workdir) @@ -227,6 +265,7 @@ impl PersistedWorkingLog { .to_string_lossy() .to_string(); } + return file_path.to_string(); } diff --git a/src/git/repository.rs b/src/git/repository.rs index 4c45116c..30b1d8af 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -802,6 +802,9 @@ pub struct Repository { pub pre_command_base_commit: Option, pub pre_command_refname: Option, workdir: PathBuf, + /// Canonical (absolute, resolved) version of workdir for reliable path comparisons + /// On Windows, this uses the \\?\ UNC prefix format + canonical_workdir: PathBuf, } impl Repository { @@ -915,6 +918,36 @@ impl Repository { Ok(self.workdir.clone()) } + /// Get the canonical (absolute, resolved) path of the working directory + /// On Windows, this uses the \\?\ UNC prefix format for reliable path comparisons + pub fn canonical_workdir(&self) -> &Path { + &self.canonical_workdir + } + + /// Check if a path is within the repository's working directory + /// Uses canonical path comparison for reliability on Windows + pub fn path_is_in_workdir(&self, path: &Path) -> bool { + // Try canonical comparison first (most reliable, especially on Windows) + if let Ok(canonical_path) = path.canonicalize() { + return canonical_path.starts_with(&self.canonical_workdir); + } + + // Fallback for paths that don't exist yet: normalize by resolving .. and . + let normalized = path + .components() + .fold(std::path::PathBuf::new(), |mut acc, component| { + match component { + std::path::Component::ParentDir => { + acc.pop(); + } + std::path::Component::CurDir => {} + _ => acc.push(component), + } + acc + }); + normalized.starts_with(&self.workdir) + } + // List all remotes for a given repository pub fn remotes(&self) -> Result, GitAiError> { let mut args = self.global_args_for_exec(); @@ -1713,7 +1746,16 @@ pub fn find_repository(global_args: &Vec) -> Result) -> Result Result { .to_path_buf(); let global_args = vec!["-C".to_string(), git_dir.to_string_lossy().to_string()]; + let canonical_workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.clone()); + Ok(Repository { global_args, storage: RepoStorage::for_repo_path(git_dir, &workdir), @@ -1739,6 +1784,7 @@ pub fn from_bare_repository(git_dir: &Path) -> Result { pre_command_base_commit: None, pre_command_refname: None, workdir, + canonical_workdir, }) } diff --git a/src/utils.rs b/src/utils.rs index 77a90a4e..aef1cee6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -76,6 +76,12 @@ pub fn _print_diff(diff: &Diff, old_label: &str, new_label: &str) { } } + +#[inline] +pub fn normalize_to_posix(path: &str) -> String { + path.replace('\\', "/") +} + pub fn current_git_ai_exe() -> Result { let path = std::env::current_exe()?; diff --git a/tests/ai_tab.rs b/tests/ai_tab.rs index 5711c1a1..0ec6637e 100644 --- a/tests/ai_tab.rs +++ b/tests/ai_tab.rs @@ -16,8 +16,13 @@ use git_ai::{ fn run_ai_tab_checkpoint(repo: &TestRepo, hook_payload: serde_json::Value) { let hook_input = hook_payload.to_string(); let args: Vec<&str> = vec!["checkpoint", "ai_tab", "--hook-input", hook_input.as_str()]; - if let Err(err) = repo.git_ai(&args) { - panic!("ai_tab checkpoint failed: {}", err); + match repo.git_ai(&args) { + Ok(output) => { + println!("git_ai checkpoint output: {}", output); + } + Err(err) => { + panic!("ai_tab checkpoint failed: {}", err); + } } } @@ -130,10 +135,7 @@ fn test_ai_tab_after_edit_checkpoint_includes_dirty_files_and_paths() { let edited = result .edited_filepaths .expect("after_edit should include edited filepaths"); - assert_eq!( - edited, - vec!["/Users/test/project/src/main.rs".to_string()] - ); + assert_eq!(edited, vec!["/Users/test/project/src/main.rs".to_string()]); let dirty_files = result .dirty_files @@ -168,7 +170,10 @@ fn test_ai_tab_rejects_invalid_hook_event() { message ); } - other => panic!("expected PresetError for invalid hook_event_name, got {:?}", other), + other => panic!( + "expected PresetError for invalid hook_event_name, got {:?}", + other + ), } } @@ -223,7 +228,7 @@ fn test_ai_tab_requires_non_empty_tool_and_model() { fn test_ai_tab_e2e_marks_ai_lines() { let repo = TestRepo::new(); let relative_path = "notes_test.ts"; - let file_path = repo.path().join(relative_path); + let file_path = repo.canonical_path().join(relative_path); let base_content = "console.log(\"hello world\");\n".to_string(); fs::write(&file_path, &base_content).unwrap(); @@ -238,7 +243,7 @@ fn test_ai_tab_e2e_marks_ai_lines() { "hook_event_name": "before_edit", "tool": "github-copilot-tab", "model": "default", - "repo_working_dir": repo.path().to_string_lossy(), + "repo_working_dir": repo.canonical_path().to_string_lossy(), "will_edit_filepaths": [file_path_str.clone()], "dirty_files": { file_path_str.clone(): base_content.clone() @@ -247,7 +252,9 @@ fn test_ai_tab_e2e_marks_ai_lines() { ); // AI tab inserts new lines alongside the existing content - let ai_content = "console.log(\"hello world\");\n// Log hello world\nconsole.log(\"hello from ai\");\n".to_string(); + let ai_content = + "console.log(\"hello world\");\n// Log hello world\nconsole.log(\"hello from ai\");\n" + .to_string(); fs::write(&file_path, &ai_content).unwrap(); run_ai_tab_checkpoint( @@ -256,7 +263,7 @@ fn test_ai_tab_e2e_marks_ai_lines() { "hook_event_name": "after_edit", "tool": "github-copilot-tab", "model": "default", - "repo_working_dir": repo.path().to_string_lossy(), + "repo_working_dir": repo.canonical_path().to_string_lossy(), "edited_filepaths": [file_path_str.clone()], "dirty_files": { file_path_str.clone(): ai_content.clone() @@ -264,7 +271,8 @@ fn test_ai_tab_e2e_marks_ai_lines() { }), ); - repo.stage_all_and_commit("Accept AI tab completion").unwrap(); + repo.stage_all_and_commit("Accept AI tab completion") + .unwrap(); let mut file = repo.filename(relative_path); file.assert_lines_and_blame(lines![ @@ -299,6 +307,8 @@ fn test_ai_tab_e2e_handles_dirty_files_map() { let lib_file_path_str = lib_file_path.to_string_lossy().to_string(); let readme_file_path_str = readme_file_path.to_string_lossy().to_string(); + let working_logs = repo.current_working_logs(); + // Before edit snapshot includes all dirty files (AI target plus unrelated human edits) run_ai_tab_checkpoint( &repo, @@ -306,7 +316,7 @@ fn test_ai_tab_e2e_handles_dirty_files_map() { "hook_event_name": "before_edit", "tool": "github-copilot-tab", "model": "default", - "repo_working_dir": repo.path().to_string_lossy(), + "repo_working_dir": repo.canonical_path().to_string_lossy(), "will_edit_filepaths": [lib_file_path_str.clone()], "dirty_files": { lib_file_path_str.clone(): base_lib_content.clone(), @@ -321,13 +331,15 @@ fn test_ai_tab_e2e_handles_dirty_files_map() { .to_string(); fs::write(&lib_file_path, &ai_content).unwrap(); + let working_logs = repo.current_working_logs(); + run_ai_tab_checkpoint( &repo, json!({ "hook_event_name": "after_edit", "tool": "github-copilot-tab", "model": "default", - "repo_working_dir": repo.path().to_string_lossy(), + "repo_working_dir": repo.canonical_path().to_string_lossy(), "edited_filepaths": [lib_file_path_str.clone()], "dirty_files": { lib_file_path_str.clone(): ai_content.clone(), @@ -336,9 +348,15 @@ fn test_ai_tab_e2e_handles_dirty_files_map() { }), ); - repo.stage_all_and_commit("Record AI tab completion while other files dirty").unwrap(); + let working_logs = repo.current_working_logs(); + + let commit_result = repo + .stage_all_and_commit("Record AI tab completion while other files dirty") + .unwrap(); - let mut file = repo.filename(&std::path::Path::new("src").join("lib.rs").to_string_lossy()); + commit_result.print_authorship(); + + let mut file = repo.filename("src/lib.rs"); file.assert_lines_and_blame(lines![ "fn greet() {".human(), " println!(\"hello\");".human(), @@ -347,4 +365,4 @@ fn test_ai_tab_e2e_handles_dirty_files_map() { " println!(\"from ai\");".ai(), "}".ai(), ]); -} \ No newline at end of file +} diff --git a/tests/amend.rs b/tests/amend.rs index 83d4e19c..bbb5cdbd 100644 --- a/tests/amend.rs +++ b/tests/amend.rs @@ -23,7 +23,6 @@ fn test_amend_add_lines_at_top() { ); // Amend the commit WITHOUT staging the AI lines - // repo.git(&["add", "-A"]).unwrap(); repo.git(&["commit", "--amend", "-m", "Initial commit (amended)"]) .unwrap(); diff --git a/tests/repos/test_file.rs b/tests/repos/test_file.rs index 01dd4981..994a41ee 100644 --- a/tests/repos/test_file.rs +++ b/tests/repos/test_file.rs @@ -727,10 +727,10 @@ impl<'a> TestFile<'a> { fn write_and_checkpoint(&self, author_type: &AuthorType) { let contents = self.contents(); fs::write(&self.file_path, contents).unwrap(); - let _ = if author_type == &AuthorType::Ai { - self.repo.git_ai(&["checkpoint", "mock_ai"]) + if author_type == &AuthorType::Ai { + self.repo.git_ai(&["checkpoint", "mock_ai"]).unwrap(); } else { - self.repo.git_ai(&["checkpoint"]) + self.repo.git_ai(&["checkpoint"]).unwrap(); }; } diff --git a/tests/repos/test_repo.rs b/tests/repos/test_repo.rs index 3395fb1a..7df7d361 100644 --- a/tests/repos/test_repo.rs +++ b/tests/repos/test_repo.rs @@ -39,6 +39,12 @@ impl TestRepo { &self.path } + pub fn canonical_path(&self) -> PathBuf { + self.path + .canonicalize() + .expect("failed to canonicalize test repo path") + } + pub fn stats(&self) -> Result { let mut stats = self.git_ai(&["stats", "--json"]).unwrap(); stats = stats.split("}}}").next().unwrap().to_string() + "}}}"; diff --git a/tests/snapshots/stats__markdown_stats_all_ai.snap b/tests/snapshots/stats__markdown_stats_all_ai.snap index 6a6d6869..6a543f15 100644 --- a/tests/snapshots/stats__markdown_stats_all_ai.snap +++ b/tests/snapshots/stats__markdown_stats_all_ai.snap @@ -2,4 +2,4 @@ source: tests/stats.rs expression: markdown --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\nšŸ¤– ai ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 100%\n```\n\n
\nMore stats\n\n- 1.0 lines generated for every 1 accepted\n- 30 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\nšŸ¤– ai ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 100%\n```\n\n
\nMore stats\n\n- 1.0 lines generated for every 1 accepted\n- 30 seconds waiting for AI \n\n
" diff --git a/tests/snapshots/stats__markdown_stats_all_human.snap b/tests/snapshots/stats__markdown_stats_all_human.snap index 43a68fe8..a291943a 100644 --- a/tests/snapshots/stats__markdown_stats_all_human.snap +++ b/tests/snapshots/stats__markdown_stats_all_human.snap @@ -2,4 +2,4 @@ source: tests/stats.rs expression: markdown --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 100%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\n```\n\n
\nMore stats\n\n- 0.0 lines generated for every 1 accepted\n- 0 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 100%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 0%\n```\n\n
\nMore stats\n\n- 0.0 lines generated for every 1 accepted\n- 0 seconds waiting for AI \n\n
" diff --git a/tests/snapshots/stats__markdown_stats_formatting.snap b/tests/snapshots/stats__markdown_stats_formatting.snap index 0fce86af..4a6a753f 100644 --- a/tests/snapshots/stats__markdown_stats_formatting.snap +++ b/tests/snapshots/stats__markdown_stats_formatting.snap @@ -2,4 +2,4 @@ source: tests/stats.rs expression: markdown --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 38%\nšŸ¤ mixed ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 15%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 46%\n```\n\n
\nMore stats\n\n- 1.7 lines generated for every 1 accepted\n- 25 secs time waiting for AI\n- Top model: cursor::claude-3.5-sonnet (6 accepted lines, 10 generated lines)\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 38%\nšŸ¤ mixed ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 15%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 46%\n```\n\n
\nMore stats\n\n- 1.7 lines generated for every 1 accepted\n- 25 seconds waiting for AI \n- Top model: cursor::claude-3.5-sonnet (6 accepted lines, 10 generated lines)\n\n
" diff --git a/tests/snapshots/stats__markdown_stats_minimal_human.snap b/tests/snapshots/stats__markdown_stats_minimal_human.snap index 0b893871..2bd81976 100644 --- a/tests/snapshots/stats__markdown_stats_minimal_human.snap +++ b/tests/snapshots/stats__markdown_stats_minimal_human.snap @@ -2,4 +2,4 @@ source: tests/stats.rs expression: markdown --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 2%\nšŸ¤– ai ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 98%\n```\n\n
\nMore stats\n\n- 1.0 lines generated for every 1 accepted\n- 10 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 2%\nšŸ¤– ai ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 98%\n```\n\n
\nMore stats\n\n- 1.0 lines generated for every 1 accepted\n- 10 seconds waiting for AI \n\n
" diff --git a/tests/snapshots/stats__markdown_stats_mixed.snap b/tests/snapshots/stats__markdown_stats_mixed.snap index 4599a631..e6e36be3 100644 --- a/tests/snapshots/stats__markdown_stats_mixed.snap +++ b/tests/snapshots/stats__markdown_stats_mixed.snap @@ -2,4 +2,4 @@ source: tests/stats.rs expression: markdown --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 33%\nšŸ¤ mixed ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 17%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 50%\n```\n\n
\nMore stats\n\n- 1.7 lines generated for every 1 accepted\n- 45 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 33%\nšŸ¤ mixed ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 17%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 50%\n```\n\n
\nMore stats\n\n- 1.7 lines generated for every 1 accepted\n- 45 seconds waiting for AI \n\n
" diff --git a/tests/snapshots/stats__markdown_stats_no_mixed.snap b/tests/snapshots/stats__markdown_stats_no_mixed.snap index 0d44dec0..880da7b1 100644 --- a/tests/snapshots/stats__markdown_stats_no_mixed.snap +++ b/tests/snapshots/stats__markdown_stats_no_mixed.snap @@ -2,4 +2,4 @@ source: tests/stats.rs expression: markdown --- -"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 40%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 60%\n```\n\n
\nMore stats\n\n- 1.0 lines generated for every 1 accepted\n- 15 secs time waiting for AI\n\n
" +"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ 40%\nšŸ¤– ai ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ 60%\n```\n\n
\nMore stats\n\n- 1.0 lines generated for every 1 accepted\n- 15 seconds waiting for AI \n\n
"