From 0ee9e9bb4d6405559974fc6a932124e71bb2d685 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Nov 2025 23:38:24 +0000 Subject: [PATCH] feat: add worktree status command with MCP parity Add `worktree status` CLI command that displays: - Enabled status, worktree count, and default branch - Current worktree info when inside one - All worktrees with branch, path, and ahead/behind tracking Includes extended types, git tracking status function, and tests. --- src/commands/status.ts | 112 +++++++++++++++++++++++++++ src/index.ts | 6 ++ src/lib/types.ts | 25 ++++++ src/utils/git.ts | 36 +++++++++ tests/status.test.ts | 172 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 351 insertions(+) create mode 100644 src/commands/status.ts create mode 100644 tests/status.test.ts diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..d320599 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,112 @@ +import { resolve, sep } from 'node:path'; +import { getDefaultBranch, getWorktrees, hasWorktreeStructure } from '@/lib/git'; +import type { ExtendedStatusResult, WorktreeStatusInfo } from '@/lib/types'; +import { getGitRoot, gitGetTrackingStatus } from '@/utils/git'; +import { intro, log, outro } from '@/utils/prompts'; +import { tryCatch } from '@/utils/try-catch'; + +export async function extendedStatus(): Promise { + const gitRoot = await getGitRoot(); + const worktrees = await getWorktrees(gitRoot); + const defaultBranch = await getDefaultBranch(gitRoot); + const enabled = await hasWorktreeStructure(gitRoot); + + const { data: currentDir } = tryCatch(() => resolve(process.cwd())); + + const worktreeStatusList: WorktreeStatusInfo[] = await Promise.all( + worktrees.map(async (wt) => { + const resolvedWtPath = resolve(wt.path); + const isCurrent = + currentDir != null && + (currentDir === resolvedWtPath || currentDir.startsWith(resolvedWtPath + sep)); + + let tracking: WorktreeStatusInfo['tracking']; + if (wt.branch !== 'detached') { + const trackingResult = await gitGetTrackingStatus(wt.branch, wt.path); + if (trackingResult) { + tracking = trackingResult; + } + } + + return { + ...wt, + isCurrent, + tracking, + }; + }) + ); + + const currentWorktree = worktreeStatusList.find((wt) => wt.isCurrent); + + return { + enabled, + count: worktrees.length, + defaultBranch, + worktrees: worktreeStatusList, + currentWorktree, + }; +} + +export async function statusCommand(): Promise { + intro('Worktree Status'); + + const status = await extendedStatus(); + + // Show basic status info + log.message(`Enabled: ${status.enabled ? 'yes' : 'no'}`); + log.message(`Worktree count: ${status.count}`); + if (status.defaultBranch) { + log.message(`Default branch: ${status.defaultBranch}`); + } + + // Show current worktree info if in one + if (status.currentWorktree) { + log.message(''); + log.message(`Current worktree: ${status.currentWorktree.branch}`); + log.message(` Path: ${status.currentWorktree.path}`); + if (status.currentWorktree.tracking) { + const { ahead, behind, upstream } = status.currentWorktree.tracking; + if (ahead === 0 && behind === 0) { + log.message(` Status: up to date with ${upstream}`); + } else { + const parts: string[] = []; + if (ahead > 0) parts.push(`${ahead} ahead`); + if (behind > 0) parts.push(`${behind} behind`); + log.message(` Status: ${parts.join(', ')} ${upstream}`); + } + } + } + + // List all worktrees + if (status.worktrees.length > 0) { + log.message(''); + log.message('Worktrees:'); + + for (const [i, wt] of status.worktrees.entries()) { + const isMain = i === 0; + const icon = isMain ? '⚡' : '📦'; + const currentLabel = wt.isCurrent ? ' (current)' : ''; + + // Build tracking status string + let trackingStr = ''; + if (wt.tracking) { + const { ahead, behind } = wt.tracking; + if (ahead === 0 && behind === 0) { + trackingStr = ' [up to date]'; + } else { + const parts: string[] = []; + if (ahead > 0) parts.push(`↑${ahead}`); + if (behind > 0) parts.push(`↓${behind}`); + trackingStr = ` [${parts.join(' ')}]`; + } + } + + const paddedBranch = wt.branch.padEnd(30, ' '); + log.message(` ${icon} ${paddedBranch}${wt.path}${currentLabel}${trackingStr}`); + } + } + + outro(`${status.count} worktree${status.count === 1 ? '' : 's'}`); + + return 0; +} diff --git a/src/index.ts b/src/index.ts index bab2194..2fff90f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { mcpConfigCommand, mcpStartCommand, mcpTestCommand } from '@/commands/mc import { prCommand } from '@/commands/pr'; import { removeCommand } from '@/commands/remove'; import { setupCommand } from '@/commands/setup'; +import { statusCommand } from '@/commands/status'; import { switchCommand } from '@/commands/switch'; import { UserCancelledError, WorktreeError } from '@/utils/errors'; import { log } from '@/utils/prompts'; @@ -136,6 +137,11 @@ program .description('List all active worktrees') .action(() => handleCommandError(() => listCommand())()); +program + .command('status') + .description('Show worktree status and configuration') + .action(() => handleCommandError(() => statusCommand())()); + program .command('switch [branch]') .description('Switch to an existing worktree') diff --git a/src/lib/types.ts b/src/lib/types.ts index 6d42cff..84f2d03 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -20,6 +20,31 @@ export interface StatusResult { defaultBranch?: string; } +/** + * Ahead/behind tracking status for a branch + */ +export interface TrackingStatus { + ahead: number; + behind: number; + upstream?: string; +} + +/** + * Extended worktree info with tracking status + */ +export interface WorktreeStatusInfo extends WorktreeInfo { + tracking?: TrackingStatus; + isCurrent: boolean; +} + +/** + * Extended status result for CLI command + */ +export interface ExtendedStatusResult extends StatusResult { + worktrees: WorktreeStatusInfo[]; + currentWorktree?: WorktreeStatusInfo; +} + /** * Result of creating a new worktree */ diff --git a/src/utils/git.ts b/src/utils/git.ts index 7783b33..9fd86eb 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -348,3 +348,39 @@ export async function gitHasUncommittedChanges( return data.stdout.trim().length > 0; } + +/** + * Get ahead/behind status for a branch relative to its upstream + */ +export async function gitGetTrackingStatus( + branch: string, + cwd?: string +): Promise<{ ahead: number; behind: number; upstream?: string } | null> { + // Get the upstream branch + const { error: upstreamError, data: upstreamData } = await tryCatch( + execGit(['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], cwd) + ); + + if (upstreamError || !upstreamData?.stdout) { + return null; // No upstream configured + } + + const upstream = upstreamData.stdout; + + // Get ahead/behind counts + const { error: countError, data: countData } = await tryCatch( + execGit(['rev-list', '--left-right', '--count', `${branch}...${upstream}`], cwd) + ); + + if (countError || !countData?.stdout) { + return null; + } + + const [ahead, behind] = countData.stdout.split('\t').map((n) => Number.parseInt(n, 10)); + + return { + ahead: ahead || 0, + behind: behind || 0, + upstream, + }; +} diff --git a/tests/status.test.ts b/tests/status.test.ts new file mode 100644 index 0000000..f1b6452 --- /dev/null +++ b/tests/status.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for the status command and extendedStatus function + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { $ } from 'bun'; +import { extendedStatus } from '@/commands/status'; +import * as worktree from '@/lib/worktree'; + +let testDir: string; +let originalCwd: string; +let defaultBranch: string; + +beforeEach(async () => { + originalCwd = process.cwd(); + testDir = await mkdtemp(join(tmpdir(), 'worktree-status-test-')); + process.chdir(testDir); + + // Initialize a bare repo structure with main worktree + await $`git init --bare .bare`.quiet(); + await $`git clone ${testDir}/.bare main`.quiet(); + process.chdir(join(testDir, 'main')); + + // Create initial commit + await writeFile('README.md', '# Test Repo'); + await $`git add .`.quiet(); + await $`git config user.email "test@example.com"`.quiet(); + await $`git config user.name "Test User"`.quiet(); + await $`git config commit.gpgsign false`.quiet(); + await $`git commit -m "Initial commit"`.quiet(); + + // Get the default branch name + const branchResult = await $`git branch --show-current`.quiet(); + defaultBranch = branchResult.stdout.toString().trim(); + + // Push to populate bare repo + try { + await $`git push -u origin ${defaultBranch}`.quiet(); + } catch { + // Push failed - manually set up bare repo HEAD ref + await $`git --git-dir=${testDir}/.bare symbolic-ref HEAD refs/heads/${defaultBranch}`.quiet(); + } +}); + +afterEach(async () => { + process.chdir(originalCwd); + await rm(testDir, { recursive: true, force: true }); +}); + +describe('extendedStatus()', () => { + test('returns basic status info', async () => { + const status = await extendedStatus(); + + expect(status.enabled).toBe(false); + expect(status.count).toBe(1); + expect(status.worktrees).toBeArrayOfSize(1); + }); + + test('returns worktree list with branch and path', async () => { + const status = await extendedStatus(); + + expect(status.worktrees[0].branch).toBe(defaultBranch); + expect(status.worktrees[0].path).toContain('main'); + }); + + test('detects current worktree', async () => { + const status = await extendedStatus(); + + // We're in main, so it should be current + expect(status.worktrees[0].isCurrent).toBe(true); + expect(status.currentWorktree).toBeDefined(); + expect(status.currentWorktree?.branch).toBe(defaultBranch); + }); + + test('returns multiple worktrees after creation', async () => { + await worktree.create('feature/test'); + const status = await extendedStatus(); + + expect(status.enabled).toBe(true); + expect(status.count).toBe(2); + expect(status.worktrees).toBeArrayOfSize(2); + expect(status.worktrees.some((wt) => wt.branch === 'feature/test')).toBe(true); + }); + + test('shows current worktree changes when switching directories', async () => { + await worktree.create('feature/other'); + const featurePath = join(testDir, 'feature-other'); + + // Check status from main - main is current + let status = await extendedStatus(); + expect(status.currentWorktree?.branch).toBe(defaultBranch); + + // Switch to feature and check again + process.chdir(featurePath); + status = await extendedStatus(); + expect(status.currentWorktree?.branch).toBe('feature/other'); + }); + + test('includes tracking status when upstream is set', async () => { + // Push and set upstream for default branch + try { + await $`git push -u origin ${defaultBranch}`.quiet(); + } catch { + // May already be pushed + } + + const status = await extendedStatus(); + const mainWorktree = status.worktrees.find((wt) => wt.branch === defaultBranch); + + // Should have tracking info since upstream is set + expect(mainWorktree?.tracking).toBeDefined(); + expect(mainWorktree?.tracking?.ahead).toBe(0); + expect(mainWorktree?.tracking?.behind).toBe(0); + }); + + test('shows ahead commits when local is ahead', async () => { + // Ensure upstream is set + try { + await $`git push -u origin ${defaultBranch}`.quiet(); + } catch { + // May fail in CI + } + + // Add a local commit + await writeFile('new-file.txt', 'new content'); + await $`git add .`.quiet(); + await $`git commit -m "Local commit"`.quiet(); + + const status = await extendedStatus(); + const mainWorktree = status.worktrees.find((wt) => wt.branch === defaultBranch); + + if (mainWorktree?.tracking) { + expect(mainWorktree.tracking.ahead).toBeGreaterThan(0); + } + }); + + test('returns defaultBranch in status', async () => { + // After creating a worktree, defaultBranch should be set + await worktree.create('feature/test'); + const statusAfter = await extendedStatus(); + + expect(statusAfter.defaultBranch).toBeDefined(); + }); + + test('handles detached HEAD worktree', async () => { + // Get current commit hash + const hashResult = await $`git rev-parse HEAD`.quiet(); + const commitHash = hashResult.stdout.toString().trim(); + + // Create a detached worktree + await $`git worktree add --detach ../detached ${commitHash}`.quiet(); + + const status = await extendedStatus(); + // Detached worktrees show as 'detached' in the branch field + const detachedWorktree = status.worktrees.find((wt) => wt.path.includes('detached')); + + expect(detachedWorktree).toBeDefined(); + // Detached worktrees should not have tracking info + expect(detachedWorktree?.tracking).toBeUndefined(); + }); + + test('non-current worktrees have isCurrent false', async () => { + await worktree.create('feature/not-current'); + const status = await extendedStatus(); + + const featureWorktree = status.worktrees.find((wt) => wt.branch === 'feature/not-current'); + expect(featureWorktree?.isCurrent).toBe(false); + }); +});