Show conflicts to parent branch

This commit is contained in:
2026-07-28 11:46:43 +02:00
parent 0e880fa9b0
commit f93c2c517a
8 changed files with 509 additions and 41 deletions

190
conflicts.go Normal file
View File

@@ -0,0 +1,190 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
)
type conflictFileLoader func(
context.Context, string, int, string, string, string, string,
) ([]string, error)
type conflictFileResult struct {
files []string
err error
checkedAt time.Time
}
func (c *GitHubClient) loadConflictFiles(
ctx context.Context,
repositoryURL string,
number int,
baseRef, baseOID, headOID string,
) ([]string, error) {
key := strings.Join([]string{repositoryURL, baseOID, headOID}, "\x00")
c.conflictMu.Lock()
cached, ok := c.conflictCache[key]
c.conflictMu.Unlock()
if ok && (cached.err == nil || time.Since(cached.checkedAt) < time.Minute) {
return append([]string(nil), cached.files...), cached.err
}
files, err := c.conflicts(ctx, repositoryURL, number, baseRef, baseOID, headOID, c.token)
result := conflictFileResult{
files: append([]string(nil), files...), err: err, checkedAt: time.Now(),
}
c.conflictMu.Lock()
c.conflictCache[key] = result
c.conflictMu.Unlock()
return files, err
}
func analyzeConflictFiles(
ctx context.Context,
repositoryURL string,
number int,
baseRef, _, _, token string,
) ([]string, error) {
if repositoryURL == "" || baseRef == "" || number <= 0 {
return nil, errors.New("missing repository merge metadata")
}
gitDir, err := os.MkdirTemp("", "gh-threads-conflicts-*")
if err != nil {
return nil, fmt.Errorf("create temporary merge repository: %w", err)
}
defer os.RemoveAll(gitDir)
run := func(args ...string) ([]byte, error) {
command := exec.CommandContext(ctx, "git", args...)
command.Env = gitAuthenticationEnvironment(callerEnvironment(), token)
return command.CombinedOutput()
}
if output, runErr := run("init", "--bare", gitDir); runErr != nil {
return nil, commandError("initialize merge analysis", output, runErr)
}
cloneURL := strings.TrimSuffix(strings.TrimSuffix(repositoryURL, "/"), ".git") + ".git"
if output, runErr := run("-C", gitDir, "remote", "add", "origin", cloneURL); runErr != nil {
return nil, commandError("configure merge analysis remote", output, runErr)
}
if output, runErr := run("-C", gitDir, "config", "remote.origin.promisor", "true"); runErr != nil {
return nil, commandError("configure partial clone", output, runErr)
}
if output, runErr := run("-C", gitDir, "config", "remote.origin.partialclonefilter", "blob:none"); runErr != nil {
return nil, commandError("configure partial clone filter", output, runErr)
}
refspecs := []string{
"+refs/heads/" + baseRef + ":refs/gh-threads/base",
"+refs/pull/" + strconv.Itoa(number) + "/head:refs/gh-threads/head",
}
fetch := func(depthArgs ...string) error {
args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"}
args = append(args, depthArgs...)
args = append(args, "origin")
args = append(args, refspecs...)
output, runErr := run(args...)
return commandError("fetch merge inputs", output, runErr)
}
if err := fetch("--depth=64"); err != nil {
return nil, err
}
for _, deepen := range []string{"192", "768"} {
if mergeBaseExists(run, gitDir) {
break
}
if err := fetch("--deepen=" + deepen); err != nil {
return nil, err
}
}
if !mergeBaseExists(run, gitDir) {
if err := fetch("--unshallow"); err != nil {
return nil, err
}
}
output, runErr := run(
"-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z",
"refs/gh-threads/base", "refs/gh-threads/head",
)
if runErr == nil {
return nil, nil
}
var exitErr *exec.ExitError
if !errors.As(runErr, &exitErr) || exitErr.ExitCode() != 1 {
return nil, commandError("analyze merge conflicts", output, runErr)
}
return parseConflictFiles(output)
}
func mergeBaseExists(
run func(...string) ([]byte, error),
gitDir string,
) bool {
_, err := run(
"-C", gitDir, "merge-base", "refs/gh-threads/base", "refs/gh-threads/head",
)
return err == nil
}
func parseConflictFiles(output []byte) ([]string, error) {
parts := bytes.Split(output, []byte{0})
if len(parts) < 2 || len(parts[0]) == 0 {
return nil, errors.New("git merge-tree returned malformed conflict data")
}
files := make([]string, 0, len(parts)-2)
seen := make(map[string]bool)
for _, raw := range parts[1:] {
name := string(raw)
if name == "" || seen[name] {
continue
}
seen[name] = true
files = append(files, name)
}
sort.Strings(files)
return files, nil
}
func commandError(action string, output []byte, err error) error {
if err == nil {
return nil
}
message := strings.TrimSpace(string(output))
if message == "" {
return fmt.Errorf("%s: %w", action, err)
}
return fmt.Errorf("%s: %s", action, message)
}
func callerEnvironment() []string {
const prefix = "GIT_CONFIG_"
environment := make([]string, 0, len(os.Environ())+5)
for _, item := range os.Environ() {
if !strings.HasPrefix(item, prefix) && !strings.HasPrefix(item, "GIT_TERMINAL_PROMPT=") {
environment = append(environment, item)
}
}
return environment
}
func gitAuthenticationEnvironment(environment []string, token string) []string {
environment = append(environment, "GIT_TERMINAL_PROMPT=0")
if token == "" {
return environment
}
credentials := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token))
return append(environment,
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=http.extraHeader",
"GIT_CONFIG_VALUE_0=Authorization: Basic "+credentials,
)
}