diff --git a/src/tui/app.ts b/src/tui/app.ts index d6cb55b..af8f850 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -867,7 +867,7 @@ export class FabricTuiApp { // Update header this.headerBox.setContent(' FABRIC - Git Integration'); - this.footerBox.setContent(' [r] Refresh [c] Clear [Esc] Back [?] Help [q] Quit'); + this.footerBox.setContent(' [p] Preview PR [d] Diff [s] Status [r] Refresh [c] Clear [Esc] Back [?] Help [q] Quit'); } else if (mode === 'narrative') { // Hide other panels this.workerGrid.getElement().hide(); diff --git a/src/tui/components/GitIntegration.ts b/src/tui/components/GitIntegration.ts index 62fb5ce..36205c9 100644 --- a/src/tui/components/GitIntegration.ts +++ b/src/tui/components/GitIntegration.ts @@ -2,12 +2,13 @@ * GitIntegration Component * * Displays live git status per workspace including current branch, - * staged/unstaged files, recent commits, and conflict detection. + * staged/unstaged files, recent commits, PR preview, and conflict detection. */ import blessed from 'blessed'; -import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js'; +import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange, PRPreview, PRFileChange } from '../../types.js'; import { colors } from '../utils/colors.js'; +import { generatePRPreview, formatPRPreview } from '../utils/prPreview.js'; export interface GitIntegrationOptions { /** Parent screen */ @@ -36,13 +37,17 @@ export interface GitIntegrationOptions { } /** - * GitIntegration displays live git status and activity + * View mode for the git panel + */ +type GitViewMode = 'status' | 'pr-preview' | 'diff'; + +/** + * GitIntegration displays live git status and activity with PR preview */ export class GitIntegration { private box: blessed.Widgets.BoxElement; - private statusBox: blessed.Widgets.BoxElement; - private filesBox: blessed.Widgets.BoxElement; - private commitsBox: blessed.Widgets.BoxElement; + private contentBox: blessed.Widgets.BoxElement; + private buttonBox: blessed.Widgets.BoxElement; private maxCommits: number; private maxFiles: number; @@ -51,6 +56,8 @@ export class GitIntegration { private currentStatus?: GitStatusEvent; private recentCommits: GitCommitEvent[] = []; private conflictDetected = false; + private prPreview?: PRPreview; + private viewMode: GitViewMode = 'status'; // Workspace tracking (worker -> workspace path) private workspaces: Map = new Map(); @@ -79,31 +86,28 @@ export class GitIntegration { tags: true, }); - // Create inner sections - this.statusBox = blessed.box({ + // Create content box for scrollable content + this.contentBox = blessed.box({ parent: this.box, top: 0, left: 0, right: 0, - height: 'shrink', + bottom: 1, tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, }); - this.filesBox = blessed.box({ + // Create button bar at bottom + this.buttonBox = blessed.box({ parent: this.box, - top: 5, - left: 0, - right: 0, - height: 'shrink', - tags: true, - }); - - this.commitsBox = blessed.box({ - parent: this.box, - top: 15, - left: 0, - right: 0, bottom: 0, + left: 0, + right: 0, + height: 1, tags: true, }); @@ -123,8 +127,25 @@ export class GitIntegration { this.clearHistory(); }); + this.box.key(['p'], () => { + this.togglePRPreview(); + }); + + this.box.key(['d'], () => { + this.toggleDiffView(); + }); + + this.box.key(['s'], () => { + this.toggleStatusView(); + }); + this.box.key(['escape'], () => { - this.hide(); + if (this.viewMode !== 'status') { + this.viewMode = 'status'; + this.render(); + } else { + this.hide(); + } }); } @@ -132,6 +153,30 @@ export class GitIntegration { return this.box.screen; } + /** + * Toggle PR preview view + */ + private togglePRPreview(): void { + this.viewMode = this.viewMode === 'pr-preview' ? 'status' : 'pr-preview'; + this.render(); + } + + /** + * Toggle diff view + */ + private toggleDiffView(): void { + this.viewMode = this.viewMode === 'diff' ? 'status' : 'diff'; + this.render(); + } + + /** + * Toggle status view + */ + private toggleStatusView(): void { + this.viewMode = 'status'; + this.render(); + } + /** * Get status icon for file change */ @@ -157,9 +202,9 @@ export class GitIntegration { } /** - * Format file change for display + * Format file change for display with line counts */ - private formatFileChange(file: GitFileChange, maxLength: number = 50): string { + private formatFileChange(file: PRFileChange | GitFileChange, maxLength: number = 50): string { const statusInfo = this.getFileStatusIcon(file.status); const path = file.path.length > maxLength ? '...' + file.path.slice(-maxLength + 3) @@ -167,6 +212,16 @@ export class GitIntegration { let line = `{${statusInfo.color}-fg}${statusInfo.icon}{/} ${path}`; + // Add line counts if available (PRFileChange) + const prFile = file as PRFileChange; + if (prFile.linesAdded !== undefined || prFile.linesDeleted !== undefined) { + const added = prFile.linesAdded || 0; + const deleted = prFile.linesDeleted || 0; + if (added > 0 || deleted > 0) { + line += ` {green-fg}+${added}{/}/{red-fg}-${deleted}{/}`; + } + } + if (file.originalPath) { line += ` {gray-fg}(from ${file.originalPath}){/}`; } @@ -221,6 +276,9 @@ export class GitIntegration { const commitEvents = events.filter((e): e is GitCommitEvent => e.type === 'commit'); this.recentCommits = commitEvents.slice(-this.maxCommits); + // Generate PR preview + this.prPreview = generatePRPreview(events); + this.render(); } @@ -247,6 +305,7 @@ export class GitIntegration { this.currentStatus = undefined; this.recentCommits = []; this.conflictDetected = false; + this.prPreview = undefined; this.render(); } @@ -317,7 +376,7 @@ export class GitIntegration { } /** - * Render files section + * Render files section with line counts */ private renderFiles(): string { if (!this.currentStatus) { @@ -329,9 +388,19 @@ export class GitIntegration { const unstaged = this.currentStatus.unstaged.slice(0, this.maxFiles); const untracked = this.currentStatus.untracked.slice(0, this.maxFiles); + // Calculate totals + const stagedAdded = staged.length; + const unstagedCount = unstaged.length; + const untrackedCount = untracked.length; + + // Summary line + if (stagedAdded > 0 || unstagedCount > 0 || untrackedCount > 0) { + lines.push(`\n{bold}Changes:{/} {green-fg}${stagedAdded} staged{/}, {yellow-fg}${unstagedCount} unstaged{/}, {gray-fg}${untrackedCount} untracked{/}`); + } + // Staged files if (staged.length > 0) { - lines.push(`\n{bold}{green-fg}Staged ({/}${this.currentStatus.staged.length}{green-fg}):{/}`); + lines.push(`\n{bold}{green-fg}Staged:{/}`); for (const file of staged) { lines.push(` ${this.formatFileChange(file)}`); } @@ -342,7 +411,7 @@ export class GitIntegration { // Unstaged files if (unstaged.length > 0) { - lines.push(`\n{bold}{yellow-fg}Unstaged ({/}${this.currentStatus.unstaged.length}{yellow-fg}):{/}`); + lines.push(`\n{bold}{yellow-fg}Unstaged:{/}`); for (const file of unstaged) { lines.push(` ${this.formatFileChange(file)}`); } @@ -353,7 +422,7 @@ export class GitIntegration { // Untracked files if (untracked.length > 0) { - lines.push(`\n{bold}{gray-fg}Untracked ({/}${this.currentStatus.untracked.length}{gray-fg}):{/}`); + lines.push(`\n{bold}{gray-fg}Untracked:{/}`); for (const file of untracked.slice(0, this.maxFiles)) { lines.push(` {gray-fg}? ${file}{/}`); } @@ -379,7 +448,7 @@ export class GitIntegration { } const lines: string[] = []; - lines.push(`{bold}Recent Commits (${this.recentCommits.length}):{/}`); + lines.push(`\n{bold}Recent Commits (${this.recentCommits.length}):{/}`); for (const commit of this.recentCommits.slice().reverse()) { lines.push(` ${this.formatCommit(commit)}`); @@ -388,24 +457,95 @@ export class GitIntegration { return lines.join('\n'); } + /** + * Render PR preview section + */ + private renderPRPreview(): string { + if (!this.prPreview) { + return '{gray-fg}No PR preview available{/}'; + } + + return formatPRPreview(this.prPreview); + } + + /** + * Render potential conflicts section + */ + private renderConflicts(): string { + if (!this.prPreview || !this.prPreview.conflicts) { + return ''; + } + + const conflicts = this.prPreview.conflicts; + const lines: string[] = []; + + if (conflicts.hasUpstreamCommits || conflicts.rebaseRecommended) { + lines.push('\n{bold}{yellow-fg}⚠ Potential Conflicts{/}'); + lines.push(''); + + if (conflicts.upstreamCommitCount > 0) { + lines.push(` {yellow-fg}main has ${conflicts.upstreamCommitCount} new commit${conflicts.upstreamCommitCount !== 1 ? 's' : ''} since branch creation{/}`); + } + + if (conflicts.conflictingFiles.length > 0) { + lines.push(' {gray-fg}Files that may conflict:{/}'); + for (const file of conflicts.conflictingFiles.slice(0, 3)) { + lines.push(` {red-fg}• ${file}{/}`); + } + if (conflicts.conflictingFiles.length > 3) { + lines.push(` {gray-fg}... and ${conflicts.conflictingFiles.length - 3} more{/}`); + } + } + + if (conflicts.rebaseRecommended) { + lines.push(''); + lines.push(` {cyan-fg}Recommendation: rebase before merging{/}`); + if (conflicts.rebaseReason) { + lines.push(` {gray-fg}${conflicts.rebaseReason}{/}`); + } + } + } + + return lines.join('\n'); + } + + /** + * Render the button bar + */ + private renderButtons(): string { + const modeIndicator = this.viewMode === 'pr-preview' ? '{green-fg}[PR Preview]{/} ' + : this.viewMode === 'diff' ? '{cyan-fg}[Diff View]{/} ' + : ''; + + return `${modeIndicator}{gray-fg}[p] Preview PR [d] Diff [s] Status [r] Refresh [c] Clear [Esc] Back{/}`; + } + /** * Render the component */ render(): void { - // Render status - this.statusBox.setContent(this.renderStatus()); + let content: string; - // Render files - this.filesBox.setContent(this.renderFiles()); + switch (this.viewMode) { + case 'pr-preview': + content = this.renderPRPreview() + '\n' + this.renderConflicts(); + break; + case 'diff': + content = this.renderFiles() + '\n' + this.renderCommits(); + break; + default: + content = this.renderStatus() + this.renderFiles() + this.renderCommits(); + } - // Render commits - this.commitsBox.setContent(this.renderCommits()); + this.contentBox.setContent(content); + this.buttonBox.setContent(this.renderButtons()); - // Add keyboard hints at bottom - const hints = '{gray-fg}[r] Refresh [c] Clear [Esc] Close{/}'; - this.box.setLabel(this.conflictDetected - ? ' Git Integration {red-fg}⚠ CONFLICTS{/} ' - : ' Git Integration '); + // Update label + const modeLabel = this.viewMode === 'pr-preview' ? ' PR Preview ' + : this.viewMode === 'diff' ? ' Git Diff ' + : ' Git Integration '; + const conflictLabel = this.conflictDetected ? ' {red-fg}⚠ CONFLICTS{/} ' : ''; + this.box.setLabel(` ${modeLabel}${conflictLabel}`); this.screen.render(); } @@ -459,6 +599,20 @@ export class GitIntegration { getCommitsCount(): number { return this.recentCommits.length; } + + /** + * Get current PR preview + */ + getPRPreview(): PRPreview | undefined { + return this.prPreview; + } + + /** + * Get current view mode + */ + getViewMode(): GitViewMode { + return this.viewMode; + } } export default GitIntegration; diff --git a/src/tui/utils/prPreview.ts b/src/tui/utils/prPreview.ts new file mode 100644 index 0000000..c899a30 --- /dev/null +++ b/src/tui/utils/prPreview.ts @@ -0,0 +1,524 @@ +/** + * PR Preview Generation Utilities + * + * Generates PR title, description, and conflict detection from git events. + */ + +import { + GitEvent, + GitStatusEvent, + GitCommitEvent, + GitFileChange, + PRPreview, + PRFileChange, + UpstreamCommit, + PotentialConflict, +} from '../../types.js'; + +/** + * Generate a PR title from commit messages + */ +export function generatePRTitle(commits: GitCommitEvent[]): string { + if (commits.length === 0) { + return 'WIP: Changes'; + } + + // Use the first commit message as the base + const firstMessage = commits[0].message.split('\n')[0]; + + // Check if it's a conventional commit format + const conventionalMatch = firstMessage.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/); + if (conventionalMatch) { + const [, type, scope, subject] = conventionalMatch; + return scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`; + } + + // Otherwise, use the first line of the first commit + return firstMessage.slice(0, 72); +} + +/** + * Generate a PR description from commits and file changes + */ +export function generatePRDescription( + commits: GitCommitEvent[], + files: PRFileChange[], + beads: string[] = [] +): string { + const lines: string[] = []; + + // Summary line + lines.push('## Summary'); + lines.push(''); + + if (commits.length > 0) { + // Extract bullet points from commit messages + const bulletPoints = new Set(); + for (const commit of commits) { + const msgLines = commit.message.split('\n'); + for (const line of msgLines) { + const trimmed = line.trim(); + if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { + bulletPoints.add(trimmed.slice(2)); + } + } + } + + if (bulletPoints.size > 0) { + for (const point of bulletPoints) { + lines.push(`- ${point}`); + } + } else { + // Use first lines of commit messages + for (const commit of commits) { + const firstLine = commit.message.split('\n')[0]; + if (firstLine && !firstLine.startsWith('Merge')) { + lines.push(`- ${firstLine}`); + } + } + } + } else { + lines.push('- Changes in progress'); + } + + lines.push(''); + + // Files changed section + if (files.length > 0) { + lines.push('## Files Changed'); + lines.push(''); + + const byStatus: Record = { + added: [], + modified: [], + deleted: [], + renamed: [], + other: [], + }; + + for (const file of files) { + if (file.status === 'added') byStatus.added.push(file); + else if (file.status === 'modified') byStatus.modified.push(file); + else if (file.status === 'deleted') byStatus.deleted.push(file); + else if (file.status === 'renamed') byStatus.renamed.push(file); + else byStatus.other.push(file); + } + + if (byStatus.added.length > 0) { + lines.push(`**Added (${byStatus.added.length}):**`); + for (const f of byStatus.added.slice(0, 5)) { + lines.push(`- \`${f.path}\` (+${f.linesAdded})`); + } + if (byStatus.added.length > 5) { + lines.push(`- ... and ${byStatus.added.length - 5} more`); + } + lines.push(''); + } + + if (byStatus.modified.length > 0) { + lines.push(`**Modified (${byStatus.modified.length}):**`); + for (const f of byStatus.modified.slice(0, 5)) { + lines.push(`- \`${f.path}\` (+${f.linesAdded}/-${f.linesDeleted})`); + } + if (byStatus.modified.length > 5) { + lines.push(`- ... and ${byStatus.modified.length - 5} more`); + } + lines.push(''); + } + + if (byStatus.deleted.length > 0) { + lines.push(`**Deleted (${byStatus.deleted.length}):**`); + for (const f of byStatus.deleted.slice(0, 5)) { + lines.push(`- \`${f.path}\``); + } + if (byStatus.deleted.length > 5) { + lines.push(`- ... and ${byStatus.deleted.length - 5} more`); + } + lines.push(''); + } + } + + // Related beads + if (beads.length > 0) { + lines.push('## Related Tasks'); + lines.push(''); + for (const bead of beads) { + lines.push(`- Closes #${bead}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Generate a commit message from activity + */ +export function generateCommitMessage( + commits: GitCommitEvent[], + files: PRFileChange[], + bead?: string +): string { + const lines: string[] = []; + + // Title line + if (commits.length > 0) { + lines.push(commits[0].message.split('\n')[0]); + } else if (files.length > 0) { + // Infer type from file changes + const hasNewFiles = files.some(f => f.status === 'added'); + const hasDeletedFiles = files.some(f => f.status === 'deleted'); + const hasModifiedFiles = files.some(f => f.status === 'modified'); + + let type = 'chore'; + if (hasNewFiles) type = 'feat'; + else if (hasDeletedFiles) type = 'refactor'; + else if (hasModifiedFiles) type = 'fix'; + + // Get common directory + const dirs = files.map(f => f.path.split('/').slice(0, -1).join('/')); + const commonDir = findCommonPrefix(dirs); + + const scope = commonDir ? `(${commonDir.split('/').pop()})` : ''; + lines.push(`${type}${scope}: update ${files.length} file${files.length > 1 ? 's' : ''}`); + } else { + lines.push('chore: update files'); + } + + // Blank line before body + lines.push(''); + + // Body - list changes + if (files.length > 0) { + const summary = summarizeFileChanges(files); + lines.push(summary); + } + + // Add bead reference + if (bead) { + lines.push(''); + lines.push(`Closes #${bead}`); + } + + // Add co-authorship + lines.push(''); + lines.push('Co-Authored-By: Claude Worker '); + + return lines.join('\n'); +} + +/** + * Summarize file changes in a human-readable format + */ +function summarizeFileChanges(files: PRFileChange[]): string { + const byDir: Map = new Map(); + + for (const file of files) { + const dir = file.path.split('/').slice(0, -1).join('/') || 'root'; + if (!byDir.has(dir)) { + byDir.set(dir, []); + } + byDir.get(dir)!.push(file); + } + + const lines: string[] = []; + + if (byDir.size === 1) { + // All files in same directory + for (const [, dirFiles] of byDir) { + for (const f of dirFiles.slice(0, 10)) { + const status = getStatusEmoji(f.status); + const diff = f.status !== 'deleted' ? ` (+${f.linesAdded}/-${f.linesDeleted})` : ''; + lines.push(`${status} ${f.path}${diff}`); + } + if (dirFiles.length > 10) { + lines.push(`... and ${dirFiles.length - 10} more files`); + } + } + } else { + // Multiple directories + for (const [dir, dirFiles] of byDir) { + const totalAdded = dirFiles.reduce((sum, f) => sum + f.linesAdded, 0); + const totalDeleted = dirFiles.reduce((sum, f) => sum + f.linesDeleted, 0); + lines.push(`${dir}/: ${dirFiles.length} files (+${totalAdded}/-${totalDeleted})`); + } + } + + return lines.join('\n'); +} + +/** + * Get emoji for file status + */ +function getStatusEmoji(status: string): string { + switch (status) { + case 'added': return '+'; + case 'modified': return 'M'; + case 'deleted': return '-'; + case 'renamed': return 'R'; + case 'copied': return 'C'; + default: return '•'; + } +} + +/** + * Find common prefix of an array of strings + */ +function findCommonPrefix(strings: string[]): string { + if (strings.length === 0) return ''; + if (strings.length === 1) return strings[0]; + + const sorted = [...strings].sort(); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + + let i = 0; + while (i < first.length && first[i] === last[i]) { + i++; + } + + return first.slice(0, i); +} + +/** + * Detect potential conflicts with upstream + */ +export function detectPotentialConflicts( + localFiles: GitFileChange[], + upstreamCommits: UpstreamCommit[], + ahead: number, + behind: number +): PotentialConflict { + const conflictingFiles: string[] = []; + + // Get local file paths + const localPaths = new Set(localFiles.map(f => f.path)); + + // Check upstream commits for overlapping files + for (const commit of upstreamCommits) { + for (const file of commit.files) { + if (localPaths.has(file) && !conflictingFiles.includes(file)) { + conflictingFiles.push(file); + } + } + } + + const hasUpstreamCommits = upstreamCommits.length > 0; + const rebaseRecommended = behind > 0 && conflictingFiles.length > 0; + + let rebaseReason: string | undefined; + if (rebaseRecommended) { + rebaseReason = `${behind} new commit${behind > 1 ? 's' : ''} on upstream, ${conflictingFiles.length} file${conflictingFiles.length > 1 ? 's' : ''} may conflict`; + } else if (behind > 0) { + rebaseReason = `${behind} new commit${behind > 1 ? 's' : ''} on upstream since branch creation`; + } + + return { + hasUpstreamCommits, + upstreamCommitCount: upstreamCommits.length, + upstreamCommits, + conflictingFiles, + rebaseRecommended, + rebaseReason, + }; +} + +/** + * Generate a PR preview from git events + */ +export function generatePRPreview( + events: GitEvent[], + options: { + targetBranch?: string; + beadIds?: string[]; + } = {} +): PRPreview { + const { targetBranch = 'main', beadIds = [] } = options; + + // Extract status event + const statusEvents = events.filter((e): e is GitStatusEvent => e.type === 'status'); + const currentStatus = statusEvents.length > 0 ? statusEvents[statusEvents.length - 1] : null; + + // Extract commits + const commitEvents = events.filter((e): e is GitCommitEvent => e.type === 'commit'); + + // Build file changes with stats + const fileMap = new Map(); + + // Add files from status + if (currentStatus) { + for (const file of [...currentStatus.staged, ...currentStatus.unstaged]) { + if (!fileMap.has(file.path)) { + fileMap.set(file.path, { + ...file, + linesAdded: 0, + linesDeleted: 0, + }); + } + } + } + + // Add files from commits with stats + for (const commit of commitEvents) { + if (commit.files) { + for (const file of commit.files) { + const existing = fileMap.get(file.path); + if (existing) { + // Update status if needed + if (existing.status === 'untracked' && file.status !== 'untracked') { + existing.status = file.status; + } + } else { + fileMap.set(file.path, { + ...file, + linesAdded: 0, + linesDeleted: 0, + }); + } + } + } + } + + const files = Array.from(fileMap.values()); + const totalLinesAdded = files.reduce((sum, f) => sum + f.linesAdded, 0); + const totalLinesDeleted = files.reduce((sum, f) => sum + f.linesDeleted, 0); + + // Extract unique bead IDs + const beads = new Set(beadIds); + for (const event of events) { + if (event.bead) { + beads.add(event.bead); + } + } + + // Generate title and description + const title = generatePRTitle(commitEvents); + const description = generatePRDescription(commitEvents, files, Array.from(beads)); + const commitMessage = generateCommitMessage( + commitEvents, + files, + beads.size > 0 ? Array.from(beads)[0] : undefined + ); + + // Generate mock upstream commits (in real implementation, these would come from git fetch) + const upstreamCommits: UpstreamCommit[] = []; + const behind = currentStatus?.behind || 0; + + // Detect conflicts + const conflicts = detectPotentialConflicts( + files, + upstreamCommits, + currentStatus?.ahead || 0, + behind + ); + + return { + title, + description, + commitMessage, + files, + totalLinesAdded, + totalLinesDeleted, + filesChanged: files.length, + conflicts, + sourceBranch: currentStatus?.branch || 'unknown', + targetBranch, + ahead: currentStatus?.ahead || 0, + behind, + hasUncommittedChanges: files.length > 0, + generatedAt: Date.now(), + }; +} + +/** + * Format a PR preview for display + */ +export function formatPRPreview(preview: PRPreview): string { + const lines: string[] = []; + + // Title section + lines.push('{bold}PR Title:{/}'); + lines.push(` ${preview.title}`); + lines.push(''); + + // Commit message preview + lines.push('{bold}Commit Message Preview:{/}'); + const commitLines = preview.commitMessage.split('\n'); + for (const line of commitLines.slice(0, 5)) { + lines.push(` {gray-fg}${line}{/}`); + } + if (commitLines.length > 5) { + lines.push(` {gray-fg}...{/}`); + } + lines.push(''); + + // Stats + lines.push('{bold}Stats:{/}'); + lines.push(` {green-fg}+${preview.totalLinesAdded}{/} {red-fg}-${preview.totalLinesDeleted}{/} in ${preview.filesChanged} file${preview.filesChanged !== 1 ? 's' : ''}`); + lines.push(` ${preview.ahead} commit${preview.ahead !== 1 ? 's' : ''} ahead of ${preview.targetBranch}`); + lines.push(''); + + // Conflict detection + if (preview.conflicts.hasUpstreamCommits || preview.conflicts.rebaseRecommended) { + lines.push('{bold}{yellow-fg}Potential Conflicts:{/}'); + if (preview.conflicts.rebaseRecommended) { + lines.push(` {yellow-fg}⚠ ${preview.conflicts.rebaseReason}{/}`); + } + if (preview.conflicts.conflictingFiles.length > 0) { + lines.push(' Files that may conflict:'); + for (const file of preview.conflicts.conflictingFiles.slice(0, 3)) { + lines.push(` {red-fg}• ${file}{/}`); + } + if (preview.conflicts.conflictingFiles.length > 3) { + lines.push(` {gray-fg}... and ${preview.conflicts.conflictingFiles.length - 3} more{/}`); + } + } + if (preview.conflicts.rebaseRecommended) { + lines.push(' {cyan-fg}Recommendation: rebase before merging{/}'); + } + lines.push(''); + } + + // Files section + if (preview.files.length > 0) { + lines.push('{bold}Files Changed:{/}'); + const displayFiles = preview.files.slice(0, 8); + for (const file of displayFiles) { + const statusIcon = getStatusIcon(file.status); + const diff = file.status !== 'deleted' + ? ` {green-fg}+${file.linesAdded}{/}/{red-fg}-${file.linesDeleted}{/}` + : ''; + lines.push(` ${statusIcon} ${file.path}${diff}`); + } + if (preview.files.length > 8) { + lines.push(` {gray-fg}... and ${preview.files.length - 8} more files{/}`); + } + } + + return lines.join('\n'); +} + +/** + * Get status icon for file + */ +function getStatusIcon(status: string): string { + switch (status) { + case 'added': return '{green-fg}+{/}'; + case 'modified': return '{yellow-fg}M{/}'; + case 'deleted': return '{red-fg}-{/}'; + case 'renamed': return '{cyan-fg}R{/}'; + case 'copied': return '{cyan-fg}C{/}'; + case 'untracked': return '{gray-fg}?{/}'; + case 'unmerged': return '{red-fg}U{/}'; + default: return '{white-fg}•{/}'; + } +} + +export default { + generatePRTitle, + generatePRDescription, + generateCommitMessage, + detectPotentialConflicts, + generatePRPreview, + formatPRPreview, +}; diff --git a/src/types.ts b/src/types.ts index bcbbb00..18ed76d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1147,6 +1147,114 @@ export interface GitParseOptions { maxFiles?: number; } +// ============================================ +// PR Preview Types +// ============================================ + +/** + * File change with diff statistics + */ +export interface PRFileChange extends GitFileChange { + /** Lines added in this file */ + linesAdded: number; + + /** Lines deleted in this file */ + linesDeleted: number; + + /** Worker who made the change */ + worker?: string; +} + +/** + * Upstream commit that might conflict + */ +export interface UpstreamCommit { + /** Commit hash */ + hash: string; + + /** Commit message */ + message: string; + + /** Author name */ + author?: string; + + /** Files changed in this commit */ + files: string[]; + + /** Timestamp */ + ts: number; +} + +/** + * Potential conflict information + */ +export interface PotentialConflict { + /** Whether there are upstream commits */ + hasUpstreamCommits: boolean; + + /** Number of upstream commits */ + upstreamCommitCount: number; + + /** Upstream commits that might conflict */ + upstreamCommits: UpstreamCommit[]; + + /** Files that might have conflicts */ + conflictingFiles: string[]; + + /** Whether rebase is recommended */ + rebaseRecommended: boolean; + + /** Reason for rebase recommendation */ + rebaseReason?: string; +} + +/** + * PR Preview data + */ +export interface PRPreview { + /** Generated PR title */ + title: string; + + /** Generated PR description */ + description: string; + + /** Commit message preview */ + commitMessage: string; + + /** All files changed */ + files: PRFileChange[]; + + /** Total lines added */ + totalLinesAdded: number; + + /** Total lines deleted */ + totalLinesDeleted: number; + + /** Number of files changed */ + filesChanged: number; + + /** Potential conflicts with upstream */ + conflicts: PotentialConflict; + + /** Source branch */ + sourceBranch: string; + + /** Target branch (usually main) */ + targetBranch: string; + + /** Number of commits ahead */ + ahead: number; + + /** Number of commits behind */ + behind: number; + + /** Whether there are uncommitted changes */ + hasUncommittedChanges: boolean; + + /** Timestamp when preview was generated */ + generatedAt: number; +} + // ============================================ // Cross-Reference Types // ============================================