/** * Pure formatting functions for CLI output * These functions are side-effect free and return strings for display */ import chalk from "chalk"; import type { Project, UnifiedRepo, BatchResult, ViewMode } from "../types/index.ts"; // ============================================================================ // Types // ============================================================================ export interface FormatOptions { verbose?: boolean; json?: boolean; } export interface ProjectStats { total: number; gitCount: number; submoduleCount: number; nonGitCount: number; dirtyCount: number; unpushedCount: number; unpulledCount: number; } export interface StatusSummary { total: number; dirty: Project[]; unpushed: Project[]; unpulled: Project[]; noRemote: Project[]; nonGit: Project[]; } export interface UnifiedStats { total: number; both: number; localOnly: number; githubOnly: number; dirty: number; unpushed: number; unpulled: number; } // ============================================================================ // Project Formatting // ============================================================================ /** * Get the type icon for a project */ export function getProjectTypeIcon(type: Project["type"]): string { const icons = { git: chalk.green("●"), "git-submodule": chalk.magenta("○"), "non-git": chalk.gray("-"), }; return icons[type]; } /** * Format project status as colored string parts */ export function formatProjectStatus(project: Project): string { const statusParts: string[] = []; if (project.status) { if (project.status.isDirty) { statusParts.push(chalk.yellow(`${project.status.modifiedCount}M`)); } if (project.status.untrackedCount > 0) { statusParts.push(chalk.gray(`${project.status.untrackedCount}?`)); } if (project.status.isAhead) { statusParts.push(chalk.blue(`↑${project.status.unpushedCommits}`)); } if (project.status.isBehind) { statusParts.push(chalk.magenta(`↓${project.status.unpulledCommits}`)); } if (!project.status.hasRemote) { statusParts.push(chalk.gray("no-remote")); } } return statusParts.length > 0 ? statusParts.join(" ") : chalk.green("clean"); } /** * Format a single project for display */ export function formatProject(project: Project, verbose = false): string { const typeIcon = getProjectTypeIcon(project.type); const status = formatProjectStatus(project); if (verbose) { return [ `${typeIcon} ${chalk.bold(project.name)}`, ` Path: ${project.path}`, ` Status: ${status}`, ` Branch: ${project.status?.currentBranch ?? "N/A"}`, ].join("\n"); } return `${typeIcon} ${project.name.padEnd(30)} ${status}`; } /** * Calculate stats from a list of projects */ export function calculateProjectStats(projects: Project[]): ProjectStats { return { total: projects.length, gitCount: projects.filter((p) => p.type === "git").length, submoduleCount: projects.filter((p) => p.type === "git-submodule").length, nonGitCount: projects.filter((p) => p.type === "non-git").length, dirtyCount: projects.filter((p) => p.status?.isDirty).length, unpushedCount: projects.filter((p) => p.status?.isAhead).length, unpulledCount: projects.filter((p) => p.status?.isBehind).length, }; } /** * Format project stats as a summary line */ export function formatProjectStats(stats: ProjectStats): string { return [ `${stats.gitCount} git`, `${stats.submoduleCount} submodules`, `${stats.nonGitCount} non-git`, chalk.yellow(`${stats.dirtyCount} dirty`), chalk.blue(`${stats.unpushedCount} unpushed`), ].join(" | "); } /** * Format full project list output */ export function formatProjectList( projects: Project[], options: FormatOptions = {} ): string { if (options.json) { return JSON.stringify(projects, null, 2); } const lines: string[] = []; lines.push(chalk.cyan(`\nFound ${projects.length} projects:\n`)); for (const project of projects) { lines.push(formatProject(project, options.verbose)); } const stats = calculateProjectStats(projects); lines.push(chalk.gray("\n---")); lines.push(formatProjectStats(stats)); return lines.join("\n"); } // ============================================================================ // Status Summary Formatting // ============================================================================ /** * Calculate status summary from projects */ export function calculateStatusSummary(projects: Project[]): StatusSummary { return { total: projects.length, dirty: projects.filter((p) => p.status?.isDirty), unpushed: projects.filter((p) => p.status?.isAhead), unpulled: projects.filter((p) => p.status?.isBehind), noRemote: projects.filter((p) => p.type === "git" && !p.status?.hasRemote), nonGit: projects.filter((p) => p.type === "non-git"), }; } /** * Format status summary as JSON */ export function formatStatusSummaryJson(summary: StatusSummary): string { return JSON.stringify( { total: summary.total, dirty: summary.dirty.map((p) => p.name), unpushed: summary.unpushed.map((p) => p.name), unpulled: summary.unpulled.map((p) => p.name), noRemote: summary.noRemote.map((p) => p.name), nonGit: summary.nonGit.map((p) => p.name), }, null, 2 ); } /** * Format status summary for display */ export function formatStatusSummary(summary: StatusSummary): string { const lines: string[] = []; lines.push(chalk.cyan(`\n=== Status Summary (${summary.total} projects) ===\n`)); if (summary.dirty.length > 0) { lines.push(chalk.yellow(`Dirty (${summary.dirty.length}):`)); summary.dirty.forEach((p) => lines.push(` ${p.name}`)); lines.push(""); } if (summary.unpushed.length > 0) { lines.push(chalk.blue(`Unpushed (${summary.unpushed.length}):`)); summary.unpushed.forEach((p) => lines.push(` ${p.name} (↑${p.status?.unpushedCommits})`) ); lines.push(""); } if (summary.unpulled.length > 0) { lines.push(chalk.magenta(`Unpulled (${summary.unpulled.length}):`)); summary.unpulled.forEach((p) => lines.push(` ${p.name} (↓${p.status?.unpulledCommits})`) ); lines.push(""); } if (summary.noRemote.length > 0) { lines.push(chalk.gray(`No Remote (${summary.noRemote.length}):`)); summary.noRemote.forEach((p) => lines.push(` ${p.name}`)); lines.push(""); } if ( summary.dirty.length === 0 && summary.unpushed.length === 0 && summary.unpulled.length === 0 ) { lines.push(chalk.green("✓ All repositories are clean and in sync!")); } return lines.join("\n"); } // ============================================================================ // Batch Operation Result Formatting // ============================================================================ /** * Format batch operation result */ export function formatBatchResult( result: BatchResult, operationName: string ): string { const lines: string[] = []; lines.push( chalk.green(`✓ ${operationName} ${result.successful}/${result.total} repositories`) ); if (result.failed > 0) { lines.push(chalk.red(`✗ ${result.failed} failed:`)); result.results .filter((r) => !r.success) .forEach((r) => lines.push(` ${r.projectPath}: ${r.error}`)); } lines.push(chalk.gray(`Duration: ${result.duration}ms`)); return lines.join("\n"); } /** * Format progress indicator (for stdout.write) */ export function formatProgress(completed: number, total: number): string { return `\r Progress: ${completed}/${total}`; } // ============================================================================ // Dirty Repos Formatting // ============================================================================ /** * Format dirty repos list */ export function formatDirtyRepos(dirty: Project[], json = false): string { if (json) { return JSON.stringify(dirty, null, 2); } if (dirty.length === 0) { return chalk.green("\n✓ All repositories are clean!"); } const lines: string[] = []; lines.push(chalk.yellow(`\n${dirty.length} dirty repositories:\n`)); dirty.forEach((p) => { const changes: string[] = []; if (p.status?.modifiedCount) changes.push(`${p.status.modifiedCount} modified`); if (p.status?.stagedCount) changes.push(`${p.status.stagedCount} staged`); if (p.status?.untrackedCount) changes.push(`${p.status.untrackedCount} untracked`); lines.push(` ${chalk.bold(p.name)}: ${changes.join(", ")}`); }); return lines.join("\n"); } // ============================================================================ // Unified Repo Formatting // ============================================================================ /** * Get source icon for unified repo */ export function getSourceIcon(source: UnifiedRepo["source"]): string { const icons = { local: chalk.blue("L"), github: chalk.magenta("G"), both: chalk.green("✓"), }; return icons[source]; } /** * Format unified repo status */ export function formatUnifiedRepoStatus(repo: UnifiedRepo): string { const statusParts: string[] = []; if (repo.source === "github") { statusParts.push(chalk.yellow("not cloned")); } else if (repo.local?.status) { if (repo.local.status.isDirty) { statusParts.push(chalk.yellow(`${repo.local.status.modifiedCount}M`)); } if (repo.local.status.isAhead) { statusParts.push(chalk.blue(`↑${repo.local.status.unpushedCommits}`)); } if (repo.local.status.isBehind) { statusParts.push(chalk.magenta(`↓${repo.local.status.unpulledCommits}`)); } if (!repo.isOnGitHub) { statusParts.push(chalk.gray("local-only")); } } return statusParts.length > 0 ? statusParts.join(" ") : chalk.green("synced"); } /** * Format a unified repo for display */ export function formatUnifiedRepo(repo: UnifiedRepo, verbose = false): string { const sourceIcon = getSourceIcon(repo.source); const status = formatUnifiedRepoStatus(repo); const visibility = repo.github?.isPrivate ? chalk.gray("(private)") : ""; if (verbose) { const lines = [ `${sourceIcon} ${chalk.bold(repo.name)} ${visibility}`, repo.localPath ? ` Local: ${repo.localPath}` : null, repo.github ? ` GitHub: ${repo.github.fullName}` : null, repo.github?.description ? ` Desc: ${repo.github.description}` : null, ` Status: ${status}`, ].filter(Boolean); return lines.join("\n"); } return `${sourceIcon} ${repo.name.padEnd(35)} ${status} ${visibility}`; } /** * Format unified stats line */ export function formatUnifiedStats(stats: UnifiedStats): string { return [ chalk.green(`${stats.both} synced`), chalk.blue(`${stats.localOnly} local-only`), chalk.magenta(`${stats.githubOnly} github-only`), chalk.yellow(`${stats.dirty} dirty`), chalk.blue(`${stats.unpushed} unpushed`), ].join(" | "); } /** * Get view mode label */ export function getViewModeLabel(mode: ViewMode): string { const labels: Record = { local: "Local Only", github: "GitHub Only (Not Cloned)", combined: "All Repositories", }; return labels[mode]; } /** * Format unified repo list */ export function formatUnifiedRepoList( repos: UnifiedRepo[], stats: UnifiedStats, viewMode: ViewMode, options: FormatOptions = {} ): string { if (options.json) { return JSON.stringify(repos, null, 2); } const lines: string[] = []; lines.push(chalk.cyan(`\n=== ${getViewModeLabel(viewMode)} (${repos.length}) ===\n`)); for (const repo of repos) { lines.push(formatUnifiedRepo(repo, options.verbose)); } lines.push(chalk.gray("\n---")); lines.push(formatUnifiedStats(stats)); return lines.join("\n"); } // ============================================================================ // GitHub Auth Formatting // ============================================================================ /** * Format GitHub auth success */ export function formatAuthSuccess(login: string, name?: string): string { const lines = [chalk.green(`✓ Authenticated as ${login}`)]; if (name) { lines.push(chalk.gray(` Name: ${name}`)); } return lines.join("\n"); } /** * Format GitHub auth failure */ export function formatAuthFailure(error?: string): string { const lines = [chalk.red(`✗ Authentication failed${error ? `: ${error}` : ""}`)]; return lines.join("\n"); } /** * Format GitHub token not set message */ export function formatNoToken(): string { return [ chalk.red("✗ GITHUB_TOKEN not set"), chalk.gray("\nTo authenticate:"), chalk.white(" gitforest login"), chalk.gray(" (Opens browser for GitHub OAuth)"), chalk.gray("\nOr set token manually:"), chalk.gray(" export GITHUB_TOKEN=your_token"), ].join("\n"); } // ============================================================================ // Operation Messages // ============================================================================ /** * Format scanning message */ export function formatScanning(message = "Scanning directories..."): string { return chalk.cyan(message); } /** * Format warning message */ export function formatWarning(message: string): string { return chalk.yellow(`Warning: ${message}`); } /** * Format error message */ export function formatError(message: string): string { return chalk.red(message); } /** * Format success message */ export function formatSuccess(message: string): string { return chalk.green(message); } /** * Format info message */ export function formatInfo(message: string): string { return chalk.cyan(message); } /** * Format a simple operation result (success/failure) */ export function formatOperationItem( name: string, success: boolean, error?: string ): string { if (success) { return chalk.green(` ✓ ${name}`); } return chalk.red(` ✗ ${name}${error ? `: ${error}` : ""}`); } /** * Format operation summary line */ export function formatOperationSummary( operation: string, success: number, total: number ): string { return chalk.green(`${operation} ${success}/${total} ${success === 1 ? "item" : "items"}`); } // ============================================================================ // Unified Status Formatting // ============================================================================ export interface UnifiedStatusData { stats: UnifiedStats; githubOnly: UnifiedRepo[]; localOnly: UnifiedRepo[]; dirty: UnifiedRepo[]; unpushed: UnifiedRepo[]; unpulled: UnifiedRepo[]; } /** * Format unified status as JSON */ export function formatUnifiedStatusJson(data: UnifiedStatusData): string { return JSON.stringify( { stats: data.stats, githubOnly: data.githubOnly.map((r) => r.github?.fullName), localOnly: data.localOnly.map((r) => r.name), dirty: data.dirty.map((r) => r.name), unpushed: data.unpushed.map((r) => r.name), unpulled: data.unpulled.map((r) => r.name), }, null, 2 ); } /** * Format unified status for display */ export function formatUnifiedStatusDisplay(data: UnifiedStatusData): string { const { stats, githubOnly, dirty, unpushed, unpulled } = data; const lines: string[] = []; lines.push(chalk.cyan(`\n=== Unified Status ===\n`)); lines.push(`Total: ${stats.total} repositories`); lines.push(` ${chalk.green(`${stats.both} synced`)} (local + GitHub)`); lines.push(` ${chalk.blue(`${stats.localOnly} local-only`)} (not on GitHub)`); lines.push(` ${chalk.magenta(`${stats.githubOnly} github-only`)} (not cloned)\n`); if (githubOnly.length > 0) { lines.push(chalk.magenta(`Not Cloned (${githubOnly.length}):`)); githubOnly.slice(0, 10).forEach((r) => { const desc = r.github?.description ? chalk.gray(` - ${r.github.description.slice(0, 40)}`) : ""; lines.push(` ${r.github?.fullName}${desc}`); }); if (githubOnly.length > 10) { lines.push(chalk.gray(` ... and ${githubOnly.length - 10} more`)); } lines.push(""); } if (dirty.length > 0) { lines.push(chalk.yellow(`Dirty (${dirty.length}):`)); dirty.forEach((r) => lines.push(` ${r.name}`)); lines.push(""); } if (unpushed.length > 0) { lines.push(chalk.blue(`Unpushed (${unpushed.length}):`)); unpushed.forEach((r) => lines.push(` ${r.name} (↑${r.local?.status?.unpushedCommits})`) ); lines.push(""); } if (unpulled.length > 0) { lines.push(chalk.magenta(`Unpulled (${unpulled.length}):`)); unpulled.forEach((r) => lines.push(` ${r.name} (↓${r.local?.status?.unpulledCommits})`) ); lines.push(""); } if ( dirty.length === 0 && unpushed.length === 0 && unpulled.length === 0 && githubOnly.length === 0 ) { lines.push(chalk.green("✓ All repositories are synced!")); } return lines.join("\n"); } /** * Format clone result item */ export function formatCloneItem( fullName: string | undefined, success: boolean, path?: string, error?: string ): string { if (success) { return chalk.green(` ✓ ${fullName} → ${path}`); } return chalk.red(` ✗ ${fullName}: ${error}`); }