Repository: runatlantis/atlantis (language: go)

Issue:
fix: parallel plan and apply also in a single workspace
## what

- Add more thorough locking around Clone() calls, covering all of these phases:
  - Am I on the right commit
  - Merge with upstream
  - Clone if necessary
- Reduce the number of remote git operations when planning or applying in parallel
- Clean up the Clone() method, split into Clone() and MergeAgain()

For parallel mode to work, you must either set the environment variable TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE to something, or check in your .hcl files. Otherwise terraform cannot run in parallel.

## why

The Clone call had several race conditions where it could miss clones or delete the working directory under running processes causing failures.

## tests

- [X] I have tested my changes by make test-all
- [X] Run in production with several repos, large and small, including a monorepo multi-directory setup

## references

<!--
- Link to any supporting github issues or helpful documentation to add some context (e.g. stackoverflow). 
- Use `closes #123`, if this PR closes a GitHub issue `#123`
-->




Discussion / hints:

Fix patch:
diff --git a/server/controllers/api_controller.go b/server/controllers/api_controller.go
index 30a3308377..59f03d6819 100644
--- a/server/controllers/api_controller.go
+++ b/server/controllers/api_controller.go
@@ -222,7 +222,7 @@ func (a *APIController) apiSetup(ctx *command.Context) error {
 	defer unlockFn()
 
 	// ensure workingDir is present
-	_, _, err = a.WorkingDir.Clone(ctx.Log, headRepo, pull, events.DefaultWorkspace)
+	_, err = a.WorkingDir.Clone(ctx.Log, headRepo, pull, events.DefaultWorkspace)
 	if err != nil {
 		return err
 	}
diff --git a/server/events/github_app_working_dir.go b/server/events/github_app_working_dir.go
index a06599efe0..5852a94e9c 100644
--- a/server/events/github_app_working_dir.go
+++ b/server/events/github_app_working_dir.go
@@ -20,7 +20,7 @@ type GithubAppWorkingDir struct {
 }
 
 // Clone writes a fresh token for Github App authentication
-func (g *GithubAppWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error) {
+func (g *GithubAppWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) {
 	baseRepo := &p.BaseRepo
 
 	// Realistically, this is a super brittle way of supporting clones using gh app installation tokens
diff --git a/server/events/mocks/mock_event_parsing.go b/server/events/mocks/mock_event_parsing.go
index 31ad488f93..de4df29445 100644
--- a/server/events/mocks/mock_event_parsing.go
+++ b/server/events/mocks/mock_event_parsing.go
@@ -11,7 +11,7 @@ import (
 	models "github.com/runatlantis/atlantis/server/events/models"
 	gitea0 "github.com/runatlantis/atlantis/server/events/vcs/gitea"
 	logging "github.com/runatlantis/atlantis/server/logging"
-	go_gitlab "gitlab.com/gitlab-org/api/client-go"
+	client_go "gitlab.com/gitlab-org/api/client-go"
 	"reflect"
 	"time"
 )
@@ -490,7 +490,7 @@ func (mock *MockEventParsing) ParseGithubRepo(ghRepo *github.Repository) (models
 	return _ret0, _ret1
 }
 
-func (mock *MockEventParsing) ParseGitlabMergeRequest(mr *go_gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest {
+func (mock *MockEventParsing) ParseGitlabMergeRequest(mr *client_go.MergeRequest, baseRepo models.Repo) models.PullRequest {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockEventParsing().")
 	}
@@ -505,7 +505,7 @@ func (mock *MockEventParsing) ParseGitlabMergeRequest(mr *go_gitlab.MergeRequest
 	return _ret0
 }
 
-func (mock *MockEventParsing) ParseGitlabMergeRequestCommentEvent(event go_gitlab.MergeCommentEvent) (models.Repo, models.Repo, int, models.User, error) {
+func (mock *MockEventParsing) ParseGitlabMergeRequestCommentEvent(event client_go.MergeCommentEvent) (models.Repo, models.Repo, int, models.User, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockEventParsing().")
 	}
@@ -536,7 +536,7 @@ func (mock *MockEventParsing) ParseGitlabMergeRequestCommentEvent(event go_gitla
 	return _ret0, _ret1, _ret2, _ret3, _ret4
 }
 
-func (mock *MockEventParsing) ParseGitlabMergeRequestEvent(event go_gitlab.MergeEvent) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) {
+func (mock *MockEventParsing) ParseGitlabMergeRequestEvent(event client_go.MergeEvent) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockEventParsing().")
 	}
@@ -571,7 +571,7 @@ func (mock *MockEventParsing) ParseGitlabMergeRequestEvent(event go_gitlab.Merge
 	return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5
 }
 
-func (mock *MockEventParsing) ParseGitlabMergeRequestUpdateEvent(event go_gitlab.MergeEvent) models.PullRequestEventType {
+func (mock *MockEventParsing) ParseGitlabMergeRequestUpdateEvent(event client_go.MergeEvent) models.PullRequestEventType {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockEventParsing().")
 	}
@@ -1158,7 +1158,7 @@ func (c *MockEventParsing_ParseGithubRepo_OngoingVerification) GetAllCapturedArg
 	return
 }
 
-func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequest(mr *go_gitlab.MergeRequest, baseRepo models.Repo) *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification {
+func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequest(mr *client_go.MergeRequest, baseRepo models.Repo) *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification {
 	_params := []pegomock.Param{mr, baseRepo}
 	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequest", _params, verifier.timeout)
 	return &MockEventParsing_ParseGitlabMergeRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
@@ -1169,18 +1169,18 @@ type MockEventParsing_ParseGitlabMergeRequest_OngoingVerification struct {
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetCapturedArguments() (*go_gitlab.MergeRequest, models.Repo) {
+func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetCapturedArguments() (*client_go.MergeRequest, models.Repo) {
 	mr, baseRepo := c.GetAllCapturedArguments()
 	return mr[len(mr)-1], baseRepo[len(baseRepo)-1]
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []*go_gitlab.MergeRequest, _param1 []models.Repo) {
+func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []*client_go.MergeRequest, _param1 []models.Repo) {
 	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
 	if len(_params) > 0 {
 		if len(_params) > 0 {
-			_param0 = make([]*go_gitlab.MergeRequest, len(c.methodInvocations))
+			_param0 = make([]*client_go.MergeRequest, len(c.methodInvocations))
 			for u, param := range _params[0] {
-				_param0[u] = param.(*go_gitlab.MergeRequest)
+				_param0[u] = param.(*client_go.MergeRequest)
 			}
 		}
 		if len(_params) > 1 {
@@ -1193,7 +1193,7 @@ func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetAllCap
 	return
 }
 
-func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestCommentEvent(event go_gitlab.MergeCommentEvent) *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification {
+func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestCommentEvent(event client_go.MergeCommentEvent) *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification {
 	_params := []pegomock.Param{event}
 	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequestCommentEvent", _params, verifier.timeout)
 	return &MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
@@ -1204,25 +1204,25 @@ type MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification st
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification) GetCapturedArguments() go_gitlab.MergeCommentEvent {
+func (c *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification) GetCapturedArguments() client_go.MergeCommentEvent {
 	event := c.GetAllCapturedArguments()
 	return event[len(event)-1]
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []go_gitlab.MergeCommentEvent) {
+func (c *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []client_go.MergeCommentEvent) {
 	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
 	if len(_params) > 0 {
 		if len(_params) > 0 {
-			_param0 = make([]go_gitlab.MergeCommentEvent, len(c.methodInvocations))
+			_param0 = make([]client_go.MergeCommentEvent, len(c.methodInvocations))
 			for u, param := range _params[0] {
-				_param0[u] = param.(go_gitlab.MergeCommentEvent)
+				_param0[u] = param.(client_go.MergeCommentEvent)
 			}
 		}
 	}
 	return
 }
 
-func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestEvent(event go_gitlab.MergeEvent) *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification {
+func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestEvent(event client_go.MergeEvent) *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification {
 	_params := []pegomock.Param{event}
 	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequestEvent", _params, verifier.timeout)
 	return &MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
@@ -1233,25 +1233,25 @@ type MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification struct {
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification) GetCapturedArguments() go_gitlab.MergeEvent {
+func (c *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification) GetCapturedArguments() client_go.MergeEvent {
 	event := c.GetAllCapturedArguments()
 	return event[len(event)-1]
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []go_gitlab.MergeEvent) {
+func (c *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []client_go.MergeEvent) {
 	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
 	if len(_params) > 0 {
 		if len(_params) > 0 {
-			_param0 = make([]go_gitlab.MergeEvent, len(c.methodInvocations))
+			_param0 = make([]client_go.MergeEvent, len(c.methodInvocations))
 			for u, param := range _params[0] {
-				_param0[u] = param.(go_gitlab.MergeEvent)
+				_param0[u] = param.(client_go.MergeEvent)
 			}
 		}
 	}
 	return
 }
 
-func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestUpdateEvent(event go_gitlab.MergeEvent) *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification {
+func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestUpdateEvent(event client_go.MergeEvent) *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification {
 	_params := []pegomock.Param{event}
 	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequestUpdateEvent", _params, verifier.timeout)
 	return &MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
@@ -1262,18 +1262,18 @@ type MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification str
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification) GetCapturedArguments() go_gitlab.MergeEvent {
+func (c *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification) GetCapturedArguments() client_go.MergeEvent {
 	event := c.GetAllCapturedArguments()
 	return event[len(event)-1]
 }
 
-func (c *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []go_gitlab.MergeEvent) {
+func (c *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []client_go.MergeEvent) {
 	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
 	if len(_params) > 0 {
 		if len(_params) > 0 {
-			_param0 = make([]go_gitlab.MergeEvent, len(c.methodInvocations))
+			_param0 = make([]client_go.MergeEvent, len(c.methodInvocations))
 			for u, param := range _params[0] {
-				_param0[u] = param.(go_gitlab.MergeEvent)
+				_param0[u] = param.(client_go.MergeEvent)
 			}
 		}
 	}
diff --git a/server/events/mocks/mock_gitlab_merge_request_getter.go b/server/events/mocks/mock_gitlab_merge_request_getter.go
index 2b28aae238..2c03369b20 100644
--- a/server/events/mocks/mock_gitlab_merge_request_getter.go
+++ b/server/events/mocks/mock_gitlab_merge_request_getter.go
@@ -6,7 +6,7 @@ package mocks
 import (
 	pegomock "github.com/petergtz/pegomock/v4"
 	logging "github.com/runatlantis/atlantis/server/logging"
-	go_gitlab "gitlab.com/gitlab-org/api/client-go"
+	client_go "gitlab.com/gitlab-org/api/client-go"
 	"reflect"
 	"time"
 )
@@ -26,17 +26,17 @@ func NewMockGitlabMergeRequestGetter(options ...pegomock.Option) *MockGitlabMerg
 func (mock *MockGitlabMergeRequestGetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
 func (mock *MockGitlabMergeRequestGetter) FailHandler() pegomock.FailHandler      { return mock.fail }
 
-func (mock *MockGitlabMergeRequestGetter) GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*go_gitlab.MergeRequest, error) {
+func (mock *MockGitlabMergeRequestGetter) GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*client_go.MergeRequest, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockGitlabMergeRequestGetter().")
 	}
 	_params := []pegomock.Param{logger, repoFullName, pullNum}
-	_result := pegomock.GetGenericMockFrom(mock).Invoke("GetMergeRequest", _params, []reflect.Type{reflect.TypeOf((**go_gitlab.MergeRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
-	var _ret0 *go_gitlab.MergeRequest
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("GetMergeRequest", _params, []reflect.Type{reflect.TypeOf((**client_go.MergeRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 *client_go.MergeRequest
 	var _ret1 error
 	if len(_result) != 0 {
 		if _result[0] != nil {
-			_ret0 = _result[0].(*go_gitlab.MergeRequest)
+			_ret0 = _result[0].(*client_go.MergeRequest)
 		}
 		if _result[1] != nil {
 			_ret1 = _result[1].(error)
diff --git a/server/events/mocks/mock_working_dir.go b/server/events/mocks/mock_working_dir.go
index c4b410c1ab..f17c3a59a4 100644
--- a/server/events/mocks/mock_working_dir.go
+++ b/server/events/mocks/mock_working_dir.go
@@ -26,27 +26,23 @@ func NewMockWorkingDir(options ...pegomock.Option) *MockWorkingDir {
 func (mock *MockWorkingDir) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
 func (mock *MockWorkingDir) FailHandler() pegomock.FailHandler      { return mock.fail }
 
-func (mock *MockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error) {
+func (mock *MockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockWorkingDir().")
 	}
 	_params := []pegomock.Param{logger, headRepo, p, workspace}
-	_result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
 	var _ret0 string
-	var _ret1 bool
-	var _ret2 error
+	var _ret1 error
 	if len(_result) != 0 {
 		if _result[0] != nil {
 			_ret0 = _result[0].(string)
 		}
 		if _result[1] != nil {
-			_ret1 = _result[1].(bool)
-		}
-		if _result[2] != nil {
-			_ret2 = _result[2].(error)
+			_ret1 = _result[1].(error)
 		}
 	}
-	return _ret0, _ret1, _ret2
+	return _ret0, _ret1
 }
 
 func (mock *MockWorkingDir) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error {
@@ -166,12 +162,23 @@ func (mock *MockWorkingDir) HasDiverged(logger logging.SimpleLogging, cloneDir s
 	return _ret0
 }
 
-func (mock *MockWorkingDir) SetCheckForUpstreamChanges() {
+func (mock *MockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockWorkingDir().")
 	}
-	_params := []pegomock.Param{}
-	pegomock.GetGenericMockFrom(mock).Invoke("SetCheckForUpstreamChanges", _params, []reflect.Type{})
+	_params := []pegomock.Param{logger, headRepo, p, workspace}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("MergeAgain", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 bool
+	var _ret1 error
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(bool)
+		}
+		if _result[1] != nil {
+			_ret1 = _result[1].(error)
+		}
+	}
+	return _ret0, _ret1
 }
 
 func (mock *MockWorkingDir) VerifyWasCalledOnce() *VerifierMockWorkingDir {
@@ -563,19 +570,49 @@ func (c *MockWorkingDir_HasDiverged_OngoingVerification) GetAllCapturedArguments
 	return
 }
 
-func (verifier *VerifierMockWorkingDir) SetCheckForUpstreamChanges() *MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification {
-	_params := []pegomock.Param{}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SetCheckForUpstreamChanges", _params, verifier.timeout)
-	return &MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+func (verifier *VerifierMockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_MergeAgain_OngoingVerification {
+	_params := []pegomock.Param{logger, headRepo, p, workspace}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MergeAgain", _params, verifier.timeout)
+	return &MockWorkingDir_MergeAgain_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
 }
 
-type MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification struct {
+type MockWorkingDir_MergeAgain_OngoingVerification struct {
 	mock              *MockWorkingDir
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification) GetCapturedArguments() {
+func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) {
+	logger, headRepo, p, workspace := c.GetAllCapturedArguments()
+	return logger[len(logger)-1], headRepo[len(headRepo)-1], p[len(p)-1], workspace[len(workspace)-1]
 }
 
-func (c *MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification) GetAllCapturedArguments() {
+func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) {
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]logging.SimpleLogging, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(logging.SimpleLogging)
+			}
+		}
+		if len(_params) > 1 {
+			_param1 = make([]models.Repo, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(models.Repo)
+			}
+		}
+		if len(_params) > 2 {
+			_param2 = make([]models.PullRequest, len(c.methodInvocations))
+			for u, param := range _params[2] {
+				_param2[u] = param.(models.PullRequest)
+			}
+		}
+		if len(_params) > 3 {
+			_param3 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[3] {
+				_param3[u] = param.(string)
+			}
+		}
+	}
+	return
 }
diff --git a/server/events/mocks/mock_working_dir_locker.go b/server/events/mocks/mock_working_dir_locker.go
index af5de92cfb..adf786dc51 100644
--- a/server/events/mocks/mock_working_dir_locker.go
+++ b/server/events/mocks/mock_working_dir_locker.go
@@ -43,25 +43,6 @@ func (mock *MockWorkingDirLocker) TryLock(repoFullName string, pullNum int, work
 	return _ret0, _ret1
 }
 
-func (mock *MockWorkingDirLocker) TryLockPull(repoFullName string, pullNum int) (func(), error) {
-	if mock == nil {
-		panic("mock must not be nil. Use myMock := NewMockWorkingDirLocker().")
-	}
-	_params := []pegomock.Param{repoFullName, pullNum}
-	_result := pegomock.GetGenericMockFrom(mock).Invoke("TryLockPull", _params, []reflect.Type{reflect.TypeOf((*func())(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
-	var _ret0 func()
-	var _ret1 error
-	if len(_result) != 0 {
-		if _result[0] != nil {
-			_ret0 = _result[0].(func())
-		}
-		if _result[1] != nil {
-			_ret1 = _result[1].(error)
-		}
-	}
-	return _ret0, _ret1
-}
-
 func (mock *MockWorkingDirLocker) VerifyWasCalledOnce() *VerifierMockWorkingDirLocker {
 	return &VerifierMockWorkingDirLocker{
 		mock:                   mock,
@@ -145,38 +126,3 @@ func (c *MockWorkingDirLocker_TryLock_OngoingVerification) GetAllCapturedArgumen
 	}
 	return
 }
-
-func (verifier *VerifierMockWorkingDirLocker) TryLockPull(repoFullName string, pullNum int) *MockWorkingDirLocker_TryLockPull_OngoingVerification {
-	_params := []pegomock.Param{repoFullName, pullNum}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "TryLockPull", _params, verifier.timeout)
-	return &MockWorkingDirLocker_TryLockPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
-}
-
-type MockWorkingDirLocker_TryLockPull_OngoingVerification struct {
-	mock              *MockWorkingDirLocker
-	methodInvocations []pegomock.MethodInvocation
-}
-
-func (c *MockWorkingDirLocker_TryLockPull_OngoingVerification) GetCapturedArguments() (string, int) {
-	repoFullName, pullNum := c.GetAllCapturedArguments()
-	return repoFullName[len(repoFullName)-1], pullNum[len(pullNum)-1]
-}
-
-func (c *MockWorkingDirLocker_TryLockPull_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []int) {
-	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
-	if len(_params) > 0 {
-		if len(_params) > 0 {
-			_param0 = make([]string, len(c.methodInvocations))
-			for u, param := range _params[0] {
-				_param0[u] = param.(string)
-			}
-		}
-		if len(_params) > 1 {
-			_param1 = make([]int, len(c.methodInvocations))
-			for u, param := range _params[1] {
-				_param1[u] = param.(int)
-			}
-		}
-	}
-	return
-}
diff --git a/server/events/post_workflow_hooks_command_runner.go b/server/events/post_workflow_hooks_command_runner.go
index 2739587b31..ba3bb5d6d3 100644
--- a/server/events/post_workflow_hooks_command_runner.go
+++ b/server/events/post_workflow_hooks_command_runner.go
@@ -59,7 +59,7 @@ func (w *DefaultPostWorkflowHooksCommandRunner) RunPostHooks(ctx *command.Contex
 	ctx.Log.Debug("got workspace lock")
 	defer unlockFn()
 
-	repoDir, _, err := w.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace)
+	repoDir, err := w.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace)
 	if err != nil {
 		return err
 	}
diff --git a/server/events/pre_workflow_hooks_command_runner.go b/server/events/pre_workflow_hooks_command_runner.go
index 684c3c5e56..953193e514 100644
--- a/server/events/pre_workflow_hooks_command_runner.go
+++ b/server/events/pre_workflow_hooks_command_runner.go
@@ -59,7 +59,7 @@ func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context,
 	ctx.Log.Debug("got workspace lock")
 	defer unlockFn()
 
-	repoDir, _, err := w.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace)
+	repoDir, err := w.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace)
 	if err != nil {
 		return err
 	}
diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go
index f66d2f659f..604e9da19d 100644
--- a/server/events/project_command_builder.go
+++ b/server/events/project_command_builder.go
@@ -479,7 +479,7 @@ func (p *DefaultProjectCommandBuilder) buildAllCommandsByCfg(ctx *command.Contex
 	ctx.Log.Debug("got workspace lock")
 	defer unlockFn()
 
-	repoDir, _, err := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, workspace)
+	repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, workspace)
 	if err != nil {
 		return nil, err
 	}
@@ -604,7 +604,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont
 	defer unlockFn()
 
 	ctx.Log.Debug("cloning repository")
-	_, _, err = p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace)
+	_, err = p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace)
 	if err != nil {
 		return pcc, err
 	}
@@ -682,7 +682,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont
 
 	if DefaultWorkspace != workspace {
 		ctx.Log.Debug("cloning repository with workspace %s", workspace)
-		_, _, err = p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, workspace)
+		_, err = p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, workspace)
 		if err != nil {
 			return pcc, err
 		}
@@ -766,14 +766,6 @@ func (p *DefaultProjectCommandBuilder) getCfg(ctx *command.Context, projectName
 // buildAllProjectCommandsByPlan builds contexts for a command for every project that has
 // pending plans in this ctx.
 func (p *DefaultProjectCommandBuilder) buildAllProjectCommandsByPlan(ctx *command.Context, commentCmd *CommentCommand) ([]command.ProjectContext, error) {
-	// Lock all dirs in this pull request (instead of a single dir) because we
-	// don't know how many dirs we'll need to run the command in.
-	unlockFn, err := p.WorkingDirLocker.TryLockPull(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num)
-	if err != nil {
-		return nil, err
-	}
-	defer unlockFn()
-
 	pullDir, err := p.WorkingDir.GetPullDir(ctx.Pull.BaseRepo, ctx.Pull)
 	if err != nil {
 		return nil, err
@@ -793,6 +785,12 @@ func (p *DefaultProjectCommandBuilder) buildAllProjectCommandsByPlan(ctx *comman
 
 	var cmds []command.ProjectContext
 	for _, plan := range plans {
+		// Lock all the directories we need to run the command in
+		unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, plan.Workspace, plan.RepoRelDir)
+		if err != nil {
+			return nil, err
+		}
+		defer unlockFn()
 		commentCmds, err := p.buildProjectCommandCtx(ctx, commentCmd.CommandName(), commentCmd.SubName, plan.ProjectName, commentCmd.Flags, defaultRepoDir, plan.RepoRelDir, plan.Workspace, commentCmd.Verbose)
 		if err != nil {
 			return nil, errors.Wrapf(err, "building command for dir '%s'", plan.RepoRelDir)
diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go
index 523dbd87ac..6dd5fadd19 100644
--- a/server/events/project_command_runner.go
+++ b/server/events/project_command_runner.go
@@ -580,15 +580,22 @@ func (p *DefaultProjectCommandRunner) doPlan(ctx command.ProjectContext) (*model
 	}
 	defer unlockFn()
 
-	p.WorkingDir.SetCheckForUpstreamChanges()
 	// Clone is idempotent so okay to run even if the repo was already cloned.
-	repoDir, mergedAgain, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
-	if cloneErr != nil {
+	repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
+	if err != nil {
 		if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil {
 			ctx.Log.Err("error unlocking state after plan error: %v", unlockErr)
 		}
-		return nil, "", cloneErr
+		return nil, "", err
 	}
+	mergedAgain, err := p.WorkingDir.MergeAgain(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
+	if err != nil {
+		if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil {
+			ctx.Log.Err("error unlocking state after plan error: %v", unlockErr)
+		}
+		return nil, "", err
+	}
+
 	projAbsPath := filepath.Join(repoDir, ctx.RepoRelDir)
 	if _, err = os.Stat(projAbsPath); os.IsNotExist(err) {
 		return nil, "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir}
@@ -706,7 +713,7 @@ func (p *DefaultProjectCommandRunner) doVersion(ctx command.ProjectContext) (ver
 
 func (p *DefaultProjectCommandRunner) doImport(ctx command.ProjectContext) (out *models.ImportSuccess, failure string, err error) {
 	// Clone is idempotent so okay to run even if the repo was already cloned.
-	repoDir, _, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
+	repoDir, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
 	if cloneErr != nil {
 		return nil, "", cloneErr
 	}
@@ -752,7 +759,7 @@ func (p *DefaultProjectCommandRunner) doImport(ctx command.ProjectContext) (out
 
 func (p *DefaultProjectCommandRunner) doStateRm(ctx command.ProjectContext) (out *models.StateRmSuccess, failure string, err error) {
 	// Clone is idempotent so okay to run even if the repo was already cloned.
-	repoDir, _, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
+	repoDir, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
 	if cloneErr != nil {
 		return nil, "", cloneErr
 	}
diff --git a/server/events/working_dir.go b/server/events/working_dir.go
index f77378a2a4..870eca941b 100644
--- a/server/events/working_dir.go
+++ b/server/events/working_dir.go
@@ -32,6 +32,7 @@ import (
 const workingDirPrefix = "repos"
 
 var cloneLocks sync.Map
+var recheckRequiredMap sync.Map
 
 //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_working_dir.go WorkingDir
 //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package events WorkingDir
@@ -39,10 +40,11 @@ var cloneLocks sync.Map
 // WorkingDir handles the workspace on disk for running commands.
 type WorkingDir interface {
 	// Clone git clones headRepo, checks out the branch and then returns the
-	// absolute path to the root of the cloned repo. It also returns
-	// a boolean indicating if we should warn users that the branch we're
-	// merging into has been updated since we cloned it.
-	Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error)
+	// absolute path to the root of the cloned repo.
+	Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error)
+	// MergeAgain merges again with upstream if upstream has been modified, returns
+	// whether it actually did a new merge
+	MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error)
 	// GetWorkingDir returns the path to the workspace for this repo and pull.
 	// If workspace does not exist on disk, error will be of type os.IsNotExist.
 	GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error)
@@ -51,10 +53,6 @@ type WorkingDir interface {
 	// Delete deletes the workspace for this repo and pull.
 	Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error
 	DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) error
-	// Set a flag in the workingdir so Clone() can know that it is safe to re-clone the workingdir if
-	// the upstream branch has been modified. This is only safe after grabbing the project lock
-	// and before running any plans
-	SetCheckForUpstreamChanges()
 	// DeletePlan deletes the plan for this repo, pull, workspace path and project name
 	DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, path string, projectName string) error
 	// GetGitUntrackedFiles returns a list of Git untracked files in the working dir.
@@ -90,14 +88,19 @@ type FileWorkspace struct {
 }
 
 // Clone git clones headRepo, checks out the branch and then returns the absolute
-// path to the root of the cloned repo. It also returns
-// a boolean indicating whether we had to merge with upstream again.
+// path to the root of the cloned repo.
 // If the repo already exists and is at
 // the right commit it does nothing. This is to support running commands in
 // multiple dirs of the same repo without deleting existing plans.
-func (w *FileWorkspace) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error) {
+func (w *FileWorkspace) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) {
 	cloneDir := w.cloneDir(p.BaseRepo, p, workspace)
-	defer func() { w.CheckForUpstreamChanges = false }()
+
+	// Unconditionally wait for the clone lock here, if anyone else is doing any clone
+	// operation in this directory, we wait for it to finish before we check anything.
+	value, _ := cloneLocks.LoadOrStore(cloneDir, new(sync.Mutex))
+	mutex := value.(*sync.Mutex)
+	mutex.Lock()
+	defer mutex.Unlock()
 
 	c := wrappedGitContext{cloneDir, headRepo, p}
 	// If the directory already exists, check if it's at the right commit.
@@ -119,27 +122,62 @@ func (w *FileWorkspace) Clone(logger logging.SimpleLogging, headRepo models.Repo
 		outputRevParseCmd, err := revParseCmd.CombinedOutput()
 		if err != nil {
 			logger.Warn("will re-clone repo, could not determine if was at correct commit: %s: %s: %s", strings.Join(revParseCmd.Args, " "), err, string(outputRevParseCmd))
-			return cloneDir, false, w.forceClone(logger, c)
+			return cloneDir, w.forceClone(logger, c)
 		}
 		currCommit := strings.Trim(string(outputRevParseCmd), "\n")
 
 		// We're prefix matching here because BitBucket doesn't give us the full
 		// commit, only a 12 character prefix.
 		if strings.HasPrefix(currCommit, p.HeadCommit) {
-			if w.CheckForUpstreamChanges && w.CheckoutMerge && w.recheckDiverged(logger, p, headRepo, cloneDir) {
-				logger.Info("base branch has been updated, using merge strategy and will clone again")
-				return cloneDir, true, w.mergeAgain(logger, c)
-			}
-			logger.Debug("repo is at correct commit '%s' so will not re-clone", p.HeadCommit)
-			return cloneDir, false, nil
-		} else {
-			logger.Debug("repo was already cloned but is not at correct commit, wanted '%s' got '%s'", p.HeadCommit, currCommit)
+			logger.Debug("repo is at correct commit %q so will not re-clone", p.HeadCommit)
+			return cloneDir, nil
 		}
+		logger.Debug("repo was already cloned but is not at correct commit, wanted %q got %q", p.HeadCommit, currCommit)
 		// We'll fall through to re-clone.
 	}
 
 	// Otherwise we clone the repo.
-	return cloneDir, false, w.forceClone(logger, c)
+	return cloneDir, w.forceClone(logger, c)
+}
+
+// MergeAgain merges again with upstream if we are using the merge checkout strategy,
+// and upstream has been modified since we last checked.
+// It returns a flag indicating whether we had to merge with upstream again.
+func (w *FileWorkspace) MergeAgain(
+	logger logging.SimpleLogging,
+	headRepo models.Repo,
+	p models.PullRequest,
+	workspace string) (bool, error) {
+
+	if !w.CheckoutMerge {
+		return false, nil
+	}
+
+	cloneDir := w.cloneDir(p.BaseRepo, p, workspace)
+	// We atomically set the recheckRequiredMap flag here before grabbing the clone lock.
+	// If the flag is cleared after we grab the lock, it means some other thread
+	// did the necessary work late enough that we do not have to do it again.
+	recheckRequiredMap.Store(cloneDir, struct{}{})
+
+	// Unconditionally wait for the clone lock here, if anyone else is doing any clone
+	// operation in this directory, we wait for it to finish before we check anything.
+	value, _ := cloneLocks.LoadOrStore(cloneDir, new(sync.Mutex))
+	mutex := value.(*sync.Mutex)
+	mutex.Lock()
+	defer mutex.Unlock()
+
+	if _, exists := recheckRequiredMap.Load(cloneDir); !exists {
+		logger.Debug("Skipping upstream check. Some other thread has done this for us")
+		return false, nil
+	}
+	recheckRequiredMap.Delete(cloneDir)
+
+	c := wrappedGitContext{cloneDir, headRepo, p}
+	if w.recheckDiverged(logger, p, headRepo, cloneDir) {
+		logger.Info("base branch has been updated, using merge strategy and will merge again")
+		return true, w.mergeAgain(logger, c)
+	}
+	return false, nil
 }
 
 // recheckDiverged returns true if the branch we're merging into has diverged
@@ -217,15 +255,6 @@ func (w *FileWorkspace) HasDiverged(logger logging.SimpleLogging, cloneDir strin
 }
 
 func (w *FileWorkspace) forceClone(logger logging.SimpleLogging, c wrappedGitContext) error {
-	value, _ := cloneLocks.LoadOrStore(c.dir, new(sync.Mutex))
-	mutex := value.(*sync.Mutex)
-
-	defer mutex.Unlock()
-	if locked := mutex.TryLock(); !locked {
-		mutex.Lock()
-		return nil
-	}
-
 	err := os.RemoveAll(c.dir)
 	if err != nil {
 		return errors.Wrapf(err, "deleting dir '%s' before cloning", c.dir)
@@ -280,15 +309,6 @@ func (w *FileWorkspace) forceClone(logger logging.SimpleLogging, c wrappedGitCon
 // There is a new upstream update that we need, and we want to update to it
 // without deleting any existing plans
 func (w *FileWorkspace) mergeAgain(logger logging.SimpleLogging, c wrappedGitContext) error {
-	value, _ := cloneLocks.LoadOrStore(c.dir, new(sync.Mutex))
-	mutex := value.(*sync.Mutex)
-
-	defer mutex.Unlock()
-	if locked := mutex.TryLock(); !locked {
-		mutex.Lock()
-		return nil
-	}
-
 	// Reset branch as if it was cloned again
 	if err := w.wrappedGit(logger, c, "reset", "--hard", fmt.Sprintf("refs/remotes/origin/%s", c.pr.BaseBranch)); err != nil {
 		return err
diff --git a/server/events/working_dir_locker.go b/server/events/working_dir_locker.go
index 06af44ec5a..901c8fabc1 100644
--- a/server/events/working_dir_locker.go
+++ b/server/events/working_dir_locker.go
@@ -15,7 +15,6 @@ package events
 
 import (
 	"fmt"
-	"strings"
 	"sync"
 )
 
@@ -32,12 +31,6 @@ type WorkingDirLocker interface {
 	// an error if the workspace is already locked. The error is expected to
 	// be printed to the pull request.
 	TryLock(repoFullName string, pullNum int, workspace string, path string) (func(), error)
-	// TryLockPull tries to acquire a lock for all the workspaces in this repo
-	// and pull.
-	// It returns a function that should be used to unlock the workspace and
-	// an error if the workspace is already locked. The error is expected to
-	// be printed to the pull request.
-	TryLockPull(repoFullName string, pullNum int) (func(), error)
 }
 
 // DefaultWorkingDirLocker implements WorkingDirLocker.
@@ -45,49 +38,26 @@ type DefaultWorkingDirLocker struct {
 	// mutex prevents against multiple threads calling functions on this struct
 	// concurrently. It's only used for entry/exit to each function.
 	mutex sync.Mutex
-	// locks is a list of the keys that are locked. We then use prefix
-	// matching to determine if something is locked. It's naive but that's okay
-	// because there won't be many locks at one time.
-	locks []string
+	// locks is a set of the keys that are locked.
+	locks map[string]struct{}
 }
 
 // NewDefaultWorkingDirLocker is a constructor.
 func NewDefaultWorkingDirLocker() *DefaultWorkingDirLocker {
-	return &DefaultWorkingDirLocker{}
-}
-
-func (d *DefaultWorkingDirLocker) TryLockPull(repoFullName string, pullNum int) (func(), error) {
-	d.mutex.Lock()
-	defer d.mutex.Unlock()
-
-	pullKey := d.pullKey(repoFullName, pullNum)
-	for _, l := range d.locks {
-		if l == pullKey || strings.HasPrefix(l, pullKey+"/") {
-			return func() {}, fmt.Errorf("the Atlantis working dir is currently locked by another" +
-				" command that is running for this pull request.\n" +
-				"Wait until the previous command is complete and try again")
-		}
-	}
-	d.locks = append(d.locks, pullKey)
-	return func() {
-		d.UnlockPull(repoFullName, pullNum)
-	}, nil
+	return &DefaultWorkingDirLocker{locks: make(map[string]struct{})}
 }
 
 func (d *DefaultWorkingDirLocker) TryLock(repoFullName string, pullNum int, workspace string, path string) (func(), error) {
 	d.mutex.Lock()
 	defer d.mutex.Unlock()
 
-	pullKey := d.pullKey(repoFullName, pullNum)
 	workspaceKey := d.workspaceKey(repoFullName, pullNum, workspace, path)
-	for _, l := range d.locks {
-		if l == pullKey || l == workspaceKey {
-			return func() {}, fmt.Errorf("the %s workspace at path %s is currently locked by another"+
-				" command that is running for this pull request.\n"+
-				"Wait until the previous command is complete and try again", workspace, path)
-		}
+	if _, exists := d.locks[workspaceKey]; exists {
+		return func() {}, fmt.Errorf("the %s workspace at path %s is currently locked by another"+
+			" command that is running for this pull request.\n"+
+			"Wait until the previous command is complete and try again", workspace, path)
 	}
-	d.locks = append(d.locks, workspaceKey)
+	d.locks[workspaceKey] = struct{}{}
 	return func() {
 		d.unlock(repoFullName, pullNum, workspace, path)
 	}, nil
@@ -99,32 +69,9 @@ func (d *DefaultWorkingDirLocker) unlock(repoFullName string, pullNum int, works
 	defer d.mutex.Unlock()
 
 	workspaceKey := d.workspaceKey(repoFullName, pullNum, workspace, path)
-	d.removeLock(workspaceKey)
-}
-
-// Unlock unlocks all workspaces for this pull.
-func (d *DefaultWorkingDirLocker) UnlockPull(repoFullName string, pullNum int) {
-	d.mutex.Lock()
-	defer d.mutex.Unlock()
-
-	pullKey := d.pullKey(repoFullName, pullNum)
-	d.removeLock(pullKey)
-}
-
-func (d *DefaultWorkingDirLocker) removeLock(key string) {
-	var newLocks []string
-	for _, l := range d.locks {
-		if l != key {
-			newLocks = append(newLocks, l)
-		}
-	}
-	d.locks = newLocks
+	delete(d.locks, workspaceKey)
 }
 
 func (d *DefaultWorkingDirLocker) workspaceKey(repo string, pull int, workspace string, path string) string {
-	return fmt.Sprintf("%s/%s/%s", d.pullKey(repo, pull), workspace, path)
-}
-
-func (d *DefaultWorkingDirLocker) pullKey(repo string, pull int) string {
-	return fmt.Sprintf("%s/%d", repo, pull)
+	return fmt.Sprintf("%s/%d/%s/%s", repo, pull, workspace, path)
 }


Test patch:
diff --git a/server/events/github_app_working_dir_test.go b/server/events/github_app_working_dir_test.go
index 78e64d4e0b..900b1f6193 100644
--- a/server/events/github_app_working_dir_test.go
+++ b/server/events/github_app_working_dir_test.go
@@ -45,7 +45,7 @@ func TestClone_GithubAppNoneExisting(t *testing.T) {
 		GithubHostname: testServer,
 	}
 
-	cloneDir, _, err := gwd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := gwd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 	}, "default")
@@ -90,11 +90,11 @@ func TestClone_GithubAppSetsCorrectUrl(t *testing.T) {
 
 	When(credentials.GetToken()).ThenReturn("token", nil)
 	When(workingDir.Clone(Any[logging.SimpleLogging](), Eq(modifiedBaseRepo), Eq(models.PullRequest{BaseRepo: modifiedBaseRepo}),
-		Eq("default"))).ThenReturn("", true, nil)
+		Eq("default"))).ThenReturn("", nil)
 
-	_, success, _ := ghAppWorkingDir.Clone(logger, headRepo, models.PullRequest{BaseRepo: baseRepo}, "default")
+	_, err := ghAppWorkingDir.Clone(logger, headRepo, models.PullRequest{BaseRepo: baseRepo}, "default")
 
 	workingDir.VerifyWasCalledOnce().Clone(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default")
 
-	Assert(t, success == true, "clone url mutation error")
+	Ok(t, err)
 }
diff --git a/server/events/mock_workingdir_test.go b/server/events/mock_workingdir_test.go
index 65d5fc00a7..7723e15292 100644
--- a/server/events/mock_workingdir_test.go
+++ b/server/events/mock_workingdir_test.go
@@ -4,11 +4,12 @@
 package events
 
 import (
+	"reflect"
+	"time"
+
 	pegomock "github.com/petergtz/pegomock/v4"
 	models "github.com/runatlantis/atlantis/server/events/models"
 	logging "github.com/runatlantis/atlantis/server/logging"
-	"reflect"
-	"time"
 )
 
 type MockWorkingDir struct {
@@ -26,27 +27,23 @@ func NewMockWorkingDir(options ...pegomock.Option) *MockWorkingDir {
 func (mock *MockWorkingDir) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
 func (mock *MockWorkingDir) FailHandler() pegomock.FailHandler      { return mock.fail }
 
-func (mock *MockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, bool, error) {
+func (mock *MockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockWorkingDir().")
 	}
 	_params := []pegomock.Param{logger, headRepo, p, workspace}
-	_result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
 	var _ret0 string
-	var _ret1 bool
-	var _ret2 error
+	var _ret1 error
 	if len(_result) != 0 {
 		if _result[0] != nil {
 			_ret0 = _result[0].(string)
 		}
 		if _result[1] != nil {
-			_ret1 = _result[1].(bool)
-		}
-		if _result[2] != nil {
-			_ret2 = _result[2].(error)
+			_ret1 = _result[1].(error)
 		}
 	}
-	return _ret0, _ret1, _ret2
+	return _ret0, _ret1
 }
 
 func (mock *MockWorkingDir) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error {
@@ -166,12 +163,23 @@ func (mock *MockWorkingDir) HasDiverged(logger logging.SimpleLogging, cloneDir s
 	return _ret0
 }
 
-func (mock *MockWorkingDir) SetCheckForUpstreamChanges() {
+func (mock *MockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockWorkingDir().")
 	}
-	_params := []pegomock.Param{}
-	pegomock.GetGenericMockFrom(mock).Invoke("SetCheckForUpstreamChanges", _params, []reflect.Type{})
+	_params := []pegomock.Param{logger, headRepo, p, workspace}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("MergeAgain", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 bool
+	var _ret1 error
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(bool)
+		}
+		if _result[1] != nil {
+			_ret1 = _result[1].(error)
+		}
+	}
+	return _ret0, _ret1
 }
 
 func (mock *MockWorkingDir) VerifyWasCalledOnce() *VerifierMockWorkingDir {
@@ -563,19 +571,49 @@ func (c *MockWorkingDir_HasDiverged_OngoingVerification) GetAllCapturedArguments
 	return
 }
 
-func (verifier *VerifierMockWorkingDir) SetCheckForUpstreamChanges() *MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification {
-	_params := []pegomock.Param{}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SetCheckForUpstreamChanges", _params, verifier.timeout)
-	return &MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+func (verifier *VerifierMockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_MergeAgain_OngoingVerification {
+	_params := []pegomock.Param{logger, headRepo, p, workspace}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MergeAgain", _params, verifier.timeout)
+	return &MockWorkingDir_MergeAgain_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
 }
 
-type MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification struct {
+type MockWorkingDir_MergeAgain_OngoingVerification struct {
 	mock              *MockWorkingDir
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification) GetCapturedArguments() {
+func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) {
+	logger, headRepo, p, workspace := c.GetAllCapturedArguments()
+	return logger[len(logger)-1], headRepo[len(headRepo)-1], p[len(p)-1], workspace[len(workspace)-1]
 }
 
-func (c *MockWorkingDir_SetCheckForUpstreamChanges_OngoingVerification) GetAllCapturedArguments() {
+func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) {
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]logging.SimpleLogging, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(logging.SimpleLogging)
+			}
+		}
+		if len(_params) > 1 {
+			_param1 = make([]models.Repo, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(models.Repo)
+			}
+		}
+		if len(_params) > 2 {
+			_param2 = make([]models.PullRequest, len(c.methodInvocations))
+			for u, param := range _params[2] {
+				_param2[u] = param.(models.PullRequest)
+			}
+		}
+		if len(_params) > 3 {
+			_param3 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[3] {
+				_param3[u] = param.(string)
+			}
+		}
+	}
+	return
 }
diff --git a/server/events/post_workflow_hooks_command_runner_test.go b/server/events/post_workflow_hooks_command_runner_test.go
index e1e909c9f0..897656e7a1 100644
--- a/server/events/post_workflow_hooks_command_runner_test.go
+++ b/server/events/post_workflow_hooks_command_runner_test.go
@@ -168,7 +168,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](),
 			Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -209,7 +209,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(
 			ArgThat[models.WorkflowHookCommandContext](WorkflowHookCommandContextMatcher{expected: expectedCtx}),
 			Eq(testHook.RunCommand),
@@ -312,7 +312,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, errors.New("some error"))
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, errors.New("some error"))
 
 		err := postWh.RunPostHooks(ctx, planCmd)
 
@@ -347,7 +347,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, errors.New("some error"))
 
@@ -389,7 +389,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -425,7 +425,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -461,7 +461,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -497,7 +497,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellandShellArgs.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -534,7 +534,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](),
 			Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -570,7 +570,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -606,7 +606,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanApplyCommands.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
diff --git a/server/events/pre_workflow_hooks_command_runner_test.go b/server/events/pre_workflow_hooks_command_runner_test.go
index 191a8c27dc..29912576da 100644
--- a/server/events/pre_workflow_hooks_command_runner_test.go
+++ b/server/events/pre_workflow_hooks_command_runner_test.go
@@ -145,7 +145,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -241,7 +241,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, errors.New("some error"))
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, errors.New("some error"))
 
 		err := preWh.RunPreHooks(ctx, planCmd)
 
@@ -276,7 +276,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, errors.New("some error"))
 
@@ -318,7 +318,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](),
 			Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -354,7 +354,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -390,7 +390,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -426,7 +426,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellandShellArgs.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -463,7 +463,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -499,7 +499,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
@@ -535,7 +535,7 @@ func TestRunPreHooks_Clone(t *testing.T) {
 		When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace,
 			events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
 		When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull),
-			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, false, nil)
+			Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil)
 		When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanApplyCommands.RunCommand),
 			Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)
 
diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go
index 115657e38e..7f35e01c04 100644
--- a/server/events/project_command_builder_internal_test.go
+++ b/server/events/project_command_builder_internal_test.go
@@ -631,7 +631,7 @@ projects:
 
 			workingDir := NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmp, false, nil)
+				Any[string]())).ThenReturn(tmp, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 				Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil)
@@ -846,7 +846,7 @@ projects:
 
 			workingDir := NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmp, false, nil)
+				Any[string]())).ThenReturn(tmp, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 				Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil)
@@ -1091,7 +1091,7 @@ workflows:
 
 			workingDir := NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmp, false, nil)
+				Any[string]())).ThenReturn(tmp, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 				Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil)
@@ -1245,7 +1245,7 @@ projects:
 
 			workingDir := NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmp, false, nil)
+				Any[string]())).ThenReturn(tmp, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 				Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil)
@@ -1385,7 +1385,7 @@ projects:
 
 			workingDir := NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmp, false, nil)
+				Any[string]())).ThenReturn(tmp, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 				Any[models.PullRequest]())).ThenReturn(c.modifiedFiles, nil)
diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go
index e74c563ed7..67da74e681 100644
--- a/server/events/project_command_builder_test.go
+++ b/server/events/project_command_builder_test.go
@@ -241,7 +241,7 @@ terraform {
 			tmpDir := DirStructure(t, c.TestDirStructure)
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 				Any[models.PullRequest]())).ThenReturn(ChangedFiles(c.TestDirStructure, ""), nil)
@@ -602,7 +602,7 @@ projects:
 
 				workingDir := mocks.NewMockWorkingDir()
 				When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-					Any[string]())).ThenReturn(tmpDir, false, nil)
+					Any[string]())).ThenReturn(tmpDir, nil)
 				When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 				vcsClient := vcsmocks.NewMockClient()
 				When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
@@ -790,7 +790,7 @@ projects:
 
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
@@ -1191,7 +1191,7 @@ projects:
 
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
@@ -1379,7 +1379,7 @@ projects:
 	Ok(t, err)
 
 	When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-		Any[string]())).ThenReturn(repoDir, false, nil)
+		Any[string]())).ThenReturn(repoDir, nil)
 	When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(repoDir, nil)
 
 	globalCfgArgs := valid.GlobalCfgArgs{
@@ -1467,7 +1467,7 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) {
 
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 			vcsClient := vcsmocks.NewMockClient()
 			When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
@@ -1623,7 +1623,7 @@ projects:
 				Any[models.PullRequest]())).ThenReturn(testCase.ModifiedFiles, nil)
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 
 			globalCfgArgs := valid.GlobalCfgArgs{
@@ -1821,7 +1821,7 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman
 
 	workingDir := mocks.NewMockWorkingDir()
 	When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-		Any[string]())).ThenReturn(tmpDir, false, nil)
+		Any[string]())).ThenReturn(tmpDir, nil)
 	vcsClient := vcsmocks.NewMockClient()
 	When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](),
 		Any[models.PullRequest]())).ThenReturn([]string{"main.tf"}, nil)
