diff --git a/src/authorship/diff_base.rs b/src/authorship/diff_base.rs index 9f452b24cf..83e48a6b05 100644 --- a/src/authorship/diff_base.rs +++ b/src/authorship/diff_base.rs @@ -1,4 +1,18 @@ -pub(crate) const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +pub(crate) const SHA1_EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +pub(crate) const SHA256_EMPTY_TREE: &str = + "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; + +pub(crate) fn empty_tree_for_oid(oid: &str) -> &'static str { + if oid.len() == 64 { + SHA256_EMPTY_TREE + } else { + SHA1_EMPTY_TREE + } +} + +pub(crate) fn is_empty_tree_oid(oid: &str) -> bool { + oid == SHA1_EMPTY_TREE || oid == SHA256_EMPTY_TREE +} /// Resolve the diff base for post-commit diff parsing so the diff is always /// bounded to the single commit being finalized. @@ -10,7 +24,7 @@ pub(crate) const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee490 /// tree hash because there is no parent revision. pub(crate) fn single_commit_diff_base(parent_sha: &str, commit_sha: &str) -> String { if parent_sha == "initial" { - EMPTY_TREE_SHA.to_string() + empty_tree_for_oid(commit_sha).to_string() } else { format!("{commit_sha}^") } diff --git a/src/authorship/post_commit.rs b/src/authorship/post_commit.rs index e29ef4deaf..e0fa9c42e0 100644 --- a/src/authorship/post_commit.rs +++ b/src/authorship/post_commit.rs @@ -747,7 +747,7 @@ pub(crate) fn post_commit_amend_with_recovery_timestamps_detailed( | crate::authorship::background_agent::BackgroundAgent::WithHooks { .. } ) { let diff_base = if parent_sha == "initial" { - "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + crate::authorship::diff_base::empty_tree_for_oid(amended_commit) } else { &parent_sha }; @@ -902,7 +902,7 @@ pub fn estimate_stats_cost_for_head( .map(|p| p.id()) .unwrap_or_else(|_| "initial".to_string()) } else { - "4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string() + crate::authorship::diff_base::empty_tree_for_oid(commit_sha).to_string() }; estimate_stats_cost_for_commit_range(repo, &parent_sha, commit_sha, ignore_patterns) } diff --git a/src/authorship/range_authorship.rs b/src/authorship/range_authorship.rs index 95b0221710..12c3bf8f87 100644 --- a/src/authorship/range_authorship.rs +++ b/src/authorship/range_authorship.rs @@ -223,7 +223,7 @@ fn create_authorship_log_for_range( // Special handling for empty tree: there's no start state to compare against // We only need the end state's attributions - if start_sha == EMPTY_TREE_HASH { + if crate::authorship::diff_base::is_empty_tree_oid(start_sha) { tracing::debug!("Start is empty tree - using only end commit attributions"); let repo_clone = repo.clone(); diff --git a/src/authorship/rewrite.rs b/src/authorship/rewrite.rs index 5d9235c50b..04c31cdbe7 100644 --- a/src/authorship/rewrite.rs +++ b/src/authorship/rewrite.rs @@ -10,8 +10,6 @@ use crate::git::repository::{ Repository, exec_git, exec_git_allow_nonzero, exec_git_stdin_streaming, }; -const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - #[derive(Debug)] pub enum RewriteEvent { NonFastForward { @@ -236,10 +234,6 @@ fn post_squash_metric_note_from_result( } } -fn empty_tree_sha() -> &'static str { - EMPTY_TREE_SHA -} - fn tree_revision_arg(sha: &str) -> Option { if sha == "initial" { None @@ -248,9 +242,13 @@ fn tree_revision_arg(sha: &str) -> Option { } } -fn insert_known_tree(sha_to_tree: &mut HashMap, sha: &str) -> bool { +fn insert_known_tree( + sha_to_tree: &mut HashMap, + sha: &str, + empty_tree: &str, +) -> bool { if sha == "initial" { - sha_to_tree.insert(sha.to_string(), empty_tree_sha().to_string()); + sha_to_tree.insert(sha.to_string(), empty_tree.to_string()); true } else { false @@ -277,9 +275,14 @@ fn resolve_tree_shas( ) -> Result, GitAiError> { let mut sha_to_tree = HashMap::new(); let mut shas_to_resolve = Vec::new(); + let empty_tree = unique_shas + .iter() + .find(|sha| sha.as_str() != "initial") + .map(|sha| crate::authorship::diff_base::empty_tree_for_oid(sha)) + .unwrap_or(crate::authorship::diff_base::SHA1_EMPTY_TREE); for sha in unique_shas { - if !insert_known_tree(&mut sha_to_tree, sha) { + if !insert_known_tree(&mut sha_to_tree, sha, empty_tree) { shas_to_resolve.push(sha.clone()); } } diff --git a/src/authorship/stats.rs b/src/authorship/stats.rs index 200938032d..44dad284a5 100644 --- a/src/authorship/stats.rs +++ b/src/authorship/stats.rs @@ -511,7 +511,8 @@ pub fn stats_for_commit_stats_with_parent_and_authorship( ) -> Result { use crate::commands::diff::get_diff_with_line_numbers; - let from_ref = parent_sha.unwrap_or("4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + let from_ref = + parent_sha.unwrap_or_else(|| crate::authorship::diff_base::empty_tree_for_oid(commit_sha)); let hunks = get_diff_with_line_numbers(repo, from_ref, commit_sha)?; stats_for_commit_stats_from_hunks(repo, commit_sha, ignore_patterns, &hunks, authorship_log) } @@ -686,7 +687,7 @@ pub fn get_git_diff_stats( } let from_ref = if parent_count == 0 { - "4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string() + crate::authorship::diff_base::empty_tree_for_oid(commit_sha).to_string() } else { commit_obj.parent(0)?.id() }; diff --git a/src/authorship/virtual_attribution.rs b/src/authorship/virtual_attribution.rs index 566ca73bae..7de3a7f3f8 100644 --- a/src/authorship/virtual_attribution.rs +++ b/src/authorship/virtual_attribution.rs @@ -1222,7 +1222,7 @@ fn collect_committed_hunks( // Handle initial commit (no parent) if parent_sha == "initial" { // For initial commit, use git diff against the empty tree - let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; // Git's empty tree hash + let empty_tree = crate::authorship::diff_base::empty_tree_for_oid(commit_sha); let added_lines = repo.diff_added_lines(empty_tree, commit_sha, pathspecs)?; for (file_path, lines) in added_lines { diff --git a/src/commands/diff.rs b/src/commands/diff.rs index 9a7fbca187..4ece17593c 100644 --- a/src/commands/diff.rs +++ b/src/commands/diff.rs @@ -455,14 +455,14 @@ fn resolve_parent(repo: &Repository, commit: &str) -> Result if sha.is_empty() { // No parent, this is initial commit - use empty tree - Ok("4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string()) + Ok(crate::authorship::diff_base::empty_tree_for_oid(commit).to_string()) } else { Ok(sha) } } Err(_) => { // No parent, this is initial commit - use empty tree hash - Ok("4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string()) + Ok(crate::authorship::diff_base::empty_tree_for_oid(commit).to_string()) } } } diff --git a/src/commands/log.rs b/src/commands/log.rs index 0df4e214b0..f7d2a0318b 100644 --- a/src/commands/log.rs +++ b/src/commands/log.rs @@ -663,7 +663,7 @@ fn render_stats( let parent_sha = parents .first() .map(String::as_str) - .unwrap_or("4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + .unwrap_or_else(|| crate::authorship::diff_base::empty_tree_for_oid(commit_sha)); if let Ok(estimate) = crate::authorship::post_commit::estimate_stats_cost_for_commit_range( repo, diff --git a/src/daemon/ref_cursor.rs b/src/daemon/ref_cursor.rs index d5508c25fd..0430a52e9e 100644 --- a/src/daemon/ref_cursor.rs +++ b/src/daemon/ref_cursor.rs @@ -5,6 +5,7 @@ use crate::git::cli_parser::{ explicit_rebase_branch_arg, parse_git_cli_args, summarize_rebase_args, }; use crate::git::find_repository_in_path; +use crate::git::reftable::{ReftableLogEntry, ReftableReader, reftable_stack_update_index}; use crate::git::repo_state::{common_dir_for_worktree, git_dir_for_worktree, is_valid_git_oid}; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; @@ -27,6 +28,9 @@ pub struct RefCursor { command_start_hints: HashMap, stash_stack: Vec, pending_cherry_pick_source_oids: Vec, + reftable_reader: ReftableReader, + reftable_entries: HashMap>, + uses_reftable: bool, } #[derive(Debug, Clone)] @@ -130,6 +134,9 @@ impl RefCursor { command_start_hints: HashMap::new(), stash_stack: Vec::new(), pending_cherry_pick_source_oids: Vec::new(), + reftable_reader: ReftableReader::default(), + reftable_entries: HashMap::new(), + uses_reftable: false, } } @@ -139,9 +146,9 @@ impl RefCursor { state: &FamilyState, ) -> Result, GitAiError> { cmd.ref_changes.clear(); + self.refresh_reftable_entries(cmd.worktree.as_deref())?; self.initialize_from_command_reflog_start_offsets(cmd)?; - let command_start_refs = - refs_at_reflog_start_offsets(&self.family, &cmd.reflog_start_offsets)?; + let command_start_refs = self.refs_at_command_start(cmd)?; if cmd.exit_code != 0 && !command_can_move_refs_on_nonzero(cmd.primary_command.as_deref()) { return Ok(command_start_refs); @@ -207,6 +214,200 @@ impl RefCursor { Ok(command_start_refs) } + fn refresh_reftable_entries(&mut self, worktree: Option<&Path>) -> Result<(), GitAiError> { + let common_stack = self.common_dir().join("reftable"); + let common_position = reftable_stack_update_index(&common_stack)?; + let uses_reftable = common_position.is_some(); + if self.uses_reftable != uses_reftable { + self.offsets.clear(); + self.anchors.clear(); + self.consumed_offsets.clear(); + self.consumed_anchors.clear(); + self.command_start_hints.clear(); + self.reftable_entries.clear(); + self.reftable_reader.reset_log_cache(); + } + self.uses_reftable = uses_reftable; + if !uses_reftable { + return Ok(()); + } + + let git_dir = worktree.and_then(git_dir_for_worktree); + let main_worktree = git_dir + .as_deref() + .is_some_and(|git_dir| self.git_dir_is_common(git_dir)); + let common_git_dir = self.common_dir(); + let common_head_key = head_key(&common_git_dir); + let had_common_head = self.reftable_entries.contains_key(&common_head_key); + if let Some(common_logs) = self.reftable_reader.read_logs_if_changed(&common_stack)? { + self.replace_reftable_stack_entries( + &common_stack, + (main_worktree || had_common_head).then_some(common_git_dir.as_path()), + common_logs, + ); + } + if main_worktree && !self.reftable_entries.contains_key(&common_head_key) { + let head_logs = self + .reftable_reader + .cached_logs(&common_stack) + .into_iter() + .flatten() + .filter(|log| log.reference == "HEAD") + .cloned() + .collect::>(); + for log in head_logs { + self.insert_reftable_entry(&common_stack, Some(&common_git_dir), log); + } + } + if let Some(git_dir) = git_dir.as_deref() + && !main_worktree + { + let head_stack = git_dir.join("reftable"); + if let Some(head_logs) = self.reftable_reader.read_logs_if_changed(&head_stack)? { + self.replace_reftable_stack_entries(&head_stack, Some(git_dir), head_logs); + } + } + Ok(()) + } + + fn replace_reftable_stack_entries( + &mut self, + stack_dir: &Path, + git_dir: Option<&Path>, + logs: Vec, + ) { + let stack_list = stack_dir.join("tables.list"); + self.reftable_entries.retain(|_, entries| { + entries.retain(|entry| entry.path != stack_list); + !entries.is_empty() + }); + for log in logs { + if log.reference != "HEAD" || git_dir.is_some() { + self.insert_reftable_entry(stack_dir, git_dir, log); + } + } + } + + fn insert_reftable_entry( + &mut self, + stack_dir: &Path, + git_dir: Option<&Path>, + log: ReftableLogEntry, + ) { + let key = if log.reference == "HEAD" { + let Some(git_dir) = git_dir else { + return; + }; + head_key(git_dir) + } else { + common_key(&log.reference) + }; + self.reftable_entries + .entry(key.clone()) + .or_default() + .push(CursorEntry { + key, + path: stack_dir.join("tables.list"), + reference: log.reference, + old: log.old_oid, + new: log.new_oid, + message: log.message, + timestamp_secs: Some(log.timestamp_secs), + start_offset: log.update_index.saturating_sub(1), + end_offset: log.update_index, + }); + } + + fn refs_at_command_start( + &self, + cmd: &NormalizedCommand, + ) -> Result, GitAiError> { + if !self.uses_reftable { + return refs_at_reflog_start_offsets(&self.family, &cmd.reflog_start_offsets); + } + let common_position = cmd + .reflog_start_offsets + .get(&reftable_common_key()) + .copied(); + let head_position = cmd + .worktree + .as_deref() + .and_then(git_dir_for_worktree) + .and_then(|git_dir| { + cmd.reflog_start_offsets + .get(&reftable_head_key(&git_dir)) + .copied() + .or(common_position) + }); + let mut refs = HashMap::new(); + for entries in self.reftable_entries.values() { + let Some(first) = entries.first() else { + continue; + }; + let position = if first.reference == "HEAD" { + head_position + } else { + common_position + }; + let Some(position) = position else { + continue; + }; + if let Some(entry) = entries + .iter() + .rev() + .find(|entry| entry.end_offset <= position) + && valid_non_zero_oid(&entry.new) + { + refs.insert(entry.reference.clone(), entry.new.clone()); + } + } + Ok(refs) + } + + fn read_entries( + &self, + key: String, + path: &Path, + reference: &str, + start_offset: Option, + ) -> Result, GitAiError> { + self.read_entries_with_noops(key, path, reference, start_offset, false) + } + + fn read_entries_with_noops( + &self, + key: String, + path: &Path, + reference: &str, + start_offset: Option, + include_noops: bool, + ) -> Result, GitAiError> { + if !self.uses_reftable { + return if include_noops { + read_reflog_entries_including_noops(key, path, reference, start_offset) + } else { + read_reflog_entries(key, path, reference, start_offset) + }; + } + Ok(self + .reftable_entries + .get(&key) + .into_iter() + .flatten() + .filter(|entry| start_offset.is_none_or(|start| entry.end_offset > start)) + .filter(|entry| include_noops || entry.old != entry.new) + .cloned() + .collect()) + } + + fn reftable_entry_at_or_before(&self, key: &str, position: u64) -> Option<&CursorEntry> { + self.reftable_entries + .get(key)? + .iter() + .filter(|entry| entry.end_offset <= position) + .max_by_key(|entry| entry.end_offset) + } + fn initialize_from_command_reflog_start_offsets( &mut self, cmd: &NormalizedCommand, @@ -219,11 +420,34 @@ impl RefCursor { return Ok(()); } - let offsets = cmd - .reflog_start_offsets - .iter() - .map(|(key, offset)| (key.clone(), *offset)) - .collect::>(); + let mut offsets = Vec::new(); + for (key, offset) in &cmd.reflog_start_offsets { + let offset_uses_reftable = + key == &reftable_common_key() || key.starts_with("reftable-worktree:"); + if offset_uses_reftable != self.uses_reftable { + continue; + } + if key == &reftable_common_key() { + offsets.extend( + self.reftable_entries + .keys() + .filter(|entry_key| entry_key.starts_with("common:")) + .cloned() + .map(|entry_key| (entry_key, *offset)), + ); + if let Some(worktree) = cmd.worktree.as_deref() + && let Some(git_dir) = git_dir_for_worktree(worktree) + && self.git_dir_is_common(&git_dir) + { + offsets.push((head_key(&git_dir), *offset)); + } + } else if let Some(git_dir) = key.strip_prefix("reftable-worktree:").map(PathBuf::from) + { + offsets.push((head_key(&git_dir), *offset)); + } else { + offsets.push((key.clone(), *offset)); + } + } for (key, offset) in offsets { if self.offsets.contains_key(&key) { // An authoritative in-order cursor already exists (established by @@ -297,7 +521,7 @@ impl RefCursor { } else { "HEAD".to_string() }; - let entries = read_reflog_entries(key.to_string(), &path, &reference, None)?; + let entries = self.read_entries(key.to_string(), &path, &reference, None)?; let earliest_own = entries .into_iter() .filter(|entry| { @@ -323,7 +547,7 @@ impl RefCursor { } else { "HEAD".to_string() }; - let entries = read_reflog_entries(key.to_string(), &path, &reference, None)?; + let entries = self.read_entries(key.to_string(), &path, &reference, None)?; Ok( head_span_start_near_offset(&entries, offset, &prefix_refs, expected, limit) .unwrap_or(offset), @@ -351,7 +575,8 @@ impl RefCursor { } else { "HEAD".to_string() }; - let entries = read_reflog_entries_including_noops(key.to_string(), path, &reference, None)?; + let entries = + self.read_entries_with_noops(key.to_string(), path, &reference, None, true)?; let prefixes = pull_reflog_message_prefixes(action); let prefix_refs = prefixes.iter().map(String::as_str).collect::>(); @@ -377,7 +602,8 @@ impl RefCursor { } else { "HEAD".to_string() }; - let entries = read_reflog_entries_including_noops(key.to_string(), path, &reference, None)?; + let entries = + self.read_entries_with_noops(key.to_string(), path, &reference, None, true)?; if key.starts_with("common:") { return Ok( clamp_seed_to_entry_containing_offset(&entries, offset, &["rebase"]) @@ -998,30 +1224,54 @@ impl RefCursor { cmd: &mut NormalizedCommand, ) -> Result<(), GitAiError> { let key = common_key("refs/stash"); - let old_cursor = self.offsets.get(&key).copied(); - let log_len_after = self.common_ref_log_len("refs/stash")?; - let log_was_rewritten = match (old_cursor, log_len_after) { - (Some(cursor), Some(len)) => len < cursor, - (Some(_), None) => true, - _ => false, + let target_oid = cmd + .stash_target_oid + .clone() + .or_else(|| self.resolve_stash_target_at_cursor(target).ok().flatten()); + let log_was_rewritten = if self.uses_reftable { + target_oid.as_ref().is_some_and(|target_oid| { + self.reftable_entries + .get(&key) + .is_none_or(|entries| entries.iter().all(|entry| entry.new != *target_oid)) + }) + } else { + let old_cursor = self.offsets.get(&key).copied(); + let log_len_after = self.common_ref_log_len("refs/stash")?; + match (old_cursor, log_len_after) { + (Some(cursor), Some(len)) => len < cursor, + (Some(_), None) => true, + _ => false, + } }; if !log_was_rewritten { return Ok(()); } - let target_oid = cmd - .stash_target_oid - .clone() - .or_else(|| self.resolve_stash_target_at_cursor(target).ok().flatten()); let Some(target_oid) = target_oid else { self.sync_common_ref_cursor_to_log_end_after_rewrite("refs/stash")?; return Ok(()); }; let target_index = stash_target_index(target); - let old_top = self.stash_stack.first().cloned(); - self.remove_stash_from_stack(target_index, &target_oid); + let old_top = self + .stash_stack + .first() + .cloned() + .or_else(|| (target_index.unwrap_or(0) == 0).then(|| target_oid.clone())); + if self.uses_reftable { + self.stash_stack = self + .reftable_entries + .get(&key) + .into_iter() + .flatten() + .rev() + .filter(|entry| valid_non_zero_oid(&entry.new)) + .map(|entry| entry.new.clone()) + .collect(); + } else { + self.remove_stash_from_stack(target_index, &target_oid); + } let new_top = self.stash_stack.first().cloned().unwrap_or_else(zero_oid); if old_top.as_deref() == Some(target_oid.as_str()) { @@ -1187,7 +1437,7 @@ impl RefCursor { let key = head_key(&git_dir); let path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key, &path, "HEAD", start)?; + let entries = self.read_entries(key, &path, "HEAD", start)?; Ok(entries.into_iter().find(|entry| { !self.entry_consumed(entry) @@ -1219,7 +1469,7 @@ impl RefCursor { let head_path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&head_key, &head_path)?; let head_entries = - read_reflog_entries_including_noops(head_key, &head_path, "HEAD", start)?; + self.read_entries_with_noops(head_key, &head_path, "HEAD", start, true)?; let Some(start_marker) = rebase_start_marker_for_explicit_branch(&head_entries, &branch_ref) else { @@ -1435,7 +1685,7 @@ impl RefCursor { let key = head_key(&git_dir); let path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key, &path, "HEAD", start)?; + let entries = self.read_entries(key, &path, "HEAD", start)?; Ok(entries.into_iter().find(|entry| { !self.entry_consumed(entry) @@ -1464,7 +1714,7 @@ impl RefCursor { let path = git_dir.join("logs").join("HEAD"); let key = head_key(&git_dir); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key, &path, "HEAD", start)?; + let entries = self.read_entries(key, &path, "HEAD", start)?; let mut contiguous = VecDeque::::new(); let hint = self.command_start_hints.get(&head_key(&git_dir)).copied(); let mut latest_before_hint: Option = None; @@ -1518,7 +1768,8 @@ impl RefCursor { let path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&key, &path)?; let command_window = reflog_timestamp_window(cmd); - let candidates = read_reflog_entries(key.clone(), &path, "HEAD", start)? + let candidates = self + .read_entries(key.clone(), &path, "HEAD", start)? .into_iter() .filter(|entry| { !self.entry_consumed(entry) @@ -1599,7 +1850,8 @@ impl RefCursor { }; let key = head_key(&git_dir); let path = git_dir.join("logs").join("HEAD"); - Ok(read_reflog_entries(key, &path, "HEAD", Some(start_offset))? + Ok(self + .read_entries(key, &path, "HEAD", Some(start_offset))? .into_iter() .find(|entry| { !self.entry_consumed(entry) @@ -1681,7 +1933,7 @@ impl RefCursor { }; let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key.clone(), &path, reference, start)?; + let entries = self.read_entries(key.clone(), &path, reference, start)?; let matches = entries .into_iter() .filter(|entry| matches_command(entry, self)) @@ -1690,7 +1942,8 @@ impl RefCursor { return Ok(matches); } - Ok(read_reflog_entries(key, &path, reference, None)? + Ok(self + .read_entries(key, &path, reference, None)? .into_iter() .filter(|entry| matches_command(entry, self)) .collect()) @@ -1738,7 +1991,7 @@ impl RefCursor { let path = self.common_dir().join("logs").join("refs/stash"); let key = common_key("refs/stash"); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key, &path, "refs/stash", start)?; + let entries = self.read_entries(key, &path, "refs/stash", start)?; Ok(entries.into_iter().find(|entry| { !self.entry_consumed(entry) @@ -1769,7 +2022,7 @@ impl RefCursor { use_hint: bool, ) -> Result, GitAiError> { let start = self.reflog_start_offset(&key, path)?; - let entries = read_reflog_entries(key.clone(), path, reference, start)?; + let entries = self.read_entries(key.clone(), path, reference, start)?; let mut candidates = entries.into_iter().filter(|entry| { !self.entry_consumed(entry) && expected.matches(entry) @@ -1899,7 +2152,7 @@ impl RefCursor { let key = head_key(&git_dir); let path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries_including_noops(key, &path, "HEAD", start)?; + let entries = self.read_entries_with_noops(key, &path, "HEAD", start, true)?; let Some(finish) = entries .into_iter() .find(|entry| entry.new == new && branch_from_message(&entry.message).is_some()) @@ -1956,7 +2209,7 @@ impl RefCursor { } let path = self.common_dir().join("logs").join("refs/stash"); let key = common_key("refs/stash"); - let entries = read_reflog_entries(key.clone(), &path, "refs/stash", Some(0))?; + let entries = self.read_entries(key.clone(), &path, "refs/stash", Some(0))?; let cursor = self.offsets.get(&key).copied().unwrap_or(u64::MAX); let mut stack = entries .into_iter() @@ -1992,6 +2245,16 @@ impl RefCursor { } fn discover_common_refs(&self) -> Result, GitAiError> { + if self.uses_reftable { + let mut refs = self + .reftable_entries + .keys() + .filter_map(|key| key.strip_prefix("common:").map(ToString::to_string)) + .collect::>(); + refs.sort(); + refs.dedup(); + return Ok(refs); + } let logs = self.common_dir().join("logs"); let mut refs = Vec::new(); discover_reflog_refs(&logs, &logs, &mut refs)?; @@ -2019,6 +2282,19 @@ impl RefCursor { return Ok(Some(0)); } + if self.uses_reftable { + if let Some(anchor) = self.anchors.get(key) { + let current_anchor = self + .reftable_entry_at_or_before(key, offset) + .map(ReflogAnchor::from); + if current_anchor.as_ref() != Some(anchor) { + self.clear_ref_cursor(key); + return Ok(None); + } + } + return Ok(Some(offset)); + } + let len = match fs::metadata(path) { Ok(metadata) => metadata.len(), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -2051,6 +2327,15 @@ impl RefCursor { self.anchors.remove(key); return Ok(()); } + if self.uses_reftable { + if let Some(entry) = self.reftable_entry_at_or_before(key, offset) { + self.anchors + .insert(key.to_string(), ReflogAnchor::from(entry)); + } else { + self.anchors.remove(key); + } + return Ok(()); + } let Some(path) = self.reflog_path_for_key(key) else { self.anchors.remove(key); return Ok(()); @@ -2082,6 +2367,12 @@ impl RefCursor { } fn reflog_has_records_after_offset(&self, key: &str, offset: u64) -> Result { + if self.uses_reftable { + return Ok(self + .reftable_entries + .get(key) + .is_some_and(|entries| entries.iter().any(|entry| entry.end_offset > offset))); + } let Some(path) = self.reflog_path_for_key(key) else { return Ok(false); }; @@ -2140,7 +2431,7 @@ impl RefCursor { reference: &str, ) -> Result<(), GitAiError> { let start = self.offsets.get(key).copied(); - let entries = read_reflog_entries(key.to_string(), path, reference, start)?; + let entries = self.read_entries(key.to_string(), path, reference, start)?; let mut advanced_to = start.unwrap_or(0); let mut anchor = None; for entry in entries { @@ -2181,7 +2472,7 @@ impl RefCursor { let path = self.common_dir().join("logs").join(reference); let key = common_key(reference); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key.clone(), &path, reference, start)?; + let entries = self.read_entries(key.clone(), &path, reference, start)?; for entry in entries { let Some((old_reference, new_reference)) = parse_branch_lifecycle_message(kind, &entry.message) @@ -2206,6 +2497,19 @@ impl RefCursor { ) -> Result<(), GitAiError> { let key = common_key(reference); let path = self.common_dir().join("logs").join(reference); + if self.uses_reftable { + if let Some(entry) = self + .reftable_entries + .get(&key) + .and_then(|entries| entries.last()) + .cloned() + { + self.advance_cursor_to_entry(&entry); + } else { + self.clear_ref_cursor(&key); + } + return Ok(()); + } match fs::metadata(&path) { Ok(metadata) => { let len = metadata.len(); @@ -2228,6 +2532,13 @@ impl RefCursor { } fn common_ref_log_len(&self, reference: &str) -> Result, GitAiError> { + if self.uses_reftable { + return Ok(self + .reftable_entries + .get(&common_key(reference)) + .and_then(|entries| entries.last()) + .map(|entry| entry.end_offset)); + } let path = self.common_dir().join("logs").join(reference); match fs::metadata(path) { Ok(metadata) => Ok(Some(metadata.len())), @@ -2244,7 +2555,7 @@ impl RefCursor { let path = self.common_dir().join("logs").join(branch_ref); let key = common_key(branch_ref); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key.clone(), &path, branch_ref, start)?; + let entries = self.read_entries(key.clone(), &path, branch_ref, start)?; if let Some(finished_new) = finished_new && let Some(entry) = entries.iter().rev().find(|entry| { @@ -2283,6 +2594,10 @@ impl RefCursor { PathBuf::from(&self.family.0) } + fn git_dir_is_common(&self, git_dir: &Path) -> bool { + head_key(git_dir) == head_key(&self.common_dir()) + } + fn head_expected_transition( &self, cmd: &NormalizedCommand, @@ -2317,6 +2632,20 @@ impl RefCursor { pub(crate) fn capture_reflog_start_offsets_for_worktree(worktree: &Path) -> HashMap { let mut offsets = HashMap::new(); + let Some(common_dir) = common_dir_for_worktree(worktree) else { + return offsets; + }; + if let Ok(Some(position)) = reftable_stack_update_index(&common_dir.join("reftable")) { + offsets.insert(reftable_common_key(), position); + if let Some(git_dir) = git_dir_for_worktree(worktree) + && git_dir != common_dir + && let Ok(Some(head_position)) = reftable_stack_update_index(&git_dir.join("reftable")) + { + offsets.insert(reftable_head_key(&git_dir), head_position); + } + return offsets; + } + if let Some(git_dir) = git_dir_for_worktree(worktree) { let path = git_dir.join("logs").join("HEAD"); if let Ok(metadata) = fs::metadata(&path) { @@ -2324,9 +2653,6 @@ pub(crate) fn capture_reflog_start_offsets_for_worktree(worktree: &Path) -> Hash } } - let Some(common_dir) = common_dir_for_worktree(worktree) else { - return offsets; - }; let logs = common_dir.join("logs"); let mut refs = Vec::new(); if discover_reflog_refs(&logs, &logs, &mut refs).is_ok() { @@ -3797,6 +4123,19 @@ fn common_key(reference: &str) -> String { format!("common:{}", reference) } +fn reftable_common_key() -> String { + "reftable-common".to_string() +} + +fn reftable_head_key(git_dir: &Path) -> String { + let normalized = git_dir + .canonicalize() + .unwrap_or_else(|_| git_dir.to_path_buf()) + .to_string_lossy() + .to_string(); + format!("reftable-worktree:{normalized}") +} + fn branch_arg_to_ref(branch: &str) -> String { if branch.starts_with("refs/") { branch.to_string() @@ -3936,6 +4275,197 @@ mod tests { } } + fn reftable_cursor_entry(key: &str, path: &Path, end_offset: u64) -> CursorEntry { + CursorEntry { + key: key.to_string(), + path: path.to_path_buf(), + reference: key.strip_prefix("common:").unwrap_or("HEAD").to_string(), + old: A.to_string(), + new: B.to_string(), + message: "test update".to_string(), + timestamp_secs: Some(0), + start_offset: end_offset.saturating_sub(1), + end_offset, + } + } + + #[test] + fn reftable_cursor_keeps_stack_watermark_between_ref_updates() { + let temp = tempfile::tempdir().unwrap(); + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family); + cursor.uses_reftable = true; + let key = common_key("refs/heads/main"); + let path = temp.path().join("reftable/tables.list"); + cursor + .reftable_entries + .insert(key.clone(), vec![reftable_cursor_entry(&key, &path, 3)]); + + cursor.initialize_reflog_cursor(&key, 5).unwrap(); + + assert_eq!(cursor.reflog_start_offset(&key, &path).unwrap(), Some(5)); + assert_eq!(cursor.offsets.get(&key), Some(&5)); + } + + #[test] + fn reftable_refresh_without_worktree_keeps_common_ref_history() { + let repo = crate::git::reftable::tests::native_reftable_repo("sha1"); + let common_dir = repo.path().join(".git"); + let family = FamilyKey::new(common_dir.to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family); + + cursor.refresh_reftable_entries(None).unwrap(); + + assert!(cursor.uses_reftable); + assert!( + cursor + .discover_common_refs() + .unwrap() + .contains(&"refs/heads/main".to_string()) + ); + assert!( + cursor + .reftable_entries + .keys() + .all(|key| key.starts_with("common:")), + "worktree-local HEAD history must be skipped without a worktree" + ); + + cursor.refresh_reftable_entries(Some(repo.path())).unwrap(); + + assert!( + cursor.reftable_entries.contains_key(&head_key(&common_dir)), + "main-worktree HEAD history should be materialized from the cached stack" + ); + } + + #[test] + fn failed_reftable_stash_drop_does_not_confuse_stack_and_ref_watermarks() { + let temp = tempfile::tempdir().unwrap(); + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family.clone()); + cursor.uses_reftable = true; + let key = common_key("refs/stash"); + let path = temp.path().join("reftable/tables.list"); + cursor + .reftable_entries + .insert(key.clone(), vec![reftable_cursor_entry(&key, &path, 3)]); + cursor.offsets.insert(key, 5); + cursor.stash_stack.push(B.to_string()); + let mut cmd = command(&family, &["stash", "drop"]); + cmd.exit_code = 1; + + cursor + .consume_destructive_stash_operation(None, &mut cmd) + .unwrap(); + + assert!(cmd.ref_changes.is_empty()); + assert_eq!(cursor.stash_stack, vec![B.to_string()]); + } + + #[test] + fn reftable_stash_drop_detects_target_missing_from_in_memory_stack() { + let temp = tempfile::tempdir().unwrap(); + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family.clone()); + cursor.uses_reftable = true; + let key = common_key("refs/stash"); + let path = temp.path().join("reftable/tables.list"); + cursor + .reftable_entries + .insert(key.clone(), vec![reftable_cursor_entry(&key, &path, 4)]); + cursor.offsets.insert(key, 5); + let mut cmd = command(&family, &["stash", "drop"]); + cmd.stash_target_oid = Some(C.to_string()); + + cursor + .consume_destructive_stash_operation(None, &mut cmd) + .unwrap(); + + assert_eq!( + cmd.ref_changes, + vec![RefChange { + reference: "refs/stash".to_string(), + old: C.to_string(), + new: B.to_string(), + }] + ); + assert_eq!(cursor.stash_stack, vec![B.to_string()]); + } + + #[test] + fn ignores_file_offsets_after_repository_migrates_to_reftable() { + let temp = tempfile::tempdir().unwrap(); + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family.clone()); + cursor.uses_reftable = true; + let key = common_key("refs/heads/main"); + cursor.reftable_entries.insert( + key.clone(), + vec![reftable_cursor_entry( + &key, + &temp.path().join("reftable/tables.list"), + 3, + )], + ); + let mut cmd = command(&family, &["refs", "migrate"]); + cmd.reflog_start_offsets.insert(key.clone(), 170); + + cursor + .initialize_from_command_reflog_start_offsets(&cmd) + .unwrap(); + + assert!(!cursor.offsets.contains_key(&key)); + } + + #[test] + fn ignores_reftable_offsets_after_repository_migrates_to_files() { + let temp = tempfile::tempdir().unwrap(); + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family.clone()); + let mut cmd = command(&family, &["refs", "migrate"]); + cmd.reflog_start_offsets.insert(reftable_common_key(), 5); + + cursor + .initialize_from_command_reflog_start_offsets(&cmd) + .unwrap(); + + assert!(cursor.offsets.is_empty()); + } + + #[cfg(unix)] + #[test] + fn reftable_common_watermark_seeds_head_through_symlinked_worktree() { + let temp = tempfile::tempdir().unwrap(); + let worktree = temp.path().join("real"); + let git_dir = create_git_dir(&worktree); + let symlinked_worktree = temp.path().join("linked-path"); + std::os::unix::fs::symlink(&worktree, &symlinked_worktree).unwrap(); + let family = FamilyKey::new( + git_dir + .canonicalize() + .unwrap() + .to_string_lossy() + .to_string(), + ); + let mut cursor = RefCursor::new(family.clone()); + cursor.uses_reftable = true; + let key = head_key(&symlinked_worktree.join(".git")); + let path = git_dir.join("reftable/tables.list"); + cursor + .reftable_entries + .insert(key.clone(), vec![reftable_cursor_entry(&key, &path, 6)]); + let mut cmd = + command_with_worktree(&family, Some(symlinked_worktree), &["commit", "-m", "test"]); + cmd.reflog_start_offsets.insert(reftable_common_key(), 5); + + cursor + .initialize_from_command_reflog_start_offsets(&cmd) + .unwrap(); + + assert_eq!(cursor.offsets.get(&key), Some(&5)); + } + #[test] fn cold_start_late_ingress_offset_does_not_skip_commit_on_uninitialized_head_cursor() { // Regression for the concurrent-burst / rebase-patch-stack flake. Unlike diff --git a/src/git/fast_reader.rs b/src/git/fast_reader.rs index f7507769fe..e6fa8b8df5 100644 --- a/src/git/fast_reader.rs +++ b/src/git/fast_reader.rs @@ -45,6 +45,12 @@ impl<'a> FastRefReader<'a> { if let Some(refname) = trimmed.strip_prefix("ref: ") { let refname = refname.trim(); + // Reftable keeps the real HEAD in its table stack and leaves this + // sentinel in the legacy file. Let the caller use the native + // reftable reader instead of treating the sentinel as a branch. + if refname == "refs/heads/.invalid" { + return None; + } if !refname.is_empty() { return Some(HeadKind::Symbolic(refname.to_string())); } diff --git a/src/git/mod.rs b/src/git/mod.rs index e9da736d07..5995a76d08 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -3,6 +3,7 @@ pub mod command_classification; pub mod fast_reader; pub mod notes_api; pub mod refs; +pub(crate) mod reftable; pub mod repo_state; pub mod repository; diff --git a/src/git/reftable.rs b/src/git/reftable.rs new file mode 100644 index 0000000000..7e70cc81ea --- /dev/null +++ b/src/git/reftable.rs @@ -0,0 +1,1247 @@ +// Reftable block decoding adapted from `sley-formats` (Apache-2.0): +// https://github.com/HeddleCo/sley +use crate::error::GitAiError; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const REFTABLE_MAGIC: &[u8; 4] = b"REFT"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReftableVersion { + V1, + V2, +} + +impl ReftableVersion { + fn header_len(self) -> usize { + match self { + Self::V1 => 24, + Self::V2 => 28, + } + } + + fn footer_len(self) -> usize { + match self { + Self::V1 => 68, + Self::V2 => 72, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ReftableHeader { + version: ReftableVersion, + block_size: u32, + min_update_index: u64, + max_update_index: u64, + oid_len: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedLogValue { + Deletion, + DeleteLog, + Update(ReftableLogEntry), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedLogRecord { + reference: String, + update_index: u64, + value: ParsedLogValue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ReftableRefValue { + Deletion, + Direct(String), + Symbolic(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ReftableRefEntry { + name: String, + value: ReftableRefValue, +} + +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedTable { + refs: Vec, + logs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReftableLogEntry { + pub reference: String, + pub update_index: u64, + pub old_oid: String, + pub new_oid: String, + pub message: String, + pub timestamp_secs: i64, +} + +#[derive(Debug, Default)] +pub(crate) struct ReftableReader { + parsed_refs: HashMap<(std::path::PathBuf, String), Option>, + parsed_logs: HashMap>, + unreadable_log_tables: HashSet, + log_snapshots: HashMap, +} + +#[derive(Debug)] +struct ReftableLogSnapshot { + active_paths: Vec, + logs: Vec, +} + +impl ReftableReader { + #[cfg(test)] + pub(crate) fn read_logs( + &mut self, + stack_dir: &Path, + ) -> Result, GitAiError> { + if let Some(logs) = self.read_logs_if_changed(stack_dir)? { + return Ok(logs); + } + Ok(self + .log_snapshots + .get(stack_dir) + .map(|snapshot| snapshot.logs.clone()) + .unwrap_or_default()) + } + + pub(crate) fn read_logs_if_changed( + &mut self, + stack_dir: &Path, + ) -> Result>, GitAiError> { + for attempt in 0..2 { + let active_paths = active_table_paths(stack_dir)?; + if self + .log_snapshots + .get(stack_dir) + .is_some_and(|snapshot| snapshot.active_paths == active_paths) + { + return Ok(None); + } + let mut visible = BTreeMap::<(String, u64), ReftableLogEntry>::new(); + let mut retry_stack = false; + let mut cache_snapshot = true; + for table_path in &active_paths { + if self.unreadable_log_tables.contains(table_path) { + continue; + } + let records = match self.parsed_logs(table_path) { + Ok(records) => records, + Err(error) if attempt == 0 && is_not_found(&error) => { + retry_stack = true; + break; + } + Err(error) => { + if is_invalid_reftable(&error) { + self.unreadable_log_tables.insert(table_path.clone()); + } else { + // Reftable files are immutable, so parser failures are + // deterministic. I/O failures can be transient and must + // be retried by the next command rather than poisoning + // this repo family's reader for its whole lifetime. + cache_snapshot = false; + } + tracing::warn!( + path = %table_path.display(), + error = %error, + "skipping unreadable reftable log table" + ); + continue; + } + }; + // Log keys sort by ref name and descending update index. Apply each + // ref's records oldest-to-newest so a delete-log marker only removes + // history that predates it, not later entries in the same table. + for record in records.iter().rev() { + let key = (record.reference.clone(), record.update_index); + match &record.value { + ParsedLogValue::Deletion => { + visible.remove(&key); + } + ParsedLogValue::DeleteLog => { + visible.retain(|(reference, _), _| reference != &record.reference); + } + ParsedLogValue::Update(entry) => { + visible.insert(key, entry.clone()); + } + } + } + } + if retry_stack { + continue; + } + self.prune_stack_cache(stack_dir, &active_paths); + let mut logs = visible.into_values().collect::>(); + logs.sort_by(|left, right| { + left.update_index + .cmp(&right.update_index) + .then_with(|| left.reference.cmp(&right.reference)) + }); + if cache_snapshot { + self.log_snapshots.insert( + stack_dir.to_path_buf(), + ReftableLogSnapshot { + active_paths, + logs: logs.clone(), + }, + ); + } else { + self.log_snapshots.remove(stack_dir); + } + return Ok(Some(logs)); + } + Ok(Some(Vec::new())) + } + + pub(crate) fn cached_logs(&self, stack_dir: &Path) -> Option<&[ReftableLogEntry]> { + self.log_snapshots + .get(stack_dir) + .map(|snapshot| snapshot.logs.as_slice()) + } + + pub(crate) fn reset_log_cache(&mut self) { + self.parsed_refs.clear(); + self.parsed_logs.clear(); + self.unreadable_log_tables.clear(); + self.log_snapshots.clear(); + } + + fn read_ref( + &mut self, + stack_dir: &Path, + reference: &str, + ) -> Result, GitAiError> { + for attempt in 0..2 { + let active_paths = active_table_paths(stack_dir)?; + let mut value = None; + let mut retry_stack = false; + for table_path in &active_paths { + let entry = match self.parsed_ref(table_path, reference) { + Ok(entry) => entry, + Err(error) if attempt == 0 && is_not_found(&error) => { + retry_stack = true; + break; + } + Err(error) if is_not_found(&error) => continue, + Err(error) => return Err(error), + }; + match entry { + Some(ReftableRefValue::Deletion) => value = None, + Some(entry) => value = Some(entry), + None => {} + } + } + if retry_stack { + continue; + } + self.prune_stack_cache(stack_dir, &active_paths); + return Ok(value); + } + Ok(None) + } + + fn parsed_ref( + &mut self, + table_path: &Path, + reference: &str, + ) -> Result, GitAiError> { + let key = (table_path.to_path_buf(), reference.to_string()); + if !self.parsed_refs.contains_key(&key) { + let value = + parse_table_ref(&fs::read(table_path)?, reference)?.map(|entry| entry.value); + self.parsed_refs.insert(key.clone(), value); + } + Ok(self + .parsed_refs + .get(&key) + .expect("cached reftable ref must be present") + .clone()) + } + + fn parsed_logs(&mut self, table_path: &Path) -> Result<&Vec, GitAiError> { + if !self.parsed_logs.contains_key(table_path) { + let logs = parse_table_logs(&fs::read(table_path)?)?; + self.parsed_logs.insert(table_path.to_path_buf(), logs); + } + Ok(self + .parsed_logs + .get(table_path) + .expect("cached reftable logs must be present")) + } + + fn prune_stack_cache(&mut self, stack_dir: &Path, active_paths: &[std::path::PathBuf]) { + let active_paths = active_paths.iter().cloned().collect::>(); + self.parsed_refs + .retain(|(path, _), _| !path.starts_with(stack_dir) || active_paths.contains(path)); + self.parsed_logs + .retain(|path, _| !path.starts_with(stack_dir) || active_paths.contains(path)); + self.unreadable_log_tables + .retain(|path| !path.starts_with(stack_dir) || active_paths.contains(path)); + self.log_snapshots + .retain(|path, _| path != stack_dir || !active_paths.is_empty()); + } + + pub(crate) fn read_head( + &mut self, + common_stack: &Path, + worktree_stack: &Path, + ) -> Result)>, GitAiError> { + let mut head = self.read_ref(common_stack, "HEAD")?; + if worktree_stack != common_stack + && let Some(worktree_head) = self.read_ref(worktree_stack, "HEAD")? + { + head = Some(worktree_head); + } + match head { + Some(ReftableRefValue::Direct(oid)) => Ok(Some((oid, None))), + Some(ReftableRefValue::Symbolic(target)) => { + let Some(ReftableRefValue::Direct(oid)) = self.read_ref(common_stack, &target)? + else { + return Ok(None); + }; + Ok(Some((oid, Some(target)))) + } + _ => Ok(None), + } + } +} + +fn is_not_found(error: &GitAiError) -> bool { + matches!(error, GitAiError::IoError(error) if error.kind() == std::io::ErrorKind::NotFound) +} + +fn is_invalid_reftable(error: &GitAiError) -> bool { + matches!(error, GitAiError::Generic(message) if message.starts_with("invalid reftable:")) +} + +fn active_table_paths(stack_dir: &Path) -> Result, GitAiError> { + let table_names = match fs::read_to_string(stack_dir.join("tables.list")) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + table_names + .lines() + .filter(|line| !line.is_empty()) + .map(|table_name| { + if Path::new(table_name) + .file_name() + .and_then(|name| name.to_str()) + != Some(table_name) + { + return Err(invalid_reftable("invalid table name in tables.list")); + } + Ok(stack_dir.join(table_name)) + }) + .collect() +} + +pub(crate) fn reftable_stack_update_index(stack_dir: &Path) -> Result, GitAiError> { + let table_names = match fs::read_to_string(stack_dir.join("tables.list")) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + table_names + .lines() + .filter(|line| !line.is_empty()) + .map(|table_name| { + let mut parts = table_name.split('-'); + let _min = parts.next(); + let max = parts + .next() + .and_then(|value| value.strip_prefix("0x")) + .ok_or_else(|| invalid_reftable("invalid table name in tables.list"))?; + u64::from_str_radix(max, 16).map_err(|_| invalid_reftable("invalid table update index")) + }) + .try_fold(None, |maximum, index| { + let index = index?; + Ok(Some( + maximum.map_or(index, |current: u64| current.max(index)), + )) + }) +} + +#[cfg(test)] +pub(crate) fn read_reftable_logs(stack_dir: &Path) -> Result, GitAiError> { + ReftableReader::default().read_logs(stack_dir) +} + +#[cfg(test)] +fn parse_table(bytes: &[u8]) -> Result { + Ok(ParsedTable { + refs: parse_table_refs(bytes)?, + logs: parse_table_logs(bytes)?, + }) +} + +#[derive(Debug, Clone, Copy)] +struct TableLayout { + header: ReftableHeader, + ref_end: usize, + log_section: Option<(usize, usize)>, +} + +fn parse_table_layout(bytes: &[u8]) -> Result { + let header = parse_header(bytes)?; + let footer_start = bytes + .len() + .checked_sub(header.version.footer_len()) + .ok_or_else(|| invalid_reftable("truncated footer"))?; + let footer = parse_header(&bytes[footer_start..])?; + if footer != header { + return Err(invalid_reftable("footer header does not match file header")); + } + let expected_crc = read_u32(bytes, bytes.len() - 4)?; + let actual_crc = crc32(&bytes[footer_start..bytes.len() - 4]); + if actual_crc != expected_crc { + return Err(invalid_reftable("footer CRC mismatch")); + } + + let mut footer_offset = footer_start + header.version.header_len(); + let ref_index_position = read_u64(bytes, footer_offset)? as usize; + footer_offset += 8; + let object_position = (read_u64(bytes, footer_offset)? >> 5) as usize; + footer_offset += 8; + let object_index_position = read_u64(bytes, footer_offset)? as usize; + footer_offset += 8; + let footer_log_position = read_u64(bytes, footer_offset)? as usize; + footer_offset += 8; + let log_index_position = read_u64(bytes, footer_offset)? as usize; + let ref_end = [ + ref_index_position, + object_position, + object_index_position, + footer_log_position, + log_index_position, + footer_start, + ] + .into_iter() + .filter(|position| *position != 0) + .min() + .unwrap_or(footer_start); + let log_position = if footer_log_position != 0 { + Some(footer_log_position) + } else if bytes.get(header.version.header_len()) == Some(&b'g') { + Some(header.version.header_len()) + } else { + None + }; + let log_end = if log_index_position == 0 { + footer_start + } else { + log_index_position.min(footer_start) + }; + if log_position.is_some_and(|position| position >= log_end) { + return Err(invalid_reftable("log section position is out of bounds")); + } + Ok(TableLayout { + header, + ref_end, + log_section: log_position.map(|position| (position, log_end)), + }) +} + +#[cfg(test)] +fn parse_table_refs(bytes: &[u8]) -> Result, GitAiError> { + let layout = parse_table_layout(bytes)?; + parse_ref_section(bytes, layout.header, layout.ref_end, None) +} + +fn parse_table_ref(bytes: &[u8], reference: &str) -> Result, GitAiError> { + let layout = parse_table_layout(bytes)?; + Ok(parse_ref_section(bytes, layout.header, layout.ref_end, Some(reference))?.pop()) +} + +fn parse_table_logs(bytes: &[u8]) -> Result, GitAiError> { + let layout = parse_table_layout(bytes)?; + let Some((log_position, log_end)) = layout.log_section else { + return Ok(Vec::new()); + }; + + let mut records = Vec::new(); + let mut offset = log_position; + while offset < log_end { + if bytes[offset] == 0 { + offset += 1; + continue; + } + if bytes[offset] != b'g' { + break; + } + let uncompressed_len = read_u24(bytes, offset + 1)? as usize; + if uncompressed_len < 6 { + return Err(invalid_reftable("invalid log block length")); + } + if offset.checked_add(4).is_none_or(|start| start > log_end) { + return Err(invalid_reftable("truncated log block header")); + } + let (body, consumed) = inflate_zlib( + &bytes[offset + 4..log_end], + uncompressed_len.saturating_sub(4), + )?; + let mut block = Vec::with_capacity(uncompressed_len); + block.extend_from_slice(&bytes[offset..offset + 4]); + block.extend_from_slice(&body); + records.extend(parse_log_block(&block, layout.header.oid_len)?); + offset = offset + .checked_add(4 + consumed) + .ok_or_else(|| invalid_reftable("log block position overflow"))?; + } + Ok(records) +} + +fn parse_header(bytes: &[u8]) -> Result { + if bytes.get(..4) != Some(REFTABLE_MAGIC) { + return Err(invalid_reftable("missing reftable magic")); + } + let version = match bytes.get(4) { + Some(1) => ReftableVersion::V1, + Some(2) => ReftableVersion::V2, + _ => return Err(invalid_reftable("unsupported reftable version")), + }; + if bytes.len() < version.header_len() { + return Err(invalid_reftable("truncated reftable header")); + } + let oid_len = match version { + ReftableVersion::V1 => 20, + ReftableVersion::V2 => match bytes.get(24..28) { + Some(b"sha1") => 20, + Some(b"s256") => 32, + _ => return Err(invalid_reftable("unsupported reftable object format")), + }, + }; + Ok(ReftableHeader { + version, + block_size: read_u24(bytes, 5)?, + min_update_index: read_u64(bytes, 8)?, + max_update_index: read_u64(bytes, 16)?, + oid_len, + }) +} + +fn parse_ref_section( + bytes: &[u8], + header: ReftableHeader, + ref_end: usize, + target: Option<&str>, +) -> Result, GitAiError> { + let mut refs = Vec::new(); + let mut offset = header.version.header_len(); + while offset < ref_end { + if bytes[offset] == 0 { + offset += 1; + continue; + } + if bytes[offset] != b'r' { + break; + } + let block_len = read_u24(bytes, offset + 1)? as usize; + let block_end = if offset == header.version.header_len() { + block_len + } else { + offset + .checked_add(block_len) + .ok_or_else(|| invalid_reftable("ref block position overflow"))? + }; + if block_end <= offset || block_end > ref_end || block_end > bytes.len() { + return Err(invalid_reftable("ref block extends past section")); + } + let block_refs = parse_ref_block(&bytes[offset..block_end], offset, header)?; + if let Some(target) = target { + for entry in block_refs { + match entry.name.as_str().cmp(target) { + std::cmp::Ordering::Less => {} + std::cmp::Ordering::Equal => return Ok(vec![entry]), + std::cmp::Ordering::Greater => return Ok(Vec::new()), + } + } + } else { + refs.extend(block_refs); + } + offset = block_end; + } + Ok(refs) +} + +fn parse_ref_block( + block: &[u8], + block_start: usize, + header: ReftableHeader, +) -> Result, GitAiError> { + if block.len() < 6 || block[0] != b'r' { + return Err(invalid_reftable("invalid ref block")); + } + let restart_count = read_u16(block, block.len() - 2)? as usize; + if restart_count == 0 { + return Err(invalid_reftable("ref block has no restart offsets")); + } + let restart_table_start = block + .len() + .checked_sub(2 + restart_count * 3) + .ok_or_else(|| invalid_reftable("truncated ref restart table"))?; + let mut restart_offsets = Vec::with_capacity(restart_count); + for index in 0..restart_count { + restart_offsets.push(read_u24(block, restart_table_start + index * 3)? as usize); + } + if restart_offsets.windows(2).any(|pair| pair[0] > pair[1]) { + return Err(invalid_reftable("unsorted ref restart offsets")); + } + + let restart_base = if block_start == header.version.header_len() { + block_start + } else { + 0 + }; + let mut offset = 4; + let mut previous_name = Vec::new(); + let mut refs = Vec::new(); + while offset < restart_table_start { + let restart = restart_offsets.contains(&(restart_base + offset)); + let entry = parse_ref_record( + block, + &mut offset, + restart_table_start, + header, + &previous_name, + restart, + )?; + previous_name = entry.name.as_bytes().to_vec(); + refs.push(entry); + } + if offset != restart_table_start { + return Err(invalid_reftable("ref block ended inside a record")); + } + Ok(refs) +} + +fn parse_ref_record( + block: &[u8], + offset: &mut usize, + end: usize, + header: ReftableHeader, + previous_name: &[u8], + restart: bool, +) -> Result { + let prefix_len = read_varint(block, offset, end)? as usize; + if prefix_len > previous_name.len() || (restart && prefix_len != 0) { + return Err(invalid_reftable("invalid ref name prefix")); + } + let suffix_len_and_type = read_varint(block, offset, end)?; + let suffix_len = (suffix_len_and_type >> 3) as usize; + let value_type = (suffix_len_and_type & 0x7) as u8; + let suffix_end = offset + .checked_add(suffix_len) + .ok_or_else(|| invalid_reftable("ref suffix overflow"))?; + if suffix_end > end { + return Err(invalid_reftable("truncated ref suffix")); + } + let mut name = previous_name[..prefix_len].to_vec(); + name.extend_from_slice(&block[*offset..suffix_end]); + *offset = suffix_end; + let _update_index = header + .min_update_index + .checked_add(read_varint(block, offset, end)?) + .ok_or_else(|| invalid_reftable("ref update index overflow"))?; + let value = match value_type { + 0 => ReftableRefValue::Deletion, + 1 => ReftableRefValue::Direct(read_oid(block, offset, end, header.oid_len)?), + 2 => { + let target = read_oid(block, offset, end, header.oid_len)?; + let _peeled = read_oid(block, offset, end, header.oid_len)?; + ReftableRefValue::Direct(target) + } + 3 => { + let length = read_varint(block, offset, end)? as usize; + let target_end = offset + .checked_add(length) + .ok_or_else(|| invalid_reftable("symbolic ref target overflow"))?; + if target_end > end { + return Err(invalid_reftable("truncated symbolic ref target")); + } + let target = String::from_utf8(block[*offset..target_end].to_vec()) + .map_err(|_| invalid_reftable("symbolic ref target is not UTF-8"))?; + *offset = target_end; + ReftableRefValue::Symbolic(target) + } + _ => return Err(invalid_reftable("unsupported ref value type")), + }; + let name = String::from_utf8(name).map_err(|_| invalid_reftable("ref name is not UTF-8"))?; + Ok(ReftableRefEntry { name, value }) +} + +fn parse_log_block(block: &[u8], oid_len: usize) -> Result, GitAiError> { + if block.len() < 6 || block[0] != b'g' { + return Err(invalid_reftable("invalid log block")); + } + let restart_count = read_u16(block, block.len() - 2)? as usize; + if restart_count == 0 { + return Err(invalid_reftable("log block has no restart offsets")); + } + let restart_table_start = block + .len() + .checked_sub(2 + restart_count * 3) + .ok_or_else(|| invalid_reftable("truncated log restart table"))?; + let mut offset = 4; + let mut previous_key = Vec::new(); + let mut records = Vec::new(); + while offset < restart_table_start { + records.push(parse_log_record( + block, + &mut offset, + restart_table_start, + oid_len, + &mut previous_key, + )?); + } + if offset != restart_table_start { + return Err(invalid_reftable("log block ended inside a record")); + } + Ok(records) +} + +fn parse_log_record( + block: &[u8], + offset: &mut usize, + end: usize, + oid_len: usize, + previous_key: &mut Vec, +) -> Result { + let prefix_len = read_varint(block, offset, end)? as usize; + if prefix_len > previous_key.len() { + return Err(invalid_reftable("log prefix exceeds previous key")); + } + let suffix_len_and_type = read_varint(block, offset, end)?; + let suffix_len = (suffix_len_and_type >> 3) as usize; + let value_type = (suffix_len_and_type & 0x7) as u8; + let suffix_end = offset + .checked_add(suffix_len) + .ok_or_else(|| invalid_reftable("log suffix overflow"))?; + if suffix_end > end { + return Err(invalid_reftable("truncated log suffix")); + } + let mut key = previous_key[..prefix_len].to_vec(); + key.extend_from_slice(&block[*offset..suffix_end]); + *offset = suffix_end; + if key.len() < 9 || key[key.len() - 9] != 0 { + return Err(invalid_reftable("malformed log key")); + } + let reference = String::from_utf8(key[..key.len() - 9].to_vec()) + .map_err(|_| invalid_reftable("log reference is not UTF-8"))?; + let index_bytes: [u8; 8] = key[key.len() - 8..] + .try_into() + .map_err(|_| invalid_reftable("truncated log update index"))?; + let update_index = u64::MAX - u64::from_be_bytes(index_bytes); + *previous_key = key; + + let value = match value_type { + 0 => ParsedLogValue::Deletion, + 1 => { + let old_oid = read_oid(block, offset, end, oid_len)?; + let new_oid = read_oid(block, offset, end, oid_len)?; + let _name = read_string(block, offset, end)?; + let _email = read_string(block, offset, end)?; + let timestamp = read_varint(block, offset, end)?; + let timestamp_secs = i64::try_from(timestamp) + .map_err(|_| invalid_reftable("log timestamp exceeds i64"))?; + if offset.saturating_add(2) > end { + return Err(invalid_reftable("truncated log timezone")); + } + *offset += 2; + let message = read_string(block, offset, end)? + .trim_end_matches(['\r', '\n']) + .to_string(); + if old_oid.bytes().all(|byte| byte == b'0') && new_oid.bytes().all(|byte| byte == b'0') + { + ParsedLogValue::DeleteLog + } else { + ParsedLogValue::Update(ReftableLogEntry { + reference: reference.clone(), + update_index, + old_oid, + new_oid, + message, + timestamp_secs, + }) + } + } + _ => return Err(invalid_reftable("unsupported log value type")), + }; + Ok(ParsedLogRecord { + reference, + update_index, + value, + }) +} + +fn inflate_zlib(bytes: &[u8], expected_len: usize) -> Result<(Vec, usize), GitAiError> { + use flate2::{Decompress, FlushDecompress}; + let mut decoder = Decompress::new(true); + let mut output = Vec::with_capacity(expected_len); + decoder + .decompress_vec(bytes, &mut output, FlushDecompress::Finish) + .map_err(|error| invalid_reftable(format!("log inflate failed: {error}")))?; + Ok((output, decoder.total_in() as usize)) +} + +fn read_oid( + bytes: &[u8], + offset: &mut usize, + end: usize, + oid_len: usize, +) -> Result { + let oid_end = offset + .checked_add(oid_len) + .ok_or_else(|| invalid_reftable("object id position overflow"))?; + if oid_end > end { + return Err(invalid_reftable("truncated object id")); + } + let mut hex = String::with_capacity(oid_len * 2); + for byte in &bytes[*offset..oid_end] { + use std::fmt::Write; + write!(hex, "{byte:02x}").expect("writing to String cannot fail"); + } + *offset = oid_end; + Ok(hex) +} + +fn read_string(bytes: &[u8], offset: &mut usize, end: usize) -> Result { + let len = read_varint(bytes, offset, end)? as usize; + let string_end = offset + .checked_add(len) + .ok_or_else(|| invalid_reftable("string position overflow"))?; + if string_end > end { + return Err(invalid_reftable("truncated string")); + } + let value = String::from_utf8_lossy(&bytes[*offset..string_end]).into_owned(); + *offset = string_end; + Ok(value) +} + +fn read_varint(bytes: &[u8], offset: &mut usize, end: usize) -> Result { + if *offset >= end { + return Err(invalid_reftable("truncated varint")); + } + let mut value = u64::from(bytes[*offset] & 0x7f); + while bytes[*offset] & 0x80 != 0 { + *offset += 1; + if *offset >= end { + return Err(invalid_reftable("truncated varint")); + } + value = value + .checked_add(1) + .and_then(|value| value.checked_mul(128)) + .ok_or_else(|| invalid_reftable("varint overflow"))? + | u64::from(bytes[*offset] & 0x7f); + } + *offset += 1; + Ok(value) +} + +fn read_u16(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 2) + .ok_or_else(|| invalid_reftable("truncated uint16"))?; + Ok(u16::from_be_bytes([raw[0], raw[1]])) +} + +fn read_u24(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 3) + .ok_or_else(|| invalid_reftable("truncated uint24"))?; + Ok((u32::from(raw[0]) << 16) | (u32::from(raw[1]) << 8) | u32::from(raw[2])) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 4) + .ok_or_else(|| invalid_reftable("truncated uint32"))?; + Ok(u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]])) +} + +fn read_u64(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 8) + .ok_or_else(|| invalid_reftable("truncated uint64"))?; + Ok(u64::from_be_bytes([ + raw[0], raw[1], raw[2], raw[3], raw[4], raw[5], raw[6], raw[7], + ])) +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = 0u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +fn invalid_reftable(message: impl Into) -> GitAiError { + GitAiError::Generic(format!("invalid reftable: {}", message.into())) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::ffi::OsStr; + use std::fs; + + fn git_with_env(repo: &Path, args: &[&str], env: &[(&str, &str)]) { + let mut command_args = vec!["-C".to_string(), repo.to_string_lossy().to_string()]; + command_args.extend(args.iter().map(|arg| (*arg).to_string())); + let env = env + .iter() + .map(|(key, value)| (*key, OsStr::new(value))) + .collect::>(); + let output = crate::git::repository::exec_git_allow_nonzero_with_env(&command_args, &env) + .expect("git command should run"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git(repo: &Path, args: &[&str]) { + git_with_env(repo, args, &[]); + } + + fn git_with_stdin(repo: &Path, args: &[&str], stdin: &[u8]) { + let mut command_args = vec!["-C".to_string(), repo.to_string_lossy().to_string()]; + command_args.extend(args.iter().map(|arg| (*arg).to_string())); + crate::git::repository::exec_git_stdin(&command_args, stdin) + .expect("git command with stdin should succeed"); + } + + pub(crate) fn native_reftable_repo(object_format: &str) -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + git( + temp.path(), + &[ + "init", + "--ref-format=reftable", + &format!("--object-format={object_format}"), + "-b", + "main", + ".", + ], + ); + git(temp.path(), &["config", "user.name", "Reftable Test"]); + git( + temp.path(), + &["config", "user.email", "reftable@example.com"], + ); + fs::write(temp.path().join("file.txt"), "first\n").unwrap(); + git(temp.path(), &["add", "file.txt"]); + git(temp.path(), &["commit", "-m", "first"]); + fs::write(temp.path().join("file.txt"), "first\nsecond\n").unwrap(); + git(temp.path(), &["commit", "-am", "second"]); + temp + } + + fn native_reftable_logs(object_format: &str) -> Vec { + let temp = native_reftable_repo(object_format); + read_reftable_logs(&temp.path().join(".git/reftable")).unwrap() + } + + fn corrupt_log_position_near_footer(bytes: &mut [u8]) { + let header = parse_header(bytes).unwrap(); + let footer_start = bytes.len() - header.version.footer_len(); + let log_position_field = footer_start + header.version.header_len() + 24; + let log_position = footer_start - 2; + bytes[log_position] = b'g'; + bytes[log_position_field..log_position_field + 8] + .copy_from_slice(&(log_position as u64).to_be_bytes()); + let footer_crc = crc32(&bytes[footer_start..bytes.len() - 4]); + let crc_offset = bytes.len() - 4; + bytes[crc_offset..].copy_from_slice(&footer_crc.to_be_bytes()); + } + + fn assert_native_log_history(object_format: &str, oid_hex_len: usize) { + let logs = native_reftable_logs(object_format); + let head_updates = logs + .iter() + .filter(|entry| entry.reference == "HEAD") + .collect::>(); + assert_eq!(head_updates.len(), 2, "unexpected logs: {logs:#?}"); + assert!( + head_updates[0].message.ends_with("first"), + "unexpected HEAD history: {head_updates:#?}" + ); + assert!( + head_updates[1].message.ends_with("second"), + "unexpected HEAD history: {head_updates:#?}" + ); + assert_eq!(head_updates[0].old_oid.len(), oid_hex_len); + assert_eq!(head_updates[1].new_oid.len(), oid_hex_len); + assert!(head_updates[0].update_index < head_updates[1].update_index); + } + + #[test] + fn reads_git_generated_v1_sha1_log_blocks() { + assert_native_log_history("sha1", 40); + } + + #[test] + fn reads_git_generated_v2_sha256_log_blocks() { + assert_native_log_history("sha256", 64); + } + + #[test] + fn missing_active_table_degrades_to_empty_log_snapshot() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("tables.list"), "missing.ref\n").unwrap(); + + let logs = ReftableReader::default() + .read_logs(temp.path()) + .expect("a compaction race must not abort command enrichment"); + + assert!(logs.is_empty()); + } + + #[test] + fn unchanged_stack_does_not_rebuild_log_snapshot() { + let temp = native_reftable_repo("sha1"); + let stack = temp.path().join(".git/reftable"); + let mut reader = ReftableReader::default(); + + assert!(reader.read_logs_if_changed(&stack).unwrap().is_some()); + assert!(reader.read_logs_if_changed(&stack).unwrap().is_none()); + } + + #[test] + fn transient_log_table_io_error_is_retried() { + let source = native_reftable_repo("sha1"); + let source_stack = source.path().join(".git/reftable"); + let source_table = active_table_paths(&source_stack).unwrap().pop().unwrap(); + let table_name = source_table.file_name().unwrap(); + let table_bytes = fs::read(&source_table).unwrap(); + let stack = tempfile::tempdir().unwrap(); + fs::write( + stack.path().join("tables.list"), + format!("{}\n", table_name.to_string_lossy()), + ) + .unwrap(); + let table = stack.path().join(table_name); + fs::create_dir(&table).unwrap(); + let mut reader = ReftableReader::default(); + + assert!(reader.read_logs(stack.path()).unwrap().is_empty()); + fs::remove_dir(&table).unwrap(); + fs::write(&table, table_bytes).unwrap(); + + assert!(!reader.read_logs(stack.path()).unwrap().is_empty()); + } + + #[test] + fn truncated_log_block_header_returns_error() { + let temp = native_reftable_repo("sha1"); + let stack = temp.path().join(".git/reftable"); + let table = active_table_paths(&stack).unwrap().pop().unwrap(); + let mut bytes = fs::read(table).unwrap(); + corrupt_log_position_near_footer(&mut bytes); + + assert!(parse_table(&bytes).is_err()); + } + + #[test] + fn invalid_first_ref_block_length_returns_error() { + let temp = native_reftable_repo("sha1"); + let stack = temp.path().join(".git/reftable"); + let table = active_table_paths(&stack).unwrap().pop().unwrap(); + let mut bytes = fs::read(table).unwrap(); + let header = parse_header(&bytes).unwrap(); + let invalid_len = header.version.header_len() - 1; + bytes[header.version.header_len() + 1..header.version.header_len() + 4].copy_from_slice(&[ + ((invalid_len >> 16) & 0xff) as u8, + ((invalid_len >> 8) & 0xff) as u8, + (invalid_len & 0xff) as u8, + ]); + + assert!(parse_table(&bytes).is_err()); + } + + #[test] + fn oversized_varint_returns_error() { + let mut bytes = vec![0x81; 10]; + bytes.push(0); + let mut offset = 0; + + assert!(read_varint(&bytes, &mut offset, bytes.len()).is_err()); + } + + #[test] + fn later_ref_block_restart_offsets_are_block_relative() { + let temp = native_reftable_repo("sha1"); + let stack = temp.path().join(".git/reftable"); + let Some((head_oid, _)) = ReftableReader::default().read_head(&stack, &stack).unwrap() + else { + panic!("generated repository should have a direct branch target"); + }; + let mut updates = String::new(); + for index in 0..600 { + updates.push_str(&format!( + "create refs/heads/generated-{index:04} {head_oid}\n" + )); + } + git_with_stdin(temp.path(), &["update-ref", "--stdin"], updates.as_bytes()); + + let (bytes, header, block_start, block_end) = active_table_paths(&stack) + .unwrap() + .into_iter() + .rev() + .find_map(|table| { + let bytes = fs::read(table).ok()?; + let header = parse_header(&bytes).ok()?; + let footer_start = bytes.len() - header.version.footer_len(); + let mut offset = header.version.header_len(); + let mut blocks = Vec::new(); + while offset < footer_start { + if bytes[offset] == 0 { + offset += 1; + continue; + } + if bytes[offset] != b'r' { + break; + } + let block_len = read_u24(&bytes, offset + 1).ok()? as usize; + let block_end = if offset == header.version.header_len() { + block_len + } else { + offset.checked_add(block_len)? + }; + blocks.push((offset, block_end)); + offset = block_end; + } + let (block_start, block_end) = *blocks.get(1)?; + Some((bytes, header, block_start, block_end)) + }) + .expect("generated refs should span multiple ref blocks"); + let mut block = bytes[block_start..block_end].to_vec(); + let restart_count = read_u16(&block, block.len() - 2).unwrap() as usize; + assert!(restart_count > 1); + let restart_table_start = block.len() - 2 - restart_count * 3; + let second_restart = read_u24(&block, restart_table_start + 3).unwrap() as usize; + block[second_restart] = 1; + + assert!(parse_ref_block(&block, block_start, header).is_err()); + } + + #[test] + fn head_read_does_not_parse_corrupt_log_blocks() { + let temp = native_reftable_repo("sha1"); + let stack = temp.path().join(".git/reftable"); + let expected = ReftableReader::default() + .read_head(&stack, &stack) + .unwrap() + .expect("generated repository should have HEAD"); + let table = active_table_paths(&stack).unwrap().pop().unwrap(); + let mut bytes = fs::read(&table).unwrap(); + let header = parse_header(&bytes).unwrap(); + let footer_start = bytes.len() - header.version.footer_len(); + let log_position_field = footer_start + header.version.header_len() + 24; + let log_position = read_u64(&bytes, log_position_field).unwrap() as usize; + assert_ne!(log_position, 0); + bytes[log_position + 4] ^= 0xff; + fs::write(table, bytes).unwrap(); + + assert_eq!( + ReftableReader::default().read_head(&stack, &stack).unwrap(), + Some(expected) + ); + } + + #[test] + fn merges_multiple_tables_and_applies_reflog_expiry_tombstones() { + let temp = tempfile::tempdir().unwrap(); + git( + temp.path(), + &["init", "--ref-format=reftable", "-b", "main", "."], + ); + git(temp.path(), &["config", "user.name", "Reftable Test"]); + git( + temp.path(), + &["config", "user.email", "reftable@example.com"], + ); + let stack_dir = temp.path().join(".git/reftable"); + let multi_stack = temp.path().join("multi-stack"); + fs::create_dir(&multi_stack).unwrap(); + let mut snapshot_names = Vec::new(); + for index in 0..2 { + git( + temp.path(), + &["commit", "--allow-empty", "-m", &format!("commit {index}")], + ); + for (table_index, table) in active_table_paths(&stack_dir) + .unwrap() + .into_iter() + .enumerate() + { + let snapshot_name = format!("snapshot-{index}-{table_index}.ref"); + fs::copy(table, multi_stack.join(&snapshot_name)).unwrap(); + snapshot_names.push(snapshot_name); + } + } + fs::write( + multi_stack.join("tables.list"), + format!("{}\n", snapshot_names.join("\n")), + ) + .unwrap(); + assert!( + fs::read_to_string(multi_stack.join("tables.list")) + .unwrap() + .lines() + .count() + > 1 + ); + + let mut reader = ReftableReader::default(); + assert_eq!( + reader + .read_logs(&multi_stack) + .unwrap() + .iter() + .filter(|entry| entry.reference == "HEAD") + .count(), + 2 + ); + assert_eq!( + reader + .read_logs(&stack_dir) + .unwrap() + .iter() + .filter(|entry| entry.reference == "HEAD") + .count(), + 2 + ); + git(temp.path(), &["reflog", "expire", "--expire=all", "--all"]); + let expired = reader.read_logs(&stack_dir).unwrap(); + assert!(expired.is_empty(), "expired logs remained: {expired:#?}"); + + git( + temp.path(), + &["commit", "--allow-empty", "-m", "after expiry"], + ); + let logs_after_expiry = reader.read_logs(&stack_dir).unwrap(); + assert_eq!( + logs_after_expiry + .iter() + .filter(|entry| entry.reference == "HEAD") + .map(|entry| entry.message.as_str()) + .collect::>(), + ["commit: after expiry"], + "new logs after expiry were not visible: {logs_after_expiry:#?}" + ); + } +} diff --git a/src/git/repo_state.rs b/src/git/repo_state.rs index 902ed03308..bcc8cbc6dd 100644 --- a/src/git/repo_state.rs +++ b/src/git/repo_state.rs @@ -1,5 +1,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +static REFTABLE_READER: OnceLock> = OnceLock::new(); pub fn is_valid_git_oid(value: &str) -> bool { matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit()) @@ -89,23 +92,42 @@ pub fn read_head_state_for_worktree(worktree: &Path) -> Option { let git_dir = git_dir_for_worktree(worktree)?; let common_dir = common_dir_for_git_dir(&git_dir)?; let reader = FastRefReader::new(&git_dir, &common_dir); - match reader.try_read_head()? { - HeadKind::Symbolic(refname) => { + match reader.try_read_head() { + Some(HeadKind::Symbolic(refname)) => { let branch = refname.strip_prefix("refs/heads/").map(|s| s.to_string()); let detached = branch.is_none(); let head = reader.try_resolve_ref(&refname); - Some(HeadState { + return Some(HeadState { head, branch, detached, - }) + }); + } + Some(HeadKind::Detached(oid)) => { + return Some(HeadState { + head: Some(oid), + branch: None, + detached: true, + }); } - HeadKind::Detached(oid) => Some(HeadState { - head: Some(oid), - branch: None, - detached: true, - }), + None => {} } + + let (head, symbolic) = REFTABLE_READER + .get_or_init(|| Mutex::new(crate::git::reftable::ReftableReader::default())) + .lock() + .ok()? + .read_head(&common_dir.join("reftable"), &git_dir.join("reftable")) + .ok()??; + let branch = symbolic + .as_deref() + .and_then(|refname| refname.strip_prefix("refs/heads/")) + .map(ToString::to_string); + Some(HeadState { + head: Some(head), + detached: symbolic.is_none(), + branch, + }) } #[cfg(test)] diff --git a/src/git/repository.rs b/src/git/repository.rs index 4ca4ec4a3b..82ab3bf40f 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -371,11 +371,9 @@ impl<'a> CommitRange<'a> { } pub fn is_valid(&self) -> Result<(), GitAiError> { - const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - // Check that both commits exist // Skip validation for empty tree hash - it's a special git object that may not exist in the repo - if self.start_oid != EMPTY_TREE_HASH { + if !crate::authorship::diff_base::is_empty_tree_oid(&self.start_oid) { self.repo.find_commit(self.start_oid.clone())?; } self.repo.find_commit(self.end_oid.clone())?; @@ -383,7 +381,7 @@ impl<'a> CommitRange<'a> { // Check that both commits exist on the refname // Use git merge-base --is-ancestor // Skip merge-base check for empty tree hash since it's not part of commit history - if self.start_oid != EMPTY_TREE_HASH { + if !crate::authorship::diff_base::is_empty_tree_oid(&self.start_oid) { let mut args = self.repo.global_args_for_exec(); args.push("merge-base".to_string()); args.push("--is-ancestor".to_string()); @@ -413,7 +411,7 @@ impl<'a> CommitRange<'a> { // Check that start is an ancestor of end (direct path between them) // Skip for empty tree hash - it's not part of the commit DAG - if self.start_oid != EMPTY_TREE_HASH { + if !crate::authorship::diff_base::is_empty_tree_oid(&self.start_oid) { let mut args = self.repo.global_args_for_exec(); args.push("merge-base".to_string()); args.push("--is-ancestor".to_string()); @@ -1720,8 +1718,7 @@ impl Repository { // For initial commits (no parent), compare against the empty tree if commit.parent_count()? == 0 { - let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - args.push(empty_tree.to_string()); + args.push(crate::authorship::diff_base::empty_tree_for_oid(commit_sha).to_string()); } args.push(commit_sha.to_string()); diff --git a/tests/commit_tree_update_ref.rs b/tests/commit_tree_update_ref.rs index e7d6f77bb5..4bce2c42b7 100644 --- a/tests/commit_tree_update_ref.rs +++ b/tests/commit_tree_update_ref.rs @@ -707,6 +707,49 @@ fn test_delayed_commit_trace_replay_attributes_matching_commit_not_later_commit( ); } +#[test] +fn test_reftable_delayed_commit_trace_replay_attributes_matching_commit_not_later_commit() { + let repo = TestRepo::new_reftable(); + let mut root_file = repo.filename("root.txt"); + root_file.set_contents(lines!["Human root"]); + repo.stage_all_and_commit("root").unwrap(); + root_file.assert_committed_lines(lines!["Human root".human()]); + + fs::write(repo.path().join("first-delayed.txt"), "first delayed ai\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_ai", "first-delayed.txt"]) + .unwrap(); + raw_untraced_git(&repo, &["add", "first-delayed.txt"]); + repo.sync_daemon(); + let baseline = repo.daemon_total_completion_count(); + + let trace_dir = tempfile::tempdir().expect("trace temp dir"); + let commit_trace = trace_dir.path().join("reftable-commit.trace2"); + raw_git_trace_to_file(&repo, &["commit", "-m", "first delayed"], &commit_trace); + let first_commit = head_sha(&repo); + + fs::write(repo.path().join("later-delayed.txt"), "later untraced\n").unwrap(); + raw_untraced_git(&repo, &["add", "later-delayed.txt"]); + raw_untraced_git(&repo, &["commit", "-m", "later untraced commit"]); + let later_commit = head_sha(&repo); + + replay_trace_file_to_daemon(&repo, &commit_trace); + repo.wait_for_daemon_total_completion_count(baseline, baseline + 1); + + assert_note_has_ai_for_file(&repo, &first_commit, "first-delayed.txt"); + assert!( + repo.read_authorship_note(&later_commit).is_none(), + "delayed reftable trace replay must not attach attribution to a later commit" + ); + + repo.git(&["checkout", "--detach", &first_commit]).unwrap(); + let mut first_file = repo.filename("first-delayed.txt"); + first_file.assert_committed_lines(lines!["first delayed ai".ai()]); + repo.git(&["checkout", "--detach", &later_commit]).unwrap(); + first_file.assert_committed_lines(lines!["first delayed ai".ai()]); + let mut later_file = repo.filename("later-delayed.txt"); + later_file.assert_committed_lines(lines!["later untraced".unattributed_human()]); +} + #[cfg(not(windows))] #[test] fn test_trace_listener_bootstrap_captures_commit_ref_transition_before_worker_spawn_delay() { diff --git a/tests/integration/main.rs b/tests/integration/main.rs index dc1e23081f..2c45b64b8a 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -117,6 +117,7 @@ mod rebase_merge_commit_note_leak; mod rebase_note_integrity; mod rebase_realworld; mod refs_unit; +mod reftable; mod repo_storage_unit; mod repository_unit; mod reset; diff --git a/tests/integration/reftable.rs b/tests/integration/reftable.rs new file mode 100644 index 0000000000..7f245ffc3b --- /dev/null +++ b/tests/integration/reftable.rs @@ -0,0 +1,331 @@ +use crate::repos::test_file::ExpectedLineExt; +use crate::repos::test_repo::TestRepo; + +#[test] +fn reftable_commit_preserves_mixed_attribution_across_commits() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("mixed.txt"); + + file.set_contents(lines!["Human base", "AI first".ai()]); + repo.stage_all_and_commit("mixed root").unwrap(); + file.assert_committed_lines(lines!["Human base".human(), "AI first".ai()]); + + file.set_contents(lines![ + "Human base".human(), + "AI first".ai(), + "AI second".ai(), + ]); + repo.stage_all_and_commit("mixed follow-up").unwrap(); + file.assert_committed_lines(lines![ + "Human base".human(), + "AI first".ai(), + "AI second".ai(), + ]); +} + +#[test] +fn reftable_sha256_commit_preserves_mixed_attribution() { + let repo = TestRepo::new_reftable_sha256(); + let mut file = repo.filename("sha256.txt"); + file.set_contents(lines!["Human SHA-256", "AI SHA-256".ai()]); + repo.stage_all_and_commit("sha256 root").unwrap(); + file.assert_committed_lines(lines!["Human SHA-256".human(), "AI SHA-256".ai()]); + let root_stats = repo.stats().unwrap(); + assert_eq!(root_stats.git_diff_added_lines, 2); + assert_eq!(root_stats.human_additions, 1); + assert_eq!(root_stats.ai_additions, 1); + assert_eq!(root_stats.ai_accepted, 1); + + file.insert_at(2, lines!["AI SHA-256 amend".ai()]); + repo.git(&["add", "sha256.txt"]).unwrap(); + repo.git(&["commit", "--amend", "--no-edit"]).unwrap(); + file.assert_committed_lines(lines![ + "Human SHA-256".human(), + "AI SHA-256".ai(), + "AI SHA-256 amend".ai(), + ]); +} + +#[test] +fn reftable_amend_and_soft_reset_preserve_attribution() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("rewrite.txt"); + file.set_contents(lines!["Human root", "AI one".ai()]); + let root = repo.stage_all_and_commit("root").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI one".ai()]); + + file.insert_at(2, lines!["AI two".ai()]); + repo.stage_all_and_commit("second").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI one".ai(), "AI two".ai(),]); + + repo.git(&["reset", "--soft", &root.commit_sha]).unwrap(); + repo.commit("squashed").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI one".ai(), "AI two".ai(),]); + + file.insert_at(3, lines!["AI amended".ai()]); + repo.git(&["add", "rewrite.txt"]).unwrap(); + repo.git(&["commit", "--amend", "--no-edit"]).unwrap(); + file.assert_committed_lines(lines![ + "Human root".human(), + "AI one".ai(), + "AI two".ai(), + "AI amended".ai(), + ]); +} + +#[test] +fn reftable_checkout_switch_and_branch_history_preserve_attribution() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("branches.txt"); + file.set_contents(lines!["Human root", ""]); + repo.stage_all_and_commit("root").unwrap(); + file.assert_committed_lines(lines!["Human root".human()]); + + repo.git(&["checkout", "-b", "feature"]).unwrap(); + file.insert_at(1, lines!["AI feature".ai()]); + repo.stage_all_and_commit("feature").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI feature".ai()]); + + repo.git(&["switch", "main"]).unwrap(); + file = repo.filename("branches.txt"); + file.assert_committed_lines(lines!["Human root".human()]); + repo.git(&["switch", "feature"]).unwrap(); + file = repo.filename("branches.txt"); + file.assert_committed_lines(lines!["Human root".human(), "AI feature".ai()]); +} + +#[test] +fn reftable_stash_push_apply_pop_and_drop_preserve_attribution() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("stash.txt"); + file.set_contents(lines!["Human root", ""]); + repo.stage_all_and_commit("root").unwrap(); + file.assert_committed_lines(lines!["Human root".human()]); + + file.insert_at(1, lines!["AI popped".ai()]); + repo.git(&["stash", "push", "-m", "pop me"]).unwrap(); + repo.git(&["stash", "pop"]).unwrap(); + repo.stage_all_and_commit("popped").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI popped".ai()]); + + file.insert_at(2, lines!["AI applied".ai()]); + repo.git(&["stash", "push", "-m", "apply me"]).unwrap(); + repo.git(&["stash", "apply", "stash@{0}"]).unwrap(); + repo.git(&["stash", "drop", "stash@{0}"]).unwrap(); + repo.stage_all_and_commit("applied").unwrap(); + file.assert_committed_lines(lines![ + "Human root".human(), + "AI popped".ai(), + "AI applied".ai(), + ]); +} + +#[test] +fn reftable_revert_and_multi_cherry_pick_preserve_attribution() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("history.txt"); + file.set_contents(lines!["Human root", ""]); + repo.stage_all_and_commit("root").unwrap(); + file.assert_committed_lines(lines!["Human root".human()]); + + repo.git(&["checkout", "-b", "source"]).unwrap(); + file.insert_at(1, lines!["AI source one".ai()]); + let source_one = repo.stage_all_and_commit("source one").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI source one".ai()]); + file.insert_at(2, lines!["AI source two".ai()]); + let source_two = repo.stage_all_and_commit("source two").unwrap(); + file.assert_committed_lines(lines![ + "Human root".human(), + "AI source one".ai(), + "AI source two".ai(), + ]); + + repo.git(&["switch", "main"]).unwrap(); + repo.git(&[ + "cherry-pick", + &source_one.commit_sha, + &source_two.commit_sha, + ]) + .unwrap(); + file = repo.filename("history.txt"); + repo.git(&["checkout", "--detach", "HEAD~1"]).unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI source one".ai()]); + repo.git(&["switch", "main"]).unwrap(); + file.assert_committed_lines(lines![ + "Human root".human(), + "AI source one".ai(), + "AI source two".ai(), + ]); + + repo.git(&["revert", "--no-edit", "HEAD"]).unwrap(); + file = repo.filename("history.txt"); + file.assert_committed_lines(lines!["Human root".human(), "AI source one".ai()]); +} + +#[test] +fn reftable_rebase_and_update_ref_stdin_preserve_attribution() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("rebase.txt"); + file.set_contents(lines!["Human root", ""]); + let root = repo.stage_all_and_commit("root").unwrap(); + file.assert_committed_lines(lines!["Human root".human()]); + + repo.git(&["checkout", "-b", "feature"]).unwrap(); + file.insert_at(1, lines!["AI feature".ai()]); + repo.stage_all_and_commit("feature").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI feature".ai()]); + + repo.git(&["switch", "main"]).unwrap(); + let mut main_file = repo.filename("main.txt"); + main_file.set_contents(lines!["Human main"]); + repo.stage_all_and_commit("main advance").unwrap(); + main_file.assert_committed_lines(lines!["Human main".human()]); + + repo.git(&["switch", "feature"]).unwrap(); + repo.git(&["rebase", "main"]).unwrap(); + file = repo.filename("rebase.txt"); + file.assert_committed_lines(lines!["Human root".human(), "AI feature".ai()]); + main_file = repo.filename("main.txt"); + main_file.assert_committed_lines(lines!["Human main".human()]); + + let rebased = repo.git(&["rev-parse", "HEAD"]).unwrap(); + repo.git_with_stdin( + &["update-ref", "--stdin"], + format!("update refs/heads/saved {}\n", rebased.trim()).as_bytes(), + ) + .unwrap(); + repo.git(&["update-ref", "refs/heads/saved", &root.commit_sha]) + .unwrap(); + repo.git(&["switch", "saved"]).unwrap(); + file = repo.filename("rebase.txt"); + file.assert_committed_lines(lines!["Human root".human()]); +} + +#[test] +fn reftable_linked_worktree_uses_its_own_head_log() { + let repo = TestRepo::new_reftable_worktree(); + let mut file = repo.filename("linked.txt"); + file.set_contents(lines!["Human linked", "AI linked".ai()]); + repo.stage_all_and_commit("linked root").unwrap(); + file.assert_committed_lines(lines!["Human linked".human(), "AI linked".ai()]); + + file.insert_at(2, lines!["AI linked follow-up".ai()]); + repo.stage_all_and_commit("linked follow-up").unwrap(); + file.assert_committed_lines(lines![ + "Human linked".human(), + "AI linked".ai(), + "AI linked follow-up".ai(), + ]); +} + +#[test] +fn migrating_between_files_and_reftable_keeps_cursor_and_checkpoint_state() { + let repo = TestRepo::new(); + let mut file = repo.filename("migration.txt"); + file.set_contents(lines!["Human files", "AI files".ai()]); + repo.stage_all_and_commit("files root").unwrap(); + file.assert_committed_lines(lines!["Human files".human(), "AI files".ai()]); + + repo.git(&["refs", "migrate", "--ref-format=reftable"]) + .unwrap(); + file.insert_at(2, lines!["AI reftable".ai()]); + repo.stage_all_and_commit("after reftable migration") + .unwrap(); + file.assert_committed_lines(lines![ + "Human files".human(), + "AI files".ai(), + "AI reftable".ai(), + ]); + + repo.git(&["refs", "migrate", "--ref-format=files"]) + .unwrap(); + file.insert_at(3, lines!["AI files again".ai()]); + repo.stage_all_and_commit("after files migration").unwrap(); + file.assert_committed_lines(lines![ + "Human files".human(), + "AI files".ai(), + "AI reftable".ai(), + "AI files again".ai(), + ]); +} + +#[test] +fn reftable_compaction_and_log_expiry_do_not_stale_cached_cursors() { + let repo = TestRepo::new_reftable(); + let mut file = repo.filename("compact.txt"); + file.set_contents(lines!["Human compact", ""]); + repo.stage_all_and_commit_with_env( + "compact root", + &[("GIT_TEST_REFTABLE_AUTOCOMPACTION", "0")], + ) + .unwrap(); + file.assert_committed_lines(lines!["Human compact".human()]); + + file.insert_at(1, lines!["AI before compaction".ai()]); + repo.stage_all_and_commit_with_env( + "before compaction", + &[("GIT_TEST_REFTABLE_AUTOCOMPACTION", "0")], + ) + .unwrap(); + file.assert_committed_lines(lines!["Human compact".human(), "AI before compaction".ai(),]); + + repo.git(&["refs", "optimize"]).unwrap(); + repo.git(&["reflog", "expire", "--expire=all", "--all"]) + .unwrap(); + file.insert_at(2, lines!["AI after expiry".ai()]); + repo.stage_all_and_commit("after expiry") + .unwrap_or_else(|error| panic!("{error}\n{}", repo.daemon_stderr_contents())); + file.assert_committed_lines(lines![ + "Human compact".human(), + "AI before compaction".ai(), + "AI after expiry".ai(), + ]); +} + +#[test] +fn reftable_pull_rebase_preserves_local_ai_commit() { + let (repo, _upstream) = TestRepo::new_reftable_with_remote(); + assert_eq!( + repo.git(&["rev-parse", "--show-ref-format"]) + .unwrap() + .trim(), + "reftable" + ); + let mut file = repo.filename("pull.txt"); + file.set_contents(lines!["Human root", ""]); + let root = repo.stage_all_and_commit("root").unwrap(); + file.assert_committed_lines(lines!["Human root".human()]); + repo.git(&["push", "-u", "origin", "main"]).unwrap(); + + file.insert_at(1, lines!["AI local".ai()]); + repo.stage_all_and_commit("local AI").unwrap(); + file.assert_committed_lines(lines!["Human root".human(), "AI local".ai()]); + + let tree = repo + .git_og(&["rev-parse", &format!("{}^{{tree}}", root.commit_sha)]) + .unwrap(); + let remote_commit = repo + .git_og(&[ + "-c", + "user.name=Remote User", + "-c", + "user.email=remote@example.com", + "commit-tree", + tree.trim(), + "-p", + &root.commit_sha, + "-m", + "remote advance", + ]) + .unwrap(); + repo.git_og(&[ + "push", + "origin", + &format!("{}:refs/heads/main", remote_commit.trim()), + ]) + .unwrap(); + + repo.git(&["pull", "--rebase", "origin", "main"]).unwrap(); + file = repo.filename("pull.txt"); + file.assert_committed_lines(lines!["Human root".human(), "AI local".ai()]); +} diff --git a/tests/integration/repos/test_repo.rs b/tests/integration/repos/test_repo.rs index 7df4a5fc95..0228c59be9 100644 --- a/tests/integration/repos/test_repo.rs +++ b/tests/integration/repos/test_repo.rs @@ -1300,6 +1300,24 @@ impl TestRepo { Self::new_with_daemon_scope(DaemonTestScope::Shared) } + pub fn new_reftable() -> Self { + Self::new_reftable_with_object_format("sha1") + } + + pub fn new_reftable_sha256() -> Self { + Self::new_reftable_with_object_format("sha256") + } + + pub fn new_reftable_worktree() -> Self { + Self::new_orphan_worktree_from(Self::new_reftable()) + } + + fn new_reftable_with_object_format(object_format: &str) -> Self { + Self::new_with_daemon_scope_and_template(DaemonTestScope::Shared, |path| { + clone_reftable_template_to(path, object_format); + }) + } + /// Create a worktree-backed TestRepo. /// This creates a normal base repo and then adds an orphan linked worktree /// so tests keep empty-repo semantics (the first real commit is still a root commit). @@ -1308,8 +1326,10 @@ impl TestRepo { } fn new_worktree_variant_with_daemon_scope(daemon_scope: DaemonTestScope) -> Self { - let mut base = Self::new_with_daemon_scope_inner(daemon_scope); + Self::new_orphan_worktree_from(Self::new_with_daemon_scope_inner(daemon_scope)) + } + fn new_orphan_worktree_from(mut base: Self) -> Self { let default_branch = default_branchname(); let base_branch = base.current_branch(); if base_branch == default_branch { @@ -1436,6 +1456,13 @@ impl TestRepo { } fn new_with_daemon_scope_inner(daemon_scope: DaemonTestScope) -> Self { + Self::new_with_daemon_scope_and_template(daemon_scope, clone_template_to) + } + + fn new_with_daemon_scope_and_template( + daemon_scope: DaemonTestScope, + clone_template: impl FnOnce(&Path), + ) -> Self { // Isolate this test binary's HOME before any git or git-ai subprocess is spawned. ensure_isolated_process_home(); @@ -1446,8 +1473,8 @@ impl TestRepo { let test_home = base.join(format!("{}-home", n)); let test_db_path = resolve_test_db_path(&base, n, &test_home); - // Clone from cached template (git init + config + symbolic-ref already done) - clone_template_to(&path); + // Clone from a cached template (git init + config + symbolic-ref already done). + clone_template(&path); let mut repo = Self { path, @@ -1635,7 +1662,21 @@ impl TestRepo { Self::new_with_remote_with_daemon_scope(DaemonTestScope::Shared) } + pub fn new_reftable_with_remote() -> (Self, Self) { + Self::new_with_remote_with_daemon_scope_and_ref_format( + DaemonTestScope::Shared, + Some("reftable"), + ) + } + pub fn new_with_remote_with_daemon_scope(daemon_scope: DaemonTestScope) -> (Self, Self) { + Self::new_with_remote_with_daemon_scope_and_ref_format(daemon_scope, None) + } + + fn new_with_remote_with_daemon_scope_and_ref_format( + daemon_scope: DaemonTestScope, + ref_format: Option<&str>, + ) -> (Self, Self) { let mut rng = rand::rng(); let base = std::env::temp_dir(); @@ -1669,11 +1710,13 @@ impl TestRepo { let mirror_test_db_path = resolve_test_db_path(&base, mirror_n, &mirror_test_home); let mut command = Command::new(real_git_executable()); - command.args([ - "clone", - upstream_path.to_str().unwrap(), - mirror_path.to_str().unwrap(), - ]); + command.arg("clone"); + if let Some(ref_format) = ref_format { + command.arg(format!("--ref-format={ref_format}")); + } + command + .arg(upstream_path.to_str().unwrap()) + .arg(mirror_path.to_str().unwrap()); let clone_output = run_command_output(&mut command, "clone upstream repository") .expect("failed to clone upstream repository"); @@ -2560,6 +2603,10 @@ impl TestRepo { self.git_with_env(args, &[], None) } + pub fn git_with_stdin(&self, args: &[&str], stdin_data: &[u8]) -> Result { + self.git_with_env_and_stdin(args, &[], None, Some(stdin_data)) + } + pub fn git_without_test_sync_for_test( &self, args: &[&str], @@ -2746,6 +2793,16 @@ impl TestRepo { args: &[&str], envs: &[(&str, &str)], working_dir: Option<&std::path::Path>, + ) -> Result { + self.git_with_env_and_stdin(args, envs, working_dir, None) + } + + fn git_with_env_and_stdin( + &self, + args: &[&str], + envs: &[(&str, &str)], + working_dir: Option<&std::path::Path>, + stdin_data: Option<&[u8]>, ) -> Result { let canonical_working_dir = if let Some(working_dir_path) = working_dir { Some(working_dir_path.canonicalize().map_err(|e| { @@ -2817,7 +2874,13 @@ impl TestRepo { command.env(key, value); } - let output = run_command_output(&mut command, &format!("git {:?}", args))?; + let label = format!("git {:?}", args); + let output = match stdin_data { + Some(stdin_data) => { + run_command_output_with_stdin(&mut command, &label, stdin_data)? + } + None => run_command_output(&mut command, &label)?, + }; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -3421,6 +3484,8 @@ impl NewCommit { static DEFAULT_BRANCH_NAME: OnceLock = OnceLock::new(); static TEMPLATE_REPO: OnceLock = OnceLock::new(); static TEMPLATE_BARE_REPO: OnceLock = OnceLock::new(); +static TEMPLATE_REFTABLE_SHA1_REPO: OnceLock = OnceLock::new(); +static TEMPLATE_REFTABLE_SHA256_REPO: OnceLock = OnceLock::new(); static COMPILED_BINARY: OnceLock = OnceLock::new(); /// Find the real git binary by directly probing candidate paths — without reading @@ -3594,6 +3659,36 @@ fn init_template_repo() -> PathBuf { path } +fn init_reftable_template_repo(object_format: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "git-ai-test-template-reftable-{}-{}", + object_format, + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + + let p = path.to_str().unwrap(); + let git = real_git_executable(); + let mut command = Command::new(git); + command.args([ + "init", + "--ref-format=reftable", + &format!("--object-format={object_format}"), + "-b", + "main", + p, + ]); + let output = run_command_output(&mut command, "init reftable template repo") + .expect("failed to init reftable template repo"); + assert!( + output.status.success(), + "reftable template git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + set_repo_user_config(&path); + path +} + fn init_bare_template_repo() -> PathBuf { let path = std::env::temp_dir().join(format!("git-ai-test-template-bare-{}", std::process::id())); @@ -3638,6 +3733,17 @@ fn clone_template_to(dest: &std::path::Path) { copy_dir_recursive(template, dest).expect("failed to copy template repo"); } +fn clone_reftable_template_to(dest: &Path, object_format: &str) { + let template = match object_format { + "sha1" => TEMPLATE_REFTABLE_SHA1_REPO.get_or_init(|| init_reftable_template_repo("sha1")), + "sha256" => { + TEMPLATE_REFTABLE_SHA256_REPO.get_or_init(|| init_reftable_template_repo("sha256")) + } + other => panic!("unsupported test object format: {other}"), + }; + copy_dir_recursive(template, dest).expect("failed to copy reftable template repo"); +} + /// Clone the cached bare template repo to a new destination path. fn clone_bare_template_to(dest: &std::path::Path) { let template = TEMPLATE_BARE_REPO.get_or_init(init_bare_template_repo);