/** * GitService interface and Bun implementation * Abstracts git operations for testability */ import { $ } from "bun"; import type { GitStatus, LocalStatus, RemoteStatus, OperationResult, } from "../types/index.ts"; import type { SubmoduleEntry } from "./types.ts"; import { errorToString } from "../utils/errors.ts"; import { withTimeout, TimeoutError } from "../utils/timeout.ts"; import { GIT_TIMEOUTS } from "../constants.ts"; /** * Combine a LocalStatus and an optional RemoteStatus into the flat GitStatus * shape that consumers across the codebase read by field. When `remote` is * null (no remote configured, or tracking ref not yet fetched), remote fields * are zero-filled. See ADR-0005. */ export function mergeStatus(local: LocalStatus, remote: RemoteStatus | null): GitStatus { return { ...local, unpushedCommits: remote?.unpushedCommits ?? 0, unpulledCommits: remote?.unpulledCommits ?? 0, lastRemoteActivity: remote?.lastRemoteActivity ?? null, isAhead: remote?.isAhead ?? false, isBehind: remote?.isBehind ?? false, isOutOfSync: remote?.isOutOfSync ?? false, }; } // ============================================================================ // GitService Interface // ============================================================================ export interface GitService { // Repository info isGitRepo(path: string): Promise; getGitRoot(path: string): Promise; isSubmodule(path: string): Promise; getSubmoduleParent(path: string): Promise; // Status — split by freshness contract per ADR-0005. // Local fields change with the working tree; remote fields change only on fetch. getLocalStatus(path: string): Promise; getRemoteStatus(path: string): Promise; getStatusPorcelain(path: string): Promise; getCurrentBranch(path: string): Promise; getTrackingBranch(path: string): Promise; countUnpushedCommits(path: string): Promise; countUnpulledCommits(path: string): Promise; getRemoteUrl(path: string, remote?: string): Promise; getLastCommitDate(path: string): Promise; getRemoteLastCommitDate(path: string, remoteBranch?: string): Promise; listSubmodules(path: string): Promise; // Operations init(path: string): Promise; pull(path: string): Promise; push(path: string, setUpstream?: boolean): Promise; fetch(path: string): Promise; fetchAll(path: string): Promise; addRemote(path: string, url: string, name?: string): Promise; clone(url: string, targetDir: string): Promise; } // ============================================================================ // Bun Implementation // ============================================================================ export const bunGitService: GitService = { // Repository info async isGitRepo(path: string): Promise { try { await $`git -C ${path} rev-parse --is-inside-work-tree`.quiet(); return true; } catch { return false; } }, async getGitRoot(path: string): Promise { try { const result = await $`git -C ${path} rev-parse --show-toplevel`.quiet().text(); return result.trim() || null; } catch { return null; } }, async isSubmodule(path: string): Promise { try { const result = await $`git -C ${path} rev-parse --show-superproject-working-tree`.quiet().text(); return result.trim().length > 0; } catch { return false; } }, async getSubmoduleParent(path: string): Promise { try { const result = await $`git -C ${path} rev-parse --show-superproject-working-tree`.quiet().text(); return result.trim() || null; } catch { return null; } }, // Status async getLocalStatus(path: string): Promise { const statusOutput = await this.getStatusPorcelain(path); const lines = statusOutput.split("\n").filter(Boolean); let modifiedCount = 0; let stagedCount = 0; let untrackedCount = 0; for (const line of lines) { const indexStatus = line[0]; const workingStatus = line[1]; if (indexStatus === "?" && workingStatus === "?") { untrackedCount++; } else { if (indexStatus && indexStatus !== " " && indexStatus !== "?") { stagedCount++; } if (workingStatus && workingStatus !== " " && workingStatus !== "?") { modifiedCount++; } } } const currentBranch = await this.getCurrentBranch(path); const trackingBranch = await this.getTrackingBranch(path); const remoteUrl = await this.getRemoteUrl(path); const hasRemote = remoteUrl !== null; const lastLocalCommit = await this.getLastCommitDate(path); const hasCommits = lastLocalCommit !== null; const hasUnstagedChanges = modifiedCount > 0; const hasStagedChanges = stagedCount > 0; const hasUntrackedFiles = untrackedCount > 0; const isDirty = hasUnstagedChanges || hasStagedChanges || hasUntrackedFiles; return { hasUnstagedChanges, hasStagedChanges, hasUntrackedFiles, modifiedCount, stagedCount, untrackedCount, currentBranch, trackingBranch, hasRemote, remoteUrl, lastLocalCommit, hasCommits, isDirty, }; }, async getRemoteStatus(path: string): Promise { const trackingBranch = await this.getTrackingBranch(path); const remoteUrl = await this.getRemoteUrl(path); if (!trackingBranch || remoteUrl === null) { return null; } const unpushedCommits = await this.countUnpushedCommits(path); const unpulledCommits = await this.countUnpulledCommits(path); const lastRemoteActivity = await this.getRemoteLastCommitDate(path, trackingBranch); const isAhead = unpushedCommits > 0; const isBehind = unpulledCommits > 0; const isOutOfSync = isAhead || isBehind; return { unpushedCommits, unpulledCommits, lastRemoteActivity, isAhead, isBehind, isOutOfSync, }; }, async getStatusPorcelain(path: string): Promise { try { return await withTimeout( $`git -C ${path} status --porcelain`.quiet().text(), GIT_TIMEOUTS.STATUS, `git status --porcelain for ${path}` ); } catch (error) { if (error instanceof TimeoutError) { console.error(`Timeout getting git status for ${path}: ${error.message}`); } return ""; } }, async getCurrentBranch(path: string): Promise { try { const result = await $`git -C ${path} rev-parse --abbrev-ref HEAD`.quiet().text(); return result.trim() || "unknown"; } catch { return "unknown"; } }, async getTrackingBranch(path: string): Promise { try { const result = await $`git -C ${path} rev-parse --abbrev-ref @{u}`.quiet().text(); return result.trim() || null; } catch { return null; } }, async countUnpushedCommits(path: string): Promise { try { const result = await $`git -C ${path} rev-list --count @{u}..HEAD`.quiet().text(); return parseInt(result.trim(), 10) || 0; } catch { return 0; } }, async countUnpulledCommits(path: string): Promise { try { const result = await $`git -C ${path} rev-list --count HEAD..@{u}`.quiet().text(); return parseInt(result.trim(), 10) || 0; } catch { return 0; } }, async getRemoteUrl(path: string, remote = "origin"): Promise { try { const result = await $`git -C ${path} config --get remote.${remote}.url`.quiet().text(); return result.trim() || null; } catch { return null; } }, async getLastCommitDate(path: string): Promise { try { const result = await $`git -C ${path} log -1 --format=%ai`.quiet().text(); const dateStr = result.trim(); if (!dateStr) return null; return new Date(dateStr); } catch { return null; } }, async getRemoteLastCommitDate(path: string, remoteBranch = "origin/main"): Promise { try { const result = await $`git -C ${path} log -1 --format=%ai ${remoteBranch}`.quiet().text(); const dateStr = result.trim(); if (!dateStr) return null; return new Date(dateStr); } catch { return null; } }, async listSubmodules(path: string): Promise { try { const result = await $`git -C ${path} submodule status`.quiet().text(); const lines = result.trim().split("\n").filter(Boolean); return lines.map((line) => { const statusChar = line[0] as "-" | "+" | " " | "U"; const rest = line.slice(1).trim(); const parts = rest.split(" "); const commit = parts[0] || ""; const subPath = parts[1] || ""; return { path: subPath, commit: commit, status: statusChar, }; }); } catch { return []; } }, // Operations async init(path: string): Promise { const start = Date.now(); try { await $`git -C ${path} init`.quiet(); return { success: true, projectPath: path, operation: "init", message: "Git repository initialized", duration: Date.now() - start, }; } catch (error) { return { success: false, projectPath: path, operation: "init", error: errorToString(error), duration: Date.now() - start, }; } }, async pull(path: string): Promise { const start = Date.now(); try { await withTimeout( $`git -C ${path} pull --ff-only`.quiet(), GIT_TIMEOUTS.PULL, `git pull for ${path}` ); return { success: true, projectPath: path, operation: "pull", message: "Pull successful", duration: Date.now() - start, }; } catch (error) { let errorMessage = errorToString(error); if (error instanceof TimeoutError) { errorMessage = `Pull operation timed out after ${GIT_TIMEOUTS.PULL / 1000} seconds`; } return { success: false, projectPath: path, operation: "pull", error: errorMessage, duration: Date.now() - start, }; } }, async push(path: string, setUpstream = false): Promise { const start = Date.now(); try { if (setUpstream) { const branch = await this.getCurrentBranch(path); await withTimeout( $`git -C ${path} push -u origin ${branch}`.quiet(), GIT_TIMEOUTS.PUSH, `git push -u origin ${branch} for ${path}` ); } else { await withTimeout( $`git -C ${path} push`.quiet(), GIT_TIMEOUTS.PUSH, `git push for ${path}` ); } return { success: true, projectPath: path, operation: "push", message: "Push successful", duration: Date.now() - start, }; } catch (error) { let errorMessage = errorToString(error); if (error instanceof TimeoutError) { errorMessage = `Push operation timed out after ${GIT_TIMEOUTS.PUSH / 1000} seconds`; } return { success: false, projectPath: path, operation: "push", error: errorMessage, duration: Date.now() - start, }; } }, async fetch(path: string): Promise { const start = Date.now(); try { await withTimeout( $`git -C ${path} fetch origin`.quiet(), GIT_TIMEOUTS.FETCH, `git fetch origin for ${path}` ); return { success: true, projectPath: path, operation: "fetch", message: "Fetch successful", duration: Date.now() - start, }; } catch (error) { let errorMessage = errorToString(error); if (error instanceof TimeoutError) { errorMessage = `Fetch operation timed out after ${GIT_TIMEOUTS.FETCH / 1000} seconds`; } return { success: false, projectPath: path, operation: "fetch", error: errorMessage, duration: Date.now() - start, }; } }, async fetchAll(path: string): Promise { const start = Date.now(); try { await withTimeout( $`git -C ${path} fetch --all`.quiet(), GIT_TIMEOUTS.FETCH, `git fetch --all for ${path}` ); return { success: true, projectPath: path, operation: "fetch-all", message: "Fetch all successful", duration: Date.now() - start, }; } catch (error) { let errorMessage = errorToString(error); if (error instanceof TimeoutError) { errorMessage = `Fetch all operation timed out after ${GIT_TIMEOUTS.FETCH / 1000} seconds`; } return { success: false, projectPath: path, operation: "fetch-all", error: errorMessage, duration: Date.now() - start, }; } }, async addRemote(path: string, url: string, name = "origin"): Promise { const start = Date.now(); try { await $`git -C ${path} remote add ${name} ${url}`.quiet(); return { success: true, projectPath: path, operation: "add-remote", message: `Remote '${name}' added`, duration: Date.now() - start, }; } catch (error) { return { success: false, projectPath: path, operation: "add-remote", error: errorToString(error), duration: Date.now() - start, }; } }, async clone(url: string, targetDir: string): Promise { const start = Date.now(); try { await withTimeout( $`git clone ${url} ${targetDir}`.quiet(), GIT_TIMEOUTS.CLONE, `git clone ${url} to ${targetDir}` ); return { success: true, projectPath: targetDir, operation: "clone", message: "Clone successful", duration: Date.now() - start, }; } catch (error) { let errorMessage = errorToString(error); if (error instanceof TimeoutError) { errorMessage = `Clone operation timed out after ${GIT_TIMEOUTS.CLONE / 1000} seconds`; } return { success: false, projectPath: targetDir, operation: "clone", error: errorMessage, duration: Date.now() - start, }; } }, }; // ============================================================================ // Default Export // ============================================================================ export const defaultGitService = bunGitService;