/**
 * Git service for Studiograph
 *
 * Handles Git operations with transaction-based commits,
 * proper user attribution, and security.
 */
import { execFileSync } from 'child_process';
import { existsSync, statSync, writeFileSync } from 'fs';
import { join } from 'path';
export class GitService {
    repoPath;
    constructor(repoPath) {
        this.repoPath = repoPath;
        this.ensureValidPath();
        this.initializeIfNeeded();
    }
    /**
     * Ensure the repo path exists and is a directory
     */
    ensureValidPath() {
        if (!existsSync(this.repoPath)) {
            throw new Error(`Repository path does not exist: ${this.repoPath}`);
        }
        const stats = statSync(this.repoPath);
        if (!stats.isDirectory()) {
            throw new Error(`Repository path is not a directory: ${this.repoPath}`);
        }
    }
    /**
     * Initialize Git repository if not already initialized
     */
    initializeIfNeeded() {
        const gitDir = join(this.repoPath, '.git');
        if (!existsSync(gitDir)) {
            try {
                execFileSync('git', ['init'], { cwd: this.repoPath, stdio: 'pipe' });
                // Set default branch to main
                execFileSync('git', ['branch', '-M', 'main'], { cwd: this.repoPath, stdio: 'pipe' });
                // Create initial .gitignore if it doesn't exist
                const gitignorePath = join(this.repoPath, '.gitignore');
                if (!existsSync(gitignorePath)) {
                    writeFileSync(gitignorePath, '.DS_Store\n*.tmp\n*.log\n', 'utf-8');
                }
            }
            catch (error) {
                throw new Error(`Failed to initialize Git repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
            }
        }
    }
    /**
     * Check if repository has any commits
     */
    hasCommits() {
        try {
            execFileSync('git', ['rev-parse', 'HEAD'], { cwd: this.repoPath, stdio: 'pipe' });
            return true;
        }
        catch {
            return false;
        }
    }
    /**
     * Return the current HEAD commit hash, or null if the repo has no commits.
     * Used by callers (MCP update_entity) that want to surface the post-write
     * commit hash so external agents can verify a write landed in git.
     */
    getHeadCommit() {
        try {
            return execFileSync('git', ['rev-parse', 'HEAD'], {
                cwd: this.repoPath,
                encoding: 'utf-8',
            }).trim() || null;
        }
        catch {
            return null;
        }
    }
    /**
     * Get current branch name
     */
    getCurrentBranch() {
        try {
            return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
                cwd: this.repoPath,
                encoding: 'utf-8',
            }).trim();
        }
        catch (error) {
            throw new Error(`Failed to get current branch: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Create a transaction for batched commits
     */
    createTransaction() {
        return new GitTransaction(this.repoPath);
    }
    /**
     * Get commit history
     */
    getHistory(limit = 10) {
        if (!this.hasCommits()) {
            return [];
        }
        try {
            const output = execFileSync('git', ['log', '--pretty=format:%H|%an|%ae|%at|%s', '-n', String(limit)], { cwd: this.repoPath, encoding: 'utf-8' });
            if (!output) {
                return [];
            }
            return output.split('\n').map(line => {
                const [hash, name, email, timestamp, message] = line.split('|');
                return {
                    hash,
                    author: { name, email },
                    timestamp: parseInt(timestamp) * 1000, // Convert to milliseconds
                    message
                };
            });
        }
        catch (error) {
            throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Get file history for a specific file
     */
    getFileHistory(filePath, limit = 10) {
        if (!this.hasCommits()) {
            return [];
        }
        try {
            const output = execFileSync('git', ['log', '--pretty=format:%H|%an|%ae|%at|%s', '-n', String(limit), '--', filePath], { cwd: this.repoPath, encoding: 'utf-8' });
            if (!output) {
                return [];
            }
            return output.split('\n').map(line => {
                const [hash, name, email, timestamp, message] = line.split('|');
                return {
                    hash,
                    author: { name, email },
                    timestamp: parseInt(timestamp) * 1000,
                    message
                };
            });
        }
        catch (error) {
            throw new Error(`Failed to get file history: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Get file content at a specific commit
     */
    getFileAtCommit(filePath, commitHash) {
        try {
            return execFileSync('git', ['show', `${commitHash}:${filePath}`], { cwd: this.repoPath, encoding: 'utf-8' });
        }
        catch {
            return null;
        }
    }
    /**
     * List every file path tracked in `commitHash`, repo-relative POSIX.
     * Returns an empty array on error (unknown hash, malformed history).
     * Used by restore flows that need to find a deleted entity's file
     * inside its last-known commit without knowing the path up-front.
     */
    listFilesAtCommit(commitHash) {
        try {
            const output = execFileSync('git', ['ls-tree', '-r', '--name-only', commitHash], { cwd: this.repoPath, encoding: 'utf-8' });
            return output.split('\n').filter(Boolean);
        }
        catch {
            return [];
        }
    }
    /**
     * Unified diff between two commits for a single file. `toCommitHash` may
     * be 'HEAD' or any rev-spec git accepts. All three params flow through
     * passed as argv entries so shell metacharacters in paths or revs are never
     * interpreted by a shell. Returns the raw diff (or empty string on failure).
     */
    getDiff(fromCommitHash, toCommitHash, filePath) {
        try {
            return execFileSync('git', ['diff', fromCommitHash, toCommitHash, '--', filePath], { cwd: this.repoPath, encoding: 'utf-8' });
        }
        catch (error) {
            throw new Error(`git diff failed: ${error instanceof Error ? error.message : 'unknown'}`);
        }
    }
    /**
     * Recent file deletions from the commit log, scoped to a time window.
     *
     * Uses `git log --diff-filter=D --name-only` to find every commit that
     * deleted at least one file, then walks the output and pairs each
     * deleted path with the commit that removed it. The returned list is
     * deduped on `filePath` so a file deleted-and-recreated-and-deleted
     * surfaces only its most recent removal — that's the one a "restore"
     * action is going to target.
     *
     * `withinDays` clamps to a 1-90 day window. The default of 7 covers
     * the most common "I deleted that yesterday" workflows without
     * dragging in months of forgotten removals.
     */
    listRecentDeletions(withinDays = 7) {
        if (!this.hasCommits())
            return [];
        const days = Math.min(90, Math.max(1, withinDays));
        try {
            // %x00 = NUL byte separator between fields (safe — paths and
            // messages can contain anything else). Pretty-printing in two
            // lines per commit (header line, then NUL-joined paths) keeps the
            // parse straightforward.
            const output = execFileSync('git', ['log', '--diff-filter=D', '--name-only', `--since=${days} days ago`, '--pretty=format:COMMIT%x00%H%x00%an%x00%ae%x00%at%x00%s'], { cwd: this.repoPath, encoding: 'utf-8' });
            if (!output)
                return [];
            const seen = new Set();
            const out = [];
            let current = null;
            for (const rawLine of output.split('\n')) {
                const line = rawLine.trim();
                if (!line)
                    continue;
                if (line.startsWith('COMMIT\x00')) {
                    const [, hash, name, email, ts, ...msgParts] = line.split('\x00');
                    current = {
                        hash,
                        name,
                        email,
                        ts: parseInt(ts, 10) * 1000,
                        msg: msgParts.join('\x00'),
                    };
                    continue;
                }
                if (!current)
                    continue;
                if (seen.has(line))
                    continue;
                seen.add(line);
                // The commit BEFORE this one (`{hash}~1`) is the last version
                // that still had the file — that's the source a restore will
                // pull from. May not exist for repo-initial deletions; the
                // restore tool will surface that as a 404.
                out.push({
                    filePath: line,
                    deletedAtCommit: current.hash,
                    deletedAt: current.ts,
                    deletedBy: { name: current.name, email: current.email },
                    lastVersionCommit: `${current.hash}~1`,
                    commitMessage: current.msg,
                });
            }
            return out;
        }
        catch (error) {
            throw new Error(`Failed to list recent deletions: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Get current status (modified, added, deleted files)
     */
    getStatus() {
        try {
            const output = execFileSync('git', ['status', '--porcelain'], {
                cwd: this.repoPath,
                encoding: 'utf-8',
            });
            const modified = [];
            const added = [];
            const deleted = [];
            const untracked = [];
            output.split('\n').forEach(line => {
                if (!line)
                    return;
                const status = line.substring(0, 2);
                const file = line.substring(3);
                if (status === ' M' || status === 'M ') {
                    modified.push(file);
                }
                else if (status === 'A ') {
                    added.push(file);
                }
                else if (status === ' D' || status === 'D ') {
                    deleted.push(file);
                }
                else if (status === '??') {
                    untracked.push(file);
                }
            });
            return { modified, added, deleted, untracked };
        }
        catch (error) {
            throw new Error(`Failed to get status: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Check if there are uncommitted changes
     */
    hasUncommittedChanges() {
        const status = this.getStatus();
        return (status.modified.length > 0 ||
            status.added.length > 0 ||
            status.deleted.length > 0 ||
            status.untracked.length > 0);
    }
    /**
     * Fetch from the remote to update tracking refs.
     */
    fetch() {
        try {
            execFileSync('git', ['fetch'], { cwd: this.repoPath, stdio: 'pipe', timeout: 10000 });
        }
        catch {
            // No remote or network issue — silently ignore
        }
    }
    /**
     * Get ahead/behind counts relative to the tracking remote branch.
     * Returns { ahead: 0, behind: 0 } if no remote is configured.
     */
    getAheadBehind() {
        try {
            const output = execFileSync('git', ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { cwd: this.repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
            const [ahead, behind] = output.split(/\s+/).map(Number);
            return { ahead: ahead || 0, behind: behind || 0 };
        }
        catch {
            return { ahead: 0, behind: 0 };
        }
    }
    /**
     * Revert all uncommitted changes (staged + unstaged + untracked).
     */
    revertAll() {
        try {
            execFileSync('git', ['checkout', '--', '.'], { cwd: this.repoPath, stdio: 'pipe' });
            execFileSync('git', ['clean', '-fd'], { cwd: this.repoPath, stdio: 'pipe' });
        }
        catch (error) {
            throw new Error(`Failed to revert changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Stage all and commit with user attribution.
     */
    commitAll(message, user) {
        execFileSync('git', ['add', '-A'], { cwd: this.repoPath, stdio: 'pipe' });
        // Check if there are actual changes staged
        try {
            execFileSync('git', ['diff', '--staged', '--quiet'], { cwd: this.repoPath, stdio: 'pipe' });
            return; // nothing to commit
        }
        catch {
            // changes exist
        }
        const authorString = `${user.name} <${user.email}>`;
        execFileSync('git', ['commit', '-m', message, `--author=${authorString}`], {
            cwd: this.repoPath,
            stdio: 'pipe',
            env: {
                ...process.env,
                GIT_COMMITTER_NAME: user.name,
                GIT_COMMITTER_EMAIL: user.email,
            },
        });
    }
    /**
     * Stage specific files and commit with user attribution.
     * Unlike commitAll, this only stages the given files — unrelated
     * untracked files (e.g. sync-created entities) are left untouched.
     *
     * Uses `git add -A <pathspec>` so deletions, modifications, and
     * additions under the listed paths are all staged. The path scoping
     * still prevents pulling in unrelated untracked files outside the
     * listed paths. Throws on git failure with stderr in the error.
     */
    commitFiles(filePaths, message, user) {
        if (filePaths.length === 0)
            return;
        try {
            execFileSync('git', ['add', '-A', ...filePaths], { cwd: this.repoPath, stdio: 'pipe' });
        }
        catch (err) {
            throw enrichGitError(err, 'git add', this.repoPath);
        }
        // Check if there are actual changes staged
        try {
            execFileSync('git', ['diff', '--staged', '--quiet'], { cwd: this.repoPath, stdio: 'pipe' });
            return; // nothing to commit
        }
        catch {
            // changes exist
        }
        const authorString = `${user.name} <${user.email}>`;
        try {
            execFileSync('git', ['commit', '-m', message, `--author=${authorString}`], {
                cwd: this.repoPath,
                stdio: 'pipe',
                env: {
                    ...process.env,
                    GIT_COMMITTER_NAME: user.name,
                    GIT_COMMITTER_EMAIL: user.email,
                },
            });
        }
        catch (err) {
            throw enrichGitError(err, 'git commit', this.repoPath);
        }
    }
    /**
     * Push to remote
     */
    push() {
        execFileSync('git', ['push'], { cwd: this.repoPath, stdio: 'pipe' });
    }
    /**
     * Pull from remote
     */
    pull() {
        execFileSync('git', ['pull'], { cwd: this.repoPath, stdio: 'pipe' });
    }
    /**
     * Repo-relative POSIX paths of files that differ between two commits
     * (`git diff --name-only <from> <to>`). Both revs flow through argv so
     * shell metacharacters are never interpreted. Returns `[]` on failure or
     * when nothing changed. Used by `graph.pullAndReconcile` to find the .md
     * files a pull rewrote so any live editor Y.Doc can be reconciled.
     */
    diffNameOnly(fromCommitHash, toCommitHash) {
        try {
            const output = execFileSync('git', ['diff', '--name-only', fromCommitHash, toCommitHash], { cwd: this.repoPath, encoding: 'utf-8' });
            return output.split('\n').filter(Boolean);
        }
        catch {
            return [];
        }
    }
    /**
     * Get status as array of { path, status } entries
     */
    status() {
        try {
            const output = execFileSync('git', ['status', '--porcelain'], {
                cwd: this.repoPath,
                encoding: 'utf-8',
            });
            return output.split('\n').filter(Boolean).map(line => ({
                path: line.substring(3),
                status: line.substring(0, 2).trim() || 'modified',
            }));
        }
        catch {
            return [];
        }
    }
}
/**
 * Transaction class for batched Git commits
 */
export class GitTransaction {
    repoPath;
    changes = [];
    constructor(repoPath) {
        this.repoPath = repoPath;
    }
    /**
     * Add a change to this transaction
     */
    add(change) {
        this.changes.push(change);
    }
    /**
     * Get all changes in this transaction
     */
    getChanges() {
        return [...this.changes];
    }
    /**
     * Commit all changes in this transaction
     */
    async commit(user, message) {
        if (this.changes.length === 0) {
            throw new Error('No changes to commit');
        }
        // Generate message if not provided
        const commitMessage = message || this.generateMessage();
        // Stage only the files in this transaction (not unrelated untracked files)
        try {
            execFileSync('git', ['add', ...this.changes.map(c => c.filePath)], { cwd: this.repoPath, stdio: 'pipe' });
        }
        catch (error) {
            throw new Error(`Failed to stage changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
        // Check if there are actual changes staged
        try {
            execFileSync('git', ['diff', '--staged', '--quiet'], { cwd: this.repoPath, stdio: 'pipe' });
            // No changes staged — silently clear the transaction
            this.changes = [];
            return;
        }
        catch {
            // Changes exist, proceed with commit
        }
        // Commit with proper attribution using argv entries, not shell quoting.
        const authorString = `${user.name} <${user.email}>`;
        try {
            execFileSync('git', ['commit', '-m', commitMessage, `--author=${authorString}`], {
                cwd: this.repoPath,
                stdio: 'pipe',
                env: {
                    ...process.env,
                    GIT_COMMITTER_NAME: user.name,
                    GIT_COMMITTER_EMAIL: user.email,
                },
            });
        }
        catch (error) {
            throw new Error(`Failed to commit changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
        // Clear changes after successful commit
        this.changes = [];
    }
    /**
     * Generate commit message from changes
     */
    generateMessage() {
        const creates = this.changes.filter(c => c.operation === 'create');
        const updates = this.changes.filter(c => c.operation === 'update');
        const deletes = this.changes.filter(c => c.operation === 'delete');
        const parts = [];
        if (creates.length > 0) {
            const entityTypes = [...new Set(creates.map(c => c.entityType))];
            if (entityTypes.length === 1 && creates.length === 1) {
                parts.push(`Create ${creates[0].entityType}: ${creates[0].entityId}`);
            }
            else {
                parts.push(`Create ${creates.length} ${creates.length === 1 ? 'entity' : 'entities'}`);
            }
        }
        if (updates.length > 0) {
            const entityTypes = [...new Set(updates.map(c => c.entityType))];
            if (entityTypes.length === 1 && updates.length === 1) {
                parts.push(`Update ${updates[0].entityType}: ${updates[0].entityId}`);
            }
            else {
                parts.push(`Update ${updates.length} ${updates.length === 1 ? 'entity' : 'entities'}`);
            }
        }
        if (deletes.length > 0) {
            const entityTypes = [...new Set(deletes.map(c => c.entityType))];
            if (entityTypes.length === 1 && deletes.length === 1) {
                parts.push(`Delete ${deletes[0].entityType}: ${deletes[0].entityId}`);
            }
            else {
                parts.push(`Delete ${deletes.length} ${deletes.length === 1 ? 'entity' : 'entities'}`);
            }
        }
        return parts.join(', ');
    }
    /**
     * Rollback all changes (git reset)
     */
    async rollback() {
        try {
            execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: this.repoPath, stdio: 'pipe' });
            execFileSync('git', ['clean', '-fd'], { cwd: this.repoPath, stdio: 'pipe' });
            this.changes = [];
        }
        catch (error) {
            throw new Error(`Failed to rollback changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
}
/**
 * Wrap a git process error with stderr surfaced into the message, so
 * callers and HTTP responses see why git rejected the operation
 * instead of a generic "Command failed" string.
 */
function enrichGitError(err, operation, repoPath) {
    const e = err;
    const stderr = e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '';
    const trimmed = stderr.trim();
    const exitCode = typeof e.status === 'number' ? ` (exit ${e.status})` : '';
    const detail = trimmed || e.message || 'unknown error';
    return new Error(`${operation} failed in ${repoPath}${exitCode}: ${detail}`);
}
//# sourceMappingURL=git.js.map