Skip to content
Open
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
23 changes: 19 additions & 4 deletions src/git/cli_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,11 +685,12 @@ pub fn extract_clone_target_directory(args: &[String]) -> Option<String> {
/// Derive the target directory name from a repository URL.
/// Mimics git's behavior of using the last path component, stripping .git suffix.
fn derive_directory_from_url(url: &str) -> Option<String> {
// Remove trailing slashes
let url = url.trim_end_matches('/');
// Remove trailing slashes (both forward and backslash for Windows paths)
let url = url.trim_end_matches('/').trim_end_matches('\\');

// Extract the last path component
let last_component = if let Some(pos) = url.rfind('/') {
// Extract the last path component.
// Check both '/' and '\\' to handle Windows local paths (e.g. C:\path\to\repo.git).
let last_component = if let Some(pos) = url.rfind(['/', '\\']) {
&url[pos + 1..]
} else if let Some(pos) = url.rfind(':') {
// Handle SCP-like syntax: user@host:path
Expand Down Expand Up @@ -808,6 +809,20 @@ mod tests {
derive_directory_from_url("/local/path/repo.git"),
Some("repo".to_string())
);
// Windows-style local paths with backslashes
assert_eq!(
derive_directory_from_url("C:\\Users\\user\\repos\\repo.git"),
Some("repo".to_string())
);
assert_eq!(
derive_directory_from_url("C:\\Users\\user\\repos\\my-project"),
Some("my-project".to_string())
);
// Trailing backslash
assert_eq!(
derive_directory_from_url("C:\\Users\\user\\repos\\repo.git\\"),
Some("repo".to_string())
);
}

#[test]
Expand Down
96 changes: 96 additions & 0 deletions src/git/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,102 @@ pub fn merge_notes_from_ref(repo: &Repository, source_ref: &str) -> Result<(), G
Ok(())
}

/// Fallback merge when `git notes merge -s ours` fails (e.g., due to git assertion
/// failures on corrupted/mixed-fanout notes trees). Implements the "ours" strategy
/// using a single `git fast-import` invocation that:
/// 1. Creates a merge commit with both local and source as parents
/// 2. Emits all notes via `N <blob> <object>` commands (source first, then local —
/// last writer wins, so local takes precedence on conflicts = "ours" strategy)
/// 3. Produces a clean notes tree with correct fanout regardless of input tree format
///
/// This is O(1) git process invocations regardless of note count, which matters on
/// large monorepos with thousands of notes.
pub fn fallback_merge_notes_ours(repo: &Repository, source_ref: &str) -> Result<(), GitAiError> {
let local_ref = format!("refs/notes/{}", AI_AUTHORSHIP_REFNAME);

// 1. List notes from both refs
let source_notes = list_all_notes(repo, source_ref)?;
let local_notes = list_all_notes(repo, &local_ref)?;

// 2. Resolve parent commit SHAs for the merge commit
let local_commit = rev_parse(repo, &local_ref)?;
let source_commit = rev_parse(repo, source_ref)?;

// 3. Build the fast-import stream.
// Emit source (remote) notes first, then local notes. fast-import uses
// last-writer-wins for duplicate annotated objects, so local notes take
// precedence — this implements the "ours" merge strategy.
let mut stream = String::new();
stream.push_str(&format!("commit {}\n", local_ref));
stream.push_str("committer git-ai <git-ai@noreply> 0 +0000\n");
stream.push_str("data 23\nMerge notes (fallback)\n");
stream.push_str(&format!("from {}\n", local_commit));
stream.push_str(&format!("merge {}\n", source_commit));

// Source notes first (will be overwritten by local on conflict)
for (blob, object) in &source_notes {
stream.push_str(&format!("N {} {}\n", blob, object));
}
// Local notes second (wins on conflict)
for (blob, object) in &local_notes {
stream.push_str(&format!("N {} {}\n", blob, object));
}
stream.push_str("done\n");

// 4. Run fast-import
let mut args = repo.global_args_for_exec();
args.extend_from_slice(&[
"fast-import".to_string(),
"--quiet".to_string(),
"--done".to_string(),
]);
exec_git_stdin(&args, stream.as_bytes())?;

debug_log("fallback merge via fast-import completed successfully");
Ok(())
}

/// List all notes on a given ref. Returns Vec<(note_blob_sha, annotated_object_sha)>.
fn list_all_notes(repo: &Repository, notes_ref: &str) -> Result<Vec<(String, String)>, GitAiError> {
// `git notes list` uses --ref to specify which notes ref.
// The --ref option prepends "refs/notes/" automatically, so for full refs
// like "refs/notes/ai-remote/origin" we need to strip the prefix.
let ref_arg = notes_ref.strip_prefix("refs/notes/").unwrap_or(notes_ref);

let mut args = repo.global_args_for_exec();
args.extend_from_slice(&[
"notes".to_string(),
format!("--ref={}", ref_arg),
"list".to_string(),
]);

let output = exec_git(&args)?;
let stdout = String::from_utf8(output.stdout)
.map_err(|_| GitAiError::Generic("Failed to parse notes list output".to_string()))?;

Ok(stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
})
.collect())
}

