Skip to content

Web Server Integration

Mohsen Beiranvand edited this page Aug 7, 2026 · 1 revision

Web Server Integration

There are two different things "the git-task web server" can mean, and it's worth separating them before writing any code:

  1. The bundled UI (git task web): a companion server this CLI can install and manage for you, giving you a browser-based view of your tasks with no code of your own.
  2. Your own integration: any service you build, HTTP or otherwise, that drives git-task by calling it with --format json and parsing the result. This is how you'd expose task data to a dashboard, a Slack bot, a CI check, or anything else with a JSON contract you control.

This page covers both, and then goes deep on the second, since that's the one you write yourself.

The bundled web UI

git-task ships an optional companion, git-task-web, a small Node.js server distributed separately on npm, that this CLI can install and manage as a background process. git task web doesn't implement that server itself: it wraps npm install, spawns the installed server as a detached child process, and tracks whether it's still running.

git task web start              # installs git-task-web first if needed, prompts to confirm
git task web start --yes        # non-interactive: install without prompting
git task web start --port 4700 --host 0.0.0.0
git task web status
git task web stop

Requirements: Node.js 20 or newer, and npm, both on PATH. The package installs under ~/.local/share/git-task/web (override the base directory with GIT_TASK_DATA_DIR), isolated from any project's own node_modules. Default bind address is 127.0.0.1:4600.

Behavior worth knowing:

  • The server survives the terminal that started it closing. It runs in its own session (setsid on Unix), not just detached, so a closed terminal or a SIGHUP doesn't take it down the way it would a plain background job.
  • web start waits up to 10 seconds for the port to accept a connection before reporting success; a slow-starting server is still spawned, just reported as not-yet-ready.
  • Combined stdout and stderr of the server process is appended to ~/.local/share/git-task/web.log (web status prints the exact path). Unlike git-task's own fully-silent auto-sync background worker, this log is kept, since the server is long-lived and user-visible.
  • web stop sends SIGTERM, waits up to 5 seconds, then escalates to SIGKILL if the process is still alive.
  • --format json on any web subcommand returns { running, pid, url, log }.

What git-task-web itself exposes as an HTTP API, and in what shape, is defined by that package, not by this CLI. This page documents what git task web (the process manager) does. If you want programmatic access to task data over HTTP with a contract you control, keep reading.

Building your own integration

There is no long-running API server built into the git-task binary itself. Every --format json invocation is a fresh process: it opens the repo, does the work, prints one JSON document, and exits. That's a deliberate, simple integration point: any language that can spawn a subprocess and read stdout can drive git-task, without linking against it or speaking a custom protocol.

The pattern

  1. Spawn git task (or the gtask binary directly) with argv built as an array, never a shell string.
  2. Always include --format json.
  3. Read all of stdout and parse it as one JSON document.
  4. Branch on ok, not the process exit code. A validation or not-found error is exit code 1 and a well-formed JSON document on stdout; that's the expected path, not a crash. See JSON Output for the full envelope and error-kind reference.

Never build a shell command string

Pass arguments as an array (execFile, not exec; subprocess.run([...]), not os.system(...)) so a task title containing spaces, quotes, or shell metacharacters can never be interpreted as part of the command. This isn't a git-task-specific precaution; it's the same rule for shelling out to anything with values a user typed.

// wrong: a title of `"; rm -rf ~ #` becomes part of the shell command
exec(`git task new "${title}" --format json`);

// right: title is always one argv element, never parsed by a shell
execFile('git', ['task', 'new', title, '--desc', description, '--format', 'json']);

A minimal Express example

// git-task-client.js
const { execFile } = require('node:child_process');

function runGitTask(args, cwd) {
  return new Promise((resolve, reject) => {
    execFile(
      'git',
      ['task', ...args, '--format', 'json'],
      { cwd, maxBuffer: 10 * 1024 * 1024 },
      (_err, stdout, stderr) => {
        // A non-zero exit is expected for `ok: false` responses; don't reject on `_err` alone.
        try {
          resolve(JSON.parse(stdout));
        } catch {
          reject(new Error(`git-task produced no JSON on stdout: ${stderr || stdout}`));
        }
      }
    );
  });
}

