Repository: seachicken/gh-poi (go)

Issue:
Protected Branches
**Description**

We use a git branching strategy to have our branches mirror deployment environments (Stage and Prod). Because we merge code between the branches, `poi` deletes these protected branches locally and checks out main, but I don't want to switch to main if Prod is currently checked out.

Could there be a command line option to ignore certain branches following a regex or string? Something like one of:

```sh
gh poi -keep stage -keep prod
# or
gh poi -keep "stage|prod"
```

Pull request:
Add `poi protect/unprotect` commands
Resolves #92 

Support for local branch protection.

```sh
gh poi protect <branch>...
gh poi unprotect <branch>...
```
<img width="456" alt="image" src="https://user-images.githubusercontent.com/5178598/213139716-b4be015f-466a-4842-acbf-3125d210c66e.png">



Patch:
diff --git a/README.md b/README.md
index 6b4cbea..e88b5d0 100644
--- a/README.md
+++ b/README.md
@@ -26,3 +26,5 @@ gh extension install seachicken/gh-poi
 - `gh poi` Delete the merged local branches
 - `gh poi --dry-run` You can check the branch to be deleted without actually deleting it
 - `gh poi --debug` Enable debug logs
+- `gh poi protect <branchname>...` Protect local branches from deletion
+- `gh poi unprotect <branchname>...` Unprotect local branches
diff --git a/conn/command.go b/conn/command.go
index b90f1f9..ac6750c 100644
--- a/conn/command.go
+++ b/conn/command.go
@@ -159,6 +159,20 @@ func (conn *Connection) GetConfig(ctx context.Context, key string) (string, erro
 	return conn.run(ctx, "git", args, None)
 }
 
