Repository: jenkins-x/lighthouse
Pull request #1164 (2020-11-18 14:43:59)

fix: reduce the number of github API calls
lets setup the agent when using in-repo calls upfront rather than repeating for each plugin

jenkins-x-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **jstrachan**
You can assign the PR to them by writing `/assign @jstrachan` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=jenkins-x%2Flighthouse).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/jenkins-x/lighthouse/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["jstrachan"]} -->
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | a51eeb486bbc26ae9b9feb4e78a6e506567fd9e4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/4) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jstrachan: /retest
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | a51eeb486bbc26ae9b9feb4e78a6e506567fd9e4 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/6) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jstrachan: /retest
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/8) | `/test pr-build`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/8) | `/test pr-build`
jenkins | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/11) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/8) | `/test pr-build`
jenkins | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/11) | `/test jenkins`
ghe | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/9) | `/test ghe`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/11) | `/test jenkins`
ghe | e351ffac60d4736ab5ce2c855eff746241f79820 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/9) | `/test ghe`
pr-build | 23bfb0cfe6ad6addafe64e3cc3c21a334cfe4c28 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/13) | `/test pr-build`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | f4d192f2df072ee3134504dc06f3cedd2daa9194 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/18) | `/test pr-build`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/23) | `/test pr-build`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/23) | `/test pr-build`
bbs | f4d192f2df072ee3134504dc06f3cedd2daa9194 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/20) | `/test bbs`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/23) | `/test pr-build`
bbs | f4d192f2df072ee3134504dc06f3cedd2daa9194 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/20) | `/test bbs`
jenkins | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/26) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/23) | `/test pr-build`
jenkins | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/26) | `/test jenkins`
bbs | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/25) | `/test bbs`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/26) | `/test jenkins`
bbs | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/25) | `/test bbs`
pr-build | b48d544eb99244954812d5a43602b91387a42456 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/28) | `/test pr-build`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
bbs | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/25) | `/test bbs`
pr-build | b48d544eb99244954812d5a43602b91387a42456 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/28) | `/test pr-build`
jenkins | b48d544eb99244954812d5a43602b91387a42456 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/31) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
bbs | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/25) | `/test bbs`
pr-build | b48d544eb99244954812d5a43602b91387a42456 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/28) | `/test pr-build`
jenkins | b48d544eb99244954812d5a43602b91387a42456 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/31) | `/test jenkins`
ghe | 7e434909e3a3f6c08838c186c408521d6875a7ac | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/24) | `/test ghe`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | b48d544eb99244954812d5a43602b91387a42456 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/31) | `/test jenkins`
pr-build | 734bbc8636192590221d6feae610369210f97e95 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/33) | `/test pr-build`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following tests **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pr-build | 734bbc8636192590221d6feae610369210f97e95 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/33) | `/test pr-build`
jenkins | 734bbc8636192590221d6feae610369210f97e95 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/36) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | 1ec2598cf90dfabbda7105c6bc24eeea81b527c5 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/41) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jstrachan: /retest
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | 1ec2598cf90dfabbda7105c6bc24eeea81b527c5 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/43) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
jstrachan: /retest
jenkins-x-bot: @jstrachan: The following test **failed**, say `/retest` to rerun them all:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
jenkins | 1ec2598cf90dfabbda7105c6bc24eeea81b527c5 | [link](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164/44) | `/test jenkins`

