Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ type CleanupConfig struct {
WorktreePrune string `yaml:"worktree_prune"`
BranchRetentionDays int `yaml:"branch_retention_days"`
LogArchive string `yaml:"log_archive"`
// DeleteDanglingBranches, when true (default), removes the local + remote
// branches of a requirement's non-merged stories once the requirement
// completes. Deleting a remote branch auto-closes its open PR, so this
// leaves no dangling branches or PRs. Set false to keep them.
DeleteDanglingBranches bool `yaml:"delete_dangling_branches"`
}

// MergeConfig controls how completed work is merged.
Expand Down
7 changes: 4 additions & 3 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ func DefaultConfig() Config {
ContextFreshnessTokens: 150000,
},
Cleanup: CleanupConfig{
WorktreePrune: "immediate",
BranchRetentionDays: 7,
LogArchive: "file",
WorktreePrune: "immediate",
BranchRetentionDays: 7,
LogArchive: "file",
DeleteDanglingBranches: true,
},
Merge: MergeConfig{
AutoMerge: true,
Expand Down
3 changes: 3 additions & 0 deletions internal/engine/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,9 @@ func (m *Monitor) dispatchNextWave(ctx context.Context, rc *RunContext, repoDir

if allDone {
log.Printf("[auto-resume] all %d stories complete for requirement %s", len(stories), rc.ReqID)
// Leave the workspace neat: remove dangling branches (and their open
// PRs) from stories that never merged. Merged branches are already gone.
m.cleanupDanglingBranches(rc.ReqID, repoDir)
// Mark requirement complete.
emitEventOrLog(m.eventStore, m.projStore,
state.NewEvent(state.EventReqCompleted, "monitor", "", map[string]any{"id": rc.ReqID}))
Expand Down
67 changes: 67 additions & 0 deletions internal/engine/monitor_cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package engine

import (
"log"

nxdgit "github.com/tzone85/nexus-dispatch/internal/git"
"github.com/tzone85/nexus-dispatch/internal/state"
)

// danglingBranchesToClean returns the branch names to remove once a requirement
// completes: the branches of stories that did NOT merge. Merged stories already
// had their branch deleted at merge time; split stories are logical parents with
// no branch of their own; the base branch is never touched. Pure function so the
// selection logic is unit-testable without touching git.
func danglingBranchesToClean(stories []state.Story, baseBranch string) []string {
seen := make(map[string]bool)
out := make([]string, 0, len(stories))
for _, s := range stories {
if s.Status == "merged" || s.Status == "split" {
continue
}
branch := s.Branch
if branch == "" {
branch = "vxd/" + s.ID
}
if branch == "" || branch == baseBranch || seen[branch] {
continue
}
seen[branch] = true
out = append(out, branch)
}
return out
}

// cleanupDanglingBranches deletes the local + remote branches of a completed
// requirement's non-merged stories. Deleting the remote branch auto-closes any
// open PR, so the workspace is left with no dangling branches or PRs.
// Best-effort: every failure is logged but never fatal (a branch may legitimately
// not exist locally or remotely).
func (m *Monitor) cleanupDanglingBranches(reqID, repoDir string) {
if !m.config.Cleanup.DeleteDanglingBranches {
return
}
stories, err := m.projStore.ListStories(state.StoryFilter{ReqID: reqID})
if err != nil {
log.Printf("[cleanup] list stories for %s: %v", reqID, err)
return
}
branches := danglingBranchesToClean(stories, m.config.Merge.BaseBranch)
if len(branches) == 0 {
return
}
cleaned := 0
for _, b := range branches {
if nxdgit.BranchExists(repoDir, b) {
if delErr := nxdgit.DeleteBranch(repoDir, b); delErr != nil {
log.Printf("[cleanup] local branch %s not removed: %v", b, delErr)
}
}
if remoteErr := nxdgit.DeleteRemoteBranch(repoDir, b); remoteErr != nil {
log.Printf("[cleanup] remote branch %s not removed (may not exist): %v", b, remoteErr)
} else {
cleaned++
}
}
log.Printf("[cleanup] requirement %s: removed %d dangling remote branch(es) and closed their open PRs", reqID, cleaned)
}
58 changes: 58 additions & 0 deletions internal/engine/monitor_cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package engine

import (
"sort"
"testing"

"github.com/tzone85/nexus-dispatch/internal/state"
)

func TestDanglingBranchesToClean(t *testing.T) {
stories := []state.Story{
{ID: "a-s-001", Status: "merged", Branch: "vxd/a-s-001"},
{ID: "a-s-002", Status: "pr_submitted", Branch: "vxd/a-s-002"},
{ID: "a-s-003", Status: "draft", Branch: ""},
{ID: "a-s-004", Status: "split"},
{ID: "a-s-005", Status: "failed", Branch: "vxd/a-s-005"},
{ID: "a-s-002", Status: "pr_submitted", Branch: "vxd/a-s-002"},
}

got := danglingBranchesToClean(stories, "master")
sort.Strings(got)
want := []string{"vxd/a-s-002", "vxd/a-s-003", "vxd/a-s-005"}

if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}

func TestDanglingBranchesToClean_NeverDeletesBaseBranch(t *testing.T) {
stories := []state.Story{
{ID: "x", Status: "pr_submitted", Branch: "master"},
{ID: "y", Status: "pr_submitted", Branch: "vxd/y"},
}
got := danglingBranchesToClean(stories, "master")
for _, b := range got {
if b == "master" {
t.Fatalf("base branch must never be cleaned, got %v", got)
}
}
if len(got) != 1 || got[0] != "vxd/y" {
t.Fatalf("expected only vxd/y, got %v", got)
}
}

func TestDanglingBranchesToClean_AllMergedIsEmpty(t *testing.T) {
stories := []state.Story{
{ID: "a", Status: "merged", Branch: "vxd/a"},
{ID: "b", Status: "merged", Branch: "vxd/b"},
}
if got := danglingBranchesToClean(stories, "master"); len(got) != 0 {
t.Fatalf("all-merged requirement should have nothing to clean, got %v", got)
}
}
1 change: 1 addition & 0 deletions nxd.config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ cleanup:
worktree_prune: immediate
branch_retention_days: 7
log_archive: file
delete_dangling_branches: true
merge:
auto_merge: true
review_before_merge: false
Expand Down
Loading