/// Parse a revision to its SHA
fn rev_parse(repo: &Repository, rev: &str) -> Result<String, GitAiError> {
let mut args = repo.global_args_for_exec();
args.extend_from_slice(&["rev-parse".to_string(), rev.to_string()]);
let output = exec_git(&args)?;
String::from_utf8(output.stdout)
.map_err(|_| GitAiError::Generic("Failed to parse rev-parse output".to_string()))
.map(|s| s.trim().to_string())
}

/// Copy a ref to another location (used for initial setup of local notes from tracking ref)
pub fn copy_ref(repo: &Repository, source_ref: &str, dest_ref: &str) -> Result<(), GitAiError> {
let mut args = repo.global_args_for_exec();
Expand Down
133 changes: 90 additions & 43 deletions src/git/sync_authorship.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::git::refs::{
AI_AUTHORSHIP_PUSH_REFSPEC, copy_ref, merge_notes_from_ref, ref_exists, tracking_ref_for_remote,
AI_AUTHORSHIP_PUSH_REFSPEC, copy_ref, fallback_merge_notes_ours, merge_notes_from_ref,
ref_exists, tracking_ref_for_remote,
};
use crate::{
error::GitAiError,
Expand Down Expand Up @@ -133,7 +134,10 @@ pub fn fetch_authorship_notes(
));
if let Err(e) = merge_notes_from_ref(repository, &tracking_ref) {
debug_log(&format!("notes merge failed: {}", e));
// Don't fail on merge errors, just log and continue
// Fallback: manually merge notes when git notes merge crashes
if let Err(e2) = fallback_merge_notes_ours(repository, &tracking_ref) {
debug_log(&format!("fallback merge also failed: {}", e2));
}
}
} else {
// Only tracking ref exists - copy it to local
Expand Down Expand Up @@ -168,67 +172,110 @@ fn is_missing_remote_notes_ref_error(error: &GitAiError) -> bool {
|| stderr_lower.contains("remote ref does not exist")
|| stderr_lower.contains("not our ref"))
}
/// Maximum number of fetch-merge-push attempts before giving up.
/// On busy monorepos, concurrent pushers can cause non-fast-forward rejections
/// even after a successful merge, so we retry the full cycle.
const PUSH_NOTES_MAX_ATTEMPTS: usize = 3;

// for use with post-push hook
pub fn push_authorship_notes(repository: &Repository, remote_name: &str) -> Result<(), GitAiError> {
// STEP 1: Fetch remote notes into tracking ref and merge before pushing
// This ensures we don't lose notes from other branches/clones
let mut last_error = None;

for attempt in 0..PUSH_NOTES_MAX_ATTEMPTS {
if attempt > 0 {
debug_log(&format!(
"retrying notes push (attempt {}/{})",
attempt + 1,
PUSH_NOTES_MAX_ATTEMPTS
));
}

fetch_and_merge_tracking_notes(repository, remote_name);

// Push notes without force (requires fast-forward)
let push_args = build_authorship_push_args(repository.global_args_for_exec(), remote_name);

debug_log(&format!(
"pushing authorship refs (no force): {:?}",
&push_args
));

match exec_git(&push_args) {
Ok(_) => return Ok(()),
Err(e) => {
debug_log(&format!("authorship push failed: {}", e));
if is_non_fast_forward_error(&e) && attempt + 1 < PUSH_NOTES_MAX_ATTEMPTS {
// Another pusher updated remote notes between our merge and push.
// Retry the full fetch-merge-push cycle.
last_error = Some(e);
continue;
}
return Err(e);
}
}
}

Err(last_error
.unwrap_or_else(|| GitAiError::Generic("notes push exhausted retries".to_string())))
}

