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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions crates/but-graph/src/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,19 @@ fn initial_tips_from_workspace_metadata<T: RefMetadata>(
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) {
Expand All @@ -1610,6 +1623,28 @@ fn initial_tips_from_workspace_metadata<T: RefMetadata>(
);
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<gix::ObjectId> {
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())
}
Loading