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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crab/docs/architecture/indexed-remote-reads.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions crab/scripts/e2e/run_add_commit_push_rustfs_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"])
Expand Down
186 changes: 178 additions & 8 deletions crab/src/git/capsule_push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -736,6 +742,15 @@ fn prepare_visibility_delta(
) -> Result<Option<crab_metadata::capsule_protocol::CapsuleVisibilityDelta>> {
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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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");
Expand Down
Loading