/// Fetch remote notes into a tracking ref and merge into local refs/notes/ai.
fn fetch_and_merge_tracking_notes(repository: &Repository, remote_name: &str) {
let tracking_ref = tracking_ref_for_remote(remote_name);
let fetch_refspec = format!("+refs/notes/ai:{}", tracking_ref);

let fetch_before_push = build_authorship_fetch_args(
let fetch_args = build_authorship_fetch_args(
repository.global_args_for_exec(),
remote_name,
&fetch_refspec,
);

debug_log(&format!(
"pre-push authorship fetch: {:?}",
&fetch_before_push
));
debug_log(&format!("pre-push authorship fetch: {:?}", &fetch_args));

// Fetch is best-effort; if it fails (e.g., no remote notes yet), continue
if exec_git(&fetch_before_push).is_ok() {
// Merge fetched notes into local refs/notes/ai
let local_notes_ref = "refs/notes/ai";
if exec_git(&fetch_args).is_err() {
return;
}

if ref_exists(repository, &tracking_ref) {
if ref_exists(repository, local_notes_ref) {
// Both exist - merge them
debug_log(&format!(
"pre-push: merging {} into {}",
tracking_ref, local_notes_ref
));
if let Err(e) = merge_notes_from_ref(repository, &tracking_ref) {
debug_log(&format!("pre-push notes merge failed: {}", e));
}
} else {
// Only tracking ref exists - copy it to local
debug_log(&format!(
"pre-push: initializing {} from {}",
local_notes_ref, tracking_ref
));
if let Err(e) = copy_ref(repository, &tracking_ref, local_notes_ref) {
debug_log(&format!("pre-push notes copy failed: {}", e));
}
}
}
let local_notes_ref = "refs/notes/ai";

if !ref_exists(repository, &tracking_ref) {
return;
}

// STEP 2: Push notes without force (requires fast-forward)
let push_authorship =
build_authorship_push_args(repository.global_args_for_exec(), remote_name);
if !ref_exists(repository, local_notes_ref) {
// Only tracking ref exists - copy it to local
debug_log(&format!(
"pre-push: initializing {} from {}",
local_notes_ref, tracking_ref
));
if let Err(e) = copy_ref(repository, &tracking_ref, local_notes_ref) {
debug_log(&format!("pre-push notes copy failed: {}", e));
}
return;
}

// Both exist - merge them
debug_log(&format!(
"pushing authorship refs (no force): {:?}",
&push_authorship
"pre-push: merging {} into {}",
tracking_ref, local_notes_ref
));
if let Err(e) = exec_git(&push_authorship) {
// Best-effort; don't fail user operation due to authorship sync issues
debug_log(&format!("authorship push skipped due to error: {}", e));
return Err(e);
if let Err(e) = merge_notes_from_ref(repository, &tracking_ref) {
debug_log(&format!("pre-push notes merge failed: {}", e));
// Fallback: manually merge notes when git notes merge crashes
// (e.g., due to corrupted/mixed-fanout notes trees, or git bugs
// with fanout-level mismatches on older git versions like macOS)
if let Err(e2) = fallback_merge_notes_ours(repository, &tracking_ref) {
debug_log(&format!("pre-push fallback merge also failed: {}", e2));
}
}
}

Ok(())
fn is_non_fast_forward_error(error: &GitAiError) -> bool {
let GitAiError::GitCliError { stderr, .. } = error else {
return false;
};
stderr.contains("non-fast-forward")
}

fn extract_remote_from_fetch_args(args: &[String]) -> Option<String> {
Expand Down
Loading
Loading