From 33b4be95d538d3de8b05d65bb64326169179d594 Mon Sep 17 00:00:00 2001 From: Thando Date: Tue, 23 Jun 2026 14:12:17 +0200 Subject: [PATCH] feat(cleanup): delete dangling branches + auto-close their PRs on requirement completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the VXD cleanup step (vortex-dispatch #85). NXD cleaned merged branches but left non-merged story branches — and their open PRs — dangling. New cleanupDanglingBranches (monitor_cleanup.go) runs in the requirement- completion path (dispatchNextWave, before REQ_COMPLETED): for every story that did NOT merge, it deletes the local + remote branch. Deleting the remote branch auto-closes its PR, so a completed requirement leaves no dangling branches/PRs. - Pure selector danglingBranchesToClean(stories, baseBranch): skips merged + split, never deletes the base branch, dedups. Best-effort executor. - Config cleanup.delete_dangling_branches (default true; opt-out per project). - 3 unit tests; example YAML updated. Full suite green. Git-based cleanup — applies cleanly to NXD's offline-first core (no cloud deps). --- internal/config/config.go | 5 ++ internal/config/loader.go | 7 +-- internal/engine/monitor.go | 3 ++ internal/engine/monitor_cleanup.go | 67 +++++++++++++++++++++++++ internal/engine/monitor_cleanup_test.go | 58 +++++++++++++++++++++ nxd.config.example.yaml | 1 + 6 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 internal/engine/monitor_cleanup.go create mode 100644 internal/engine/monitor_cleanup_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 64d8bd4..ddd06bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/loader.go b/internal/config/loader.go index 0d730ac..288b144 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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, diff --git a/internal/engine/monitor.go b/internal/engine/monitor.go index 6be2158..b4001f9 100644 --- a/internal/engine/monitor.go +++ b/internal/engine/monitor.go @@ -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})) diff --git a/internal/engine/monitor_cleanup.go b/internal/engine/monitor_cleanup.go new file mode 100644 index 0000000..7bb6077 --- /dev/null +++ b/internal/engine/monitor_cleanup.go @@ -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) +} diff --git a/internal/engine/monitor_cleanup_test.go b/internal/engine/monitor_cleanup_test.go new file mode 100644 index 0000000..e840c62 --- /dev/null +++ b/internal/engine/monitor_cleanup_test.go @@ -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) + } +} diff --git a/nxd.config.example.yaml b/nxd.config.example.yaml index 79afd81..e80358c 100644 --- a/nxd.config.example.yaml +++ b/nxd.config.example.yaml @@ -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