From 4b7199e269c0f333bf0859afd5da15f92a9e20dd Mon Sep 17 00:00:00 2001 From: Netanel David Date: Wed, 24 Jun 2026 00:41:58 +0300 Subject: [PATCH] Support git worktrees in the branch picker Worktree branches are now first-class in both the checkout and delete flows instead of appearing as un-actionable "+ branch" entries that git refuses to check out or delete. - Detect worktrees via `git worktree list --porcelain` and map each to its path; keep git's "+" marker in the list display. - Checkout mode: selecting a worktree opens a new shell in that directory (a CLI can't cd its parent shell), cross-platform via $SHELL / ComSpec. Regular branches still `git checkout`. - Delete mode (-d): selecting a worktree runs `git worktree remove` (kept per-item so a dirty/locked one is skipped with a message rather than aborting the batch); regular branches still `git branch -D`. - Fix default selection: the autocomplete prompt matches `default` against a choice value, not an index, so the current branch is now actually preselected. - Docs: README "Worktrees" section + help text; bump to 1.1.0. Co-Authored-By: Claude Opus 4.8 --- README.md | 17 ++- index.js | 292 ++++++++++++++++++++++++++++++++++----------------- package.json | 2 +- 3 files changed, 211 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index f70440c..4daf0fb 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Example prompt: ◯ master ``` -> Note: The -d flag only deletes local branches. Use with caution—deleted branches cannot be recovered unless pushed to remote. +> Note: The -d flag deletes local branches and removes worktrees. Use with caution—deleted branches cannot be recovered unless pushed to remote. (Worktrees that have uncommitted changes are skipped, with a message, rather than force-removed.) Then you'll be displayed with the available branches list. Simple pick one and hit Enter. ``` @@ -59,6 +59,21 @@ When prompted, you can type in to filter results > hotfix/fish-tacos-for-lunch ``` +# Worktrees + +Branches that are checked out in another [git worktree](https://git-scm.com/docs/git-worktree) show up in the list automatically, marked with a `+` (the same marker `git branch` uses): + +``` +? Select branch/worktree: + feature/my-awesome-feature ++ master +> add-d-to-help-text +``` + +Picking a worktree opens a **new shell in that directory** — you land there ready to work, and type `exit` to return to where you started. (A CLI can't change its parent shell's directory, so it spawns a shell instead.) This works on macOS, Linux and Windows — it launches your `$SHELL`, or `cmd`/PowerShell on Windows. + +With `-d`, selecting a worktree runs `git worktree remove` on it (the underlying branch is kept), while a regular branch still gets `git branch -D`. + Display help (yes, it has a '-h' arg) `gitcheckout -h` diff --git a/index.js b/index.js index 8930c3a..beae7b6 100644 --- a/index.js +++ b/index.js @@ -1,98 +1,194 @@ -#!/usr/bin/env node - -import { exec } from "child_process"; -import inquirer from 'inquirer'; -import inquirerPrompt from 'inquirer-autocomplete-prompt'; - -inquirer.registerPrompt("autocomplete", inquirerPrompt); - -const showHelp = process.argv.includes("-h"); -const fetchFirst = process.argv.includes("-f"); -const deleteBranches = process.argv.includes("-d"); -const includeOrigin = process.argv.includes("-l") || deleteBranches ? "" : " -a"; - -if (showHelp) { - console.log(` -Command: gitcheckout [-l] [-f] [-d] -Select a branch using the keyboard arrows, hit Enter, and watch the magic as it happens. -The current checked out branch is the default selection. - --l: include only local branches in branches list --f: fetch before listing branches (quietly skips if failed) --d: select local branches to delete -`) - process.exit(0); -} - -function execute(cmd, pipe = true) { - return new Promise((resolve, reject) => { - exec(cmd, (err, stdout) => { - if (err) { - return reject(err); - } - resolve(stdout); - }); - }); -} - -async function execDeleteBranches(branches) { - const { branchesToDelete } = await inquirer.prompt([ - { - type: "checkbox", - message: "Select branches to delete:", - name: "branchesToDelete", - choices: branches, - pageSize: 10, - } - ]); - await execute(`git branch -D ${branchesToDelete.join(" ")}`); -} - -(async function run() { - if (fetchFirst) { - await execute(`git fetch`).catch(() => { - console.log("Couldn't fetch. Fetch manually before to display new remote branches"); - return; - }); - } - const gitBranchOut = await execute(`git branch --sort=-committerdate${includeOrigin}`, false); - let checkedOutBranch = 0; - let branches = gitBranchOut - // removed output whitespace - .trim() - // deal with windows - .replace(/\r/, "") - // split to rows - each one is a branch - .split("\n") - // removed the checked out branch indicator '*', origin/ if branch -a was executed and trim whitespace - .map((n, i) => { - if (n.match(/^\s*\*/)) { - checkedOutBranch = i; - } - return n.replace(/^\s*\*|^\s*remotes\/origin\//g, "").trim() - }); - - // dedup (if local was or is checked out and origins are included) - branches = [...new Set(branches)]; - if (deleteBranches) { - return execDeleteBranches(branches); - } - const { branch } = await inquirer.prompt([ - { - type: "autocomplete", - message: "Select branch to checkout:", - name: "branch", - source: (answersSoFar, input) => { - return Promise.resolve(!input ? branches : branches.filter(n => n.includes(input))); - }, - pageSize: 10, - default: checkedOutBranch, - } - ]); - await execute(`git checkout ${branch}`); - -})() - .catch((e) => { - console.log(e.message || e); - process.exit(1); - }); +#!/usr/bin/env node + +import { exec, spawn } from "child_process"; +import { homedir } from "os"; +import inquirer from 'inquirer'; +import inquirerPrompt from 'inquirer-autocomplete-prompt'; + +inquirer.registerPrompt("autocomplete", inquirerPrompt); + +const showHelp = process.argv.includes("-h"); +const fetchFirst = process.argv.includes("-f"); +const deleteBranches = process.argv.includes("-d"); +const includeOrigin = process.argv.includes("-l") || deleteBranches ? "" : " -a"; + +if (showHelp) { + console.log(` +Command: gitcheckout [-l] [-f] [-d] +Select a branch using the keyboard arrows, hit Enter, and watch the magic as it happens. +The current checked out branch is the default selection. +Worktrees appear in the list marked with a '+' (just like 'git branch'). Selecting +one opens a new shell in that directory; type 'exit' to return to where you came from. + +-l: include only local branches in branches list +-f: fetch before listing branches (quietly skips if failed) +-d: select local branches/worktrees to delete (worktrees are removed via 'git worktree remove') +`) + process.exit(0); +} + +function execute(cmd, pipe = true) { + return new Promise((resolve, reject) => { + exec(cmd, (err, stdout) => { + if (err) { + return reject(err); + } + resolve(stdout); + }); + }); +} + +const home = homedir(); +// prettify long paths by collapsing the home directory to '~' +function tildify(p) { + return home && p.startsWith(home) ? "~" + p.slice(home.length) : p; +} + +// how an entry is shown in the list (worktrees keep git's '+' marker) +function displayName(entry) { + if (entry.type === "worktree") { + if (entry.branch) return `+ ${entry.branch}`; + return `+ (detached${entry.head ? ` ${entry.head.slice(0, 7)}` : ""})`; + } + return entry.branch; +} + +// open a brand-new shell sitting inside the worktree dir. A child process can't +// cd its parent shell, so we drop the user into a fresh interactive shell there. +function openShell(cwd) { + const shell = process.env.SHELL + || process.env.ComSpec + || (process.platform === "win32" ? "cmd.exe" : "/bin/sh"); + console.log(`\nEntering worktree: ${tildify(cwd)} (type 'exit' to return)\n`); + const child = spawn(shell, [], { cwd, stdio: "inherit" }); + child.on("exit", (code) => process.exit(code == null ? 0 : code)); + child.on("error", (err) => { + console.log(`Couldn't open a shell: ${err.message}`); + process.exit(1); + }); +} + +// parse `git worktree list --porcelain` into a branch->path map (+ detached ones), +// excluding the current worktree and any bare repo entry. +async function getWorktrees() { + const out = await execute(`git worktree list --porcelain`).catch(() => ""); + const currentTop = (await execute(`git rev-parse --show-toplevel`).catch(() => "")).trim(); + + const byBranch = new Map(); + const detached = []; + const register = (b) => { + if (!b || !b.path || b.bare || b.path === currentTop) return; + if (b.branch) byBranch.set(b.branch, b.path); + else detached.push(b); + }; + + let block = {}; + for (const line of out.replace(/\r/g, "").split("\n")) { + if (line.startsWith("worktree ")) block = { path: line.slice("worktree ".length).trim() }; + else if (line.startsWith("HEAD ")) block.head = line.slice("HEAD ".length).trim(); + else if (line.startsWith("branch ")) block.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "").trim(); + else if (line === "detached") block.detached = true; + else if (line === "bare") block.bare = true; + else if (line === "") { register(block); block = {}; } + } + register(block); + + return { byBranch, detached }; +} + +async function execDelete(choices) { + const { selected } = await inquirer.prompt([ + { + type: "checkbox", + message: "Select branches/worktrees to delete:", + name: "selected", + choices, + pageSize: 10, + } + ]); + const worktrees = selected.filter((e) => e.type === "worktree"); + const branches = selected.filter((e) => e.type === "branch").map((e) => e.branch); + + // remove worktrees one by one so a single failure (e.g. dirty/locked) doesn't abort the rest + for (const wt of worktrees) { + await execute(`git worktree remove "${wt.path}"`).catch((err) => { + console.log(`Could not remove worktree ${tildify(wt.path)}: ${(err.message || err).trim()}`); + }); + } + if (branches.length) { + await execute(`git branch -D ${branches.join(" ")}`); + } +} + +(async function run() { + if (fetchFirst) { + await execute(`git fetch`).catch(() => { + console.log("Couldn't fetch. Fetch manually before to display new remote branches"); + return; + }); + } + + const { byBranch, detached } = await getWorktrees(); + const gitBranchOut = await execute(`git branch --sort=-committerdate${includeOrigin}`, false); + + // build a deduped, ordered map of branch-name -> entry, where a branch that + // lives in another worktree becomes a 'worktree' entry (with its path). + const seen = new Map(); + let currentName = null; + for (const rawLine of gitBranchOut.replace(/\r/g, "").trim().split("\n")) { + if (!rawLine) continue; + const isCurrent = rawLine.startsWith("*"); // current branch in this worktree + const isOtherWorktree = rawLine.startsWith("+"); // branch checked out in another worktree + // drop the 2-char marker column ('* ', '+ ', ' ') and the remotes/origin/ prefix + const clean = rawLine.slice(2).trim().replace(/^remotes\/origin\//, ""); + if (!clean || clean.includes("->")) continue; // skip the 'HEAD -> origin/master' pointer + if (isCurrent) currentName = clean; + + const wtPath = byBranch.get(clean); + const asWorktree = !isCurrent && (isOtherWorktree || wtPath) && wtPath; + const existing = seen.get(clean); + if (!existing) { + seen.set(clean, asWorktree + ? { type: "worktree", branch: clean, path: wtPath } + : { type: "branch", branch: clean }); + } else if (existing.type === "branch" && asWorktree) { + // a later line revealed this branch is actually in a worktree — upgrade it + seen.set(clean, { type: "worktree", branch: clean, path: wtPath }); + } + } + + const entries = [ + ...seen.values(), + ...detached.map((wt) => ({ type: "worktree", branch: null, path: wt.path, head: wt.head })), + ]; + const choices = entries.map((e) => ({ name: displayName(e), value: e })); + + if (deleteBranches) { + return execDelete(choices); + } + + const defaultEntry = entries.find((e) => e.type === "branch" && e.branch === currentName); + const { chosen } = await inquirer.prompt([ + { + type: "autocomplete", + message: "Select branch/worktree:", + name: "chosen", + source: (answersSoFar, input) => { + return Promise.resolve(!input ? choices : choices.filter((c) => c.name.includes(input))); + }, + pageSize: 10, + default: defaultEntry, + } + ]); + + if (chosen.type === "worktree") { + openShell(chosen.path); + } else { + await execute(`git checkout ${chosen.branch}`); + } + +})() + .catch((e) => { + console.log(e.message || e); + process.exit(1); + }); diff --git a/package.json b/package.json index 5bfa90e..07375b2 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,5 @@ "url": "git+https://github.com/idobh2/node-gitcheckout-cli.git" }, "scripts": {}, - "version": "1.0.7" + "version": "1.1.0" }