Skip to content

feat(settings): add automated remote branch cleanup on task archive/delete#1442

Open
singhvibhanshu wants to merge 3 commits intogeneralaction:mainfrom
singhvibhanshu:autodeleteBranches
Open

feat(settings): add automated remote branch cleanup on task archive/delete#1442
singhvibhanshu wants to merge 3 commits intogeneralaction:mainfrom
singhvibhanshu:autodeleteBranches

Conversation

@singhvibhanshu
Copy link
Contributor

Summary

Adds a configurable setting to automatically delete remote branches when a task is archived or deleted.

Previously, stale remote branches accumulated on GitHub, requiring manual cleanup. This PR introduces a Remote Branch Cleanup setting with four modes:

  • Never (default): Keeps current behavior.
  • Always: Automatically deletes the remote branch silently.
  • Ask every time: Prompts the user via window.confirm before deletion.
  • Auto-delete after threshold: Deletes branches whose last commit is older than a configurable number of days (1-365).

Implementation Details:

  • Created a dedicated RemoteBranchService in the Node backend to execute git push --delete safely.
  • Fully idempotent: The deletion logic handles edge cases gracefully without throwing errors (e.g., branch already deleted on remote, no remote configured, network timeouts).
  • Plumbed through new IPC channels (git:delete-remote-branch, git:evaluate-branch-cleanup) to connect the React UI with the Git service.
  • Added robust unit testing (57 new tests covering Git operations, date parsing logic, and settings validation).

Fixes

Fixes #1430

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Chore (refactoring code, technical debt, workflow improvements)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (does not change functionality, e.g. code style improvements, linting)
  • This change requires a documentation update

Mandatory Tasks

  • I have self-reviewed the code
  • A decent size PR without self-review might be rejected

Checklist

  • I have read the contributing guide
  • My code follows the style guidelines of this project (pnpm run format)
  • I have commented my code, particularly in hard-to-understand areas
  • I have checked if my PR needs changes to the documentation
  • I have checked if my changes generate no new warnings (pnpm run lint)
  • I have added tests that prove my fix is effective or that my feature works
  • I have checked if new and existing unit tests pass locally with my changes

@vercel
Copy link

vercel bot commented Mar 12, 2026

@singhvibhanshu is attempting to deploy a commit to the General Action Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR introduces a configurable Remote Branch Cleanup setting that automatically (or on-prompt) deletes the corresponding remote branch when a task is archived or deleted. The feature is well-structured: shared types live in src/shared/remoteBranchCleanup.ts, a dedicated RemoteBranchService handles git operations, two new IPC channels wire the backend to the React UI, and settings validation is thorough.

Key points:

  • Architecture is sound: the evaluate-then-act split (git:evaluate-branch-cleanupgit:delete-remote-branch) cleanly separates the decision from the side-effect, and both archive and delete paths handle errors gracefully without blocking the primary operation.
  • Two logic issues in RemoteBranchService: (1) ALREADY_ABSENT_PATTERNS includes a broad /not found/i regex that can incorrectly classify network errors (e.g., "hostname not found") as "branch already absent", silently masking failed deletions; (2) isBranchStale returns true when the local commit date cannot be determined, which can trigger deletion of remote branches that cannot be verified as stale.
  • window.confirm for 'ask' mode is the only confirm call in the codebase — it's inconsistent with the app's existing useModalContext / showModal infrastructure and can behave unexpectedly in stricter Electron security configurations.
  • Uncontrolled <Input defaultValue> in RemoteBranchCleanupCard can display a stale days-threshold value if settings are updated externally while the component is mounted.

Confidence Score: 2/5

  • Not safe to merge as-is — two logic issues in RemoteBranchService could cause silent data loss (remote branches deleted or failed deletions masked).
  • The broad /not found/i error pattern can silently treat network failures as "branch already absent", and the "treat unknown as stale" default in isBranchStale can delete remote branches that cannot actually be verified as old. Both are in the core deletion path and could cause irreversible data loss for users who enable 'always' or 'auto' mode.
  • src/main/services/RemoteBranchService.ts — both flagged logic issues are here and need resolution before merge.

