Files
AgentHub/server/lib/git-diff.js
T
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00

1232 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Reads a lane's code changes as structured hunks, for the Workspace's
* Changes panel (overview / review / single-file / history / file). Three
* ranges, because the questions a lane raises are different: `work` is "what
* has the agent touched and not committed yet" (HEAD vs the working tree,
* untracked files included), `branch` is "what does this lane add on top of
* its base" (`<base>...HEAD`), and `commit` is one recorded commit.
*
* Everything here is READ-ONLY and every git call goes through worktree.js's
* `git()` argv wrapper — no shell strings, same rule as the rest of the lane
* git surface. Untracked files are rendered as an all-added patch from the file
* itself rather than via `git diff --no-index`, which exits non-zero on a
* difference and would read as a failure.
*
* Output is capped in three places (files listed, bytes per file, bytes in
* total) because this rides an HTTP response a browser has to render: a lane
* mid-`npm install` or holding a committed lockfile would otherwise ship
* megabytes of patch to a React list. A cap that bites sets `truncated`.
*
* The lane's cwd is the boundary, even when it is a SUBDIRECTORY of its repo:
* every path in and out is cwd-relative, status entries outside the cwd are
* dropped, and every git call treats a path as a literal file name, never a
* pathspec glob (`GIT_LITERAL_PATHSPECS`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const worktree = require("./worktree");
/** Every git call here: argv only, and a path argument is a literal name. */
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/**
* A git READ whose output this module caps itself: the buffer is sized past
* the cap (x4, a UTF-8 char is at most 4 bytes, plus headroom) so git's own
* buffer can never be what fails first, and an overflow comes back as the
* partial output — longer than `capChars`, so the caller's cap marks it
* truncated — instead of an error read as "missing".
*/
async function readCapped(dir, args, capChars) {
try {
return (await git(dir, args, { maxBuffer: capChars * 4 + 64 * 1024 })).stdout;
} catch (err) {
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && err.git?.stdout) {
return String(err.git.stdout);
}
throw err;
}
}
/** Most files one response names. Beyond this the list is `truncated`. */
const MAX_FILES = 300;
/** Most patch bytes read for ONE file before its hunks are cut short. */
const MAX_FILE_BYTES = 200_000;
/** Most patch bytes across all files in a `full` (review-mode) response. */
const MAX_TOTAL_BYTES = 1_000_000;
/** An untracked file larger than this is reported without content. */
const MAX_UNTRACKED_BYTES = 1_000_000;
/** Most bytes `readFileAt` returns before cutting the tail off. */
const MAX_FILE_VIEW_BYTES = 1_000_000;
/** Most paths `listPaths`/`listTree` answer with. */
const MAX_PATHS = 500;
/** Characters of `ls-files -z` read before the listing counts as truncated. */
const MAX_LS_CHARS = 16 * 1024 * 1024;
/** Lines of unchanged context around a hunk, and the ceiling "expand all"
* is clamped to — a whole large file rendered as context is the one way this
* panel can be made to ship more bytes than the caps above allow. */
const DEFAULT_CONTEXT = 3;
const MAX_CONTEXT = 5000;
const VALID_RANGES = new Set(["work", "branch", "commit"]);
/** A commit-ish this module will pass to git. Deliberately strict: a sha, or a
* ref name without the characters that would let it read as an option or a
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
/** The file whose CONTENT this module never returns, at any rev and at ANY
* depth: `settings.local.json` is where AgentHub once wrote a lane's AI
* Proxy API key, and key material never leaves the server. Any depth,
* because a lane at the repo root sees a subdirectory lane's key as
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
* filesystem (macOS) opens it under any casing. */
const SECRET_FILE = ".claude/settings.local.json";
const SECRET_BASENAME = path.posix.basename(SECRET_FILE);
/** Whether a lane-relative path names a secret file, however it is spelled. */
function isSecret(rel) {
const norm = path.posix
.normalize(String(rel).replace(/\\/g, "/"))
.replace(/^\.\//, "")
.toLowerCase();
// By BASENAME, in any directory: a `.claude` symlinked to `conf/` stores the
// key as `conf/settings.local.json`, and a lane above that one (or a commit
// of that target) never spells it through `.claude/`. A file with this name
// is Claude Code's local settings in practice, so nothing legitimate is lost.
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
}
/**
* S3: whether `abs` is a dot entry at the TOP of a member's home — `.claude`,
* `.codex`, `.config/gh`, `.ssh`, `.git-credentials`, `.agenthub/handoff`…:
* where that member's logins and pane tokens live. A workspace is a folder
* under the home, so this never hides project files; it closes a workspace
* rooted AT the home (made before that was refused, or a symlink) to anyone
* reading it, analysts included. Not by basename: `auth`/`token` patterns
* would hide `AuthGate.tsx` in every member workspace.
*/
function inMemberDotDir(abs) {
const runAs = require("./run-as");
const acct = require("./linux-accounts").s3Enabled() ? runAs.ownerForRead(abs) : null;
if (!acct) return false;
const rel = path.relative(acct.home, abs);
return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith(".");
}
/**
* The real path of a workspace folder for a filesystem read or write (S3:
* refused, EFOREIGN, when it resolves into another account's files). The
* account a read or write runs as comes from the PATH (run-as.js), so a
* member who swaps their workspace folder for a link to another member's
* folder, the host's home or /etc must not have the dashboard follow it.
*/
async function laneRoot(dir) {
const root = await fs.promises.realpath(dir);
if (!require("./run-as").sameOwner(path.resolve(dir), root)) {
throw badRequest("EFOREIGN", "the workspace folder resolves outside its owner's files");
}
return root;
}
/**
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
* `conf/settings.local.json`, which no name check catches. Secret when the
* real path, relative to the lane's real root, is one by `isSecret`, or when
* it is the very file `<lane>/.claude/settings.local.json` resolves to. A
* path that does not exist on disk is judged by its name alone.
*/
async function isSecretOnDisk(dir, file) {
if (isSecret(file) || inMemberDotDir(path.resolve(dir, file))) return true;
let root;
let real;
try {
root = await fs.promises.realpath(dir);
real = await fs.promises.realpath(path.resolve(root, file));
} catch {
return false;
}
if (isSecret(path.relative(root, real)) || inMemberDotDir(real)) return true;
try {
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
} catch {
return false;
}
}
/**
* The ONE lexical guard for a caller-supplied path, at every rev: a relative
* path with no `..` segment, normalised (`./a//b` → `a/b`) so the secret
* check and git both see the lane-relative spelling. Throws EBADPATH.
*/
function lanePath(file) {
if (typeof file !== "string" || !file || file.includes("\0")) {
throw badRequest("EBADPATH", "path must be a relative path inside the workspace");
}
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
if (file.startsWith("/") || file.split("/").includes("..")) {
throw badRequest("EBADPATH", "path must be a relative path inside the workspace");
}
const norm = path.posix
.normalize(file)
.replace(/^(\.\/)+/, "")
.replace(/\/+$/, "");
if (!norm || norm === ".") throw badRequest("EBADPATH", "path must name a file");
return norm;
}
/**
* Resolve `file` inside the lane for a CONTENT read: realpath-confined to the
* lane's own realpath, so neither `..` nor a symlink can walk out of it, and
* refused when either the requested or the resolved path is a secret file.
* Throws EBADPATH / ESECRET / ENOFILE.
*/
async function resolveInLane(dir, file) {
if (isSecret(file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
const escapes = (base, target) => {
const r = path.relative(base, target);
return !r || r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
};
// Lexically first, so `../x` is EBADPATH whether or not x exists.
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
throw badRequest("EBADPATH", "path escapes the workspace directory");
}
const root = await laneRoot(dir);
let real;
try {
real = await fs.promises.realpath(path.resolve(root, file));
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the workspace directory");
if (await isSecretOnDisk(root, file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
return real;
}
/** Throw a coded error the routes map onto a 400. */
function badRequest(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/** Clamp a caller's context request into [0, MAX_CONTEXT]. */
function normalizeContext(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT;
return Math.min(Math.floor(n), MAX_CONTEXT);
}
/**
* Parse one file's unified diff into hunks with both gutters resolved, so the
* client renders rows instead of re-parsing `@@` headers.
*
* Returns `[]` for an empty patch (an unchanged file, or a mode-only change).
*/
function parseUnifiedDiff(patch) {
const hunks = [];
let current = null;
let oldLine = 0;
let newLine = 0;
for (const raw of patch.split("\n")) {
const header = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
if (header) {
oldLine = Number(header[1]);
newLine = Number(header[2]);
current = {
header: raw,
context: header[3].trim(),
oldStart: oldLine,
newStart: newLine,
lines: [],
};
hunks.push(current);
continue;
}
if (!current) continue; // preamble: ---/+++/index/diff lines
// "\ No newline at end of file" annotates the previous line; it is not a
// row of its own and carries no line number.
if (raw.startsWith("\\")) continue;
const marker = raw[0];
const text = raw.slice(1);
if (marker === "+") {
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
} else if (marker === "-") {
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
} else if (marker === " ") {
// An unchanged EMPTY line is " " — one space, no text. A truly empty
// string is the tail of the final "\n", not a row.
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
}
}
return hunks;
}
/** Whether a buffer looks binary — the same NUL-byte heuristic git uses. */
function looksBinary(buf) {
return buf.includes(0);
}
/**
* Resolve the ref a `branch`-range diff measures against: `origin/<base>` when
* the remote-tracking ref exists, else the local branch, else null (a lane with
* no base, or a base that has since been deleted — reported, not guessed).
*/
async function resolveBaseRef(dir, base) {
if (!base) return null;
for (const ref of [`origin/${base}`, base]) {
try {
await git(dir, ["rev-parse", "--verify", "--quiet", ref]);
return ref;
} catch {
/* try the next candidate */
}
}
return null;
}
/**
* The repo's default branch, for a lane that recorded no base of its own (an
* adopted folder, a session's `claude --worktree` tree): what `origin/HEAD`
* points at, else `main` or `master` when one exists, else null.
*/
async function defaultBranch(dir) {
const head = await git(dir, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]).then(
(r) => r.stdout.trim(),
() => ""
);
if (head.startsWith("origin/")) return head.slice("origin/".length);
for (const name of ["main", "master"]) {
if (await resolveBaseRef(dir, name)) return name;
}
return null;
}
/**
* `git diff --numstat -z` output → Map<path, {added, deleted, binary}>. `-z`
* because without it git C-quotes any path with a space, a quote or a
* non-ASCII byte (`"t\303\240i.txt"`), which then keys nothing. A rename is
* `a\td\t\0old\0new\0`; the path that exists now is the key. Shared with
* worktree.js's `laneFiles` (the lane card), so the card and the Changes panel
* count a file the same way.
*/
function parseNumstat(stdout) {
const stats = new Map();
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const m = tokens[i].replace(/^\n/, "").match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
if (!m) continue;
let file = m[3];
if (!file) {
file = tokens[i + 2];
i += 2;
}
if (!file) continue;
stats.set(file, {
added: m[1] === "-" ? null : Number(m[1]),
deleted: m[2] === "-" ? null : Number(m[2]),
binary: m[1] === "-",
});
}
return stats;
}
/**
* `git status --porcelain=v1 -z` → [{code, path, from}]. Each record is
* `XY path\0`; a rename/copy carries its ORIGINAL path as the next record.
* Shared with git-ops.js so the paths a write may name are the paths shown.
*/
function parsePorcelainZ(stdout) {
const entries = [];
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const rec = tokens[i];
if (rec.length < 4) continue;
const code = rec.slice(0, 2);
const entry = { code, path: rec.slice(3), from: null };
if (code[0] === "R" || code[0] === "C" || code[1] === "R" || code[1] === "C") {
entry.from = tokens[++i] || null;
}
entries.push(entry);
}
return entries;
}
/**
* `git status` for the LANE: porcelain paths are always repo-ROOT-relative,
* while every pathspec and every fs read here resolves from the lane's cwd.
* So entries are cut down to the ones inside the cwd and re-spelled relative
* to it — a subdirectory lane neither shows nor can name a change outside
* itself. A rename whose original path is outside keeps `from: null`.
* Shared with git-ops.js so the paths a write may name are the paths shown.
* `--untracked-files=all` (#64): `normal` folds a wholly untracked folder
* into one "dir/" entry, which the panel can neither open nor diff — every
* new file is listed instead, as JetBrains and VS Code do.
*/
async function laneStatus(dir) {
const [status, prefix] = await Promise.all([
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
git(dir, ["rev-parse", "--show-prefix"]),
]);
const pre = prefix.stdout.trim();
const inside = (p) => (p && p.startsWith(pre) ? p.slice(pre.length) || null : null);
const entries = [];
for (const e of parsePorcelainZ(status.stdout)) {
const rel = inside(e.path);
if (rel) entries.push({ code: e.code, path: rel, from: inside(e.from) });
}
return entries;
}
/**
* Porcelain codes that mean "unmerged" — a merge or a rebase left this file
* with conflict markers. git's own list; `UU` is the common one, but a
* delete/modify conflict reports `DU`/`UD` and an add/add reports `AA`.
*/
const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
async function summarizeWork(dir) {
const [entries, numstat] = await Promise.all([
laneStatus(dir),
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })),
]);
const stats = parseNumstat(numstat.stdout);
const files = [];
for (const { code, path: file } of entries) {
const untracked = code === "??";
const stat = stats.get(file);
// One read per untracked file, not two: its line count and its
// binary-ness are the same buffer answered twice.
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
const fresh = untracked ? await untrackedFacts(dir, file) : null;
files.push({
path: file,
status: code,
untracked,
// The two porcelain columns are the index and the working tree, which
// is the whole of what per-file staging needs to render: a file can be
// BOTH (edited, staged, edited again) and the row has to say so rather
// than pick one.
staged: !untracked && code[0] !== " " && code[0] !== "?",
unstaged: untracked || (code[1] !== " " && code[1] !== "?"),
conflicted: CONFLICT_CODES.has(code),
added: untracked ? fresh.lines : (stat?.added ?? 0),
deleted: untracked ? 0 : (stat?.deleted ?? 0),
binary: untracked ? fresh.binary : !!stat?.binary,
});
}
return files;
}
/**
* An untracked file's line count and binary-ness, from ONE read.
*
* `lines` is null when the file is too big, binary, or unreadable — git has no
* delta for an untracked file either, and a null there already means "unknown"
* everywhere else in this response. A file over the cap counts as binary so
* nothing downstream tries to render it.
*/
async function untrackedFacts(dir, file) {
try {
const abs = await resolveInLane(dir, file);
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
return { lines: null, binary: true };
const buf = await require("./run-as").readFileSafeAsync(abs);
if (looksBinary(buf)) return { lines: null, binary: true };
const text = buf.toString("utf8");
if (!text) return { lines: 0, binary: false };
const lines = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
return { lines, binary: false };
} catch {
// A directory entry (git reports untracked DIRS with a trailing slash), a
// dangling symlink, a file deleted between the status call and this read,
// or one resolveInLane refuses (a secret, a symlink out of the lane).
return { lines: null, binary: false };
}
}
/** Branch summary: `<baseRef>...HEAD`, name-status for the letter, numstat for counts. */
async function summarizeBranch(dir, baseRef) {
const spec = `${baseRef}...HEAD`;
const [nameStatus, numstat] = await Promise.all([
git(dir, ["diff", "--name-status", "-z", "--relative", spec]),
git(dir, ["diff", "--numstat", "-z", "--relative", spec]),
]);
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
}
/**
* One commit's summary. `git show` against a MERGE commit prints nothing by
* default (it diffs against no single parent), so `-m --first-parent` picks
* the mainline side — which is what "what did this commit bring in" means to
* a reader scrolling a branch's history.
*/
async function summarizeCommit(dir, sha) {
const base = ["show", "--format=", "-m", "--first-parent", "--relative", sha];
const [nameStatus, numstat] = await Promise.all([
git(dir, [...base, "--name-status", "-z"]),
git(dir, [...base, "--numstat", "-z"]),
]);
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
}
/** Shared `--name-status -z` reader for the ranges that have no working tree. */
function parseNameStatus(stdout, stats) {
const files = [];
const seen = new Set();
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const status = tokens[i].replace(/^\n/, "");
if (!/^[A-Z]/.test(status)) continue;
const letter = status[0];
// A rename/copy is "R100\0old\0new" — the new path is the live one.
const file = letter === "R" || letter === "C" ? tokens[(i += 2)] : tokens[++i];
if (!file) continue;
// `-m` prints one diff per parent for a merge; --first-parent keeps it to
// one, but a defensive de-dup costs nothing and a doubled row is a lie.
if (seen.has(file)) continue;
seen.add(file);
const stat = stats.get(file);
files.push({
path: file,
status: `${letter} `,
untracked: false,
staged: false,
unstaged: false,
conflicted: false,
added: stat?.added ?? 0,
deleted: stat?.deleted ?? 0,
binary: !!stat?.binary,
});
}
return files;
}
/** One file's patch text, for any range. Empty string when there is none. */
async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) {
// A secret file may be tracked by mistake; its hunks would carry the key —
// under its own name, or under a symlinked `.claude`'s target name.
if (await isSecretOnDisk(dir, file)) return "";
if (untracked) return synthesizeAddedPatch(dir, file);
const u = `-U${context}`;
const args =
range === "commit"
? ["show", "--format=", "-m", "--first-parent", u, sha, "--", file]
: range === "branch"
? ["diff", u, `${baseRef}...HEAD`, "--", file]
: ["diff", u, "HEAD", "--", file];
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
}
/**
* An untracked file has no git-side "before", so its patch is synthesized: one
* hunk starting at line 1 with every line added. This is what `git diff
* --no-index /dev/null <file>` would print, without the non-zero exit that
* wrapper treats as a failure.
*/
async function synthesizeAddedPatch(dir, file) {
let buf;
try {
buf = await require("./run-as").readFileSafeAsync(await resolveInLane(dir, file));
} catch {
return "";
}
if (looksBinary(buf)) return "";
const text = buf.toString("utf8");
if (!text) return "";
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
const body = lines.map((l) => `+${l}`).join("\n");
return `@@ -0,0 +1,${lines.length} @@\n${body}\n`;
}
/**
* The lane's changes for one range.
*
* @param {string} dir Lane working directory.
* @param {object} opts
* @param {"work"|"branch"|"commit"} [opts.range="work"]
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`;
* none falls back to the repo's default branch (`defaultBranch`).
* @param {string|null} [opts.sha] The commit to read, for `range="commit"`.
* @param {string|null} [opts.file] Only this path, with its hunks (single-file mode).
* @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when
* `file` is set, which is already a full read of one file.
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
* raises this to expand a diff towards the whole file.
* @returns {Promise<{range, base, baseRef, mergeBase, sha, context, files, truncated}>}
*/
async function collectChanges(
dir,
{ range = "work", base = null, sha = null, file = null, full = false, context } = {}
) {
if (!VALID_RANGES.has(range)) {
throw badRequest("EBADRANGE", `unknown range ${range}`);
}
const ctx = normalizeContext(context);
let baseRef = null;
// The commit a branch diff is taken from (`<baseRef>...HEAD`): the editor
// reads a file's original text there.
let mergeBase = null;
if (range === "branch") {
base = base || (await defaultBranch(dir));
baseRef = await resolveBaseRef(dir, base);
if (!baseRef) {
throw badRequest("ENOBASE", "no base branch to compare against");
}
mergeBase = await git(dir, ["merge-base", baseRef, "HEAD"])
.then((r) => r.stdout.trim() || null)
.catch(() => null);
}
if (range === "commit") {
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
try {
// Resolve it once, so a bad ref is a 400 here rather than an empty diff
// three calls later, and so the response reports the full sha.
sha = (await git(dir, ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`])).stdout.trim();
} catch {
throw badRequest("EBADREV", "no such commit in this repository");
}
}
let files =
range === "branch"
? await summarizeBranch(dir, baseRef)
: range === "commit"
? await summarizeCommit(dir, sha)
: await summarizeWork(dir);
files.sort((a, b) => a.path.localeCompare(b.path));
let truncated = false;
if (file) {
const one = files.find((f) => f.path === file);
if (!one) {
throw badRequest("ENOFILECHANGE", "file has no changes in this range");
}
files = [one];
} else if (files.length > MAX_FILES) {
files = files.slice(0, MAX_FILES);
truncated = true;
}
if (file || full) {
let budget = file ? MAX_FILE_BYTES : MAX_TOTAL_BYTES;
for (const entry of files) {
if (entry.binary) {
entry.hunks = [];
continue;
}
if (budget <= 0) {
entry.hunks = [];
entry.truncated = true;
truncated = true;
continue;
}
let patch = await patchFor(dir, {
range,
baseRef,
sha,
file: entry.path,
untracked: entry.untracked,
context: ctx,
});
const cap = Math.min(MAX_FILE_BYTES, budget);
if (patch.length > cap) {
// Cut on a line boundary so the last row is a whole line — but only
// when that boundary is near the cap. A minified file is one enormous
// line, so its last newline inside the cap is the HUNK HEADER's, and
// honouring it would return a hunk with no lines at all (and with no
// newline anywhere, `lastIndexOf` is -1 and the patch came back empty)
// — a file changed by a megabyte rendering as "no textual change".
// Past the halfway mark the boundary is worth keeping; before it, the
// content is, so the last row is cut mid-line instead.
const nl = patch.lastIndexOf("\n", cap);
patch = patch.slice(0, nl > cap / 2 ? nl + 1 : cap);
entry.truncated = true;
truncated = true;
}
budget -= patch.length;
entry.hunks = parseUnifiedDiff(patch);
}
}
return { range, base, baseRef, mergeBase, sha, context: ctx, files, truncated };
}
/**
* Recent commits on the lane's current branch, for the history list.
*
* `%x1f`/`%x1e` (unit/record separators) delimit the format instead of a
* printable character: a commit SUBJECT can legally contain a tab, a pipe or
* any punctuation a hand-picked delimiter would have used.
*
* @param {string} dir Lane working directory.
* @param {object} [opts]
* @param {number} [opts.limit=30] Commits to read, clamped to 200.
* @param {string|null} [opts.file] Only commits touching this path.
* @param {string|null} [opts.ref] Another branch's history instead of HEAD's
* (where cherry-pick finds its commits): a short branch name, looked up
* with `branchRef` and ENOBRANCH when it is not one.
*/
async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
// --date-order: no parent before all its children, which the history's
// commit graph needs to draw a lane down to each parent.
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
if (ref) {
const full = await worktree.branchRef(dir, ref);
if (!full) throw badRequest("ENOBRANCH", `no branch ${ref}`);
args.push(full);
}
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
// `--parents` makes git REWRITE each parent to the file's own previous
// commit, so a file's history draws one joined-up line, not loose ends.
if (file) args.push("--parents", "--", lanePath(file));
const result = await git(dir, args);
const commits = [];
for (const record of result.stdout.split("\x1e")) {
const line = record.replace(/^\n/, "");
if (!line.trim()) continue;
const [sha, short, subject, author, date, refs, parents] = line.split("\x1f");
commits.push({
sha,
short,
subject,
author,
date,
// `%D` is "HEAD -> main, origin/main, tag: v1" — the decorations, split
// into the chips a history row shows.
refs: (refs || "")
.split(", ")
.map((r) => r.trim())
.filter(Boolean),
parents: (parents || "").split(" ").filter(Boolean),
});
}
return commits;
}
/**
* Annotate (JetBrains' blame gutter): who last changed each line of the
* working-tree file. `git blame --porcelain` on the file on disk, so a line
* nobody committed yet is `null`. Same guards as a whole-file read: the
* lexical lane guard, a secret refused (ESECRET), and the file's size capped.
* Answers `{commits: {sha: {short, author, date, summary}}, lines: [sha|null]}`.
*/
async function blameFile(dir, requested) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
const st = await fs.promises.stat(path.join(dir, file)).catch(() => null);
if (!st?.isFile()) throw badRequest("ENOFILE", "no such file");
if (st.size > MAX_FILE_VIEW_BYTES) throw badRequest("ETOOBIG", "file too large to annotate");
const out = (
await git(dir, ["blame", "--porcelain", "--", file], { maxBuffer: 64 * 1024 * 1024 })
).stdout;
const commits = {};
const lines = [];
let sha = null;
for (const line of out.split("\n")) {
const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line);
if (head) {
sha = head[1];
if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) };
continue;
}
if (line.startsWith("\t")) {
lines.push(/^0+$/.test(sha) ? null : sha);
continue;
}
const c = commits[sha];
if (!c) continue;
const sp = line.indexOf(" ");
const key = line.slice(0, sp);
const val = line.slice(sp + 1);
if (key === "author") c.author = val;
else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString();
else if (key === "summary") c.summary = val;
}
return { path: file, commits, lines };
}
/**
* One file's full content at a revision, for the "read the whole file" view
* that a diff's three lines of context cannot answer.
*
* `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a
* commit-ish. The path is resolved against git's own index/tree rather than
* trusted: for `work` it is realpath-confined to the lane directory, because
* that is the one branch that reads the filesystem directly. A SECRET_PATHS
* file is refused (ESECRET) at every rev.
*/
async function readFileAt(dir, requested, rev = "work") {
// Lexical guard FIRST and for every rev: `git show <rev>:<path>` reads the
// whole repo, so a `../` here is a committed file outside the lane.
const file = lanePath(requested);
if (rev !== "work" && !SAFE_REV.test(rev)) {
throw badRequest("EBADREV", "rev is not a valid revision");
}
// By resolved path too, at every rev: the key committed under a symlinked
// `.claude`'s target name is still the key.
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
let text;
let binary = false;
let sha;
if (rev === "work") {
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
// compares REAL paths, not the string the caller sent.
const abs = await resolveInLane(dir, file);
let buf;
try {
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
// whole to answer with its first megabyte. The extra byte is what
// tells the cap below the file was longer.
const fh = await require("./run-as").openSafeAsync(abs);
try {
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
buf = Buffer.alloc(size);
let off = 0;
while (off < size) {
const { bytesRead } = await fh.read(buf, off, size - off, off);
if (!bytesRead) break;
off += bytesRead;
}
buf = buf.subarray(0, off);
} finally {
await fh.close();
}
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
binary = looksBinary(buf);
text = binary ? "" : buf.toString("utf8");
// What an editor must send back to save over THIS content and nothing
// newer (lane-file-write.js). Only for a whole, textual file.
if (!binary && buf.length <= MAX_FILE_VIEW_BYTES) sha = contentSha(buf);
} else {
try {
// `./`: resolve from the lane's cwd, not the repo root (a bare
// `<rev>:<path>` is root-relative) — `file` has no `..`, so it stays in.
text = await readCapped(dir, ["show", `${rev}:./${file}`], MAX_FILE_VIEW_BYTES);
} catch {
throw badRequest("ENOFILE", "no such file at that revision");
}
binary = text.includes("\0");
if (binary) text = "";
}
let truncated = false;
if (text.length > MAX_FILE_VIEW_BYTES) {
const nl = text.lastIndexOf("\n", MAX_FILE_VIEW_BYTES);
text = text.slice(0, nl > MAX_FILE_VIEW_BYTES / 2 ? nl + 1 : MAX_FILE_VIEW_BYTES);
truncated = true;
}
const lines = text ? text.split("\n") : [];
if (lines[lines.length - 1] === "") lines.pop();
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
}
/** The image types the viewer shows, by extension. Raster and SVG only. */
const IMAGE_TYPES = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
};
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
/**
* One image's bytes at a revision, for the viewer's `<img>`: `readFileAt`'s
* guards (lexical path, safe rev, secret, realpath-in-lane for `work`), plus
* an extension allowlist that also picks the Content-Type and a size cap.
*/
async function readImageAt(dir, requested, rev = "work") {
const file = lanePath(requested);
const type = IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()];
if (!type) throw badRequest("ENOTIMAGE", "not an image file");
if (rev !== "work" && !SAFE_REV.test(rev)) {
throw badRequest("EBADREV", "rev is not a valid revision");
}
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
const tooBig = () => badRequest("ETOOBIG", "image is larger than 20 MB");
if (rev === "work") {
const abs = await resolveInLane(dir, file);
let stat;
try {
stat = await fs.promises.stat(abs);
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
return { type, buf: await require("./run-as").readFileSafeAsync(abs) };
}
try {
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
encoding: "buffer",
maxBuffer: MAX_IMAGE_BYTES,
});
return { type, buf: stdout };
} catch (err) {
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw tooBig();
throw badRequest("ENOFILE", "no such file at that revision");
}
}
/** The version stamp of a file's bytes, for a stale-save check. */
function contentSha(buf) {
return crypto.createHash("sha1").update(buf).digest("hex");
}
/**
* Tracked paths matching a substring query — the quick-open list.
*
* `git ls-files` rather than a filesystem walk: it already honours
* `.gitignore`, so `node_modules` and build output never enter the list and no
* skip-list has to be maintained here.
*/
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
const { all, cut } = await lsFiles(dir, []);
const needle = String(q || "")
.toLowerCase()
.trim();
const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS);
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
// every file under `src/api/`.
if (needle) {
hits.sort((a, b) => {
const ab = path.basename(a).toLowerCase().includes(needle) ? 0 : 1;
const bb = path.basename(b).toLowerCase().includes(needle) ? 0 : 1;
return ab - bb || a.length - b.length || a.localeCompare(b);
});
}
return { paths: hits.slice(0, cap), truncated: cut || hits.length > cap, total: hits.length };
}
/** `git ls-files -z [args]` → paths, and whether a huge listing was cut short. */
async function lsFiles(dir, args) {
const out = await readCapped(dir, ["ls-files", "-z", ...args], MAX_LS_CHARS);
const all = out.split("\0");
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
const cut = all.pop() !== "";
// Mid-merge, `ls-files --cached` prints a conflicted path once per stage
// (base, ours, theirs): one path is one file.
return { all: [...new Set(all.filter(Boolean))], cut };
}
/** What the explorer lists: tracked files plus untracked ones git does not
* ignore — the new file an agent just wrote is the one you want to open. */
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
/**
* A lane's working-copy status for decorating the explorer, from ONE
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file,
* `dirChanged(path)` is whether anything under a folder is.
*/
async function treeStatus(dir) {
const codes = new Map();
const dirs = new Set();
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
codes.set(p, code);
const parts = p.split("/");
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
}
return {
fileStatus: (p) => codes.get(p),
dirChanged: (p) => dirs.has(p),
};
}
/**
* Adds to one level what git does not list there — ignored files and folders
* (and an empty folder, which git never lists either) — marked `ignored`,
* read off the disk because `ls-files --ignored` cannot list INSIDE an
* ignored folder. Returns the folder names it added.
*/
async function addIgnored(dir, sub, dirNames, files) {
const added = new Set();
let dirents;
try {
const root = await laneRoot(dir);
const real = await fs.promises.realpath(path.join(root, sub));
const r = path.relative(root, real);
// A symlinked folder pointing out of the workspace lists nothing.
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) return added;
dirents = await fs.promises.readdir(real, { withFileTypes: true });
} catch {
return added;
}
const prefix = sub ? `${sub}/` : "";
const listed = new Set(files.map((f) => f.name));
for (const d of dirents) {
if (d.name === ".git" || dirNames.has(d.name) || listed.has(d.name)) continue;
if (d.isDirectory()) {
dirNames.add(d.name);
added.add(d.name);
} else {
files.push({ name: d.name, path: `${prefix}${d.name}`, dir: false, ignored: true });
}
}
return added;
}
/** A folded level's entries, each carrying its status (see `treeStatus`). */
function treeEntries(prefix, dirNames, files, st, ignoredDirs = new Set()) {
return [
...[...dirNames].sort().map((name) => {
const p = `${prefix}${name}`;
if (ignoredDirs.has(name)) return { name, path: p, dir: true, ignored: true };
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
}),
...files
.sort((a, b) => a.name.localeCompare(b.name))
.map((f) => {
const status = st.fileStatus(f.path);
return status ? { ...f, status } : f;
}),
];
}
/**
* One directory level of the working tree, for the file explorer: tracked
* files plus untracked-but-not-ignored ones (`TREE_LS`) and, marked
* `ignored`, what git ignores there (`addIgnored`), folded into
* directories at this level — a tree that lists a repo's every file at once is
* both slow to render and not what "expand a folder" means. A changed file
* carries its porcelain `status`, a folder holding one `changed: true`.
*/
async function listTree(dir, { subdir = "" } = {}) {
const clean = String(subdir || "")
.replace(/^\/+|\/+$/g, "")
.trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace");
const [{ all, cut }, st] = await Promise.all([
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
treeStatus(dir),
]);
const prefix = clean ? `${clean}/` : "";
const dirs = new Set();
const files = [];
for (const p of all) {
const rest = p.slice(prefix.length);
const slash = rest.indexOf("/");
if (slash === -1) files.push({ name: rest, path: p, dir: false });
else dirs.add(rest.slice(0, slash));
}
const ignored = await addIgnored(dir, clean, dirs, files);
const entries = treeEntries(prefix, dirs, files, st, ignored);
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
truncated: cut || entries.length > MAX_PATHS,
};
}
/**
* ONE hunk of a tracked file's working-copy diff (`git diff HEAD`, the same
* read the Changes panel shows), as a standalone patch `git apply` accepts:
* the file's own header lines plus that hunk. The hunk is named by its `@@`
* line and matched against a FRESH diff at the caller's context, so what gets
* applied is always git's own text of the current tree — never a patch a
* request carried. Null when no hunk matches (the tree moved on, or the file
* is a secret, binary or too large to read whole).
*
* `block` narrows it to one CHANGE BLOCK of that hunk — a run of consecutive
* +/- lines. git merges changes up to 2×context lines apart into one hunk, so
* without this a single Undo/Keep acted on every nearby edit at once (#50).
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
*/
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
if (!header) return null;
const diff = await diffHunks(dir, file, context);
const hunk = diff?.hunks.find((h) => h.slice(0, h.indexOf("\n")) === header);
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? diff.head + (body.endsWith("\n") ? body : `${body}\n`) : null;
}
/** A tracked file's fresh `git diff HEAD` at `context`: its header lines and
* its hunks' texts, in order. Null for a secret, binary or too-large file. */
async function diffHunks(dir, file, context) {
if (await isSecretOnDisk(dir, file)) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
dir,
["diff", u, "HEAD", "--no-color", "--no-ext-diff", "--", file],
MAX_FILE_BYTES
).catch(() => "");
// A capped read may end mid-hunk; applying half a hunk is worse than none.
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
const at = raw.indexOf("\n@@ ");
if (at < 0) return null;
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
return { head: raw.slice(0, at + 1), hunks: raw.slice(at + 1).split(/^(?=@@ )/m) };
}
/**
* The hunk-level commit's patch for one file (JetBrains' partial commit):
* the file's header plus each picked hunk cut down to its picked `blocks`,
* in file order, to be applied FORWARD onto an index holding HEAD. Every
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
* git wrote it — `git apply` locates a hunk by its old side.
*/
async function selectionPatch(dir, file, context, picks) {
const diff = await diffHunks(dir, file, context);
if (!diff || !picks.length) return null;
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
if (picks.some((p) => at(p.hunk) < 0)) return null;
const bodies = [];
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
if (!body) return null;
bodies.push(body);
}
return diff.head + bodies.join("");
}
/**
* One hunk's text cut down to its `block`-th change block, the way an edited
* `git add -p` hunk is: every OTHER block is folded into the side the patch
* will be checked against, so that side stays byte-for-byte the hunk's own and
* `git apply` finds it exactly where git put it.
*
* - forward (stage, applied to the index = the OLD side): another block's
* "-" lines become context and its "+" lines are dropped;
* - reverse (undo, `apply -R` on the working tree = the NEW side): its "+"
* lines become context and its "-" lines are dropped.
*
* A "\ No newline" marker follows the line it annotates, kept or dropped with
* it. The header's counts are recomputed; its starts do not move, because the
* checked side is unchanged. Null when the hunk has no such block.
*/
function onlyBlock(hunk, block, reverse) {
// One block, or several (the hunk-level commit keeps every ticked one).
const keep = Array.isArray(block) ? block : [block];
if (!keep.length || keep.some((b) => !Number.isInteger(b) || b < 0)) return null;
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
if (!m) return null;
const out = [];
let index = -1;
let inBlock = false;
let keptPrev = false;
let oldCount = 0;
let newCount = 0;
for (const line of lines) {
const mark = line[0];
if (mark === "\\") {
if (keptPrev) out.push(line);
continue;
}
const change = mark === "+" || mark === "-";
if (change && !inBlock) index += 1;
inBlock = change;
let kept = line;
if (change && !keep.includes(index)) {
const fold = reverse ? "+" : "-";
kept = mark === fold ? ` ${line.slice(1)}` : null;
}
keptPrev = kept !== null;
if (kept === null) continue;
out.push(kept);
if (kept[0] !== "+") oldCount += 1;
if (kept[0] !== "-") newCount += 1;
}
if (Math.max(...keep) > index) return null;
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
}
/**
* Every level of the explorer tree (tracked + untracked-not-ignored, plus each level's ignored entries) from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
* per folder, which on a large repo is thousands of git processes.
*/
async function listTreeAll(dir) {
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
const byDir = new Map();
const level = (d) => {
let l = byDir.get(d);
if (!l) byDir.set(d, (l = { dirs: new Set(), files: [] }));
return l;
};
level("");
for (const p of all) {
const parts = p.split("/");
for (let i = 0; i < parts.length - 1; i++) {
level(parts.slice(0, i).join("/")).dirs.add(parts[i]);
}
const parent = parts.slice(0, -1).join("/");
level(parent).files.push({ name: parts[parts.length - 1], path: p, dir: false });
}
const levels = {};
let truncated = cut;
// An ignored folder gets no level of its own: it stays shut (node_modules).
const ignored = new Map(
await Promise.all(
[...byDir].map(async ([d, l]) => [d, await addIgnored(dir, d, l.dirs, l.files)])
)
);
for (const [d, { dirs, files }] of byDir) {
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st, ignored.get(d));
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
return { levels, truncated };
}
module.exports = {
collectChanges,
CONFLICT_CODES,
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
hunkPatch,
selectionPatch,
blameFile,
laneStatus,
listCommits,
listPaths,
listTree,
listTreeAll,
parseNumstat,
parsePorcelainZ,
parseUnifiedDiff,
readFileAt,
readImageAt,
resolveBaseRef,
defaultBranch,
DEFAULT_CONTEXT,
MAX_CONTEXT,
MAX_FILES,
MAX_FILE_BYTES,
MAX_TOTAL_BYTES,
};