@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
text/typescript
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 };
}
}
}