Skip to content
Merged
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
1 change: 1 addition & 0 deletions .worktreerc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DEFAULT_BASE=origin/main
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.0] - 2026-04-17

### Added

- Origin merge status displayed in `list`, `open`, and `remove` commands — shows whether branch is merged into default branch on origin
- Full branch status in `remove` command — shows uncommitted changes, unpushed commits, and origin merge status before confirming removal
- Auto-detect default branch from `origin/HEAD` when `DEFAULT_BASE` is not set in `.worktreerc`

### Changed

- `remove` command always confirms before removal, displaying comprehensive branch status
- `remove` command fetches from origin before checking merge status (non-fatal on failure)
- Worktree selection hint now shows origin merge status alongside local changes

### Fixed

- Inaccurate file counts: switched from `git status --porcelain` to `--porcelain=v2 -uall` to expand untracked directories into individual files
- `DEFAULT_BASE=origin/main` (the documented format) no longer produces an invalid `origin/origin/main` ref in the merge check — `getDefaultBranch` now strips a leading `origin/` so all consumers see a bare branch name
- `checkMergedIntoOrigin` now distinguishes "not an ancestor" (exit code 1 → `false`) from ref-missing / corrupt-state errors (other non-zero → `null` = unknown), avoiding misleading "NOT merged" messages on error states

## [1.1.0] - 2026-04-07

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "worktree-cli",
"version": "1.1.0",
"version": "1.2.0",
"description": "Git worktree manager with automatic env file copying, dependency installation, and editor integration.",
"type": "module",
"module": "src/index.ts",
Expand Down
54 changes: 36 additions & 18 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,49 @@
import { command } from "@drizzle-team/brocli";
import path from "node:path";
import type { BranchStatus } from "../lib/git";
import {
getGitRoot,
gitWorktreePrune,
gitWorktreeListPorcelain,
parsePorcelainOutput,
gitStatusCount,
gitAheadBehind,
gitBranchStatus,
getDefaultBranch,
} from "../lib/git";
import { loadConfig } from "../lib/config";
import { printHeader, printInfo, COLORS } from "../lib/logger";

