Skip to content

Prompting From Inside a Git Hook Without Blocking CI

During a commit-msg hook, stdin isn't the user's terminal. How Verbatim Commit asks a yes/no question anyway, and why it checks for CI before it tries.

By Jonathan Contreras4 min read

Share

Verbatim Commit installs a commit-msg hook. When a commit message looks weak ("fix stuff", "update"), the hook prints a warning and asks one question:

Commit anyway? (y/N)

That question is the whole feature, and asking it is trickier than it looks. A hook that warns and can't be answered is noise. A hook that waits for an answer nobody can give is a hung build.

Stdin is not yours

The obvious way to ask a question from a Node script is to read stdin. Inside a commit-msg hook that doesn't work, because during the hook stdin belongs to git, not to the person typing. A prompt that reads stdin is not reading the keyboard.

The terminal is still there, though. The process just has to open it by name instead of inheriting it:

function ttyPaths(): { input: string; output: string } {
  if (process.platform === "win32") {
    return { input: "\\\\.\\CONIN$", output: "\\\\.\\CONOUT$" };
  }
  return { input: "/dev/tty", output: "/dev/tty" };
}

On macOS and Linux, /dev/tty is the controlling terminal of the current process, whatever stdin happens to be pointing at. Windows has no /dev/tty, but it has the same idea under two names: CONIN$ for the console's input and CONOUT$ for its output.

The prompt writes the question to the output side, then reads the input side one byte at a time until it sees a newline. It is synchronous. A hook is a short script that has to return an exit code, and there is nothing else for it to do while it waits.

The case that matters is when nobody is there

Opening the terminal can fail. There is no controlling terminal in a script, in most CI jobs, or when a GUI git client runs the hook. That failure is the important branch, not the edge case:

let outFd: number;
try {
  outFd = openSync(output, "w");
} catch {
  return null;
}

null means "there was nobody to ask", and the caller treats it as permission to continue:

const proceed = confirmTty("\nCommit anyway? (y/N) ", true);

The second argument is the answer to use when there is no terminal. It is true. With a person present, only an explicit y or yes lets a flagged commit through. With no person present, the commit goes through untouched. The hook is a nudge for a human and invisible to everything else.

A console that opens and never answers

"Try to open the terminal, fall through if you can't" turned out not to be enough. A Windows CI runner attaches a console that nobody types into. CONIN$ opens without complaint, the read blocks, and the job sits there until it times out.

There is no way to tell from the file handle that the console is unattended. So the prompt checks for CI before it touches the terminal at all:

export function isCI(env: NodeJS.ProcessEnv = process.env): boolean {
  const value = env.CI?.trim().toLowerCase();
  return value !== undefined && value !== "" && value !== "0" && value !== "false";
}

Nearly every CI system sets CI. If it is set to anything other than empty, 0 or false, the prompt returns null immediately and the commit proceeds.

What I'd take from it

Two things, both small.

Ask the terminal, not stdin, when your process runs inside someone else's. The same trick works for any tool that gets launched by another program and still needs a human.

Decide what happens when nobody answers before you write the prompt. The unattended console is the case that "try it and fall through" misses. The rule the tool follows is that its own failure, or its own inability to ask, must never be the reason a commit doesn't happen.

The code is in src/lib/tty.ts and the rest of the tool is written up in the case study.

Related