Skip to content
Merged
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
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,20 @@ Smart HTTP: https://git-scm.com/docs/gitprotocol-http
| Capability | Description |
| --------------------- | ----------------------------------------------------------------------------- |
| Web repository browser| List repos, view trees, files (with raw downloads), commits, logs, branches, tags, READMEs |
| Built-in or cgit UI | Pick the dependency-free built-in browser or, if installed, the classic [cgit] interface |
| Smart HTTP clone/push | Uses git's own `git http-backend` CGI - no third-party server needed |
| Bare and work trees | Serves `project.git` and regular `project/` (with `.git`) repositories |
| Push to work trees | Uses `receive.denyCurrentBranch=updateInstead` so pushed commits appear in the working tree |
| Interactive setup | First run asks *where your repositories live*, port, address, push & web preferences |
| Lightweight | Pure Node.js http server, tiny dependency-free browser UI, ~tens of MB RAM |

[cgit]: https://git.zx2c4.com/cgit/

## Requirements

- **Node.js >= 18**
- **git** on your `PATH` (with `git http-backend`, present in every standard git install)
- **cgit** (optional) - only needed when you choose the cgit web interface

No npm packages are installed at runtime.

Expand Down Expand Up @@ -70,7 +74,11 @@ On first run `localgit` asks a few questions:
3. **Which port should the server use?** - defaults to `4000`.
4. **Allow git push?** - enable receive-pack (default yes).
5. **Enable the web browser?** - the repo browser at `/` (default yes).
6. **Scan depth** - how many folder levels deep to search for repos.
6. **Which web interface?** - the dependency-free *built-in* browser (default)
or *cgit* (if installed). cgit is the classic, feature-complete git browser;
everything runs as CGI via `git http-backend` for clone/push and a small
bridge that translates requests to the `cgit` binary.
7. **Scan depth** - how many folder levels deep to search for repos.

Then it starts the server and prints what it found:

Expand All @@ -80,7 +88,7 @@ Then it starts the server and prints what it found:
Web UI http://127.0.0.1:4000/
Repos dir /media/you/USB/git
Push enabled
Web enabled
Web enabled (cgit)

3 repositories:
http://127.0.0.1:4000/another.git
Expand Down Expand Up @@ -122,6 +130,12 @@ Open `http://127.0.0.1:4000/`:
- `.../:project/commit/?id=<sha>` - commit details & diff
- `.../:project/branches/`, `…/:project/tags/` - refs

With the **cgit** interface (`--web-ui cgit`) the same `http://127.0.0.1:4000/`
address instead renders cgit's own pages (`/project/`, `/project/tree/`,
`/project/log/`, `/project/commit/`, …), with cgit's static assets served from
`/cgit.css`, `/cgit.js` and `/cgit.png`. The repo names are identical either
way; cloning and pushing use the same smart-HTTP endpoints.

## Commands & options

```
Expand All @@ -142,11 +156,19 @@ Options
-c, --config <file> Use a specific config file
--no-push Disable git push
--no-web Disable the web browser
--web-ui <name> Web interface: builtin (default) or cgit
--setup Re-run the setup questions
--yes Accept defaults / no prompts
--no-save Do not write the config file
```

Environment variables:

- `CGIT_BINARY` - path to the cgit executable (otherwise probed on `PATH` and
at the usual locations such as `/usr/lib/cgit/cgit.cgi`)
- `CGIT_ASSETS` - directory holding cgit's `cgit.css` / `cgit.js` / `cgit.png`
(otherwise probed, e.g. `/usr/share/cgit`)

Examples:

```sh
Expand All @@ -159,6 +181,9 @@ localgit serve --dir /media/you/USB/git --port 4000
# expose to your local network
localgit --dir /media/you/USB/git --host 0.0.0.0

# use the cgit web interface (must be installed)
localgit serve --dir /media/you/USB/git --web-ui cgit

# list what would be served
localgit list --dir /media/you/USB/git

Expand All @@ -184,10 +209,14 @@ Example:
"port": 4000,
"allowPush": true,
"web": true,
"webInterface": "builtin",
"scanDepth": 3
}
```

Set `"webInterface": "cgit"` to use cgit (see `CGIT_BINARY` / `CGIT_ASSETS` above
if it is not in a standard location).

## How it works

