From 1726f497b48dfbf4df36e25048cf2e8c9feb4999 Mon Sep 17 00:00:00 2001 From: Bhagya Mudgal Date: Fri, 17 Apr 2026 16:38:28 +0530 Subject: [PATCH 1/3] feat: show origin merge status and fix file counts in worktree commands --- .worktreerc | 1 + CHANGELOG.md | 18 ++++++++ package.json | 2 +- src/commands/list.ts | 54 +++++++++++++++-------- src/commands/open.ts | 6 ++- src/commands/remove.ts | 46 ++++++++++++++++---- src/lib/git.ts | 97 ++++++++++++++++++++++++++++++++++-------- 7 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 .worktreerc diff --git a/.worktreerc b/.worktreerc new file mode 100644 index 0000000..f1ea15d --- /dev/null +++ b/.worktreerc @@ -0,0 +1 @@ +DEFAULT_BASE=origin/main \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b3fd8..f815fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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 + ## [1.1.0] - 2026-04-07 ### Added diff --git a/package.json b/package.json index 05ef83e..14f86c7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/list.ts b/src/commands/list.ts index 7b7c29b..8aeb65d 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -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 { + 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}` @@ -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(); @@ -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(""); diff --git a/src/commands/open.ts b/src/commands/open.ts index 37b0a10..ba20404 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -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"; @@ -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); diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 2fe7265..ccd8390 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -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, @@ -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); @@ -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); } } diff --git a/src/lib/git.ts b/src/lib/git.ts index 88d3a68..bdea91b 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -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 = ""; @@ -104,7 +111,9 @@ async function gitWorktreePrune(expire?: string): Promise { } async function gitStatusCount(cwd: string): Promise { - 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; } @@ -168,9 +177,69 @@ async function gitRevParseGitDir(cwd: string): Promise { return result.exitCode === 0; } +async function getDefaultBranch(configBase?: string): Promise { + if (configBase) return configBase; + + const result = await run("git", [ + "symbolic-ref", + "refs/remotes/origin/HEAD", + ]); + + if (result.exitCode !== 0 || result.stdout === "") return null; + + const prefix = "refs/remotes/origin/"; + return result.stdout.startsWith(prefix) + ? result.stdout.slice(prefix.length) + : result.stdout; +} + +async function gitBranchStatus( + cwd: string, + defaultBranch: string | null +): Promise { + 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 { + if (!defaultBranch) return null; + + const result = await run( + "git", + ["merge-base", "--is-ancestor", "HEAD", `origin/${defaultBranch}`], + { cwd } + ); + + return result.exitCode === 0; +} + +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 { const pruneSuccess = await gitWorktreePrune(); if (!pruneSuccess) { @@ -204,18 +273,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 }; }) @@ -243,13 +305,14 @@ export { gitWorktreeListPorcelain, parsePorcelainOutput, gitWorktreePrune, - gitStatusCount, - gitAheadBehind, gitBranchDelete, gitBranchShowCurrent, gitBranchList, gitUnsetUpstream, gitRevParseGitDir, + getDefaultBranch, + gitBranchStatus, + formatBranchStatusHint, selectWorktree, }; -export type { WorktreeEntry }; +export type { WorktreeEntry, BranchStatus }; From f0348ed7a65624bf8094776fc2309e3ccc73883e Mon Sep 17 00:00:00 2001 From: Bhagya Mudgal Date: Fri, 17 Apr 2026 17:24:49 +0530 Subject: [PATCH 2/3] fix: strip origin/ prefix in getDefaultBranch so DEFAULT_BASE=origin/main works --- src/lib/git.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib/git.ts b/src/lib/git.ts index bdea91b..c5f0e5f 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -178,7 +178,13 @@ async function gitRevParseGitDir(cwd: string): Promise { } async function getDefaultBranch(configBase?: string): Promise { - if (configBase) return configBase; + const ORIGIN_PREFIX = "origin/"; + + if (configBase) { + return configBase.startsWith(ORIGIN_PREFIX) + ? configBase.slice(ORIGIN_PREFIX.length) + : configBase; + } const result = await run("git", [ "symbolic-ref", @@ -187,9 +193,9 @@ async function getDefaultBranch(configBase?: string): Promise { if (result.exitCode !== 0 || result.stdout === "") return null; - const prefix = "refs/remotes/origin/"; - return result.stdout.startsWith(prefix) - ? result.stdout.slice(prefix.length) + const REF_PREFIX = "refs/remotes/origin/"; + return result.stdout.startsWith(REF_PREFIX) + ? result.stdout.slice(REF_PREFIX.length) : result.stdout; } From fec75e6a44c95fa34c1e4dd18fde6b2b82be0fc9 Mon Sep 17 00:00:00 2001 From: Bhagya Mudgal Date: Fri, 17 Apr 2026 19:08:21 +0530 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20strip=20origin/=20prefix=20in=20DEFAULT=5FBASE,=20d?= =?UTF-8?q?istinguish=20merge-check=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ src/lib/git.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f815fb8..79d2758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/src/lib/git.ts b/src/lib/git.ts index c5f0e5f..cf2941c 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -224,7 +224,9 @@ async function checkMergedIntoOrigin( { cwd } ); - return result.exitCode === 0; + if (result.exitCode === 0) return true; + if (result.exitCode === 1) return false; + return null; } function formatBranchStatusHint(status: BranchStatus): string {