Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce diff3 based conflict resolver #13

Merged
merged 1 commit into from
Aug 23, 2024
Merged
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
18 changes: 18 additions & 0 deletions cmd/squash_cherry_pick.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var (
committerEmail string
committerTime string
ref string
conflictRef string
currentRefHash string
abortOnConflict bool

Expand All @@ -53,6 +54,11 @@ var squashCherryPick = &cobra.Command{
}

client := &http.Client{Transport: &authnRoundtripper{}}
var conflictRef *plumbing.ReferenceName
if squashCherryPickArgs.conflictRef != "" {
r := plumbing.ReferenceName(squashCherryPickArgs.conflictRef)
conflictRef = &r
}
result, fetchDebugInfo, pushDebugInfo, pushErr := nichegit.PushSquashCherryPick(
squashCherryPickArgs.repoURL,
client,
Expand All @@ -63,6 +69,7 @@ var squashCherryPick = &cobra.Command{
author,
committer,
plumbing.ReferenceName(squashCherryPickArgs.ref),
conflictRef,
currentRefhash,
squashCherryPickArgs.abortOnConflict,
)
Expand All @@ -75,6 +82,8 @@ var squashCherryPick = &cobra.Command{
output.CherryPickedFiles = result.CherryPickedFiles
output.ConflictOpenFiles = result.ConflictOpenFiles
output.ConflictResolvedFiles = result.ConflictResolvedFiles
output.BinaryConflictFiles = result.BinaryConflictFiles
output.NonFileConflictFiles = result.NonFileConflictFiles
}
if output.CherryPickedFiles == nil {
output.CherryPickedFiles = []string{}
Expand All @@ -85,6 +94,12 @@ var squashCherryPick = &cobra.Command{
if output.ConflictResolvedFiles == nil {
output.ConflictResolvedFiles = []string{}
}
if output.BinaryConflictFiles == nil {
output.BinaryConflictFiles = []string{}
}
if output.NonFileConflictFiles == nil {
output.NonFileConflictFiles = []string{}
}
if pushErr != nil {
output.Error = pushErr.Error()
}
Expand Down Expand Up @@ -118,6 +133,8 @@ type squashCherryPickOutput struct {
CherryPickedFiles []string `json:"cherryPickedFiles"`
ConflictOpenFiles []string `json:"conflictOpenFiles"`
ConflictResolvedFiles []string `json:"conflictResolvedFiles"`
BinaryConflictFiles []string `json:"binaryConflictFiles"`
NonFileConflictFiles []string `json:"nonFileConflictFiles"`
FetchDebugInfo debug.FetchDebugInfo `json:"fetchDebugInfo"`
PushDebugInfo *debug.PushDebugInfo `json:"pushDebugInfo"`
Error string `json:"error,omitempty"`
Expand All @@ -137,6 +154,7 @@ func init() {
squashCherryPick.Flags().StringVar(&squashCherryPickArgs.committerEmail, "committer-email", "", "Commiter email address")
squashCherryPick.Flags().StringVar(&squashCherryPickArgs.committerTime, "committer-time", "", "Commit time in RFC3339 format (e.g. 2024-01-01T00:00:00Z)")
squashCherryPick.Flags().StringVar(&squashCherryPickArgs.ref, "ref", "", "A ref name (e.g. refs/heads/foobar) to push")
squashCherryPick.Flags().StringVar(&squashCherryPickArgs.conflictRef, "conflict-ref", "", "A ref name (e.g. refs/heads/foobar) to push when there's a conflict")
squashCherryPick.Flags().StringVar(&squashCherryPickArgs.currentRefHash, "current-ref-hash", "", "The expected current commit hash of the ref. If this is specified, the push will use the current commit hash. This is used for compare-and-swap.")
squashCherryPick.Flags().BoolVar(&squashCherryPickArgs.abortOnConflict, "abort-on-conflict", false, "Abort the operation if there is a merge conflict")
_ = squashCherryPick.MarkFlagRequired("repo-url")
Expand Down
1 change: 1 addition & 0 deletions e2e_tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/niche-git
95 changes: 95 additions & 0 deletions e2e_tests/helper_git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package e2e_tests

import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/go-git/go-git/v5/plumbing"
"github.com/kr/text"
"github.com/stretchr/testify/require"
)

func NewTempRepo(t *testing.T) *GitTestRepo {
dir := t.TempDir()
init := exec.Command("git", "init", "--initial-branch=main")
init.Dir = dir
err := init.Run()
require.NoError(t, err, "failed to initialize git repository")
repo := &GitTestRepo{dir}
require.NoError(t, err, "failed to open repo")

settings := map[string]string{
"user.name": "niche-git-test",
"user.email": "niche-git-test@nonexistant",
}
for k, v := range settings {
repo.Git(t, "config", k, v)
}

repo.CommitFile(t, "README.md", "Hello World")
return repo
}

type GitTestRepo struct {
RepoDir string
}

func (r *GitTestRepo) Git(t *testing.T, args ...string) string {
cmd := exec.Command("git", args...)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Dir = r.RepoDir
err := cmd.Run()
var exitError *exec.ExitError
if err != nil && !errors.As(err, &exitError) {
t.Fatal(err)
}
t.Logf("Running git\n"+
"args: %v\n"+
"exit code: %v\n"+
"stdout:\n"+
"%s"+
"stderr:\n"+
"%s",
args,
cmd.ProcessState.ExitCode(),
text.Indent(stdout.String(), " "),
text.Indent(stderr.String(), " "),
)
return stdout.String()
}

func (r *GitTestRepo) AddFile(t *testing.T, fp string) {
r.Git(t, "add", fp)
}

func (r *GitTestRepo) CreateFile(t *testing.T, filename string, body string) string {
fp := filepath.Join(r.RepoDir, filename)
err := os.WriteFile(fp, []byte(body), 0644)
require.NoError(t, err, "failed to write file: %s", filename)
return fp
}

func (r *GitTestRepo) CommitFile(t *testing.T, filename string, body string) plumbing.Hash {
filepath := r.CreateFile(t, filename, body)
r.AddFile(t, filepath)

args := []string{"commit", "-m", fmt.Sprintf("Write %s", filename)}
r.Git(t, args...)
return plumbing.NewHash(strings.TrimSpace(r.Git(t, "rev-parse", "HEAD")))
}

func (r *GitTestRepo) ReadFile(t *testing.T, filename string) string {
fp := filepath.Join(r.RepoDir, filename)
bs, err := os.ReadFile(fp)
require.NoError(t, err, "failed to read file: %s", filename)
return string(bs)
}
80 changes: 80 additions & 0 deletions e2e_tests/helper_niche_git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package e2e_tests

import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/kr/text"
"github.com/stretchr/testify/require"
)

var nicheGitCmdPath string

func init() {
cmd := exec.Command("go", "build", "../cmd/niche-git")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
panic(err)
}
var err error
nicheGitCmdPath, err = filepath.Abs("./niche-git")
if err != nil {
panic(err)
}
}

type NicheGitOutput struct {
ExitCode int
Stdout string
Stderr string
}

func cmd(t *testing.T, exe string, args ...string) NicheGitOutput {
cmd := exec.Command(exe, args...)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr

err := cmd.Run()
var exitError *exec.ExitError
if err != nil && !errors.As(err, &exitError) {
t.Fatal(err)
}

output := NicheGitOutput{
ExitCode: cmd.ProcessState.ExitCode(),
Stdout: stdout.String(),
Stderr: stderr.String(),
}
t.Logf("Running niche-git\n"+
"args: %v\n"+
"exit code: %v\n"+
"stdout:\n"+
"%s"+
"stderr:\n"+
"%s",
args,
cmd.ProcessState.ExitCode(),
text.Indent(stdout.String(), " "),
text.Indent(stderr.String(), " "),
)
return output
}

func NicheGit(t *testing.T, args ...string) NicheGitOutput {
return cmd(t, nicheGitCmdPath, args...)
}

func RequireNicheGit(t *testing.T, args ...string) NicheGitOutput {
t.Helper()
output := NicheGit(t, args...)
require.Equal(t, 0, output.ExitCode, "niche-git %s: exited with %v", args, output.ExitCode)
return output
}
Binary file modified e2e_tests/niche-git
Binary file not shown.
124 changes: 124 additions & 0 deletions e2e_tests/squash_cherry_pick_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package e2e_tests

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestSquashCherryPick_Conflict_Resolve(t *testing.T) {
repo := NewTempRepo(t)

baseHash := repo.CommitFile(t, "file1", `
line 1
line 2
line 3
line 4
line 6
line 7
line 8
line 9
`)

repo.Git(t, "checkout", "-b", "feature")
featureHash := repo.CommitFile(t, "file1", `
line 1
line 2
line 3
line 4
line 6
line 7
line 8
line 9
line 10
`)
repo.Git(t, "checkout", "main")
targetHash := repo.CommitFile(t, "file1", `
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
`)

// At this point, featureHash adds line 10, targetHash adds line 5.
RequireNicheGit(t,
"squash-cherry-pick",
"--repo-url", "file://"+repo.RepoDir,
"--cherry-pick-from", featureHash.String(),
"--cherry-pick-to", targetHash.String(),
"--cherry-pick-base", baseHash.String(),
"--commit-message", "pick feature",
"--author", "cherry-picker",
"--author-email", "cherry-picker@nonexistent",
"--committer", "cherry-picker",
"--committer-email", "cherry-picker@nonexistent",
"--ref", "refs/heads/cherry-pick-result",
)
repo.Git(t, "checkout", "cherry-pick-result")
require.Equal(t, `
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
`, repo.ReadFile(t, "file1"))
}

func TestSquashCherryPick_Conflict_Unresolved(t *testing.T) {
repo := NewTempRepo(t)

baseHash := repo.CommitFile(t, "file1", `
line 1
line 2
line 3
`)

repo.Git(t, "checkout", "-b", "feature")
featureHash := repo.CommitFile(t, "file1", `
line 1
line 5
line 6
`)
repo.Git(t, "checkout", "main")
targetHash := repo.CommitFile(t, "file1", `
line 1
line 8
line 9
`)

RequireNicheGit(t,
"squash-cherry-pick",
"--repo-url", "file://"+repo.RepoDir,
"--cherry-pick-from", featureHash.String(),
"--cherry-pick-to", targetHash.String(),
"--cherry-pick-base", baseHash.String(),
"--commit-message", "pick feature",
"--author", "cherry-picker",
"--author-email", "cherry-picker@nonexistent",
"--committer", "cherry-picker",
"--committer-email", "cherry-picker@nonexistent",
"--ref", "refs/heads/cherry-pick-result",
"--conflict-ref", "refs/heads/cherry-pick-conflict",
)
repo.Git(t, "checkout", "cherry-pick-conflict")
require.Equal(t, `
line 1
<<<<<<<<< Cherry-pick content
line 5
line 6
=========
line 8
line 9
>>>>>>>>> Base content
`, repo.ReadFile(t, "file1"))
}
Loading