- **Discovery** - `src/repos.js` walks the configured folder (up to the scan
Expand All @@ -201,6 +230,10 @@ Example:
repository files are ever modified.
- **Web UI** - `src/web.js` renders a small, dependency-free, XSS-escaped
gitweb-like interface by shelling out to `git log`, `git ls-tree`, `git show`.
- **cgit UI** - `src/cgit.js` locates the `cgit` binary, generates a throwaway
`cgitrc` (in a temp dir, never on the drive) listing the discovered
repositories, and proxies web requests to cgit over CGI. `src/cgi.js` is the
shared CGI bridge used by both `git http-backend` and cgit.
- **Server** - `src/server.js` routes between git endpoints (`/info/refs`,
`/git-upload-pack`, `/git-receive-pack`, …) and web pages, with a short scan
cache so new repositories appear within seconds.
Expand Down
209 changes: 209 additions & 0 deletions src/cgi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
'use strict';

const { spawn } = require('node:child_process');

/**
* Bridge a Node request to an arbitrary CGI program (used for `git
* http-backend` and for `cgit`).
*
* Both speak CGI: they read the request body from stdin and write CGI
* response headers + body to stdout. We translate both directions:
*
* - requests without `Content-Length` (chunked bodies) are buffered first so
* the child always sees a real `CONTENT_LENGTH`,
* - the child's stdout is streamed, with the first header block parsed as
* CGI response headers (`Status`, `Content-Type`, …) followed by the body.
*
* @param {import('node:http').IncomingMessage} req
* @param {import('node:http').ServerResponse} res
* @param {{
* command: string,
* args?: string[],
* env: NodeJS.ProcessEnv,
* maxBodyBytes?: number,
* label?: string,
* onExit?: (code: number|null, stderr: string) => void,
* }} options
*/
function serveCgi(req, res, options) {
const {
command,
args = [],
env,
maxBodyBytes = 2 * 1024 * 1024 * 1024,
label = command,
onExit,
} = options;

const isBodyless = req.method === 'GET' || req.method === 'HEAD';
const contentLength = Number(req.headers['content-length']);
const hasContentLength = Number.isFinite(contentLength) && contentLength >= 0;

/**
* Spawn the CGI program and wire stdin/stdout once the body situation is
* known.
* @param {Buffer|null} bufferedBody set when the body was buffered first
*/
const spawnAndPipe = (bufferedBody) => {
if (bufferedBody) env.CONTENT_LENGTH = String(bufferedBody.length);

/** @type {import('node:child_process').ChildProcess} */
let child;
try {
child = spawn(command, args, {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`Failed to start ${label}: ${error.message}\n`);
return;
}

let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
stderr += chunk;
});

let headersDone = false;
let headerBuffer = Buffer.alloc(0);

const writeHeaders = (terminatorIndex, terminatorLength) => {
const rawHeaders = headerBuffer.subarray(0, terminatorIndex).toString('utf8');
const body = headerBuffer.subarray(terminatorIndex + terminatorLength);

let status = 200;
let statusMessage;
/** @type {Record<string,string>} */
const headers = {};

for (const line of rawHeaders.split(/\r?\n/)) {
if (!line) continue;
const colon = line.indexOf(':');
if (colon === -1) continue;
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
if (key.toLowerCase() === 'status') {
const match = value.match(/^(\d{3})\s*(.*)$/);
if (match) {
status = Number(match[1]);
if (match[2]) statusMessage = match[2];
}
} else {
headers[key] = value;
}
}

headersDone = true;
res.writeHead(status, statusMessage, headers);
if (body.length) res.write(body);
};

child.stdout.on('data', (chunk) => {
if (headersDone) {
res.write(chunk);
return;
}
headerBuffer = Buffer.concat([headerBuffer, chunk]);
let index = headerBuffer.indexOf('\r\n\r\n');
let length = 4;
if (index === -1) {
index = headerBuffer.indexOf('\n\n');
length = 2;
}
if (index === -1) return;
writeHeaders(index, length);
});

child.stdout.on('end', () => {
if (!headersDone) {
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(stderr || `${label} produced no response\n`);
return;
}
res.end();
});

child.on('error', (error) => {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`Failed to start ${label}: ${error.message}\n`);
} else if (!res.writableEnded) {
res.end();
}
});

child.on('close', (code) => {
if (onExit) onExit(code, stderr);
if (!res.writableEnded) res.end();
});

// Feed the request body into the child.
if (isBodyless) {
child.stdin.end();
} else if (bufferedBody) {
child.stdin.end(bufferedBody);
} else {
let written = 0;
let aborted = false;
req.on('data', (chunk) => {
if (aborted) return;
written += chunk.length;
if (written > maxBodyBytes) {
aborted = true;
req.destroy();
child.kill();
return;
}
child.stdin.write(chunk);
});
req.on('end', () => child.stdin.end());
req.on('error', () => child.stdin.end());
}

res.on('close', () => {
if (!child.killed && !res.writableEnded) child.kill();
});
};

if (!isBodyless && !hasContentLength) {
// Chunked transfer encoding: buffer so we can hand the backend a
// CONTENT_LENGTH, which CGI requires.
readBody(req, maxBodyBytes)
.then(spawnAndPipe)
.catch((error) => {
res.writeHead(413, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`${error.message}\n`);
});
return;
}
spawnAndPipe(null);
}

/**
* Collect a request body, rejecting once `limit` bytes is exceeded.
* @param {import('node:http').IncomingMessage} req
* @param {number} limit
* @returns {Promise<Buffer>}
*/
function readBody(req, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
reject(new Error('Request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}

module.exports = { serveCgi, readBody };
Loading
Loading