Important Files Changed

Filename Overview
src/main/services/RemoteBranchService.ts New service for remote branch deletion — well-structured, but the overly broad /not found/i pattern can mask real network errors as "already absent", and isBranchStale treats unresolvable branches as stale which can trigger silent deletions.
src/renderer/hooks/useTaskManagement.ts Remote branch cleanup logic integrated cleanly before optimistic mutations; window.confirm for 'ask' mode is inconsistent with the app's modal system and fragile in some Electron configurations.
src/renderer/components/RemoteBranchCleanupCard.tsx Clean settings UI; uses defaultValue (uncontrolled) for the days-threshold input, which can show a stale value if settings update while the component is mounted.
src/shared/remoteBranchCleanup.ts Well-defined shared types, constants, and validation helpers for the cleanup modes; clampCleanupDays correctly handles non-finite and out-of-range inputs.
src/main/ipc/gitIpc.ts Two new IPC handlers (git:delete-remote-branch, git:evaluate-branch-cleanup) added correctly with input validation and graceful error returns.
src/main/settings.ts New remoteBranchCleanup and remoteBranchCleanupDaysThreshold settings correctly added with validation, defaults, and normalization in normalizeSettings.
src/main/services/WorktreeService.ts Remote branch deletion in removeWorktree is gated on an explicit deleteRemoteBranch option flag and only executes when requested, keeping existing behavior unchanged by default.
src/main/preload.ts New deleteRemoteBranch and evaluateBranchCleanup methods correctly exposed via contextBridge.

Sequence Diagram

sequenceDiagram
    participant UI as Renderer (useTaskManagement)
    participant IPC as IPC Bridge (preload)
    participant GitIPC as gitIpc.ts
    participant RBS as RemoteBranchService
    participant WS as WorktreeService

    UI->>UI: shouldDeleteRemoteBranch(project, task)
    UI->>IPC: evaluateBranchCleanup({projectPath, branch, mode, daysThreshold})
    IPC->>GitIPC: git:evaluate-branch-cleanup
    GitIPC->>RBS: evaluateCleanupAction(...)
    RBS-->>GitIPC: 'delete' | 'skip' | 'ask'
    GitIPC-->>IPC: { success, action }
    IPC-->>UI: { success, action }

    alt action === 'ask'
        UI->>UI: window.confirm(...)
    end

    alt Archive task
        UI->>UI: archiveTaskMutation.mutateAsync({deleteRemoteBranch})
        UI->>IPC: deleteRemoteBranch({projectPath, branch})
        IPC->>GitIPC: git:delete-remote-branch
        GitIPC->>RBS: deleteRemoteBranch(projectPath, branch)
        RBS-->>GitIPC: RemoteBranchDeletionResult
    else Delete task
        UI->>UI: deleteTaskMutation.mutateAsync({deleteRemoteBranch})
        UI->>IPC: worktreeRemove({..., deleteRemoteBranch})
        IPC->>WS: removeWorktree(..., {deleteRemoteBranch})
        WS->>RBS: deleteRemoteBranch(projectPath, branch)
        RBS-->>WS: RemoteBranchDeletionResult
    end
Loading

Last reviewed commit: d88cadc

Comment on lines +26 to +27
/error: unable to delete '[^']*': remote ref does not exist/i,
];
Copy link
Contributor

Choose a reason for hiding this comment

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

Overly broad not found pattern can mask network errors

The pattern /not found/i is too generic. Git error messages for network failures (e.g., Could not resolve hostname 'github.com': Name or service not found, SSL_connect: Connection not found) also contain "not found". When this pattern matches a transient network error, the code returns { success: true, alreadyAbsent: true } — silently reporting success and masking what was actually a failed deletion.

