📝

[Linux][Node.js]インタラクティブにgitのブランチを削除するCLIツールを作ってみた

に公開

TL;DR

  1. 標準ストリームを監視
  2. ターミナルをRaw Modeに変更
  3. Node.jsexecSync()gitコマンドを実行
  4. stdout.write()でターミナルに描画
この記事で作成する最終的なコード
gitInteract.js
#!/usr/bin/env node
const { execSync } = require("child_process");

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";
const ENTER = "\r";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

const MOUSE_SCROLL_EVENT_START = "\x1b[?1006h";
const MOUSE_SCROLL_EVENT_END = "\x1b[?1006l";

const DRAG_EVENT_START = "\x1b[?1002h";
const DRAG_EVENT_END = "\x1b[?1002l";

const INVERT_COLORS = "\x1b[7m";
const RESET_COLORS = "\x1b[0m";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(MOUSE_SCROLL_EVENT_START);
  stdout.write(DRAG_EVENT_START);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  const branches = getGitBranches();
  let currentIndex = 0;
  let mode = "normal";
  render(currentIndex, mode, branches);

  stdin.on("data", (key) => {
    throttleMouseScrollEvent(key, stdout, () => {
      render(currentIndex, mode, branches);
    });

    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(MOUSE_SCROLL_EVENT_END);
        stdout.write(DRAG_EVENT_END);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ENTER:
        if (mode === "normal") {
          mode = "deleteStandby";
        } else if (mode === "deleteStandby") {
          deleteBranch(branches, currentIndex);
          if (currentIndex >= branches.length) {
            currentIndex = branches.length - 1;
          }
          mode = "normal";
        }
        render(currentIndex, mode, branches);
        break;
      case "f": // ブランチを強制削除
        if (mode === "deleteStandby") {
          deleteBranch(branches, currentIndex, true);
          if (currentIndex >= branches.length) {
            currentIndex = branches.length - 1;
          }
          mode = "normal";
          render(currentIndex, mode, branches);
        }
        break;
      case ARROW_UP:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? branches.length - 1 : currentIndex - 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      case ARROW_DOWN:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === branches.length - 1 ? 0 : currentIndex + 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex, mode = "normal", branches) {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, branches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  if (branches.length === 0) {
    stdout.write("Error: No branches found.");
  } else {
    for (let i = startIndex; i < endIndex; i++) {
      const branch = branches[i];
      const prefix = branch === branches[currentIndex] ? ">" : " ";
      const lineBreak = i === endIndex - 1 ? "" : "\n";
      const deleteStandbyMessage = (() => {
        const startLineBreak = i !== endIndex - 1 ? "" : "\n";
        return i === currentIndex && mode === "deleteStandby"
          ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
          : "";
      })();
      stdout.write(`${prefix} ${branch}${lineBreak}`);
      stdout.write(deleteStandbyMessage);
    }
  }
}

function getGitBranches() {
  const branches = [];
  try {
    const output = execSync("git branch", { encoding: "utf-8" });
    console.log(output);

    output.split("\n").forEach((line) => {
      const trimmedLine = line.trim();
      if (trimmedLine.length > 0) {
        branches.push(trimmedLine);
      }
    });

    return branches;
  } catch (error) {
    console.error("Error fetching git branches:", error.message);
    return branches;
  }
}

function deleteBranch(branches, currentIndex, forceDelete = false) {
  const forceDeleteFlag = forceDelete ? "-D" : "-d";
  try {
    execSync(`git branch ${forceDeleteFlag} "${branches[currentIndex]}"`, {
      encoding: "utf-8",
    });

    const updatedBranches = getGitBranches();
    branches.splice(0, branches.length, ...updatedBranches);
  } catch (error) {
    console.error("Error deleting git branch:", error.message);
  }
}

let isFlashing = false;
let flashTimeout = null;
function throttleMouseScrollEvent(key, stdout, onRestore) {
  if (key.includes("\x1b[<") && (key.includes("64;") || key.includes("65;"))) {
    if (isFlashing) {
      return;
    }
    isFlashing = true;

    stdout.write(CLEAR_SCREEN);
    stdout.write(MOVE_CURSOR_HOME);
    stdout.write(INVERT_COLORS);
    const rows = process.stdout.rows || 24;
    const cols = process.stdout.columns || 80;
    let fullScreen = "";
    for (let i = 0; i < rows; i++) {
      fullScreen += " ".repeat(cols) + "\n";
    }
    stdout.write(fullScreen);

    if (flashTimeout) {
      clearTimeout(flashTimeout);
    }

    flashTimeout = setTimeout(() => {
      stdout.write(RESET_COLORS);
      onRestore();
      setTimeout(() => {
        isFlashing = false;
        flashTimeout = null;
      }, 1500);
    }, 100);
    return;
  }
}

main();

想定読者

  • ローカルのgitリポジトリに不要になったブランチが溢れている方
  • git branch -d <branch>を毎回実行するのが面倒だと感じている方

背景

gitでリポジトリを管理していると、作業の合間でブランチを切ることが多いと思います。
他のブランチに影響を与えずに、独自の作業スペースのようなものを作ることのできる便利な機能です。

ただ、作成したブランチはメインブランチを除いて最終的には不要となる場合がほとんどです。

リモートブランチはgithubの機能を設定すれば自動で消去できますが、
ローカルのブランチはgit branch -d <branch>を利用して1つずつ削除する必要があります。

私はこのgit branch -d <branch>を行う作業が苦手で、
気づけば30個以上の不要なブランチがローカルのgitリポジトリに存在する状態になっていました。

今後の作業の障害になることはあまりないと思うのですが、
ふとgit branchを実行した時の見た目がイマイチに感じたので、
気軽にブランチの削除を行えるCLIツールを作ってみました。

今回作成するツールの要件

今回ブランチを削除するCLIツールを作成するにあたって、
個人的に「こうなっていたらいいな」という動作を以下のようにまとめました。

  1. ブランチが一行ごとに表示される
develop
staging
branch
  1. 削除したいブランチをカーソルで指し示すことができる
> develop
  staging
  branch
  1. 方向キーでカーソルを移動できる
> develop
  staging
  branch

// 方向キー下を押下
  develop
> staging
  branch
  1. Enterキーを押下すると削除確認メッセージが表示される(まだ削除は実行しない)
> develop
>> ブランチを削除しますか?
  staging
  branch
  1. 削除確認メッセージが表示された状態で再度Enterキーを押下すると削除が実行される
> develop
>> ブランチを削除しますか?
  staging
  branch

// Enterキーを再度押下(developが削除される)
> staging
  branch

このイメージをもとに実装を進めます。

1. 実行ファイルの作成


まずはじめに、ファイルを実行したらターミナルにHello world!と表示されるようにします。

今回はホームディレクトリの直下にファイルを作成します。

% cd
% touch gitInteract.js

続いて、ファイルを編集しconsole.log()Hello world!を出力できるようにします。
shebangで記載するインタプリタにはnodeを指定しておきます。
https://ja.wikipedia.org/wiki/シバン_(Unix)

gitInteract.js
#!/usr/bin/env node

function main() {
  console.log("Hello world!");
}

main();

次に、このファイルを実行可能なファイルに変更します。
実行可能なファイルにする際はchmodを利用します。
https://qiita.com/shisama/items/5f4c4fa768642aad9e06

chmod +x gitInteract.js
/// chmod: change modeコマンド。ファイルやディレクトリに対するアクセス権限を変更できる
/// +x: 対象をプログラムとして実行可能にする

ファイルを実行し、Hello world!と出力されることを確認しましょう。

% ./gitInteract.js
Hello world!
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node

function main() {
  console.log("Hello world!");
}

main();

2. キー入力を監視する


次に、キー入力を監視し、キーの入力内容に応じた処理を実行できるようにします。

キー入力は標準入力を監視することで確認できます。
標準入力の監視はprocess.stdinで行うことができます。
https://nodejs.org/api/process.html#processstdin

const stdin = process.stdin;

また、stdin.onを利用することで、標準入力に流れてきたデータを受け取ることができます。
std.onを使用する際はどのイベントを監視するかを第一引数に指定する必要があります。
今回はキー入力を検知したいので、dataイベントを指定します。

function main() {
  const stdin = process.stdin;

  stdin.on("data", (key) => {
    console.log("You pressed:", key);
  });
}

ファイルを実行し、入力したキーの内容に合わせて文字が出力されることを確認しましょう。

% ~/gitInteract.js
abc // 最後にEnterキーを押下する必要がある
You pressed: <Buffer 61 62 63 0a>
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node

function main() {
  const stdin = process.stdin;

  stdin.on("data", (key) => {
    console.log("You pressed:", key);
  });
}

main();

3. Raw Modeを有効にする


現在の状態だとキー入力を行った後にEnterキーを押下しないと処理が実行されません。
これはターミナルのデフォルトのモードではキー入力を一時保管(バッファリング)するためです。
一時保管された入力情報はEnterキー(\n)を入力した際にプログラムに送信されます。

今回は方向キーの入力に合わせてカーソルを移動するような処理を即座に行えるようにしたいです。
そのため、ターミナルのモードを「キー入力を1文字ずつ直接プログラムに送信するモード(Raw Mode)」を有効にします。
Raw Modestdin.setRawMode()で有効・無効の切り替えが可能です。
https://nodejs.org/api/tty.html#readstreamsetrawmodemode

function main() {
  const stdin = process.stdin;
  stdin.setRawMode(true); // ターミナルをRaw Modeにする

  stdin.on("data", (key) => {
    console.log("You pressed:", key);
  });
}

ファイルを実行し、
キー入力に合わせて即座に文字列が出力されることを確認しましょう。

% ~/gitInteract.js
You pressed: <Buffer 61> // aを押下
You pressed: <Buffer 62> // bを押下
You pressed: <Buffer 63> // cを押下
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node

function main() {
  const stdin = process.stdin;
  stdin.setRawMode(true);

  stdin.on("data", (key) => {
    console.log("You pressed:", key);
  });
}

main();

4. ctrl+cでファイルの実行を終了する

前節でRaw Modeを有効化しました。
しかし、現在の状態だとctrl+cqを入力しても以下のように文字として出力されてしまい、
ファイルを終了する処理として実行されません。

You pressed: <Buffer 03> // ctrl+cを押下

Raw Modectrl+cでプログラムを終了できるようにするためには自身で機能を実装する必要があります。
実際に実装してみましょう。

まず、標準入力のデータをバッファーオブジェクト(Buffer)ではなくUTF-8にエンコーディングします。
これにより、人間が読みやすい表現で条件分岐を記述できます。

function main() {
  const stdin = process.stdin;
  stdin.setEncoding("utf8"); // 標準入力で受け取ったデータをUTF-8に変換
  stdin.setRawMode(true);

  stdin.on("data", (key) => {
    ...

次に、ctrl+cが入力された際に実行する処理を記述します。
今回作成するツールでは複数のキーを利用する相対のため、
switch文で処理を記述するようにします。

ファイルの実行の終了はprocess.exit()で行います。

const CTRL_C = "\u0003"; // Unicodeエスケープシーケンスでctrl+cを記述

function main(){
  ...
  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C: // ctrl+cが入力された場合の処理
        stdin.setRawMode(false); // Raw Modeを無効化
        process.exit(); // ファイルの実行を終了
        break;
      default:
        console.log("You pressed:", key);
        break;
    }
  });
}

このとき、Raw Modeを無効化することを忘れないようにしましょう。
Raw Modeのまま実行を終了すると、ターミナルがRaw Modeのまま残ってしまい正常に操作できなくなります。

変更を行ったらファイルを実行し、
ctrl+c以外を入力した際は文字列が出力され、ctrl+cが入力された際はプログラムが終了することを確認しましょう。

% ~/gitInteract.js
You pressed: a
You pressed: b
You pressed: c
%

また、qの入力でスクリプトを終了させたい場合はctrl+cと同様の処理を実行させるようにケースを追加します。

stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case 'q': // qでもスクリプトを終了可能に
        stdin.setRawMode(false);
        process.exit();
        break;
      default:
        console.log("You pressed:", key);
        break;
    }
});
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node
const CTRL_C = "\u0003";

