Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/git/conventionalCommits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as fs from 'fs';
import * as path from 'path';

export const CONVENTIONAL_COMMIT_TYPES = [
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'chore',
'ci',
] as const;

export type ConventionalCommitType = (typeof CONVENTIONAL_COMMIT_TYPES)[number];

export interface ConventionalCommitParts {
type: string;
scope?: string;
breaking?: boolean;
subject: string;
body?: string;
footer?: string;
}

export interface IssueSuggestionItem {
number: number;
title: string;
state?: string;
}

/**
* Formats structured Conventional Commit fields into a standard Git commit message.
*/
export function formatConventionalCommit(parts: ConventionalCommitParts): string {
const cleanType = (parts.type || 'feat').trim().toLowerCase() || 'feat';
const cleanScope = parts.scope ? parts.scope.trim().replace(/[()\r\n]/g, '') : '';
const cleanSubject = (parts.subject || '').trim().replace(/\r?\n/g, ' ');
const bang = parts.breaking ? '!' : '';
const scopeSegment = cleanScope ? `(${cleanScope})` : '';

const header = `${cleanType}${scopeSegment}${bang}: ${cleanSubject}`;
const sections: string[] = [header];

const cleanBody = parts.body ? parts.body.trim() : '';
if (cleanBody) {
sections.push(cleanBody);
}

let cleanFooter = parts.footer ? parts.footer.trim() : '';
if (parts.breaking && !/BREAKING CHANGE:/i.test(cleanFooter)) {
const breakingNote = `BREAKING CHANGE: ${cleanSubject || 'introduces breaking changes'}`;
cleanFooter = cleanFooter ? `${cleanFooter}\n${breakingNote}` : breakingNote;
}
if (cleanFooter) {
sections.push(cleanFooter);
}

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

/**
* Parses a Conventional Commit string back into its structured parts if it matches the spec.
*/
export function parseConventionalCommit(message: string): ConventionalCommitParts | null {
const normalized = (message || '').replace(/\r\n/g, '\n').trim();
if (!normalized) return null;

const firstNewline = normalized.indexOf('\n');
const header = firstNewline === -1 ? normalized : normalized.slice(0, firstNewline);
const rest = firstNewline === -1 ? '' : normalized.slice(firstNewline + 1).trim();

const match = header.match(/^([a-zA-Z0-9_-]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
if (!match) return null;

const hasBreakingFooter = /BREAKING CHANGE:/i.test(rest);
return {
type: match[1].toLowerCase(),
scope: match[2]?.trim() || undefined,
breaking: Boolean(match[3]) || hasBreakingFooter,
subject: match[4].trim(),
body: rest || undefined,
};
}

/**
* Strips `#` comment lines from a `.gitmessage` template while collapsing extra blank lines.
*/
export function stripGitTemplateComments(rawTemplate: string): string {
const lines = rawTemplate
.replace(/\r\n/g, '\n')
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'));

return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}

/**
* Reads a repository's commit template from `.gitmessage`, `.git/.gitmessage`, or `.github/COMMIT_TEMPLATE`.
*/
export function readGitMessageTemplate(repoRoot: string): string | null {
if (!repoRoot) return null;

const candidatePaths = [
path.join(repoRoot, '.gitmessage'),
path.join(repoRoot, '.gitmessage.txt'),
path.join(repoRoot, '.git', '.gitmessage'),
path.join(repoRoot, '.github', 'COMMIT_TEMPLATE'),
];

for (const candidate of candidatePaths) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
const raw = fs.readFileSync(candidate, 'utf8');
const cleaned = stripGitTemplateComments(raw);
if (cleaned.length > 0) {
return cleaned;
}
}
} catch {
// Ignore unreadable template files and continue fallback chain
}
}

return null;
}

