diff --git a/pkg/cmd/ci/run.go b/pkg/cmd/ci/run.go index 3097160e..b64d052c 100644 --- a/pkg/cmd/ci/run.go +++ b/pkg/cmd/ci/run.go @@ -358,10 +358,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)) @@ -370,6 +368,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 + } } } @@ -389,6 +392,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 { diff --git a/pkg/cmd/ci/run_test.go b/pkg/cmd/ci/run_test.go index ebe20ccc..eef250d3 100644 --- a/pkg/cmd/ci/run_test.go +++ b/pkg/cmd/ci/run_test.go @@ -174,6 +174,48 @@ 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) + } +} + func TestValidateWorkspacePatch_emptyMergeBase(t *testing.T) { err := validateWorkspacePatch(&patchInfo{mergeBase: "", content: "x"}) if err == nil {