@@ -2038,7 +2038,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands_Single_With_RestrictFile
 
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetGitUntrackedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
 				Any[string]())).ThenReturn(c.UntrackedFiles, nil)
@@ -2149,7 +2149,7 @@ func TestDefaultProjectCommandBuilder_BuildPlanCommands_with_IncludeGitUntracked
 
 			workingDir := mocks.NewMockWorkingDir()
 			When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(tmpDir, false, nil)
+				Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil)
 			When(workingDir.GetGitUntrackedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
 				Any[string]())).ThenReturn(c.UntrackedFiles, nil)
diff --git a/server/events/project_command_runner_test.go b/server/events/project_command_runner_test.go
index 382bda6d18..5956929f57 100644
--- a/server/events/project_command_runner_test.go
+++ b/server/events/project_command_runner_test.go
@@ -66,7 +66,7 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) {
 
 	repoDir := t.TempDir()
 	When(mockWorkingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-		Any[string]())).ThenReturn(repoDir, false, nil)
+		Any[string]())).ThenReturn(repoDir, nil)
 	When(mockLocker.TryLock(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](),
 		Any[models.Project](), AnyBool())).ThenReturn(&events.TryLockResponse{LockAcquired: true, LockKey: "lock-key"}, nil)
 
@@ -106,6 +106,7 @@ func TestDefaultProjectCommandRunner_Plan(t *testing.T) {
 
 	Assert(t, res.PlanSuccess != nil, "exp plan success")
 	Equals(t, "https://lock-key", res.PlanSuccess.LockURL)
+	t.Logf("output is %s", res.PlanSuccess.TerraformOutput)
 	Equals(t, "run\napply\nplan\ninit", res.PlanSuccess.TerraformOutput)
 	expSteps := []string{"run", "apply", "plan", "init", "env"}
 	for _, step := range expSteps {
@@ -575,7 +576,7 @@ func TestDefaultProjectCommandRunner_RunEnvSteps(t *testing.T) {
 
 	repoDir := t.TempDir()
 	When(mockWorkingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-		Any[string]())).ThenReturn(repoDir, false, nil)
+		Any[string]())).ThenReturn(repoDir, nil)
 	When(mockLocker.TryLock(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](),
 		Any[models.Project](), AnyBool())).ThenReturn(&events.TryLockResponse{LockAcquired: true, LockKey: "lock-key"}, nil)
 