/**
* Filters repository issues for `#issue` inline autocomplete in the commit builder.
*/
export function filterIssueSuggestions(
issues: IssueSuggestionItem[],
query: string,
limit = 8
): IssueSuggestionItem[] {
const normalizedQuery = (query || '').replace(/^#/, '').trim().toLowerCase();
const safeLimit = Math.max(1, Math.min(25, limit));

const sorted = [...issues].sort((a, b) => {
const aClosed = a.state === 'closed' ? 1 : 0;
const bClosed = b.state === 'closed' ? 1 : 0;
if (aClosed !== bClosed) return aClosed - bClosed;
return b.number - a.number;
});

if (!normalizedQuery) {
return sorted.slice(0, safeLimit);
}

return sorted
.filter(
(issue) =>
String(issue.number).includes(normalizedQuery) ||
(issue.title || '').toLowerCase().includes(normalizedQuery)
)
.slice(0, safeLimit);
}
140 changes: 140 additions & 0 deletions src/git/gitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
RebaseSimulationResult,
RemoteRepoInfo,
RepoHealthMetrics,
ConflictedFileInfo,
RepositoryOption,
RepoStats,
UserPersonalStats,
Expand Down Expand Up @@ -1171,6 +1172,19 @@ export class GitClient {
const userIdentity = await this.getUserIdentity(cwd);
const pullRequests: PullRequestInfo[] = [];

// 0. Prefer native RemoteProvider REST integration (GitHub Enterprise / GitLab / Forgejo) when available
if (remoteInfo) {
try {
const provider = RemoteManager.getInstance().getProvider(remoteInfo.provider);
if (provider?.getPullRequests) {
const providerPrs = await provider.getPullRequests(remoteInfo, 'all');
if (providerPrs.length > 0) {
return providerPrs;
}
}
} catch {}
}

// 1. Try GitHub CLI (`gh pr list`) if remote is GitHub
if (remoteInfo?.provider === 'github') {
try {
Expand Down Expand Up @@ -2377,4 +2391,130 @@ export class GitClient {
public async skipRebase(cwd: string): Promise<string> {
return await this.execute(['rebase', '--skip'], cwd);
}

/**
* Aborts an in-flight merge operation
*/
public async abortMerge(cwd: string): Promise<string> {
return await this.execute(['merge', '--abort'], cwd);
}

/**
* Returns unmerged conflicted files with conflict hunk counts and header preview lines.
*/
public async getConflictedFiles(cwd: string): Promise<ConflictedFileInfo[]> {
let raw = '';
try {
raw = await this.execute(['diff', '--name-only', '--diff-filter=U'], cwd);
} catch {
return [];
}

const relPaths = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);

const rootResolved = path.resolve(cwd);
const results: ConflictedFileInfo[] = [];

for (const relPath of relPaths) {
const absPath = path.resolve(cwd, relPath);
if (absPath !== rootResolved && !absPath.startsWith(rootResolved + path.sep)) {
continue;
}

let hunksCount = 0;
let oursLabel: string | undefined;
let theirsLabel: string | undefined;
const previewLines: string[] = [];

try {
if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) {
const content = fs.readFileSync(absPath, 'utf8');
const lines = content.split(/\r?\n/);
for (const line of lines) {
if (line.startsWith('<<<<<<<')) {
hunksCount++;
if (!oursLabel) oursLabel = line.slice(7).trim() || 'HEAD';
} else if (line.startsWith('>>>>>>>') && !theirsLabel) {
theirsLabel = line.slice(7).trim() || 'incoming';
}
if (previewLines.length < 12) {
previewLines.push(line);
}
}
}
} catch {
// Binary or unreadable conflicted file; keep metadata entry
}

results.push({
filePath: relPath.replace(/\\/g, '/'),
hunksCount,
oursLabel,
theirsLabel,
previewLines,
});
}

return results;
}

/**
* Resolves a conflicted file using 'ours', 'theirs', or staging manually edited content.
*/
public async resolveConflictFile(
cwd: string,
filePath: string,
strategy: 'ours' | 'theirs' | 'mark-resolved'
): Promise<void> {
const cleanPath = (filePath || '').trim();
if (!cleanPath) {
throw new Error('File path is required to resolve conflict.');
}

const rootResolved = path.resolve(cwd);
const absPath = path.resolve(cwd, cleanPath);
if (absPath !== rootResolved && !absPath.startsWith(rootResolved + path.sep)) {
throw new Error('Invalid conflict path outside repository root.');
}

if (strategy === 'ours') {
await this.execute(['checkout', '--ours', '--', cleanPath], cwd);
} else if (strategy === 'theirs') {
await this.execute(['checkout', '--theirs', '--', cleanPath], cwd);
}

await this.execute(['add', '--', cleanPath], cwd);
}

/**
* Commits staged changes (optionally staging all tracked/untracked changes when index is empty).
*/
public async commitStagedChanges(
cwd: string,
message: string,
stageAllIfEmpty = false
): Promise<{ hash: string; subject: string }> {
const cleanMessage = (message || '').trim();
if (!cleanMessage) {
throw new Error('Commit message cannot be empty.');
}

let staged = (await this.execute(['diff', '--cached', '--name-only'], cwd)).trim();
if (!staged && stageAllIfEmpty) {
await this.execute(['add', '-A'], cwd);
staged = (await this.execute(['diff', '--cached', '--name-only'], cwd)).trim();
}

if (!staged) {
throw new Error('No staged changes found. Stage files or enable Stage All Changes.');
}

await this.execute(['commit', '-m', cleanMessage], cwd);
const hash = (await this.execute(['rev-parse', 'HEAD'], cwd)).trim();
const subject = cleanMessage.split(/\r?\n/)[0].trim();
return { hash, subject };
}
}
26 changes: 25 additions & 1 deletion src/git/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,17 @@ export interface WebviewToExtensionMessage {
| 'applyDagFilters'
| 'dndResetBranchToCommit'
| 'dndCherryPickToBranch'
| 'dndRebaseOntoCommit';
| 'dndRebaseOntoCommit'
// Conflict Resolution Studio, Conventional Commits & PR Actions:
| 'requestConflictedFiles'
| 'resolveConflictFile'
| 'openMergeEditor'
| 'abortMerge'
| 'requestCommitTemplate'
| 'commitStagedChanges'
| 'requestPrComments'
| 'addPrComment'
| 'approvePullRequest';
payload?: any;
}

Expand Down Expand Up @@ -395,6 +405,12 @@ export interface ExtensionToWebviewMessage {
| 'aiKeysData'
| 'aiKeyGenerated'
| 'aiKeyRevoked'
| 'conflictedFilesData'
| 'commitTemplateData'
| 'commitCreated'
| 'prCommentsData'
| 'prCommentAdded'
| 'prApproved'
| 'error'
| 'loading';
payload?: any;
Expand Down Expand Up @@ -496,3 +512,11 @@ export interface RebaseSimulationResult {
dropCount: number;
}

export interface ConflictedFileInfo {
filePath: string;
hunksCount: number;
oursLabel?: string;
theirsLabel?: string;
previewLines?: string[];
}

Loading
Loading