Consider tightening the pattern to specifically match git branch-not-found output:

Suggested change
/error: unable to delete '[^']*': remote ref does not exist/i,
];
const ALREADY_ABSENT_PATTERNS = [
/remote ref does not exist/i,
/unknown revision/i,
/error: unable to delete '[^']*': remote ref does not exist/i,
];

The fourth pattern (error: unable to delete '...') is already a precise superset of the first for the branch-absent case; and unknown revision covers the remaining scenario, so dropping the broad /not found/i loses no real coverage while eliminating false positives.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Implemented!

Comment on lines +154 to +158
const dateStr = await getLastCommitDate(projectPath, branch);
if (!dateStr) {
// Cannot determine — treat as stale so users aren't surprised by
// branches that silently escape cleanup.
return true;
Copy link
Contributor

Choose a reason for hiding this comment

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

Treating unknown commit date as stale can silently delete live branches

When getLastCommitDate returns null (branch doesn't exist locally, git failed, etc.), isBranchStale returns true, which causes evaluateCleanupAction to return 'delete'. This means any branch that can't be resolved locally — even a branch pushed very recently from another machine — will be unconditionally deleted in auto mode.

The safer conservative choice is to return false (not stale) when the age cannot be determined, preventing silent deletions of branches that cannot be verified:

Suggested change
const dateStr = await getLastCommitDate(projectPath, branch);
if (!dateStr) {
// Cannot determine — treat as stale so users aren't surprised by
// branches that silently escape cleanup.
return true;
if (!dateStr) {
// Cannot determine age — skip deletion to avoid removing branches
// that may still be active but lack a local tracking ref.
return false;
}

The existing tests (returns true when date cannot be determined) explicitly assert the current "fail open" behaviour; those tests would need updating if this is changed.

case 'delete':
return true;
case 'ask':
return window.confirm(`Also delete remote branch "${task.branch}" on origin?`);
Copy link
Contributor

Choose a reason for hiding this comment

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

window.confirm is inconsistent with the app's modal system

window.confirm renders a plain browser-style dialog, bypassing the project's custom modal infrastructure (useModalContext / showModal). In certain Electron security configurations (e.g., when allowRunningInsecureContent or contextIsolation settings are tightened), window.confirm can be suppressed or return false silently, which would cause the 'ask' mode to always skip deletion without any user prompt.

Consider invoking the existing modal system (or an electronAPI IPC call to dialog.showMessageBox) to keep UX consistent and avoid platform-specific surprises:

Suggested change
return window.confirm(`Also delete remote branch "${task.branch}" on origin?`);
return new Promise<boolean>((resolve) => {
showModal('confirmDialog', {
title: 'Delete remote branch?',
description: `Also delete remote branch "${task.branch}" on origin?`,
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
});

(Adjust to match the actual modal API shape in your codebase.)

type="number"
min={1}
max={365}
defaultValue={currentDays}
Copy link
Contributor

Choose a reason for hiding this comment

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

Uncontrolled input can show a stale value

defaultValue only sets the initial DOM value; subsequent changes to currentDays (e.g., after another settings write that triggers a re-render while the auto block remains mounted) will not update the displayed number. Using a controlled value + onChange pattern keeps the input in sync with the persisted setting:

Suggested change
defaultValue={currentDays}
value={currentDays}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
if (!Number.isFinite(raw)) return;
const clamped = Math.min(365, Math.max(1, raw));
updateSettings({
repository: { remoteBranchCleanupDaysThreshold: clamped },
});
}}

You can remove the onBlur handler once onChange is in place (or keep both for debouncing purposes).

@singhvibhanshu
Copy link
Contributor Author

All flagged issues have been addressed in the latest commit

@singhvibhanshu
Copy link
Contributor Author

Merge conflicts resolved.
Kindly have a look.
I am happy to address any feedbacks.
Thanks!

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.

Feature: Auto-delete remote branches on task archive/delete

1 participant