Repository: diggerhq/digger
Pull request #278 (2023-05-19 08:53:14)

feat: add support for disabling locking with NoOp lock provider
This pull request introduces the ability to disable locking functionality by adding a new action input called `disable-locking`. When set to `true`, it utilizes the NoOp lock provider, which performs no actual locking operations.

The logic code has been implemented to support this new feature, ensuring the appropriate environment is utilized based on the `disable-locking` input value.

For more details, please refer to the linked issue.

Linked Issue: #99


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6063c7da..d762546b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,6 +11,7 @@ and we would be happy to set you up.
 - [How to contribute](#how-to-contribute)
 - [Coding conventions](#coding-conventions)
 - [Submitting a pull request](#submitting-a-pull-request)
+- [Release Process](#release-process)
 - [Code of Conduct](#code-of-conduct)
 - [License](#license)
 
@@ -38,15 +39,25 @@ We strive to maintain a consistent coding style throughout the project. Please f
 
 ## Submitting a pull request
 When you have made changes to the codebase that you would like to contribute back, please follow these steps:
-1. Fork the repository and create a new branch from `main`.
+1. Fork the repository and create a new branch from `develop`.
 2. Make your changes and ensure that the code passes all tests.
 3. Write tests for your changes, if applicable.
 4. Update the documentation to reflect your changes, if applicable.
-5. Submit a pull request to the `main` branch.
+5. Submit a pull request to the `develop` branch.
    We will review your pull request as soon as possible. Please be patient and open to feedback. We appreciate your contributions!
 
+## Release Process
+**NOTE: The default branch `@develop` is not guaranteed to be stable and you should always use published release versions in your testing and production.**
+
+- All pull requests are merged to the default develop branch after initial unit tests and integration tests are passing and required code review requirements are met.
+- We checkout a pre-release branch to prepare for an upcoming release with the pattern `prerelease-0.1.xx`.
+- We perform additional manual and automated tests in this branch to make sure there are no regressions.
+- Once we are ready we tag the head of our release branch and perform a release on it.
+- Tagged releases are published as actions and they are the most suitable to be used in production.
+
+
 ## Code of Conduct
 We expect all contributors to follow our [Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) when participating in our community. Please read it carefully before contributing.
 
 ## License
-Digger is released under the [Apache License](LICENSE). By contributing to this project, you agree to license your contributions under the same license.
\ No newline at end of file
+Digger is released under the [Apache License](LICENSE). By contributing to this project, you agree to license your contributions under the same license.
diff --git a/README.md b/README.md
index 8c2368cf..f99031f8 100644
--- a/README.md
+++ b/README.md
@@ -5,13 +5,11 @@
   <a href="https://docs.digger.dev/">Docs</a>
 </h2>
 
-Digger runs Terraform jobs in the CI/CD system you already have, such as Github Actions.
+CI/CD for Terraform is [tricky](https://itnext.io/pains-in-terraform-collaboration-249a56b4534e). To make life easier, specialised CI systems aka [TACOS](https://itnext.io/spice-up-your-infrastructure-as-code-with-tacos-1a9c179e0783) exist - Terraform Cloud, Spacelift, Atlantis, etc.
 
-CI/CD for Terraform is [not easy](https://itnext.io/pains-in-terraform-collaboration-249a56b4534e). This is why Terraform Cloud, Spacelift and Atlantis are essentially standalone full-stack CI/CD systems.
+But why have 2 CI systems? Why not reuse the async jobs infrastructure with compute, orchestration, logs, etc of your existing CI?
 
-But why have 2 CI systems? Why not reuse the existing CI infrastructure? Digger does just that.
-
-With Digger terraform jobs natively in your CI runners. It takes care of locks, state, outputs etc. [Demo video](https://www.loom.com/share/e201e639a73941e0b5508710377a6106)
+Digger runs terraform natively in your CI and takes care of the other bits - locks, plan artifacts and so on. [Demo video](https://www.loom.com/share/e201e639a73941e0b5508710377a6106)
 
 
 ## Features
diff --git a/action.yml b/action.yml
index 4d380823..e51cf89c 100644
--- a/action.yml
+++ b/action.yml
@@ -64,6 +64,10 @@ inputs:
     description: Checkov version
     required: false
     default: '2.3.245'
+  disable-locking:
+    description: Disable locking
+    required: false
+    default: 'false'
 
 outputs:
   output:
@@ -129,7 +133,7 @@ runs:
       if: inputs.setup-terraform == 'true'
 
     - name: Setup Terragrunt
-      uses: autero1/action-terragrunt@v1.3.1
+      uses: autero1/action-terragrunt@v1.3.2
       with:
         terragrunt_version: ${{ inputs.terragrunt-version }}
       if: inputs.setup-terragrunt == 'true'
@@ -150,6 +154,7 @@ runs:
       env:
         PLAN_UPLOAD_DESTINATION: ${{ inputs.upload-plan-destination }}
         ACTIVATE_VENV: ${{ inputs.setup-checkov == 'true' }}
+        DISABLE_LOCKING: ${{ inputs.disable-locking == 'true' }}
       run: |
           cd ${{ github.action_path }}
           go build -o digger ./cmd/digger
@@ -162,6 +167,7 @@ runs:
       env:
         actionref: ${{ github.action_ref }}
         PLAN_UPLOAD_DESTINATION: ${{ inputs.upload-plan-destination }}
+        DISABLE_LOCKING: ${{ inputs.disable-locking == 'true' }}
       id: digger
       shell: bash
       run: |
diff --git a/cmd/digger/main.go b/cmd/digger/main.go
index 67622f46..e5f50610 100644
--- a/cmd/digger/main.go
+++ b/cmd/digger/main.go
@@ -8,6 +8,7 @@ import (
 	dg_github "digger/pkg/github"
 	"digger/pkg/gitlab"
 	"digger/pkg/models"
+	"digger/pkg/usage"
 	"digger/pkg/utils"
 	"fmt"
 	"github.com/google/go-github/v51/github"
@@ -20,9 +21,9 @@ func gitHubCI(lock utils.Lock) {
 	println("Using GitHub.")
 	githubRepositoryOwner := os.Getenv("GITHUB_REPOSITORY_OWNER")
 	if githubRepositoryOwner != "" {
-		utils.SendUsageRecord(githubRepositoryOwner, "log", "initialize")
+		usage.SendUsageRecord(githubRepositoryOwner, "log", "initialize")
 	} else {
-		utils.SendUsageRecord("", "log", "non github initialisation")
+		usage.SendUsageRecord("", "log", "non github initialisation")
 	}
 
 	ghToken := os.Getenv("GITHUB_TOKEN")
@@ -265,7 +266,7 @@ func logCommands(projectCommands []digger.ProjectCommand) {
 
 func reportErrorAndExit(repoOwner string, message string, exitCode int) {
 	fmt.Printf(message)
-	err := utils.SendLogRecord(repoOwner, message)
+	err := usage.SendLogRecord(repoOwner, message)
 	if err != nil {
 		fmt.Printf("Failed to send log record. %s\n", err)
 	}
diff --git a/go.mod b/go.mod
index bc6763c9..38baba4a 100644
--- a/go.mod
+++ b/go.mod
@@ -6,14 +6,13 @@ require (
 	cloud.google.com/go/storage v1.30.1
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0
 	github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1
-	github.com/aws/aws-sdk-go v1.44.264
+	github.com/aws/aws-sdk-go v1.44.268
 	github.com/bmatcuk/doublestar/v4 v4.6.0
 	github.com/caarlos0/env/v7 v7.1.0
-	github.com/caarlos0/env/v8 v8.0.0
 	github.com/google/go-github/v51 v51.0.0
 	github.com/google/uuid v1.3.0
-	github.com/stretchr/testify v1.8.2
-	github.com/xanzy/go-gitlab v0.83.0
+	github.com/stretchr/testify v1.8.4
+	github.com/xanzy/go-gitlab v0.84.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
@@ -39,7 +38,6 @@ require (
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
 	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
-	github.com/stretchr/objx v0.5.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	golang.org/x/crypto v0.7.0 // indirect
 	golang.org/x/net v0.9.0 // indirect
diff --git a/go.sum b/go.sum
index a7d55255..fc652126 100644
--- a/go.sum
+++ b/go.sum
@@ -23,22 +23,15 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDm
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
 github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
-github.com/aws/aws-sdk-go v1.44.260 h1:78IJkDpDPXvLXvIkNAKDP/i3z8Vj+3sTAtQYw/v/2o8=
-github.com/aws/aws-sdk-go v1.44.260/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
-github.com/aws/aws-sdk-go v1.44.261 h1:PcTMX/QVk+P3yh2n34UzuXDF5FS2z5Lse2bt+r3IpU4=
-github.com/aws/aws-sdk-go v1.44.261/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
-github.com/aws/aws-sdk-go v1.44.262 h1:gyXpcJptWoNkK+DiAiaBltlreoWKQXjAIh6FRh60F+I=
-github.com/aws/aws-sdk-go v1.44.262/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
-github.com/aws/aws-sdk-go v1.44.263 h1:Dkt5fcdtL8QtK3cz0bOTQ84m9dGx+YDeTsDl+wY2yW4=
-github.com/aws/aws-sdk-go v1.44.263/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
-github.com/aws/aws-sdk-go v1.44.264 h1:5klL62ebn6uv3oJ0ixF7K12hKItj8lV3QqWeQPlkFSs=
-github.com/aws/aws-sdk-go v1.44.264/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aws/aws-sdk-go v1.44.266 h1:MWd775dcYf7NrwgcHLtlsIbWoWkX8p4vomfNHr88zH0=
+github.com/aws/aws-sdk-go v1.44.266/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aws/aws-sdk-go v1.44.268 h1:WoK20tlAvsvQzTcE6TajoprbXmTbcud6MjhErL4P/38=
+github.com/aws/aws-sdk-go v1.44.268/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
 github.com/bmatcuk/doublestar/v4 v4.6.0 h1:HTuxyug8GyFbRkrffIpzNCSK4luc0TY3wzXvzIZhEXc=
 github.com/bmatcuk/doublestar/v4 v4.6.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/caarlos0/env/v7 v7.1.0 h1:9lzTF5amyQeWHZzuZeKlCb5FWSUxpG1js43mhbY8ozg=
 github.com/caarlos0/env/v7 v7.1.0/go.mod h1:LPPWniDUq4JaO6Q41vtlyikhMknqymCLBw0eX4dcH1E=
-github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY=
@@ -110,9 +103,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
-github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI=
-github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -121,16 +111,19 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
+github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/xanzy/go-gitlab v0.83.0 h1:37p0MpTPNbsTMKX/JnmJtY8Ch1sFiJzVF342+RvZEGw=
 github.com/xanzy/go-gitlab v0.83.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw=
+github.com/xanzy/go-gitlab v0.84.0 h1:PdpCaskQSgcVDsx21c6ikf8Rfyo7SNtFAJwP9PrbCFE=
+github.com/xanzy/go-gitlab v0.84.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
diff --git a/pkg/configuration/digger_config.go b/pkg/configuration/digger_config.go
index 6c867960..7458a258 100644
--- a/pkg/configuration/digger_config.go
+++ b/pkg/configuration/digger_config.go
@@ -1,6 +1,7 @@
 package configuration
 
 import (
+	"digger/pkg/utils"
 	"errors"
 	"fmt"
 	"github.com/bmatcuk/doublestar/v4"
@@ -8,7 +9,6 @@ import (
 	"os"
 	"path"
 	"path/filepath"
-	"strings"
 )
 
 type WorkflowConfiguration struct {
@@ -44,11 +44,13 @@ type GenerateProjectsConfig struct {
 }
 
 type Project struct {
-	Name       string `yaml:"name"`
-	Dir        string `yaml:"dir"`
-	Workspace  string `yaml:"workspace"`
-	Terragrunt bool   `yaml:"terragrunt"`
-	Workflow   string `yaml:"workflow"`
+	Name            string   `yaml:"name"`
+	Dir             string   `yaml:"dir"`
+	Workspace       string   `yaml:"workspace"`
+	Terragrunt      bool     `yaml:"terragrunt"`
+	Workflow        string   `yaml:"workflow"`
+	IncludePatterns []string `yaml:"include_patterns,omitempty"`
+	ExcludePatterns []string `yaml:"exclude_patterns,omitempty"`
 }
 
 type Stage struct {
@@ -337,12 +339,14 @@ func (c *DiggerConfig) GetProjects(projectName string) []Project {
 func (c *DiggerConfig) GetModifiedProjects(changedFiles []string) []Project {
 	var result []Project
 	for _, project := range c.Projects {
-		for _, file := range changedFiles {
-			absoluteFile, _ := filepath.Abs(path.Join("/", file))
-			absoluteDir, _ := filepath.Abs(path.Join("/", project.Dir))
-
-			//fmt.Printf("absoluteFile: %s, absoluteDir: %s \n", absoluteFile, absoluteDir)
-			if strings.HasPrefix(absoluteFile, absoluteDir) {
+		for _, changedFile := range changedFiles {
+			// we append ** to make our directory a globable pattern
+			projectDirPattern := path.Join(project.Dir, "**")
+			includePatterns := project.IncludePatterns
+			excludePatterns := project.ExcludePatterns
+			// all our patterns are the globale dir pattern + the include patterns specified by user
+			allIncludePatterns := append([]string{projectDirPattern}, includePatterns...)
+			if utils.MatchIncludeExcludePatternsToFile(changedFile, allIncludePatterns, excludePatterns) {
 				result = append(result, project)
 				break
 			}
diff --git a/pkg/digger/digger.go b/pkg/digger/digger.go
index d2c6144d..1a7d5ec8 100644
--- a/pkg/digger/digger.go
+++ b/pkg/digger/digger.go
@@ -6,6 +6,7 @@ import (
 	"digger/pkg/configuration"
 	"digger/pkg/models"
 	"digger/pkg/terraform"
+	"digger/pkg/usage"
 	"digger/pkg/utils"
 	"encoding/json"
 	"errors"
@@ -102,38 +103,36 @@ func RunCommandsPerProject(commandsPerProject []ProjectCommand, repoOwner string
 			}
 			switch command {
 			case "digger plan":
-				utils.SendUsageRecord(repoOwner, eventName, "plan")
+				usage.SendUsageRecord(repoOwner, eventName, "plan")
 				ciService.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/plan")
-				err := diggerExecutor.Plan(prNumber)
+				planPerformed, err := diggerExecutor.Plan(prNumber)
 				if err != nil {
 					log.Printf("Failed to run digger plan command. %v", err)
 					ciService.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/plan")
-
 					return false, fmt.Errorf("failed to run digger plan command. %v", err)
-				} else {
+				} else if planPerformed {
 					ciService.SetStatus(prNumber, "success", projectCommands.ProjectName+"/plan")
 				}
 			case "digger apply":
-				utils.SendUsageRecord(repoName, eventName, "apply")
+				usage.SendUsageRecord(repoName, eventName, "apply")
 				ciService.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/apply")
-				err := diggerExecutor.Apply(prNumber)
+				applyPerformed, err := diggerExecutor.Apply(prNumber)
 				if err != nil {
 					log.Printf("Failed to run digger apply command. %v", err)
 					ciService.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/apply")
-
 					return false, fmt.Errorf("failed to run digger apply command. %v", err)
-				} else {
+				} else if applyPerformed {
 					ciService.SetStatus(prNumber, "success", projectCommands.ProjectName+"/apply")
 					appliesPerProject[projectCommands.ProjectName] = true
 				}
 			case "digger unlock":
-				utils.SendUsageRecord(repoOwner, eventName, "unlock")
+				usage.SendUsageRecord(repoOwner, eventName, "unlock")
 				err := diggerExecutor.Unlock(prNumber)
 				if err != nil {
 					return false, fmt.Errorf("failed to unlock project. %v", err)
 				}
 			case "digger lock":
-				utils.SendUsageRecord(repoOwner, eventName, "lock")
+				usage.SendUsageRecord(repoOwner, eventName, "lock")
 				err := diggerExecutor.Lock(prNumber)
 				if err != nil {
 					return false, fmt.Errorf("failed to lock project. %v", err)
@@ -430,13 +429,13 @@ func (d DiggerExecutor) storedPlanFilePath() string {
 	return path.Join(d.RepoOwner, d.planFileName())
 }
 
-func (d DiggerExecutor) Plan(prNumber int) error {
-	res, err := d.ProjectLock.Lock(prNumber)
+func (d DiggerExecutor) Plan(prNumber int) (bool, error) {
+	locked, err := d.ProjectLock.Lock(prNumber)
 	if err != nil {
-		return fmt.Errorf("error locking project: %v", err)
+		return false, fmt.Errorf("error locking project: %v", err)
 	}
-	log.Printf("Lock result: %t\n", res)
-	if res {
+	log.Printf("Lock result: %t\n", locked)
+	if locked {
 		var planSteps []configuration.Step
 
 		if d.PlanStage != nil {
@@ -455,7 +454,7 @@ func (d DiggerExecutor) Plan(prNumber int) error {
 			if step.Action == "init" {
 				_, _, err := d.TerraformExecutor.Init(step.ExtraArgs, d.StateEnvVars)
 				if err != nil {
-					return fmt.Errorf("error running init: %v", err)
+					return false, fmt.Errorf("error running init: %v", err)
 				}
 			}
 			if step.Action == "plan" {
@@ -463,12 +462,24 @@ func (d DiggerExecutor) Plan(prNumber int) error {
 				planArgs = append(planArgs, step.ExtraArgs...)
 				isNonEmptyPlan, stdout, stderr, err := d.TerraformExecutor.Plan(planArgs, d.CommandEnvVars)
 				if err != nil {
-					return fmt.Errorf("error executing plan: %v", err)
+					return false, fmt.Errorf("error executing plan: %v", err)
 				}
 				if d.PlanStorage != nil {
+					planExists, err := d.PlanStorage.PlanExists(d.storedPlanFilePath())
+					if err != nil {
+						return false, fmt.Errorf("error checking if plan exists: %v", err)
+					}
+
+					if planExists {
+						err = d.PlanStorage.DeleteStoredPlan(d.storedPlanFilePath())
+						if err != nil {
+							return false, fmt.Errorf("error deleting plan: %v", err)
+						}
+					}
+
 					err = d.PlanStorage.StorePlan(d.localPlanFilePath(), d.storedPlanFilePath())
 					if err != nil {
-						return fmt.Errorf("error storing plan: %v", err)
+						return false, fmt.Errorf("error storing plan: %v", err)
 					}
 				}
 				plan := cleanupTerraformPlan(isNonEmptyPlan, err, stdout, stderr)
@@ -484,34 +495,41 @@ func (d DiggerExecutor) Plan(prNumber int) error {
 				log.Printf("Running %v for **%v**\n", step.Value, d.ProjectLock.LockId())
 				_, _, err := d.CommandRunner.Run(d.ProjectPath, step.Shell, commands)
 				if err != nil {
-					return fmt.Errorf("error running command: %v", err)
+					return false, fmt.Errorf("error running command: %v", err)
 				}
 			}
 		}
+		return true, nil
 	}
-	return nil
+	return false, nil
 }
 
-func (d DiggerExecutor) Apply(prNumber int) error {
+func (d DiggerExecutor) Apply(prNumber int) (bool, error) {
 	var plansFilename *string
 	if d.PlanStorage != nil {
 		var err error
 		plansFilename, err = d.PlanStorage.RetrievePlan(d.localPlanFilePath(), d.storedPlanFilePath())
 		if err != nil {
-			return fmt.Errorf("error retrieving plan: %v", err)
+			return false, fmt.Errorf("error retrieving plan: %v", err)
 		}
 	}
 
 	isMergeable, err := d.CIService.IsMergeable(prNumber)
 	if err != nil {
-		return fmt.Errorf("error validating is PR is mergeable: %v", err)
+		return false, fmt.Errorf("error validating is PR is mergeable: %v", err)
 	}
 	if !isMergeable {
 		comment := "Cannot perform Apply since the PR is not currently mergeable."
 		d.CIService.PublishComment(prNumber, comment)
+		return false, nil
 	} else {
+		locked, err := d.ProjectLock.Lock(prNumber)
+
+		if err != nil {
+			return false, fmt.Errorf("error locking project: %v", err)
+		}
 
-		if res, _ := d.ProjectLock.Lock(prNumber); res {
+		if locked {
 			var applySteps []configuration.Step
 
 			if d.ApplyStage != nil {
@@ -531,7 +549,7 @@ func (d DiggerExecutor) Apply(prNumber int) error {
 				if step.Action == "init" {
 					_, _, err := d.TerraformExecutor.Init(step.ExtraArgs, d.StateEnvVars)
 					if err != nil {
-						return fmt.Errorf("error running init: %v", err)
+						return false, fmt.Errorf("error running init: %v", err)
 					}
 				}
 				if step.Action == "apply" {
@@ -541,7 +559,7 @@ func (d DiggerExecutor) Apply(prNumber int) error {
 					d.CIService.PublishComment(prNumber, comment)
 					if err != nil {
 						d.CIService.PublishComment(prNumber, "Error during applying.")
-						return fmt.Errorf("error executing apply: %v", err)
+						return false, fmt.Errorf("error executing apply: %v", err)
 					}
 				}
 				if step.Action == "run" {
@@ -553,13 +571,15 @@ func (d DiggerExecutor) Apply(prNumber int) error {
 					log.Printf("Running %v for **%v**\n", step.Value, d.ProjectLock.LockId())
 					_, _, err := d.CommandRunner.Run(d.ProjectPath, step.Shell, commands)
 					if err != nil {
-						return fmt.Errorf("error running command: %v", err)
+						return false, fmt.Errorf("error running command: %v", err)
 					}
 				}
 			}
+			return true, nil
+		} else {
+			return false, nil
 		}
 	}
-	return nil
 }
 
 func (d DiggerExecutor) Unlock(prNumber int) error {
@@ -619,7 +639,8 @@ func cleanupTerraformOutput(nonEmptyOutput bool, planError error, stdout string,
 		regex := regexp.MustCompile(*regexStr)
 		matches := regex.FindStringSubmatch(stdout)
 		if len(matches) > 0 {
-			endPos = strings.Index(stdout, matches[0]) + len(matches[0])
+			firstMatch := matches[0]
+			endPos = strings.LastIndex(stdout, firstMatch) + len(firstMatch)
 		}
 	}
 
diff --git a/pkg/github/github.go b/pkg/github/github.go
index 956d3c7f..4e4967cd 100644
--- a/pkg/github/github.go
+++ b/pkg/github/github.go
@@ -90,6 +90,7 @@ func isMergeableState(mergeableState string) bool {
 	// https://docs.github.com/en/github-ae@latest/graphql/reference/enums#mergestatestatus
 	mergeableStates := map[string]int{
 		"clean":     0,
+		"unstable":  0,
 		"has_hooks": 1,
 	}
 	_, exists := mergeableStates[strings.ToLower(mergeableState)]
diff --git a/pkg/gitlab/gitlab.go b/pkg/gitlab/gitlab.go
index a6c09e26..dea32a43 100644
--- a/pkg/gitlab/gitlab.go
+++ b/pkg/gitlab/gitlab.go
@@ -5,14 +5,14 @@ import (
 	"digger/pkg/configuration"
 	"digger/pkg/digger"
 	"digger/pkg/terraform"
+	"digger/pkg/usage"
 	"digger/pkg/utils"
 	"fmt"
+	"github.com/caarlos0/env/v7"
+	go_gitlab "github.com/xanzy/go-gitlab"
 	"log"
 	"path"
 	"strings"
-
-	"github.com/caarlos0/env/v7"
-	go_gitlab "github.com/xanzy/go-gitlab"
 )
 
 // based on https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
@@ -368,38 +368,41 @@ func RunCommandsPerProject(commandsPerProject []digger.ProjectCommand, gitLabCon
 			eventName := ""
 			switch command {
 			case "digger plan":
-				utils.SendUsageRecord(repoOwner, eventName, "plan")
+				usage.SendUsageRecord(repoOwner, eventName, "plan")
 				service.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/plan")
-				err := diggerExecutor.Plan(prNumber)
+				planPerformed, err := diggerExecutor.Plan(prNumber)
 				if err != nil {
 					log.Printf("Failed to run digger plan command. %v", err)
 					service.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/plan")
 
 					return false, fmt.Errorf("failed to run digger plan command. %v", err)
+				} else if !planPerformed {
+					service.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/plan")
 				} else {
 					service.SetStatus(prNumber, "success", projectCommands.ProjectName+"/plan")
 				}
 			case "digger apply":
-				utils.SendUsageRecord(repoName, eventName, "apply")
+				usage.SendUsageRecord(repoName, eventName, "apply")
 				service.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/apply")
-				err := diggerExecutor.Apply(prNumber)
+				applyPerformed, err := diggerExecutor.Apply(prNumber)
 				if err != nil {
 					log.Printf("Failed to run digger apply command. %v", err)
 					service.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/apply")
-
 					return false, fmt.Errorf("failed to run digger apply command. %v", err)
+				} else if !applyPerformed {
+					service.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/apply")
 				} else {
 					service.SetStatus(prNumber, "success", projectCommands.ProjectName+"/apply")
 					appliesPerProject[projectCommands.ProjectName] = true
 				}
 			case "digger unlock":
-				utils.SendUsageRecord(repoOwner, eventName, "unlock")
+				usage.SendUsageRecord(repoOwner, eventName, "unlock")
 				err := diggerExecutor.Unlock(prNumber)
 				if err != nil {
 					return false, fmt.Errorf("failed to unlock project. %v", err)
 				}
 			case "digger lock":
-				utils.SendUsageRecord(repoOwner, eventName, "lock")
+				usage.SendUsageRecord(repoOwner, eventName, "lock")
 				err := diggerExecutor.Lock(prNumber)
 				if err != nil {
 					return false, fmt.Errorf("failed to lock project. %v", err)
diff --git a/pkg/utils/usage.go b/pkg/usage/usage.go
similarity index 99%
rename from pkg/utils/usage.go
rename to pkg/usage/usage.go
index d953438e..dba316d8 100644
--- a/pkg/utils/usage.go
+++ b/pkg/usage/usage.go
@@ -1,4 +1,4 @@
-package utils
+package usage
 
 import (
 	"bytes"
diff --git a/pkg/utils/io.go b/pkg/utils/io.go
index d33fe212..6cd120b5 100644
--- a/pkg/utils/io.go
+++ b/pkg/utils/io.go
@@ -3,8 +3,12 @@ package utils
 import (
 	"archive/zip"
 	"bytes"
+	"github.com/bmatcuk/doublestar/v4"
 	"io"
 	"io/ioutil"
+	"log"
+	"path"
+	"path/filepath"
 	"strings"
 )
 
@@ -66,3 +70,46 @@ func (z *Zipper) GetFileFromZip(zipFile string, filename string) (string, error)
 
 	return "", nil
 }
+
+func NormalizeFileName(fileName string) string {
+	res, err := filepath.Abs(path.Join("/", fileName))
+	if err != nil {
+		log.Fatalf("Failed to convert path to absolute: %v", err)
+	}
+	return res
+}
+
+func MatchIncludeExcludePatternsToFile(fileToMatch string, includePatterns []string, excludePatterns []string) bool {
+	fileToMatch = NormalizeFileName(fileToMatch)
+	for i, _ := range includePatterns {
+		includePatterns[i] = NormalizeFileName(includePatterns[i])
+	}
+	for i, _ := range excludePatterns {
+		excludePatterns[i] = NormalizeFileName(excludePatterns[i])
+	}
+
+	matching := false
+	for _, ipattern := range includePatterns {
+		isMatched, err := doublestar.PathMatch(ipattern, fileToMatch)
+		if err != nil {
+			log.Fatalf("Failed to match modified files (%v, %v): Error: %v", fileToMatch, ipattern, err)
+		}
+		if isMatched {
+			matching = true
+			break
+		}
+	}
+
+	for _, epattern := range excludePatterns {
+		excluded, err := doublestar.PathMatch(epattern, fileToMatch)
+		if err != nil {
+			log.Fatalf("Failed to match modified files (%v, %v): Error: %v", fileToMatch, epattern, err)
+		}
+		if excluded {
+			matching = false
+			break
+		}
+	}
+
+	return matching
+}
diff --git a/pkg/utils/locking.go b/pkg/utils/locking.go
index fd064aff..014a1e2c 100644
--- a/pkg/utils/locking.go
+++ b/pkg/utils/locking.go
@@ -36,6 +36,21 @@ type Lock interface {
 	GetLock(resource string) (*int, error)
 }
 
+type NoOpLock struct {
+}
+
+func (noOpLock *NoOpLock) Lock(transactionId int, resource string) (bool, error) {
+	return true, nil
+}
+
+func (noOpLock *NoOpLock) Unlock(resource string) (bool, error) {
+	return true, nil
+}
+
+func (noOpLock *NoOpLock) GetLock(resource string) (*int, error) {
+	return nil, nil
+}
+
 type ProjectLock interface {
 	Lock(prNumber int) (bool, error)
 	Unlock(prNumber int) (bool, error)
@@ -77,7 +92,9 @@ func (projectLock *ProjectLockImpl) Lock(prNumber int) (bool, error) {
 		return false, err
 	}
 
-	if lockAcquired {
+	_, isNoOpLock := projectLock.InternalLock.(*NoOpLock)
+
+	if lockAcquired && !isNoOpLock {
 		comment := "Project " + projectLock.projectId() + " has been locked by PR #" + strconv.Itoa(prNumber)
 		projectLock.CIService.PublishComment(prNumber, comment)
 		println("project " + projectLock.projectId() + " locked successfully. PR # " + strconv.Itoa(prNumber))
@@ -178,6 +195,11 @@ func GetLock() (Lock, error) {
 	awsRegion := strings.ToLower(os.Getenv("AWS_REGION"))
 	awsProfile := strings.ToLower(os.Getenv("AWS_PROFILE"))
 	lockProvider := strings.ToLower(os.Getenv("LOCK_PROVIDER"))
+	disableLocking := strings.ToLower(os.Getenv("DISABLE_LOCKING")) == "true"
+	if disableLocking {
+		log.Println("Using NoOp lock provider.")
+		return &NoOpLock{}, nil
+	}
 	if lockProvider == "" || lockProvider == "aws" {
 		log.Println("Using AWS lock provider.")
 		sess, err := session.NewSessionWithOptions(session.Options{
diff --git a/pkg/utils/mocks.go b/pkg/utils/mocks.go
index 21f0b88b..39392e4e 100644
--- a/pkg/utils/mocks.go
+++ b/pkg/utils/mocks.go
@@ -88,3 +88,7 @@ func (t MockPlanStorage) RetrievePlan(localPlanFilePath string, storedPlanFilePa
 func (t MockPlanStorage) DeleteStoredPlan(storedPlanFilePath string) error {
 	return nil
 }
+
+func (t MockPlanStorage) PlanExists(storedPlanFilePath string) (bool, error) {
+	return false, nil
+}
diff --git a/pkg/utils/plan_storage.go b/pkg/utils/plan_storage.go
index 92a79328..87d715ff 100644
--- a/pkg/utils/plan_storage.go
+++ b/pkg/utils/plan_storage.go
@@ -18,6 +18,7 @@ type PlanStorage interface {
 	StorePlan(localPlanFilePath string, storedPlanFilePath string) error
 	RetrievePlan(localPlanFilePath string, storedPlanFilePath string) (*string, error)
 	DeleteStoredPlan(storedPlanFilePath string) error
+	PlanExists(storedPlanFilePath string) (bool, error)
 }
 
 type PlanStorageGcp struct {
@@ -34,6 +35,18 @@ type GithubPlanStorage struct {
 	ZipManager        Zipper
 }
 
+func (psg *PlanStorageGcp) PlanExists(storedPlanFilePath string) (bool, error) {
+	obj := psg.Bucket.Object(storedPlanFilePath)
+	_, err := obj.Attrs(psg.Context)
+	if err != nil {
+		if err == storage.ErrObjectNotExist {
+			return false, nil
+		}
+		return false, fmt.Errorf("unable to get object attributes: %v", err)
+	}
+	return true, nil
+}
+
 func (psg *PlanStorageGcp) StorePlan(localPlanFilePath string, storedPlanFilePath string) error {
 	file, err := os.Open(localPlanFilePath)
 	if err != nil {
@@ -114,6 +127,23 @@ func (gps *GithubPlanStorage) RetrievePlan(localPlanFilePath string, storedPlanF
 	return &plansFilename, nil
 }
 
+func (gps *GithubPlanStorage) PlanExists(storedPlanFilePath string) (bool, error) {
+	artifacts, _, err := gps.Client.Actions.ListArtifacts(context.Background(), gps.Owner, gps.RepoName, &github.ListOptions{
+		PerPage: 100,
+	})
+
+	if err != nil {
+		return false, err
+	}
+
+	latestPlans := getLatestArtifactWithName(artifacts.Artifacts, "plans-"+strconv.Itoa(gps.PullRequestNumber))
+
+	if latestPlans == nil {
+		return false, nil
+	}
+	return true, nil
+}
+
 func (gps *GithubPlanStorage) DeleteStoredPlan(storedPlanFilePath string) error {
 	return nil
 }


Test patch:
diff --git a/pkg/digger/digger_test.go b/pkg/digger/digger_test.go
index a6621ced..a0ad1155 100644
--- a/pkg/digger/digger_test.go
+++ b/pkg/digger/digger_test.go
@@ -143,6 +143,11 @@ func (m *MockPlanStorage) DeleteStoredPlan(storedPlanFilePath string) error {
 	return nil
 }
 
+func (m *MockPlanStorage) PlanExists(storedPlanFilePath string) (bool, error) {
+	m.Commands = append(m.Commands, RunInfo{"PlanExists", storedPlanFilePath, time.Now()})
+	return false, nil
+}
+
 func TestCorrectCommandExecutionWhenApplying(t *testing.T) {
 
 	commandRunner := &MockCommandRunner{}
@@ -224,7 +229,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) {
 
 	commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage)
 
-	assert.Equal(t, []string{"Lock 1", "Init ", "Plan -out #.tfplan", "StorePlan #.tfplan", "LockId ", "PublishComment 1 <details>\n  <summary>Plan for ****</summary>\n\n  ```terraform\n\n  ```\n</details>", "LockId ", "Run   echo"}, commandStrings)
+	assert.Equal(t, []string{"Lock 1", "Init ", "Plan -out #.tfplan", "PlanExists #.tfplan", "StorePlan #.tfplan", "LockId ", "PublishComment 1 <details>\n  <summary>Plan for ****</summary>\n\n  ```terraform\n\n  ```\n</details>", "LockId ", "Run   echo"}, commandStrings)
 }
 
 func allCommandsInOrderWithParams(terraformExecutor *MockTerraformExecutor, commandRunner *MockCommandRunner, prManager *MockPRManager, lock *MockProjectLock, planStorage *MockPlanStorage) []string {
diff --git a/pkg/utils/usage_test.go b/pkg/usage/usage_test.go
similarity index 95%
rename from pkg/utils/usage_test.go
rename to pkg/usage/usage_test.go
index 48a46c27..5e7555a0 100644
--- a/pkg/utils/usage_test.go
+++ b/pkg/usage/usage_test.go
@@ -1,4 +1,4 @@
-package utils
+package usage
 
 import (
 	"github.com/stretchr/testify/assert"
diff --git a/pkg/utils/io_test.go b/pkg/utils/io_test.go
index 23f91155..17767e9f 100644
--- a/pkg/utils/io_test.go
+++ b/pkg/utils/io_test.go
@@ -77,3 +77,40 @@ func TestGetFileFromZipNoFileExists(t *testing.T) {
 	// Clean up the test files.
 	os.Remove(zipFileName)
 }
+
+func TestNormalizeFileName(t *testing.T) {
+	type NormTest struct {
+		input, expectedOutput string // input and expected normalized output
+	}
+	var normTests = []NormTest{
+		{"my/directory", "/my/directory"},
+		{"./my/directory", "/my/directory"},
+	}
+	for _, tt := range normTests {
+		res := NormalizeFileName(tt.input)
+		assert.Equal(t, tt.expectedOutput, res)
+	}
+}
+
+func TestMatchIncludeExcludePatternsToFile(t *testing.T) {
+	type MatchTest struct {
+		fileToMatch     string
+		includePatterns []string
+		excludePatterns []string
+		expectedResult  bool
+	}
+	var normTests = []MatchTest{
+		{"dev/main.tf", []string{"dev/**"}, []string{}, true},
+		{"dev/main.tf", []string{"./dev/**"}, []string{}, true},
+		{"dev/main.tf", []string{"dev/**"}, []string{"dev/"}, true},
+		{"dev/main.tf", []string{"prod/**"}, []string{"dev/"}, false},
+		{"modules/moduleA/main.tf", []string{"dev**", "modules/**"}, []string{"dev/"}, true},
+		{"modules/moduleA/main.tf", []string{"dev**", "./modules/**"}, []string{"dev/"}, true},
+		{"modules/moduleA/main.tf", []string{"dev**"}, []string{"dev/"}, false},
+		{"modules/moduleA/main.tf", []string{"dev**", "modules/**"}, []string{"modules/moduleA/**"}, false},
+	}
+	for _, tt := range normTests {
+		res := MatchIncludeExcludePatternsToFile(tt.fileToMatch, tt.includePatterns, tt.excludePatterns)
+		assert.Equal(t, tt.expectedResult, res)
+	}
+}