diff --git a/crab/docs/architecture/indexed-remote-reads.md b/crab/docs/architecture/indexed-remote-reads.md index 1968be230..00c2fffc5 100644 --- a/crab/docs/architecture/indexed-remote-reads.md +++ b/crab/docs/architecture/indexed-remote-reads.md @@ -2130,3 +2130,27 @@ relaxed. Both updated fixtures pass against the unchanged #570 root consumer (23 commands, 23 checks), and the 11 source-isolation tests pass. The explicit v1 upgrade/reset fixtures remain separate. Full final-source canary proof is still pending. + +## Forced publication without a local remote tip + +A clone can diverge after another writer advances the remote. Its explicit +force push must not require fetching the discarded tip. Capsule admission now +allows an unavailable ancestry result only for an explicitly forced update or +matched ref lease when receive policy permits rewrites. Stale leases and +`receive.denyNonFastForwards` still reject; publication retains its expected-old +conditional transition. Real ancestry-probe errors remain errors. + +Proven fast-forwards and rewrites with an available prior tip keep incremental +visibility. Rewrites lacking that tip publish the +existing authenticated replacement-closure representation, walking only the +new tip. This removes old objects from that ref's authorization without reading +discarded history. Pack generation and LFS publication already exclude only +remote tips present in the local object database. Both native CLI and Git +remote-helper publication use this capsule owner. + +The regression matrix covers ordinary, forced, matched/stale lease and denied +updates with the old remote commit absent locally. Successful rewrites verify +the remote tip, revoke old-tip visibility, install packs into an independent +repository and run Git connectivity checks. The existing RustFS ref matrix +also exercises native and remote-helper force pushes without fetching first. +These tests remain pending until the updated-source CI run completes. diff --git a/crab/scripts/e2e/run_add_commit_push_rustfs_smoke.py b/crab/scripts/e2e/run_add_commit_push_rustfs_smoke.py index 17f2f63d9..dad662289 100755 --- a/crab/scripts/e2e/run_add_commit_push_rustfs_smoke.py +++ b/crab/scripts/e2e/run_add_commit_push_rustfs_smoke.py @@ -960,6 +960,17 @@ def run_ref_update_delete_force_and_tag_case(self) -> None: {"expected": third}, ) + missing_tip = self.run_git( + contender, + ["cat-file", "-e", third], + name=f"{case_name} remote tip absent locally", + check=False, + ) + self.check( + f"{case_name}-force-push-does-not-require-fetch", + missing_tip.exit_code != 0, + {"remote_tip": third}, + ) self.run_git(contender, ["push", "--force", "origin", "main"]) self.check( f"{case_name}-force-push-updates-tip", @@ -970,6 +981,20 @@ def run_ref_update_delete_force_and_tag_case(self) -> None: {"expected": divergent}, ) + self.run_crab( + repo, + ["push", "--force", "origin", "main"], + name=f"{case_name} native force without fetching contender", + ) + self.check( + f"{case_name}-native-force-push-updates-tip", + self.ls_remote(remote_url, name=f"{case_name} list after native force").get( + "refs/heads/main" + ) + == third, + {"expected": third}, + ) + self.run_git(contender, ["push", "--force", "origin", "main"]) self.run_git(contender, ["tag", "-a", "v1", "-m", "version one"]) tag_oid = self.rev_parse(contender, "refs/tags/v1") self.run_git(contender, ["push", "--follow-tags", "origin", "main"]) diff --git a/crab/src/git/capsule_push.rs b/crab/src/git/capsule_push.rs index ae657e236..33cd317ac 100644 --- a/crab/src/git/capsule_push.rs +++ b/crab/src/git/capsule_push.rs @@ -257,12 +257,18 @@ async fn run_inner( } else { is_ancestor(&common_git_dir, old_oid, &new_oid)? }; - let Some(is_fast_forward) = is_fast_forward else { - outcomes.insert( - spec.dst.clone(), - RefPushOutcome::Rejected(PushRejectReason::StaleInfo), - ); - continue; + let is_fast_forward = match is_fast_forward { + Some(proven) => proven, + // A forced rewrite need not have fetched the remote tip. The ref + // lease was checked above; publication still compares the old tip. + None if update.force && !config.receive_deny_non_fast_forwards => false, + None => { + outcomes.insert( + spec.dst.clone(), + RefPushOutcome::Rejected(PushRejectReason::StaleInfo), + ); + continue; + } }; let decision = if !is_fast_forward && config.receive_deny_non_fast_forwards { Err(PushRejectReason::DenyNonFastForward) @@ -736,6 +742,15 @@ fn prepare_visibility_delta( ) -> Result> { let maximum = usize::try_from(crab_metadata::git_visibility::MAX_GIT_VISIBILITY_OBJECTS) .map_err(|_| CrabError::Internal("Git visibility limit does not fit usize".to_owned()))?; + let rewritten_old_refs = edits + .iter() + .filter(|edit| edit.new_oid().is_some() && !fast_forward_refs.contains(edit.ref_name())) + .filter_map(|edit| { + edit.expected_old() + .map(|oid| (edit.ref_name().to_owned(), oid.to_owned())) + }) + .collect(); + let available_old_tips = locally_available_remote_tips(git_dir, &rewritten_old_refs)?; let mut visibility = BTreeMap::new(); for edit in edits { let Some(new_oid) = edit.new_oid() else { @@ -756,7 +771,10 @@ fn prepare_visibility_delta( pack_objects.iter().map(ToString::to_string).collect(), Vec::new(), ) - } else if let Some(old_oid) = edit.expected_old() { + } else if let Some(old_oid) = edit.expected_old() + && (fast_forward_refs.contains(edit.ref_name()) + || available_old_tips.iter().any(|tip| tip == old_oid)) + { let added = super::push::enumerate_visibility_difference( git_dir, new_oid, @@ -784,11 +802,13 @@ fn prepare_visibility_delta( removed, ) } else { + // Rewrites may lack the old tip locally. A complete replacement + // closure revokes its visibility without fetching discarded history. let objects = super::push::enumerate_visibility_difference(git_dir, new_oid, None, maximum)? .ok_or_else(|| visibility_limit_error(edit.ref_name()))?; crab_metadata::git_visibility::GitVisibilityEdit::from_replacement_objects( - None, + edit.expected_old().map(str::to_owned), new_oid.to_owned(), objects, ) @@ -1416,6 +1436,156 @@ mod tests { ); } + #[tokio::test] + async fn force_rewrite_without_local_remote_tip_preserves_policy_and_visibility() { + let _git_env = crate::test::git_repo::CleanGitEnvGuard::new(); + for (name, force, lease, deny, succeeds) in [ + ("ordinary", false, None, false, false), + ("force", true, None, false, true), + ("lease", false, Some(true), false, true), + ("stale-lease", true, Some(false), false, false), + ("deny-force", true, None, true, false), + ("deny-lease", false, Some(true), true, false), + ] { + let source = tempfile::tempdir().expect("source repository"); + git(source.path(), &["init", "-b", "main"]); + git(source.path(), &["config", "user.name", "Crab Test"]); + git( + source.path(), + &["config", "user.email", "crab@example.invalid"], + ); + let first = commit(source.path(), "first"); + let contender_root = tempfile::tempdir().expect("contender directory"); + let contender = contender_root.path().join("repo"); + git( + contender_root.path(), + &[ + "clone", + "--no-local", + source.path().to_str().unwrap(), + "repo", + ], + ); + git(&contender, &["config", "user.name", "Crab Test"]); + git( + &contender, + &["config", "user.email", "crab@example.invalid"], + ); + let second = commit(source.path(), "second"); + let divergent = commit(&contender, "divergent"); + let git_dir = contender.join(".git"); + assert_eq!( + is_ancestor(&git_dir, &second, &divergent).unwrap(), + None, + "{name}" + ); + + let store = crate::storage::store::Store::from_storage(crab_storage::Store::new( + Arc::new(InMemory::new()), + )); + let router = crate::storage::StoreLayout::new(store.clone(), "repos/test".to_owned()); + let layout = + crab_storage::StoreLayout::new(store.as_storage().clone(), "repos/test".to_owned()); + crab_write::capsule_protocol::initialize(&layout, &"1".repeat(64), "refs/heads/main") + .await + .unwrap(); + let limits = crab_read::capsule_protocol::CapsuleReadLimits { + max_capsule_bytes: 16 * 1024 * 1024, + max_frontier_bytes: 16 * 1024 * 1024, + }; + let spec = PushSpec { + force: false, + src: "refs/heads/main".to_owned(), + dst: "refs/heads/main".to_owned(), + }; + let config = PushConfig { + git_dir: Some(source.path().join(".git")), + ..PushConfig::default() + }; + let (initial, _) = run( + &config, + std::slice::from_ref(&spec), + &store, + &router, + None, + &[], + None, + None, + None, + &CancellationToken::new(), + ) + .await + .unwrap(); + assert!(initial.all_ok(), "{name}: initial publication"); + let mut config = PushConfig { + git_dir: Some(git_dir), + receive_deny_non_fast_forwards: deny, + ..PushConfig::default() + }; + if let Some(matches) = lease { + config.expected_refs.insert( + "refs/heads/main".to_owned(), + Some(if matches { second.clone() } else { first }), + ); + } + let spec = PushSpec { force, ..spec }; + let (result, _) = run( + &config, + &[spec], + &store, + &router, + None, + &[], + None, + None, + None, + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(result.all_ok(), succeeds, "{name}"); + let view = crab_read::capsule_protocol::open_view(&layout, limits) + .await + .unwrap(); + assert_eq!( + view.refs()["refs/heads/main"], + if succeeds { + divergent.clone() + } else { + second.clone() + }, + "{name}: remote tip" + ); + if succeeds { + let visibility = view.git_visibility_index().unwrap(); + let refs = visibility.ref_closures(); + let closure = &refs["refs/heads/main"]; + assert!( + closure.binary_search(&divergent).is_ok() + && closure.binary_search(&second).is_err(), + "{name}: rewrite must retire old visibility" + ); + let destination = tempfile::tempdir().unwrap(); + git(destination.path(), &["init", "--bare"]); + crab_read::capsule_protocol::install_git_packs_from_store( + &view, + &layout, + destination.path(), + 16 * 1024 * 1024, + None, + &CancellationToken::new(), + ) + .await + .unwrap(); + git( + destination.path(), + &["cat-file", "-e", &format!("{divergent}^{{commit}}")], + ); + git(destination.path(), &["fsck", "--connectivity-only"]); + } + } + } + #[test] fn missing_remote_tip_requests_refresh_instead_of_internal_failure() { let source = tempfile::tempdir().expect("source repository");