@@ -717,7 +718,7 @@ func TestDefaultProjectCommandRunner_Import(t *testing.T) {
 			}
 			repoDir := t.TempDir()
 			When(mockWorkingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](),
-				Any[string]())).ThenReturn(repoDir, false, nil)
+				Any[string]())).ThenReturn(repoDir, nil)
 			if c.setup != nil {
 				c.setup(repoDir, ctx, mockLocker, mockInit, mockImport)
 			}
diff --git a/server/events/working_dir_locker_test.go b/server/events/working_dir_locker_test.go
index 11c21ea151..36ce0dc948 100644
--- a/server/events/working_dir_locker_test.go
+++ b/server/events/working_dir_locker_test.go
@@ -170,47 +170,3 @@ func TestUnlockDifferentPulls(t *testing.T) {
 	_, err = locker.TryLock(repo, newPull, workspace, path)
 	Ok(t, err)
 }
-
-func TestLockPull(t *testing.T) {
-	locker := events.NewDefaultWorkingDirLocker()
-	unlock, err := locker.TryLockPull("owner/repo", 1)
-	Ok(t, err)
-
-	// Now a lock for the same pull or for a workspace should fail.
-	_, err = locker.TryLockPull("owner/repo", 1)
-	Assert(t, err != nil, "exp err")
-	_, err = locker.TryLock("owner/repo", 1, "workspace", path)
-	Assert(t, err != nil, "exp err")
-
-	// Lock for a different pull and workspace should succeed.
-	_, err = locker.TryLockPull("owner/repo", 2)
-	Ok(t, err)
-	_, err = locker.TryLock("owner/repo", 3, "workspace", path)
-	Ok(t, err)
-
-	// After unlocking, should be able to get a pull lock.
-	unlock()
-	unlock, err = locker.TryLockPull("owner/repo", 1)
-	Ok(t, err)
-
-	// If we unlock that too, should be able to get the workspace lock.
-	unlock()
-	_, err = locker.TryLock("owner/repo", 1, "workspace", path)
-	Ok(t, err)
-	unlock()
-}
-
-// If the workspace was locked first, we shouldn't be able to get the pull lock.
-func TestLockPull_WorkspaceFirst(t *testing.T) {
-	locker := events.NewDefaultWorkingDirLocker()
-	unlock, err := locker.TryLock("owner/repo", 1, "workspace", path)
-	Ok(t, err)
-
-	_, err = locker.TryLockPull("owner/repo", 1)
-	Assert(t, err != nil, "exp err")
-
-	// After unlocking the workspace, should be able to get the lock.
-	unlock()
-	_, err = locker.TryLockPull("owner/repo", 1)
-	Ok(t, err)
-}
diff --git a/server/events/working_dir_test.go b/server/events/working_dir_test.go
index e25c420100..26c82823b7 100644
--- a/server/events/working_dir_test.go
+++ b/server/events/working_dir_test.go
@@ -45,7 +45,7 @@ func TestClone_NoneExisting(t *testing.T) {
 		GpgNoSigningEnabled:         true,
 	}
 
