feat(integrations): support automatic branch cleanup after merge

Add a new git::review_cleanup module that implements a CleanupPlan with
prepare() and finish() routines to safely remove/clean tracking and local
branches after a PR/MR is merged. The cleanup logic validates branch names,
ensures a clean worktree, checks remotes/URLs, verifies commits/ancestry,
protects against concurrent worktrees or divergent local/remote commits, and
performs authenticated fetch/push and ref updates. Unit tests for the cleanup
behavior are included.

Wire provider-side cleanup into integrations:
- add an integrations/cleanup module to read provider PR payloads and derive
  cleanup inputs
- run cleanup::prepare(...) before performing a merge when an optional
  cleanup_path is provided
- after a successful provider merge, run cleanup::finish(...); any failure is
  reported as MERGE_ACCEPTED_CLEANUP_FAILED

Also:
- export the new git review_cleanup module (src-tauri/src/git.rs)
- accept an optional cleanup_path parameter in run_integration_review_action
- remove the previous REVIEW_REQUEST_TIMEOUT wrapper around the spawned
  blocking task (the integration action is no longer wrapped with the 35s timeout)
This commit is contained in:
2026-09-18 15:22:57 +02:00
parent 6a40159f9f
commit 5db4f36abf
7 changed files with 791 additions and 18 deletions
+8
View File
@@ -6117,6 +6117,14 @@
localResolutionMessage={reviewConflictMessage}
loadCredential={loadStoredCredential}
onOpenSettings={() => { appSettingsOpen = true; }}
onCleanupStateChange={async (busy, path) => {
if (busy) {
operation = appLanguage === "de" ? "PR zusammenführen und Branch aufräumen" : "Merging PR and cleaning up branch";
} else {
try { if (sameRepoPath(activeRepoPath, path)) await refreshRepositorySnapshot(path); }
finally { operation = ""; }
}
}}
onStartLocalResolution={startReviewConflictResolution}
onOpenLocalResolver={reopenReviewConflictResolver}
onContinueLocalResolution={continueReviewConflictMerge}
+34 -10
View File
@@ -31,6 +31,7 @@
localResolutionMessage?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onOpenSettings: () => void;
onCleanupStateChange?: (busy: boolean, path: string) => void | Promise<void>;
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
onOpenLocalResolver?: () => void | Promise<void>;
onContinueLocalResolution?: () => void | Promise<void>;
@@ -38,7 +39,7 @@
onPushLocalResolution?: () => void | Promise<void>;
}
let { aiSettings, localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let { aiSettings, localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onCleanupStateChange = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let createOpen = $state(false);
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
@@ -400,27 +401,29 @@
}
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod }) => void) | null = null;
let reviewConfirmResolve: ((result: { confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }) => void) | null = null;
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }> {
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeOptions?: IntegrationMergeOptions): Promise<{ confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }> {
const values = { number: request.number };
reviewConfirmRequest = action === "merge"
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false, select: mergeOptions ? { label: de ? "Merge-Methode" : "Merge method", value: mergeOptions.defaultMethod, options: mergeOptions.methods.map(method => ({ value: method, label: mergeMethodLabel(method, request) })) } : undefined }
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false,
checkbox: { label: de ? "Quellbranch remote und lokal löschen" : "Delete source branch remotely and locally", note: de ? `Nach dem Merge zu „${request.targetBranch}“ wechseln und „${request.sourceBranch}“ löschen. Ein passendes lokales Repository ohne ungesicherte Änderungen muss geöffnet sein.` : `After merging, switch to “${request.targetBranch}” and delete “${request.sourceBranch}”. Open the matching local repository with a clean working tree first.`, defaultChecked: false },
select: mergeOptions ? { label: de ? "Merge-Methode" : "Merge method", value: mergeOptions.defaultMethod, options: mergeOptions.methods.map(method => ({ value: method, label: mergeMethodLabel(method, request) })) } : undefined }
: action === "close"
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod }>((resolve) => {
return new Promise<{ confirmed: boolean; method?: IntegrationMergeMethod; deleteBranch?: boolean }>((resolve) => {
reviewConfirmResolve = resolve;
});
}
function answerReviewConfirmation(confirmed: boolean, value?: string) {
function answerReviewConfirmation(confirmed: boolean, value?: string, checked = false) {
const method = reviewConfirmRequest?.select?.options.find(option => option.value === value)?.value as IntegrationMergeMethod | undefined;
const resolve = reviewConfirmResolve;
reviewConfirmRequest = null;
reviewConfirmResolve = null;
resolve?.({ confirmed, method });
resolve?.({ confirmed, method, deleteBranch: checked });
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
@@ -430,6 +433,7 @@
actionNotice = "";
actionBusyId = request.id;
errors = [];
let cleanupPath: string | undefined;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
@@ -440,15 +444,35 @@
const result = await askReviewConfirmation(request, action, options);
if (!result.confirmed) return;
mergeMethod = result.method;
if (action === "merge" && result.deleteBranch) {
if (!localRepositoryPath) throw new Error(de ? "Öffne zuerst das passende lokale Repository, um den Quellbranch remote und lokal zu löschen." : "Open the matching local repository before deleting the source branch remotely and locally.");
cleanupPath = localRepositoryPath;
await onCleanupStateChange(true, cleanupPath);
}
}
let cleanupError = "";
try {
// Cleanup can include fetch, checkout and push; do not time out a still-running mutation.
await runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action, mergeMethod, cleanupPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.startsWith("MERGE_ACCEPTED_CLEANUP_FAILED:")) throw error;
cleanupError = (de ? "Merge vom Anbieter angenommen, Branch-Aufräumen nicht abgeschlossen: " : "Merge accepted by the provider, branch cleanup incomplete: ") + message.replace("MERGE_ACCEPTED_CLEANUP_FAILED:", "").trim();
}
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action, mergeMethod), source.label);
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
requests = [];
loadedStates = new Set();
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
if (cleanupError) errors = [...errors, { source: source.label, message: cleanupError }];
else actionNotice = cleanupPath
? (de ? `PR zusammengeführt. Zu „${request.targetBranch}“ gewechselt und „${request.sourceBranch}“ remote und lokal gelöscht.` : `PR merged. Switched to “${request.targetBranch}” and deleted “${request.sourceBranch}” remotely and locally.`)
: (de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
if (cleanupPath) {
try { await onCleanupStateChange(false, cleanupPath); }
catch (error) { errors = [...errors, { source: source.label, message: String(error) }]; }
}
actionBusyId = "";
}
}
@@ -743,7 +767,7 @@
{#if reviewConfirmRequest}
<ConfirmDialog
request={reviewConfirmRequest}
onConfirm={({ value }) => answerReviewConfirmation(true, value)}
onConfirm={({ value, checked }) => answerReviewConfirmation(true, value, checked)}
onCancel={() => answerReviewConfirmation(false)}
/>
{/if}
+2 -2
View File
@@ -72,8 +72,8 @@ export function getIntegrationReviewMergeOptions(provider: GitIntegrationProvide
return invoke("get_integration_review_merge_options", { provider, baseUrl, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName });
}
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeMethod?: IntegrationMergeMethod): Promise<void> {
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action, mergeMethod });
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction, mergeMethod?: IntegrationMergeMethod, cleanupPath?: string): Promise<void> {
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action, mergeMethod, cleanupPath });
}
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {