diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 2802774fbd..d13ea53156 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -5,11 +5,11 @@ on: workflow_dispatch: inputs: version: - description: 'Beta version (e.g., 2.8.0-beta.1)' + description: "Beta version (e.g., 2.8.0-beta.1)" required: true type: string dry_run: - description: 'Test build without creating release' + description: "Test build without creating release" required: false default: false type: boolean @@ -74,12 +74,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -169,12 +169,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -260,12 +260,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -284,7 +284,7 @@ jobs: - name: Cache pip wheel cache uses: actions/cache@v4 with: - path: ~\AppData\Local\pip\Cache + path: ~/AppData/Local/pip/Cache key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | pip-wheel-${{ runner.os }}-x64- @@ -330,12 +330,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -396,7 +396,14 @@ jobs: apps/frontend/dist/*.yml create-release: - needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux] + needs: + [ + create-tag, + build-macos-intel, + build-macos-arm64, + build-windows, + build-linux, + ] runs-on: ubuntu-latest if: ${{ github.event.inputs.dry_run != 'true' }} permissions: @@ -461,7 +468,14 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} dry-run-summary: - needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux] + needs: + [ + create-tag, + build-macos-intel, + build-macos-arm64, + build-windows, + build-linux, + ] runs-on: ubuntu-latest if: ${{ github.event.inputs.dry_run == 'true' }} steps: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ac10837861..e3d1583f55 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -10,8 +10,8 @@ on: push: branches: [main] paths: - - 'apps/frontend/package.json' - - 'package.json' + - "apps/frontend/package.json" + - "package.json" jobs: check-and-tag: @@ -87,11 +87,17 @@ jobs: # Extract changelog section for this version # Looks for "## X.Y.Z" header and captures until next "## " or "---" or end CHANGELOG_CONTENT=$(awk -v ver="$VERSION" ' - BEGIN { found=0; content="" } + BEGIN { + found=0 + content="" + # Escape dots in version for regex matching + ver_escaped = ver + gsub(/\./, "\\\\.", ver_escaped) + } /^## / { if (found) exit # Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3") - if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") { + if ($2 == ver || $2 ~ "^"ver_escaped"[[:space:]]*-") { found=1 # Skip the header line itself, we will add our own next diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0590f3689f..9434d4cccc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,11 @@ name: Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: inputs: dry_run: - description: 'Test build without creating release' + description: "Test build without creating release" required: false default: true type: boolean @@ -24,12 +24,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -112,12 +112,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -196,12 +196,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -258,12 +258,12 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Get npm cache directory id: npm-cache @@ -516,45 +516,15 @@ jobs: echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..." - if [ ! -f "$CHANGELOG_FILE" ]; then - echo "::warning::CHANGELOG.md not found, using minimal release notes" - echo "body=Release v$VERSION" >> $GITHUB_OUTPUT - exit 0 - fi - - # Extract changelog section for this version - # Looks for "## X.Y.Z" header and captures until next "## " or "---" - CHANGELOG_CONTENT=$(awk -v ver="$VERSION" ' - BEGIN { found=0; content="" } - /^## / { - if (found) exit - # Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3") - if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") { - found=1 - next - } - } - /^---$/ { if (found) exit } - found { content = content $0 "\n" } - END { - if (!found) { - print "NOT_FOUND" - exit 0 - } - # Trim leading/trailing whitespace - gsub(/^[[:space:]]+|[[:space:]]+$/, "", content) - print content - } - ' "$CHANGELOG_FILE") - - if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then + # Use shared extraction script with proper regex escaping + if CHANGELOG_CONTENT=$(bash scripts/extract-changelog.sh "$VERSION" "$CHANGELOG_FILE"); then + echo "✅ Extracted changelog content" + else echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes" REPO="${{ github.repository }}" CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/blob/main/CHANGELOG.md) for details." fi - echo "✅ Extracted changelog content" - # Save to file first (more reliable for multiline) echo "$CHANGELOG_CONTENT" > changelog-body.md diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index ab3b89e3b3..a2ed3dc838 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -438,6 +438,34 @@ def merge_worktree( self._run_git(["merge", "--abort"]) return False + # NEW: Verify merge commit was created (for non-staged merges) + if not no_commit: + # Get the latest commit message to verify merge succeeded + verify_result = self._run_git(["log", "-1", "--format=%s"]) + if verify_result.returncode == 0: + latest_commit_msg = verify_result.stdout.strip() + expected_msg = f"auto-claude: Merge {info.branch}" + + if latest_commit_msg != expected_msg: + print(f"Warning: Expected merge commit message not found.") + print(f"Expected: '{expected_msg}'") + print(f"Got: '{latest_commit_msg}'") + + # Check if branch is already merged (acceptable edge case) + check_merged = self._run_git( + ["branch", "--merged", self.base_branch, info.branch] + ) + + if info.branch not in check_merged.stdout: + print(f"Error: Branch {info.branch} is not fully merged into {self.base_branch}") + print("This may indicate a fast-forward merge or other issue.") + print("Please verify with: git log --oneline -n 10") + return False + else: + print(f"Branch {info.branch} appears to be already merged. Continuing...") + else: + print(f"✓ Merge commit created successfully: {latest_commit_msg}") + if no_commit: # Unstage any files that are gitignored in the main branch # These get staged during merge because they exist in the worktree branch diff --git a/apps/backend/merge/file_merger.py b/apps/backend/merge/file_merger.py index 53cebc4d5e..4187572985 100644 --- a/apps/backend/merge/file_merger.py +++ b/apps/backend/merge/file_merger.py @@ -46,10 +46,12 @@ def apply_single_task_changes( if change.change_type == ChangeType.ADD_IMPORT: # Add import at top # Use splitlines() to handle all line ending styles (LF, CRLF, CR) + # Detect and preserve original line ending style + original_ending = "\r\n" if "\r\n" in content else "\n" lines = content.splitlines() import_end = find_import_end(lines, file_path) lines.insert(import_end, change.content_after) - content = "\n".join(lines) + content = original_ending.join(lines) elif change.change_type == ChangeType.ADD_FUNCTION: # Add function at end (before exports) content += f"\n\n{change.content_after}" diff --git a/apps/backend/merge/semantic_analysis/regex_analyzer.py b/apps/backend/merge/semantic_analysis/regex_analyzer.py index ae4eafa284..c9c8fcc059 100644 --- a/apps/backend/merge/semantic_analysis/regex_analyzer.py +++ b/apps/backend/merge/semantic_analysis/regex_analyzer.py @@ -10,6 +10,33 @@ from ..types import ChangeType, FileAnalysis, SemanticChange +def _extract_func_names_from_matches( + matches: list[str | tuple[str, ...]] +) -> set[str]: + """ + Extract function names from regex findall matches. + + For patterns with alternation, findall() returns tuples. + This helper extracts the non-empty match from each tuple. + + Args: + matches: List of matches from re.findall(), which can be strings or tuples + + Returns: + Set of extracted function names + """ + names = set() + for match in matches: + if isinstance(match, tuple): + # Get the first non-empty group from the tuple + name = next((m for m in match if m), None) + if name: + names.add(name) + elif match: + names.add(match) + return names + + def analyze_with_regex( file_path: str, before: str, @@ -96,20 +123,8 @@ def analyze_with_regex( if func_pattern: # For JS/TS patterns with alternation, findall() returns tuples # Extract the non-empty match from each tuple - def extract_func_names(matches): - names = set() - for match in matches: - if isinstance(match, tuple): - # Get the first non-empty group from the tuple - name = next((m for m in match if m), None) - if name: - names.add(name) - elif match: - names.add(match) - return names - - funcs_before = extract_func_names(func_pattern.findall(before_normalized)) - funcs_after = extract_func_names(func_pattern.findall(after_normalized)) + funcs_before = _extract_func_names_from_matches(func_pattern.findall(before_normalized)) + funcs_after = _extract_func_names_from_matches(func_pattern.findall(after_normalized)) for func in funcs_after - funcs_before: changes.append( diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index a9edf89c6f..2e75e94370 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -1677,6 +1677,88 @@ export function registerWorktreeHandlers( planStatus = 'completed'; message = 'Changes merged successfully'; staged = false; + + // Clean up worktree after successful full merge (fixes #243) + // NEW: Add verification before cleanup to prevent data loss (fixes #797) + try { + if (worktreePath && existsSync(worktreePath)) { + const taskBranch = `auto-claude/${task.specId}`; + + // Verify the branch is fully merged before deleting worktree + debug('Verifying merge before worktree cleanup...'); + try { + const mergedBranches = execFileSync( + getToolPath('git'), + ['branch', '--merged', 'HEAD'], + { cwd: project.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + + // Check if our task branch appears in the merged branches list + const branchLines = mergedBranches.split('\n').map(line => line.trim()); + const isBranchMerged = branchLines.some(line => + line === taskBranch || line === `* ${taskBranch}` + ); + + if (!isBranchMerged) { + debug('WARNING: Branch not fully merged. Keeping worktree for safety.'); + // Update message to warn user + message = 'Merge completed but worktree kept for safety. Please verify with: git log --oneline -n 10'; + newStatus = 'human_review'; + planStatus = 'review'; + + resolve({ + success: true, + data: { + success: true, + message, + staged: false, + projectPath: project.path + } + }); + return; + } + + debug('✓ Verified branch is fully merged. Safe to delete worktree.'); + } catch (verifyErr) { + debug('Could not verify merge status. Keeping worktree for safety:', verifyErr); + message = 'Merge completed but worktree kept (verification failed). Please check: git log'; + newStatus = 'human_review'; + planStatus = 'review'; + + resolve({ + success: true, + data: { + success: true, + message, + staged: false, + projectPath: project.path + } + }); + return; + } + + // Verification passed - safe to delete worktree + execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], { + cwd: project.path, + encoding: 'utf-8' + }); + debug('Worktree cleaned up after full merge:', worktreePath); + + // Also delete the task branch since we merged successfully + try { + execFileSync(getToolPath('git'), ['branch', '-D', taskBranch], { + cwd: project.path, + encoding: 'utf-8' + }); + debug('Task branch deleted:', taskBranch); + } catch { + // Branch might not exist or already deleted + } + } + } catch (cleanupErr) { + debug('Worktree cleanup failed (non-fatal):', cleanupErr); + // Non-fatal - merge succeeded, cleanup can be done manually + } } debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged); diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index 5fe8349c64..499d023b01 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -41,27 +41,77 @@ const WINDOWS_SHELL_PATHS: Record = { ], }; +/** + * Search for a shell executable in PATH + */ +function findShellInPath(shellName: string): string | null { + const pathEnv = process.env.PATH || ''; + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const paths = pathEnv.split(pathSeparator); + + for (const dir of paths) { + const shellPath = `${dir}\\${shellName}`; + if (existsSync(shellPath)) { + return shellPath; + } + } + return null; +} + /** * Get the Windows shell executable based on preferred terminal setting + * Supports user-configurable paths via terminal.shellPaths setting */ function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): string { + // Read user settings for custom shell paths + const settings = readSettingsFile(); + const customShellPaths = settings?.terminal?.shellPaths as Record | undefined; + // If no preference or 'system', use COMSPEC (usually cmd.exe) if (!preferredTerminal || preferredTerminal === 'system') { return process.env.COMSPEC || 'cmd.exe'; } - // Check if we have paths defined for this terminal type - const paths = WINDOWS_SHELL_PATHS[preferredTerminal]; - if (paths) { - // Find the first existing shell - for (const shellPath of paths) { - if (existsSync(shellPath)) { - return shellPath; - } + // Merge custom paths with defaults (custom paths take priority) + let paths: string[] = []; + + // 1. Check user-provided custom paths first + if (customShellPaths && customShellPaths[preferredTerminal]) { + paths = [...customShellPaths[preferredTerminal]]; + } + + // 2. Add default paths as fallback + const defaultPaths = WINDOWS_SHELL_PATHS[preferredTerminal]; + if (defaultPaths) { + paths = [...paths, ...defaultPaths]; + } + + // 3. Find the first existing shell from merged paths + for (const shellPath of paths) { + if (existsSync(shellPath)) { + return shellPath; + } + } + + // 4. Try to find shell in PATH as additional fallback + const shellExecutables: Record = { + powershell: 'pwsh.exe', + windowsterminal: 'pwsh.exe', + cmd: 'cmd.exe', + gitbash: 'bash.exe', + cygwin: 'bash.exe', + msys2: 'bash.exe', + }; + + const shellName = shellExecutables[preferredTerminal]; + if (shellName) { + const pathShell = findShellInPath(shellName); + if (pathShell) { + return pathShell; } } - // Fallback to COMSPEC for unrecognized terminals + // 5. Final fallback to COMSPEC return process.env.COMSPEC || 'cmd.exe'; } diff --git a/scripts/extract-changelog.sh b/scripts/extract-changelog.sh new file mode 100644 index 0000000000..5a41cc03d4 --- /dev/null +++ b/scripts/extract-changelog.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Extract changelog section for a specific version from CHANGELOG.md +# Usage: extract-changelog.sh VERSION [CHANGELOG_FILE] +# Outputs the changelog content to stdout and returns 0 on success, 1 if not found + +set -euo pipefail + +VERSION="${1:-}" +CHANGELOG_FILE="${2:-CHANGELOG.md}" + +if [ -z "$VERSION" ]; then + echo "Usage: $0 VERSION [CHANGELOG_FILE]" >&2 + exit 1 +fi + +if [ ! -f "$CHANGELOG_FILE" ]; then + echo "::warning::$CHANGELOG_FILE not found" >&2 + echo "NOT_FOUND" + exit 1 +fi + +# Extract changelog section for this version +# Looks for "## X.Y.Z" header and captures until next "## " or "---" or end +# Uses escaped version to match dots literally in regex +CHANGELOG_CONTENT=$(awk -v ver="$VERSION" ' + BEGIN { + found=0 + content="" + # Escape dots in version for regex matching + ver_escaped = ver + gsub(/\./, "\\.", ver_escaped) + } + /^## / { + if (found) exit + # Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3") + # Use exact match OR regex with escaped version + if ($2 == ver || $2 ~ "^"ver_escaped"[[:space:]]*-") { + found=1 + # Skip the header line itself, we will add our own + next + } + } + /^---$/ { if (found) exit } + found { content = content $0 "\n" } + END { + if (!found) { + print "NOT_FOUND" + exit 1 + } + # Trim leading/trailing whitespace + gsub(/^[[:space:]]+|[[:space:]]+$/, "", content) + print content + } +' "$CHANGELOG_FILE") + +# Check if extraction succeeded +if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then + echo "NOT_FOUND" + exit 1 +fi + +echo "$CHANGELOG_CONTENT" +exit 0