+func (conn *Connection) AddConfig(ctx context.Context, key string, value string) (string, error) {
+	args := []string{
+		"config", "--add", key, value,
+	}
+	return conn.run(ctx, "git", args, None)
+}
+
+func (conn *Connection) RemoveConfig(ctx context.Context, key string) (string, error) {
+	args := []string{
+		"config", "--unset", key,
+	}
+	return conn.run(ctx, "git", args, None)
+}
+
 func (conn *Connection) CheckoutBranch(ctx context.Context, branchName string) (string, error) {
 	args := []string{
 		"checkout", "--quiet", branchName,
diff --git a/conn/fixtures/git/config_empty.txt b/conn/fixtures/git/config_empty.txt
new file mode 100644
index 0000000..e69de29
diff --git a/conn/fixtures/git/config_protected.txt b/conn/fixtures/git/config_protected.txt
new file mode 100644
index 0000000..27ba77d
--- /dev/null
+++ b/conn/fixtures/git/config_protected.txt
@@ -0,0 +1,1 @@
+true
diff --git a/main.go b/main.go
index f6983e5..f69106a 100644
--- a/main.go
+++ b/main.go
@@ -28,8 +28,27 @@ func main() {
 	flag.BoolVar(&debug, "debug", false, "Enable debug logs")
 	flag.BoolVar(&dryRun, "check", false, "[Deprecated] Show branches to delete")
 	flag.Parse()
+	args := flag.Args()
 
-	runMain(dryRun, debug)
+	if len(args) == 0 {
+		runMain(dryRun, debug)
+	} else {
+		subcmd, args := args[0], args[1:]
+		switch subcmd {
+		case "protect":
+			protectCmd := flag.NewFlagSet("protect", flag.ExitOnError)
+			protectCmd.Parse(args)
+
+			runProtect(args, debug)
+		case "unprotect":
+			unprotectCmd := flag.NewFlagSet("unprotect", flag.ExitOnError)
+			unprotectCmd.Parse(args)
+
+			runUnprotect(args, debug)
+		default:
+			fmt.Fprintf(os.Stderr, "unknown command %q for poi\n", subcmd)
+		}
+	}
 }
 
 func runMain(dryRun bool, debug bool) {
@@ -115,6 +134,32 @@ func runMain(dryRun bool, debug bool) {
 	fmt.Println()
 }
 
+func runProtect(branchNames []string, debug bool) {
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+	defer stop()
+
+	connection := &conn.Connection{Debug: debug}
+
+	err := ProtectBranches(ctx, branchNames, connection)
+	if err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		return
+	}
+}
+
+func runUnprotect(branchNames []string, debug bool) {
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+	defer stop()
+
+	connection := &conn.Connection{Debug: debug}
+
+	err := UnprotectBranches(ctx, branchNames, connection)
+	if err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		return
+	}
+}
+
 func printBranches(branches []Branch) {
 	if len(branches) == 0 {
 		fmt.Fprintf(color.Output, "%s\n",
@@ -123,9 +168,18 @@ func printBranches(branches []Branch) {
 
 	for _, branch := range branches {
 		if branch.Head {
-			fmt.Fprintf(color.Output, "* %s\n", green(branch.Name))
+			fmt.Fprintf(color.Output, "* %s", green(branch.Name))
+		} else {
+			fmt.Fprintf(color.Output, "  %s", white(branch.Name))
+		}
+		reason := ""
+		if branch.IsProtected {
+			reason = "protected"
+		}
+		if reason == "" {
+			fmt.Fprintln(color.Output, "")
 		} else {
-			fmt.Fprintf(color.Output, "  %s\n", white(branch.Name))
+			fmt.Fprintf(color.Output, " %s\n", hiBlack("["+reason+"]"))
 		}
 
 		for i, pr := range branch.PullRequests {
diff --git a/mocks/poi_mock.go b/mocks/poi_mock.go
index 62217af..4e133c1 100644
--- a/mocks/poi_mock.go
+++ b/mocks/poi_mock.go
@@ -34,6 +34,21 @@ func (m *MockConnection) EXPECT() *MockConnectionMockRecorder {
 	return m.recorder
 }
 
+// AddConfig mocks base method.
+func (m *MockConnection) AddConfig(ctx context.Context, key, value string) (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AddConfig", ctx, key, value)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// AddConfig indicates an expected call of AddConfig.
+func (mr *MockConnectionMockRecorder) AddConfig(ctx, key, value interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddConfig", reflect.TypeOf((*MockConnection)(nil).AddConfig), ctx, key, value)
+}
+
 // CheckRepos mocks base method.
 func (m *MockConnection) CheckRepos(ctx context.Context, hostname string, repoNames []string) error {
 	m.ctrl.T.Helper()
@@ -257,3 +272,18 @@ func (mr *MockConnectionMockRecorder) GetUncommittedChanges(ctx interface{}) *go
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUncommittedChanges", reflect.TypeOf((*MockConnection)(nil).GetUncommittedChanges), ctx)
 }
+
+// RemoveConfig mocks base method.
+func (m *MockConnection) RemoveConfig(ctx context.Context, key string) (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "RemoveConfig", ctx, key)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// RemoveConfig indicates an expected call of RemoveConfig.
+func (mr *MockConnectionMockRecorder) RemoveConfig(ctx, key interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveConfig", reflect.TypeOf((*MockConnection)(nil).RemoveConfig), ctx, key)
+}
diff --git a/poi.go b/poi.go
index 5f6e7b3..e2f8144 100644
--- a/poi.go
+++ b/poi.go
@@ -28,6 +28,8 @@ type (
 		GetPullRequests(ctx context.Context, hostname string, orgs string, repos string, queryHashes string) (string, error)
 		GetUncommittedChanges(ctx context.Context) (string, error)
 		GetConfig(ctx context.Context, key string) (string, error)
+		AddConfig(ctx context.Context, key string, value string) (string, error)
+		RemoveConfig(ctx context.Context, key string) (string, error)
 		CheckoutBranch(ctx context.Context, branchName string) (string, error)
 		DeleteBranches(ctx context.Context, branchNames []string) (string, error)
 	}
@@ -44,6 +46,7 @@ type (
 		Head          bool
 		Name          string
 		IsMerged      bool
+		IsProtected   bool
 		RemoteHeadOid string
 		Commits       []string
 		PullRequests  []PullRequest
@@ -133,6 +136,10 @@ func GetBranches(ctx context.Context, remote Remote, connection Connection, dryR
 			return nil, err
 		}
 		branches = applyMerged(branches, extractMergedBranchNames(splitLines(mergedNames)))
+		branches, err = applyProtected(ctx, branches, connection)
+		if err != nil {
+			return nil, err
+		}
 		branches, err = applyCommits(ctx, remote, branches, defaultBranchName, connection)
 		if err != nil {
 			return nil, err
@@ -190,6 +197,7 @@ func GetBranches(ctx context.Context, remote Remote, connection Connection, dryR
 			result = append(result, Branch{
 				true, defaultBranchName,
 				false,
+				false,
 				"",
 				[]string{},
 				[]PullRequest{},
@@ -291,6 +299,21 @@ func nameExists(name string, names []string) bool {
 	return false
 }
 
+func applyProtected(ctx context.Context, branches []Branch, connection Connection) ([]Branch, error) {
+	results := []Branch{}
+
+	for _, branch := range branches {
+		config, _ := connection.GetConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", branch.Name))
+		splitConfig := splitLines(config)
+		if len(splitConfig) > 0 && splitConfig[0] == "true" {
+			branch.IsProtected = true
+		}
+		results = append(results, branch)
+	}
+
+	return results, nil
+}
+
 func applyCommits(ctx context.Context, remote Remote, branches []Branch, defaultBranchName string, connection Connection) ([]Branch, error) {
 	results := []Branch{}
 
@@ -489,6 +512,10 @@ func checkDeletion(branches []Branch, uncommittedChanges []UncommittedChange) []
 }
 
 func getDeleteStatus(branch Branch, uncommittedChanges []UncommittedChange) BranchState {
+	if branch.IsProtected {
+		return NotDeletable
+	}
+
 	hasTrackedChanges := false
 	for _, change := range uncommittedChanges {
 		if !change.IsUntracked() {
@@ -545,6 +572,7 @@ func toBranch(branchNames []string) []Branch {
 			splitedNames[0] == "*",
 			splitedNames[1],
 			false,
+			false,
 			"",
 			[]string{},
 			[]PullRequest{},
diff --git a/protect.go b/protect.go
new file mode 100644
index 0000000..7b9ee3a
--- /dev/null
+++ b/protect.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+	"context"
+	"fmt"
+)
+
+func ProtectBranches(ctx context.Context, targetBranchNames []string, connection Connection) error {
+	branchNameResults, err := connection.GetBranchNames(ctx)
+	if err != nil {
+		return err
+	}
+	branches := toBranch(splitLines(branchNameResults))
+
+	for _, targetName := range targetBranchNames {
+		if branchNameExists(targetName, branches) {
+			connection.RemoveConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", targetName))
+			_, err = connection.AddConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", targetName), "true")
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func UnprotectBranches(ctx context.Context, targetBranchNames []string, connection Connection) error {
+	branchNameResults, err := connection.GetBranchNames(ctx)
+	if err != nil {
+		return err
+	}
+	branches := toBranch(splitLines(branchNameResults))
+
+	for _, targetName := range targetBranchNames {
+		if branchNameExists(targetName, branches) {
+			connection.RemoveConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", targetName))
+		}
+	}
+
+	return nil
+}


Test patch:
diff --git a/conn/command_test.go b/conn/command_test.go
index f6af4af..62e31e5 100644
--- a/conn/command_test.go
+++ b/conn/command_test.go
@@ -83,6 +83,26 @@ func Test_RepoBasic(t *testing.T) {
 			actual,
 		)
 	})
+
+	t.Run("AddConfig", func(t *testing.T) {
+		conn.AddConfig(context.Background(), "branch.issue2.gh-poi-protected", "true")
+		actual, _ := conn.GetConfig(context.Background(), "branch.issue2.gh-poi-protected")
+		assert.Equal(t,
+			stub.readFile("git", "config", "protected"),
+			actual,
+		)
+		conn.RemoveConfig(context.Background(), "branch.issue2.gh-poi-protected")
+	})
+
+	t.Run("AddAndRemoveConfig", func(t *testing.T) {
+		conn.AddConfig(context.Background(), "branch.issue2.gh-poi-protected", "true")
+		conn.RemoveConfig(context.Background(), "branch.issue2.gh-poi-protected")
+		actual, _ := conn.GetConfig(context.Background(), "branch.issue2.gh-poi-protected")
+		assert.Equal(t,
+			stub.readFile("git", "config", "empty"),
+			actual,
+		)
+	})
 }
 
 func setGitDir(repoName string, t *testing.T) {
diff --git a/main_test.go b/main_test.go
index 1f0d14e..287071a 100644
--- a/main_test.go
+++ b/main_test.go
@@ -29,6 +29,16 @@ func Test_DoNotDeleteBranchesWhenTheDryRunOptionIsTrue(t *testing.T) {
 	assert.Contains(t, actual, expected)
 }
 
+func Test_ProtectAndUnprotect(t *testing.T) {
+	onlyCI(t)
+
+	actualProtect := captureOutput(func() { runProtect([]string{"main"}, false) })
+	assert.Equal(t, actualProtect, "")
+
+	actualUnprotect := captureOutput(func() { runUnprotect([]string{"main"}, false) })
+	assert.Equal(t, actualUnprotect, "")
+}
+
 func onlyCI(t *testing.T) {
 	if os.Getenv("CI") == "" {
 		t.Skip("skipping test in local")
diff --git a/poi_test.go b/poi_test.go
index 26adb0f..87f7c4a 100644
--- a/poi_test.go
+++ b/poi_test.go
@@ -33,7 +33,9 @@ func Test_ShouldBeDeletableWhenRemoteBranchesAssociatedWithMergedPR(t *testing.T
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -68,8 +70,10 @@ func Test_ShouldBeDeletableWhenLsRemoteBranchesAssociatedWithMergedPR(t *testing
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -107,8 +111,10 @@ func Test_ShouldBeDeletableWhenBranchesAssociatedWithMergedPR(t *testing.T) {
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -145,8 +151,10 @@ func Test_ShouldBeDeletableWhenBranchesAssociatedWithSquashAndMergedPR(t *testin
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -183,8 +191,10 @@ func Test_ShouldBeDeletableWhenBranchesAssociatedWithUpstreamSquashAndMergedPR(t
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -220,9 +230,11 @@ func Test_ShouldBeDeletableWhenPRCheckoutBranchesAssociatedWithUpstreamSquashAnd
 		GetPullRequests("forkMainUpMerged", nil, nil).
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
-			{BranchName: "branch.fork/main.merge", Filename: "mergeForkMain"},
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
+			{BranchName: "branch.fork/main.merge", Filename: "mergeForkMain"},
 			{BranchName: "branch.fork/main.remote", Filename: "remote"},
+			{BranchName: "branch.fork/main.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -259,8 +271,10 @@ func Test_ShouldBeDeletableWhenBranchIsCheckedOutWithTheCheckIsFalse(t *testing.
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil).
 		CheckoutBranch(nil, conn.NewConf(&conn.Times{N: 1}))
 	remote, _ := GetRemote(context.Background(), s.Conn)
@@ -298,8 +312,10 @@ func Test_ShouldBeDeletableWhenBranchIsCheckedOutWithTheCheckIsTrue(t *testing.T
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil).
 		CheckoutBranch(nil, conn.NewConf(&conn.Times{N: 0}))
 	remote, _ := GetRemote(context.Background(), s.Conn)
@@ -338,6 +354,7 @@ func Test_ShouldBeDeletableWhenBranchIsCheckedOutWithoutADefaultBranch(t *testin
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil).
 		CheckoutBranch(nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
@@ -375,8 +392,10 @@ func Test_ShouldNotDeletableWhenBranchHasModifiedUncommittedChanges(t *testing.T
 		GetUncommittedChanges(" M README.md", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil).
 		CheckoutBranch(nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
@@ -414,8 +433,10 @@ func Test_ShouldBeDeletableWhenBranchHasUntrackedUncommittedChanges(t *testing.T
 		GetUncommittedChanges("?? new.txt", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil).
 		CheckoutBranch(nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
@@ -453,8 +474,10 @@ func Test_ShouldNotDeletableWhenBranchesAssociatedWithClosedPR(t *testing.T) {
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -491,8 +514,10 @@ func Test_ShouldBeDeletableWhenBranchesAssociatedWithSquashAndMergedAndClosedPRs
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -531,8 +556,10 @@ func Test_ShouldNotDeletableWhenBranchesAssociatedWithNotFullyMergedPR(t *testin
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -569,8 +596,46 @@ func Test_ShouldNotDeletableWhenDefaultBranchAssociatedWithMergedPR(t *testing.T
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
+		}, nil, nil)
+	remote, _ := GetRemote(context.Background(), s.Conn)
+
+	actual, _ := GetBranches(context.Background(), remote, s.Conn, false)
+
+	assert.Equal(t, 2, len(actual))
+	assert.Equal(t, "issue1", actual[0].Name)
+	assert.Equal(t, NotDeletable, actual[0].State)
+	assert.Equal(t, "main", actual[1].Name)
+	assert.Equal(t, NotDeletable, actual[1].State)
+}
+
+func Test_ShouldNotDeletableWhenBranchIsProtected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	s := conn.Setup(ctrl).
+		CheckRepos(nil, nil).
+		GetRemoteNames("origin", nil, nil).
+		GetSshConfig("github.com", nil, nil).
+		GetRepoNames("origin", nil, nil).
+		GetBranchNames("@main_issue1", nil, nil).
+		GetMergedBranchNames("@main_issue1", nil, nil).
+		GetRemoteHeadOid([]conn.RemoteHeadStub{
+			{BranchName: "issue1", Filename: "issue1"},
+		}, nil, nil).
+		GetLog([]conn.LogStub{
+			{BranchName: "main", Filename: "main_issue1Merged"}, {BranchName: "issue1", Filename: "issue1Merged"},
+		}, nil, nil).
+		GetPullRequests("issue1Merged", nil, nil).
+		GetUncommittedChanges("", nil, nil).
+		GetConfig([]conn.ConfigStub{
+			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
+			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "protected"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -578,8 +643,10 @@ func Test_ShouldNotDeletableWhenDefaultBranchAssociatedWithMergedPR(t *testing.T
 
 	assert.Equal(t, 2, len(actual))
 	assert.Equal(t, "issue1", actual[0].Name)
+	assert.Equal(t, true, actual[0].IsProtected)
 	assert.Equal(t, NotDeletable, actual[0].State)
 	assert.Equal(t, "main", actual[1].Name)
+	assert.Equal(t, false, actual[1].IsProtected)
 	assert.Equal(t, NotDeletable, actual[1].State)
 }
 
@@ -609,8 +676,10 @@ func Test_BranchesAndPRsAreNotAssociatedWhenManyLocalCommitsAreAhead(t *testing.
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -647,8 +716,10 @@ func Test_ShouldBeNoCommitHistoryWhenTheFirstCommitOfATopicBranchIsAssociatedWit
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -685,6 +756,8 @@ func Test_ShouldBeNoCommitHistoryWhenDetachedBranch(t *testing.T) {
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
+			{BranchName: "branch.(HEAD detached at a97e963).gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -734,8 +807,10 @@ func Test_DoesNotReturnsAnErrorWhenGetSshConfigFails(t *testing.T) {
 		GetUncommittedChanges("", nil, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -827,7 +902,9 @@ func Test_ReturnsAnErrorWhenGetLogFails(t *testing.T) {
 			{BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"},
 		}, ErrCommand, nil).
 		GetConfig([]conn.ConfigStub{
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -857,7 +934,9 @@ func Test_ReturnsAnErrorWhenGetAssociatedRefNamesFails(t *testing.T) {
 			{Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"},
 		}, ErrCommand, nil).
 		GetConfig([]conn.ConfigStub{
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -888,7 +967,9 @@ func Test_ReturnsAnErrorWhenGetPullRequestsFails(t *testing.T) {
 		}, nil, nil).
 		GetPullRequests("issue1Merged", ErrCommand, nil).
 		GetConfig([]conn.ConfigStub{
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -921,8 +1002,10 @@ func Test_ReturnsAnErrorWhenGetUncommittedChangesFails(t *testing.T) {
 		GetUncommittedChanges("", ErrCommand, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -956,8 +1039,10 @@ func Test_ReturnsAnErrorWhenCheckoutBranchFails(t *testing.T) {
 		CheckoutBranch(ErrCommand, nil).
 		GetConfig([]conn.ConfigStub{
 			{BranchName: "branch.main.merge", Filename: "mergeMain"},
+			{BranchName: "branch.main.gh-poi-protected", Filename: "empty"},
 			{BranchName: "branch.issue1.merge", Filename: "mergeIssue1"},
 			{BranchName: "branch.issue1.remote", Filename: "remote"},
+			{BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"},
 		}, nil, nil)
 	remote, _ := GetRemote(context.Background(), s.Conn)
 
@@ -975,8 +1060,8 @@ func Test_DeletingDeletableBranches(t *testing.T) {
 		DeleteBranches(nil, conn.NewConf(&conn.Times{N: 1}))
 
 	branches := []Branch{
-		{false, "issue1", false, "", []string{}, []PullRequest{}, Deletable},
-		{true, "main", true, "", []string{}, []PullRequest{}, NotDeletable},
+		{false, "issue1", false, false, "", []string{}, []PullRequest{}, Deletable},
+		{true, "main", true, false, "", []string{}, []PullRequest{}, NotDeletable},
 	}
 
 	actual, _ := DeleteBranches(context.Background(), branches, s.Conn)
@@ -996,8 +1081,8 @@ func Test_DoNotDeleteNotDeletableBranches(t *testing.T) {
 		DeleteBranches(nil, conn.NewConf(&conn.Times{N: 0}))
 
 	branches := []Branch{
-		{false, "issue1", false, "", []string{}, []PullRequest{}, NotDeletable},
-		{true, "main", true, "", []string{}, []PullRequest{}, NotDeletable},
+		{false, "issue1", false, false, "", []string{}, []PullRequest{}, NotDeletable},
+		{true, "main", true, false, "", []string{}, []PullRequest{}, NotDeletable},
 	}
 
 	actual, _ := DeleteBranches(context.Background(), branches, s.Conn)
diff --git a/querygen_test.go b/querygen_test.go
index 87ea343..ed10ad6 100644
--- a/querygen_test.go
+++ b/querygen_test.go
@@ -31,19 +31,19 @@ func Test_GetQueryHashesWithCommitOid(t *testing.T) {
 			"hash:c1dfd96eea8cc2b62785275bca38ac261256e278",
 		},
 		getQueryHashes([]Branch{
-			{false, "main", false,
+			{false, "main", false, false,
 				"",
 				[]string{},
 				[]PullRequest{}, Unknown,
 			},
-			{true, "issue1", false,
+			{true, "issue1", false, false,
 				"",
 				[]string{
 					"356a192b7913b04c54574d18c28d46e6395428ab",
 				},
 				[]PullRequest{}, Unknown,
 			},
-			{false, "issue2", false,
+			{false, "issue2", false, false,
 				"",
 				[]string{
 					"da4b9237bacccdf19c0760cab7aec4a8359010b0",
@@ -51,28 +51,28 @@ func Test_GetQueryHashesWithCommitOid(t *testing.T) {
 				},
 				[]PullRequest{}, Unknown,
 			},
-			{false, "issue3", false,
+			{false, "issue3", false, false,
 				"",
 				[]string{
 					"77de68daecd823babbb58edb1c8e14d7106e83bb",
 				},
 				[]PullRequest{}, Unknown,
 			},
-			{false, "issue4", false,
+			{false, "issue4", false, false,
 				"",
 				[]string{
 					"1b6453892473a467d07372d45eb05abc2031647a",
 				},
 				[]PullRequest{}, Unknown,
 			},
-			{false, "issue5", false,
+			{false, "issue5", false, false,
 				"",
 				[]string{
 					"ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4",
 				},
 				[]PullRequest{}, Unknown,
 			},
-			{false, "issue6", false,
+			{false, "issue6", false, false,
 				"",
 				[]string{
 					"c1dfd96eea8cc2b62785275bca38ac261256e278",
@@ -89,7 +89,7 @@ func Test_GetQueryHashesWithRemoteOid(t *testing.T) {
 			"hash:356a192b7913b04c54574d18c28d46e6395428ab",
 		},
 		getQueryHashes([]Branch{
-			{true, "issue1", false,
+			{true, "issue1", false, false,
 				"356a192b7913b04c54574d18c28d46e6395428ab",
 				[]string{
 					"da4b9237bacccdf19c0760cab7aec4a8359010b0",