module.exports = { runGitTask };
// server.js
const express = require('express');
const { runGitTask } = require('./git-task-client');

const app = express();
app.use(express.json());

const REPO_PATH = '/path/to/your/repo';

app.get('/api/tasks', async (req, res) => {
  const args = ['ls'];
  if (req.query.status) args.push('--status', req.query.status);
  if (req.query.kind) args.push('--kind', req.query.kind);
  const result = await runGitTask(args, REPO_PATH);
  if (!result.ok) return res.status(422).json(result.error);
  res.json(result.data);
});

app.get('/api/tasks/:id', async (req, res) => {
  const result = await runGitTask(['show', req.params.id], REPO_PATH);
  if (!result.ok) {
    const status = result.error.kind === 'not_found' ? 404 : 422;
    return res.status(status).json(result.error);
  }
  res.json(result.data);
});

app.post('/api/tasks', async (req, res) => {
  const { title, kind, description, priority, assignee } = req.body;
  const args = ['new', title, '--desc', description ?? ''];
  if (kind) args.push('--kind', kind);
  if (priority) args.push('--priority', priority);
  if (assignee) args.push('--assignee', assignee);
  const result = await runGitTask(args, REPO_PATH);
  if (!result.ok) return res.status(422).json(result.error);
  res.status(201).json(result.data.task);
});

app.post('/api/tasks/:id/status', async (req, res) => {
  const result = await runGitTask(['status', req.params.id, req.body.status], REPO_PATH);
  if (!result.ok) return res.status(422).json(result.error);
  res.json(result.data.task);
});

app.listen(3000);

Every field in a request body above ends up as its own argv element, so there's no escaping to get wrong. The title/status values can contain anything, including characters that would matter to a shell, and it makes no difference here.

Handling write conflicts

Two processes can race to append to the same task: two web requests editing the same task at once, or a request racing an interactive git task edit someone's running locally. git-task detects this itself, via compare-and-swap on the underlying git ref, and retries once internally before surfacing error.kind: "conflict" if both attempts lost the race. At the scale a single-repo integration usually sees, that's already enough. If you expect real contention (many concurrent writers touching the same tasks), wrap the call in a small retry loop and re-fetch the task with show before reapplying, rather than assuming your view of it is still current:

async function runGitTaskWithRetry(args, cwd, attempts = 3) {
  let last;
  for (let i = 0; i < attempts; i++) {
    last = await runGitTask(args, cwd);
    if (last.ok || last.error?.kind !== 'conflict') return last;
  }
  return last;
}

Performance notes

  • Spawning git has real, if small, overhead: repo discovery, opening the object database. For a UI that lists tasks on every keystroke of a search box, filter server-side with ls's own flags (--status, --label, --kind, and so on) rather than shelling out once per keystroke and filtering the results yourself in application code.
  • ls defaults to just the current repo. If you've registered multiple repos (see Sync and Multi-Repo) and don't want the aggregate, pass --here explicitly rather than relying on the default, since the default behavior depends on whether a repo happens to be registered at all.
  • history/--with-history roughly doubles response size for a repo with long-lived tasks. Leave it off unless the UI actually renders an audit trail.

Authentication and exposure

git-task itself has no concept of a user, a session, or a permission check beyond what your operating system's filesystem permissions already enforce on the repo. Anything you build in front of it, a web server, a chat bot, a CI job, is responsible for its own authentication and authorization. Don't expose an endpoint that runs arbitrary git-task subcommands built from unvalidated request input; constrain which subcommands and flags a given route can trigger, the same way you would for any other internal command wrapped by a public API.

Related: JSON Output for the full response contract this section builds on.

Clone this wiki locally