function main() {
  const stdin = process.stdin;
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdin.setRawMode(false);
        process.exit();
        break;
      default:
        break;
    }
  });
}

main();

5. 方向キーの入力に合わせて文字を出力


前節でctrl+cを入力した時にスクリプトが終了できるようにしました。
同様の手順で、方向キーを入力した際に処理が実行されるようにしましょう。

方向キーの入力はANSIエスケープシーケンスで記述します。

const ARROW_UP = "\x1b[A"; // 上矢印キー
const ARROW_DOWN = "\x1b[B"; // 下矢印キー

switch文に方向キーが入力された際のケースを追加します。
この段階ではconsole.log()を実行させるだけにします。

stdin.on("data", (key) => {
    switch (key) {
      ...
      case ARROW_UP:
        console.log("You pressed: Arrow Up");
        break;
      case ARROW_DOWN:
        console.log("You pressed: Arrow Down");
        break;
      ...
    }
});

ファイルを実行し、方向キーを押すとメッセージが表示されることを確認しましょう。

% ~/gitInteract.js
You pressed: Arrow Up
You pressed: Arrow Down
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node
#!/usr/bin/env node
const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A"; // 上矢印キー
const ARROW_DOWN = "\x1b[B"; // 下矢印キー

function main() {
  const stdin = process.stdin;
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdin.setRawMode(false);
        process.exit();
        break;
      case ARROW_UP:
        console.log("You pressed: Arrow Up");
        break;
      case ARROW_DOWN:
        console.log("You pressed: Arrow Down");
        break;
      default:
        break;
    }
  });
}

