Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ jobs:
${{ runner.os }}-cargo-

- name: Run tests
run: cargo test -- --test-threads=1
run: cargo test
env:
CARGO_INCREMENTAL: 0
1 change: 1 addition & 0 deletions src/authorship/post_commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<details>\n<summary>More stats</summary>\n\n- 1.1 lines generated for every 1 accepted\n- 45 secs time waiting for AI\n\n</details>"
"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ░░░░░░░░░░░░░░░░░░░░ 0%\n🤖 ai ░███████████████████ 95%\n```\n\n<details>\n<summary>More stats</summary>\n\n- 1.1 lines generated for every 1 accepted\n- 45 seconds waiting for AI \n\n</details>"
Original file line number Diff line number Diff line change
Expand Up @@ -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<details>\n<summary>More stats</summary>\n\n- 0.0 lines generated for every 1 accepted\n- 0 secs time waiting for AI\n\n</details>"
"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you ████████████████████ 100%\n🤖 ai ░░░░░░░░░░░░░░░░░░░░ 0%\n```\n\n<details>\n<summary>More stats</summary>\n\n- 0.0 lines generated for every 1 accepted\n- 0 seconds waiting for AI \n\n</details>"
Original file line number Diff line number Diff line change
Expand Up @@ -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<details>\n<summary>More stats</summary>\n\n- 1.1 lines generated for every 1 accepted\n- 30 secs time waiting for AI\n\n</details>"
"Stats powered by [Git AI](https://github.com/acunniffe/git-ai)\n\n```text\n🧠 you █░░░░░░░░░░░░░░░░░░░ 2%\n🤖 ai ░███████████████████ 93%\n```\n\n<details>\n<summary>More stats</summary>\n\n- 1.1 lines generated for every 1 accepted\n- 30 seconds waiting for AI \n\n</details>"
Original file line number Diff line number Diff line change
Expand Up @@ -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<details>\n<summary>More stats</summary>\n\n- 4.0 lines generated for every 1 accepted\n- 1200 mins 9 secs time waiting for AI\n\n</details>"
"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<details>\n<summary>More stats</summary>\n\n- 4.0 lines generated for every 1 accepted\n- 1200 minutes waiting for AI \n\n</details>"
22 changes: 22 additions & 0 deletions src/commands/blame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
99 changes: 55 additions & 44 deletions src/commands/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -73,45 +73,45 @@ pub fn run(

paths.and_then(|p| {
let repo_workdir = repo.workdir().ok()?;

let filtered: Vec<String> = 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();

Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -453,7 +459,9 @@ fn get_checkpoint_entry_for_file(
initial_attributions: Arc<HashMap<String, Vec<LineAttribution>>>,
ts: u128,
) -> Result<Option<WorkingLogEntry>, 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| {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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)
}
Expand Down
49 changes: 44 additions & 5 deletions src/git/repo_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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<HashMap<String, String>>,
}

Expand All @@ -131,22 +144,30 @@ impl PersistedWorkingLog {
dir: PathBuf,
base_commit: &str,
repo_root: PathBuf,
canonical_workdir: PathBuf,
dirty_files: Option<HashMap<String, String>>,
) -> 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<HashMap<String, String>>) {
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::<HashMap<_, _>>()
});

self.dirty_files = normalized_dirty_files;
}

pub fn reset_working_log(&self) -> Result<(), GitAiError> {
Expand Down Expand Up @@ -212,21 +233,39 @@ 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)
.unwrap()
.to_string_lossy()
.to_string();
}

return file_path.to_string();
}

Expand Down
Loading
Loading