cli/git/client_test.go
Tyler McGoffin 48e2681017 Merge branch 'trunk' into find-pr-by-rev-parse-push
A recent refactor caused the API for ReadBranchConfig to change, resulting
in changes for its consumers. Additionally, there was a large refactor of
the tests associated with ReadBranchConfig and its consumers to gain
confidence in this refactor. This commit attempts to resolve the
conflicts between the refactor and this effort as well as massage the
changes introduced here to reflect the refactor.

The refactor PR can be found here: https://github.com/cli/cli/pull/10197

I'll note that there are still a few failing tests in status_test.go. I
haven't had a chance to fully grok while they are failing, yet, and
suspect that some insights from the original PR author may be helpful
here.

Full disclaimer: I haven't verified any of this is working locally yet.
My primary motivation is to get these new changes working together in a
manner that unblocks further iteration on this effort.

* trunk: (79 commits)
  Enhance help docs on ext upgrade notices
  chore: fix some function names in comment
  Expand docs on cleaning extension update dir
  Simplifying cleanExtensionUpdateDir logic
  Separate logic for checking updates
  Capture greater detail on updaterEnabled
  Restore old error functionality of prSelectorForCurrentBranch
  Change error handling on ReadBranchConfig to respect git Exit Codes
  fix: add back colon that I removed
  fix: actually read how MaxFunc work and simplify the code
  fix: padded display
  Collapse dryrun checks in ext bin upgrade
  Bump github.com/mattn/go-colorable from 0.1.13 to 0.1.14
  Rename test user in tests
  Change pr number in test
  Surface and handle error from ReadBranchConfig in parseCurrentBranch
  Directly stub headBranchConfig in Test_tryDetermineTrackingRef
  Refactor error handling in ReadBranchConfig to avoid panic
  Refine error handling of ReadBranchConfig
  Add test for empty BranchConfig in prSelectorForCurrentBranch
  ...
2025-01-13 20:38:00 -08:00

2112 lines
60 KiB
Go