-	cloneDir, _, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 	}, "default")
@@ -97,13 +97,12 @@ func TestClone_CheckoutMergeNoneExisting(t *testing.T) {
 		GpgNoSigningEnabled:         true,
 	}
 
-	cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		BaseBranch: "main",
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 
 	// Check the commits.
 	actBaseCommit := runCmd(t, cloneDir, "git", "rev-parse", "HEAD~1")
@@ -148,25 +147,23 @@ func TestClone_CheckoutMergeNoReclone(t *testing.T) {
 		GpgNoSigningEnabled:         true,
 	}
 
-	_, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	_, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		BaseBranch: "main",
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 
 	// Create a file that we can use to check if the repo was recloned.
 	runCmd(t, dataDir, "touch", "repos/0/default/proof")
 
 	// Now run the clone again.
-	cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		BaseBranch: "main",
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 
 	// Check that our proof file is still there, proving that we didn't reclone.
 	_, err = os.Stat(filepath.Join(cloneDir, "proof"))
@@ -200,25 +197,23 @@ func TestClone_CheckoutMergeNoRecloneFastForward(t *testing.T) {
 		GpgNoSigningEnabled:         true,
 	}
 
-	_, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	_, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		BaseBranch: "main",
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 
 	// Create a file that we can use to check if the repo was recloned.
 	runCmd(t, dataDir, "touch", "repos/0/default/proof")
 
 	// Now run the clone again.
