๐Ÿ“ฆ juspay / workforge

๐Ÿ“„ BranchCleaner.ts ยท 271 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271import { spawnSync } from 'child_process';
import { SafetyCheckResult, OperationResult, BranchDeletionRecommendation, RelatedBranches } from '../types/index.js';

/**
 * Branch Cleaner
 *
 * Handles branch deletion after worktree closure:
 * - Safe delete (git branch -d) for merged branches
 * - Force delete (git branch -D) for unmerged branches
 * - Remote branch warnings
 * - User prompts based on merge status
 */
export class BranchCleaner {
  /**
   * Delete a branch
   *
   * @param branchName - Name of branch to delete
   * @param repoRoot - Repository root path
   * @param safety - Safety check result
   * @param force - Force deletion even if not merged
   * @returns Result object with success status and message
   */
  async cleanup(
    branchName: string,
    repoRoot: string,
    safety: SafetyCheckResult,
    force: boolean = false
  ): Promise<OperationResult> {
    const warnings: string[] = [];

    // Check if branch is main/master
    if (branchName === 'main' || branchName === 'master') {
      return {
        success: false,
        message: 'Cannot delete main/master branch',
        warnings: []
      };
    }

    // Determine delete strategy based on safety checks
    const strategy = this.getDeletionStrategy(safety, force);

    // Add warnings about remote branch
    if (safety.hasRemoteBranch) {
      warnings.push('Branch has a remote tracking branch. Consider deleting it as well.');
    }

    // Add warning about unpushed commits
    if (safety.hasUnpushedCommits) {
      warnings.push('Branch has unpushed commits that will be lost.');
    }

    // Execute deletion
    const args = ['branch'];

    if (strategy === 'force') {
      args.push('-D'); // Force delete
    } else {
      args.push('-d'); // Safe delete
    }

    args.push(branchName);

    const result = spawnSync('git', args, {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (result.status === 0) {
      return {
        success: true,
        message: `Branch deleted: ${branchName}`,
        warnings
      };
    }

    // Handle errors
    const errorMessage = result.stderr || result.stdout || 'Unknown error';

    // Check for common error patterns
    if (errorMessage.includes('not fully merged')) {
      return {
        success: false,
        message:
          'Branch is not fully merged. Use --force to delete anyway, or merge the branch first.',
        warnings
      };
    }

    if (errorMessage.includes('not found')) {
      return {
        success: false,
        message: `Branch not found: ${branchName}`,
        warnings
      };
    }

    return {
      success: false,
      message: `Failed to delete branch: ${errorMessage.trim()}`,
      warnings
    };
  }

  /**
   * Delete remote branch
   *
   * @param branchName - Name of branch to delete
   * @param repoRoot - Repository root path
   * @param remoteName - Remote name (default: origin)
   * @returns Result object with success status and message
   */
  async deleteRemote(
    branchName: string,
    repoRoot: string,
    remoteName: string = 'origin'
  ): Promise<OperationResult> {
    const result = spawnSync('git', ['push', remoteName, '--delete', branchName], {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (result.status === 0) {
      return {
        success: true,
        message: `Remote branch deleted: ${remoteName}/${branchName}`
      };
    }

    const errorMessage = result.stderr || result.stdout || 'Unknown error';

    return {
      success: false,
      message: `Failed to delete remote branch: ${errorMessage.trim()}`
    };
  }

  /**
   * Check if branch can be safely deleted
   *
   * @param branchName - Branch name
   * @param repoRoot - Repository root path
   * @returns True if branch is merged and can be safely deleted
   */
  canSafelyDelete(branchName: string, repoRoot: string): boolean {
    // Check against main
    let result = spawnSync('git', ['branch', '--merged', 'main'], {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (result.status === 0 && result.stdout.includes(branchName)) {
      return true;
    }

    // Check against master
    result = spawnSync('git', ['branch', '--merged', 'master'], {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (result.status === 0 && result.stdout.includes(branchName)) {
      return true;
    }

    return false;
  }

  /**
   * Get deletion strategy based on safety checks
   *
   * @param safety - Safety check result
   * @param force - Force flag from user
   * @returns Deletion strategy
   */
  private getDeletionStrategy(safety: SafetyCheckResult, force: boolean): 'safe' | 'force' {
    // If user explicitly requested force, use force
    if (force) {
      return 'force';
    }

    // If branch is merged, use safe delete
    if (safety.isMerged) {
      return 'safe';
    }

    // If branch is not merged, require force
    return 'force';
  }

  /**
   * Get deletion recommendation
   *
   * @param safety - Safety check result
   * @returns Recommendation object
   */
  getRecommendation(safety: SafetyCheckResult): BranchDeletionRecommendation {
    // Branch is merged - safe to delete
    if (safety.isMerged) {
      return {
        shouldDelete: true,
        requiresForce: false,
        message: 'Branch is merged and can be safely deleted.'
      };
    }

    // Branch has unpushed commits - warn user
    if (safety.hasUnpushedCommits) {
      return {
        shouldDelete: false,
        requiresForce: true,
        message: 'Branch has unpushed commits. Consider pushing first, or use --force to delete.'
      };
    }

    // Branch is not merged but has no unpushed commits
    return {
      shouldDelete: false,
      requiresForce: true,
      message: 'Branch is not merged. Consider merging first, or use --force to delete.'
    };
  }

  /**
   * List branches that would be affected
   *
   * @param branchName - Branch name
   * @param repoRoot - Repository root path
   * @returns List of related branches (local and remote)
   */
  getRelatedBranches(branchName: string, repoRoot: string): RelatedBranches {
    const related = {
      local: false,
      remote: [] as string[]
    };

    // Check local branch
    const localResult = spawnSync('git', ['branch', '--list', branchName], {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (localResult.status === 0 && localResult.stdout.trim() !== '') {
      related.local = true;
    }

    // Check remote branches
    const remoteResult = spawnSync('git', ['branch', '-r', '--list', `*/${branchName}`], {
      cwd: repoRoot,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (remoteResult.status === 0) {
      const remoteBranches = remoteResult.stdout
        .split('\n')
        .map(line => line.trim())
        .filter(line => line !== '');

      related.remote = remoteBranches;
    }

    return related;
  }
}