Skip to content

[Feat] Unified worktree protection policy for destructive operations#64

Merged
samzong merged 2 commits intomainfrom
feat/unified-protection
Apr 3, 2026
Merged

[Feat] Unified worktree protection policy for destructive operations#64
samzong merged 2 commits intomainfrom
feat/unified-protection

Conversation

@samzong
Copy link
Copy Markdown
Owner

@samzong samzong commented Apr 3, 2026

What's changed?

  • Add ProtectionPolicy struct (IsProtected / Reason) as a single predicate for worktree protection
  • Wire protection into Remove, Promote, and Prune — replaces scattered inline bare/main checks
  • Unify main branch resolution: getDefaultBranch, findMainWorktreePath, and prune all go through resolveBaseBranchWithPolicy
  • Add --all / -a flag to gmc wt rm that removes all non-protected worktrees
  • Contextual error messages reflect why a worktree is protected (bare repository / main worktree / main branch)
  • Bare-layout repos correctly use HEAD fallback for non-main/master default branches

Why

  • Protection was inconsistent: Remove only checked bare, Promote had no check at all, Prune had its own inline logic
  • Main branch identification was scattered across three different functions with different resolution strategies
  • --all is a common workflow need when cleaning up after parallel development sessions

Add ProtectionPolicy struct with IsProtected/Reason methods as a single
predicate for bare, main-branch, and root-worktree protection. Wire into
Remove, Promote, and Prune to replace scattered inline checks.

- Unify main branch resolution via resolveBaseBranchWithPolicy
- Add --all/-a flag to wt rm (skips protected, removes the rest)
- Contextual error messages: bare repository / main worktree / main branch
- Bare-layout HEAD fallback for non-main/master default branches

Signed-off-by: samzong <[email protected]>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a --all flag for the worktree remove command and implements a centralized ProtectionPolicy to prevent the accidental removal or promotion of critical worktrees, such as the main branch or bare repository. The feedback focuses on the safety of the ProtectionPolicy implementation; specifically, the NewProtectionPolicy function and its helpers currently swallow errors when resolving the repository root or main branch. It is recommended to update these methods to return and handle errors to ensure that bulk operations do not proceed with an incomplete or incorrect protection state.

Comment thread cmd/worktree.go Outdated
return nil, err
}

pp := wtClient.NewProtectionPolicy()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The NewProtectionPolicy function can fail if it cannot resolve the repository root or the main branch. This error should be handled to prevent proceeding with an incomplete protection policy, which could lead to accidental removal of protected worktrees.

	pp, err := wtClient.NewProtectionPolicy()
	if err != nil {
		return nil, err
	}

Comment thread internal/worktree/prune.go Outdated
repoDir := repoDirForGit(root)
isBare := repoDir != root

pp := c.NewProtectionPolicy()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Handle the error from NewProtectionPolicy to ensure that pruning candidates are correctly identified and protected worktrees are not accidentally included.

	pp, err := c.NewProtectionPolicy()
	if err != nil {
		return nil, "", err
	}

Comment thread internal/worktree/share_discover.go Outdated
for _, wt := range worktrees {
if wt.Branch == "main" || wt.Branch == "master" {
return wt.Path, nil
mainBranch := c.resolvedMainBranch()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Handle the potential error from resolvedMainBranch to ensure the main worktree path is correctly identified.

	mainBranch, err := c.resolvedMainBranch()
	if err != nil {
		return "", err
	}

Comment thread internal/worktree/worktree.go Outdated
Comment on lines +487 to +488
pp := c.NewProtectionPolicy()
if pp.IsProtected(wtInfo) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

It is critical to handle errors when initializing the protection policy. If the policy fails to resolve the main branch, it might silently allow the removal of the main worktree.

	pp, err := c.NewProtectionPolicy()
	if err != nil {
		return removeContext{}, err
	}
	if pp.IsProtected(wtInfo) {

Comment thread internal/worktree/worktree.go Outdated
return report, errors.New("worktree is in detached HEAD state, cannot promote")
}

pp := c.NewProtectionPolicy()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Ensure the protection policy is correctly initialized before checking if a worktree can be promoted.

	pp, err := c.NewProtectionPolicy()
	if err != nil {
		return report, err
	}

Comment thread internal/worktree/worktree.go Outdated
Comment on lines +769 to +784
func (c *Client) NewProtectionPolicy() ProtectionPolicy {
var p ProtectionPolicy
root, err := c.GetWorktreeRoot()
if err != nil {
return p
}
p.RootPath = root
repoDir := repoDirForGit(root)
isBareLayout := repoDir != root
branch, err := c.resolveBaseBranchWithPolicy(repoDir, "", isBareLayout)
if err != nil {
return p
}
p.MainBranch = localBranchName(branch)
return p
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The NewProtectionPolicy function currently swallows errors from GetWorktreeRoot and resolveBaseBranchWithPolicy. This is a significant safety risk: if the main branch cannot be resolved (e.g., due to a git error), the policy will have an empty MainBranch, and IsProtected will return false for the main branch worktree. This could lead to accidental deletion of the main branch during bulk operations like rm --all. The function should return an error so callers can abort safely.

Suggested change
func (c *Client) NewProtectionPolicy() ProtectionPolicy {
var p ProtectionPolicy
root, err := c.GetWorktreeRoot()
if err != nil {
return p
}
p.RootPath = root
repoDir := repoDirForGit(root)
isBareLayout := repoDir != root
branch, err := c.resolveBaseBranchWithPolicy(repoDir, "", isBareLayout)
if err != nil {
return p
}
p.MainBranch = localBranchName(branch)
return p
}
func (c *Client) NewProtectionPolicy() (ProtectionPolicy, error) {
var p ProtectionPolicy
root, err := c.GetWorktreeRoot()
if err != nil {
return p, fmt.Errorf("failed to get worktree root: %w", err)
}
p.RootPath = root
repoDir := repoDirForGit(root)
isBareLayout := repoDir != root
branch, err := c.resolveBaseBranchWithPolicy(repoDir, "", isBareLayout)
if err != nil {
return p, fmt.Errorf("failed to resolve main branch: %w", err)
}
p.MainBranch = localBranchName(branch)
return p, nil
}

Comment thread internal/worktree/worktree.go Outdated
Comment on lines +809 to +815
func (c *Client) IsProtectedWorktree(wt Info) bool {
return c.NewProtectionPolicy().IsProtected(wt)
}

func (c *Client) resolvedMainBranch() string {
return c.NewProtectionPolicy().MainBranch
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update these helper methods to propagate errors from NewProtectionPolicy.

func (c *Client) IsProtectedWorktree(wt Info) (bool, error) {
	pp, err := c.NewProtectionPolicy()
	if err != nil {
		return false, err
	}
	return pp.IsProtected(wt), nil
}

func (c *Client) resolvedMainBranch() (string, error) {
	pp, err := c.NewProtectionPolicy()
	if err != nil {
		return "", err
	}
	return pp.MainBranch, nil
}

Comment thread internal/worktree/worktree_test.go Outdated
}

for _, wt := range worktrees {
protected := client.IsProtectedWorktree(wt)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test to handle the new error return from IsProtectedWorktree.

		protected, err := client.IsProtectedWorktree(wt)
		if err != nil {
			t.Fatalf("IsProtectedWorktree() error = %v", err)
		}

Return error from NewProtectionPolicy, IsProtectedWorktree, and
resolvedMainBranch instead of silently returning empty policy.
Propagate errors in all callers (prepareRemove, Promote, Prune,
resolveAllRemovableWorktrees) so destructive operations abort
when protection state cannot be determined.

Signed-off-by: samzong <[email protected]>
@samzong samzong merged commit 6c888d6 into main Apr 3, 2026
1 check passed
samzong added a commit that referenced this pull request Apr 17, 2026
Fix 7 new lint violations from #63 and #64:
- lll: break long lines in worktree.go, worktree_test.go, prune.go, resource.go
- perfsprint: use errors.New instead of fmt.Errorf for static strings in prune.go
- gocritic/unlambda: simplify exec.Command wrapper in worktree_test.go

Signed-off-by: samzong <[email protected]>
samzong added a commit that referenced this pull request Apr 17, 2026
* fix: resolve lint issues introduced by protection and cache PRs

Fix 7 new lint violations from #63 and #64:
- lll: break long lines in worktree.go, worktree_test.go, prune.go, resource.go
- perfsprint: use errors.New instead of fmt.Errorf for static strings in prune.go
- gocritic/unlambda: simplify exec.Command wrapper in worktree_test.go

Signed-off-by: samzong <[email protected]>

* fix(wt): derive worktree name from -b when no name given

- Allow `gmc wt add -b <branch>` without positional args; worktree name defaults to the base branch value.

- Split the `-b/--base` flag variable between `wt add` and `wt dup` so dup's "main" default no longer pollutes add at init time.

## Considered and deferred

- cmd/worktree.go [BOT-NIT]: wtBaseBranch could be renamed to wtAddBase for symmetry with wtDupBase / wtPruneBase. Deferred — not a behavior concern.
- cmd/worktree.go [BOT-SCOPE]: No cmd-level test for the new zero-arg + -b path. Deferred for a follow-up test-only PR.

Signed-off-by: samzong <[email protected]>

---------

Signed-off-by: samzong <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant