File size: 6,123 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * branch-utils - Shared git branch helper utilities
 *
 * Provides common git operations used by both checkout-branch-service and
 * worktree-branch-service. Extracted to avoid duplication and ensure
 * consistent behaviour across branch-related services.
 */

import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js';

const logger = createLogger('BranchUtils');

// ============================================================================
// Types
// ============================================================================

export interface HasAnyChangesOptions {
  /**
   * When true, lines that refer to worktree-internal paths (containing
   * ".worktrees/" or ending with ".worktrees") are excluded from the count.
   * Use this in contexts where worktree directory entries should not be
   * considered as real working-tree changes (e.g. worktree-branch-service).
   */
  excludeWorktreePaths?: boolean;
  /**
   * When true (default), untracked files (lines starting with "??") are
   * included in the change count. When false, untracked files are ignored so
   * that hasAnyChanges() is consistent with stashChanges() called without
   * --include-untracked.
   */
  includeUntracked?: boolean;
}

// ============================================================================
// Helpers
// ============================================================================

/**
 * Returns true when a `git status --porcelain` output line refers to a
 * worktree-internal path that should be ignored when deciding whether there
 * are "real" local changes.
 */
function isExcludedWorktreeLine(line: string): boolean {
  return line.includes('.worktrees/') || line.endsWith('.worktrees');
}

// ============================================================================
// Exported Utilities
// ============================================================================

/**
 * Check if there are any changes that should be stashed.
 *
 * @param cwd - Working directory of the git repository / worktree
 * @param options - Optional flags controlling which lines are counted
 * @param options.excludeWorktreePaths - When true, lines matching worktree
 *   internal paths are excluded so they are not mistaken for real changes
 * @param options.includeUntracked - When false, untracked files (lines
 *   starting with "??") are excluded so this is consistent with a
 *   stashChanges() call that does not pass --include-untracked.
 *   Defaults to true.
 */
export async function hasAnyChanges(cwd: string, options?: HasAnyChangesOptions): Promise<boolean> {
  try {
    const includeUntracked = options?.includeUntracked ?? true;
    const stdout = await execGitCommand(['status', '--porcelain'], cwd);
    const lines = stdout
      .trim()
      .split('\n')
      .filter((line) => {
        if (!line.trim()) return false;
        if (options?.excludeWorktreePaths && isExcludedWorktreeLine(line)) return false;
        if (!includeUntracked && line.startsWith('??')) return false;
        return true;
      });
    return lines.length > 0;
  } catch (err) {
    logger.error('hasAnyChanges: execGitCommand failed — returning false', {
      cwd,
      error: getErrorMessage(err),
    });
    return false;
  }
}

/**
 * Stash all local changes (including untracked files if requested).
 * Returns true if a stash was created, false if there was nothing to stash.
 * Throws on unexpected errors so callers abort rather than proceeding silently.
 *
 * @param cwd - Working directory of the git repository / worktree
 * @param message - Stash message
 * @param includeUntracked - When true, passes `--include-untracked` to git stash
 */
export async function stashChanges(
  cwd: string,
  message: string,
  includeUntracked: boolean = true
): Promise<boolean> {
  try {
    const args = ['stash', 'push'];
    if (includeUntracked) {
      args.push('--include-untracked');
    }
    args.push('-m', message);

    const stdout = await execGitCommandWithLockRetry(args, cwd);

    // git exits 0 but prints a benign message when there is nothing to stash
    const stdoutLower = stdout.toLowerCase();
    if (
      stdoutLower.includes('no local changes to save') ||
      stdoutLower.includes('nothing to stash')
    ) {
      logger.debug('stashChanges: nothing to stash', { cwd, message, stdout });
      return false;
    }

    return true;
  } catch (error) {
    const errorMsg = getErrorMessage(error);

    // Unexpected error – log full details and re-throw so the caller aborts
    // rather than proceeding with an un-stashed working tree
    logger.error('stashChanges: unexpected error during stash', {
      cwd,
      message,
      error: errorMsg,
    });
    throw new Error(`Failed to stash changes in ${cwd}: ${errorMsg}`);
  }
}

/**
 * Pop the most recent stash entry.
 * Returns an object indicating success and whether there were conflicts.
 *
 * @param cwd - Working directory of the git repository / worktree
 */
export async function popStash(
  cwd: string
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
  try {
    await execGitCommandWithLockRetry(['stash', 'pop'], cwd);
    // If execGitCommandWithLockRetry succeeds (zero exit code), there are no conflicts
    return { success: true, hasConflicts: false };
  } catch (error) {
    const errorMsg = getErrorMessage(error);
    if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
      return { success: false, hasConflicts: true, error: errorMsg };
    }
    return { success: false, hasConflicts: false, error: errorMsg };
  }
}

/**
 * Check if a local branch already exists.
 *
 * @param cwd - Working directory of the git repository / worktree
 * @param branchName - The branch name to look up (without refs/heads/ prefix)
 */
export async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
  try {
    await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd);
    return true;
  } catch {
    return false;
  }
}