[View all Builds for this Pull Request](https://dashboard-jx.jenkins-x.live/teams/jx/projects/jenkins-x/lighthouse/PR-1164) 

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [jenkins-x/lighthouse](https://github.com/jenkins-x/lighthouse/issues/new?title=Command%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
rawlingsj: we've been running this on Jenkins X own infra for a couple of weeks and not seen one api limit hit since so can verify this is a big improvement.
abayer: /lgtm
jenkins-x-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *[abayer](https://github.com/jenkins-x/lighthouse/pull/1164#issuecomment-738810744 "LGTM")*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=jenkins-x%2Flighthouse).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com#issuecomment-738810744/jenkins-x/lighthouse/blob/master/OWNERS)~~ [abayer]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/go.mod b/go.mod
index c94908d9..245f5caa 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
 	github.com/google/go-cmp v0.4.1
 	github.com/gorilla/sessions v1.2.0
 	github.com/hashicorp/go-multierror v1.1.0
+	github.com/hashicorp/golang-lru v0.5.4
 	github.com/jenkins-x/go-scm v1.5.193
 	github.com/mattn/go-zglob v0.0.1
 	github.com/onsi/ginkgo v1.11.0
diff --git a/pkg/filebrowser/git_file_browser.go b/pkg/filebrowser/git_file_browser.go
new file mode 100644
index 00000000..5fc77538
--- /dev/null
+++ b/pkg/filebrowser/git_file_browser.go
@@ -0,0 +1,215 @@
+package filebrowser
+
+import (
+	"io/ioutil"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/lighthouse/pkg/git/v2"
+	"github.com/pkg/errors"
+)
+
+type gitFileBrowser struct {
+	clientFactory git.ClientFactory
+	clientsLock   sync.RWMutex
+	clients       map[string]*repoClientFacade
+}
+
+const headBranchPrefix = "HEAD branch:"
+
+// NewFileBrowserFromGitClient creates a new file browser from an Scm client
+func NewFileBrowserFromGitClient(clientFactory git.ClientFactory) Interface {
+	return &gitFileBrowser{
+		clientFactory: clientFactory,
+		clients:       map[string]*repoClientFacade{},
+	}
+}
+
+func (f *gitFileBrowser) GetMainAndCurrentBranchRefs(_, _, eventRef string) ([]string, error) {
+	return []string{"", eventRef}, nil
+}
+
+func (f *gitFileBrowser) GetFile(owner, repo, path, ref string) (answer []byte, err error) {
+	err = f.withRepoClient(owner, repo, ref, func(repoClient git.RepoClient) error {
+		f := repoPath(repoClient, path)
+		var err error
+		answer, err = ioutil.ReadFile(f) // #nosec
+		return err
+	})
+	return
+}
+
+func (f *gitFileBrowser) ListFiles(owner, repo, path, ref string) (answer []*scm.FileEntry, err error) {
+	err = f.withRepoClient(owner, repo, ref, func(repoClient git.RepoClient) error {
+		dir := repoPath(repoClient, path)
+		fileNames, err := ioutil.ReadDir(dir)
+		if err != nil {
+			return errors.Wrapf(err, "failed to list files in directory %s", dir)
+		}
+		for _, f := range fileNames {
+			name := f.Name()
+			if name == ".git" {
+				continue
+			}
+			t := "file"
+			if f.IsDir() {
+				t = "dir"
+			}
+			path := filepath.Join(dir, name)
+			answer = append(answer, &scm.FileEntry{
+				Name: name,
+				Path: path,
+				Type: t,
+				Size: int(f.Size()),
+				Sha:  ref,
+				Link: path,
+			})
+		}
+		return nil
+	})
+	return
+}
+
+func repoPath(repoClient git.RepoClient, path string) string {
+	dir := repoClient.Directory()
+	if path == "" || path == "/" {
+		return dir
+	}
+	return filepath.Join(dir, path)
+}
+
+func (f *gitFileBrowser) withRepoClient(owner, repo, ref string, fn func(repoClient git.RepoClient) error) error {
+	client := f.getOrCreateClient(owner, repo)
+
+	var repoClient git.RepoClient
+	var err error
+	client.lock.Lock()
+	if client.repoClient == nil {
+		client.repoClient, err = f.clientFactory.ClientFor(owner, repo)
+		if err != nil {
+			err = errors.Wrapf(err, "failed to create repo client")
+		}
+	}
+	if client.mainBranch == "" {
+		var err error
+		client.mainBranch, err = getMainBranch(client.repoClient.Directory())
+		if err != nil {
+			return errors.Wrapf(err, "failed to detect the main branch")
+		}
+	}
+	if err == nil {
+		repoClient = client.repoClient
+		err = client.UseRef(ref)
+		if err != nil {
+			err = errors.Wrapf(err, "failed to switch to ref %s", ref)
+		}
+		if err == nil {
+			err = fn(repoClient)
+			if err != nil {
+				err = errors.Wrapf(err, "failed to process repo %s/%s refref %s", owner, repo, ref)
+			}
+		}
+	}
+	client.lock.Unlock()
+	return err
+}
+
+// getOrCreateClient lazily creates a repo client and lock
+func (f *gitFileBrowser) getOrCreateClient(owner string, repo string) *repoClientFacade {
+	fullName := scm.Join(owner, repo)
+	f.clientsLock.Lock()
+	client := f.clients[fullName]
+	if client == nil {
+		client = &repoClientFacade{
+			fullName: fullName,
+		}
+		f.clients[fullName] = client
+	}
+	f.clientsLock.Unlock()
+	return client
+}
+
+// repoClientFacade a repo client and a lock to create/use it
+type repoClientFacade struct {
+	lock       sync.RWMutex
+	repoClient git.RepoClient
+	fullName   string
+	mainBranch string
+	ref        string
+}
+
+// UseRef this method should only be used within the lock
+func (c *repoClientFacade) UseRef(ref string) error {
+	if ref == "" {
+		ref = c.mainBranch
+	}
+	if c.ref == ref {
+		return nil
+	}
+
+	// lets switch to the main branch first before we go to a custom sha/ref
+	if c.ref != "" && c.ref != c.mainBranch {
+		err := c.repoClient.Checkout(c.mainBranch)
+		if err != nil {
+			return errors.Wrapf(err, "failed to checkout repository %s main branch %s", c.fullName, c.mainBranch)
+		}
+	}
+
+	if ref != c.mainBranch {
+		err := c.repoClient.FetchRef(ref)
+		if err != nil {
+			return errors.Wrapf(err, "failed to fetch repository %s", c.fullName)
+		}
+	} else {
+		err := c.repoClient.Fetch()
+		if err != nil {
+			return errors.Wrapf(err, "failed to fetch repository %s", c.fullName)
+		}
+	}
+	c.ref = ref
+	err := c.repoClient.Checkout(ref)
+	if err != nil {
+		return errors.Wrapf(err, "failed to checkout repository %s ref %s", c.fullName, ref)
+	}
+	return nil
+}
+
+// getMainBranch returns the main branch name such as 'master' or 'main'
+func getMainBranch(dir string) (string, error) {
+	remoteName := "origin"
+	text, err := runCmd(dir, "git", "rev-parse", "--abbrev-ref", remoteName+"/HEAD")
+	if err != nil {
+		text, err = runCmd(dir, "git", "remote", "show", remoteName)
+		if err != nil {
+			return "", errors.Wrapf(err, "failed to get the remote branch name for %s", remoteName)
+		}
+		lines := strings.Split(text, "\n")
+		for _, line := range lines {
+			line = strings.TrimSpace(line)
+			if strings.HasPrefix(line, headBranchPrefix) {
+				mainBranch := strings.TrimSpace(strings.TrimPrefix(line, headBranchPrefix))
+				if mainBranch != "" {
+					return mainBranch, nil
+				}
+			}
+		}
+		return "", errors.Errorf("output of git remote show %s has no prefix %s as was: %s", remoteName, headBranchPrefix, text)
+	}
+	text = strings.TrimSpace(text)
+	text = strings.TrimPrefix(text, remoteName)
+	return strings.TrimPrefix(text, "/"), nil
+}
+
+func runCmd(dir, cmd string, arg ...string) (string, error) {
+	c := exec.Command(cmd, arg...) // #nosec
+	c.Dir = dir
+	b, err := c.CombinedOutput()
+	text := strings.TrimSpace(string(b))
+	if err != nil {
+		return text, errors.Wrapf(err, "failed to run command in dir %s: %s, %v: %s", dir, cmd, arg, text)
+	}
+	return text, nil
+}
diff --git a/pkg/filebrowser/interface.go b/pkg/filebrowser/interface.go
new file mode 100644
index 00000000..561560e0
--- /dev/null
+++ b/pkg/filebrowser/interface.go
@@ -0,0 +1,15 @@
+package filebrowser
+
+import "github.com/jenkins-x/go-scm/scm"
+
+// Interface an interface to represent browsing files in a repository
+type Interface interface {
+	// GetMainAndCurrentBranchRefs returns the main branch then the ref value if its different
+	GetMainAndCurrentBranchRefs(owner, repo, ref string) ([]string, error)
+
+	// GetFile returns a file from the given path in the repository with the given sha
+	GetFile(owner, repo, path, ref string) ([]byte, error)
+
+	// ListFiles returns the file and directory entries in the given path in the repository with the given sha
+	ListFiles(owner, repo, path, ref string) ([]*scm.FileEntry, error)
+}
diff --git a/pkg/filebrowser/scm_file_browser.go b/pkg/filebrowser/scm_file_browser.go
new file mode 100644
index 00000000..301f71ff
--- /dev/null
+++ b/pkg/filebrowser/scm_file_browser.go
@@ -0,0 +1,46 @@
+package filebrowser
+
+import (
+	"strings"
+
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/pkg/errors"
+)
+
+type scmFileBrowser struct {
+	scmClient scmProviderClient
+}
+
+// NewFileBrowserFromScmClient creates a new file browser from an Scm client
+func NewFileBrowserFromScmClient(scmClient scmProviderClient) Interface {
+	return &scmFileBrowser{scmClient: scmClient}
+}
+
+func (f *scmFileBrowser) GetMainAndCurrentBranchRefs(owner, repo, eventRef string) ([]string, error) {
+	fullName := scm.Join(owner, repo)
+	repository, err := f.scmClient.GetRepositoryByFullName(fullName)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to find repository %s", fullName)
+	}
+	mainBranch := repository.Branch
+	if mainBranch == "" {
+		mainBranch = "master"
+	}
+
+	refs := []string{mainBranch}
+
+	eventRef = strings.TrimPrefix(eventRef, "refs/heads/")
+	eventRef = strings.TrimPrefix(eventRef, "refs/tags/")
+	if eventRef != mainBranch && eventRef != "" {
+		refs = append(refs, eventRef)
+	}
+	return refs, nil
+}
+
+func (f *scmFileBrowser) GetFile(owner, repo, path, ref string) ([]byte, error) {
+	return f.scmClient.GetFile(owner, repo, path, ref)
+}
+
+func (f *scmFileBrowser) ListFiles(owner, repo, path, ref string) ([]*scm.FileEntry, error) {
+	return f.scmClient.ListFiles(owner, repo, path, ref)
+}
diff --git a/pkg/filebrowser/scm_provider_client.go b/pkg/filebrowser/scm_provider_client.go
new file mode 100644
index 00000000..1ae0c198
--- /dev/null
+++ b/pkg/filebrowser/scm_provider_client.go
@@ -0,0 +1,35 @@
+package filebrowser
+
+import (
+	"strings"
+
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/pkg/errors"
+)
+
+type scmProviderClient interface {
+	GetRepositoryByFullName(string) (*scm.Repository, error)
+	GetFile(string, string, string, string) ([]byte, error)
+	ListFiles(string, string, string, string) ([]*scm.FileEntry, error)
+}
+
+// GetMainAndCurrentBranchRefs a function to return the main branch then the eventRef
+func GetMainAndCurrentBranchRefs(scmClient scmProviderClient, fullName, eventRef string) ([]string, error) {
+	repository, err := scmClient.GetRepositoryByFullName(fullName)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to find repository %s", fullName)
+	}
+	mainBranch := repository.Branch
+	if mainBranch == "" {
+		mainBranch = "master"
+	}
+
+	refs := []string{mainBranch}
+
+	eventRef = strings.TrimPrefix(eventRef, "refs/heads/")
+	eventRef = strings.TrimPrefix(eventRef, "refs/tags/")
+	if eventRef != mainBranch && eventRef != "" {
+		refs = append(refs, eventRef)
+	}
+	return refs, nil
+}
diff --git a/pkg/git/v2/client_factory.go b/pkg/git/v2/client_factory.go
new file mode 100644
index 00000000..9c1aed4c
--- /dev/null
+++ b/pkg/git/v2/client_factory.go
@@ -0,0 +1,290 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"io/ioutil"
+	"os"
+	"path"
+	"runtime"
+	"sync"
+
+	"github.com/sirupsen/logrus"
+	utilpointer "k8s.io/utils/pointer"
+)
+
+// ClientFactory knows how to create clientFactory for repos
+type ClientFactory interface {
+	// ClientFromDir creates a client that operates on a repo that has already
+	// been cloned to the given directory.
+	ClientFromDir(org, repo, dir string) (RepoClient, error)
+	// ClientFor creates a client that operates on a new clone of the repo.
+	ClientFor(org, repo string) (RepoClient, error)
+
+	// Clean removes the caches used to generate clients
+	Clean() error
+}
+
+// RepoClient exposes interactions with a git repo
+type RepoClient interface {
+	Publisher
+	Interactor
+}
+
+type repoClient struct {
+	publisher
+	interactor
+}
+
+// ClientFactoryOpts provides options for configuring the factory
+type ClientFactoryOpts struct {
+	// Host, defaults to "github.com" if unset
+	Host string
+	// UseSSH, defaults to false
+	UseSSH *bool
+	// The directory in which the cache should be
+	// created. Defaults to the "/var/tmp" on
+	// Linux and os.TempDir otherwise
+	CacheDirBase *string
+	// If unset, publishing action will error
+	Username LoginGetter
+	// If unset, publishing action will error
+	Token TokenGetter
+	// The git user to use.
+	GitUser UserGetter
+	// The censor to use. Not needed for anonymous
+	// actions.
+	Censor Censor
+}
+
+// Apply allows to use a ClientFactoryOpts as Opt
+func (cfo *ClientFactoryOpts) Apply(target *ClientFactoryOpts) {
+	if cfo.Host != "" {
+		target.Host = cfo.Host
+	}
+	if cfo.UseSSH != nil {
+		target.UseSSH = cfo.UseSSH
+	}
+	if cfo.CacheDirBase != nil {
+		target.CacheDirBase = cfo.CacheDirBase
+	}
+	if cfo.Token != nil {
+		target.Token = cfo.Token
+	}
+	if cfo.GitUser != nil {
+		target.Token = cfo.Token
+	}
+	if cfo.Censor != nil {
+		target.Censor = cfo.Censor
+	}
+	if cfo.Username != nil {
+		target.Username = cfo.Username
+	}
+}
+
+// ClientFactoryOpt allows to manipulate the options for a ClientFactory
+type ClientFactoryOpt func(*ClientFactoryOpts)
+
+func defaultClientFactoryOpts(cfo *ClientFactoryOpts) {
+	if cfo.Host == "" {
+		cfo.Host = "github.com"
+	}
+	if cfo.CacheDirBase == nil {
+		switch runtime.GOOS {
+		case "linux":
+			cfo.CacheDirBase = utilpointer.StringPtr("/var/tmp")
+		default:
+			cfo.CacheDirBase = utilpointer.StringPtr("")
+		}
+	}
+	if cfo.Censor == nil {
+		cfo.Censor = func(in []byte) []byte { return in }
+	}
+}
+
+// NewClientFactory allows for the creation of repository clients. It uses github.com
+// without authentication by default.
+func NewClientFactory(opts ...ClientFactoryOpt) (ClientFactory, error) {
+	o := ClientFactoryOpts{}
+	defaultClientFactoryOpts(&o)
+	for _, opt := range opts {
+		opt(&o)
+	}
+
+	cacheDir, err := ioutil.TempDir(*o.CacheDirBase, "gitcache")
+	if err != nil {
+		return nil, err
+	}
+	var remotes RemoteResolverFactory
+	if o.UseSSH != nil && *o.UseSSH {
+		remotes = &sshRemoteResolverFactory{
+			host:     o.Host,
+			username: o.Username,
+		}
+	} else {
+		remotes = &httpResolverFactory{
+			host:     o.Host,
+			username: o.Username,
+			token:    o.Token,
+		}
+	}
+	return &clientFactory{
+		cacheDir:     cacheDir,
+		cacheDirBase: *o.CacheDirBase,
+		remotes:      remotes,
+		gitUser:      o.GitUser,
+		censor:       o.Censor,
+		masterLock:   &sync.Mutex{},
+		repoLocks:    map[string]*sync.Mutex{},
+		logger:       logrus.WithField("client", "git"),
+	}, nil
+}
+
+// NewLocalClientFactory allows for the creation of repository clients
+// based on a local filepath remote for testing
+func NewLocalClientFactory(baseDir string, gitUser UserGetter, censor Censor) (ClientFactory, error) {
+	cacheDir, err := ioutil.TempDir("", "gitcache")
+	if err != nil {
+		return nil, err
+	}
+	return &clientFactory{
+		cacheDir:   cacheDir,
+		remotes:    &pathResolverFactory{baseDir: baseDir},
+		gitUser:    gitUser,
+		censor:     censor,
+		masterLock: &sync.Mutex{},
+		repoLocks:  map[string]*sync.Mutex{},
+		logger:     logrus.WithField("client", "git"),
+	}, nil
+}
+
+type clientFactory struct {
+	remotes RemoteResolverFactory
+	gitUser UserGetter
+	censor  Censor
+	logger  *logrus.Entry
+
+	// cacheDir is the root under which cached clones of repos are created
+	cacheDir string
+	// cacheDirBase is the basedir under which create tempdirs
+	cacheDirBase string
+	// masterLock guards mutations to the repoLocks records
+	masterLock *sync.Mutex
+	// repoLocks guard mutating access to subdirectories under the cacheDir
+	repoLocks map[string]*sync.Mutex
+}
+
+// bootstrapClients returns a repository client and cloner for a dir.
+func (c *clientFactory) bootstrapClients(org, repo, dir string) (cacher, cloner, RepoClient, error) {
+	if dir == "" {
+		workdir, err := os.Getwd()
+		if err != nil {
+			return nil, nil, nil, err
+		}
+		dir = workdir
+	}
+	logger := c.logger.WithFields(logrus.Fields{"org": org, "repo": repo})
+	logger.WithField("dir", dir).Debug("Creating a pre-initialized client.")
+	executor, err := NewCensoringExecutor(dir, c.censor, logger)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	client := &repoClient{
+		publisher: publisher{
+			remotes: remotes{
+				publishRemote: c.remotes.PublishRemote(org, repo),
+				centralRemote: c.remotes.CentralRemote(org, repo),
+			},
+			executor: executor,
+			info:     c.gitUser,
+			logger:   logger,
+		},
+		interactor: interactor{
+			dir:      dir,
+			remote:   c.remotes.CentralRemote(org, repo),
+			executor: executor,
+			logger:   logger,
+		},
+	}
+	return client, client, client, nil
+}
+
+// ClientFromDir returns a repository client for a directory that's already initialized with content.
+// If the directory isn't specified, the current working directory is used.
+func (c *clientFactory) ClientFromDir(org, repo, dir string) (RepoClient, error) {
+	_, _, client, err := c.bootstrapClients(org, repo, dir)
+	return client, err
+}
+
+// ClientFor returns a repository client for the specified repository.
+// This function may take a long time if it is the first time cloning the repo.
+// In that case, it must do a full git mirror clone. For large repos, this can
+// take a while. Once that is done, it will do a git fetch instead of a clone,
+// which will usually take at most a few seconds.
+func (c *clientFactory) ClientFor(org, repo string) (RepoClient, error) {
+	cacheDir := path.Join(c.cacheDir, org, repo)
+	c.logger.WithFields(logrus.Fields{"org": org, "repo": repo, "dir": cacheDir}).Debug("Creating a client from the cache.")
+	cacheClientCacher, _, _, err := c.bootstrapClients(org, repo, cacheDir)
+	if err != nil {
+		return nil, err
+	}
+
+	repoDir, err := ioutil.TempDir(c.cacheDirBase, "gitrepo")
+	if err != nil {
+		return nil, err
+	}
+	_, repoClientCloner, repoClient, err := c.bootstrapClients(org, repo, repoDir)
+	if err != nil {
+		return nil, err
+	}
+	c.masterLock.Lock()
+	if _, exists := c.repoLocks[cacheDir]; !exists {
+		c.repoLocks[cacheDir] = &sync.Mutex{}
+	}
+	c.masterLock.Unlock()
+	c.repoLocks[cacheDir].Lock()
+	defer c.repoLocks[cacheDir].Unlock()
+	if _, err := os.Stat(path.Join(cacheDir, "HEAD")); os.IsNotExist(err) {
+		// we have not yet cloned this repo, we need to do a full clone
+		if err := os.MkdirAll(cacheDir, os.ModePerm); err != nil && !os.IsExist(err) {
+			return nil, err
+		}
+		if err := cacheClientCacher.MirrorClone(); err != nil {
+			return nil, err
+		}
+	} else if err != nil {
+		// something unexpected happened
+		return nil, err
+	} else {
+		// we have cloned the repo previously, but will refresh it
+		if err := cacheClientCacher.RemoteUpdate(); err != nil {
+			return nil, err
+		}
+	}
+
+	// initialize the new derivative repo from the cache
+	if err := repoClientCloner.Clone(cacheDir); err != nil {
+		return nil, err
+	}
+
+	return repoClient, nil
+}
+
+// Clean removes the caches used to generate clients
+func (c *clientFactory) Clean() error {
+	return os.RemoveAll(c.cacheDir)
+}
diff --git a/pkg/git/v2/executor.go b/pkg/git/v2/executor.go
new file mode 100644
index 00000000..e6f69bb9
--- /dev/null
+++ b/pkg/git/v2/executor.go
@@ -0,0 +1,76 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"os/exec"
+	"strings"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Executor knows how to execute Git commands
+type Executor interface {
+	Run(args ...string) ([]byte, error)
+}
+
+// Censor censors content to remove secrets
+type Censor func(content []byte) []byte
+
+// NewCensoringExecutor creates a new Executor
+func NewCensoringExecutor(dir string, censor Censor, logger *logrus.Entry) (Executor, error) {
+	g, err := exec.LookPath("git")
+	if err != nil {
+		return nil, err
+	}
+	return &censoringExecutor{
+		logger: logger.WithField("client", "git"),
+		dir:    dir,
+		git:    g,
+		censor: censor,
+		execute: func(dir, command string, args ...string) ([]byte, error) {
+			c := exec.Command(command, args...)
+			c.Dir = dir
+			return c.CombinedOutput()
+		},
+	}, nil
+}
+
+type censoringExecutor struct {
+	// logger will be used to log git operations
+	logger *logrus.Entry
+	// dir is the location of this repo.
+	dir string
+	// git is the path to the git binary.
+	git string
+	// censor removes sensitive data from output
+	censor Censor
+	// execute executes a command
+	execute func(dir, command string, args ...string) ([]byte, error)
+}
+
+func (e *censoringExecutor) Run(args ...string) ([]byte, error) {
+	logger := e.logger.WithField("args", strings.Join(args, " "))
+	b, err := e.execute(e.dir, e.git, args...)
+	b = e.censor(b)
+	if err != nil {
+		logger.WithError(err).WithField("output", string(b)).Debug("Running command failed.")
+	} else {
+		logger.Debug("Running command succeeded.")
+	}
+	return b, err
+}
diff --git a/pkg/git/v2/fakes.go b/pkg/git/v2/fakes.go
new file mode 100644
index 00000000..c4abbf2c
--- /dev/null
+++ b/pkg/git/v2/fakes.go
@@ -0,0 +1,52 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"fmt"
+	"strings"
+)
+
+// fakeResolver allows for simple injections in tests
+type fakeResolver struct {
+	out string
+	err error
+}
+
+func (r *fakeResolver) Resolve() (string, error) {
+	return r.out, r.err
+}
+
+type execResponse struct {
+	out []byte
+	err error
+}
+
+// fakeExecutor is useful in testing for mocking an Executor
+type fakeExecutor struct {
+	records   [][]string
+	responses map[string]execResponse
+}
+
+func (e *fakeExecutor) Run(args ...string) ([]byte, error) {
+	e.records = append(e.records, args)
+	key := strings.Join(args, " ")
+	if response, ok := e.responses[key]; ok {
+		return response.out, response.err
+	}
+	return []byte{}, fmt.Errorf("no response configured for %s", key)
+}
diff --git a/pkg/git/v2/interactor.go b/pkg/git/v2/interactor.go
new file mode 100644
index 00000000..36f2361e
--- /dev/null
+++ b/pkg/git/v2/interactor.go
@@ -0,0 +1,375 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Interactor knows how to operate on a git repository cloned from GitHub
+// using a local cache.
+type Interactor interface {
+	// Directory exposes the directory in which the repository has been cloned
+	Directory() string
+	// Clean removes the repository. It is up to the user to call this once they are done
+	Clean() error
+	// Checkout runs `git checkout`
+	Checkout(commitlike string) error
+	// RevParse runs `git rev-parse`
+	RevParse(commitlike string) (string, error)
+	// BranchExists determines if a branch with the name exists
+	BranchExists(branch string) bool
+	// CheckoutNewBranch creates a new branch from HEAD and checks it out
+	CheckoutNewBranch(branch string) error
+	// Merge merges the commitlike into the current HEAD
+	Merge(commitlike string) (bool, error)
+	// MergeWithStrategy merges the commitlike into the current HEAD with the strategy
+	MergeWithStrategy(commitlike, mergeStrategy string, opts ...MergeOpt) (bool, error)
+	// MergeAndCheckout merges all commitlikes into the current HEAD with the appropriate strategy
+	MergeAndCheckout(baseSHA string, mergeStrategy string, headSHAs ...string) error
+	// Am calls `git am`
+	Am(path string) error
+	// Fetch calls `git fetch`
+	Fetch() error
+	// FetchRef fetches the refspec
+	FetchRef(refspec string) error
+	// FetchFromRemote fetches the branch of the given remote
+	FetchFromRemote(remote RemoteResolver, branch string) error
+	// CheckoutPullRequest fetches and checks out the synthetic refspec from GitHub for a pull request HEAD
+	CheckoutPullRequest(number int) error
+	// Config runs `git config`
+	Config(key, value string) error
+	// Diff runs `git diff`
+	Diff(head, sha string) (changes []string, err error)
+	// MergeCommitsExistBetween determines if merge commits exist between target and HEAD
+	MergeCommitsExistBetween(target, head string) (bool, error)
+	// ShowRef returns the commit for a commitlike. Unlike rev-parse it does not require a checkout.
+	ShowRef(commitlike string) (string, error)
+}
+
+// cacher knows how to cache and update repositories in a central cache
+type cacher interface {
+	// MirrorClone sets up a mirror of the source repository.
+	MirrorClone() error
+	// RemoteUpdate fetches all updates from the remote.
+	RemoteUpdate() error
+}
+
+// cloner knows how to clone repositories from a central cache
+type cloner interface {
+	// Clone clones the repository from a local path.
+	Clone(from string) error
+}
+
+// MergeOpt holds options for git merge operations.
+// Currently only commit message option is supported.
+type MergeOpt struct {
+	CommitMessage string
+}
+
+type interactor struct {
+	executor Executor
+	remote   RemoteResolver
+	dir      string
+	logger   *logrus.Entry
+}
+
+// Directory exposes the directory in which this repository has been cloned
+func (i *interactor) Directory() string {
+	return i.dir
+}
+
+// Clean cleans up the repository from the on-disk cache
+func (i *interactor) Clean() error {
+	return os.RemoveAll(i.dir)
+}
+
+// Clone clones the repository from a local path.
+func (i *interactor) Clone(from string) error {
+	i.logger.Infof("Creating a clone of the repo at %s from %s", i.dir, from)
+	if out, err := i.executor.Run("clone", from, i.dir); err != nil {
+		return fmt.Errorf("error creating a clone: %v %v", err, string(out))
+	}
+	return nil
+}
+
+// MirrorClone sets up a mirror of the source repository.
+func (i *interactor) MirrorClone() error {
+	i.logger.Infof("Creating a mirror of the repo at %s", i.dir)
+	i.logger.Infof("Creating a mirror of the repo at %s", i.dir)
+	remote, err := i.remote()
+	if err != nil {
+		return fmt.Errorf("could not resolve remote for cloning: %v", err)
+	}
+	if out, err := i.executor.Run("clone", "--mirror", remote, i.dir); err != nil {
+		return fmt.Errorf("error creating a mirror clone: %v %v", err, string(out))
+	}
+	return nil
+}
+
+// Checkout runs git checkout.
+func (i *interactor) Checkout(commitlike string) error {
+	i.logger.Infof("Checking out %q", commitlike)
+	if out, err := i.executor.Run("checkout", commitlike); err != nil {
+		return fmt.Errorf("error checking out %q: %v %v", commitlike, err, string(out))
+	}
+	return nil
+}
+
+// RevParse runs git rev-parse.
+func (i *interactor) RevParse(commitlike string) (string, error) {
+	i.logger.Infof("Parsing revision %q", commitlike)
+	out, err := i.executor.Run("rev-parse", commitlike)
+	if err != nil {
+		return "", fmt.Errorf("error parsing %q: %v %v", commitlike, err, string(out))
+	}
+	return string(out), nil
+}
+
+// BranchExists returns true if branch exists in heads.
+func (i *interactor) BranchExists(branch string) bool {
+	i.logger.Infof("Checking if branch %q exists", branch)
+	_, err := i.executor.Run("ls-remote", "--exit-code", "--heads", "origin", branch)
+	return err == nil
+}
+
+// CheckoutNewBranch creates a new branch and checks it out.
+func (i *interactor) CheckoutNewBranch(branch string) error {
+	i.logger.Infof("Checking out new branch %q", branch)
+	if out, err := i.executor.Run("checkout", "-b", branch); err != nil {
+		return fmt.Errorf("error checking out new branch %q: %v %v", branch, err, string(out))
+	}
+	return nil
+}
+
+// Merge attempts to merge commitlike into the current branch. It returns true
+// if the merge completes. It returns an error if the abort fails.
+func (i *interactor) Merge(commitlike string) (bool, error) {
+	return i.MergeWithStrategy(commitlike, "merge")
+}
+
+// MergeWithStrategy attempts to merge commitlike into the current branch given the merge strategy.
+// It returns true if the merge completes. if the merge does not complete successfully, we try to
+// abort it and return an error if the abort fails.
+func (i *interactor) MergeWithStrategy(commitlike, mergeStrategy string, opts ...MergeOpt) (bool, error) {
+	i.logger.Infof("Merging %q using the %q strategy", commitlike, mergeStrategy)
+	switch mergeStrategy {
+	case "merge":
+		return i.mergeMerge(commitlike, opts...)
+	case "squash":
+		return i.squashMerge(commitlike)
+	default:
+		return false, fmt.Errorf("merge strategy %q is not supported", mergeStrategy)
+	}
+}
+
+func (i *interactor) mergeMerge(commitlike string, opts ...MergeOpt) (bool, error) {
+	args := []string{"merge", "--no-ff", "--no-stat"}
+
+	if len(opts) == 0 {
+		args = append(args, []string{"-m", "merge"}...)
+	} else {
+		for _, opt := range opts {
+			args = append(args, []string{"-m", opt.CommitMessage}...)
+		}
+	}
+
+	args = append(args, commitlike)
+
+	out, err := i.executor.Run(args...)
+	if err == nil {
+		return true, nil
+	}
+	i.logger.WithError(err).Warnf("Error merging %q: %s", commitlike, string(out))
+	if out, err := i.executor.Run("merge", "--abort"); err != nil {
+		return false, fmt.Errorf("error aborting merge of %q: %v %v", commitlike, err, string(out))
+	}
+	return false, nil
+}
+
+func (i *interactor) squashMerge(commitlike string) (bool, error) {
+	out, err := i.executor.Run("merge", "--squash", "--no-stat", commitlike)
+	if err != nil {
+		i.logger.WithError(err).Warnf("Error staging merge for %q: %s", commitlike, string(out))
+		if out, err := i.executor.Run("reset", "--hard", "HEAD"); err != nil {
+			return false, fmt.Errorf("error aborting merge of %q: %v %v", commitlike, err, string(out))
+		}
+		return false, nil
+	}
+	out, err = i.executor.Run("commit", "--no-stat", "-m", "merge")
+	if err != nil {
+		i.logger.WithError(err).Warnf("Error committing merge for %q: %s", commitlike, string(out))
+		if out, err := i.executor.Run("reset", "--hard", "HEAD"); err != nil {
+			return false, fmt.Errorf("error aborting merge of %q: %v %v", commitlike, err, string(out))
+		}
+		return false, nil
+	}
+	return true, nil
+}
+
+// Only the `merge` and `squash` strategies are supported.
+func (i *interactor) MergeAndCheckout(baseSHA string, mergeStrategy string, headSHAs ...string) error {
+	if baseSHA == "" {
+		return errors.New("baseSHA must be set")
+	}
+	if err := i.Checkout(baseSHA); err != nil {
+		return err
+	}
+	for _, headSHA := range headSHAs {
+		ok, err := i.MergeWithStrategy(headSHA, mergeStrategy)
+		if err != nil {
+			return err
+		} else if !ok {
+			return fmt.Errorf("failed to merge %q", headSHA)
+		}
+	}
+	return nil
+}
+
+// Am tries to apply the patch in the given path into the current branch
+// by performing a three-way merge (similar to git cherry-pick). It returns
+// an error if the patch cannot be applied.
+func (i *interactor) Am(path string) error {
+	i.logger.Infof("Applying patch at %s", path)
+	out, err := i.executor.Run("am", "--3way", path)
+	if err == nil {
+		return nil
+	}
+	i.logger.WithError(err).Infof("Patch apply failed with output: %s", string(out))
+	if abortOut, abortErr := i.executor.Run("am", "--abort"); err != nil {
+		i.logger.WithError(abortErr).Warningf("Aborting patch apply failed with output: %s", string(abortOut))
+	}
+	return errors.New(string(bytes.TrimPrefix(out, []byte("The copy of the patch that failed is found in: .git/rebase-apply/patch"))))
+}
+
+// RemoteUpdate fetches all updates from the remote.
+func (i *interactor) RemoteUpdate() error {
+	i.logger.Info("Updating from remote")
+	if out, err := i.executor.Run("remote", "update"); err != nil {
+		return fmt.Errorf("error updating: %v %v", err, string(out))
+	}
+	return nil
+}
+
+// Fetch fetches all updates from the remote.
+func (i *interactor) Fetch() error {
+	remote, err := i.remote()
+	if err != nil {
+		return fmt.Errorf("could not resolve remote for fetching: %v", err)
+	}
+	i.logger.Infof("Fetching from %s", remote)
+	if out, err := i.executor.Run("fetch", remote); err != nil {
+		return fmt.Errorf("error fetching: %v %v", err, string(out))
+	}
+	return nil
+}
+
+// FetchRef fetches a refspec from the remote and leaves it as FETCH_HEAD.
+func (i *interactor) FetchRef(refspec string) error {
+	remote, err := i.remote()
+	if err != nil {
+		return fmt.Errorf("could not resolve remote for fetching: %v", err)
+	}
+	i.logger.Infof("Fetching %q from %s", refspec, remote)
+	if out, err := i.executor.Run("fetch", remote, refspec); err != nil {
+		return fmt.Errorf("error fetching %q: %v %v", refspec, err, string(out))
+	}
+	return nil
+}
+
+// FetchFromRemote fetches all update from a specific remote and branch and leaves it as FETCH_HEAD.
+func (i *interactor) FetchFromRemote(remote RemoteResolver, branch string) error {
+	r, err := remote()
+	if err != nil {
+		return fmt.Errorf("couldn't get remote: %v", err)
+	}
+
+	i.logger.Infof("Fetching %s from %s", branch, r)
+	if out, err := i.executor.Run("fetch", r, branch); err != nil {
+		return fmt.Errorf("error fetching %s from %s: %v %v", branch, r, err, string(out))
+	}
+	return nil
+}
+
+// CheckoutPullRequest fetches the HEAD of a pull request using a synthetic refspec
+// available on GitHub remotes and creates a branch at that commit.
+func (i *interactor) CheckoutPullRequest(number int) error {
+	i.logger.Infof("Checking out pull request %d", number)
+	if err := i.FetchRef(fmt.Sprintf("pull/%d/head", number)); err != nil {
+		return err
+	}
+	if err := i.Checkout("FETCH_HEAD"); err != nil {
+		return err
+	}
+	if err := i.CheckoutNewBranch(fmt.Sprintf("pull%d", number)); err != nil {
+		return err
+	}
+	return nil
+}
+
+// Config runs git config.
+func (i *interactor) Config(key, value string) error {
+	i.logger.Infof("Configuring %q=%q", key, value)
+	if out, err := i.executor.Run("config", key, value); err != nil {
+		return fmt.Errorf("error configuring %q=%q: %v %v", key, value, err, string(out))
+	}
+	return nil
+}
+
+// Diff lists the difference between the two references, returning the output
+// line by line.
+func (i *interactor) Diff(head, sha string) ([]string, error) {
+	i.logger.Infof("Finding the differences between %q and %q", head, sha)
+	out, err := i.executor.Run("diff", head, sha, "--name-only")
+	if err != nil {
+		return nil, err
+	}
+	var changes []string
+	scan := bufio.NewScanner(bytes.NewReader(out))
+	scan.Split(bufio.ScanLines)
+	for scan.Scan() {
+		changes = append(changes, scan.Text())
+	}
+	return changes, nil
+}
+
+// MergeCommitsExistBetween runs 'git log <target>..<head> --merged' to verify
+// if merge commits exist between "target" and "head".
+func (i *interactor) MergeCommitsExistBetween(target, head string) (bool, error) {
+	i.logger.Infof("Determining if merge commits exist between %q and %q", target, head)
+	out, err := i.executor.Run("log", fmt.Sprintf("%s..%s", target, head), "--oneline", "--merges")
+	if err != nil {
+		return false, fmt.Errorf("error verifying if merge commits exist between %q and %q: %v %s", target, head, err, string(out))
+	}
+	return len(out) != 0, nil
+}
+
+func (i *interactor) ShowRef(commitlike string) (string, error) {
+	i.logger.Infof("Getting the commit sha for commitlike %s", commitlike)
+	out, err := i.executor.Run("show-ref", "-s", commitlike)
+	if err != nil {
+		return "", fmt.Errorf("failed to get commit sha for commitlike %s: %v", commitlike, err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
diff --git a/pkg/git/v2/publisher.go b/pkg/git/v2/publisher.go
new file mode 100644
index 00000000..19d5f449
--- /dev/null
+++ b/pkg/git/v2/publisher.go
@@ -0,0 +1,107 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"fmt"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Publisher knows how to publish local work to a remote
+type Publisher interface {
+	// Commit stages all changes and commits them with the message
+	Commit(title, body string) error
+	// PushToFork pushes the local state to the fork remote
+	PushToFork(branch string, force bool) error
+	// PushToCentral pushes the local state to the central remote
+	PushToCentral(branch string, force bool) error
+}
+
+// UserGetter fetches a name and email for us in git commits on-demand
+type UserGetter func() (name, email string, err error)
+
+type remotes struct {
+	publishRemote RemoteResolver
+	centralRemote RemoteResolver
+}
+
+type publisher struct {
+	executor Executor
+	remotes  remotes
+	info     UserGetter
+	logger   *logrus.Entry
+}
+
+// Commit adds all of the current content to the index and creates a commit
+func (p *publisher) Commit(title, body string) error {
+	p.logger.Infof("Committing changes with title %q", title)
+	name, email, err := p.info()
+	if err != nil {
+		return err
+	}
+	commands := [][]string{
+		{"add", "--all"},
+		{"commit", "--message", title, "--message", body, "--author", fmt.Sprintf("%s <%s>", name, email)},
+	}
+	for _, command := range commands {
+		if out, err := p.executor.Run(command...); err != nil {
+			return fmt.Errorf("error committing %q: %v %v", title, err, string(out))
+		}
+	}
+	return nil
+}
+
+// PublishPush pushes the local state to the publish remote
+func (p *publisher) PushToFork(branch string, force bool) error {
+	remote, err := p.remotes.publishRemote()
+	if err != nil {
+		return err
+	}
+
+	args := []string{"push"}
+	if force {
+		args = append(args, "--force")
+	}
+	args = append(args, []string{remote, branch}...)
+
+	p.logger.Infof("Pushing branch %q to %q", branch, remote)
+	if out, err := p.executor.Run(args...); err != nil {
+		return fmt.Errorf("error pushing %q: %v %v", branch, err, string(out))
+	}
+	return nil
+}
+
+// CentralPush pushes the local state to the central remote
+func (p *publisher) PushToCentral(branch string, force bool) error {
+	remote, err := p.remotes.centralRemote()
+	if err != nil {
+		return err
+	}
+
+	args := []string{"push"}
+	if force {
+		args = append(args, "--force")
+	}
+	args = append(args, []string{remote, branch}...)
+
+	p.logger.Infof("Pushing branch %q to %q", branch, remote)
+	if out, err := p.executor.Run(args...); err != nil {
+		return fmt.Errorf("error pushing %q: %v %v", branch, err, string(out))
+	}
+	return nil
+}
diff --git a/pkg/git/v2/remote.go b/pkg/git/v2/remote.go
new file mode 100644
index 00000000..287884d9
--- /dev/null
+++ b/pkg/git/v2/remote.go
@@ -0,0 +1,146 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"path"
+)
+
+// RemoteResolverFactory knows how to construct remote resolvers for
+// authoritative central remotes (to pull from) and publish remotes
+// (to push to) for a repository. These resolvers are called at run-time
+// to determine remotes for git commands.
+type RemoteResolverFactory interface {
+	// CentralRemote returns a resolver for a remote server with an
+	// authoritative version of the repository. This type of remote
+	// is useful for fetching refs and cloning.
+	CentralRemote(org, repo string) RemoteResolver
+	// PublishRemote returns a resolver for a remote server with a
+	// personal fork of the repository. This type of remote is most
+	// useful for publishing local changes.
+	PublishRemote(org, repo string) RemoteResolver
+}
+
+// RemoteResolver knows how to construct a remote URL for git calls
+type RemoteResolver func() (string, error)
+
+// LoginGetter fetches a GitHub login on-demand
+type LoginGetter func() (login string, err error)
+
+// TokenGetter fetches a GitHub OAuth token on-demand
+type TokenGetter func() []byte
+
+type sshRemoteResolverFactory struct {
+	host     string
+	username LoginGetter
+}
+
+// CentralRemote creates a remote resolver that refers to an authoritative remote
+// for the repository.
+func (f *sshRemoteResolverFactory) CentralRemote(org, repo string) RemoteResolver {
+	remote := fmt.Sprintf("git@%s:%s/%s.git", f.host, org, repo)
+	return func() (string, error) {
+		return remote, nil
+	}
+}
+
+// PublishRemote creates a remote resolver that refers to a user's remote
+// for the repository that can be published to.
+func (f *sshRemoteResolverFactory) PublishRemote(_, repo string) RemoteResolver {
+	return func() (string, error) {
+		org, err := f.username()
+		if err != nil {
+			return "", err
+		}
+		return fmt.Sprintf("git@%s:%s/%s.git", f.host, org, repo), nil
+	}
+}
+
+type httpResolverFactory struct {
+	host string
+	// Optional, either both or none must be set
+	username LoginGetter
+	token    TokenGetter
+}
+
+// CentralRemote creates a remote resolver that refers to an authoritative remote
+// for the repository.
+func (f *httpResolverFactory) CentralRemote(org, repo string) RemoteResolver {
+	return HTTPResolver(func() (*url.URL, error) {
+		return &url.URL{Scheme: "https", Host: f.host, Path: fmt.Sprintf("%s/%s", org, repo)}, nil
+	}, f.username, f.token)
+}
+
+// PublishRemote creates a remote resolver that refers to a user's remote
+// for the repository that can be published to.
+func (f *httpResolverFactory) PublishRemote(_, repo string) RemoteResolver {
+	return HTTPResolver(func() (*url.URL, error) {
+		if f.username == nil {
+			return nil, errors.New("username not configured, no publish repo available")
+		}
+		o, err := f.username()
+		if err != nil {
+			return nil, err
+		}
+		return &url.URL{Scheme: "https", Host: f.host, Path: fmt.Sprintf("%s/%s", o, repo)}, nil
+	}, f.username, f.token)
+}
+
+// HTTPResolver builds http URLs that may optionally contain simple auth credentials, resolved dynamically.
+func HTTPResolver(remote func() (*url.URL, error), username LoginGetter, token TokenGetter) RemoteResolver {
+	return func() (string, error) {
+		remote, err := remote()
+		if err != nil {
+			return "", fmt.Errorf("could not resolve remote: %v", err)
+		}
+
+		if username != nil {
+			name, err := username()
+			if err != nil {
+				return "", fmt.Errorf("could not resolve username: %v", err)
+			}
+			remote.User = url.UserPassword(name, string(token()))
+		}
+
+		return remote.String(), nil
+	}
+}
+
+// pathResolverFactory generates resolvers for local path-based repositories,
+// used in local integration testing only
+type pathResolverFactory struct {
+	baseDir string
+}
+
+// CentralRemote creates a remote resolver that refers to an authoritative remote
+// for the repository.
+func (f *pathResolverFactory) CentralRemote(org, repo string) RemoteResolver {
+	return func() (string, error) {
+		return path.Join(f.baseDir, org, repo), nil
+	}
+}
+
+// PublishRemote creates a remote resolver that refers to a user's remote
+// for the repository that can be published to.
+func (f *pathResolverFactory) PublishRemote(org, repo string) RemoteResolver {
+	return func() (string, error) {
+		return path.Join(f.baseDir, org, repo), nil
+	}
+}
diff --git a/pkg/keeper/keeper.go b/pkg/keeper/keeper.go
index 46709c47..5fb26c1c 100644
--- a/pkg/keeper/keeper.go
+++ b/pkg/keeper/keeper.go
@@ -37,6 +37,7 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/config/job"
 	"github.com/jenkins-x/lighthouse/pkg/config/keeper"
 	"github.com/jenkins-x/lighthouse/pkg/errorutil"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/git"
 	"github.com/jenkins-x/lighthouse/pkg/jobutil"
 	"github.com/jenkins-x/lighthouse/pkg/keeper/blockers"
@@ -1347,7 +1348,7 @@ func (c *DefaultController) presubmitsByPull(sp *subpool) (map[int][]job.Presubm
 	owner := sp.org
 	repo := sp.repo
 	sharedConfig := c.config()
-	cfg, _, err := inrepo.Generate(c.spc, sharedConfig, nil, owner, repo, "")
+	cfg, _, err := inrepo.Generate(filebrowser.NewFileBrowserFromScmClient(c.spc), sharedConfig, nil, owner, repo, "")
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to calculate in repo config")
 	}
diff --git a/pkg/triggerconfig/inrepo/calculate.go b/pkg/triggerconfig/inrepo/calculate.go
index 75a61b99..01e9cab9 100644
--- a/pkg/triggerconfig/inrepo/calculate.go
+++ b/pkg/triggerconfig/inrepo/calculate.go
@@ -1,32 +1,21 @@
 package inrepo
 
 import (
-	"strings"
-
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig/merge"
 	"github.com/pkg/errors"
 )
 
 // Generate generates the in repository config if enabled for this repository otherwise return the shared config
-func Generate(scmClient scmProviderClient, sharedConfig *config.Config, sharedPlugins *plugins.Configuration, owner, repo, eventRef string) (*config.Config, *plugins.Configuration, error) {
+func Generate(fileBrowser filebrowser.Interface, sharedConfig *config.Config, sharedPlugins *plugins.Configuration, owner, repo, eventRef string) (*config.Config, *plugins.Configuration, error) {
 	fullName := scm.Join(owner, repo)
 	if !sharedConfig.InRepoConfigEnabled(fullName) {
 		return sharedConfig, sharedPlugins, nil
 	}
 
-	// lets find the main branch
-	repository, err := scmClient.GetRepositoryByFullName(fullName)
-	if err != nil {
-		return sharedConfig, sharedPlugins, errors.Wrapf(err, "failed to find repository %s", fullName)
-	}
-	mainBranch := repository.Branch
-	if mainBranch == "" {
-		mainBranch = "master"
-	}
-
 	// in repository configuration configured for this repository so lets create the in repository specific config structs
 	cfg := *sharedConfig
 
@@ -43,15 +32,13 @@ func Generate(scmClient scmProviderClient, sharedConfig *config.Config, sharedPl
 	}
 
 	// lets load the main branch first then merge in any changes from this PR/branch
-	refs := []string{mainBranch}
-
-	eventRef = strings.TrimPrefix(eventRef, "refs/heads/")
-	eventRef = strings.TrimPrefix(eventRef, "refs/tags/")
-	if eventRef != mainBranch && eventRef != "" {
-		refs = append(refs, eventRef)
+	refs, err := fileBrowser.GetMainAndCurrentBranchRefs(owner, repo, eventRef)
+	if err != nil {
+		return sharedConfig, sharedPlugins, errors.Wrapf(err, "failed to find main branch %s", fullName)
 	}
+
 	for _, ref := range refs {
-		repoConfig, err := LoadTriggerConfig(scmClient, owner, repo, ref)
+		repoConfig, err := LoadTriggerConfig(fileBrowser, owner, repo, ref)
 		if err != nil {
 			return sharedConfig, sharedPlugins, errors.Wrapf(err, "failed to create trigger config from local source for repo %s/%s ref %s", owner, repo, ref)
 		}
diff --git a/pkg/triggerconfig/inrepo/load_triggers.go b/pkg/triggerconfig/inrepo/load_triggers.go
index 8515e5e8..968013e8 100644
--- a/pkg/triggerconfig/inrepo/load_triggers.go
+++ b/pkg/triggerconfig/inrepo/load_triggers.go
@@ -8,6 +8,7 @@ import (
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/config/job"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig/merge"
@@ -16,8 +17,8 @@ import (
 )
 
 // MergeTriggers merges the configuration with any `lighthouse.yaml` files in the repository
-func MergeTriggers(cfg *config.Config, pluginCfg *plugins.Configuration, scmClient scmProviderClient, ownerName string, repoName string, sha string) (bool, error) {
-	repoConfig, err := LoadTriggerConfig(scmClient, ownerName, repoName, sha)
+func MergeTriggers(cfg *config.Config, pluginCfg *plugins.Configuration, fileBrowser filebrowser.Interface, ownerName string, repoName string, sha string) (bool, error) {
+	repoConfig, err := LoadTriggerConfig(fileBrowser, ownerName, repoName, sha)
 	if err != nil {
 		return false, errors.Wrap(err, "failed to load configs")
 	}
@@ -33,17 +34,17 @@ func MergeTriggers(cfg *config.Config, pluginCfg *plugins.Configuration, scmClie
 }
 
 // LoadTriggerConfig loads the `lighthouse.yaml` configuration files in the repository
-func LoadTriggerConfig(scmClient scmProviderClient, ownerName string, repoName string, sha string) (*triggerconfig.Config, error) {
+func LoadTriggerConfig(fileBrowser filebrowser.Interface, ownerName string, repoName string, sha string) (*triggerconfig.Config, error) {
 	m := map[string]*triggerconfig.Config{}
 	path := ".lighthouse"
-	files, err := scmClient.ListFiles(ownerName, repoName, path, sha)
+	files, err := fileBrowser.ListFiles(ownerName, repoName, path, sha)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to find any lighthouse configuration files in repo %s/%s at sha %s", ownerName, repoName, sha)
 	}
 	for _, f := range files {
 		if isDirType(f.Type) {
 			filePath := path + "/" + f.Name + "/triggers.yaml"
-			cfg, err := loadConfigFile(scmClient, ownerName, repoName, filePath, sha)
+			cfg, err := loadConfigFile(fileBrowser, ownerName, repoName, filePath, sha)
 			if err != nil {
 				return nil, errors.Wrapf(err, "failed to load file %s in %s/%s with sha %s", filePath, ownerName, repoName, sha)
 			}
@@ -52,7 +53,7 @@ func LoadTriggerConfig(scmClient scmProviderClient, ownerName string, repoName s
 			}
 		} else if f.Name == "triggers.yaml" {
 			filePath := path + "/" + f.Name
-			cfg, err := loadConfigFile(scmClient, ownerName, repoName, filePath, sha)
+			cfg, err := loadConfigFile(fileBrowser, ownerName, repoName, filePath, sha)
 			if err != nil {
 				return nil, errors.Wrapf(err, "failed to load file %s in %s/%s with sha %s", filePath, ownerName, repoName, sha)
 			}
@@ -98,7 +99,7 @@ func isDirType(t string) bool {
 	return strings.ToLower(t) == "dir"
 }
 
-func loadConfigFile(client scmProviderClient, ownerName, repoName, path, sha string) (*triggerconfig.Config, error) {
+func loadConfigFile(client filebrowser.Interface, ownerName, repoName, path, sha string) (*triggerconfig.Config, error) {
 	data, err := client.GetFile(ownerName, repoName, path, sha)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to find file %s in repo %s/%s with sha %s", path, ownerName, repoName, sha)
@@ -140,7 +141,7 @@ func loadConfigFile(client scmProviderClient, ownerName, repoName, path, sha str
 	return repoConfig, nil
 }
 
-func loadJobBaseFromSourcePath(client scmProviderClient, j *job.Base, ownerName, repoName, path, sha string) error {
+func loadJobBaseFromSourcePath(client filebrowser.Interface, j *job.Base, ownerName, repoName, path, sha string) error {
 	data, err := client.GetFile(ownerName, repoName, path, sha)
 	if err != nil {
 		return errors.Wrapf(err, "failed to find file %s in repo %s/%s with sha %s", path, ownerName, repoName, sha)
diff --git a/pkg/triggerconfig/inrepo/scm_provider_client.go b/pkg/triggerconfig/inrepo/scm_provider_client.go
deleted file mode 100644
index afd1b067..00000000
--- a/pkg/triggerconfig/inrepo/scm_provider_client.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package inrepo
-
-import "github.com/jenkins-x/go-scm/scm"
-
-type scmProviderClient interface {
-	GetRepositoryByFullName(string) (*scm.Repository, error)
-	GetFile(string, string, string, string) ([]byte, error)
-	ListFiles(string, string, string, string) ([]*scm.FileEntry, error)
-}
diff --git a/pkg/webhook/create_agent.go b/pkg/webhook/create_agent.go
index 28ceff9b..ea60fb79 100644
--- a/pkg/webhook/create_agent.go
+++ b/pkg/webhook/create_agent.go
@@ -17,22 +17,59 @@ limitations under the License.
 package webhook
 
 import (
+	"strings"
+
+	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig/inrepo"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
 
-// CreateAgent creates an agent for the given plugin and repository
+// CreateAgent creates an agent for the given repository
 // if the repository is configured to use in repository configuration then we create the use the repository specific
 // configuration
-func (s *Server) CreateAgent(l *logrus.Entry, plugin, owner, repo, ref string) (plugins.Agent, error) {
-	pc := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l.WithField("plugin", plugin))
+func (s *Server) CreateAgent(l *logrus.Entry, owner, repo, ref string) (plugins.Agent, error) {
+	pc := plugins.NewAgent(s.ConfigAgent, s.Plugins, s.ClientAgent, s.ServerURL, l)
+	fullName := scm.Join(owner, repo)
+	if !pc.Config.InRepoConfigEnabled(fullName) {
+		return pc, nil
+	}
 
-	var err error
-	pc.Config, pc.PluginConfig, err = inrepo.Generate(pc.SCMProviderClient, pc.Config, pc.PluginConfig, owner, repo, ref)
+	if !IsSHA(ref) {
+		err := s.createAgent(&pc, owner, repo, ref)
+		if err != nil {
+			return pc, errors.Wrapf(err, "failed to calculate in repo config")
+		}
+		return pc, nil
+	}
+
+	key := owner + "/" + repo + "/" + ref
+	c := s.InRepoCache
+	if x, found := c.Get(key); found {
+		pa := x.(*plugins.Agent)
+		if pa != nil {
+			return *pa, nil
+		}
+	}
+	err := s.createAgent(&pc, owner, repo, ref)
 	if err != nil {
-		return pc, errors.Wrapf(err, "failed to calculate in repo config")
+		return pc, errors.Wrapf(err, "failed to create agent")
 	}
+	c.Add(key, &pc)
 	return pc, nil
 }
+
+func (s *Server) createAgent(pc *plugins.Agent, owner, repo, ref string) error {
+	var err error
+	pc.Config, pc.PluginConfig, err = inrepo.Generate(s.FileBrowser, pc.Config, pc.PluginConfig, owner, repo, ref)
+	if err != nil {
+		return errors.Wrapf(err, "failed to calculate in repo config")
+	}
+	return nil
+}
+
+// IsSHA returns true if the given ref is a SHA
+func IsSHA(ref string) bool {
+	return len(ref) > 7 && !strings.Contains(ref, "/")
+}
diff --git a/pkg/webhook/events.go b/pkg/webhook/events.go
index 01b353d9..ea84dc29 100644
--- a/pkg/webhook/events.go
+++ b/pkg/webhook/events.go
@@ -23,8 +23,10 @@ import (
 	"strconv"
 	"sync"
 
+	lru "github.com/hashicorp/golang-lru"
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/util"
@@ -39,6 +41,8 @@ type Server struct {
 	ServerURL      *url.URL
 	TokenGenerator func() []byte
 	Metrics        *Metrics
+	FileBrowser    filebrowser.Interface
+	InRepoCache    *lru.Cache
 
 	// Tracks running handlers for graceful shutdown
 	wg sync.WaitGroup
@@ -123,16 +127,21 @@ func (s *Server) handlePullRequestCommentEvent(l *logrus.Entry, pc scm.PullReque
 }
 
 func (s *Server) handleGenericComment(l *logrus.Entry, ce *scmprovider.GenericCommentEvent) {
+	agent, err := s.CreateAgent(l, ce.Repo.Namespace, ce.Repo.Name, ce.HeadSha)
+	if err != nil {
+		agent.Logger.WithError(err).Error("Error creating agent for GenericCommentEvent.")
+		return
+	}
+	agent.InitializeCommentPruner(
+		ce.Repo.Namespace,
+		ce.Repo.Name,
+		ce.Number,
+	)
 	for p, h := range s.getPlugins(ce.Repo.Namespace, ce.Repo.Name) {
 		if h.GenericCommentHandler != nil {
 			s.wg.Add(1)
 			go func(p string, h plugins.GenericCommentHandler) {
 				defer s.wg.Done()
-				agent, err := s.CreateAgent(l, p, ce.Repo.Namespace, ce.Repo.Name, ce.HeadSha)
-				if err != nil {
-					agent.Logger.WithError(err).Error("Error creating agent for GenericCommentEvent.")
-					return
-				}
 				if err := h(agent, *ce); err != nil {
 					agent.Logger.WithError(err).Error("Error handling GenericCommentEvent.")
 				}
@@ -143,16 +152,6 @@ func (s *Server) handleGenericComment(l *logrus.Entry, ce *scmprovider.GenericCo
 				s.wg.Add(1)
 				go func(p string, h plugins.CommandEventHandler, m plugins.CommandMatch) {
 					defer s.wg.Done()
-					agent, err := s.CreateAgent(l, p, ce.Repo.Namespace, ce.Repo.Name, ce.HeadSha)
-					if err != nil {
-						agent.Logger.WithError(err).Error("Error creating agent for GenericCommentEvent.")
-						return
-					}
-					agent.InitializeCommentPruner(
-						ce.Repo.Namespace,
-						ce.Repo.Name,
-						ce.Number,
-					)
 					if err := h(m, agent, *ce); err != nil {
 						agent.Logger.WithError(err).Error("Error handling GenericCommentEvent.")
 					}
@@ -177,17 +176,21 @@ func (s *Server) handlePushEvent(l *logrus.Entry, pe *scm.PushHook) {
 	})
 	l.Info("Push event.")
 	c := 0
+	ref := pe.After
+	if ref == "" {
+		ref = pe.Ref
+	}
+	agent, err := s.CreateAgent(l, repo.Namespace, repo.Name, ref)
+	if err != nil {
+		agent.Logger.WithError(err).Error("Error creating agent for PushEvent.")
+		return
+	}
 	for p, h := range s.getPlugins(pe.Repo.Namespace, pe.Repo.Name) {
 		if h.PushEventHandler != nil {
 			s.wg.Add(1)
 			c++
 			go func(p string, h plugins.PushEventHandler) {
 				defer s.wg.Done()
-				agent, err := s.CreateAgent(l, p, repo.Namespace, repo.Name, pe.Ref)
-				if err != nil {
-					agent.Logger.WithError(err).Error("Error creating agent for PushEvent.")
-					return
-				}
 				if err := h(agent, *pe); err != nil {
 					agent.Logger.WithError(err).Error("Error handling PushEvent.")
 				}
@@ -212,25 +215,25 @@ func (s *Server) handlePullRequestEvent(l *logrus.Entry, pr *scm.PullRequestHook
 	if repo.Name == "" {
 		repo = pr.Repo
 	}
+	agent, err := s.CreateAgent(l, repo.Namespace, repo.Name, pr.PullRequest.Sha)
+	if err != nil {
+		agent.Logger.WithError(err).Error("Error creating agent for PullRequestEvent.")
+
+		// the error could be related to a bad local triggers.yaml change so lets comment on the Pull Request
+		s.reportErrorToPullRequest(l, agent, repo, pr, err)
+		return
+	}
+	agent.InitializeCommentPruner(
+		pr.Repo.Namespace,
+		pr.Repo.Name,
+		pr.PullRequest.Number,
+	)
 	for p, h := range s.getPlugins(repo.Namespace, repo.Name) {
 		if h.PullRequestHandler != nil {
 			s.wg.Add(1)
 			c++
 			go func(p string, h plugins.PullRequestHandler) {
 				defer s.wg.Done()
-				agent, err := s.CreateAgent(l, p, repo.Namespace, repo.Name, pr.PullRequest.Sha)
-				if err != nil {
-					agent.Logger.WithError(err).Error("Error creating agent for PullRequestEvent.")
-
-					// the error could be related to a bad local triggers.yaml change so lets comment on the Pull Request
-					s.reportErrorToPullRequest(l, agent, repo, pr, err)
-					return
-				}
-				agent.InitializeCommentPruner(
-					pr.Repo.Namespace,
-					pr.Repo.Name,
-					pr.PullRequest.Number,
-				)
 				if err := h(agent, *pr); err != nil {
 					agent.Logger.WithError(err).Error("Error handling PullRequestEvent.")
 				}
@@ -279,22 +282,22 @@ func (s *Server) handleReviewEvent(l *logrus.Entry, re scm.ReviewHook) {
 		"url":                    re.Review.Link,
 	})
 	l.Infof("Review %s.", re.Action)
+	repo := re.PullRequest.Base.Repo
+	agent, err := s.CreateAgent(l, repo.Namespace, repo.Name, re.PullRequest.Sha)
+	if err != nil {
+		agent.Logger.WithError(err).Error("Error creating agent for ReviewEvent.")
+		return
+	}
+	agent.InitializeCommentPruner(
+		re.Repo.Namespace,
+		re.Repo.Name,
+		re.PullRequest.Number,
+	)
 	for p, h := range s.getPlugins(re.PullRequest.Base.Repo.Namespace, re.PullRequest.Base.Repo.Name) {
-		repo := re.PullRequest.Base.Repo
 		if h.ReviewEventHandler != nil {
 			s.wg.Add(1)
 			go func(p string, h plugins.ReviewEventHandler) {
 				defer s.wg.Done()
-				agent, err := s.CreateAgent(l, p, repo.Namespace, repo.Name, re.PullRequest.Sha)
-				if err != nil {
-					agent.Logger.WithError(err).Error("Error creating agent for ReviewEvent.")
-					return
-				}
-				agent.InitializeCommentPruner(
-					re.Repo.Namespace,
-					re.Repo.Name,
-					re.PullRequest.Number,
-				)
 				if err := h(agent, re); err != nil {
 					agent.Logger.WithError(err).Error("Error handling ReviewEvent.")
 				}
diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
index b34c33dc..be45e06a 100644
--- a/pkg/webhook/webhook.go
+++ b/pkg/webhook/webhook.go
@@ -6,15 +6,20 @@ import (
 	"io/ioutil"
 	"net/http"
 	"net/url"
+	"os"
 	"strings"
 
+	lru "github.com/hashicorp/golang-lru"
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/lighthouse/pkg/clients"
 	"github.com/jenkins-x/lighthouse/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/git"
+	gitv2 "github.com/jenkins-x/lighthouse/pkg/git/v2"
 	"github.com/jenkins-x/lighthouse/pkg/launcher"
 	"github.com/jenkins-x/lighthouse/pkg/metrics"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
+	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/util"
 	"github.com/jenkins-x/lighthouse/pkg/version"
 	"github.com/jenkins-x/lighthouse/pkg/watcher"
@@ -199,6 +204,38 @@ func (o *WebhooksController) HandleWebhookRequests(w http.ResponseWriter, r *htt
 		LighthouseClient:  lhClient.LighthouseV1alpha1().LighthouseJobs(o.namespace),
 		LauncherClient:    o.launcher,
 	}
+
+	if o.server.FileBrowser == nil {
+		// allow an enviroment variable to enable the git based file browsing
+		if os.Getenv("FILE_BROWSER") == "git" {
+			configureOpts := func(opts *gitv2.ClientFactoryOpts) {
+				opts.Token = func() []byte {
+					return []byte(token)
+				}
+				opts.GitUser = func() (name, email string, err error) {
+					name = gitCloneUser
+					return
+				}
+				opts.Username = func() (login string, err error) {
+					login = gitCloneUser
+					return
+				}
+				if o.server.ServerURL.Host != "" {
+					opts.Host = o.server.ServerURL.Host
+				}
+			}
+			gitFactory, err := gitv2.NewClientFactory(configureOpts)
+			if err != nil {
+				err = errors.Wrapf(err, "failed to create git client factory for server %s", o.gitServerURL)
+				responseHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("500 Internal Server Error: %s", err.Error()))
+				return
+			}
+			o.server.FileBrowser = filebrowser.NewFileBrowserFromGitClient(gitFactory)
+		} else {
+			scmProvider := scmprovider.ToClient(scmClient, o.server.ClientAgent.BotName)
+			o.server.FileBrowser = filebrowser.NewFileBrowserFromScmClient(scmProvider)
+		}
+	}
 	l, output, err := o.ProcessWebHook(logrus.WithField("Webhook", webhook.Kind()), webhook)
 	if err != nil {
 		responseHTTPError(w, http.StatusInternalServerError, fmt.Sprintf("500 Internal Server Error: %s", err.Error()))
@@ -390,11 +427,18 @@ func (o *WebhooksController) createHookServer() (*Server, error) {
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to parse server URL %s", o.gitServerURL)
 	}
+
+	cache, err := lru.New(5000)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to create in-repo LRU cache")
+	}
+
 	server := &Server{
 		ConfigAgent: configAgent,
 		Plugins:     pluginAgent,
 		Metrics:     promMetrics,
 		ServerURL:   serverURL,
+		InRepoCache: cache,
 		//TokenGenerator: secretAgent.GetTokenGenerator(o.webhookSecretFile),
 	}
 	return server, nil


Test patch:
diff --git a/pkg/filebrowser/git_file_browser_test.go b/pkg/filebrowser/git_file_browser_test.go
new file mode 100644
index 00000000..1b1d7112
--- /dev/null
+++ b/pkg/filebrowser/git_file_browser_test.go
@@ -0,0 +1,95 @@
+package filebrowser
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/lighthouse/pkg/git/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGitFileBrowser(t *testing.T) {
+	const verbose = false
+
+	cf, err := git.NewClientFactory()
+	require.NoError(t, err, "failed to create git client factory")
+	fb := NewFileBrowserFromGitClient(cf)
+
+	owner := "jenkins-x-quickstarts"
+	repo := "node-http"
+	path := "/"
+	ref := "5067522b7ed292bef46570ffe3ab75d3a5428769"
+	fileName := "LICENSE"
+
+	message := func() string {
+		return fmt.Sprintf("for %s/%s path %s ref %s", owner, repo, path, ref)
+	}
+
+	fileMessage := func() string {
+		return message() + " file " + fileName
+	}
+
+	files, err := fb.ListFiles(owner, repo, path, ref)
+	require.NoError(t, err, "failed to list files "+message())
+	assert.NotEmpty(t, files, "should not be empty")
+	for _, f := range files {
+		t.Logf("file %s type %s\n", f.Name, f.Type)
+	}
+
+	data, err := fb.GetFile(owner, repo, fileName, ref)
+	require.NoError(t, err, "failed to get file "+fileMessage())
+	text := string(data)
+
+	if verbose {
+		t.Logf("loaded file %s content: %s\n", path, text)
+	}
+	require.Contains(t, text, "Apache", message())
+
+	// switch the main branch
+	ref = ""
+	fileName = "package.json"
+
+	data, err = fb.GetFile(owner, repo, fileName, ref)
+	require.NoError(t, err, "failed to get file "+fileMessage())
+	text = string(data)
+
+	t.Logf("loaded file %s content: %s\n", path, text)
+	require.Contains(t, text, "server.js", message())
+
+	// switch back to a old sha
+	ref = "5067522b7ed292bef46570ffe3ab75d3a5428769"
+	files, err = fb.ListFiles(owner, repo, path, ref)
+	require.NoError(t, err, "failed to list files "+message())
+	assert.NotEmpty(t, files, "should not be empty")
+	for _, f := range files {
+		t.Logf("file %s type %s\n", f.Name, f.Type)
+	}
+
+	assertNoScmFileExists(t, files, fileName, message())
+
+	ref = ""
+	files, err = fb.ListFiles(owner, repo, path, ref)
+	require.NoError(t, err, "failed to list files "+message())
+	assertScmFileExists(t, files, fileName, message())
+}
+
+func assertScmFileExists(t *testing.T, files []*scm.FileEntry, name, message string) {
+	for _, f := range files {
+		if f.Name == name {
+			t.Logf("includes expected file name %s for %s", name, message)
+			return
+		}
+	}
+	require.Fail(t, "should have found file %s %s", name, message)
+}
+
+func assertNoScmFileExists(t *testing.T, files []*scm.FileEntry, name, message string) {
+	for _, f := range files {
+		if f.Name == name {
+			require.Fail(t, "should not have found file %s %s", name, message)
+		}
+	}
+	t.Logf("correctly does not include file name %s for %s", name, message)
+}
diff --git a/pkg/git/v2/executor_test.go b/pkg/git/v2/executor_test.go
new file mode 100644
index 00000000..df47f0ec
--- /dev/null
+++ b/pkg/git/v2/executor_test.go
@@ -0,0 +1,127 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"bytes"
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/sirupsen/logrus"
+
+	"k8s.io/apimachinery/pkg/util/diff"
+)
+
+func TestCensoringExecutor_Run(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		dir, git    string
+		args        []string
+		censor      Censor
+		executeOut  []byte
+		executeErr  error
+		expectedOut []byte
+		expectedErr bool
+	}{
+		{
+			name: "happy path with nothing to censor returns all output",
+			dir:  "/somewhere/repo",
+			git:  "/usr/bin/git",
+			args: []string{"status"},
+			censor: func(content []byte) []byte {
+				return content
+			},
+			executeOut:  []byte("hi"),
+			executeErr:  nil,
+			expectedOut: []byte("hi"),
+			expectedErr: false,
+		},
+		{
+			name: "happy path with nonstandard git binary",
+			dir:  "/somewhere/repo",
+			git:  "/usr/local/bin/git",
+			args: []string{"status"},
+			censor: func(content []byte) []byte {
+				return content
+			},
+			executeOut:  []byte("hi"),
+			executeErr:  nil,
+			expectedOut: []byte("hi"),
+			expectedErr: false,
+		},
+		{
+			name: "happy path with something to censor returns altered output",
+			dir:  "/somewhere/repo",
+			git:  "/usr/bin/git",
+			args: []string{"status"},
+			censor: func(content []byte) []byte {
+				return bytes.ReplaceAll(content, []byte("secret"), []byte("CENSORED"))
+			},
+			executeOut:  []byte("hi secret"),
+			executeErr:  nil,
+			expectedOut: []byte("hi CENSORED"),
+			expectedErr: false,
+		},
+		{
+			name: "error is propagated",
+			dir:  "/somewhere/repo",
+			git:  "/usr/bin/git",
+			args: []string{"status"},
+			censor: func(content []byte) []byte {
+				return bytes.ReplaceAll(content, []byte("secret"), []byte("CENSORED"))
+			},
+			executeOut:  []byte("hi secret"),
+			executeErr:  errors.New("oops"),
+			expectedOut: []byte("hi CENSORED"),
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := censoringExecutor{
+				logger: logrus.WithField("name", testCase.name),
+				dir:    testCase.dir,
+				git:    testCase.git,
+				censor: testCase.censor,
+				execute: func(dir, command string, args ...string) (bytes []byte, e error) {
+					if dir != testCase.dir {
+						t.Errorf("%s: got incorrect dir: %v", testCase.name, diff.StringDiff(dir, testCase.dir))
+					}
+					if command != testCase.git {
+						t.Errorf("%s: got incorrect command: %v", testCase.name, diff.StringDiff(command, testCase.git))
+					}
+					if !reflect.DeepEqual(args, testCase.args) {
+						t.Errorf("%s: got incorrect args: %v", testCase.name, diff.ObjectReflectDiff(args, testCase.args))
+					}
+					return testCase.executeOut, testCase.executeErr
+				},
+			}
+			actual, actualErr := e.Run(testCase.args...)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if !reflect.DeepEqual(actual, testCase.expectedOut) {
+				t.Errorf("%s: got incorrect command output: %v", testCase.name, diff.StringDiff(string(actual), string(testCase.expectedOut)))
+			}
+		})
+	}
+}
diff --git a/pkg/git/v2/interactor_test.go b/pkg/git/v2/interactor_test.go
new file mode 100644
index 00000000..b274c4bd
--- /dev/null
+++ b/pkg/git/v2/interactor_test.go
@@ -0,0 +1,1743 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"errors"
+	"fmt"
+	"reflect"
+	"testing"
+
+	"github.com/sirupsen/logrus"
+
+	"k8s.io/apimachinery/pkg/util/diff"
+)
+
+func TestInteractor_Clone(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		dir           string
+		from          string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			dir:  "/else",
+			from: "/somewhere",
+			responses: map[string]execResponse{
+				"clone /somewhere /else": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"clone", "/somewhere", "/else"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "clone fails",
+			dir:  "/else",
+			from: "/somewhere",
+			responses: map[string]execResponse{
+				"clone /somewhere /else": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"clone", "/somewhere", "/else"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				dir:      testCase.dir,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.Clone(testCase.from)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_MirrorClone(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		dir           string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			dir:  "/else",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"clone --mirror someone.com /else": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"clone", "--mirror", "someone.com", "/else"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "remote resolution fails",
+			dir:  "/else",
+			remote: func() (string, error) {
+				return "", errors.New("oops")
+			},
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name: "clone fails",
+			dir:  "/else",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"clone --mirror someone.com /else": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"clone", "--mirror", "someone.com", "/else"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				dir:      testCase.dir,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.MirrorClone()
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_Checkout(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		commitlike    string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:       "happy case",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"checkout shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "shasum"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:       "checkout fails",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"checkout shasum": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "shasum"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.Checkout(testCase.commitlike)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_RevParse(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		commitlike    string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedOut   string
+		expectedErr   bool
+	}{
+		{
+			name:       "happy case",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"rev-parse shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"rev-parse", "shasum"},
+			},
+			expectedOut: "ok",
+			expectedErr: false,
+		},
+		{
+			name:       "rev-parse fails",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"rev-parse shasum": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"rev-parse", "shasum"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualOut, actualErr := i.RevParse(testCase.commitlike)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+			if actualOut != testCase.expectedOut {
+				t.Errorf("%s: got incorrect output: expected %v, got %v", testCase.name, testCase.expectedOut, actualOut)
+			}
+		})
+	}
+}
+
+func TestInteractor_BranchExists(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		branch        string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedOut   bool
+	}{
+		{
+			name:   "happy case",
+			branch: "branch",
+			responses: map[string]execResponse{
+				"ls-remote --exit-code --heads origin branch": {
+					out: []byte(`c165713776618ff3162643ea4d0382ca039adfeb	refs/heads/branch`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"ls-remote", "--exit-code", "--heads", "origin", "branch"},
+			},
+			expectedOut: true,
+		},
+		{
+			name:   "ls-remote fails",
+			branch: "branch",
+			responses: map[string]execResponse{
+				"ls-remote --exit-code --heads origin branch": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"ls-remote", "--exit-code", "--heads", "origin", "branch"},
+			},
+			expectedOut: false,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualOut := i.BranchExists(testCase.branch)
+			if testCase.expectedOut != actualOut {
+				t.Errorf("%s: got incorrect output: expected %v, got %v", testCase.name, testCase.expectedOut, actualOut)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_CheckoutNewBranch(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		branch        string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:   "happy case",
+			branch: "new-branch",
+			responses: map[string]execResponse{
+				"checkout -b new-branch": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "-b", "new-branch"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:   "checkout fails",
+			branch: "new-branch",
+			responses: map[string]execResponse{
+				"checkout -b new-branch": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "-b", "new-branch"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.CheckoutNewBranch(testCase.branch)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_Merge(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		commitlike    string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedMerge bool
+		expectedErr   bool
+	}{
+		{
+			name:       "happy case",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m merge shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "shasum"},
+			},
+			expectedMerge: true,
+			expectedErr:   false,
+		},
+		{
+			name:       "merge fails but abort succeeds",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m merge shasum": {
+					err: errors.New("oops"),
+				},
+				"merge --abort": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "shasum"},
+				{"merge", "--abort"},
+			},
+			expectedMerge: false,
+			expectedErr:   false,
+		},
+		{
+			name:       "merge fails and abort fails",
+			commitlike: "shasum",
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m merge shasum": {
+					err: errors.New("oops"),
+				},
+				"merge --abort": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "shasum"},
+				{"merge", "--abort"},
+			},
+			expectedMerge: false,
+			expectedErr:   true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualMerge, actualErr := i.Merge(testCase.commitlike)
+			if testCase.expectedMerge != actualMerge {
+				t.Errorf("%s: got incorrect output: expected %v, got %v", testCase.name, testCase.expectedMerge, actualMerge)
+			}
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_MergeWithStrategy(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		commitlike    string
+		strategy      string
+		opts          []MergeOpt
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedMerge bool
+		expectedErr   bool
+	}{
+		{
+			name:       "happy merge case",
+			commitlike: "shasum",
+			strategy:   "merge",
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m merge shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "shasum"},
+			},
+			expectedMerge: true,
+			expectedErr:   false,
+		},
+		{
+			name:       "happy merge case with options",
+			commitlike: "shasum",
+			strategy:   "merge",
+			opts:       []MergeOpt{{CommitMessage: "message"}},
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m message shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "message", "shasum"},
+			},
+			expectedMerge: true,
+			expectedErr:   false,
+		},
+		{
+			name:       "happy merge case with multi words message",
+			commitlike: "shasum",
+			strategy:   "merge",
+			opts:       []MergeOpt{{CommitMessage: "my happy merge message"}},
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m my happy merge message shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "my happy merge message", "shasum"},
+			},
+			expectedMerge: true,
+			expectedErr:   false,
+		},
+		{
+			name:       "happy merge case with multiple options with single/multi words message",
+			commitlike: "shasum",
+			strategy:   "merge",
+			opts: []MergeOpt{
+				{CommitMessage: "my"},
+				{CommitMessage: "happy merge"},
+				{CommitMessage: "message"},
+			},
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m my -m happy merge -m message shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "my", "-m", "happy merge", "-m", "message", "shasum"},
+			},
+			expectedMerge: true,
+			expectedErr:   false,
+		},
+		{
+			name:       "happy squash case",
+			commitlike: "shasum",
+			strategy:   "squash",
+			responses: map[string]execResponse{
+				"merge --squash --no-stat shasum": {
+					out: []byte(`ok`),
+				},
+				"commit --no-stat -m merge": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--squash", "--no-stat", "shasum"},
+				{"commit", "--no-stat", "-m", "merge"},
+			},
+			expectedMerge: true,
+			expectedErr:   false,
+		},
+		{
+			name:          "invalid strategy",
+			commitlike:    "shasum",
+			strategy:      "whatever",
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedMerge: false,
+			expectedErr:   true,
+		},
+		{
+			name:       "merge fails but abort succeeds",
+			commitlike: "shasum",
+			strategy:   "merge",
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m merge shasum": {
+					err: errors.New("oops"),
+				},
+				"merge --abort": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "shasum"},
+				{"merge", "--abort"},
+			},
+			expectedMerge: false,
+			expectedErr:   false,
+		},
+		{
+			name:       "merge fails and abort fails",
+			commitlike: "shasum",
+			strategy:   "merge",
+			responses: map[string]execResponse{
+				"merge --no-ff --no-stat -m merge shasum": {
+					err: errors.New("oops"),
+				},
+				"merge --abort": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "shasum"},
+				{"merge", "--abort"},
+			},
+			expectedMerge: false,
+			expectedErr:   true,
+		},
+		{
+			name:       "squash merge fails but abort succeeds",
+			commitlike: "shasum",
+			strategy:   "squash",
+			responses: map[string]execResponse{
+				"merge --squash --no-stat shasum": {
+					err: errors.New("oops"),
+				},
+				"reset --hard HEAD": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--squash", "--no-stat", "shasum"},
+				{"reset", "--hard", "HEAD"},
+			},
+			expectedMerge: false,
+			expectedErr:   false,
+		},
+		{
+			name:       "squash merge fails and abort fails",
+			commitlike: "shasum",
+			strategy:   "squash",
+			responses: map[string]execResponse{
+				"merge --squash --no-stat shasum": {
+					err: errors.New("oops"),
+				},
+				"reset --hard HEAD": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--squash", "--no-stat", "shasum"},
+				{"reset", "--hard", "HEAD"},
+			},
+			expectedMerge: false,
+			expectedErr:   true,
+		},
+		{
+			name:       "squash merge staging succeeds, commit fails and abort succeeds",
+			commitlike: "shasum",
+			strategy:   "squash",
+			responses: map[string]execResponse{
+				"merge --squash --no-stat shasum": {
+					out: []byte(`ok`),
+				},
+				"commit --no-stat -m merge": {
+					err: errors.New("oops"),
+				},
+				"reset --hard HEAD": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--squash", "--no-stat", "shasum"},
+				{"commit", "--no-stat", "-m", "merge"},
+				{"reset", "--hard", "HEAD"},
+			},
+			expectedMerge: false,
+			expectedErr:   false,
+		},
+		{
+			name:       "squash merge staging succeeds, commit fails and abort fails",
+			commitlike: "shasum",
+			strategy:   "squash",
+			responses: map[string]execResponse{
+				"merge --squash --no-stat shasum": {
+					out: []byte(`ok`),
+				},
+				"commit --no-stat -m merge": {
+					err: errors.New("oops"),
+				},
+				"reset --hard HEAD": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"merge", "--squash", "--no-stat", "shasum"},
+				{"commit", "--no-stat", "-m", "merge"},
+				{"reset", "--hard", "HEAD"},
+			},
+			expectedMerge: false,
+			expectedErr:   true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualMerge, actualErr := i.MergeWithStrategy(testCase.commitlike, testCase.strategy, testCase.opts...)
+			if testCase.expectedMerge != actualMerge {
+				t.Errorf("%s: got incorrect output: expected %v, got %v", testCase.name, testCase.expectedMerge, actualMerge)
+			}
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_MergeAndCheckout(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		baseSHA       string
+		commitlikes   []string
+		strategy      string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:        "happy do nothing case",
+			baseSHA:     "base",
+			commitlikes: []string{},
+			strategy:    "merge",
+			responses: map[string]execResponse{
+				"checkout base": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "base"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:        "happy merge case",
+			baseSHA:     "base",
+			commitlikes: []string{"first", "second"},
+			strategy:    "merge",
+			responses: map[string]execResponse{
+				"checkout base": {
+					out: []byte(`ok`),
+				},
+				"merge --no-ff --no-stat -m merge first": {
+					out: []byte(`ok`),
+				},
+				"merge --no-ff --no-stat -m merge second": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "base"},
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "first"},
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "second"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:        "happy squash case",
+			baseSHA:     "base",
+			commitlikes: []string{"first", "second"},
+			strategy:    "squash",
+			responses: map[string]execResponse{
+				"checkout base": {
+					out: []byte(`ok`),
+				},
+				"merge --squash --no-stat first": {
+					out: []byte(`ok`),
+				},
+				"commit --no-stat -m merge": {
+					out: []byte(`ok`),
+				},
+				"merge --squash --no-stat second": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "base"},
+				{"merge", "--squash", "--no-stat", "first"},
+				{"commit", "--no-stat", "-m", "merge"},
+				{"merge", "--squash", "--no-stat", "second"},
+				{"commit", "--no-stat", "-m", "merge"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:          "invalid strategy",
+			commitlikes:   []string{"shasum"},
+			strategy:      "whatever",
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name:        "checkout fails",
+			baseSHA:     "base",
+			commitlikes: []string{"first", "second"},
+			strategy:    "squash",
+			responses: map[string]execResponse{
+				"checkout base": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "base"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:        "merge fails but abort succeeds",
+			baseSHA:     "base",
+			commitlikes: []string{"first", "second"},
+			strategy:    "merge",
+			responses: map[string]execResponse{
+				"checkout base": {
+					out: []byte(`ok`),
+				},
+				"merge --no-ff --no-stat -m merge first": {
+					err: errors.New("oops"),
+				},
+				"merge --abort": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "base"},
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "first"},
+				{"merge", "--abort"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:        "merge fails and abort fails",
+			baseSHA:     "base",
+			commitlikes: []string{"first", "second"},
+			strategy:    "merge",
+			responses: map[string]execResponse{
+				"checkout base": {
+					out: []byte(`ok`),
+				},
+				"merge --no-ff --no-stat -m merge first": {
+					err: errors.New("oops"),
+				},
+				"merge --abort": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"checkout", "base"},
+				{"merge", "--no-ff", "--no-stat", "-m", "merge", "first"},
+				{"merge", "--abort"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.MergeAndCheckout(testCase.baseSHA, testCase.strategy, testCase.commitlikes...)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_Am(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		path          string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			path: "my/changes.patch",
+			responses: map[string]execResponse{
+				"am --3way my/changes.patch": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"am", "--3way", "my/changes.patch"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "am fails but abort succeeds",
+			path: "my/changes.patch",
+			responses: map[string]execResponse{
+				"am --3way my/changes.patch": {
+					err: errors.New("oops"),
+				},
+				"am --abort": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"am", "--3way", "my/changes.patch"},
+				{"am", "--abort"},
+			},
+			expectedErr: true,
+		},
+		{
+			name: "am fails and abort fails",
+			path: "my/changes.patch",
+			responses: map[string]execResponse{
+				"am --3way my/changes.patch": {
+					err: errors.New("oops"),
+				},
+				"am --abort": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"am", "--3way", "my/changes.patch"},
+				{"am", "--abort"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.Am(testCase.path)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_RemoteUpdate(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			responses: map[string]execResponse{
+				"remote update": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"remote", "update"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "update fails",
+			responses: map[string]execResponse{
+				"remote update": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"remote", "update"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.RemoteUpdate()
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_Fetch(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "remote resolution fails",
+			remote: func() (string, error) {
+				return "", errors.New("oops")
+			},
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name: "fetch fails",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.Fetch()
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_FetchRef(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		refspec       string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:    "happy case",
+			refspec: "shasum",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com shasum": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com", "shasum"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:    "remote resolution fails",
+			refspec: "shasum",
+			remote: func() (string, error) {
+				return "", errors.New("oops")
+			},
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name:    "fetch fails",
+			refspec: "shasum",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com shasum": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com", "shasum"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.FetchRef(testCase.refspec)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_FetchFromRemote(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		remote        RemoteResolver
+		toRemote      RemoteResolver
+		branch        string
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "fetch from different remote without token",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			toRemote: func() (string, error) {
+				return "https://github.com/kubernetes/test-infra-fork", nil
+			},
+			branch: "test-branch",
+			responses: map[string]execResponse{
+				"fetch https://github.com/kubernetes/test-infra-fork test-branch": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "https://github.com/kubernetes/test-infra-fork", "test-branch"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "fetch from different remote with token",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			toRemote: func() (string, error) {
+				return "https://user:pass@github.com/kubernetes/test-infra-fork", nil
+			},
+			branch: "test-branch",
+			responses: map[string]execResponse{
+				"fetch https://user:pass@github.com/kubernetes/test-infra-fork test-branch": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "https://user:pass@github.com/kubernetes/test-infra-fork", "test-branch"},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "passing non-valid remote",
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			toRemote: func() (string, error) {
+				return "", fmt.Errorf("non-valid URL")
+			},
+			branch:        "test-branch",
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+
+			actualErr := i.FetchFromRemote(testCase.toRemote, testCase.branch)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_CheckoutPullRequest(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		number        int
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:   "happy case",
+			number: 1,
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com pull/1/head": {
+					out: []byte(`ok`),
+				},
+				"checkout FETCH_HEAD": {
+					out: []byte(`ok`),
+				},
+				"checkout -b pull1": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com", "pull/1/head"},
+				{"checkout", "FETCH_HEAD"},
+				{"checkout", "-b", "pull1"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:   "remote resolution fails",
+			number: 1,
+			remote: func() (string, error) {
+				return "", errors.New("oops")
+			},
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name:   "fetch fails",
+			number: 1,
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com pull/1/head": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com", "pull/1/head"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:   "checkout fails",
+			number: 1,
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com pull/1/head": {
+					out: []byte(`ok`),
+				},
+				"checkout FETCH_HEAD": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com", "pull/1/head"},
+				{"checkout", "FETCH_HEAD"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:   "branch fails",
+			number: 1,
+			remote: func() (string, error) {
+				return "someone.com", nil
+			},
+			responses: map[string]execResponse{
+				"fetch someone.com pull/1/head": {
+					out: []byte(`ok`),
+				},
+				"checkout FETCH_HEAD": {
+					out: []byte(`ok`),
+				},
+				"checkout -b pull1": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"fetch", "someone.com", "pull/1/head"},
+				{"checkout", "FETCH_HEAD"},
+				{"checkout", "-b", "pull1"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.CheckoutPullRequest(testCase.number)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_Config(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		key, value    string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:  "happy case",
+			key:   "key",
+			value: "value",
+			responses: map[string]execResponse{
+				"config key value": {
+					out: []byte(`ok`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"config", "key", "value"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:  "config fails",
+			key:   "key",
+			value: "value",
+			responses: map[string]execResponse{
+				"config key value": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"config", "key", "value"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := i.Config(testCase.key, testCase.value)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_Diff(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		head, sha     string
+		remote        RemoteResolver
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedOut   []string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			head: "head",
+			sha:  "sha",
+			responses: map[string]execResponse{
+				"diff head sha --name-only": {
+					out: []byte(`prow/git/v2/client_factory.go
+prow/git/v2/Executor.go
+prow/git/v2/executor_test.go
+prow/git/v2/fakes.go
+prow/git/v2/interactor.go
+prow/git/v2/publisher.go
+prow/git/v2/publisher_test.go
+prow/git/v2/remote.go
+prow/git/v2/remote_test.go`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"diff", "head", "sha", "--name-only"},
+			},
+			expectedOut: []string{
+				"prow/git/v2/client_factory.go",
+				"prow/git/v2/Executor.go",
+				"prow/git/v2/executor_test.go",
+				"prow/git/v2/fakes.go",
+				"prow/git/v2/interactor.go",
+				"prow/git/v2/publisher.go",
+				"prow/git/v2/publisher_test.go",
+				"prow/git/v2/remote.go",
+				"prow/git/v2/remote_test.go",
+			},
+			expectedErr: false,
+		},
+		{
+			name: "config fails",
+			head: "head",
+			sha:  "sha",
+			responses: map[string]execResponse{
+				"diff head sha --name-only": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"diff", "head", "sha", "--name-only"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				remote:   testCase.remote,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualOut, actualErr := i.Diff(testCase.head, testCase.sha)
+			if !reflect.DeepEqual(actualOut, testCase.expectedOut) {
+				t.Errorf("%s: got incorrect output: %v", testCase.name, diff.ObjectReflectDiff(actualOut, testCase.expectedOut))
+			}
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_MergeCommitsExistBetween(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		target, head  string
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedOut   bool
+		expectedErr   bool
+	}{
+		{
+			name:   "happy case and merges exist",
+			target: "target",
+			head:   "head",
+			responses: map[string]execResponse{
+				"log target..head --oneline --merges": {
+					out: []byte(`8df5654e6 Merge pull request #14911 from mborsz/etcd
+96cbeee23 Merge pull request #14755 from justinsb/the_life_changing_magic_of_tidying_up`),
+				},
+			},
+			expectedCalls: [][]string{
+				{"log", "target..head", "--oneline", "--merges"},
+			},
+			expectedOut: true,
+			expectedErr: false,
+		},
+		{
+			name:   "happy case and merges don't exist",
+			target: "target",
+			head:   "head",
+			responses: map[string]execResponse{
+				"log target..head --oneline --merges": {
+					out: []byte(``),
+				},
+			},
+			expectedCalls: [][]string{
+				{"log", "target..head", "--oneline", "--merges"},
+			},
+			expectedOut: false,
+			expectedErr: false,
+		},
+		{
+			name:   "log fails",
+			target: "target",
+			head:   "head",
+			responses: map[string]execResponse{
+				"log target..head --oneline --merges": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"log", "target..head", "--oneline", "--merges"},
+			},
+			expectedOut: false,
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualOut, actualErr := i.MergeCommitsExistBetween(testCase.target, testCase.head)
+			if testCase.expectedOut != actualOut {
+				t.Errorf("%s: got incorrect output: expected %v, got %v", testCase.name, testCase.expectedOut, actualOut)
+			}
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestInteractor_ShowRef(t *testing.T) {
+	const target = "some-branch"
+	var testCases = []struct {
+		name          string
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name: "happy case",
+			responses: map[string]execResponse{
+				"show-ref -s some-branch": {out: []byte("32d3f5a6826109c625527f18a59f2e7144a330b6\n")},
+			},
+			expectedCalls: [][]string{
+				{"show-ref", "-s", target},
+			},
+			expectedErr: false,
+		},
+		{
+			name: "unhappy case",
+			responses: map[string]execResponse{
+				"git show-ref -s some-undef-branch": {err: errors.New("some-err")},
+			},
+			expectedCalls: [][]string{
+				{"show-ref", "-s", target},
+			},
+			expectedErr: true,
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			i := interactor{
+				executor: &e,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			_, actualErr := i.ShowRef(target)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
diff --git a/pkg/git/v2/publisher_test.go b/pkg/git/v2/publisher_test.go
new file mode 100644
index 00000000..cb7201f0
--- /dev/null
+++ b/pkg/git/v2/publisher_test.go
@@ -0,0 +1,348 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/util/diff"
+)
+
+func TestPublisher_Commit(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		title, body   string
+		info          UserGetter
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:  "no errors works fine",
+			title: "title",
+			body:  `body\nmore`,
+			info: func() (string, string, error) {
+				return "robot", "boop@beep.zoop", nil
+			},
+			responses: map[string]execResponse{
+				"add --all": {
+					out: []byte("ok"),
+				},
+				`commit --message title --message body\nmore --author robot <boop@beep.zoop>`: {
+					out: []byte("ok"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"add", "--all"},
+				{"commit", "--message", "title", "--message", `body\nmore`, "--author", "robot <boop@beep.zoop>"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:  "add fails",
+			title: "title",
+			body:  `body\nmore`,
+			info: func() (string, string, error) {
+				return "robot", "boop@beep.zoop", nil
+			},
+			responses: map[string]execResponse{
+				"add --all": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"add", "--all"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:  "info fails",
+			title: "title",
+			body:  `body\nmore`,
+			info: func() (string, string, error) {
+				return "", "", errors.New("oops")
+			},
+			responses:     map[string]execResponse{},
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name:  "commit fails",
+			title: "title",
+			body:  `body\nmore`,
+			info: func() (string, string, error) {
+				return "robot", "boop@beep.zoop", nil
+			},
+			responses: map[string]execResponse{
+				"add --all": {
+					out: []byte("ok"),
+				},
+				`commit --message title --message body\nmore --author robot <boop@beep.zoop>`: {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"add", "--all"},
+				{"commit", "--message", "title", "--message", `body\nmore`, "--author", "robot <boop@beep.zoop>"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			p := publisher{
+				executor: &e,
+				info:     testCase.info,
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := p.Commit(testCase.title, testCase.body)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestPublisher_PushToFork(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		branch        string
+		remote        string
+		force         bool
+		resolveErr    error
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:   "no errors, no force",
+			branch: "master",
+			remote: "http.com",
+			force:  false,
+			responses: map[string]execResponse{
+				"push http.com master": {
+					out: []byte("ok"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "http.com", "master"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:   "no errors with force",
+			branch: "master",
+			remote: "http.com",
+			force:  true,
+			responses: map[string]execResponse{
+				"push --force http.com master": {
+					out: []byte("ok"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "--force", "http.com", "master"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:          "error resolving remote makes no calls",
+			branch:        "master",
+			resolveErr:    errors.New("oops"),
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name:   "errors pushing propagates, without force",
+			branch: "master",
+			remote: "http.com",
+			responses: map[string]execResponse{
+				"push http.com master": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "http.com", "master"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:   "errors pushing propagates, with force",
+			branch: "master",
+			remote: "http.com",
+			force:  true,
+			responses: map[string]execResponse{
+				"push --force http.com master": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "--force", "http.com", "master"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			r := fakeResolver{
+				out: testCase.remote,
+				err: testCase.resolveErr,
+			}
+			p := publisher{
+				executor: &e,
+				remotes:  remotes{publishRemote: r.Resolve},
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := p.PushToFork(testCase.branch, testCase.force)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
+
+func TestPublisher_PushToCentral(t *testing.T) {
+	var testCases = []struct {
+		name          string
+		branch        string
+		remote        string
+		force         bool
+		resolveErr    error
+		responses     map[string]execResponse
+		expectedCalls [][]string
+		expectedErr   bool
+	}{
+		{
+			name:   "no errors, no force",
+			branch: "master",
+			remote: "http.com",
+			force:  false,
+			responses: map[string]execResponse{
+				"push http.com master": {
+					out: []byte("ok"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "http.com", "master"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:   "no errors with force",
+			branch: "master",
+			remote: "http.com",
+			force:  true,
+			responses: map[string]execResponse{
+				"push --force http.com master": {
+					out: []byte("ok"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "--force", "http.com", "master"},
+			},
+			expectedErr: false,
+		},
+		{
+			name:          "error resolving remote makes no calls",
+			branch:        "master",
+			resolveErr:    errors.New("oops"),
+			expectedCalls: [][]string{},
+			expectedErr:   true,
+		},
+		{
+			name:   "errors pushing propagates, without force",
+			branch: "master",
+			remote: "http.com",
+			responses: map[string]execResponse{
+				"push http.com master": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "http.com", "master"},
+			},
+			expectedErr: true,
+		},
+		{
+			name:   "errors pushing propagates, with force",
+			branch: "master",
+			remote: "http.com",
+			force:  true,
+			responses: map[string]execResponse{
+				"push --force http.com master": {
+					err: errors.New("oops"),
+				},
+			},
+			expectedCalls: [][]string{
+				{"push", "--force", "http.com", "master"},
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			e := fakeExecutor{
+				records:   [][]string{},
+				responses: testCase.responses,
+			}
+			r := fakeResolver{
+				out: testCase.remote,
+				err: testCase.resolveErr,
+			}
+			p := publisher{
+				executor: &e,
+				remotes:  remotes{centralRemote: r.Resolve},
+				logger:   logrus.WithField("test", testCase.name),
+			}
+			actualErr := p.PushToCentral(testCase.branch, testCase.force)
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual, expected := e.records, testCase.expectedCalls; !reflect.DeepEqual(actual, expected) {
+				t.Errorf("%s: got incorrect git calls: %v", testCase.name, diff.ObjectReflectDiff(actual, expected))
+			}
+		})
+	}
+}
diff --git a/pkg/git/v2/remote_test.go b/pkg/git/v2/remote_test.go
new file mode 100644
index 00000000..10c9488e
--- /dev/null
+++ b/pkg/git/v2/remote_test.go
@@ -0,0 +1,226 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+	"errors"
+	"net/url"
+	"reflect"
+	"testing"
+
+	"k8s.io/apimachinery/pkg/util/diff"
+)
+
+func TestHTTPResolver(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		remote      func() (*url.URL, error)
+		username    LoginGetter
+		token       TokenGetter
+		expected    string
+		expectedErr bool
+	}{
+		{
+			name: "happy case works",
+			remote: func() (*url.URL, error) {
+				return &url.URL{
+					Scheme: "https",
+					Host:   "github.com",
+					Path:   "org/repo",
+				}, nil
+			},
+			username: func() (string, error) {
+				return "gitUser", nil
+			},
+			token: func() []byte {
+				return []byte("pass")
+			},
+			expected:    "https://gitUser:pass@github.com/org/repo",
+			expectedErr: false,
+		},
+		{
+			name: "failure to resolve remote URL creates error",
+			remote: func() (*url.URL, error) {
+				return nil, errors.New("oops")
+			},
+			expectedErr: true,
+		},
+		{
+			name: "failure to get username creates error",
+			remote: func() (*url.URL, error) {
+				return nil, nil
+			},
+			username: func() (string, error) {
+				return "", errors.New("oops")
+			},
+			expectedErr: true,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			actual, actualErr := HTTPResolver(testCase.remote, testCase.username, testCase.token)()
+			if testCase.expectedErr && actualErr == nil {
+				t.Errorf("%s: expected an error but got none", testCase.name)
+			}
+			if !testCase.expectedErr && actualErr != nil {
+				t.Errorf("%s: expected no error but got one: %v", testCase.name, actualErr)
+			}
+			if actual != testCase.expected {
+				t.Errorf("%s: got incorrect remote URL: %v", testCase.name, diff.StringDiff(actual, testCase.expected))
+			}
+		})
+	}
+}
+
+type stringWithError struct {
+	str string
+	err error
+}
+
+// usernameVendor returns a func that vends logins and errors from the provided slices, in order
+func usernameVendor(logins []stringWithError) func() (string, error) {
+	index := 0
+	return func() (string, error) {
+		item := logins[index%len(logins)]
+		index++
+		return item.str, item.err
+	}
+}
+
+// tokenVendor returns a func that vends tokens from the provided slice, in order
+func tokenVendor(tokens [][]byte) func() []byte {
+	index := 0
+	return func() []byte {
+		token := tokens[index%len(tokens)]
+		index++
+		return token
+	}
+}
+
+func TestSSHRemoteResolverFactory(t *testing.T) {
+	factory := sshRemoteResolverFactory{
+		host: "ssh.host.com",
+		username: usernameVendor([]stringWithError{
+			{str: "first", err: nil},
+			{str: "second", err: errors.New("oops")},
+			{str: "third", err: nil},
+		}),
+	}
+
+	central := factory.CentralRemote("org", "repo")
+	for i, expected := range []stringWithError{
+		{str: "git@ssh.host.com:org/repo.git", err: nil},
+		{str: "git@ssh.host.com:org/repo.git", err: nil},
+		{str: "git@ssh.host.com:org/repo.git", err: nil},
+	} {
+		actualRemote, actualErr := central()
+		if actualRemote != expected.str {
+			t.Errorf("central remote test %d returned incorrect remote, expected %v got %v", i, expected.str, actualRemote)
+		}
+		if !reflect.DeepEqual(actualErr, expected.err) {
+			t.Errorf("central remote test %d returned incorrect error, expected %v got %v", i, expected.err, actualErr)
+		}
+	}
+
+	publish := factory.PublishRemote("org", "repo")
+	for i, expected := range []stringWithError{
+		{str: "git@ssh.host.com:first/repo.git", err: nil},
+		{str: "", err: errors.New("oops")},
+		{str: "git@ssh.host.com:third/repo.git", err: nil},
+	} {
+		actualRemote, actualErr := publish()
+		if actualRemote != expected.str {
+			t.Errorf("publish remote test %d returned incorrect remote, expected %v got %v", i, expected.str, actualRemote)
+		}
+		if !reflect.DeepEqual(actualErr, expected.err) {
+			t.Errorf("publish remote test %d returned incorrect error, expected %v got %v", i, expected.err, actualErr)
+		}
+	}
+}
+
+func TestHTTPResolverFactory_NoAuth(t *testing.T) {
+	t.Run("CentralRemote", func(t *testing.T) {
+		expected := "https://some-host.com/org/repo"
+		res, err := (&httpResolverFactory{host: "some-host.com"}).CentralRemote("org", "repo")()
+		if err != nil {
+			t.Fatalf("CentralRemote: %v", err)
+		}
+		if res != expected {
+			t.Errorf("Expected result to be %s, was %s", expected, res)
+		}
+	})
+
+	t.Run("PublishRemote", func(t *testing.T) {
+		expectedErr := "could not resolve remote: username not configured, no publish repo available"
+		_, err := (&httpResolverFactory{host: "some-host.com"}).PublishRemote("org", "repo")()
+		if err == nil || err.Error() != expectedErr {
+			t.Errorf("expectedErr to be %s, was %v", expectedErr, err)
+		}
+	})
+}
+
+func TestHTTPResolverFactory(t *testing.T) {
+	factory := httpResolverFactory{
+		host: "host.com",
+		username: usernameVendor([]stringWithError{
+			{str: "first", err: nil},
+			{str: "second", err: errors.New("oops")},
+			{str: "third", err: nil},
+			// this is called twice for publish remote resolution
+			{str: "first", err: nil},
+			{str: "first", err: nil},
+			{str: "second", err: errors.New("oops")},
+			{str: "third", err: nil},
+			{str: "third", err: nil},
+			{str: "fourth", err: nil},
+			{str: "fourth", err: errors.New("oops")},
+		}),
+		token: tokenVendor([][]byte{[]byte("one"), []byte("three")}), // only called when username succeeds
+	}
+
+	central := factory.CentralRemote("org", "repo")
+	for i, expected := range []stringWithError{
+		{str: "https://first:one@host.com/org/repo", err: nil},
+		{str: "", err: errors.New("could not resolve username: oops")},
+		{str: "https://third:three@host.com/org/repo", err: nil},
+	} {
+		actualRemote, actualErr := central()
+		if actualRemote != expected.str {
+			t.Errorf("central remote test %d returned incorrect remote, expected %v got %v", i, expected.str, actualRemote)
+		}
+		if !reflect.DeepEqual(actualErr, expected.err) {
+			t.Errorf("central remote test %d returned incorrect error, expected %v got %v", i, expected.err, actualErr)
+		}
+	}
+
+	publish := factory.PublishRemote("org", "repo")
+	for i, expected := range []stringWithError{
+		{str: "https://first:one@host.com/first/repo", err: nil},
+		{str: "", err: errors.New("could not resolve remote: oops")},
+		{str: "https://third:three@host.com/third/repo", err: nil},
+		{str: "", err: errors.New("could not resolve username: oops")},
+	} {
+		actualRemote, actualErr := publish()
+		if actualRemote != expected.str {
+			t.Errorf("publish remote test %d returned incorrect remote, expected %v got %v", i, expected.str, actualRemote)
+		}
+		if !reflect.DeepEqual(actualErr, expected.err) {
+			t.Errorf("publish remote test %d returned incorrect error, expected %v got %v", i, expected.err, actualErr)
+		}
+	}
+}
diff --git a/pkg/triggerconfig/inrepo/calculate_test.go b/pkg/triggerconfig/inrepo/calculate_test.go
index 521e660c..07129d8e 100644
--- a/pkg/triggerconfig/inrepo/calculate_test.go
+++ b/pkg/triggerconfig/inrepo/calculate_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/jenkins-x/lighthouse/pkg/config"
 	"github.com/jenkins-x/lighthouse/pkg/config/job"
 	"github.com/jenkins-x/lighthouse/pkg/config/lighthouse"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig/inrepo"
@@ -44,7 +45,9 @@ func TestCalculate(t *testing.T) {
 	}
 	sharedPluginConfig := &plugins.Configuration{}
 
-	cfg, pluginsCfg, err := inrepo.Generate(scmProvider, sharedConfig, sharedPluginConfig, owner, repo, ref)
+	fileBrowser := filebrowser.NewFileBrowserFromScmClient(scmProvider)
+
+	cfg, pluginsCfg, err := inrepo.Generate(fileBrowser, sharedConfig, sharedPluginConfig, owner, repo, ref)
 	require.NoError(t, err, "failed to calculate in repo config")
 
 	require.NoError(t, err, "failed to invoke getClientAndTrigger")
@@ -121,7 +124,8 @@ func TestTriggersInBranchMergeToMaster(t *testing.T) {
 	}
 	sharedPluginConfig := &plugins.Configuration{}
 
-	cfg, _, err := inrepo.Generate(scmProvider, sharedConfig, sharedPluginConfig, owner, repo, ref)
+	fileBrowser := filebrowser.NewFileBrowserFromScmClient(scmProvider)
+	cfg, _, err := inrepo.Generate(fileBrowser, sharedConfig, sharedPluginConfig, owner, repo, ref)
 	require.NoError(t, err, "failed to calculate in repo config")
 
 	presubmits := cfg.Presubmits[fullName]
diff --git a/pkg/triggerconfig/inrepo/load_integration_test.go b/pkg/triggerconfig/inrepo/load_integration_test.go
index e5808f2a..7678df83 100644
--- a/pkg/triggerconfig/inrepo/load_integration_test.go
+++ b/pkg/triggerconfig/inrepo/load_integration_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/jenkins-x/go-scm/scm/factory"
 	"github.com/jenkins-x/lighthouse/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig/inrepo"
@@ -29,7 +30,8 @@ func TestMergeConfigIntegration(t *testing.T) {
 	cfg := &config.Config{}
 	pluginCfg := &plugins.Configuration{}
 
-	flag, err := inrepo.MergeTriggers(cfg, pluginCfg, scmProvider, repoOwner, repoName, sha)
+	fileBrowser := filebrowser.NewFileBrowserFromScmClient(scmProvider)
+	flag, err := inrepo.MergeTriggers(cfg, pluginCfg, fileBrowser, repoOwner, repoName, sha)
 	require.NoError(t, err, "failed to merge configs")
 	assert.True(t, flag, "did not return merge flag")
 
diff --git a/pkg/triggerconfig/inrepo/load_triggers_test.go b/pkg/triggerconfig/inrepo/load_triggers_test.go
index d732d87f..dc5a319e 100644
--- a/pkg/triggerconfig/inrepo/load_triggers_test.go
+++ b/pkg/triggerconfig/inrepo/load_triggers_test.go
@@ -6,6 +6,7 @@ import (
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/jenkins-x/go-scm/scm/driver/fake"
 	"github.com/jenkins-x/lighthouse/pkg/config"
+	"github.com/jenkins-x/lighthouse/pkg/filebrowser"
 	"github.com/jenkins-x/lighthouse/pkg/plugins"
 	"github.com/jenkins-x/lighthouse/pkg/scmprovider"
 	"github.com/jenkins-x/lighthouse/pkg/triggerconfig/inrepo"
@@ -33,7 +34,8 @@ func TestMergeConfig(t *testing.T) {
 
 	cfg := &config.Config{}
 	pluginCfg := &plugins.Configuration{}
-	flag, err := inrepo.MergeTriggers(cfg, pluginCfg, scmProvider, owner, repo, ref)
+	fileBrowser := filebrowser.NewFileBrowserFromScmClient(scmProvider)
+	flag, err := inrepo.MergeTriggers(cfg, pluginCfg, fileBrowser, owner, repo, ref)
 	require.NoError(t, err, "failed to merge configs")
 	assert.True(t, flag, "did not return merge flag")
 
@@ -47,12 +49,13 @@ func TestMergeConfig(t *testing.T) {
 func TestInvalidConfigs(t *testing.T) {
 	scmClient, _ := fake.NewDefault()
 	scmProvider := scmprovider.ToClient(scmClient, "my-bot")
+	fileBrowser := filebrowser.NewFileBrowserFromScmClient(scmProvider)
 
 	invalidRepos := []string{"duplicate-presubmit", "duplicate-postsubmit"}
 	for _, repo := range invalidRepos {
 		owner := "myorg"
 		ref := "master"
-		_, err := inrepo.LoadTriggerConfig(scmProvider, owner, repo, ref)
+		_, err := inrepo.LoadTriggerConfig(fileBrowser, owner, repo, ref)
 		require.Errorf(t, err, "should have failed to load triggers from repo %s/%s with ref %s", owner, repo, ref)
 
 		t.Logf("got expected error loading invalid configuration on repo %s of: %s", repo, err.Error())