Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -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<ExtendedStatusResult> {
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<number> {
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;
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getVersionInfo, updateCommand } from '@/commands/update';
import { UserCancelledError, WorktreeError } from '@/utils/errors';
Expand Down Expand Up @@ -155,6 +156,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')
Expand Down
25 changes: 25 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
36 changes: 36 additions & 0 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
172 changes: 172 additions & 0 deletions tests/status.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});