diff --git a/README.md b/README.md index 8acd652..7b34224 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: @@ -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 @@ -122,6 +130,12 @@ Open `http://127.0.0.1:4000/`: - `.../:project/commit/?id=` - 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 ``` @@ -142,11 +156,19 @@ Options -c, --config Use a specific config file --no-push Disable git push --no-web Disable the web browser + --web-ui 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 @@ -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 @@ -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 @@ -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. diff --git a/src/cgi.js b/src/cgi.js new file mode 100644 index 0000000..709a27b --- /dev/null +++ b/src/cgi.js @@ -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} */ + 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} + */ +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 }; \ No newline at end of file diff --git a/src/cgit.js b/src/cgit.js new file mode 100644 index 0000000..d2a13d3 --- /dev/null +++ b/src/cgit.js @@ -0,0 +1,314 @@ +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { serveCgi } = require('./cgi'); + +/** + * cgit web interface support. + * + * cgit (https://git.zx2c4.com/cgit/) is a fast, dependency-free git browser + * implemented as a CGI program, typically served from a web server with root + * mappings like `/cgit/...`. We run it *as the root* (`virtual-root=/`) so the + * URLs cgit generates (`/demo.git/tree/…`) land exactly in localgit's existing + * URL space, next to the smart-HTTP git endpoints. + * + * Nothing is ever installed on the user's drive: the `cgitrc` is generated + * into a fresh temporary directory on server start (and used via + * `CGIT_CONFIG`), and caching is disabled so cgit never writes cache files. + */ + +/** Well-known locations of the cgit CGI binary (Debian ships it as cgit.cgi). */ +const BINARY_CANDIDATES = [ + 'cgit', + 'cgit.cgi', + '/usr/lib/cgit/cgit.cgi', + '/usr/libexec/cgit/cgit.cgi', + '/usr/lib/cgit/cgit', + '/usr/bin/cgit', + '/usr/local/lib/cgit/cgit.cgi', + '/usr/local/libexec/cgit/cgit.cgi', +]; + +/** Well-known locations of cgit's static assets (css, logo, …). */ +const ASSET_DIRS = [ + '/usr/share/cgit', + '/usr/local/share/cgit', + '/usr/share/webapps/cgit', + '/usr/lib/cgit', +]; + +/** Static files cgit references that localgit hands out verbatim. */ +const ASSET_TYPES = { + 'cgit.css': 'text/css; charset=utf-8', + 'cgit.js': 'text/javascript; charset=utf-8', + 'cgit.png': 'image/png', + 'cgit.svg': 'image/svg+xml', + 'favicon.ico': 'image/x-icon', + 'robots.txt': 'text/plain; charset=utf-8', +}; + +/** + * Resolve a bare executable name against PATH. + * @param {string} name + * @returns {string|null} + */ +function which(name) { + for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) { + const candidate = path.join(dir, name); + if (isExecutableFile(candidate)) return candidate; + } + return null; +} + +/** + * @param {string} file + * @returns {boolean} + */ +function isExecutableFile(file) { + try { + if (!fs.statSync(file).isFile()) return false; + fs.accessSync(file, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Locate the cgit binary. `explicit` (a config value, env or test option) + * wins; otherwise well-known names and paths are tried. + * @param {string} [explicit] + * @returns {string|null} + */ +function findBinary(explicit) { + const candidates = [explicit, process.env.CGIT_BINARY, ...BINARY_CANDIDATES].filter(Boolean); + for (const candidate of candidates) { + if (candidate.includes('/') || candidate.includes('\\')) { + if (isExecutableFile(candidate)) return candidate; + } else { + const resolved = which(candidate); + if (resolved) return resolved; + } + } + return null; +} + +/** + * Locate the directory holding cgit's static assets. + * @param {string} [explicit] + * @returns {string|null} + */ +function findAssetDir(explicit) { + const candidates = [explicit, process.env.CGIT_ASSETS, ...ASSET_DIRS].filter(Boolean); + for (const dir of candidates) { + try { + if (fs.statSync(path.join(dir, 'cgit.css')).isFile()) return dir; + } catch { + /* keep looking */ + } + } + return null; +} + +/** + * Probe for cgit on this machine (used by `localgit setup` to hint the user). + * @returns {{binary: string, assetDir: string|null}|null} + */ +function locate(explicit) { + const binary = findBinary(explicit); + if (!binary) return null; + return { binary, assetDir: findAssetDir() }; +} + +/** + * Resolve a single explicitly configured cgit binary. Unlike `findBinary` + * this never falls back to auto-detection: if the caller pointed at a binary, + * they meant that binary. + * @param {string} candidate + * @returns {string|null} + */ +function locateExplicit(candidate) { + if (candidate.includes('/') || candidate.includes('\\')) { + return isExecutableFile(candidate) ? candidate : null; + } + return which(candidate); +} + +/** + * Render the cgitrc content for a session. + * + * Rather than relying on cgit's `scan-path` (which discovers repositories + * under different names than localgit, e.g. a work tree `plain/.git` instead + * of `plain`), the rc lists explicit `repo.url` / `repo.path` entries built + * from localgit's own discovery result. That keeps the URLs cgit generates + * inside localgit's URL space, and localgit rewrites the rc whenever the + * discovered repository set changes. + * + * For bare repositories `repo.path` is the repository directory itself; for + * work trees it is the resolved git directory (`.git`), which is what cgit + * needs to run its git commands. + * + * Caching is off so cgit touches only memory, and static asset paths point at + * files localgit serves under `/cgit.css`, `/cgit.js`, `/cgit.png` and + * `/favicon.ico`. When no asset directory is available the resource links are + * blanked so cgit renders fine without any external files. + * + * @param {{assetDir?: string|null, repos?: Array}} [options] + * @returns {string} + */ +function renderCgitrc(options = {}) { + const assetDir = options.assetDir || null; + const repos = options.repos || []; + const lines = [ + '# Generated by localgit. Do not edit; it is rewritten whenever the', + '# discovered repository set changes.', + '# Both the cgitrc and any cgit cache live outside the served drive.', + 'virtual-root=/', + 'root-title=localgit', + 'root-desc=Git repositories served by localgit', + 'enable-http-clone=1', + `clone-url=http://$HTTP_HOST$SCRIPT_NAME/$CGIT_REPO_URL`, + 'snapshots=0', + 'cache-size=0', + 'robots=index, nofollow', + ]; + if (assetDir) { + lines.push('css=/cgit.css', 'js=/cgit.js', 'logo=/cgit.png', 'favicon=/favicon.ico'); + } else { + lines.push('css=', 'js=', 'logo=', 'favicon=/favicon.ico'); + } + lines.push(''); + for (const repo of repos) { + lines.push(`repo.url=${repo.name}`, `repo.path=${repo.gitDir}`, ''); + } + return `${lines.join('\n')}`; +} + +/** + * Build the CGI environment handed to the cgit binary. + * @param {import('node:http').IncomingMessage} req + * @param {{config: {host:string, port:number}, runtime: object}} ctx + * @returns {NodeJS.ProcessEnv} + */ +function cgiEnv(req, ctx) { + const { config, runtime } = ctx; + const host = req.headers.host || `${config.host}:${config.port}`; + const [serverName, serverPort = '80'] = host.split(':'); + const queryIndex = (req.url || '').indexOf('?'); + + return { + ...process.env, + CGIT_CONFIG: runtime.configFile, + PATH_INFO: new URL(req.url, `http://${host}`).pathname, + SCRIPT_NAME: '', + QUERY_STRING: queryIndex === -1 ? '' : (req.url || '').slice(queryIndex + 1), + REQUEST_METHOD: req.method || 'GET', + CONTENT_TYPE: req.headers['content-type'] || '', + CONTENT_LENGTH: req.headers['content-length'] || '', + REMOTE_ADDR: req.socket.remoteAddress || '', + REMOTE_USER: '', + SERVER_NAME: serverName, + SERVER_PORT: serverPort, + SERVER_PROTOCOL: `HTTP/${req.httpVersion}`, + SERVER_SOFTWARE: 'localgit', + GATEWAY_INTERFACE: 'CGI/1.1', + HTTP_HOST: host, + }; +} + +/** + * Set up the per-process cgit runtime: locate the binary (throwing a helpful + * error when missing), generate a temporary cgitrc and remember the asset dir. + * @param {{dir:string, host:string, port:number}} config + * @param {{command?:string, assetDir?:string, repos?:Array}} [options] test/override hooks + * @returns {object} + */ +function createRuntime(config, options = {}) { + const explicit = options.command || config.cgitCommand || process.env.CGIT_BINARY; + // An explicitly configured binary is honored strictly (no silent fallback); + // otherwise cgit is auto-detected on PATH and at well-known locations. + const binary = explicit ? locateExplicit(explicit) : findBinary(); + if (!binary) { + throw new Error( + `cgit web interface is enabled, but no cgit executable was found. ` + + `${explicit ? `Configured binary "${explicit}" is not usable. ` : ''}` + + `Install cgit, set CGIT_BINARY to its path, or use --web-ui builtin.`, + ); + } + const assetDir = findAssetDir(options.assetDir || config.cgitAssets || process.env.CGIT_ASSETS); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localgit-cgit-')); + const configFile = path.join(workDir, 'cgitrc'); + + /** Last cgitrc content written, so we only touch the file when it changes. */ + let cgitrcContent = ''; + const updateRepos = (repos) => { + const next = renderCgitrc({ assetDir, repos }); + if (next === cgitrcContent) return; + cgitrcContent = next; + fs.writeFileSync(configFile, next, 'utf8'); + }; + updateRepos(options.repos || []); + + return { + binary, + assetDir, + configFile, + workDir, + /** Keep the generated cgitrc in sync with the discovered repositories. */ + updateRepos, + /** Proxy a request to the cgit CGI program. */ + serveRequest(req, res) { + serveCgi(req, res, { command: binary, env: cgiEnv(req, { config, runtime: this }) }); + }, + /** + * Serve one of cgit's static assets (`/cgit.css`, `/favicon.ico`, …). + * Returns true when the response was written, false to fall through. + * @param {string} pathname + * @param {import('node:http').ServerResponse} res + */ + assetRequest(pathname, res) { + const name = pathname.slice(1); + if (!assetDir || !name || name.includes('/') || !Object.hasOwn(ASSET_TYPES, name)) { + return false; + } + let body; + try { + body = fs.readFileSync(path.join(assetDir, name)); + } catch { + return false; + } + res.writeHead(200, { + 'Content-Type': ASSET_TYPES[name], + 'Content-Length': body.length, + 'Cache-Control': 'public, max-age=3600', + }); + res.end(body); + return true; + }, + /** Delete the temporary directory holding the generated cgitrc. */ + dispose() { + try { + fs.rmSync(workDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }, + }; +} + +module.exports = { + BINARY_CANDIDATES, + ASSET_DIRS, + ASSET_TYPES, + which, + isExecutableFile, + findBinary, + findAssetDir, + locate, + locateExplicit, + renderCgitrc, + cgiEnv, + createRuntime, +}; \ No newline at end of file diff --git a/src/cli.js b/src/cli.js index 9adc751..ebedc22 100644 --- a/src/cli.js +++ b/src/cli.js @@ -6,6 +6,7 @@ const path = require('node:path'); const configStore = require('./config'); const { discover } = require('./repos'); const { createServer } = require('./server'); +const cgit = require('./cgit'); const { withPrompt, ask, confirm, select, colors: c } = require('./prompt'); const { isDirectory, expandPath } = require('./util'); @@ -18,7 +19,7 @@ const pkg = require('../package.json'); function parseArgs(argv) { const flags = {}; const positionals = []; - const takesValue = new Set(['dir', 'port', 'host', 'depth', 'config']); + const takesValue = new Set(['dir', 'port', 'host', 'depth', 'config', 'web-ui']); const shortMap = { d: 'dir', p: 'port', H: 'host', c: 'config', h: 'help', v: 'version' }; for (let i = 0; i < argv.length; i += 1) { @@ -76,6 +77,13 @@ function overridesFromFlags(flags) { if (flags.host) overrides.host = flags.host; if (flags.push !== undefined) overrides.allowPush = Boolean(flags.push); if (flags.web !== undefined) overrides.web = Boolean(flags.web); + if (flags['web-ui'] !== undefined) { + const value = String(flags['web-ui']).toLowerCase(); + if (value !== 'builtin' && value !== 'cgit') { + throw new Error('--web-ui must be either "builtin" or "cgit".'); + } + overrides.webInterface = value; + } if (flags.depth !== undefined) overrides.scanDepth = Number(flags.depth); return overrides; } @@ -100,83 +108,135 @@ async function gatherConfig({ base, overrides }) { process.stdout.write( `\n${c.bold('localgit setup')} — a few questions, then your USB becomes a git remote.\n\n`, ); + return gatherAnswers(rl, current); + }); +} - // 1. Where repos live. - let dir = current.dir || process.cwd(); - // eslint-disable-next-line no-constant-condition - while (true) { - const answer = await ask(rl, 'Where are all your git repositories stored?', { - default: dir, - }); - const abs = expandPath(answer); - if (isDirectory(abs)) { - dir = abs; - break; - } - const create = await confirm(rl, `"${abs}" does not exist. Create it?`, true); - if (create) { - fs.mkdirSync(abs, { recursive: true }); - dir = abs; - break; - } +/** + * Ask every setup question against `rl` and return the normalized config. + * Split out from `gatherConfig` so the flow can be tested with a fake + * readline without needing a TTY. + * @param {import('node:readline/promises').Interface} rl + * @param {object} current + * @returns {Promise} + */ +async function gatherAnswers(rl, current) { + // 1. Where repos live. + let dir = current.dir || process.cwd(); + // eslint-disable-next-line no-constant-condition + while (true) { + const answer = await ask(rl, 'Where are all your git repositories stored?', { + default: dir, + }); + const abs = expandPath(answer); + if (isDirectory(abs)) { + dir = abs; + break; } + const create = await confirm(rl, `"${abs}" does not exist. Create it?`, true); + if (create) { + fs.mkdirSync(abs, { recursive: true }); + dir = abs; + break; + } + } + + // 2. Which address to listen on. + const host = await select( + rl, + 'Which address should the server listen on?', + [ + { + label: '127.0.0.1 (this computer only)', + value: '127.0.0.1', + hint: 'safest default', + }, + { + label: '0.0.0.0 (all interfaces)', + value: '0.0.0.0', + hint: 'reachable from other devices on the network', + }, + ], + current.host === '0.0.0.0' ? 1 : 0, + ); + + // 3. Port. + const port = await ask(rl, 'Which port should the server use?', { + default: String(currentPort(current)), + validate: (value) => { + const n = Number(value); + if (!Number.isInteger(n) || n < 1 || n > 65535) return 'Enter a port between 1 and 65535.'; + return true; + }, + transform: Number, + }); + + // 4. Allow push? + const allowPush = await confirm( + rl, + 'Allow git push (receive-pack) to these repositories?', + current.allowPush !== false, + ); + + // 5. Web browser? + const web = await confirm( + rl, + 'Enable the web repository browser at / ?', + current.web !== false, + ); - // 2. Which address to listen on. - const host = await select( + // 5.b. Which rendering engine, when the browser is enabled? + let webInterface = 'builtin'; + if (web) { + const detected = cgit.locate(); + const cgitHint = detected + ? `detected at ${detected.binary}` + : 'not detected (install cgit or set CGIT_BINARY)'; + webInterface = await select( rl, - 'Which address should the server listen on?', + 'Which web interface should the browser use?', [ { - label: '127.0.0.1 (this computer only)', - value: '127.0.0.1', - hint: 'safest default', + label: 'Built-in (localgit)', + value: 'builtin', + hint: 'no extra software, always available', }, { - label: '0.0.0.0 (all interfaces)', - value: '0.0.0.0', - hint: 'reachable from other devices on the network', + label: 'cgit', + value: 'cgit', + hint: cgitHint, }, ], - current.host === '0.0.0.0' ? 1 : 0, - ); - - // 3. Port. - const port = await ask(rl, 'Which port should the server use?', { - default: String(currentPort(current)), - validate: (value) => { - const n = Number(value); - if (!Number.isInteger(n) || n < 1 || n > 65535) return 'Enter a port between 1 and 65535.'; - return true; - }, - transform: Number, - }); - - // 4. Allow push? - const allowPush = await confirm( - rl, - 'Allow git push (receive-pack) to these repositories?', - current.allowPush !== false, - ); - - // 5. Web browser? - const web = await confirm( - rl, - 'Enable the web repository browser at / ?', - current.web !== false, + current.webInterface === 'cgit' && detected ? 1 : 0, ); + if (webInterface === 'cgit' && !detected) { + process.stdout.write( + `${c.yellow('!')} cgit was not found on this system. The server will refuse to start ` + + `until you install cgit or point CGIT_BINARY at the binary.\n`, + ); + } + } - // 6. Scan depth. - const scanDepth = await ask(rl, 'How many folder levels deep should I scan for repos?', { - default: String(current.scanDepth || 3), - validate: (value) => { - const n = Number(value); - if (!Number.isInteger(n) || n < 0 || n > 10) return 'Enter a number between 0 and 10.'; - return true; - }, - transform: Number, - }); + // 6. Scan depth. + const scanDepth = await ask(rl, 'How many folder levels deep should I scan for repos?', { + default: String(current.scanDepth || 3), + validate: (value) => { + const n = Number(value); + if (!Number.isInteger(n) || n < 0 || n > 10) return 'Enter a number between 0 and 10.'; + return true; + }, + transform: Number, + }); - return configStore.normalize({ ...current, dir, host, port, allowPush, web, scanDepth }); + return configStore.normalize({ + ...current, + dir, + host, + port, + allowPush, + web, + webInterface, + scanDepth, }); } @@ -196,6 +256,8 @@ function printBanner(cfg, registry) { const shown = cfg.host === '0.0.0.0' ? '127.0.0.1' : cfg.host; const base = `http://${shown}:${cfg.port}`; const repos = registry.repos; + const interfaceLabel = + cfg.webInterface === 'cgit' ? c.dim(`cgit`) : c.dim('built-in'); const lines = [ '', `${c.green('✔')} ${c.bold('localgit is running')}`, @@ -203,7 +265,7 @@ function printBanner(cfg, registry) { ` Web UI ${c.cyan(`${base}/`)}`, ` Repos dir ${cfg.dir}`, ` Push ${cfg.allowPush ? c.green('enabled') : c.yellow('disabled')}`, - ` Web ${cfg.web ? c.green('enabled') : c.yellow('disabled')}`, + ` Web ${cfg.web ? c.green(`enabled (${interfaceLabel})`) : c.yellow('disabled')}`, '', ]; @@ -386,12 +448,17 @@ ${c.bold('Options')} -c, --config Use a specific config file --no-push Disable git push --no-web Disable the web browser + --web-ui Web interface: builtin (default) or cgit --setup Re-run the setup questions --yes Accept defaults / no prompts --no-save Do not write a config file after an interactive setup -h, --help Show this help -v, --version Print the version +${c.bold('Environment')} + CGIT_BINARY Path to the cgit executable (default: probe PATH) + CGIT_ASSETS Directory with cgit's css/js/logo files + ${c.bold('Examples')} localgit # interactive first run, then serve localgit --dir /media/usb/git --port 4000 # serve a USB folder @@ -434,4 +501,4 @@ async function main(argv = process.argv.slice(2)) { } } -module.exports = { main, parseArgs, runServe, runList, runSetupCommand }; +module.exports = { main, parseArgs, gatherConfig, gatherAnswers, runServe, runList, runSetupCommand }; diff --git a/src/config.js b/src/config.js index a8f458d..2883b0c 100644 --- a/src/config.js +++ b/src/config.js @@ -10,7 +10,10 @@ const { configDir, configPath, expandPath } = require('./util'); * port: number, * allowPush: boolean, * web: boolean, + * webInterface: 'builtin'|'cgit', * scanDepth: number, + * cgitCommand?: string, + * cgitAssets?: string, * createdAt?: string, * updatedAt?: string, * }} LocalgitConfig */ @@ -20,9 +23,20 @@ const DEFAULTS = { port: 4000, allowPush: true, web: true, + webInterface: 'builtin', scanDepth: 3, }; +/** + * Coerce `webInterface`. Anything other than "cgit" is treated as the + * built-in interface. + * @param {string|undefined} value + * @returns {'builtin'|'cgit'} + */ +function normalizeWebInterface(value) { + return String(value || '').toLowerCase() === 'cgit' ? 'cgit' : 'builtin'; +} + /** * Coerce and validate a partial config, filling in defaults. * @param {Partial} input @@ -36,9 +50,12 @@ function normalize(input = {}) { port: Number.isFinite(Number(input.port)) ? Number(input.port) : DEFAULTS.port, allowPush: input.allowPush === undefined ? DEFAULTS.allowPush : Boolean(input.allowPush), web: input.web === undefined ? DEFAULTS.web : Boolean(input.web), + webInterface: normalizeWebInterface(input.webInterface), scanDepth: input.scanDepth === undefined ? DEFAULTS.scanDepth : Number(input.scanDepth), }; + if (input.cgitCommand) config.cgitCommand = String(input.cgitCommand); + if (input.cgitAssets) config.cgitAssets = String(input.cgitAssets); if (input.createdAt) config.createdAt = input.createdAt; if (input.updatedAt) config.updatedAt = input.updatedAt; return config; @@ -94,6 +111,7 @@ function save(config, file = resolveConfigPath()) { module.exports = { DEFAULTS, normalize, + normalizeWebInterface, resolveConfigPath, configDir, load, diff --git a/src/git-http.js b/src/git-http.js index 4072600..60148e4 100644 --- a/src/git-http.js +++ b/src/git-http.js @@ -1,13 +1,15 @@ 'use strict'; -const { spawn } = require('node:child_process'); +const { serveCgi } = require('./cgi'); /** * Bridge a Node request to `git http-backend` (CGI), giving clients the smart * HTTP protocol: clone, fetch, pull and push. * * `git http-backend` speaks CGI. It reads the request body from stdin and - * writes CGI response headers + body to stdout. We translate both directions. + * writes CGI response headers + body to stdout. The translation lives in + * `src/cgi.js`; this module only decides whether the request may push and + * builds the CGI environment. * * @param {import('node:http').IncomingMessage} req * @param {import('node:http').ServerResponse} res @@ -27,7 +29,6 @@ async function serveGitHttp(req, res, options) { pathInfo, allowPush, updateWorkTree = true, - maxBodyBytes = 2 * 1024 * 1024 * 1024, } = options; if (req.method === 'POST' && !allowPush) { @@ -36,160 +37,17 @@ async function serveGitHttp(req, res, options) { return; } - const isBodyless = req.method === 'GET' || req.method === 'HEAD'; - const contentLength = Number(req.headers['content-length']); - const hasContentLength = Number.isFinite(contentLength) && contentLength >= 0; - - let bufferedBody = null; - if (!isBodyless && !hasContentLength) { - // Chunked transfer encoding: buffer so we can hand the backend a - // CONTENT_LENGTH, which CGI requires. - try { - bufferedBody = await readBody(req, maxBodyBytes); - } catch (error) { - res.writeHead(413, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end(`${error.message}\n`); - return; - } - } - const env = buildEnv(req, { projectRoot, pathInfo, allowPush, updateWorkTree }); - if (bufferedBody) env.CONTENT_LENGTH = String(bufferedBody.length); - const child = spawn('git', ['http-backend'], { + serveCgi(req, res, { + command: 'git', + args: ['http-backend'], env, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - - 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} */ - 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 || 'git http-backend 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 git http-backend: ${error.message}\n`); - } else if (!res.writableEnded) { - res.end(); - } - }); - - child.on('close', (code) => { - if (code !== 0 && stderr) process.stderr.write(`[localgit] git http-backend: ${stderr}`); - if (!res.writableEnded) res.end(); - }); - - // Feed the request body into the backend. - 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(); - }); -} - -/** - * Collect a request body, rejecting once `limit` bytes is exceeded. - * @param {import('node:http').IncomingMessage} req - * @param {number} limit - * @returns {Promise} - */ -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); + label: 'git http-backend', + maxBodyBytes: options.maxBodyBytes, + onExit: (code, stderr) => { + if (code !== 0 && stderr) process.stderr.write(`[localgit] git http-backend: ${stderr}`); + }, }); } diff --git a/src/index.js b/src/index.js index 3ceaa35..6df59cd 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const config = require('./config'); const repos = require('./repos'); const { createServer } = require('./server'); const web = require('./web'); +const cgit = require('./cgit'); module.exports = { ...config, @@ -21,4 +22,5 @@ module.exports = { isGitDir: repos.isGitDir, createServer, web, + cgit, }; diff --git a/src/server.js b/src/server.js index 31c1ec8..e707cd8 100644 --- a/src/server.js +++ b/src/server.js @@ -7,6 +7,7 @@ const path = require('node:path'); const { discover, buildIndex } = require('./repos'); const { serveGitHttp } = require('./git-http'); const web = require('./web'); +const cgit = require('./cgit'); /** * @typedef {{ @@ -15,7 +16,10 @@ const web = require('./web'); * port: number, * allowPush: boolean, * web: boolean, + * webInterface: 'builtin'|'cgit', * scanDepth: number, + * cgitCommand?: string, + * cgitAssets?: string, * }} ServerConfig */ @@ -100,6 +104,12 @@ function createServer(config, options = {}) { }); registry.refresh(true); + // When cgit is selected a runtime is created up front (locating the binary + // and generating a throwaway cgitrc); a missing binary surfaces here as a + // clear error instead of returning blank pages later. + const cgitRuntime = + config.web && config.webInterface === 'cgit' ? cgit.createRuntime(config, options.cgit || {}) : null; + const serve = (req, res) => handle(req, res).catch((error) => { if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); @@ -109,12 +119,20 @@ function createServer(config, options = {}) { const handle = async (req, res) => { registry.refresh(); + if (cgitRuntime) cgitRuntime.updateRepos(registry.repos); const host = req.headers.host || `${config.host}:${config.port}`; const base = `http://${host}`; const requestUrl = new URL(req.url || '/', base); const pathname = requestUrl.pathname; + // cgit's static assets live at the web root; serve them from the asset + // directory before treating them as repositories. + if (cgitRuntime) { + if (pathname === '/favicon.ico' && cgitRuntime.assetRequest(pathname, res)) return; + if (segmentsOf(pathname).length === 1 && cgitRuntime.assetRequest(pathname, res)) return; + } + if (pathname === '/favicon.ico') { res.writeHead(204); res.end(); @@ -126,6 +144,7 @@ function createServer(config, options = {}) { // Root: repository listing. if (segments.length === 0) { if (!config.web) return notFound(res, base, 'Web browser is disabled.'); + if (cgitRuntime) return cgitRuntime.serveRequest(req, res); const html = await web.renderHome({ repos: registry.repos, base, @@ -172,6 +191,8 @@ function createServer(config, options = {}) { return notFound(res, base, 'Web browser is disabled.'); } + if (cgitRuntime) return cgitRuntime.serveRequest(req, res); + await routeWeb(req, res, requestUrl, repo, base, segments.slice(consumed)); }; @@ -243,6 +264,7 @@ function createServer(config, options = {}) { server.close((error) => (error ? reject(error) : resolve())); // fetch()/undici keeps connections alive; drop them so close() resolves. if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); + if (cgitRuntime) cgitRuntime.dispose(); }), }; } diff --git a/test/cgit.test.js b/test/cgit.test.js new file mode 100644 index 0000000..9479c91 --- /dev/null +++ b/test/cgit.test.js @@ -0,0 +1,270 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { createServer } = require('../src/server'); +const configStore = require('../src/config'); +const cgit = require('../src/cgit'); + +function tmpdir(prefix = 'localgit-cgit-') { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +function baseConfig(dir, overrides = {}) { + return { + dir, + host: '127.0.0.1', + port: 0, + allowPush: true, + web: true, + webInterface: 'builtin', + scanDepth: 3, + ...overrides, + }; +} + +function fakeCgi(dir, body) { + const file = path.join(dir, 'fake-cgit'); + fs.writeFileSync( + file, + `#!/bin/sh\n${body}\n`, + ); + fs.chmodSync(file, 0o755); + return file; +} + +async function startServer(t, config, options) { + const { server, registry, close } = createServer(config, { ttl: 0, ...options }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const { port } = server.address(); + t.after(async () => { + server.closeAllConnections?.(); + await close(); + }); + return { base: `http://127.0.0.1:${port}`, registry }; +} + +test('config normalize defaults to the built-in interface and accepts cgit', () => { + assert.equal(configStore.normalize({}).webInterface, 'builtin'); + assert.equal(configStore.normalize({ webInterface: 'cgit' }).webInterface, 'cgit'); + assert.equal(configStore.normalize({ webInterface: 'CGIT' }).webInterface, 'cgit'); + assert.equal(configStore.normalize({ webInterface: 'whatever' }).webInterface, 'builtin'); + assert.equal(configStore.DEFAULTS.webInterface, 'builtin'); +}); + +test('renderCgitrc creates a self-contained cgitrc for the served repository set', () => { + const repos = [ + { name: 'demo.git', gitDir: '/media/usb/git/demo.git' }, + { name: 'plain', gitDir: '/media/usb/git/plain/.git' }, + ]; + const rc = cgit.renderCgitrc({ repos }); + assert.match(rc, /^virtual-root=\//m); + assert.match(rc, /^repo\.url=demo\.git\nrepo\.path=\/media\/usb\/git\/demo\.git/m); + assert.match(rc, /^repo\.url=plain\nrepo\.path=\/media\/usb\/git\/plain\/\.git/m); + assert.match(rc, /^cache-size=0/m); + // With an asset dir cgit's files are referenced under the web root … + const withAssets = cgit.renderCgitrc({ assetDir: '/usr/share/cgit', repos }); + assert.match(withAssets, /^css=\/cgit\.css/m); + // … and without one the resource links are blanked out. + const without = cgit.renderCgitrc({ assetDir: null, repos }); + assert.match(without, /^css=$/m); +}); + +test('findBinary resolves an explicit path and a bare name on PATH', () => { + const dir = tmpdir(); + fs.writeFileSync(path.join(dir, 'cgit-stub'), '#!/bin/sh\nexit 0\n'); + fs.chmodSync(path.join(dir, 'cgit-stub'), 0o755); + + assert.equal(cgit.findBinary(path.join(dir, 'cgit-stub')), path.join(dir, 'cgit-stub')); + + const savedPath = process.env.PATH; + process.env.PATH = `${dir}:/usr/bin:/bin`; + try { + assert.equal(cgit.findBinary('cgit-stub'), path.join(dir, 'cgit-stub')); + } finally { + process.env.PATH = savedPath; + } + + // Explicit configuration is strict: a bad path never falls back to another + // cgit, while an existing one is used verbatim. + assert.equal(cgit.locateExplicit(path.join(dir, 'missing-cgit')), null); + assert.equal(cgit.locateExplicit(path.join(dir, 'cgit-stub')), path.join(dir, 'cgit-stub')); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('cgit interface proxies requests to the CGI program', { timeout: 30000 }, async (t) => { + const root = tmpdir(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + git(['init', '--bare', '-q', path.join(root, 'demo.git')]); + git(['--git-dir', path.join(root, 'demo.git'), 'symbolic-ref', 'HEAD', 'refs/heads/main']); + + // A fake CGI that echoes the CGI environment localgit hands it. + const script = fakeCgi( + root, + ` +printf 'Content-Type: text/plain\\n\\n' +printf 'method=%s\\n' "$REQUEST_METHOD" +printf 'path=%s\\n' "$PATH_INFO" +printf 'query=%s\\n' "$QUERY_STRING" +printf 'root=%s\\n' "$CGIT_CONFIG" +`, + ); + const { base } = await startServer( + t, + baseConfig(root, { webInterface: 'cgit' }), + { cgit: { command: script } }, + ); + + const home = await fetch(`${base}/?q=search`); + assert.equal(home.status, 200); + const body = await home.text(); + assert.match(body, /method=GET/); + assert.match(body, /path=\//); + assert.match(body, /query=q=search/); + assert.match(body, /root=.*cgitrc/); + + const tree = await fetch(`${base}/demo.git/tree/?h=main`); + assert.match(await tree.text(), /path=\/demo\.git\/tree\//); + + // Git endpoints are still served by git http-backend, not passed to cgit. + const refs = await fetch(`${base}/demo.git/info/refs?service=git-upload-pack`); + assert.equal(refs.status, 200); + assert.equal(refs.headers.get('content-type'), 'application/x-git-upload-pack-advertisement'); +}); + +test('cgit interface serves static assets from the asset directory', { timeout: 30000 }, async (t) => { + const root = tmpdir(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + git(['init', '--bare', '-q', path.join(root, 'demo.git')]); + + const assets = path.join(root, 'assets'); + fs.mkdirSync(assets); + fs.writeFileSync(path.join(assets, 'cgit.css'), 'body { color: red; }\n'); + + const { base } = await startServer( + t, + baseConfig(root, { webInterface: 'cgit' }), + { + cgit: { + command: fakeCgi(root, `printf 'Content-Type: text/plain\\n\\n'`), + assetDir: assets, + }, + }, + ); + + const css = await fetch(`${base}/cgit.css`); + assert.equal(css.status, 200); + assert.equal(css.headers.get('content-type'), 'text/css; charset=utf-8'); + assert.match(await css.text(), /color: red/); + + // Unknown asset requests fall through to the normal router (404 here). + assert.equal((await fetch(`${base}/nope.css`)).status, 404); +}); + +test('interactive setup asks for and honors the cgit web interface', async () => { + const { gatherAnswers } = require('../src/cli'); + const answers = ['', '', '', '', '', '2', '']; + const rl = { + async question() { + const next = answers.shift(); + return next === undefined ? '' : next; + }, + }; + const cfg = await gatherAnswers(rl, {}); + assert.equal(cfg.webInterface, 'cgit'); + assert.equal(cfg.web, true); + assert.equal(cfg.allowPush, true); + assert.equal(cfg.port, 4000); + assert.equal(cfg.scanDepth, 3); + + // The built-in interface stays the default. + const rl2 = { + async question() { + return ''; + }, + }; + const cfg2 = await gatherAnswers(rl2, {}); + assert.equal(cfg2.webInterface, 'builtin'); + + // When the browser is disabled, no interface question is asked. + const rl3 = { + async question() { + return ''; + }, + /** the confirm() for "Enable the web repository browser" */ + }; + const cfg3 = await gatherAnswers(rl3, { web: false }); + assert.equal(cfg3.web, false); + assert.equal(cfg3.webInterface, 'builtin'); +}); + +test('createServer fails clearly when cgit is enabled but missing', () => { + assert.throws( + () => + createServer(baseConfig(tmpdir(), { webInterface: 'cgit' }), { + cgit: { command: '/nonexistent/cgit' }, + }), + /no cgit executable was found/, + ); +}); + +test('cgit interface renders real cgit pages when cgit is installed', { timeout: 60000 }, async (t) => { + const located = cgit.locate(); + if (!located) { + t.skip('cgit binary not available on this system'); + return; + } + const root = tmpdir(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + git(['init', '--bare', '-q', path.join(root, 'demo.git')]); + git(['--git-dir', path.join(root, 'demo.git'), 'symbolic-ref', 'HEAD', 'refs/heads/main']); + git(['init', '-q', path.join(root, 'plain')]); + git(['-C', path.join(root, 'plain'), 'config', 'user.email', 't@e.st']); + git(['-C', path.join(root, 'plain'), 'config', 'user.name', 'T']); + fs.writeFileSync(path.join(root, 'plain', 'hello.txt'), 'hello\n'); + git(['-C', path.join(root, 'plain'), 'add', '.']); + git(['-C', path.join(root, 'plain'), 'commit', '-qm', 'first']); + git(['-C', path.join(root, 'plain'), 'branch', '-M', 'main']); + + const { base } = await startServer(t, baseConfig(root, { webInterface: 'cgit' })); + + const home = await fetch(`${base}/`); + assert.equal(home.status, 200); + const homeText = await home.text(); + assert.match(homeText, /cgit/); + assert.match(homeText, /demo\.git/); + // Work trees keep their localgit name (not cgit's "plain/.git"). + assert.match(homeText, /href='\/plain\/'>/); + + const repo = await fetch(`${base}/demo.git/`); + assert.equal(repo.status, 200); + assert.match(await repo.text(), /summary/); + + const work = await fetch(`${base}/plain/tree/`); + assert.equal(work.status, 200); + assert.match(await work.text(), /hello\.txt/); + + // Static assets are served when the asset directory is discoverable. + if (located.assetDir) { + const css = await fetch(`${base}/cgit.css`); + assert.equal(css.status, 200); + assert.match(css.headers.get('content-type'), /text\/css/); + } + + // Smart HTTP still works next to cgit. + const refs = await fetch(`${base}/demo.git/info/refs?service=git-upload-pack`); + assert.equal(refs.status, 200); + assert.equal(refs.headers.get('content-type'), 'application/x-git-upload-pack-advertisement'); +}); \ No newline at end of file