📦 juspay / workforge

📄 SafetyChecker.ts · 316 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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316import { spawnSync } from 'child_process';
import { existsSync } from 'fs';
import * as path from 'path';
import { WorktreeInfo, SafetyCheckResult } from '../types/index.js';

/**
 * Safety Checker
 *
 * Performs comprehensive safety checks before closing a worktree:
 * - Uncommitted changes (staged and unstaged)
 * - Unpushed commits
 * - Branch merge status
 * - Remote branch existence
 * - Detached HEAD
 * - Merge/rebase in progress
 */
export class SafetyChecker {
  /**
   * Perform all safety checks on a worktree
   *
   * @param worktree - Worktree information
   * @returns Safety check result with warnings
   */
  async check(worktree: WorktreeInfo): Promise<SafetyCheckResult> {
    const result: SafetyCheckResult = {
      hasUncommittedChanges: false,
      hasUnpushedCommits: false,
      isMerged: false,
      hasRemoteBranch: false,
      isDetachedHead: false,
      isMergeInProgress: false,
      isRebaseInProgress: false,
      warnings: [],
      canClose: true
    };

    // Check if worktree exists
    if (!existsSync(worktree.path)) {
      result.warnings.push('Worktree directory does not exist');
      result.canClose = false;
      return result;
    }

    // Check for detached HEAD
    if (worktree.branchName === 'HEAD' || !worktree.branchName) {
      result.isDetachedHead = true;
      result.warnings.push('Worktree is in detached HEAD state');
    }

    // Check for uncommitted changes
    result.hasUncommittedChanges = this.checkUncommittedChanges(worktree.path);
    if (result.hasUncommittedChanges) {
      result.warnings.push('Worktree has uncommitted changes');
      result.canClose = false;
    }

    // Check for merge in progress
    result.isMergeInProgress = this.checkMergeInProgress(worktree.path);
    if (result.isMergeInProgress) {
      result.warnings.push('Merge is in progress');
      result.canClose = false;
    }

    // Check for rebase in progress
    result.isRebaseInProgress = this.checkRebaseInProgress(worktree.path);
    if (result.isRebaseInProgress) {
      result.warnings.push('Rebase is in progress');
      result.canClose = false;
    }

    // Only check remote status if we have a proper branch
    if (!result.isDetachedHead && worktree.branchName) {
      // Check for remote branch
      result.hasRemoteBranch = this.checkRemoteBranch(worktree.path, worktree.branchName);

      // Check for unpushed commits
      if (result.hasRemoteBranch) {
        result.hasUnpushedCommits = this.checkUnpushedCommits(worktree.path, worktree.branchName);
        if (result.hasUnpushedCommits) {
          result.warnings.push('Branch has unpushed commits');
        }

        // Check if branch is merged
        result.isMerged = this.checkBranchMerged(worktree.path, worktree.branchName);
        if (!result.isMerged) {
          result.warnings.push('Branch has not been merged into main/master');
        }
      } else {
        result.warnings.push('Branch does not have a remote tracking branch');
      }
    }

    return result;
  }

