diff --git a/crates/but-graph/src/init/mod.rs b/crates/but-graph/src/init/mod.rs index a38ea41b3e1..56b8e589897 100644 --- a/crates/but-graph/src/init/mod.rs +++ b/crates/but-graph/src/init/mod.rs @@ -1600,6 +1600,19 @@ fn initial_tips_from_workspace_metadata( push_integrated_tip_once(&mut tips, extra_target); } + // The live tip of the target ref, used to detect target commits orphaned by an upstream + // history rewrite: they still exist as objects (so `find_commit` succeeds below) but no + // longer share any history with the rewritten target. + let target_ref_tip = workspace_target_tip(repo, project_meta.target_ref.as_ref())? + .map(|(_target_ref, target_ref_id, _local_info)| target_ref_id); + // On a shallow clone gix's merge-base silently treats a missing (below-graft) parent as a root, + // so a commit whose real merge-base lies below the boundary reports as "no merge-base" exactly + // like a genuinely rewritten-away commit. We therefore only trust a disjoint result on a full + // clone; on a shallow clone we keep the stored commit rather than risk dropping a still-reachable + // target and silently shifting the workspace base. An unreadable `.git/shallow` is assumed + // shallow, preserving the conservative "don't drop what we can't prove disjoint" default. + let repo_is_shallow = repo.shallow_commits().map(|c| c.is_some()).unwrap_or(true); + for target_commit_id in additional_target_commits { // These are possibly from metadata, and thus might not exist (anymore). Ignore if that's the case. if let Err(err) = repo.find_commit(target_commit_id) { @@ -1610,6 +1623,28 @@ fn initial_tips_from_workspace_metadata( ); continue; } + // A history rewrite (e.g. `git filter-repo` + force-push) can leave the stored target commit + // existing as an object but sharing no history with the rewritten target ref. Pinning such a + // disjoint commit anchors the workspace on a base with no merge-base with its target -> the + // perpetual "No merge-base found" / "Target branch divergence" state of #14415. On a full + // clone, gix's `NotFound` ("no common ancestor") reliably means a genuine rewrite, so drop the + // commit and let the workspace fall back to the live target tip. Anything else -- it shares + // history, or the relationship couldn't be computed -- leaves the stored commit untouched. + if let Some(target_ref_tip) = target_ref_tip + && !repo_is_shallow + && matches!( + repo.for_find_only() + .merge_base(target_commit_id, target_ref_tip), + Err(gix::repository::merge_base::Error::NotFound { .. }) + ) + { + tracing::warn!( + ?target_commit_id, + ?target_ref_tip, + "Ignoring stale target commit id as it shares no history with the target ref (history rewritten?)" + ); + continue; + } // We don't really have a place to store the segment index of the segment owning the target commit // so we will re-acquire it later when building the workspace projection. push_integrated_tip_once(&mut tips, target_commit_id); diff --git a/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs b/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs index 7c59e9d7068..de3153e5c54 100644 --- a/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs +++ b/crates/but-graph/tests/graph/workspace/resolved_target_commit_id.rs @@ -156,3 +156,64 @@ fn returns_extra_target_without_target_ref() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn ignores_orphaned_target_commit_unreachable_from_target_ref() -> anyhow::Result<()> { + // Reproduces #14415: after an upstream history rewrite (e.g. `git filter-branch` / + // `git filter-repo` followed by a force-push), the stored `target_commit_id` + // (`gitbutler.project.targetCommitId`) points at a commit that still EXISTS as an object + // but is no longer reachable from the rewritten target ref. The init code only skips such + // a commit when the object is *gone* (`repo.find_commit` errors), so an orphaned-but-still- + // existing commit gets pinned into the workspace as an integrated tip. The workspace then + // shares no merge-base with its target -> the perpetual "No merge-base found" / + // "Target branch divergence" loop the issue describes. + let (repo, mut meta) = read_only_in_memory_scenario("ws/two-branches-one-below-base")?; + + // Stand-in for the pre-rewrite target commit: a real object that shares no history with + // origin/main, so `find_commit` succeeds but there is no merge-base with the target. + let orphan = write_orphan_commit(&repo, "orphaned pre-rewrite target")?; + assert!( + repo.find_commit(orphan).is_ok(), + "the orphaned commit must still exist as an object (not yet GC'd) to trigger the bug" + ); + + add_workspace_with_target(&mut meta, orphan); + + let ws = Graph::from_head(&repo, &*meta, project_meta(&*meta), standard_options())? + .validated()? + .into_workspace()?; + + let origin_main_tip = repo.rev_parse_single("origin/main")?.detach(); + assert_eq!( + ws.resolved_target_commit_id(), + Some(origin_main_tip), + "an orphaned target_commit (unreachable from the target ref) must be ignored and the live \ + target tip used instead; pinning the orphan is the #14415 divergence bug" + ); + + Ok(()) +} + +/// Write a parent-less commit with an empty tree directly into `repo`'s object database. +/// It exists as an object (so `find_commit` succeeds) but shares no history with any branch, +/// standing in for a commit orphaned by an upstream history rewrite. +fn write_orphan_commit(repo: &gix::Repository, message: &str) -> anyhow::Result { + let signature = gix::actor::Signature { + name: "Rewrite".into(), + email: "rewrite@example.com".into(), + time: gix::date::Time { + seconds: 0, + offset: 0, + }, + }; + let commit = gix::objs::Commit { + tree: repo.object_hash().empty_tree(), + parents: vec![].into(), + author: signature.clone(), + committer: signature, + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + Ok(repo.write_object(&commit)?.detach()) +}