diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml new file mode 100644 index 0000000..7a44878 --- /dev/null +++ b/.github/workflows/code-review.yml @@ -0,0 +1,133 @@ +name: AI Code Review +on: + pull_request: + types: [opened, synchronize, ready_for_review] + push: + branches: [main] + +concurrency: + group: review-${{ github.ref }} + cancel-in-progress: true + +permissions: + pull-requests: write + contents: write + checks: write + statuses: write + +env: + ZAI_BASE_URL: "https://api.z.ai/api/coding/paas/v4/" + +jobs: + secret-scan: + name: "🔒 Secret Scan" + runs-on: ubuntu-latest + outputs: + secrets_found: ${{ steps.scan.outputs.found }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan diff for secrets + id: scan + uses: sulthonzh/code-reviewer@main + with: + command: secret-scan + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Block if secrets found + if: steps.scan.outputs.found == 'true' + run: | + echo "::error::Found potential secret(s) in the diff. Remove before merging." + exit 1 + + ai-review: + name: "🤖 AI Review" + runs-on: ubuntu-latest + needs: secret-scan + outputs: + approved: ${{ steps.review.outputs.approved }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect project context + id: context + uses: sulthonzh/code-reviewer@main + with: + command: detect-context + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Route model + id: model + run: | + DIFF_LINES=$(git diff origin/main...HEAD 2>/dev/null | wc -l || echo 0) + if [ "$DIFF_LINES" -gt 500 ]; then + echo "model=glm-5.1" >> "$GITHUB_OUTPUT" + else + echo "model=glm-4.5" >> "$GITHUB_OUTPUT" + fi + - name: Run AI review + id: review + uses: sulthonzh/code-reviewer@main + with: + command: ai-review + model: ${{ steps.model.outputs.model }} + project-type: ${{ steps.context.outputs.project_type }} + zai-api-key: ${{ secrets.ZAI_API_KEY }} + zai-base-url: ${{ env.ZAI_BASE_URL }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + quality-gate: + name: "✅ Quality Gate" + runs-on: ubuntu-latest + needs: [secret-scan, ai-review] + outputs: + passed: ${{ steps.gate.outputs.passed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Run quality checks + id: gate + uses: sulthonzh/code-reviewer@main + with: + command: quality-gate + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Post status + if: always() + uses: sulthonzh/code-reviewer@main + with: + command: post-status + passed: ${{ steps.gate.outputs.passed }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + auto-merge: + name: "🔀 Auto-Merge" + runs-on: ubuntu-latest + needs: [ai-review, quality-gate] + if: >- + needs.ai-review.outputs.approved == 'true' && + needs.quality-gate.outputs.passed == 'true' && + github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Approve and merge + uses: sulthonzh/code-reviewer@main + with: + command: auto-merge + github-token: ${{ secrets.GITHUB_TOKEN }} + + auto-release: + name: "📦 Auto-Release" + runs-on: ubuntu-latest + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect and release + uses: sulthonzh/code-reviewer@main + with: + command: auto-release + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 06d701b..835c463 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ When `git merge` fails, developers face pain points: - ✅ Open each file in your preferred `$EDITOR` - ✅ Validate conflict markers are resolved before continuing - ✅ Cross-platform (macOS, Linux, Windows) -- ✅ Continue/abort functionality +- ✅ Abort merge functionality +- ✅ Auto-stage resolved files (`--stage`) +- ✅ JSON output for scripting/CI (`--json`) - ✅ Zero configuration required ## Installation @@ -79,6 +81,22 @@ Run "git commit" to complete the merge. git-conflicts --abort ``` +### Auto-stage resolved files + +```bash +git-conflicts --stage +``` + +With `--stage`, each file is automatically `git add`ed after conflict markers are resolved. + +### JSON output (for scripting/CI) + +```bash +git-conflicts --status --json +``` + +Returns structured JSON with conflict info, useful for CI pipelines or scripting. + ## Configuration `git-conflicts` respects your environment variables: @@ -94,7 +112,7 @@ If neither is set, it defaults to: 1. Detects merge conflicts using `git diff --name-only --diff-filter=U` 2. Opens each conflicted file in your configured editor -3. Validates that conflict markers (`<<<<<<<`) are removed +3. Validates that all conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`, `|||||||`) are removed 4. Tracks progress and shows summary 5. Prompts you to run `git commit` when done diff --git a/jest.config.js b/jest.config.js index dba8e8c..2023849 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,9 @@ module.exports = { coverageReporters: ['text', 'lcov'], moduleFileExtensions: ['ts', 'js', 'json'], verbose: true, + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: 'tsconfig.test.json', + }], + }, }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 01d4496..adc0e90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "git-conflicts", - "version": "1.0.0", + "name": "@sulthonzh/git-conflicts", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "git-conflicts", - "version": "1.0.0", + "name": "@sulthonzh/git-conflicts", + "version": "1.1.0", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/src/cli.ts b/src/cli.ts index eb9e71b..4d996e8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,9 +20,10 @@ program .option('-s, --status', 'Show conflict status only') .option('--stage', 'Auto-stage resolved files with git add') .option('-j, --json', 'Output in JSON format (for scripting/CI)') + .option('--cwd ', 'Path to git repository (defaults to current directory)') .action(async (options) => { - const gitOps = new GitOperations(); - const resolver = new ConflictResolver(); + const gitOps = new GitOperations(options.cwd); + const resolver = new ConflictResolver(gitOps); try { // Handle abort diff --git a/src/core.test.ts b/src/core.test.ts index fbaad72..cf2bdba 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -75,6 +75,23 @@ describe('GitOperations', () => { expect(gitOps.hasConflictMarkers(cleanContent)).toBe(false); }); + it('should not false-positive on marker text inside strings', () => { + // A string literal or comment containing <<<<<<< should not be flagged + const contentWithString = 'const msg = "use <<<<<<< to mark conflicts";'; + expect(gitOps.hasConflictMarkers(contentWithString)).toBe(false); + }); + + it('should detect diff3 conflict markers (|||||||)', () => { + const diff3Content = '||||||| merged common ancestors\nbase\n=======\nours\n>>>>>>> feature'; + expect(gitOps.hasConflictMarkers(diff3Content)).toBe(true); + }); + + it('should detect remaining ======= and >>>>>>> markers', () => { + // User deleted <<<<<<< but left other markers + const partialMarkers = 'some code\n=======\ntheir code\n>>>>>>> feature\nend'; + expect(gitOps.hasConflictMarkers(partialMarkers)).toBe(true); + }); + it('should count conflict markers correctly', () => { const singleConflict = 'some code\n<<<<<<< HEAD\nmy code\n=======\ntheir code\n>>>>>>> feature\nend'; expect(gitOps.countConflicts(singleConflict)).toBe(1); @@ -85,6 +102,11 @@ describe('GitOperations', () => { const cleanContent = 'no conflicts here'; expect(gitOps.countConflicts(cleanContent)).toBe(0); }); + + it('should count bare <<<<<<< lines without branch name', () => { + const bareMarker = 'some code\n<<<<<<<\nmy code\n=======\ntheir code\n>>>>>>> feature'; + expect(gitOps.countConflicts(bareMarker)).toBe(1); + }); }); describe('ConflictResolver', () => { @@ -94,6 +116,13 @@ describe('ConflictResolver', () => { resolver = new ConflictResolver(); }); + it('should accept a GitOperations instance', () => { + const customGitOps = new GitOperations('/some/path'); + const customResolver = new ConflictResolver(customGitOps); + // Should not throw + expect(customResolver).toBeInstanceOf(ConflictResolver); + }); + it('should validate clean file content', async () => { const cleanContent = 'const x = 1;'; (fs.readFile as jest.Mock).mockResolvedValue(cleanContent); diff --git a/src/git.ts b/src/git.ts index 27e9399..4f2f4f3 100644 --- a/src/git.ts +++ b/src/git.ts @@ -6,6 +6,7 @@ import { existsSync } from 'fs'; interface StatusFile { index: string; path: string; + working_dir?: string; } export class GitOperations { @@ -22,8 +23,6 @@ export class GitOperations { */ async getConflictedFiles(): Promise { try { - await this.git.status(); - const diffOutput = await this.git.diff(['--name-only', '--diff-filter=U']); const files = diffOutput .split('\n') @@ -49,6 +48,16 @@ export class GitOperations { return status.current || 'HEAD'; } + /** + * Resolve the absolute .git directory path + */ + private async getAbsoluteGitDir(): Promise { + const gitDir = await this.git.revparse(['--git-dir']); + // revparse --git-dir can return relative paths like ".git" + const absolute = resolve(this.workingDir, gitDir.trim()); + return absolute; + } + /** * Get merge conflict info (branches involved) * Attempts to read MERGE_HEAD and MERGE_MSG for better context @@ -58,10 +67,11 @@ export class GitOperations { let merging: string | undefined; let mergeMessage: string | undefined; + const gitDir = await this.getAbsoluteGitDir(); + // Try to read .git/MERGE_MSG for merge context try { - const gitDir = await this.git.revparse(['--git-dir']); - const mergeMsgPath = resolve(this.workingDir, gitDir.trim(), 'MERGE_MSG'); + const mergeMsgPath = resolve(gitDir, 'MERGE_MSG'); if (existsSync(mergeMsgPath)) { const msg = await readFile(mergeMsgPath, 'utf-8'); // Extract branch name from "Merge branch 'xyz'" @@ -78,8 +88,7 @@ export class GitOperations { // Try to read .git/MERGE_HEAD for the commit being merged if (!merging) { try { - const gitDir = await this.git.revparse(['--git-dir']); - const mergeHeadPath = resolve(this.workingDir, gitDir.trim(), 'MERGE_HEAD'); + const mergeHeadPath = resolve(gitDir, 'MERGE_HEAD'); if (existsSync(mergeHeadPath)) { const sha = (await readFile(mergeHeadPath, 'utf-8')).trim(); // Get short ref name for the SHA @@ -95,6 +104,20 @@ export class GitOperations { } } + // During rebase, check rebase-merge directory for branch info + if (!merging) { + try { + const headNamePath = resolve(gitDir, 'rebase-merge', 'head-name'); + if (existsSync(headNamePath)) { + const headName = (await readFile(headNamePath, 'utf-8')).trim(); + // head-name is typically "refs/heads/branch-name" + merging = headName.replace(/^refs\/heads\//, ''); + } + } catch { + // ignore + } + } + return { current, merging, mergeMessage }; } @@ -108,16 +131,21 @@ export class GitOperations { /** * Check if file content still has conflict markers + * Checks for all conflict marker types at line start to avoid false positives + * from strings/comments that happen to contain marker-like text. */ hasConflictMarkers(content: string): boolean { - return content.includes('<<<<<<<'); + // Check for any of the four conflict marker types at line start + // <<<<<<< = ours, ======= = separator, >>>>>>> = theirs, ||||||| = base (diff3) + return /^(<<<<<<<|=======|>>>>>>>|\|\|\|\|\|\|\|)/m.test(content); } /** * Count conflict markers in content */ countConflicts(content: string): number { - const matches = content.match(/<<<<<<<.+$/gm); + // Match <<<<<<< at line start with optional trailing text (including bare <<<<<<<) + const matches = content.match(/^<<<<<<<.*$/gm); return matches ? matches.length : 0; } /** @@ -135,12 +163,39 @@ export class GitOperations { } /** - * Check if merge is in progress + * Check if merge or rebase is in progress + * Checks for all unmerged status codes: UU, AA, DD, AU, UA, DU, UD + * Also detects rebase conflicts via .git/rebase-merge directory */ async isMergeInProgress(): Promise { try { const status = await this.git.status(); - return status.files.some((f: StatusFile) => f.index === 'U'); + // Unmerged status codes: both modified (UU), both added (AA), both deleted (DD), + // added by us (AU), added by them (UA), deleted by us (DU), deleted by them (UD) + const unmergedCodes = new Set(['U', 'A', 'D']); + const hasUnmergedFiles = status.files.some( + (f: StatusFile) => + unmergedCodes.has(f.index) && unmergedCodes.has(f.working_dir || '') + ); + + if (hasUnmergedFiles) { + return true; + } + + // Check for rebase in progress (no MERGE_HEAD, but rebase-merge dir exists) + const gitDir = await this.getAbsoluteGitDir(); + const rebaseMergeDir = resolve(gitDir, 'rebase-merge'); + if (existsSync(rebaseMergeDir)) { + return true; + } + + // Check for cherry-pick in progress + const cherryPickHead = resolve(gitDir, 'CHERRY_PICK_HEAD'); + if (existsSync(cherryPickHead)) { + return true; + } + + return false; } catch { return false; } @@ -166,4 +221,4 @@ export class GitOperations { mergeMessage: info.mergeMessage, }; } -} \ No newline at end of file +} diff --git a/src/resolver.ts b/src/resolver.ts index 273adef..6abd454 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -6,21 +6,63 @@ import { GitOperations } from './git'; export class ConflictResolver { private gitOps: GitOperations; - constructor() { - this.gitOps = new GitOperations(); + constructor(gitOps?: GitOperations) { + this.gitOps = gitOps ?? new GitOperations(); + } + + /** + * Parse editor command string into command and args array. + * Handles editors with flags like "code --wait" or "vim -c 'set diff'" + */ + private parseEditorCommand(editorString: string): { command: string; args: string[] } { + // Simple shell-like parsing: split on whitespace, respect basic quoting + const parts: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (const ch of editorString) { + if (inQuote) { + if (ch === quoteChar) { + inQuote = false; + } else { + current += ch; + } + } else if (ch === '"' || ch === "'") { + inQuote = true; + quoteChar = ch; + } else if (ch === ' ' || ch === '\t') { + if (current.length > 0) { + parts.push(current); + current = ''; + } + } else { + current += ch; + } + } + if (current.length > 0) { + parts.push(current); + } + + if (parts.length === 0) { + return { command: editorString, args: [] }; + } + + return { command: parts[0], args: parts.slice(1) }; } /** * Open file in user's preferred editor */ async openInEditor(filePath: string): Promise { - const editor = process.env.EDITOR || process.env.VISUAL || this.getDefaultEditor(); + const editorString = process.env.EDITOR || process.env.VISUAL || this.getDefaultEditor(); const fullPath = resolve(filePath); + const { command, args } = this.parseEditorCommand(editorString); - console.log(` Opening in ${editor}...`); + console.log(` Opening in ${editorString}...`); return new Promise((resolvePromise, reject) => { - const editorProcess = spawn(editor, [fullPath], { + const editorProcess = spawn(command, [...args, fullPath], { stdio: 'inherit', }); @@ -89,31 +131,34 @@ export class ConflictResolver { /** * Resolve a single conflict file * Returns true if resolved, false if skipped or failed + * + * Note: After the editor closes (even with a non-zero exit code), + * we still validate whether the file was actually resolved. + * Editors like vim can exit with code 1 for benign reasons + * (swap file warnings) while the user has saved their changes. */ async resolveFile(filePath: string): Promise<{ success: boolean; message: string }> { try { - // Open in editor await this.openInEditor(filePath); + } catch { + // Editor exited with non-zero code — don't give up yet. + // The user may have saved the file with conflicts resolved. + // Fall through to validate the file content below. + } - // Validate resolution - const validation = await this.validateResolution(filePath); - - if (!validation.valid) { - return { - success: false, - message: `${filePath}: ${validation.reason}`, - }; - } + // Always validate resolution regardless of editor exit code + const validation = await this.validateResolution(filePath); - return { - success: true, - message: `Resolved ${filePath}`, - }; - } catch (error) { + if (!validation.valid) { return { success: false, - message: `Failed to resolve ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: `${filePath}: ${validation.reason}`, }; } + + return { + success: true, + message: `Resolved ${filePath}`, + }; } } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..421708a --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["src/**/*", "global.d.ts"], + "exclude": ["node_modules", "dist"] +}