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
62 changes: 60 additions & 2 deletions pkg/cmd/ci/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,8 @@ func findMergeBase(workflowDir string) (baseBranch string, mergeBase string, err
branch := strings.TrimSpace(string(branchOut))
if branch != "" && branch != "HEAD" { // not detached
remoteBranch := "origin/" + branch
// Verify the remote branch exists
_, err := exec.Command("git", "-C", workflowDir, "rev-parse", "--verify", remoteBranch).Output()
if err == nil {
// Use merge-base to find common ancestor
shaOut, err := exec.Command("git", "-C", workflowDir, "merge-base", "HEAD", remoteBranch).Output()
if err == nil {
sha := strings.TrimSpace(string(shaOut))
Expand All @@ -321,6 +319,11 @@ func findMergeBase(workflowDir string) (baseBranch string, mergeBase string, err
}
}
}
} else if branch == "HEAD" {
// Detached HEAD: find the closest remote tracking ancestor
if ref, sha, err := findClosestRemoteAncestor(workflowDir); err == nil {
return ref, sha, nil
}
}
}

Expand All @@ -340,6 +343,61 @@ func findMergeBase(workflowDir string) (baseBranch string, mergeBase string, err
return defaultBranch, strings.TrimSpace(string(mergeBaseOut)), nil
}

// findClosestRemoteAncestor walks remote tracking refs to find the one
// closest to HEAD (fewest commits between it and HEAD). This produces
// smaller patches for detached-HEAD workflows like jj.
func findClosestRemoteAncestor(workflowDir string) (refName string, sha string, err error) {
refsOut, err := exec.Command("git", "-C", workflowDir,
"for-each-ref", "refs/remotes/origin/", "--format=%(objectname) %(refname:short)").Output()
if err != nil {
return "", "", err
}

bestRef := ""
bestSHA := ""
bestDist := -1

for _, line := range strings.Split(strings.TrimSpace(string(refsOut)), "\n") {
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
continue
}
refSHA, ref := parts[0], parts[1]

// Skip origin/HEAD (symbolic ref, not a real branch)
if ref == "origin/HEAD" {
continue
}

// Check if this ref is an ancestor of HEAD
err := exec.Command("git", "-C", workflowDir, "merge-base", "--is-ancestor", refSHA, "HEAD").Run()
if err != nil {
continue
}

// Count commits between this ref and HEAD
countOut, err := exec.Command("git", "-C", workflowDir, "rev-list", "--count", refSHA+"..HEAD").Output()
if err != nil {
continue
}

dist := 0
fmt.Sscanf(strings.TrimSpace(string(countOut)), "%d", &dist)

if bestDist < 0 || dist < bestDist {
bestDist = dist
bestRef = ref
bestSHA = refSHA
}
}

if bestRef == "" {
return "", "", fmt.Errorf("no remote ancestor found")
}

return bestRef, bestSHA, nil
}

func detectPatch(workflowDir string) *patchInfo {
baseBranch, mergeBase, err := findMergeBase(workflowDir)
if err != nil {
Expand Down
42 changes: 42 additions & 0 deletions pkg/cmd/ci/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,45 @@ func TestFindMergeBase_DetachedHEAD(t *testing.T) {

_ = baseBranch
}

func TestFindMergeBase_DetachedHEAD_WithPushedAncestor(t *testing.T) {
bare := initBareRemote(t)
clone := cloneRepo(t, bare)

// Create a feature branch and push it
run(t, clone, "git", "checkout", "-b", "feature/jj-test")
writeFile(t, filepath.Join(clone, "feature.txt"), "pushed work")
run(t, clone, "git", "add", ".")
run(t, clone, "git", "commit", "-m", "pushed feature commit")
run(t, clone, "git", "push", "-u", "origin", "feature/jj-test")

pushedSHA := run(t, clone, "git", "rev-parse", "HEAD")

// Add two local-only commits (simulating jj workflow)
writeFile(t, filepath.Join(clone, "local1.txt"), "local1")
run(t, clone, "git", "add", ".")
run(t, clone, "git", "commit", "-m", "local commit 1")

writeFile(t, filepath.Join(clone, "local2.txt"), "local2")
run(t, clone, "git", "add", ".")
run(t, clone, "git", "commit", "-m", "local commit 2")

// Detach HEAD (standard jj workflow)
headSHA := run(t, clone, "git", "rev-parse", "HEAD")
run(t, clone, "git", "checkout", headSHA)

baseBranch, mergeBase, err := findMergeBase(clone)
if err != nil {
t.Fatalf("findMergeBase failed: %v", err)
}

// Should find the pushed feature branch as the closest ancestor,
// NOT fall back to origin/main (which would produce a bigger patch)
if mergeBase != pushedSHA {
t.Errorf("expected mergeBase=%s (pushed feature commit), got %s", pushedSHA, mergeBase)
}

if baseBranch != "origin/feature/jj-test" {
t.Errorf("expected baseBranch=origin/feature/jj-test, got %q", baseBranch)
}
}
Loading