main();

6. リストの描画


次に、ブランチのリストをターミナル上に描画できるようにします。

この段階ではgit branchで取得したものではなく、
ファイル内で定義したダミーデータを利用します。
(最初からgit branchを使おうとすると実装する内容が複雑になってしまうため、回避しています)

const dummyBranches = ["develop", "staging", "production"];

配列のforEach()メソッドを利用して、
要素をひとつずつconsole.log()でターミナルに表示します。

function main() {
  ...
  stdin.setRawMode(true);

  dummyBranches.forEach((branch) => {
    console.log(`${branch}`);
  });

  stdin.on("data", (key) => {
    ...
  });
}

ファイルを実行し、リストが表示されることを確認します。

% ~/gitInteract.js
develop
staging
production
|

また、現在リストを出力した際にパイプ(|)が表示されていますが、
今回作成するツールでは>でユーザーが現在操作を行っている位置を示す想定のため、
パイプの表示は必要なさそうです。

標準出力にパイプを非表示にするよう命令しましょう。
Raw Modeと同様に、実行を終了する際にパイプを表示にする命令を行わないとパイプが非表示のままになってしまうため注意してください。

const HIDE_PIPE = "\x1b[?25l"; // パイプを非表示
const SHOW_ = "\x1b[?25h"; // パイプを表示

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;

  stdout.write(HIDE_PIPE); // パイプを非表示にする

  ...

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(SHOW_PIPE); // パイプを表示する
        stdin.setRawMode(false);
        process.exit();
        break;
}

また、現在スクリプトを実行した際にproductionの下に余計な改行が行われていると思います。

% ~/gitInteract.js
develop
staging
production
 // この行が不要

これはconsole.log()が文字の出力と改行を同時に行なっているためです。
リストの最終行でも改行が実行されてしまった結果、余分な表示が生まれています。

このような場合はconcole.log()ではなくstdout.write()を利用し、
任意のタイミングで改行を実行できるようにしましょう。

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  dummyBranches.forEach((branch, index) => {
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n"; // 配列の最後の要素の場合は改行文字を挿入しないようにする
    stdout.write(`${branch}${lineBreak}`);
  });

  ...
}

最後にファイルを実行し、
キャレットが表示されないことと、余計な改行が行われないことを確認しましょう。