async function printWorktreeInfo(
function printWorktreeInfo(
wtPath: string,
branch: string,
root: string
): Promise<void> {
root: string,
status: BranchStatus
): void {
const { BOLD, CYAN, YELLOW, GREEN, RED, DIM, RESET } = COLORS;

let relativePath = path.relative(root, wtPath);
if (wtPath === root) relativePath = ". (main)";

const changes = await gitStatusCount(wtPath);
const { ahead, behind } = await gitAheadBehind(wtPath);

console.error(` ${BOLD}${relativePath}${RESET}`);
console.error(` Branch: ${CYAN}${branch || "detached"}${RESET}`);

const statusParts: string[] = [];
if (changes > 0) statusParts.push(`${YELLOW}${changes} changed${RESET}`);
if (ahead > 0) statusParts.push(`${GREEN}${ahead} ahead${RESET}`);
if (behind > 0) statusParts.push(`${RED}${behind} behind${RESET}`);
if (status.changes > 0)
statusParts.push(`${YELLOW}${status.changes} changed${RESET}`);
if (status.ahead > 0)
statusParts.push(`${GREEN}${status.ahead} ahead${RESET}`);
if (status.behind > 0)
statusParts.push(`${RED}${status.behind} behind${RESET}`);

const localStatus =
statusParts.length > 0 ? statusParts.join(", ") : `${DIM}clean${RESET}`;

let mergeStatus = "";
if (status.isMerged === true)
mergeStatus = ` | ${GREEN}origin: merged${RESET}`;
else if (status.isMerged === false)
mergeStatus = ` | ${YELLOW}origin: not merged${RESET}`;

if (statusParts.length > 0) {
console.error(` Status: ${statusParts.join(", ")}`);
} else {
console.error(` Status: ${DIM}clean${RESET}`);
}
console.error(` Status: ${localStatus}${mergeStatus}`);

console.error(
`${DIM}──────────────────────────────────────────────────────${RESET}`
Expand All @@ -47,6 +55,7 @@ export const listCommand = command({
desc: "List all worktrees with status",
handler: async () => {
const root = await getGitRoot();
const config = await loadConfig(root);

await gitWorktreePrune();

Expand All @@ -64,14 +73,23 @@ export const listCommand = command({
return;
}

const defaultBranch = await getDefaultBranch(config.DEFAULT_BASE);

const rows = await Promise.all(
worktreeEntries.map(async (entry) => ({
entry,
status: await gitBranchStatus(entry.path, defaultBranch),
}))
);

console.error("");
printHeader("Active Worktrees");
console.error(
`${COLORS.DIM}──────────────────────────────────────────────────────${COLORS.RESET}`
);

for (const entry of worktreeEntries) {
await printWorktreeInfo(entry.path, entry.branch, root);
for (const { entry, status } of rows) {
printWorktreeInfo(entry.path, entry.branch, root, status);
}

console.error("");
Expand Down
6 changes: 4 additions & 2 deletions src/commands/open.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { command, positional, string } from "@drizzle-team/brocli";
import path from "node:path";
import fs from "node:fs/promises";
import { getGitRoot, selectWorktree } from "../lib/git";
import { getGitRoot, getDefaultBranch, selectWorktree } from "../lib/git";
import { loadConfig } from "../lib/config";
import { resolveEditor, openInEditor } from "../lib/editor";
import { printError } from "../lib/logger";
Expand All @@ -18,8 +18,10 @@ export const openCommand = command({
const root = await getGitRoot();
const config = await loadConfig(root);

const defaultBranch = await getDefaultBranch(config.DEFAULT_BASE);
const name =
opts.name ?? (await selectWorktree(root, config.WORKTREE_DIR));
opts.name ??
(await selectWorktree(root, config.WORKTREE_DIR, defaultBranch));
const worktreePath = path.join(root, config.WORKTREE_DIR, name);

const dirExists = await fs.stat(worktreePath).catch(() => null);
Expand Down
46 changes: 38 additions & 8 deletions src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import * as p from "@clack/prompts";
import { tryCatch } from "../lib/try-catch";
import {
getGitRoot,
gitStatusCount,
gitFetch,
gitBranchStatus,
getDefaultBranch,
gitWorktreeRemove,
gitWorktreeListPorcelain,
parsePorcelainOutput,
Expand Down Expand Up @@ -57,8 +59,18 @@ export const removeCommand = command({
handler: async (opts) => {
const root = await getGitRoot();
const config = await loadConfig(root);

const [fetchResult, defaultBranch] = await Promise.all([
gitFetch(),
getDefaultBranch(config.DEFAULT_BASE),
]);
if (!fetchResult.success)
printWarn(
"Could not fetch from origin. Merge status may be stale."
);
const name =
opts.name ?? (await selectWorktree(root, config.WORKTREE_DIR));
opts.name ??
(await selectWorktree(root, config.WORKTREE_DIR, defaultBranch));
const worktreeBase = path.join(root, config.WORKTREE_DIR);
const worktreePath = path.join(worktreeBase, name);

Expand Down Expand Up @@ -91,20 +103,38 @@ export const removeCommand = command({
let worktreeBranch = "";

if (isValidWorktree) {
worktreeBranch = await gitBranchShowCurrent(worktreePath);
const changes = await gitStatusCount(worktreePath);
const [branch, status] = await Promise.all([
gitBranchShowCurrent(worktreePath),
gitBranchStatus(worktreePath, defaultBranch),
]);
worktreeBranch = branch;

if (status.changes > 0)
printWarn(
`Worktree '${name}' has ${status.changes} uncommitted change(s).`
);
if (status.ahead > 0)
printWarn(
`Worktree '${name}' has ${status.ahead} unpushed commit(s).`
);

if (changes > 0) {
if (status.isMerged === true)
printSuccess(
`Branch is merged into ${defaultBranch} on origin.`
);
else if (status.isMerged === false)
printWarn(
`Worktree '${name}' has ${changes} uncommitted change(s).`
`Branch is NOT merged into ${defaultBranch} on origin.`
);
await confirmOrExit("Remove anyway?");

await confirmOrExit(`Remove worktree '${name}'?`);

if (status.changes > 0) {
await forceRemoveWorktree(worktreePath);
} else {
const result = await gitWorktreeRemove(worktreePath);
if (!result.success) {
printWarn(result.output || "git worktree remove failed.");
await confirmOrExit("Continue?");
await forceRemoveWorktree(worktreePath);
}
}
Expand Down
105 changes: 88 additions & 17 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ type WorktreeEntry = {
branch: string;
};

type BranchStatus = {
changes: number;
ahead: number;
behind: number;
isMerged: boolean | null;
};

function parsePorcelainOutput(output: string): WorktreeEntry[] {
const entries: WorktreeEntry[] = [];
let currentPath = "";
Expand Down Expand Up @@ -104,7 +111,9 @@ async function gitWorktreePrune(expire?: string): Promise<boolean> {
}

async function gitStatusCount(cwd: string): Promise<number> {
const result = await run("git", ["status", "--porcelain"], { cwd });
const result = await run("git", ["status", "--porcelain=v2", "-uall"], {
cwd,
});
if (result.exitCode !== 0 || result.stdout === "") return 0;
return result.stdout.split("\n").filter(Boolean).length;
}
Expand Down Expand Up @@ -168,9 +177,77 @@ async function gitRevParseGitDir(cwd: string): Promise<boolean> {
return result.exitCode === 0;
}

async function getDefaultBranch(configBase?: string): Promise<string | null> {
const ORIGIN_PREFIX = "origin/";

if (configBase) {
return configBase.startsWith(ORIGIN_PREFIX)
? configBase.slice(ORIGIN_PREFIX.length)
: configBase;
}

const result = await run("git", [
"symbolic-ref",
"refs/remotes/origin/HEAD",
]);

if (result.exitCode !== 0 || result.stdout === "") return null;

const REF_PREFIX = "refs/remotes/origin/";
return result.stdout.startsWith(REF_PREFIX)
? result.stdout.slice(REF_PREFIX.length)
: result.stdout;
}

async function gitBranchStatus(
cwd: string,
defaultBranch: string | null
): Promise<BranchStatus> {
const [changes, { ahead, behind }, isMerged] = await Promise.all([
gitStatusCount(cwd),
gitAheadBehind(cwd),
checkMergedIntoOrigin(cwd, defaultBranch),
]);

return { changes, ahead, behind, isMerged };
}

async function checkMergedIntoOrigin(
cwd: string,
defaultBranch: string | null
): Promise<boolean | null> {
if (!defaultBranch) return null;

const result = await run(
"git",
["merge-base", "--is-ancestor", "HEAD", `origin/${defaultBranch}`],
{ cwd }
);

if (result.exitCode === 0) return true;
if (result.exitCode === 1) return false;
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function formatBranchStatusHint(status: BranchStatus): string {
const parts: string[] = [];
if (status.changes > 0)
parts.push(`${status.changes} change${status.changes > 1 ? "s" : ""}`);
if (status.ahead > 0) parts.push(`${status.ahead} ahead`);
if (status.behind > 0) parts.push(`${status.behind} behind`);
const localHint = parts.length > 0 ? parts.join(", ") : "clean";

let mergeHint = "";
if (status.isMerged === true) mergeHint = "merged";
else if (status.isMerged === false) mergeHint = "not merged";

return mergeHint ? `${localHint} | ${mergeHint}` : localHint;
}

async function selectWorktree(
root: string,
worktreeDir: string
worktreeDir: string,
defaultBranch?: string | null
): Promise<string> {
const pruneSuccess = await gitWorktreePrune();
if (!pruneSuccess) {
Expand Down Expand Up @@ -204,18 +281,11 @@ async function selectWorktree(

const options = await Promise.all(
worktreeEntries.map(async (entry) => {
const [changes, { ahead, behind }] = await Promise.all([
gitStatusCount(entry.path),
gitAheadBehind(entry.path),
]);

const parts: string[] = [];
if (changes > 0)
parts.push(`${changes} change${changes > 1 ? "s" : ""}`);
if (ahead > 0) parts.push(`${ahead} ahead`);
if (behind > 0) parts.push(`${behind} behind`);
const hint = parts.length > 0 ? parts.join(", ") : "clean";

const status = await gitBranchStatus(
entry.path,
defaultBranch ?? null
);
const hint = formatBranchStatusHint(status);
const name = path.relative(worktreeBase, entry.path);
return { value: name, label: entry.branch || name, hint };
})
Expand Down Expand Up @@ -243,13 +313,14 @@ export {
gitWorktreeListPorcelain,
parsePorcelainOutput,
gitWorktreePrune,
gitStatusCount,
gitAheadBehind,
gitBranchDelete,
gitBranchShowCurrent,
gitBranchList,
gitUnsetUpstream,
gitRevParseGitDir,
getDefaultBranch,
gitBranchStatus,
formatBranchStatusHint,
selectWorktree,
};
export type { WorktreeEntry };
export type { WorktreeEntry, BranchStatus };
Loading