-	cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		BaseBranch: "main",
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 
 	// Check that our proof file is still there, proving that we didn't reclone.
 	_, err = os.Stat(filepath.Join(cloneDir, "proof"))
@@ -257,7 +252,7 @@ func TestClone_CheckoutMergeConflict(t *testing.T) {
 		GpgNoSigningEnabled:         true,
 	}
 
-	_, _, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	_, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		BaseBranch: "main",
@@ -316,13 +311,12 @@ func TestClone_CheckoutMergeShallow(t *testing.T) {
 			GpgNoSigningEnabled:         true,
 		}
 
-		cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+		cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 			BaseRepo:   models.Repo{},
 			HeadBranch: "branch",
 			BaseBranch: "main",
 		}, "default")
 		Ok(t, err)
-		Equals(t, false, mergedAgain)
 
 		gotBaseCommitType := runCmd(t, cloneDir, "git", "cat-file", "-t", baseCommit)
 		Assert(t, gotBaseCommitType == "commit\n", "should have merge-base in shallow repo")
@@ -346,13 +340,12 @@ func TestClone_CheckoutMergeShallow(t *testing.T) {
 			GpgNoSigningEnabled:         true,
 		}
 
-		cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+		cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 			BaseRepo:   models.Repo{},
 			HeadBranch: "branch",
 			BaseBranch: "main",
 		}, "default")
 		Ok(t, err)
