Skip to content
Open
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
Expand All @@ -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`
Expand Down
292 changes: 194 additions & 98 deletions index.js
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
"url": "git+https://github.com/idobh2/node-gitcheckout-cli.git"
},
"scripts": {},
"version": "1.0.8"
"version": "1.0.9"
}