use crate::domain::{IndexEntry, WrappedNeuralCommit};
use crate::error::Result;
use crate::git::GitRepository;
use crate::storage::{ObjectStore, RefStore};
#[derive(Debug, Clone)]
pub struct GhostCommitInfo {
pub git_hashes: Vec<String>,
pub changed_files: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ConflictCheckResult {
pub has_conflict: bool,
pub conflicting_files: Vec<String>,
pub ghost_info: Option<GhostCommitInfo>,
}
impl ConflictCheckResult {
pub fn no_conflict() -> Self {
Self {
has_conflict: false,
conflicting_files: Vec::new(),
ghost_info: None,
}
}
pub fn conflict(conflicting_files: Vec<String>, ghost_info: GhostCommitInfo) -> Self {
Self {
has_conflict: true,
conflicting_files,
ghost_info: Some(ghost_info),
}
}
pub fn no_conflict_with_ghost(ghost_info: GhostCommitInfo) -> Self {
Self {
has_conflict: false,
conflicting_files: Vec::new(),
ghost_info: Some(ghost_info),
}
}
}
pub fn detect_ghost_commits<O: ObjectStore, R: RefStore>(
git: &GitRepository,
objects: &O,
refs: &R,
branch: &str,
) -> Result<Option<GhostCommitInfo>> {
let current_git_hash = git.head_commit_hash()?;
let neural_hash = match refs.get(branch)? {
Some(h) => h,
None => {
return Ok(None);
},
};
let data = objects.load(&neural_hash)?;
let wrapped: WrappedNeuralCommit = serde_json::from_slice(&data)?;
let last_known_git_hash = &wrapped.data.git_hash;
if ¤t_git_hash == last_known_git_hash {
return Ok(None);
}
let changed_files = git.diff_commits(last_known_git_hash, ¤t_git_hash)?;
let git_hashes = git.commits_between(last_known_git_hash, ¤t_git_hash)?;
Ok(Some(GhostCommitInfo {
git_hashes,
changed_files,
}))
}
pub fn extract_file_references(entries: &[IndexEntry]) -> Vec<String> {
let mut references = Vec::new();
for entry in entries {
extract_file_patterns(&entry.content, &mut references);
}
references.sort();
references.dedup();
references
}
fn extract_file_patterns(content: &str, references: &mut Vec<String>) {
for word in content.split(|c: char| c.is_whitespace() || c == ',' || c == ';' || c == ':') {
let word =
word.trim_matches(|c: char| c == '\'' || c == '"' || c == '`' || c == '(' || c == ')');
if word.is_empty() {
continue;
}
if looks_like_file_path(word) {
let normalized = word
.trim_start_matches("./")
.trim_start_matches(".\\")
.trim_start_matches('/')
.trim_start_matches('\\');
if !normalized.is_empty() {
references.push(normalized.to_string());
}
}
}
}
fn looks_like_file_path(s: &str) -> bool {
let has_extension = s.contains('.') && !s.starts_with('.') && !s.ends_with('.');
let has_path_sep = s.contains('/') || s.contains('\\');
let common_extensions = [
".rs", ".ts", ".js", ".tsx", ".jsx", ".py", ".go", ".java", ".c", ".cpp", ".h", ".hpp",
".cs", ".rb", ".php", ".swift", ".kt", ".scala", ".md", ".txt", ".json", ".yaml", ".yml",
".toml", ".xml", ".html", ".css", ".scss", ".less", ".sql", ".sh", ".bash", ".zsh", ".ps1",
".bat", ".cmd",
];
if has_path_sep {
return true;
}
if has_extension {
let lower = s.to_lowercase();
for ext in &common_extensions {
if lower.ends_with(ext) {
return true;
}
}
}
false
}
pub fn check_semantic_conflict(
ghost_info: &GhostCommitInfo,
pending_entries: &[IndexEntry],
) -> ConflictCheckResult {
if ghost_info.changed_files.is_empty() || pending_entries.is_empty() {
return ConflictCheckResult::no_conflict_with_ghost(ghost_info.clone());
}
let thought_files = extract_file_references(pending_entries);
if thought_files.is_empty() {
return ConflictCheckResult::no_conflict_with_ghost(ghost_info.clone());
}
let mut conflicting_files = Vec::new();
for changed_file in &ghost_info.changed_files {
let changed_normalized = changed_file
.replace('\\', "/")
.trim_start_matches("./")
.to_string();
for thought_file in &thought_files {
let thought_normalized = thought_file
.replace('\\', "/")
.trim_start_matches("./")
.to_string();
let is_match = changed_normalized == thought_normalized
|| changed_normalized.ends_with(&format!("/{}", thought_normalized))
|| thought_normalized.ends_with(&format!("/{}", changed_normalized))
|| changed_normalized.contains(&thought_normalized)
|| thought_normalized.contains(&changed_normalized);
if is_match && !conflicting_files.contains(changed_file) {
conflicting_files.push(changed_file.clone());
}
}
}
if conflicting_files.is_empty() {
ConflictCheckResult::no_conflict_with_ghost(ghost_info.clone())
} else {
ConflictCheckResult::conflict(conflicting_files, ghost_info.clone())
}
}
pub fn check_for_conflicts<O: ObjectStore, R: RefStore>(
git: &GitRepository,
objects: &O,
refs: &R,
branch: &str,
pending_entries: &[IndexEntry],
) -> Result<ConflictCheckResult> {
let ghost_info = match detect_ghost_commits(git, objects, refs, branch)? {
Some(info) => info,
None => return Ok(ConflictCheckResult::no_conflict()),
};
Ok(check_semantic_conflict(&ghost_info, pending_entries))
}
const AMEND_TIMESTAMP_TOLERANCE_SECS: i64 = 60;
fn is_amend_replacement(git: &GitRepository, old_git_hash: &str, new_git_hash: &str) -> bool {
let old_meta = match git.get_commit_metadata(old_git_hash) {
Ok(m) => m,
Err(_) => return false, };
let new_meta = match git.get_commit_metadata(new_git_hash) {
Ok(m) => m,
Err(_) => return false,
};
let same_author = old_meta.author_email == new_meta.author_email;
let same_message = old_meta.message_first_line == new_meta.message_first_line;
let timestamp_close =
(old_meta.timestamp - new_meta.timestamp).abs() <= AMEND_TIMESTAMP_TOLERANCE_SECS;
same_author && same_message && timestamp_close
}
fn migrate_neural_commit<O: ObjectStore, R: RefStore>(
objects: &O,
refs: &R,
branch: &str,
neural_hash: &str,
new_git_hash: &str,
) -> Result<String> {
let data = objects.load(neural_hash)?;
let mut wrapped: WrappedNeuralCommit = serde_json::from_slice(&data)?;
wrapped.data.git_hash = new_git_hash.to_string();
let new_data = serde_json::to_vec(&wrapped)?;
let new_neural_hash = objects.save(&new_data)?;
refs.update(branch, &new_neural_hash)?;
Ok(new_neural_hash)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RewindResult {
NoRewindNeeded,
Rewound {
old_hash: String,
new_hash: String,
orphaned_count: usize,
},
NoValidAncestor {
neural_hash: String,
},
MigratedAmend {
old_git_hash: String,
new_git_hash: String,
new_neural_hash: String,
},
}
pub fn reconcile_rewind<O: ObjectStore, R: RefStore>(
git: &GitRepository,
objects: &O,
refs: &R,
branch: &str,
) -> Result<RewindResult> {
let neural_hash = match refs.get(branch)? {
Some(h) => h,
None => return Ok(RewindResult::NoRewindNeeded), };
let data = objects.load(&neural_hash)?;
let wrapped: WrappedNeuralCommit = serde_json::from_slice(&data)?;
let agit_git_hash = &wrapped.data.git_hash;
let current_git_head = git.head_commit_hash()?;
if agit_git_hash == ¤t_git_head {
return Ok(RewindResult::NoRewindNeeded);
}
if git.is_ancestor(agit_git_hash, ¤t_git_head)? {
return Ok(RewindResult::NoRewindNeeded); }
if is_amend_replacement(git, agit_git_hash, ¤t_git_head) {
let old_git_hash = agit_git_hash.clone();
let new_neural_hash =
migrate_neural_commit(objects, refs, branch, &neural_hash, ¤t_git_head)?;
return Ok(RewindResult::MigratedAmend {
old_git_hash,
new_git_hash: current_git_head,
new_neural_hash,
});
}
find_valid_ancestor(git, objects, refs, branch, &neural_hash, ¤t_git_head)
}
fn find_valid_ancestor<O: ObjectStore, R: RefStore>(
git: &GitRepository,
objects: &O,
refs: &R,
branch: &str,
start_hash: &str,
git_head: &str,
) -> Result<RewindResult> {
let mut current = start_hash.to_string();
let mut orphaned_count = 0;
loop {
let data = objects.load(¤t)?;
let wrapped: WrappedNeuralCommit = serde_json::from_slice(&data)?;
let commit = &wrapped.data;
let is_valid =
commit.git_hash == git_head || git.is_ancestor(&commit.git_hash, git_head)?;
if is_valid {
refs.update(branch, ¤t)?;
return Ok(RewindResult::Rewound {
old_hash: start_hash.to_string(),
new_hash: current,
orphaned_count,
});
}
orphaned_count += 1;
match commit.first_parent() {
Some(parent) => current = parent.to_string(),
None => {
return Ok(RewindResult::NoValidAncestor {
neural_hash: start_hash.to_string(),
});
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{Category, Role};
fn make_entry(content: &str) -> IndexEntry {
IndexEntry {
role: Role::Ai,
category: Category::Reasoning,
content: content.to_string(),
timestamp: chrono::Utc::now(),
locations: None,
file_path: None,
line_number: None,
}
}
#[test]
fn test_extract_file_references_basic() {
let entries = vec![make_entry("I will modify src/main.rs to fix the bug")];
let refs = extract_file_references(&entries);
assert!(refs.contains(&"src/main.rs".to_string()));
}
#[test]
fn test_extract_file_references_multiple() {
let entries = vec![
make_entry("Update auth.rs and config.toml"),
make_entry("Also check test/unit.rs"),
];
let refs = extract_file_references(&entries);
assert!(refs.contains(&"auth.rs".to_string()));
assert!(refs.contains(&"config.toml".to_string()));
assert!(refs.contains(&"test/unit.rs".to_string()));
}
#[test]
fn test_extract_file_references_empty() {
let entries = vec![make_entry("I will fix the authentication logic")];
let refs = extract_file_references(&entries);
assert!(refs.is_empty());
}
#[test]
fn test_looks_like_file_path() {
assert!(looks_like_file_path("main.rs"));
assert!(looks_like_file_path("src/lib.rs"));
assert!(looks_like_file_path("test/unit.py"));
assert!(!looks_like_file_path("hello"));
assert!(!looks_like_file_path("README")); assert!(!looks_like_file_path(".gitignore")); }
#[test]
fn test_check_semantic_conflict_no_overlap() {
let ghost = GhostCommitInfo {
git_hashes: vec!["abc123".to_string()],
changed_files: vec!["README.md".to_string()],
};
let entries = vec![make_entry("I will modify src/main.rs")];
let result = check_semantic_conflict(&ghost, &entries);
assert!(!result.has_conflict);
assert!(result.conflicting_files.is_empty());
}
#[test]
fn test_check_semantic_conflict_with_overlap() {
let ghost = GhostCommitInfo {
git_hashes: vec!["abc123".to_string()],
changed_files: vec!["src/main.rs".to_string()],
};
let entries = vec![make_entry("I will modify src/main.rs to fix the bug")];
let result = check_semantic_conflict(&ghost, &entries);
assert!(result.has_conflict);
assert!(result
.conflicting_files
.contains(&"src/main.rs".to_string()));
}
#[test]
fn test_check_semantic_conflict_partial_match() {
let ghost = GhostCommitInfo {
git_hashes: vec!["abc123".to_string()],
changed_files: vec!["src/auth/login.rs".to_string()],
};
let entries = vec![make_entry("Update login.rs with new validation")];
let result = check_semantic_conflict(&ghost, &entries);
assert!(result.has_conflict);
}
}