UNPKG

@owloops/claude-powerline

Version:

Beautiful vim-style powerline statusline for Claude Code with real-time usage tracking, git integration, and custom themes

493 lines (432 loc) 13.5 kB
import { exec } from "node:child_process"; import { promisify } from "node:util"; import fs from "node:fs"; import path from "node:path"; import { debug } from "../utils/logger"; const execAsync = promisify(exec); export interface GitInfo { branch: string; status: "clean" | "dirty" | "conflicts"; ahead: number; behind: number; sha?: string; staged?: number; unstaged?: number; untracked?: number; conflicts?: number; operation?: string; tag?: string; timeSinceCommit?: number; stashCount?: number; upstream?: string; repoName?: string; isWorktree?: boolean; } export class GitService { private isGitRepo(workingDir: string): boolean { try { return fs.existsSync(path.join(workingDir, ".git")); } catch { return false; } } private async execGitAsync( command: string, options: { cwd: string; encoding: string; timeout: number }, ): Promise<{ stdout: string }> { return execAsync(command, { ...options, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, }); } private async findGitRoot(workingDir: string): Promise<string | null> { try { const result = await this.execGitAsync("git rev-parse --show-toplevel", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const gitRoot = result.stdout.trim(); return gitRoot || null; } catch { return null; } } async getGitInfo( workingDir: string, options: { showSha?: boolean; showWorkingTree?: boolean; showOperation?: boolean; showTag?: boolean; showTimeSinceCommit?: boolean; showStashCount?: boolean; showUpstream?: boolean; showRepoName?: boolean; } = {}, projectDir?: string, ): Promise<GitInfo | null> { let gitDir: string; const isWorktreeDir = this.isWorktree(workingDir); if (isWorktreeDir) { // Worktree's .git is a file pointing to the main repo; // git commands must run from the worktree directory. gitDir = workingDir; } else if (projectDir && this.isGitRepo(projectDir)) { gitDir = projectDir; } else if (this.isGitRepo(workingDir)) { gitDir = workingDir; } else { const foundGitRoot = await this.findGitRoot(workingDir); if (!foundGitRoot) { return null; } gitDir = foundGitRoot; } try { const statusWithBranch = await this.getStatusWithBranchAsync(gitDir); const aheadBehind = await this.getAheadBehindAsync(gitDir); const result: GitInfo = { branch: statusWithBranch.branch || "detached", status: statusWithBranch.status, ahead: aheadBehind.ahead, behind: aheadBehind.behind, }; if (options.showWorkingTree && statusWithBranch.workingTree) { result.staged = statusWithBranch.workingTree.staged; result.unstaged = statusWithBranch.workingTree.unstaged; result.untracked = statusWithBranch.workingTree.untracked; result.conflicts = statusWithBranch.workingTree.conflicts; } const heavyOperations: Record<string, Promise<unknown>> = {}; const lightOperations: Record<string, Promise<unknown>> = {}; if (options.showSha) { heavyOperations.sha = this.getShaAsync(gitDir); } if (options.showTag) { heavyOperations.tag = this.getNearestTagAsync(gitDir); } if (options.showTimeSinceCommit) { heavyOperations.timeSinceCommit = this.getTimeSinceLastCommitAsync(gitDir); } if (options.showStashCount) { lightOperations.stashCount = this.getStashCountAsync(gitDir); } if (options.showUpstream) { lightOperations.upstream = this.getUpstreamAsync(gitDir); } if (options.showRepoName) { lightOperations.repoName = this.getRepoNameAsync(gitDir); } const resultMap = new Map<string, unknown>(); for (const [key, promise] of Object.entries(heavyOperations)) { try { const value = await promise; resultMap.set(key, value); } catch {} } if (Object.keys(lightOperations).length > 0) { const lightResults = await Promise.allSettled( Object.entries(lightOperations).map(async ([key, promise]) => ({ key, value: await promise, })), ); lightResults.forEach((result) => { if (result.status === "fulfilled") { resultMap.set(result.value.key, result.value.value); } }); } if (options.showSha) { result.sha = (resultMap.get("sha") as string) || undefined; } if (options.showOperation) { result.operation = this.getOngoingOperation(gitDir) || undefined; } if (options.showTag) { result.tag = (resultMap.get("tag") as string) || undefined; } if (options.showTimeSinceCommit) { result.timeSinceCommit = (resultMap.get("timeSinceCommit") as number) || undefined; } if (options.showStashCount) { result.stashCount = (resultMap.get("stashCount") as number) || 0; } if (options.showUpstream) { result.upstream = (resultMap.get("upstream") as string) || undefined; } if (options.showRepoName) { result.repoName = (resultMap.get("repoName") as string) || undefined; result.isWorktree = isWorktreeDir; } return result; } catch { return null; } } private async getShaAsync(workingDir: string): Promise<string | null> { try { const result = await this.execGitAsync("git rev-parse --short=7 HEAD", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const sha = result.stdout.trim(); return sha || null; } catch { return null; } } private resolveGitDir(workingDir: string): string { const dotGit = path.join(workingDir, ".git"); if (fs.existsSync(dotGit) && fs.statSync(dotGit).isFile()) { const content = fs.readFileSync(dotGit, "utf-8"); const match = content.match(/^gitdir:\s*(.+)$/m); if (match?.[1]) { return path.resolve(workingDir, match[1].trim()); } } return dotGit; } private getOngoingOperation(workingDir: string): string | null { try { const gitDir = this.resolveGitDir(workingDir); if (fs.existsSync(path.join(gitDir, "MERGE_HEAD"))) return "MERGE"; if (fs.existsSync(path.join(gitDir, "CHERRY_PICK_HEAD"))) return "CHERRY-PICK"; if (fs.existsSync(path.join(gitDir, "REVERT_HEAD"))) return "REVERT"; if (fs.existsSync(path.join(gitDir, "BISECT_LOG"))) return "BISECT"; if ( fs.existsSync(path.join(gitDir, "rebase-merge")) || fs.existsSync(path.join(gitDir, "rebase-apply")) ) return "REBASE"; return null; } catch { return null; } } private async getNearestTagAsync(workingDir: string): Promise<string | null> { try { const result = await this.execGitAsync("git describe --tags --abbrev=0", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const tag = result.stdout.trim(); return tag || null; } catch { return null; } } private async getTimeSinceLastCommitAsync( workingDir: string, ): Promise<number | null> { try { const result = await this.execGitAsync("git log -1 --format=%ct", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const timestamp = result.stdout.trim(); if (!timestamp) return null; const commitTime = parseInt(timestamp) * 1000; const now = Date.now(); return Math.floor((now - commitTime) / 1000); } catch { return null; } } private async getStashCountAsync(workingDir: string): Promise<number> { try { const result = await this.execGitAsync("git stash list", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const stashList = result.stdout.trim(); if (!stashList) return 0; return stashList.split("\n").length; } catch { return 0; } } private async getUpstreamAsync(workingDir: string): Promise<string | null> { try { const result = await this.execGitAsync( "git rev-parse --abbrev-ref @{u}", { cwd: workingDir, encoding: "utf8", timeout: 2000, }, ); const upstream = result.stdout.trim(); return upstream || null; } catch { return null; } } private async getRepoNameAsync(workingDir: string): Promise<string | null> { try { const result = await this.execGitAsync( "git config --get remote.origin.url", { cwd: workingDir, encoding: "utf8", timeout: 2000, }, ); const remoteUrl = result.stdout.trim(); if (!remoteUrl) return path.basename(workingDir); const match = remoteUrl.match(/\/([^/]+?)(\.git)?$/); return match?.[1] || path.basename(workingDir); } catch { return path.basename(workingDir); } } private isWorktree(workingDir: string): boolean { try { const gitDir = path.join(workingDir, ".git"); if (fs.existsSync(gitDir) && fs.statSync(gitDir).isFile()) { return true; } return false; } catch { return false; } } private async getStatusWithBranchAsync(workingDir: string): Promise<{ branch: string | null; status: "clean" | "dirty" | "conflicts"; workingTree?: { staged: number; unstaged: number; untracked: number; conflicts: number; }; }> { try { debug(`[GIT-EXEC] Running git status in ${workingDir}`); const result = await this.execGitAsync("git status --porcelain -b", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const output = result.stdout; const lines = output.split("\n"); let branch: string | null = null; let status: "clean" | "dirty" | "conflicts" = "clean"; let staged = 0; let unstaged = 0; let untracked = 0; let conflicts = 0; for (const line of lines) { if (!line) continue; if (line.startsWith("## ")) { const branchLine = line.substring(3); const branchMatch = branchLine.split("...")[0]; if (branchMatch && branchMatch !== "HEAD (no branch)") { branch = branchMatch; } continue; } if (line.length >= 2) { const indexStatus = line.charAt(0); const worktreeStatus = line.charAt(1); if (indexStatus === "?" && worktreeStatus === "?") { untracked++; if (status === "clean") status = "dirty"; continue; } const statusPair = indexStatus + worktreeStatus; if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(statusPair)) { conflicts++; status = "conflicts"; continue; } if (indexStatus !== " " && indexStatus !== "?") { staged++; if (status === "clean") status = "dirty"; } if (worktreeStatus !== " " && worktreeStatus !== "?") { unstaged++; if (status === "clean") status = "dirty"; } } } return { branch: branch || (await this.getFallbackBranch(workingDir)), status, workingTree: { staged, unstaged, untracked, conflicts }, }; } catch (error) { debug(`Git status with branch command failed in ${workingDir}:`, error); return { branch: await this.getFallbackBranch(workingDir), status: "clean", }; } } private async getFallbackBranch(workingDir: string): Promise<string | null> { try { const result = await this.execGitAsync("git branch --show-current", { cwd: workingDir, encoding: "utf8", timeout: 2000, }); const branch = result.stdout.trim(); if (branch) { return branch; } } catch { try { const result = await this.execGitAsync( "git symbolic-ref --short HEAD", { cwd: workingDir, encoding: "utf8", timeout: 2000, }, ); const branch = result.stdout.trim(); if (branch) { return branch; } } catch { return null; } } return null; } private async getAheadBehindAsync(workingDir: string): Promise<{ ahead: number; behind: number; }> { try { debug(`[GIT-EXEC] Running git ahead/behind in ${workingDir}`); const [aheadResult, behindResult] = await Promise.all([ this.execGitAsync("git rev-list --count @{u}..HEAD", { cwd: workingDir, encoding: "utf8", timeout: 2000, }), this.execGitAsync("git rev-list --count HEAD..@{u}", { cwd: workingDir, encoding: "utf8", timeout: 2000, }), ]); return { ahead: parseInt(aheadResult.stdout.trim()) || 0, behind: parseInt(behindResult.stdout.trim()) || 0, }; } catch (error) { debug(`Git ahead/behind command failed in ${workingDir}:`, error); return { ahead: 0, behind: 0 }; } } }