package git
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClientCommand(t *testing.T) {
tests := []struct {
name string
repoDir string
gitPath string
wantExe string
wantArgs []string
}{
{
name: "creates command",
gitPath: "path/to/git",
wantExe: "path/to/git",
wantArgs: []string{"path/to/git", "ref-log"},
},
{
name: "adds repo directory configuration",
repoDir: "path/to/repo",
gitPath: "path/to/git",
wantExe: "path/to/git",
wantArgs: []string{"path/to/git", "-C", "path/to/repo", "ref-log"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in, out, errOut := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}
client := Client{
Stdin: in,
Stdout: out,
Stderr: errOut,
RepoDir: tt.repoDir,
GitPath: tt.gitPath,
}
cmd, err := client.Command(context.Background(), "ref-log")
assert.NoError(t, err)
assert.Equal(t, tt.wantExe, cmd.Path)
assert.Equal(t, tt.wantArgs, cmd.Args)
assert.Equal(t, in, cmd.Stdin)
assert.Equal(t, out, cmd.Stdout)
assert.Equal(t, errOut, cmd.Stderr)
})
}
}
func TestClientAuthenticatedCommand(t *testing.T) {
tests := []struct {
name string
path string
pattern CredentialPattern
wantArgs []string
wantErr error
}{
{
name: "when credential pattern allows for anything, credential helper matches everything",
path: "path/to/gh",
pattern: AllMatchingCredentialsPattern,
wantArgs: []string{"path/to/git", "-c", "credential.helper=", "-c", `credential.helper=!"path/to/gh" auth git-credential`, "fetch"},
},
{
name: "when credential pattern is set, credential helper only matches that pattern",
path: "path/to/gh",
pattern: CredentialPattern{pattern: "https://github.com"},
wantArgs: []string{"path/to/git", "-c", "credential.https://github.com.helper=", "-c", `credential.https://github.com.helper=!"path/to/gh" auth git-credential`, "fetch"},
},
{
name: "fallback when GhPath is not set",
pattern: AllMatchingCredentialsPattern,
wantArgs: []string{"path/to/git", "-c", "credential.helper=", "-c", `credential.helper=!"gh" auth git-credential`, "fetch"},
},
{
name: "errors when attempting to use an empty pattern that isn't marked all matching",
pattern: CredentialPattern{allMatching: false, pattern: ""},
wantErr: fmt.Errorf("empty credential pattern is not allowed unless provided explicitly"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := Client{
GhPath: tt.path,
GitPath: "path/to/git",
}
cmd, err := client.AuthenticatedCommand(context.Background(), tt.pattern, "fetch")
if tt.wantErr != nil {
require.Equal(t, tt.wantErr, err)
return
}
require.Equal(t, tt.wantArgs, cmd.Args)
})
}
}
func TestClientRemotes(t *testing.T) {
tempDir := t.TempDir()
initRepo(t, tempDir)
gitDir := filepath.Join(tempDir, ".git")
remoteFile := filepath.Join(gitDir, "config")
remotes := `
[remote "origin"]
url = git@example.com:monalisa/origin.git
[remote "test"]
url = git://github.com/hubot/test.git
gh-resolved = other
[remote "upstream"]
url = https://github.com/monalisa/upstream.git
gh-resolved = base
[remote "github"]
url = git@github.com:hubot/github.git
`
f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755)
assert.NoError(t, err)
_, err = f.Write([]byte(remotes))
assert.NoError(t, err)
err = f.Close()
assert.NoError(t, err)
client := Client{
RepoDir: tempDir,
}
rs, err := client.Remotes(context.Background())
assert.NoError(t, err)
assert.Equal(t, 4, len(rs))
assert.Equal(t, "upstream", rs[0].Name)
assert.Equal(t, "base", rs[0].Resolved)
assert.Equal(t, "github", rs[1].Name)
assert.Equal(t, "", rs[1].Resolved)
assert.Equal(t, "origin", rs[2].Name)
assert.Equal(t, "", rs[2].Resolved)
assert.Equal(t, "test", rs[3].Name)
assert.Equal(t, "other", rs[3].Resolved)
}
func TestClientRemotes_no_resolved_remote(t *testing.T) {
tempDir := t.TempDir()
initRepo(t, tempDir)
gitDir := filepath.Join(tempDir, ".git")
remoteFile := filepath.Join(gitDir, "config")
remotes := `
[remote "origin"]
url = git@example.com:monalisa/origin.git
[remote "test"]
url = git://github.com/hubot/test.git
[remote "upstream"]
url = https://github.com/monalisa/upstream.git
[remote "github"]
url = git@github.com:hubot/github.git
`
f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755)
assert.NoError(t, err)
_, err = f.Write([]byte(remotes))
assert.NoError(t, err)
err = f.Close()
assert.NoError(t, err)
client := Client{
RepoDir: tempDir,
}
rs, err := client.Remotes(context.Background())
assert.NoError(t, err)
assert.Equal(t, 4, len(rs))
assert.Equal(t, "upstream", rs[0].Name)
assert.Equal(t, "github", rs[1].Name)
assert.Equal(t, "origin", rs[2].Name)
assert.Equal(t, "", rs[2].Resolved)
assert.Equal(t, "test", rs[3].Name)
}
func TestParseRemotes(t *testing.T) {
remoteList := []string{
"mona\tgit@github.com:monalisa/myfork.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat-push.git (push)",
"upstream\thttps://example.com/nowhere.git (fetch)",
"upstream\thttps://github.com/hubot/tools (push)",
"zardoz\thttps://example.com/zed.git (push)",
"koke\tgit://github.com/koke/grit.git (fetch)",
"koke\tgit://github.com/koke/grit.git (push)",
}
r := parseRemotes(remoteList)
assert.Equal(t, 5, len(r))
assert.Equal(t, "mona", r[0].Name)
assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String())
assert.Nil(t, r[0].PushURL)
assert.Equal(t, "origin", r[1].Name)
assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path)
assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path)
assert.Equal(t, "upstream", r[2].Name)
assert.Equal(t, "example.com", r[2].FetchURL.Host)
assert.Equal(t, "github.com", r[2].PushURL.Host)
assert.Equal(t, "zardoz", r[3].Name)
assert.Nil(t, r[3].FetchURL)
assert.Equal(t, "https://example.com/zed.git", r[3].PushURL.String())
assert.Equal(t, "koke", r[4].Name)
assert.Equal(t, "/koke/grit.git", r[4].FetchURL.Path)
assert.Equal(t, "/koke/grit.git", r[4].PushURL.Path)
}
func TestClientUpdateRemoteURL(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "update remote url",
wantCmdArgs: `path/to/git remote set-url test https://test.com`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git remote set-url test https://test.com`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.UpdateRemoteURL(context.Background(), "test", "https://test.com")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientSetRemoteResolution(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "set remote resolution",
wantCmdArgs: `path/to/git config --add remote.origin.gh-resolved base`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git config --add remote.origin.gh-resolved base`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.SetRemoteResolution(context.Background(), "origin", "base")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientCurrentBranch(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
wantBranch string
}{
{
name: "branch name",
cmdStdout: "branch-name\n",
wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`,
wantBranch: "branch-name",
},
{
name: "ref",
cmdStdout: "refs/heads/branch-name\n",
wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`,
wantBranch: "branch-name",
},
{
name: "escaped ref",
cmdStdout: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n",
wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`,
wantBranch: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
},
{
name: "detached head",
cmdExitStatus: 1,
wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`,
wantErrorMsg: "failed to run git: not on any branch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
branch, err := client.CurrentBranch(context.Background())
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
assert.Equal(t, tt.wantBranch, branch)
})
}
}
func TestClientShowRefs(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantRefs []Ref
wantErrorMsg string
}{
{
name: "show refs with one valid ref and one invalid ref",
cmdExitStatus: 128,
cmdStdout: "9ea76237a557015e73446d33268569a114c0649c refs/heads/valid",
cmdStderr: "fatal: 'refs/heads/invalid' - not a valid ref",
wantCmdArgs: `path/to/git show-ref --verify -- refs/heads/valid refs/heads/invalid`,
wantRefs: []Ref{{
Hash: "9ea76237a557015e73446d33268569a114c0649c",
Name: "refs/heads/valid",
}},
wantErrorMsg: "failed to run git: fatal: 'refs/heads/invalid' - not a valid ref",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
refs, err := client.ShowRefs(context.Background(), []string{"refs/heads/valid", "refs/heads/invalid"})
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.EqualError(t, err, tt.wantErrorMsg)
assert.Equal(t, tt.wantRefs, refs)
})
}
}
func TestClientConfig(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantOut string
wantErrorMsg string
}{
{
name: "get config key",
cmdStdout: "test",
wantCmdArgs: `path/to/git config credential.helper`,
wantOut: "test",
},
{
name: "get unknown config key",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git config credential.helper`,
wantErrorMsg: "failed to run git: unknown config key credential.helper",
},
{
name: "git error",
cmdExitStatus: 2,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git config credential.helper`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
out, err := client.Config(context.Background(), "credential.helper")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
assert.Equal(t, tt.wantOut, out)
})
}
}
func TestClientUncommittedChangeCount(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantChangeCount int
}{
{
name: "no changes",
wantCmdArgs: `path/to/git status --porcelain`,
wantChangeCount: 0,
},
{
name: "one change",
cmdStdout: " M poem.txt",
wantCmdArgs: `path/to/git status --porcelain`,
wantChangeCount: 1,
},
{
name: "untracked file",
cmdStdout: " M poem.txt\n?? new.txt",
wantCmdArgs: `path/to/git status --porcelain`,
wantChangeCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
ucc, err := client.UncommittedChangeCount(context.Background())
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.NoError(t, err)
assert.Equal(t, tt.wantChangeCount, ucc)
})
}
}
type stubbedCommit struct {
Sha string
Title string
Body string
}
type stubbedCommitsCommandData struct {
ExitStatus int
ErrMsg string
Commits []stubbedCommit
}
func TestClientCommits(t *testing.T) {
tests := []struct {
name string
testData stubbedCommitsCommandData
wantCmdArgs string
wantCommits []*Commit
wantErrorMsg string
}{
{
name: "single commit no body",
testData: stubbedCommitsCommandData{
Commits: []stubbedCommit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "",
},
},
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantCommits: []*Commit{{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
}},
},
{
name: "single commit with body",
testData: stubbedCommitsCommandData{
Commits: []stubbedCommit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "This is the body",
},
},
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantCommits: []*Commit{{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "This is the body",
}},
},
{
name: "multiple commits with bodies",
testData: stubbedCommitsCommandData{
Commits: []stubbedCommit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "This is the body",
},
{
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
Title: "testing testability test 2",
Body: "This is the body 2",
},
},
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantCommits: []*Commit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "This is the body",
},
{
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
Title: "testing testability test 2",
Body: "This is the body 2",
},
},
},
{
name: "multiple commits mixed bodies",
testData: stubbedCommitsCommandData{
Commits: []stubbedCommit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
},
{
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
Title: "testing testability test 2",
Body: "This is the body 2",
},
},
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantCommits: []*Commit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
},
{
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
Title: "testing testability test 2",
Body: "This is the body 2",
},
},
},
{
name: "multiple commits newlines in bodies",
testData: stubbedCommitsCommandData{
Commits: []stubbedCommit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "This is the body\nwith a newline",
},
{
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
Title: "testing testability test 2",
Body: "This is the body 2",
},
},
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantCommits: []*Commit{
{
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
Title: "testing testability test",
Body: "This is the body\nwith a newline",
},
{
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
Title: "testing testability test 2",
Body: "This is the body 2",
},
},
},
{
name: "no commits between SHAs",
testData: stubbedCommitsCommandData{
Commits: []stubbedCommit{},
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantErrorMsg: "could not find any commits between SHA1 and SHA2",
},
{
name: "git error",
testData: stubbedCommitsCommandData{
ErrMsg: "git error message",
ExitStatus: 1,
},
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommitsCommandContext(t, tt.testData)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
commits, err := client.Commits(context.Background(), "SHA1", "SHA2")
require.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg != "" {
require.EqualError(t, err, tt.wantErrorMsg)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantCommits, commits)
})
}
}
func TestCommitsHelperProcess(t *testing.T) {
if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
return
}
var td stubbedCommitsCommandData
_ = json.Unmarshal([]byte(os.Getenv("GH_COMMITS_TEST_DATA")), &td)
if td.ErrMsg != "" {
fmt.Fprint(os.Stderr, td.ErrMsg)
} else {
var sb strings.Builder
for _, commit := range td.Commits {
sb.WriteString(commit.Sha)
sb.WriteString("\u0000")
sb.WriteString(commit.Title)
sb.WriteString("\u0000")
sb.WriteString(commit.Body)
sb.WriteString("\u0000")
sb.WriteString("\n")
}
fmt.Fprint(os.Stdout, sb.String())
}
os.Exit(td.ExitStatus)
}
func createCommitsCommandContext(t *testing.T, testData stubbedCommitsCommandData) (*exec.Cmd, commandCtx) {
t.Helper()
b, err := json.Marshal(testData)
require.NoError(t, err)
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestCommitsHelperProcess", "--")
cmd.Env = []string{
"GH_WANT_HELPER_PROCESS=1",
"GH_COMMITS_TEST_DATA=" + string(b),
}
return cmd, func(ctx context.Context, exe string, args ...string) *exec.Cmd {
cmd.Args = append(cmd.Args, exe)
cmd.Args = append(cmd.Args, args...)
return cmd
}
}
func TestClientLastCommit(t *testing.T) {
client := Client{
RepoDir: "./fixtures/simple.git",
}
c, err := client.LastCommit(context.Background())
assert.NoError(t, err)
assert.Equal(t, "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha)
assert.Equal(t, "Second commit", c.Title)
}
func TestClientCommitBody(t *testing.T) {
client := Client{
RepoDir: "./fixtures/simple.git",
}
body, err := client.CommitBody(context.Background(), "6f1a2405cace1633d89a79c74c65f22fe78f9659")
assert.NoError(t, err)
assert.Equal(t, "I'm starting to get the hang of things\n", body)
}
func TestClientReadBranchConfig(t *testing.T) {
tests := []struct {
name string
cmds mockedCommands
branch string
wantBranchConfig BranchConfig
wantError *GitError
}{
{
name: "when the git config has no (remote|merge|pushremote|gh-merge-base) keys, it should return an empty BranchConfig and no error",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
ExitStatus: 1,
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{},
wantError: nil,
},
{
name: "when the git fails to read the config, it should return an empty BranchConfig and the error",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
ExitStatus: 2,
Stderr: "git error",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{},
wantError: &GitError{
ExitCode: 2,
Stderr: "git error",
},
},
{
name: "when the git reads the config, pushDefault isn't set, and rev-parse succeeds, it should return the correct BranchConfig",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\n",
},
`path/to/git config remote.pushDefault`: {
ExitStatus: 1,
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "origin",
PushRemoteName: "origin",
Push: "origin/trunk",
},
wantError: nil,
},
{
name: "when the git reads the config, pushDefault is set, and rev-parse succeeds, it should return the correct BranchConfig",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\n",
},
`path/to/git config remote.pushDefault`: {
Stdout: "pushdefault",
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "origin",
PushRemoteName: "pushdefault",
Push: "origin/trunk",
PushDefaultName: "pushdefault",
},
wantError: nil,
},
{
name: "when git reads the config, pushDefault is set, an the branch hasn't been pushed, it should return the correct BranchConfig",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\n",
},
`path/to/git config remote.pushDefault`: {
Stdout: "pushdefault",
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
ExitStatus: 1,
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "origin",
PushRemoteName: "pushdefault",
PushDefaultName: "pushdefault",
},
wantError: nil,
},
{
name: "when git reads the config, pushDefault is set, and rev-parse fails, it should return an empty BranchConfig and the error",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\n",
},
`path/to/git config remote.pushDefault`: {
Stdout: "pushdefault",
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
ExitStatus: 2,
Stderr: "rev-parse error",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{},
wantError: &GitError{
ExitCode: 2,
Stderr: "rev-parse error",
},
},
{
name: "when git reads the config but pushdefault fails, it should return and empty BranchConfig and the error",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\n",
},
`path/to/git config remote.pushDefault`: {
ExitStatus: 2,
Stderr: "pushdefault error",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{},
wantError: &GitError{
ExitCode: 2,
Stderr: "pushdefault error",
},
},
{
name: "read branch config, central",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base merge-base",
},
`path/to/git config remote.pushDefault`: {
ExitStatus: 1,
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "origin",
MergeRef: "refs/heads/trunk",
MergeBase: "merge-base",
PushRemoteName: "origin",
Push: "origin/trunk",
},
wantError: nil,
},
{
name: "read branch config, central, push.default = upstream",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk-remote",
},
`path/to/git config remote.pushDefault`: {
ExitStatus: 1,
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk-remote",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "origin",
MergeRef: "refs/heads/trunk-remote",
PushRemoteName: "origin",
Push: "origin/trunk-remote",
},
},
{
name: "read branch config, central, push.default = upstream, no existing remote branch",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/main",
},
`path/to/git config remote.pushDefault`: {
ExitStatus: 1,
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "origin",
MergeRef: "refs/heads/main",
PushRemoteName: "origin",
Push: "origin/trunk",
},
},
{
name: "read branch config, triangular, push.default = current, has existing remote branch, branch.trunk.pushremote effective",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote upstream\nbranch.trunk.merge refs/heads/main\nbranch.trunk.pushremote origin",
},
`path/to/git config remote.pushDefault`: {
ExitStatus: 1,
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "upstream",
MergeRef: "refs/heads/main",
PushRemoteName: "origin",
Push: "origin/trunk",
},
},
{
name: "read branch config, triangular, push.default = current, has existing remote branch, remote.pushDefault effective",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote upstream\nbranch.trunk.merge refs/heads/main",
},
`path/to/git config remote.pushDefault`: {
Stdout: "origin",
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
Stdout: "origin/trunk",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "upstream",
MergeRef: "refs/heads/main",
PushRemoteName: "origin",
Push: "origin/trunk",
PushDefaultName: "origin",
},
},
{
name: "read branch config, triangular, push.default = current, no existing remote branch, branch.trunk.pushremote effective",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote upstream\nbranch.trunk.merge refs/heads/main\nbranch.trunk.pushremote origin",
},
`path/to/git config remote.pushDefault`: {
Stdout: "current",
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
ExitStatus: 1,
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "upstream",
MergeRef: "refs/heads/main",
PushRemoteName: "origin",
PushDefaultName: "current",
},
},
{
name: "read branch config, triangular, push.default = current, no existing remote branch, remote.pushDefault effective",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: "branch.trunk.remote upstream\nbranch.trunk.merge refs/heads/main",
},
`path/to/git config remote.pushDefault`: {
Stdout: "origin",
},
`path/to/git rev-parse --verify --quiet --abbrev-ref trunk@{push}`: {
ExitStatus: 1,
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "upstream",
MergeRef: "refs/heads/main",
PushRemoteName: "origin",
PushDefaultName: "origin",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdCtx := createMockedCommandContext(t, tt.cmds)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
branchConfig, err := client.ReadBranchConfig(context.Background(), tt.branch)
assert.Equal(t, tt.wantBranchConfig, branchConfig)
if tt.wantError != nil {
var gitError *GitError
require.ErrorAs(t, err, &gitError)
assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode)
assert.Equal(t, tt.wantError.Stderr, gitError.Stderr)
} else {
assert.NoError(t, err)
}
})
}
}
func Test_parseBranchConfig(t *testing.T) {
tests := []struct {
name string
configLines []string
pushDefault string
revParse string
wantBranchConfig BranchConfig
}{
{
name: "remote branch",
configLines: []string{"branch.trunk.remote origin"},
wantBranchConfig: BranchConfig{
RemoteName: "origin",
PushRemoteName: "origin",
},
},
{
name: "merge ref",
configLines: []string{"branch.trunk.merge refs/heads/trunk"},
wantBranchConfig: BranchConfig{
MergeRef: "refs/heads/trunk",
},
},
{
name: "merge base",
configLines: []string{"branch.trunk.gh-merge-base gh-merge-base"},
wantBranchConfig: BranchConfig{
MergeBase: "gh-merge-base",
},
},
{
name: "push remote",
configLines: []string{"branch.trunk.pushremote pushremote"},
wantBranchConfig: BranchConfig{
PushRemoteName: "pushremote",
},
},
{
name: "rev parse specified",
configLines: []string{},
revParse: "origin/trunk",
wantBranchConfig: BranchConfig{
Push: "origin/trunk",
},
},
{
name: "push default specified",
configLines: []string{},
pushDefault: "pushdefault",
wantBranchConfig: BranchConfig{
PushRemoteName: "pushdefault",
PushDefaultName: "pushdefault",
},
},
{
name: "remote and pushremote are specified by name",
configLines: []string{
"branch.trunk.remote origin",
"branch.trunk.pushremote pushremote",
},
wantBranchConfig: BranchConfig{
RemoteName: "origin",
PushRemoteName: "pushremote",
},
},
{
name: "remote and pushremote are specified by url",
configLines: []string{
"branch.Frederick888/main.remote git@github.com:Frederick888/remote.git",
"branch.Frederick888/main.pushremote git@github.com:Frederick888/pushremote.git",
},
wantBranchConfig: BranchConfig{
RemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/Frederick888/remote.git",
},
PushRemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/Frederick888/pushremote.git",
},
},
},
{
name: "remote, pushremote, gh-merge-base, merge ref, push default, and rev parse all specified",
configLines: []string{
"branch.trunk.remote remote",
"branch.trunk.pushremote pushremote",
"branch.trunk.gh-merge-base gh-merge-base",
"branch.trunk.merge refs/heads/trunk",
},
pushDefault: "pushdefault",
revParse: "origin/trunk",
wantBranchConfig: BranchConfig{
RemoteName: "remote",
PushRemoteName: "pushremote",
MergeBase: "gh-merge-base",
MergeRef: "refs/heads/trunk",
Push: "origin/trunk",
PushDefaultName: "pushdefault",
},
},
{
name: "pushremote and pushDefault are not specified, but a remoteName is provided",
configLines: []string{
"branch.trunk.remote remote",
},
wantBranchConfig: BranchConfig{
RemoteName: "remote",
PushRemoteName: "remote",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
branchConfig := parseBranchConfig(tt.configLines, tt.pushDefault, tt.revParse)
assert.Equalf(t, tt.wantBranchConfig.RemoteName, branchConfig.RemoteName, "unexpected RemoteName")
assert.Equalf(t, tt.wantBranchConfig.MergeRef, branchConfig.MergeRef, "unexpected MergeRef")
assert.Equalf(t, tt.wantBranchConfig.MergeBase, branchConfig.MergeBase, "unexpected MergeBase")
assert.Equalf(t, tt.wantBranchConfig.PushRemoteName, branchConfig.PushRemoteName, "unexpected PushRemoteName")
assert.Equalf(t, tt.wantBranchConfig.Push, branchConfig.Push, "unexpected Push")
assert.Equalf(t, tt.wantBranchConfig.PushDefaultName, branchConfig.PushDefaultName, "unexpected PushDefaultName")
if tt.wantBranchConfig.RemoteURL != nil {
assert.Equalf(t, tt.wantBranchConfig.RemoteURL.String(), branchConfig.RemoteURL.String(), "unexpected RemoteURL")
}
})
}
}
func Test_parseRemoteURLOrName(t *testing.T) {
tests := []struct {
name string
value string
wantRemoteURL *url.URL
wantRemoteName string
}{
{
name: "empty value",
value: "",
wantRemoteURL: nil,
wantRemoteName: "",
},
{
name: "remote URL",
value: "git@github.com:foo/bar.git",
wantRemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/foo/bar.git",
},
wantRemoteName: "",
},
{
name: "remote name",
value: "origin",
wantRemoteURL: nil,
wantRemoteName: "origin",
},
{
name: "remote name is from filesystem",
value: "./path/to/repo",
wantRemoteURL: nil,
wantRemoteName: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
remoteURL, remoteName := parseRemoteURLOrName(tt.value)
assert.Equal(t, tt.wantRemoteURL, remoteURL)
assert.Equal(t, tt.wantRemoteName, remoteName)
})
}
}
func TestClientDeleteLocalTag(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "delete local tag",
wantCmdArgs: `path/to/git tag -d v1.0`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git tag -d v1.0`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.DeleteLocalTag(context.Background(), "v1.0")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientDeleteLocalBranch(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "delete local branch",
wantCmdArgs: `path/to/git branch -D trunk`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git branch -D trunk`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.DeleteLocalBranch(context.Background(), "trunk")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientHasLocalBranch(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantOut bool
}{
{
name: "has local branch",
wantCmdArgs: `path/to/git rev-parse --verify refs/heads/trunk`,
wantOut: true,
},
{
name: "does not have local branch",
cmdExitStatus: 1,
wantCmdArgs: `path/to/git rev-parse --verify refs/heads/trunk`,
wantOut: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
out := client.HasLocalBranch(context.Background(), "trunk")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.Equal(t, out, tt.wantOut)
})
}
}
func TestClientCheckoutBranch(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "checkout branch",
wantCmdArgs: `path/to/git checkout trunk`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git checkout trunk`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.CheckoutBranch(context.Background(), "trunk")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientCheckoutNewBranch(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "checkout new branch",
wantCmdArgs: `path/to/git checkout -b trunk --track origin/trunk`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git checkout -b trunk --track origin/trunk`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.CheckoutNewBranch(context.Background(), "origin", "trunk")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientToplevelDir(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantDir string
wantErrorMsg string
}{
{
name: "top level dir",
cmdStdout: "/path/to/repo",
wantCmdArgs: `path/to/git rev-parse --show-toplevel`,
wantDir: "/path/to/repo",
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git rev-parse --show-toplevel`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
dir, err := client.ToplevelDir(context.Background())
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
assert.Equal(t, tt.wantDir, dir)
})
}
}
func TestClientGitDir(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantDir string
wantErrorMsg string
}{
{
name: "git dir",
cmdStdout: "/path/to/repo/.git",
wantCmdArgs: `path/to/git rev-parse --git-dir`,
wantDir: "/path/to/repo/.git",
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git rev-parse --git-dir`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
dir, err := client.GitDir(context.Background())
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
assert.Equal(t, tt.wantDir, dir)
})
}
}
func TestClientPathFromRoot(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
wantDir string
}{
{
name: "current path from root",
cmdStdout: "some/path/",
wantCmdArgs: `path/to/git rev-parse --show-prefix`,
wantDir: "some/path",
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git rev-parse --show-prefix`,
wantDir: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
dir := client.PathFromRoot(context.Background())
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.Equal(t, tt.wantDir, dir)
})
}
}
func TestClientUnsetRemoteResolution(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "unset remote resolution",
wantCmdArgs: `path/to/git config --unset remote.origin.gh-resolved`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git config --unset remote.origin.gh-resolved`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.UnsetRemoteResolution(context.Background(), "origin")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientSetRemoteBranches(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "set remote branches",
wantCmdArgs: `path/to/git remote set-branches origin trunk`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git remote set-branches origin trunk`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.SetRemoteBranches(context.Background(), "origin", "trunk")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientFetch(t *testing.T) {
tests := []struct {
name string
mods []CommandModifier
commands mockedCommands
wantErrorMsg string
}{
{
name: "fetch",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
commands: map[args]commandResult{
`path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "git error on fetch",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`: {
ExitStatus: 1,
Stderr: "fetch error message",
},
},
wantErrorMsg: "failed to run git: fetch error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdCtx := createMockedCommandContext(t, tt.commands)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.Fetch(context.Background(), "origin", "trunk", tt.mods...)
if tt.wantErrorMsg == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientPull(t *testing.T) {
tests := []struct {
name string
mods []CommandModifier
commands mockedCommands
wantErrorMsg string
}{
{
name: "pull",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
commands: map[args]commandResult{
`path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "git error on pull",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`: {
ExitStatus: 1,
Stderr: "pull error message",
},
},
wantErrorMsg: "failed to run git: pull error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdCtx := createMockedCommandContext(t, tt.commands)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.Pull(context.Background(), "origin", "trunk", tt.mods...)
if tt.wantErrorMsg == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientPush(t *testing.T) {
tests := []struct {
name string
mods []CommandModifier
commands mockedCommands
wantErrorMsg string
}{
{
name: "push",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
commands: map[args]commandResult{
`path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "git error on push",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`: {
ExitStatus: 1,
Stderr: "push error message",
},
},
wantErrorMsg: "failed to run git: push error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdCtx := createMockedCommandContext(t, tt.commands)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.Push(context.Background(), "origin", "trunk", tt.mods...)
if tt.wantErrorMsg == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientClone(t *testing.T) {
tests := []struct {
name string
args []string
mods []CommandModifier
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantTarget string
wantErrorMsg string
}{
{
name: "clone",
args: []string{},
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone https://github.com/cli/cli`,
wantTarget: "cli",
},
{
name: "accepts command modifiers",
args: []string{},
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
wantCmdArgs: `path/to/git -C /path/to/repo -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone https://github.com/cli/cli`,
wantTarget: "cli",
},
{
name: "git error",
args: []string{},
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone https://github.com/cli/cli`,
wantErrorMsg: "failed to run git: git error message",
},
{
name: "bare clone",
args: []string{"--bare"},
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone --bare https://github.com/cli/cli`,
wantTarget: "cli.git",
},
{
name: "bare clone with explicit target",
args: []string{"cli-bare", "--bare"},
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone --bare https://github.com/cli/cli cli-bare`,
wantTarget: "cli-bare",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
target, err := client.Clone(context.Background(), "https://github.com/cli/cli", tt.args, tt.mods...)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
assert.Equal(t, tt.wantTarget, target)
})
}
}
func TestParseCloneArgs(t *testing.T) {
type wanted struct {
args []string
dir string
}
tests := []struct {
name string
args []string
want wanted
}{
{
name: "args and target",
args: []string{"target_directory", "-o", "upstream", "--depth", "1"},
want: wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "target_directory",
},
},
{
name: "only args",
args: []string{"-o", "upstream", "--depth", "1"},
want: wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "",
},
},
{
name: "only target",
args: []string{"target_directory"},
want: wanted{
args: []string{},
dir: "target_directory",
},
},
{
name: "no args",
args: []string{},
want: wanted{
args: []string{},
dir: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, dir := parseCloneArgs(tt.args)
got := wanted{args: args, dir: dir}
assert.Equal(t, got, tt.want)
})
}
}
func TestClientAddRemote(t *testing.T) {
tests := []struct {
title string
name string
url string
branches []string
dir string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
title: "fetch all",
name: "test",
url: "URL",
dir: "DIRECTORY",
branches: []string{},
wantCmdArgs: `path/to/git -C DIRECTORY remote add test URL`,
},
{
title: "fetch specific branches only",
name: "test",
url: "URL",
dir: "DIRECTORY",
branches: []string{"trunk", "dev"},
wantCmdArgs: `path/to/git -C DIRECTORY remote add -t trunk -t dev test URL`,
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
RepoDir: tt.dir,
commandContext: cmdCtx,
}
_, err := client.AddRemote(context.Background(), tt.name, tt.url, tt.branches)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.NoError(t, err)
})
}
}
func initRepo(t *testing.T, dir string) {
errBuf := &bytes.Buffer{}
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
client := Client{
RepoDir: dir,
Stderr: errBuf,
Stdin: inBuf,
Stdout: outBuf,
}
cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...)
assert.NoError(t, err)
_, err = cmd.Output()
assert.NoError(t, err)
}
type args string
type commandResult struct {
ExitStatus int `json:"exitStatus"`
Stdout string `json:"out"`
Stderr string `json:"err"`
}
type mockedCommands map[args]commandResult
// TestCommandMocking is an invoked test helper that emulates expected behavior for predefined shell commands, erroring when unexpected conditions are encountered.
func TestCommandMocking(t *testing.T) {
if os.Getenv("GH_WANT_HELPER_PROCESS_RICH") != "1" {
return
}
jsonVar, ok := os.LookupEnv("GH_HELPER_PROCESS_RICH_COMMANDS")
if !ok {
fmt.Fprint(os.Stderr, "missing GH_HELPER_PROCESS_RICH_COMMANDS")
// Exit 1 is reserved for empty command responses by git. This can be desirable in some cases,
// so returning an arbitrary exit code to avoid suppressing this if an exit code 1 is allowed.
os.Exit(16)
}
var commands mockedCommands
if err := json.Unmarshal([]byte(jsonVar), &commands); err != nil {
fmt.Fprint(os.Stderr, "failed to unmarshal GH_HELPER_PROCESS_RICH_COMMANDS")
// Exit 1 is reserved for empty command responses by git. This can be desirable in some cases,
// so returning an arbitrary exit code to avoid suppressing this if an exit code 1 is allowed.
os.Exit(16)
}
// The discarded args are those for the go test binary itself, e.g. `-test.run=TestHelperProcessRich`
realArgs := os.Args[3:]
commandResult, ok := commands[args(strings.Join(realArgs, " "))]
if !ok {
fmt.Fprintf(os.Stderr, "unexpected command: %s\n", strings.Join(realArgs, " "))
// Exit 1 is reserved for empty command responses by git. This can be desirable in some cases,
// so returning an arbitrary exit code to avoid suppressing this if an exit code 1 is allowed.
os.Exit(16)
}
if commandResult.Stdout != "" {
fmt.Fprint(os.Stdout, commandResult.Stdout)
}
if commandResult.Stderr != "" {
fmt.Fprint(os.Stderr, commandResult.Stderr)
}
os.Exit(commandResult.ExitStatus)
}
func TestHelperProcess(t *testing.T) {
if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
return
}
if err := func(args []string) error {
fmt.Fprint(os.Stdout, os.Getenv("GH_HELPER_PROCESS_STDOUT"))
exitStatus := os.Getenv("GH_HELPER_PROCESS_EXIT_STATUS")
if exitStatus != "0" {
return errors.New("error")
}
return nil
}(os.Args[3:]); err != nil {
fmt.Fprint(os.Stderr, os.Getenv("GH_HELPER_PROCESS_STDERR"))
exitStatus := os.Getenv("GH_HELPER_PROCESS_EXIT_STATUS")
i, err := strconv.Atoi(exitStatus)
if err != nil {
os.Exit(1)
}
os.Exit(i)
}
os.Exit(0)
}
func TestCredentialPatternFromGitURL(t *testing.T) {
tests := []struct {
name string
gitURL string
wantErr bool
wantCredentialPattern CredentialPattern
}{
{
name: "Given a well formed gitURL, it returns the corresponding CredentialPattern",
gitURL: "https://github.com/OWNER/REPO.git",
wantCredentialPattern: CredentialPattern{
pattern: "https://github.com",
allMatching: false,
},
},
{
name: "Given a malformed gitURL, it returns an error",
// This pattern is copied from the tests in ParseURL
// Unexpectedly, a non URL-like string did not error in ParseURL
gitURL: "ssh://git@[/tmp/git-repo",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
credentialPattern, err := CredentialPatternFromGitURL(tt.gitURL)
if tt.wantErr {
assert.ErrorContains(t, err, "failed to parse remote URL")
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantCredentialPattern, credentialPattern)
}
})
}
}
func TestCredentialPatternFromHost(t *testing.T) {
tests := []struct {
name string
host string
wantCredentialPattern CredentialPattern
}{
{
name: "Given a well formed host, it returns the corresponding CredentialPattern",
host: "github.com",
wantCredentialPattern: CredentialPattern{
pattern: "https://github.com",
allMatching: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
credentialPattern := CredentialPatternFromHost(tt.host)
require.Equal(t, tt.wantCredentialPattern, credentialPattern)
})
}
}
func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (*exec.Cmd, commandCtx) {
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess", "--")
cmd.Env = []string{
"GH_WANT_HELPER_PROCESS=1",
fmt.Sprintf("GH_HELPER_PROCESS_STDOUT=%s", stdout),
fmt.Sprintf("GH_HELPER_PROCESS_STDERR=%s", stderr),
fmt.Sprintf("GH_HELPER_PROCESS_EXIT_STATUS=%v", exitStatus),
}
return cmd, func(ctx context.Context, exe string, args ...string) *exec.Cmd {
cmd.Args = append(cmd.Args, exe)
cmd.Args = append(cmd.Args, args...)
return cmd
}
}
func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCtx {
marshaledCommands, err := json.Marshal(commands)
require.NoError(t, err)
// invokes helper within current test binary, emulating desired behavior
return func(ctx context.Context, exe string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestCommandMocking", "--")
cmd.Env = []string{
"GH_WANT_HELPER_PROCESS_RICH=1",
fmt.Sprintf("GH_HELPER_PROCESS_RICH_COMMANDS=%s", string(marshaledCommands)),
}
cmd.Args = append(cmd.Args, exe)
cmd.Args = append(cmd.Args, args...)
return cmd
}
}