Show conflicts to parent branch
This commit is contained in:
190
conflicts.go
Normal file
190
conflicts.go
Normal 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user