-		Equals(t, false, mergedAgain)
 
 		gotBaseCommitType := runCmd(t, cloneDir, "git", "cat-file", "-t", baseCommit)
 		Assert(t, gotBaseCommitType == "commit\n", "should have merge-base in full repo")
@@ -381,12 +374,11 @@ func TestClone_NoReclone(t *testing.T) {
 		TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir),
 		GpgNoSigningEnabled:         true,
 	}
-	cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 
 	// Check that our proof file is still there.
 	_, err = os.Stat(filepath.Join(cloneDir, "proof"))
@@ -425,13 +417,12 @@ func TestClone_RecloneWrongCommit(t *testing.T) {
 		TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir),
 		GpgNoSigningEnabled:         true,
 	}
-	cloneDir, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "branch",
 		HeadCommit: expCommit,
 	}, "default")
 	Ok(t, err)
-	Equals(t, false, mergedAgain)
 	assert.NoFileExists(t, planFile, "Plan file should have been wiped out by Clone")
 
 	// Use rev-parse to verify at correct commit.
@@ -506,23 +497,28 @@ func TestClone_MasterHasDiverged(t *testing.T) {
 	Assert(t, err == nil, "creating plan file: %v", err)
 	assert.FileExists(t, planFile)
 
-	// Run the clone without the checkout merge strategy. It should return
+	// Run MergeAgain without the checkout merge strategy. It should return
 	// false for mergedAgain
-	_, mergedAgain, err := wd.Clone(logger, models.Repo{}, models.PullRequest{
+	_, err = wd.Clone(logger, models.Repo{}, models.PullRequest{
 		BaseRepo:   models.Repo{},
 		HeadBranch: "second-pr",
 		BaseBranch: "main",
 	}, "default")
 	Ok(t, err)
-	Assert(t, mergedAgain == false, "Clone with CheckoutMerge=false should not merge")
-	assert.FileExists(t, planFile, "Existing plan file should not be deleted by Clone with merge disabled")
+	mergedAgain, err := wd.MergeAgain(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{
+		BaseRepo:   models.Repo{CloneURL: repoDir},
+		HeadBranch: "second-pr",
+		BaseBranch: "main",
+	}, "default")
+	Ok(t, err)
+	assert.FileExists(t, planFile, "Existing plan file should not be deleted by merging again")
+	Assert(t, mergedAgain == false, "MergeAgain with CheckoutMerge=false should not merge")
 
 	wd.CheckoutMerge = true
-	wd.SetCheckForUpstreamChanges()
 	// Run the clone twice with the merge strategy, the first run should
 	// return true for mergedAgain, subsequent runs should
 	// return false since the first call is supposed to merge.
-	_, mergedAgain, err = wd.Clone(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{
+	mergedAgain, err = wd.MergeAgain(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{
 		BaseRepo:   models.Repo{CloneURL: repoDir},
 		HeadBranch: "second-pr",
 		BaseBranch: "main",
@@ -531,8 +527,7 @@ func TestClone_MasterHasDiverged(t *testing.T) {
 	assert.FileExists(t, planFile, "Existing plan file should not be deleted by merging again")
 	Assert(t, mergedAgain == true, "First clone with CheckoutMerge=true with diverged base should have merged")
 
-	wd.SetCheckForUpstreamChanges()
-	_, mergedAgain, err = wd.Clone(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{
+	mergedAgain, err = wd.MergeAgain(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{
 		BaseRepo:   models.Repo{CloneURL: repoDir},
 		HeadBranch: "second-pr",
 		BaseBranch: "main",