% ~/gitInteract.js
develop
staging
production
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node
const dummyBranches = ["develop", "staging", "production"];

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  dummyBranches.forEach((branch, index) => {
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${branch}${lineBreak}`);
  });

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(SHOW_PIPE);
        stdin.setRawMode(false);
        process.exit();
        break;
      case ARROW_UP:
        console.log("You pressed: Arrow Up");
        break;
      case ARROW_DOWN:
        console.log("You pressed: Arrow Down");
        break;
      default:
        console.log("You pressed:", key);
        break;
    }
  });
}

main();

7. カーソル(>)を表示する


前節でリストを表示できるようにしたので、
続いてカーソル(>)を表示できるようにします。

カーソルを表示する際は「カーソルがリストのどの位置にいるか」を管理し、
表示条件を満たした際にカーソルが表示させる必要があります。
また、方向キーが押下された際にカーソルの位置が移動できるようにする機能も必要です。

表示条件から考えると、
下記のようにカーソルの位置情報とその位置にあるリストの要素の名前を比較し、
一致している場合にカーソルを表示すると良さそうです。

let currentIndex = 0; // 現在フォーカスしている要素のインデックスを管理
dummyBranches.forEach((branch, index) => {
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " "; // forEachで参照しているブランチとcurrentIndexのブランチが一致した場合はカーソルを表示する
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  });
});

ファイルを実行するとカーソルがdevelopの位置に表示されることを確認できます。

% ~/gitInteract.js
> develop
  staging
  production

次に、方向キーが押下された際にカーソルの位置が移動できるようにする機能を実装します。
これはcurrentIndexを方向キーが押されたタイミングで増減させることで実現できそうです。ARROW_UPARROW_DOWNのケースに変更を加えます。

  stdin.on("data", (key) => {
    switch (key) {
      ...
      case ARROW_UP:
        currentIndex = currentIndex - 1
        break;
      case ARROW_DOWN:
        currentIndex = currentIndex + 1
        break;
      default:
        break;
    }
  });

ファイルを実行し、方向キーを押下してみましょう。

% ~/gitInteract.js
> develop
  staging
  production

方向キーを押してもカーソルが移動しないと思います。
これはcurrentIndexの更新後、リストを再描画していないためです。

リストを描画する際の処理をmain()から切り出し、
新しくrender()関数として定義します。

function render(currentIndex) {
  dummyBranches.forEach((branch, index) => {
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  });
}

render()を利用して、プログラム起動時の描画と方向キーを押下した時の描画を行うように変更します。

function main() {
  ...

  stdin.on("data", (key) => {
    switch (key) {
      ...
      case ARROW_UP:
        ...
        // ブランチを再描画
        render(currentIndex);
        break;
      case ARROW_DOWN:
        ...
        // ブランチを再描画
        render(currentIndex);
        break;
      ...
    }
  });
}

ファイルを実行し、方向キーを押下すると下記のような表示になります。

% ~/gitInteract.js
> develop
  staging
  production
  develop
> staging
  production
  develop
  staging

カーソルが移動することを確認できましたが、
前回描画したリストが表示されたままになってしまっています。

今回は最新のリストのみ表示できれば良いので、
render()を実行する際に一度画面全体をクリアするように変更します。

const CLEAR_SCREEN = "\x1b[2J"; // 画面全体をクリア
const MOVE_CURSOR_HOME = "\x1b[H"; // カーソルをホームポジションに移動

...
function render(currentIndex) {
  const stdout = process.stdout;
  stdout.write(CLEAR_SCREEN); // リストの描画前に画面全体をクリア
  stdout.write(MOVE_CURSOR_HOME); // カーソルをホームポジションに移動させておく

  dummyBranches.forEach((branch, index) => {
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  });
}

ファイルを実行し、
方向キーを何回押下しても最新の状態のリストだけが表示されることを確認します。

% ~/gitInteract.js
  develop
> staging
  production

また、ブランチがひとつしかないときはカーソルの移動は不要になるのでcurrentIndexを増減させないようにしておきます。

  stdin.on("data", (key) => {
    switch (key) {
      ...
      case ARROW_UP:
        if (dummyBranches.length <= 1) { // ブランチが1つ以下の場合は以降の処理をスキップ
          return;
        }
        ...
      case ARROW_DOWN:
        if (dummyBranches.length <= 1) {  // ブランチが1つ以下の場合は以降の処理をスキップ
          return;
        }
        ...
      ...
    }
  });

おまけに、スクリプトを実行中であることを視覚的にわかりやすくしたいためヘッダーを表示するようにします。

function render(currentIndex) {
  const stdout = process.stdout;
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n"); // ヘッダーを表示
  dummyBranches.forEach((branch, index) => {
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  });
}

ファイルを実行し、ヘッダーが表示されることを確認しましょう。

% ~/gitInteract.js
===== Git Interact =====
> develop
  staging
  production
このステップでの最終的なコード
gitInteractor.js
#!/usr/bin/env node
const dummyBranches = ["develop", "staging", "production"];

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  let currentIndex = 0;
  render(currentIndex);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ARROW_UP:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? dummyBranches.length - 1 : currentIndex - 1;
        render(currentIndex);
        break;
      case ARROW_DOWN:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === dummyBranches.length - 1 ? 0 : currentIndex + 1;
        render(currentIndex);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex) {
  const stdout = process.stdout;
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  dummyBranches.forEach((branch, index) => {
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  });
}

main();

8. 代替画面を利用する


現在の処理だとリストを描画する際にターミナルの表示を全てクリアしているため、
過去に実行したコマンドの履歴が全て消去されています。

コマンドの実行履歴は残すようにしたいので、
代替画面バッファを利用してファイルを実行するようにします。

代替画面バッファは現在開いているターミナルの画面とは異なる画面を表示できるようにするものです。
RawModeと同様に、プログラムの起動時に代替画面バッファに入るよう命令し、終了する際は代替画面バッファから出るように命令します。

const ENTER_ALT_SCREEN = "\x1b[?1049h"; // 代替画面バッファに入る
const EXIT_ALT_SCREEN = "\x1b[?1049l"; // 代替画面バッファから出る

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;

  stdout.write(ENTER_ALT_SCREEN); // 代替画面バッファに入る
  stdout.write(HIDE_CURSOR); // カーソルを非表示にする

  ...

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN); // 代替画面バッファから出る
        stdout.write(SHOW_CURSOR); // カーソルを表示する
        stdin.setRawMode(false);
        process.exit();
        break;

この状態でプログラムを実行 > 終了すると、コマンドの履歴が消えずに残っていることを確認できます。

% cd
% ~/gitInteract.js
% 
このステップでの最終的なコード
gitInteract.js
#!/usr/bin/env node
const dummyBranches = ["develop", "staging", "production"];

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  let currentIndex = 0;
  render(currentIndex);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ARROW_UP:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? dummyBranches.length - 1 : currentIndex - 1;
        render(currentIndex);
        break;
      case ARROW_DOWN:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === dummyBranches.length - 1 ? 0 : currentIndex + 1;
        render(currentIndex);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex) {
  const stdout = process.stdout;
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  dummyBranches.forEach((branch, index) => {
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = index === dummyBranches.length - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  });
}

main();

9. ターミナルの最大行数に合わせてリストを出力する


ブランチを出力できるようにしましたが、
現在の状態だとブランチの数がターミナルの表示可能行数を超えた際、
画面外のブランチにカーソルを移動させても画面が追従してスクロールするようにはなっていません。

ダミーデータのブランチ数を増やしてファイルを実行してみましょう。
(ターミナルのウィンドウの高さを狭めることでも同様の事象を確認できます)

ブランチ数を増やしたダミーデータ

const dummyBranches = [
"develop-1",
"develop-2",
"develop-3",
"develop-4",
"develop-5",
"staging-1",
"staging-2",
"staging-3",
"staging-4",
"staging-5",
"production-1",
"production-2",
"production-3",
"production-4",
"production-5",
];

この状態でスクリプトを実行し方向キーでカーソルを移動させてみても、
画面に表示されるのは常にリストの末尾部分のみです。
*この例ではターミナルに表示される行数が5行になるように高さを調整しています

% ~/gitInteract.js
  production-1
  production-2
  production-3
  production-4
  production-5

この問題はターミナルの表示可能行数を元にリストを描画することで解決できます。
ターミナルの表示可能行数をprocess.stdout.rowsで取得し、
rowsの数だけブランチを描画するように変更します。

function render(currentIndex) {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows; // ターミナルの表示可能行数を取得
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  for (let i = 0; i < terminalHeight; i++) { // ターミナルの表示可能行数に到達するまで繰り返し実行
    const branch = dummyBranches[i]; // indexを指定してリストから直接参照
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak =
      i === terminalHeight - 1 ? "" : "\n"; // ターミナルの表示可能行数に到達した場合は改行しないようにする
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  }
}

ファイルを実行して表示を確認します。

% ~/gitInteract.js
> develop-1
  develop-2
  develop-3
  develop-4
  develop-5

リストが頭の方から表示されるようになりましたが、ヘッダーが表示されていません。
ヘッダーの行数を考慮して、リストを描画するように変更します。

function renderBranches(index) {
  ...
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;

  stdout.write("===== Git Interact =====\n");
  for (let i = 0; i < availableHeight; i++) {
    ...
    const lineBreak =
      i === availableHeight - 1 ? "" : "\n";
    ...
  }
}

ファイルを実行してヘッダーが表示されることを確認します。

===== Git Interact =====
> develop-1
  develop-2
  develop-3
  develop-4

スクリプト起動時の描画は期待した通りになりました。

次に、現在画面上に描画されていないブランチにカーソルが移動した際に、
リストがスクロールし描画が行われるようにしましょう。

現在はブランチを描画する際の開始位置がi=0になっているため、必ず要素の0番目から描画が行われるようになっています。
カーソルがリストの末端にある場合、方向キーが押された際にindexを更新するような挙動に変更します。

function render(currentIndex) {
  ...
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    // 方向キー下を押してスクロールが必要になった場合
    startIndex = currentIndex - availableHeight + 1; // indexは0はじまりのため+1する
  }

  // リストの終端を調整
  // ① ブランチの数がターミナルの表示可能行数より小さい場合は、ブランチの長さを終端にする
  // ② ブランチの数がターミナルの表示可能行数より多い場合は、スクロール位置から表示可能行数分を終端にする
  const endIndex = Math.min(startIndex + availableHeight, dummyBranches.length);

  ...

  for (let i = startIndex; i < endIndex; i++) { // startIndexとendIndexを利用するように変更
    const branch = dummyBranches[i];
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = i === endIndex - 1 ? "" : "\n"; // endIndexを利用するように変更
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  }

ファイルを実行し、描画されていないブランチにカーソルを移動させた際に表示がスクロールすることを確認しましょう。

===== Git Interact =====
  staging-3
  staging-4
  staging-5
> production-1
このステップでの最終的なコード
gitInteract.js
#!/usr/bin/env node
const dummyBranches = [
  "develop-1",
  "develop-2",
  "develop-3",
  "develop-4",
  "develop-5",
  "staging-1",
  "staging-2",
  "staging-3",
  "staging-4",
  "staging-5",
  "production-1",
  "production-2",
  "production-3",
  "production-4",
  "production-5",
];

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  let currentIndex = 0;
  render(currentIndex);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ARROW_UP:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? dummyBranches.length - 1 : currentIndex - 1;
        render(currentIndex);
        break;
      case ARROW_DOWN:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === dummyBranches.length - 1 ? 0 : currentIndex + 1;
        render(currentIndex);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex) {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, dummyBranches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  for (let i = startIndex; i < endIndex; i++) {
    const branch = dummyBranches[i];
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = i === endIndex - 1 ? "" : "\n";
    stdout.write(`${prefix} ${branch}${lineBreak}`);
  }
}

main();

10. Enterキーを押下した時に削除確認メッセージを表示する


リストの表示とカーソルの移動の実装を行うことができました。

続いて、カーソルが位置しているブランチを削除する実装を行います。
今回作成するスクリプトでは以下の手順でブランチを削除できるようにします。

  1. 方向キー押下でカーソルを移動
  2. 削除したいブランチにカーソルを合わせた状態でEnterキーを押下
  3. 削除確認メッセージを表示
  4. 再度Enterキーを押下
  5. ブランチの削除を実行
  6. ブランチを再描画

1は実装済みなので、2以降を実装しましょう。

まずはEnterキーを押下した際に削除確認メッセージを表示できるようにしましょう。
削除確認メッセージはEnterキーを押下したブランチの1行下に表示されるようにします。

render()関数にmodeという状態を持たせ、
Enterキーを押下したら削除待機状態に移行 > 削除待機状態の時は削除確認メッセージが描画されるようにrender()関数を変更します。

function render(index, mode = "normal") { // modeをもとにリストを描画。初期値は通常(normal)にする
  ...
  for (let i = startIndex; i < endIndex; i++) {
    ...
    const deleteStandbyMessage = (() => { // modeがdeleteStandyのときにメッセージを表示
      const startLineBreak = i !== endIndex - 1 ? "" : "\n";
      return i === currentIndex && mode === "deleteStandby"
        ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
        : "";
    })();
    stdout.write(`${prefix} ${branch}${lineBreak}`);
    stdout.write(deleteStandbyMessage);
  }
}

Enterキーを押下した際にdeleteStandbyという引数を渡すように変更します。

const ENTER = "\r";
...
function main(){
  ...
  stdin.on("data", (key) => {
      ...
      case ENTER:
        render(currentIndex, "deleteStandby"); // Enterキーを押下したら削除待機状態に移行
        break;
  ...
}

ファイルを実行し、
Enterキーを押下した際にカーソルの位置にあるブランチの1行後ろに削除確認メッセージが表示されることを確認します。

> develop-1
>> Press Enter to delete:
  develop-2
  develop-3
このステップでの最終的なコード
gitInteract.js
#!/usr/bin/env node
const dummyBranches = [
  "develop-1",
  "develop-2",
  "develop-3",
  "develop-4",
  "develop-5",
  "staging-1",
  "staging-2",
  "staging-3",
  "staging-4",
  "staging-5",
  "production-1",
  "production-2",
  "production-3",
  "production-4",
  "production-5",
];

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";
const ENTER = "\r";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  let currentIndex = 0;
  render(currentIndex);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ENTER:
        render(currentIndex, "deleteStandby");
        break;
      case ARROW_UP:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? dummyBranches.length - 1 : currentIndex - 1;
        render(currentIndex);
        break;
      case ARROW_DOWN:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === dummyBranches.length - 1 ? 0 : currentIndex + 1;
        render(currentIndex);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex, mode = "normal") {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, dummyBranches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  for (let i = startIndex; i < endIndex; i++) {
    const branch = dummyBranches[i];
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = i === endIndex - 1 ? "" : "\n";
    const deleteStandbyMessage = (() => {
      const startLineBreak = i !== endIndex - 1 ? "" : "\n";
      return i === currentIndex && mode === "deleteStandby"
        ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
        : "";
    })();
    stdout.write(`${prefix} ${branch}${lineBreak}`);
    stdout.write(deleteStandbyMessage);
  }
}

main();

11. Enterキーを押下した時に削除確認メッセージを表示する


続いて削除確認メッセージが表示されている時にEnterキーを再度押下したら該当のブランチが削除されるようにしましょう。

先ほどmodeという状態を作ったので、それを利用して削除判定を行うようにします。
削除を行った後は更新後のリストでrender()を実行し、画面の描画を更新します。

function main() {
  ...
  let mode = "normal"; // main()でmodeを定義
  stdin.on("data", (key) => {
    switch (key) {
      ...
      case ENTER:
        if (mode === "normal") { // 通常モードの時は削除待機モードに移行
          mode = "deleteStandby";
        } else if (mode === "deleteStandby") { // 削除待機モードの時は削除を実行
          dummyBranches.splice(currentIndex, 1); // 削除を実行
          if (currentIndex >= dummyBranches.length) { // 削除後にカーソルの位置を移動
            currentIndex = dummyBranches.length - 1;
          }
          mode = "normal"; // 通常モードに移行
        }
        render(currentIndex, mode);
        break;
      case ARROW_UP:
        ...
        mode = "normal"; // 削除待機モードの場合に方向キーを押下した場合は通常モードに移行させる
        render(currentIndex, mode);
        break;
      case ARROW_DOWN:
        ...
        mode = "normal"; // 削除待機モードの場合に方向キーを押下した場合は通常モードに移行させる
        render(currentIndex, mode);
        break;
  ...
}

ファイルを実行し、
削除確認メッセージが表示されている状態でEnterキーを押下したときにカーソルの位置にあるブランチが削除されることを確認しましょう。

===== Git Interact =====
  develop-1
  develop-2
> develop-4 // develop-3にカーソルが位置している状態でEnterキーを2回押下した場合
このステップでの最終的なコード
gitInteract.js
#!/usr/bin/env node
const dummyBranches = [
  "develop-1",
  "develop-2",
  "develop-3",
  "develop-4",
  "develop-5",
  "staging-1",
  "staging-2",
  "staging-3",
  "staging-4",
  "staging-5",
  "production-1",
  "production-2",
  "production-3",
  "production-4",
  "production-5",
];

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";
const ENTER = "\r";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  let currentIndex = 0;
  render(currentIndex);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ENTER:
        if (mode === "normal") {
          mode = "deleteStandby";
        } else if (mode === "deleteStandby") {
          dummyBranches.splice(currentIndex, 1);
          if (currentIndex >= dummyBranches.length) {
            currentIndex = dummyBranches.length - 1;
          }
          mode = "normal";
        }
        render(currentIndex, mode);
        break;
      case ARROW_UP:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? dummyBranches.length - 1 : currentIndex - 1;
        mode = "normal";
        render(currentIndex, mode);
        break;
      case ARROW_DOWN:
        if (dummyBranches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === dummyBranches.length - 1 ? 0 : currentIndex + 1;
        mode = "normal";
        render(currentIndex, mode);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex, mode = "normal") {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, dummyBranches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  for (let i = startIndex; i < endIndex; i++) {
    const branch = dummyBranches[i];
    const prefix = branch === dummyBranches[currentIndex] ? ">" : " ";
    const lineBreak = i === endIndex - 1 ? "" : "\n";
    const deleteStandbyMessage = (() => {
      const startLineBreak = i !== endIndex - 1 ? "" : "\n";
      return i === currentIndex && mode === "deleteStandby"
        ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
        : "";
    })();
    stdout.write(`${prefix} ${branch}${lineBreak}`);
    stdout.write(deleteStandbyMessage);
  }
}

main();

12. git branchでブランチを取得する


前節までのステップでダミーデータを利用した必要な機能の実装が完了しました。
ここからは実際のgitのブランチを対象に処理を行えるように既存のコードに修正を加えていきましょう。

まずはgit branchを実行した時に出力されたリストをgitInteract.jsで表示できるようにします。
gitなどのコマンドを実行した際の結果を利用するにはNode.jsexecSyncを利用します。

execSyncを利用する際はimportが必要になるので、ファイルの冒頭で`import``文を記述するようにします。

import { execSync } from "child_process";
...
function getGitBranches() {
  const branches = [];
  try {
    const output = execSync("git branch", { encoding: "utf-8" }); // git branchで実行した結果をutf-8にエンコーディング

    output.split("\n").forEach((line) => { // 結果ごとに改行文字を挿入
      const trimmedLine = line.trim(); // 不要なスペースを削除
      if (trimmedLine.length > 0) {
        branches.push(trimmedLine);
      }
    });

    return branches;
  } catch (error) { // ブランチ取得時に問題が発生した際に出力
    console.error("Error fetching git branches:", error.message);
    return branches;
  }
}

getGitBranches()を利用して各種処理を行います。
dummyBranchesgetGitBranches()を代入したbranchesで置き換えるだけなので、
説明は省略します。
*削除処理はgit branch -dを利用する必要があるため、一度コメントアウトしてください

修正後のコード
gitInteract.js
#!/usr/bin/env node
const { execSync } = require("child_process");

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";
const ENTER = "\r";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  const branches = getGitBranches();
  let currentIndex = 0;
  let mode = "normal";
  render(currentIndex, mode, branches);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ENTER:
        if (mode === "normal") {
          mode = "deleteStandby";
        } else if (mode === "deleteStandby") {
          //   dummyBranches.splice(currentIndex, 1);
          //   if (currentIndex >= dummyBranches.length) {
          //     currentIndex = dummyBranches.length - 1;
          //   }
          mode = "normal";
        }
        render(currentIndex, mode, branches);
        break;
      case ARROW_UP:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? branches.length - 1 : currentIndex - 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      case ARROW_DOWN:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === branches.length - 1 ? 0 : currentIndex + 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex, mode = "normal", branches) {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, branches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  if (branches.length === 0) {
    stdout.write("Error: No branches found.");
  } else {
    for (let i = startIndex; i < endIndex; i++) {
      const branch = branches[i];
      const prefix = branch === branches[currentIndex] ? ">" : " ";
      const lineBreak = i === endIndex - 1 ? "" : "\n";
      const deleteStandbyMessage = (() => {
        const startLineBreak = i !== endIndex - 1 ? "" : "\n";
        return i === currentIndex && mode === "deleteStandby"
          ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
          : "";
      })();
      stdout.write(`${prefix} ${branch}${lineBreak}`);
      stdout.write(deleteStandbyMessage);
    }
  }
}

function getGitBranches() {
  const branches = [];
  try {
    const output = execSync("git branch", { encoding: "utf-8" });
    console.log(output);

    output.split("\n").forEach((line) => {
      const trimmedLine = line.trim();
      if (trimmedLine.length > 0) {
        branches.push(trimmedLine);
      }
    });

    return branches;
  } catch (error) {
    console.error("Error fetching git branches:", error.message);
    return branches;
  }
}

main();

gitで管理されたリポジトリでスクリプトを実行し、ブランチが表示されることを確認しましょう。

===== Git Interact =====
> * develop
  master
  staging

13. git branch -d <branch>でブランチを削除する


最後の大詰めです!
git branchと同様に、execSyncを利用してgit branch -d <branch>を実行できるようにします。
ブランチの状態によっては強制削除(-D)が必要になるので任意で実行できるようにしておきましょう。

function deleteBranch(branches, currentIndex, forceDelete = false) {
  const forceDeleteFlag = forceDelete ? "-D" : "-d"; // 引数で強制削除フラグをONにできるようにする
  try {
    execSync(`git branch ${forceDeleteFlag} "${branches[currentIndex]}"`, {
      encoding: "utf-8",
    }); // ブランチを削除

    // 削除後、ブランチを更新する処理
    const updatedBranches = getGitBranches(); // git branchでデータを再取得
    branches.splice(0, branches.length, ...updatedBranches); // 再取得したデータで置き換え
  } catch (error) {
    console.error("Error deleting git branch:", error.message);
  }
}

deleteBranch()関数を利用するように処理を修正します。
削除待機モード中にfキーを押下した際は強制削除が実行されるようにします。

function main(){
  ...
    stdin.on("data", (key) => {
        switch (key) {
          ...
          case ENTER:
            if (mode === "normal") {
              mode = "deleteStandby";
            } else if (mode === "deleteStandby") {
              deleteBranch(branches, currentIndex); // 削除を実行
              if (currentIndex >= branches.length) {
                currentIndex = branches.length - 1;
              }
              mode = "normal";
            }
            render(currentIndex, mode, branches);
            break;
          case "f": // ブランチを強制削除
            if (mode === "deleteStandby") {
              deleteBranch(branches, currentIndex, true); // 削除を実行
              if (currentIndex >= branches.length) {
                currentIndex = branches.length - 1;
              }
              mode = "normal";
              render(currentIndex, mode, branches);
            }
            break;
  ...
}

ファイルを実行し、Enterキー または fキーを押下した際にブランチが削除されることを確認しましょう。

このステップでの最終的なコード
gitInteract.js
#!/usr/bin/env node
const { execSync } = require("child_process");

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";
const ENTER = "\r";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  const branches = getGitBranches();
  let currentIndex = 0;
  let mode = "normal";
  render(currentIndex, mode, branches);

  stdin.on("data", (key) => {
    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ENTER:
        if (mode === "normal") {
          mode = "deleteStandby";
        } else if (mode === "deleteStandby") {
          deleteBranch(branches, currentIndex);
          if (currentIndex >= branches.length) {
            currentIndex = branches.length - 1;
          }
          mode = "normal";
        }
        render(currentIndex, mode, branches);
        break;
      case "f": // ブランチを強制削除
        if (mode === "deleteStandby") {
          deleteBranch(branches, currentIndex, true);
          if (currentIndex >= branches.length) {
            currentIndex = branches.length - 1;
          }
          mode = "normal";
          render(currentIndex, mode, branches);
        }
        break;
      case ARROW_UP:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? branches.length - 1 : currentIndex - 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      case ARROW_DOWN:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === branches.length - 1 ? 0 : currentIndex + 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex, mode = "normal", branches) {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, branches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  if (branches.length === 0) {
    stdout.write("Error: No branches found.");
  } else {
    for (let i = startIndex; i < endIndex; i++) {
      const branch = branches[i];
      const prefix = branch === branches[currentIndex] ? ">" : " ";
      const lineBreak = i === endIndex - 1 ? "" : "\n";
      const deleteStandbyMessage = (() => {
        const startLineBreak = i !== endIndex - 1 ? "" : "\n";
        return i === currentIndex && mode === "deleteStandby"
          ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
          : "";
      })();
      stdout.write(`${prefix} ${branch}${lineBreak}`);
      stdout.write(deleteStandbyMessage);
    }
  }
}

function getGitBranches() {
  const branches = [];
  try {
    const output = execSync("git branch", { encoding: "utf-8" });
    console.log(output);

    output.split("\n").forEach((line) => {
      const trimmedLine = line.trim();
      if (trimmedLine.length > 0) {
        branches.push(trimmedLine);
      }
    });

    return branches;
  } catch (error) {
    console.error("Error fetching git branches:", error.message);
    return branches;
  }
}

function deleteBranch(branches, currentIndex, forceDelete = false) {
  const forceDeleteFlag = forceDelete ? "-D" : "-d";
  try {
    execSync(`git branch ${forceDeleteFlag} "${branches[currentIndex]}"`, {
      encoding: "utf-8",
    });

    const updatedBranches = getGitBranches();
    branches.splice(0, branches.length, ...updatedBranches);
  } catch (error) {
    console.error("Error deleting git branch:", error.message);
  }
}

main();

おまけ1: ファイルを実行するためのエイリアスを作成する

今回作成したCLIツールをコマンドっぽく実行したい場合はシェルの設定にエイリアスを追加することで実現できます。

.bashrc.zshrcに以下の記述を追加しましょう。

alias gitInteract='~/gitInteract.js'

ファイルを更新した後は、ターミナルを再起動 または source ~/.zshrcを実行して設定を読み込むようにしてください。

% source ~/.zshrc
% gitInteract

おまけ2: ターミナルのスクロールを制限する


今回作成したツールではファイルの実行後にタッチパッドやマウスでのスクロールが可能です。

% source ~/.zshrc
% gitInteract

% gitInteract

===== Git Interact =====
> dev-to-stg-WHD4690
  * develop-4
  develop-5
  staging

もしスクロールを制限したい場合は、
以下のようにマウススクロールイベントを監視 > 見つけた際に処理を無効にすることで実現できます。
(vimのように、スクロールしようとした際は画面全体の色が一瞬反転するようにしています)

let isFlashing = false;
let flashTimeout = null;
function throttleMouseScrollEvent(key, stdout, onRestore) {
  // マウススクロールイベントを検出(SGRマウスモード対応)
  if (key.includes("\x1b[<") && (key.includes("64;") || key.includes("65;"))) {
    // 既に点滅中の場合は無視
    if (isFlashing) {
      return;
    }

    // 点滅開始
    isFlashing = true;

    // 画面全体を反転表示で埋める
    stdout.write(CLEAR_SCREEN);
    stdout.write(MOVE_CURSOR_HOME);
    stdout.write(INVERT_COLORS);
    const rows = process.stdout.rows || 24;
    const cols = process.stdout.columns || 80;
    let fullScreen = "";
    for (let i = 0; i < rows; i++) {
      fullScreen += " ".repeat(cols) + "\n";
    }
    stdout.write(fullScreen);

    // 既存のタイムアウトをクリア
    if (flashTimeout) {
      clearTimeout(flashTimeout);
    }

    // 短時間後に通常表示に戻す
    flashTimeout = setTimeout(() => {
      stdout.write(RESET_COLORS);
      onRestore();
      setTimeout(() => {
        isFlashing = false;
        flashTimeout = null;
      }, 1500);
    }, 100);
    return;
  }
}
このステップでの最終的なコード
gitInteract.js
#!/usr/bin/env node
const { execSync } = require("child_process");

const CTRL_C = "\u0003";
const ARROW_UP = "\x1b[A";
const ARROW_DOWN = "\x1b[B";
const ENTER = "\r";

const HIDE_PIPE = "\x1b[?25l";
const SHOW_PIPE = "\x1b[?25h";

const CLEAR_SCREEN = "\x1b[2J";
const MOVE_CURSOR_HOME = "\x1b[H";

const ENTER_ALT_SCREEN = "\x1b[?1049h";
const EXIT_ALT_SCREEN = "\x1b[?1049l";

const MOUSE_SCROLL_EVENT_START = "\x1b[?1006h";
const MOUSE_SCROLL_EVENT_END = "\x1b[?1006l";

const DRAG_EVENT_START = "\x1b[?1002h";
const DRAG_EVENT_END = "\x1b[?1002l";

const INVERT_COLORS = "\x1b[7m";
const RESET_COLORS = "\x1b[0m";

function main() {
  const stdin = process.stdin;
  const stdout = process.stdout;
  stdout.write(ENTER_ALT_SCREEN);
  stdout.write(MOUSE_SCROLL_EVENT_START); // SGRマウスモード(拡張マウス)
  stdout.write(DRAG_EVENT_START); // マウスドラッグイベント
  stdout.write(HIDE_PIPE);
  stdin.setEncoding("utf8");
  stdin.setRawMode(true);

  const branches = getGitBranches();
  let currentIndex = 0;
  let mode = "normal";
  render(currentIndex, mode, branches);

  stdin.on("data", (key) => {
    throttleMouseScrollEvent(key, stdout, () => {
      render(currentIndex, mode, branches);
    });

    switch (key) {
      case CTRL_C:
      case "q":
        stdout.write(EXIT_ALT_SCREEN);
        stdout.write(MOUSE_SCROLL_EVENT_END); // SGRマウスモード無効化
        stdout.write(DRAG_EVENT_END); // マウスドラッグイベント無効化
        stdout.write(SHOW_PIPE);
        stdout.write("\n");
        stdin.setRawMode(false);
        process.exit();
        break;
      case ENTER:
        if (mode === "normal") {
          mode = "deleteStandby";
        } else if (mode === "deleteStandby") {
          deleteBranch(branches, currentIndex);
          if (currentIndex >= branches.length) {
            currentIndex = branches.length - 1;
          }
          mode = "normal";
        }
        render(currentIndex, mode, branches);
        break;
      case "f": // ブランチを強制削除
        if (mode === "deleteStandby") {
          deleteBranch(branches, currentIndex, true);
          if (currentIndex >= branches.length) {
            currentIndex = branches.length - 1;
          }
          mode = "normal";
          render(currentIndex, mode, branches);
        }
        break;
      case ARROW_UP:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === 0 ? branches.length - 1 : currentIndex - 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      case ARROW_DOWN:
        if (branches.length <= 1) {
          return;
        }
        currentIndex =
          currentIndex === branches.length - 1 ? 0 : currentIndex + 1;
        mode = "normal";
        render(currentIndex, mode, branches);
        break;
      default:
        break;
    }
  });
}

function render(currentIndex, mode = "normal", branches) {
  const stdout = process.stdout;
  const terminalHeight = process.stdout.rows;
  const headerHeight = 1;
  const availableHeight = terminalHeight - headerHeight;
  let startIndex = 0;
  if (currentIndex >= availableHeight) {
    startIndex = currentIndex - availableHeight + 1;
  }
  const endIndex = Math.min(startIndex + availableHeight, branches.length);
  stdout.write(CLEAR_SCREEN);
  stdout.write(MOVE_CURSOR_HOME);

  stdout.write("===== Git Interact =====\n");
  if (branches.length === 0) {
    stdout.write("Error: No branches found.");
  } else {
    for (let i = startIndex; i < endIndex; i++) {
      const branch = branches[i];
      const prefix = branch === branches[currentIndex] ? ">" : " ";
      const lineBreak = i === endIndex - 1 ? "" : "\n";
      const deleteStandbyMessage = (() => {
        const startLineBreak = i !== endIndex - 1 ? "" : "\n";
        return i === currentIndex && mode === "deleteStandby"
          ? `${startLineBreak}>> Press Enter to delete:${lineBreak}`
          : "";
      })();
      stdout.write(`${prefix} ${branch}${lineBreak}`);
      stdout.write(deleteStandbyMessage);
    }
  }
}

function getGitBranches() {
  const branches = [];
  try {
    const output = execSync("git branch", { encoding: "utf-8" });
    console.log(output);

    output.split("\n").forEach((line) => {
      const trimmedLine = line.trim();
      if (trimmedLine.length > 0) {
        branches.push(trimmedLine);
      }
    });

    return branches;
  } catch (error) {
    console.error("Error fetching git branches:", error.message);
    return branches;
  }
}

function deleteBranch(branches, currentIndex, forceDelete = false) {
  const forceDeleteFlag = forceDelete ? "-D" : "-d";
  try {
    execSync(`git branch ${forceDeleteFlag} "${branches[currentIndex]}"`, {
      encoding: "utf-8",
    });

    const updatedBranches = getGitBranches();
    branches.splice(0, branches.length, ...updatedBranches);
  } catch (error) {
    console.error("Error deleting git branch:", error.message);
  }
}

let isFlashing = false;
let flashTimeout = null;
function throttleMouseScrollEvent(key, stdout, onRestore) {
  if (key.includes("\x1b[<") && (key.includes("64;") || key.includes("65;"))) {
    if (isFlashing) {
      return;
    }
    isFlashing = true;

    stdout.write(CLEAR_SCREEN);
    stdout.write(MOVE_CURSOR_HOME);
    stdout.write(INVERT_COLORS);
    const rows = process.stdout.rows || 24;
    const cols = process.stdout.columns || 80;
    let fullScreen = "";
    for (let i = 0; i < rows; i++) {
      fullScreen += " ".repeat(cols) + "\n";
    }
    stdout.write(fullScreen);

    if (flashTimeout) {
      clearTimeout(flashTimeout);
    }

    flashTimeout = setTimeout(() => {
      stdout.write(RESET_COLORS);
      onRestore();
      setTimeout(() => {
        isFlashing = false;
        flashTimeout = null;
      }, 1500);
    }, 100);
    return;
  }
}

main();

まとめ

いかがだったでしょうか。
普段ブランチの掃除は苦手な私でしたが、今回作成したツールを利用することでローカルのgitのブランチを簡単に整理整頓することができるようになりました。

またCLIツールの作成は今回初めて行いましたが、普段利用しない技術的な知識を知ることができてとても楽しかったです。
今回のツールを作っていく過程で「あれもできそうだな・・・」というアイデアを思いついたので、
時間を作ってまたCLIツールを作ってみようと思います。

この記事はここまでです。
読んでいただき、ありがとうございました。

Bitkey Developers

Discussion