/**
* Git Operations Module
*
* Provides tools for common Git operations within the AL workspace.
* Enables version control directly from AI agents.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { getLogger } from '../utils/logger.js';
const execAsync = promisify(exec);
export interface GitStatus {
branch: string;
ahead: number;
behind: number;
staged: FileChange[];
modified: FileChange[];
untracked: string[];
hasChanges: boolean;
}
export interface FileChange {
path: string;
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied';
oldPath?: string;
}
export interface GitCommit {
hash: string;
shortHash: string;
author: string;
email: string;
date: string;
message: string;
files?: string[];
}
export interface GitBranch {
name: string;
current: boolean;
remote?: string;
upstream?: string;
}
/**
* Git Operations Manager
*/
export class GitOperations {
private workspaceRoot: string;
private logger = getLogger();
constructor(workspaceRoot: string) {
this.workspaceRoot = workspaceRoot;
}
/**
* Execute a git command
*/
private async git(args: string): Promise<{ stdout: string; stderr: string }> {
return execAsync(`git ${args}`, {
cwd: this.workspaceRoot,
maxBuffer: 10 * 1024 * 1024,
});
}
/**
* Check if workspace is a git repository
*/
async isGitRepository(): Promise<boolean> {
try {
await this.git('rev-parse --git-dir');
return true;
} catch {
return false;
}
}
/**
* Get current git status
*/
async getStatus(): Promise<GitStatus> {
// Get branch info
let branch = '';
let ahead = 0;
let behind = 0;
try {
const { stdout: branchOut } = await this.git('branch --show-current');
branch = branchOut.trim();
// Get ahead/behind counts
try {
const { stdout: trackingOut } = await this.git(`rev-list --left-right --count ${branch}...@{upstream}`);
const [aheadStr, behindStr] = trackingOut.trim().split('\t');
ahead = parseInt(aheadStr) || 0;
behind = parseInt(behindStr) || 0;
} catch {
// No upstream configured
}
} catch {
// Detached HEAD or other issue
const { stdout: headOut } = await this.git('rev-parse --short HEAD');
branch = `(detached at ${headOut.trim()})`;
}
// Get status
const staged: FileChange[] = [];
const modified: FileChange[] = [];
const untracked: string[] = [];
try {
const { stdout: statusOut } = await this.git('status --porcelain=v2');
const lines = statusOut.trim().split('\n').filter(l => l);
for (const line of lines) {
if (line.startsWith('1 ')) {
// Ordinary changed entry
const parts = line.split(' ');
const xy = parts[1];
const filePath = parts.slice(8).join(' ');
if (xy[0] !== '.') {
// Staged change
staged.push({
path: filePath,
status: this.parseStatusCode(xy[0]),
});
}
if (xy[1] !== '.') {
// Unstaged change
modified.push({
path: filePath,
status: this.parseStatusCode(xy[1]),
});
}
} else if (line.startsWith('2 ')) {
// Renamed/copied entry
const parts = line.split(' ');
const xy = parts[1];
const pathParts = parts.slice(9).join(' ').split('\t');
const newPath = pathParts[0];
const oldPath = pathParts[1];
if (xy[0] !== '.') {
staged.push({
path: newPath,
oldPath,
status: xy[0] === 'R' ? 'renamed' : 'copied',
});
}
} else if (line.startsWith('? ')) {
// Untracked file
untracked.push(line.slice(2));
}
}
} catch (error) {
this.logger.error('Failed to get git status:', error);
}
return {
branch,
ahead,
behind,
staged,
modified,
untracked,
hasChanges: staged.length > 0 || modified.length > 0 || untracked.length > 0,
};
}
/**
* Parse status code to status string
*/
private parseStatusCode(code: string): FileChange['status'] {
switch (code) {
case 'A': return 'added';
case 'M': return 'modified';
case 'D': return 'deleted';
case 'R': return 'renamed';
case 'C': return 'copied';
default: return 'modified';
}
}
/**
* Get diff of changes
*/
async getDiff(options?: {
staged?: boolean;
file?: string;
unified?: number;
}): Promise<string> {
let args = 'diff';
if (options?.staged) {
args += ' --staged';
}
if (options?.unified !== undefined) {
args += ` -U${options.unified}`;
}
if (options?.file) {
args += ` -- "${options.file}"`;
}
const { stdout } = await this.git(args);
return stdout;
}
/**
* Stage files
*/
async stage(paths: string[] | 'all'): Promise<{ success: boolean; message: string }> {
try {
if (paths === 'all') {
await this.git('add -A');
} else {
const pathsStr = paths.map(p => `"${p}"`).join(' ');
await this.git(`add ${pathsStr}`);
}
return { success: true, message: 'Files staged successfully' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Unstage files
*/
async unstage(paths: string[] | 'all'): Promise<{ success: boolean; message: string }> {
try {
if (paths === 'all') {
await this.git('reset HEAD');
} else {
const pathsStr = paths.map(p => `"${p}"`).join(' ');
await this.git(`reset HEAD -- ${pathsStr}`);
}
return { success: true, message: 'Files unstaged successfully' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Commit staged changes
*/
async commit(message: string, options?: {
amend?: boolean;
allowEmpty?: boolean;
}): Promise<{ success: boolean; message: string; hash?: string }> {
try {
let args = `commit -m "${message.replace(/"/g, '\\"')}"`;
if (options?.amend) {
args += ' --amend';
}
if (options?.allowEmpty) {
args += ' --allow-empty';
}
const { stdout } = await this.git(args);
// Extract commit hash
const hashMatch = stdout.match(/\[[\w-]+ ([a-f0-9]+)\]/);
const hash = hashMatch ? hashMatch[1] : undefined;
return { success: true, message: 'Committed successfully', hash };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Get commit history
*/
async getLog(options?: {
limit?: number;
since?: string;
until?: string;
author?: string;
grep?: string;
file?: string;
}): Promise<GitCommit[]> {
const format = '--format=%H|%h|%an|%ae|%aI|%s';
let args = `log ${format}`;
if (options?.limit) {
args += ` -n ${options.limit}`;
}
if (options?.since) {
args += ` --since="${options.since}"`;
}
if (options?.until) {
args += ` --until="${options.until}"`;
}
if (options?.author) {
args += ` --author="${options.author}"`;
}
if (options?.grep) {
args += ` --grep="${options.grep}"`;
}
if (options?.file) {
args += ` -- "${options.file}"`;
}
try {
const { stdout } = await this.git(args);
const lines = stdout.trim().split('\n').filter(l => l);
return lines.map(line => {
const [hash, shortHash, author, email, date, message] = line.split('|');
return { hash, shortHash, author, email, date, message };
});
} catch {
return [];
}
}
/**
* List branches
*/
async listBranches(remote?: boolean): Promise<GitBranch[]> {
const branches: GitBranch[] = [];
try {
const { stdout } = await this.git(`branch ${remote ? '-r' : ''} -vv`);
const lines = stdout.trim().split('\n').filter(l => l);
for (const line of lines) {
const current = line.startsWith('*');
const parts = line.slice(2).trim().split(/\s+/);
const name = parts[0];
// Extract upstream from [origin/main] format
const upstreamMatch = line.match(/\[([^\]]+)\]/);
const upstream = upstreamMatch ? upstreamMatch[1].split(':')[0] : undefined;
branches.push({
name,
current,
remote: remote ? name.split('/')[0] : undefined,
upstream,
});
}
} catch (error) {
this.logger.error('Failed to list branches:', error);
}
return branches;
}
/**
* Create a new branch
*/
async createBranch(name: string, options?: {
checkout?: boolean;
from?: string;
}): Promise<{ success: boolean; message: string }> {
try {
let args = `branch "${name}"`;
if (options?.from) {
args += ` "${options.from}"`;
}
await this.git(args);
if (options?.checkout) {
await this.git(`checkout "${name}"`);
}
return { success: true, message: `Branch '${name}' created${options?.checkout ? ' and checked out' : ''}` };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Switch to a branch
*/
async checkout(branchOrPath: string, options?: {
create?: boolean;
}): Promise<{ success: boolean; message: string }> {
try {
let args = 'checkout';
if (options?.create) {
args += ' -b';
}
args += ` "${branchOrPath}"`;
await this.git(args);
return { success: true, message: `Switched to '${branchOrPath}'` };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Pull changes from remote
*/
async pull(options?: {
remote?: string;
branch?: string;
rebase?: boolean;
}): Promise<{ success: boolean; message: string }> {
try {
let args = 'pull';
if (options?.rebase) {
args += ' --rebase';
}
if (options?.remote) {
args += ` ${options.remote}`;
if (options?.branch) {
args += ` ${options.branch}`;
}
}
const { stdout, stderr } = await this.git(args);
return { success: true, message: (stdout + stderr).trim() || 'Pull successful' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Push changes to remote
*/
async push(options?: {
remote?: string;
branch?: string;
setUpstream?: boolean;
force?: boolean;
}): Promise<{ success: boolean; message: string }> {
try {
let args = 'push';
if (options?.setUpstream) {
args += ' -u';
}
if (options?.force) {
args += ' --force-with-lease';
}
if (options?.remote) {
args += ` ${options.remote}`;
if (options?.branch) {
args += ` ${options.branch}`;
}
}
const { stdout, stderr } = await this.git(args);
return { success: true, message: (stdout + stderr).trim() || 'Push successful' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Stash changes
*/
async stash(options?: {
message?: string;
includeUntracked?: boolean;
}): Promise<{ success: boolean; message: string }> {
try {
let args = 'stash push';
if (options?.includeUntracked) {
args += ' -u';
}
if (options?.message) {
args += ` -m "${options.message}"`;
}
await this.git(args);
return { success: true, message: 'Changes stashed' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Apply stash
*/
async stashPop(index?: number): Promise<{ success: boolean; message: string }> {
try {
const stashRef = index !== undefined ? `stash@{${index}}` : '';
await this.git(`stash pop ${stashRef}`);
return { success: true, message: 'Stash applied and removed' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* List stashes
*/
async stashList(): Promise<{ index: number; message: string; date: string }[]> {
try {
const { stdout } = await this.git('stash list --format=%gd|%s|%ai');
const lines = stdout.trim().split('\n').filter(l => l);
return lines.map(line => {
const [ref, message, date] = line.split('|');
const index = parseInt(ref.match(/\{(\d+)\}/)?.[1] || '0');
return { index, message, date };
});
} catch {
return [];
}
}
/**
* Discard changes in a file
*/
async discardChanges(path: string): Promise<{ success: boolean; message: string }> {
try {
if (path === 'all') {
await this.git('checkout -- .');
} else {
await this.git(`checkout -- "${path}"`);
}
return { success: true, message: 'Changes discarded' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
}