  /**
   * Check for uncommitted changes
   *
   * @param worktreePath - Path to worktree
   * @returns True if there are uncommitted changes
   */
  private checkUncommittedChanges(worktreePath: string): boolean {
    const result = spawnSync('git', ['status', '--porcelain'], {
      cwd: worktreePath,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (result.status !== 0) {
      return false;
    }

    // If output is not empty, there are uncommitted changes
    return result.stdout.trim() !== '';
  }

  /**
   * Check for unpushed commits
   *
   * @param worktreePath - Path to worktree
   * @param branchName - Branch name
   * @returns True if there are unpushed commits
   */
  private checkUnpushedCommits(worktreePath: string, branchName: string): boolean {
    // Get commits ahead of remote
    const result = spawnSync(
      'git',
      ['rev-list', '--count', `origin/${branchName}..${branchName}`],
      {
        cwd: worktreePath,
        encoding: 'utf8',
        stdio: 'pipe'
      }
    );

    if (result.status !== 0) {
      // Assume no unpushed commits if command fails
      return false;
    }

    const count = parseInt(result.stdout.trim(), 10);
    return count > 0;
  }

  /**
   * Check if branch is merged into main/master
   *
   * @param worktreePath - Path to worktree
   * @param branchName - Branch name
   * @returns True if branch is merged
   */
  private checkBranchMerged(worktreePath: string, branchName: string): boolean {
    // Check against main first
    let result = spawnSync('git', ['branch', '--merged', 'main'], {
      cwd: worktreePath,
      encoding: 'utf8',
      stdio: 'pipe'
    });

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

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

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

    return false;
  }

  /**
   * Check if remote branch exists
   *
   * @param worktreePath - Path to worktree
   * @param branchName - Branch name
   * @returns True if remote branch exists
   */
  private checkRemoteBranch(worktreePath: string, branchName: string): boolean {
    const result = spawnSync('git', ['ls-remote', '--heads', 'origin', branchName], {
      cwd: worktreePath,
      encoding: 'utf8',
      stdio: 'pipe'
    });

    if (result.status !== 0) {
      return false;
    }

    return result.stdout.trim() !== '';
  }

  /**
   * Check for merge in progress
   *
   * @param worktreePath - Path to worktree
   * @returns True if merge is in progress
   */
  private checkMergeInProgress(worktreePath: string): boolean {
    const mergeHeadPath = path.join(worktreePath, '.git', 'MERGE_HEAD');
    return existsSync(mergeHeadPath);
  }

  /**
   * Check for rebase in progress
   *
   * @param worktreePath - Path to worktree
   * @returns True if rebase is in progress
   */
  private checkRebaseInProgress(worktreePath: string): boolean {
    const rebaseMergePath = path.join(worktreePath, '.git', 'rebase-merge');
    const rebaseApplyPath = path.join(worktreePath, '.git', 'rebase-apply');

    return existsSync(rebaseMergePath) || existsSync(rebaseApplyPath);
  }

  /**
   * Get detailed status message for safety check
   *
   * @param result - Safety check result
   * @returns Human-readable status message
   */
  getStatusMessage(result: SafetyCheckResult): string {
    if (result.canClose && result.warnings.length === 0) {
      return 'Worktree is safe to close. No issues detected.';
    }

    const messages: string[] = [];

    if (result.hasUncommittedChanges) {
      messages.push('⚠️  Uncommitted changes detected. Commit or stash your changes first.');
    }

    if (result.isMergeInProgress) {
      messages.push('⚠️  Merge in progress. Complete or abort the merge first.');
    }

    if (result.isRebaseInProgress) {
      messages.push('⚠️  Rebase in progress. Complete or abort the rebase first.');
    }

    if (result.hasUnpushedCommits) {
      messages.push('ℹ️  Branch has unpushed commits. Consider pushing before closing.');
    }

    if (!result.isMerged && !result.isDetachedHead) {
      messages.push('ℹ️  Branch has not been merged. Consider merging before closing.');
    }

    if (result.isDetachedHead) {
      messages.push('⚠️  Worktree is in detached HEAD state.');
    }

    if (!result.hasRemoteBranch && !result.isDetachedHead) {
      messages.push('ℹ️  Branch does not have a remote tracking branch.');
    }

    return messages.join('\n');
  }

  /**
   * Check if force close is needed
   *
   * @param result - Safety check result
   * @returns True if force close is required
   */
  needsForceClose(result: SafetyCheckResult): boolean {
    return !result.canClose;
  }

  /**
   * Get count of blocking issues
   *
   * @param result - Safety check result
   * @returns Number of blocking issues
   */
  getBlockingIssueCount(result: SafetyCheckResult): number {
    let count = 0;

    if (result.hasUncommittedChanges) count++;
    if (result.isMergeInProgress) count++;
    if (result.isRebaseInProgress) count++;

    return count;
  }

  /**
   * Get count of warning issues (non-blocking)
   *
   * @param result - Safety check result
   * @returns Number of warning issues
   */
  getWarningIssueCount(result: SafetyCheckResult): number {
    let count = 0;

    if (result.hasUnpushedCommits) count++;
    if (!result.isMerged && !result.isDetachedHead) count++;
    if (!result.hasRemoteBranch && !result.isDetachedHead) count++;
    if (result.isDetachedHead) count++;

    return count;
  }
}