{
  "openapi": "3.1.0",
  "info": {
    "title": "gitoskop",
    "description": "Read-only JSON API for browsing bare git repositories under configured roots.\n\nA repository is addressed by `<label>/<relpath-to-git-dir>` where `<label>` is a configured root and `<relpath>` is the path to the bare repo under it (e.g. `pkg/gears/g/git.git`). The repo path, ref names and in-repo file paths may all contain slashes; they are NOT percent-encoded. Because the repo path and the verb are both variable-length, the server resolves the repo by walking the path to the git-dir boundary and treating the remainder as the verb.",
    "version": "0.1.0",
    "license": { "name": "MIT" }
  },
  "servers": [
    { "url": "http://localhost:1449", "description": "Local instance" }
  ],
  "paths": {
    "/": {
      "get": {
        "summary": "List configured root labels",
        "operationId": "listRoots",
        "responses": {
          "200": {
            "description": "Root labels",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RootsList" } } }
          }
        }
      }
    },
    "/_/health": {
      "get": {
        "summary": "Health check",
        "operationId": "health",
        "responses": {
          "200": { "description": "Service is up", "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } } }
        }
      }
    },
    "/_/version": {
      "get": {
        "summary": "Build identity of the running binary",
        "operationId": "version",
        "responses": {
          "200": {
            "description": "Crate version, git commit at build time (`-dirty` suffix for an unclean tree; omitted when built without .git, e.g. from a tarball) and unix build timestamp. A stale `built` after a redeploy means a cached image or a non-recreated container.",
            "content": { "application/json": { "schema": { "type": "object", "required": ["version", "built"], "properties": { "version": { "type": "string" }, "commit": { "type": "string" }, "built": { "type": "integer", "format": "int64" } } } } }
          }
        }
      }
    },
    "/_/metrics": {
      "get": {
        "summary": "Prometheus metrics",
        "operationId": "metrics",
        "responses": {
          "200": { "description": "Metrics in Prometheus text exposition format", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/_/openapi.json": {
      "get": {
        "summary": "This OpenAPI document",
        "operationId": "openapi",
        "responses": {
          "200": { "description": "OpenAPI specification", "content": { "application/json": { "schema": { "type": "object" } } } }
        }
      }
    },
    "/_/search": {
      "get": {
        "summary": "Search indexed repository paths",
        "operationId": "search",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string", "maxLength": 200 }, "description": "Case-insensitive substring matched against `<label>/<relpath>`." },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50, "maximum": 200, "minimum": 0 }, "description": "Page size. Defaults to the server's `search_default_limit` (50); clamped to `search_max_limit` (200)." },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0, "minimum": 0 } }
        ],
        "responses": {
          "200": {
            "description": "Matching repository paths",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchResults" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/_/mcp": {
      "post": {
        "summary": "MCP (Model Context Protocol) endpoint for agents",
        "operationId": "mcp",
        "description": "JSON-RPC 2.0 over a single POST, MCP Streamable HTTP. Methods: initialize, notifications/initialized, tools/list, tools/call, ping. Stateless — no session header. Eight tools wrap the REST verbs with a uniform offset/limit line-range contract and a byte ceiling (mcp_max_bytes), so results stay inside an agent's context budget. Concurrent tool calls are capped (mcp_max_concurrency); excess gets a JSON-RPC -32000 busy error. Disabled when mcp_enabled = false, in which case this path 404s.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object" } } }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC response (or an array of them for a batch)",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "202": { "description": "Notification accepted; no response body" }
        }
      }
    },
    "/{repo}": {
      "get": {
        "summary": "Repository summary or directory listing",
        "description": "If `{repo}` resolves to a git repository, returns its summary. If it resolves to a directory, returns a browse listing of its children.",
        "operationId": "summaryOrBrowse",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "name": "meta", "in": "query", "schema": { "type": "string", "enum": ["0"] }, "description": "On a directory listing, `meta=0` skips per-repo summary metadata for an instant structural listing." }
        ],
        "responses": {
          "200": {
            "description": "Repository summary or directory listing",
            "content": { "application/json": { "schema": { "oneOf": [ { "$ref": "#/components/schemas/RepoSummary" }, { "$ref": "#/components/schemas/BrowseListing" } ] } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/refs": {
      "get": {
        "summary": "Branches and tags combined",
        "operationId": "refs",
        "parameters": [ { "$ref": "#/components/parameters/Repo" } ],
        "responses": {
          "200": { "description": "Refs", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/RefEntry" } } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/branches": {
      "get": {
        "summary": "Branch list",
        "operationId": "branches",
        "parameters": [ { "$ref": "#/components/parameters/Repo" } ],
        "responses": {
          "200": { "description": "Branches", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/RefEntry" } } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/tags": {
      "get": {
        "summary": "Tag list",
        "operationId": "tags",
        "parameters": [ { "$ref": "#/components/parameters/Repo" } ],
        "responses": {
          "200": { "description": "Tags", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/RefEntry" } } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/recipe/{ref}": {
      "get": {
        "summary": "The build recipe of a tree, and where its inputs live",
        "operationId": "recipe",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" }
        ],
        "responses": {
          "200": { "description": "A gear recipe (`.gear/rules`, inputs resolved to commits through the stored tag list) or a `spec` tree (a lone spec beside its own unpacked sources: an srpm import, or an upstream that ships one). `built_from` names the tree closest to what gets built when it is another commit; `upstream` the pristine tree a diff starts from.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Recipe" } } } },
          "404": { "description": "No recipe: the tree is its own sources, or the ref is unknown", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/{repo}/log/{ref}": {
      "get": {
        "summary": "Commit log for a ref, newest first",
        "operationId": "log",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50, "maximum": 500, "minimum": 0 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0, "maximum": 50000, "minimum": 0 } },
          { "name": "q", "in": "query", "schema": { "type": "string", "maxLength": 200 }, "description": "Filter the matched commit stream: case-insensitive substring over author, committer or subject, or an OID hex prefix. Pagination applies after filtering; a full history walk runs per request." },
          { "name": "path", "in": "query", "schema": { "type": "string" }, "description": "Limit to commits where the entry (file or subtree) at this repo-relative path changed vs the first parent. No rename following. Combines with `q`." }
        ],
        "responses": {
          "200": { "description": "Commits", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/CommitInfo" } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/archive/{ref}/{path}": {
      "get": {
        "summary": "tar.gz archive of the tree (or a subdirectory) at a ref",
        "operationId": "archive",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "name": "path", "in": "path", "schema": { "type": "string" }, "description": "Optional subdirectory; omit for the whole tree." }
        ],
        "responses": {
          "200": { "description": "gzip-compressed tar with a `<repo>-<ref>[-<dir>]/` prefix; entries carry the commit's mtime. Streamed with bounded memory; cut off past 256 MiB", "content": { "application/gzip": { "schema": { "type": "string", "format": "binary" } } } },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/TooLarge" },
          "503": { "description": "Too many concurrent snapshot downloads (a small fixed ceiling shared by all archive formats); retry later. Retry-After is set.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/{repo}/blame/{ref}/{path}": {
      "get": {
        "summary": "Line-group blame for a file at a ref",
        "operationId": "blame",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "$ref": "#/components/parameters/Path" }
        ],
        "responses": {
          "200": { "description": "Consecutive-line hunks with the introducing commit. Expensive: diffs through the file's history on every call, no cache.", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/BlameGroup" } } } } },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "description": "Too many concurrent blame computations; retry later. Retry-After is set.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/{repo}/grep/{ref}": {
      "get": {
        "summary": "Substring search over blob contents at a ref",
        "operationId": "grep",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string", "maxLength": 200 }, "description": "Case-insensitive substring matched per line. Blobs over 1 MiB or looking binary are skipped; at most 20 matching lines per file, line text truncated to 300 chars. A full tree walk runs per request." },
          { "name": "path", "in": "query", "schema": { "type": "string", "maxLength": 1024 }, "description": "Confine the search to this repo-relative directory; result paths stay repo-relative." },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50, "maximum": 500, "minimum": 0 }, "description": "Max files with matches; the walk stops once reached." }
        ],
        "responses": {
          "200": { "description": "Matching files", "headers": { "X-Gitoskop-Partial": { "description": "Present, as `walk-budget`, when the tree walk ran out of its time budget: the body is a prefix of the full result.", "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/GrepFile" } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/find/{ref}": {
      "get": {
        "summary": "Paths containing a substring, at a ref",
        "operationId": "find",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string", "maxLength": 200 }, "description": "Case-insensitive substring of the repo-relative path. Blobs and trees only; a directory is listed as `dir/`. A tree walk runs per request, in path order." },
          { "name": "path", "in": "query", "schema": { "type": "string", "maxLength": 1024 }, "description": "Confine the walk to this repo-relative directory; result paths stay repo-relative." },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50, "maximum": 500, "minimum": 0 }, "description": "Max paths; the walk stops once reached." }
        ],
        "responses": {
          "200": { "description": "Matching paths", "headers": { "X-Gitoskop-Partial": { "description": "Present, as `walk-budget`, when the tree walk ran out of its time budget: the body is a prefix of the full result.", "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/commit/{oid}": {
      "get": {
        "summary": "Single commit detail",
        "operationId": "commit",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "name": "oid", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Commit OID (hex)." }
        ],
        "responses": {
          "200": { "description": "Commit detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommitDetail" } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/show/{rev}": {
      "get": {
        "summary": "Full git-show analog: commit detail plus patch",
        "operationId": "show",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "name": "rev", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Single committish (branch, tag or OID). Diffed against its first parent (root commit → against the empty tree). Branch/tag names and OIDs only; revspec operators (`~`, `^`, `@{…}`) are rejected." },
          { "name": "format", "in": "query", "required": false, "schema": { "type": "string", "enum": ["patch"] }, "description": "format=patch returns a single-message git-format-patch mbox (text/plain) that applies with `git am`; omit for JSON." }
        ],
        "responses": {
          "200": {
            "description": "Commit detail plus unified diff (JSON, default: CommitDetail fields flattened alongside message/patch), or a format-patch mbox when format=patch. Output over max_patch_bytes (default 2 MiB, 0 = unlimited) is truncated with a trailing `# gitoskop: patch truncated` line in the JSON form; the mbox form responds 413 instead of emitting a patch that would fail `git am`.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ShowResponse" } },
              "text/plain": { "schema": { "type": "string" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/PatchTooLarge" }
        }
      }
    },
    "/{repo}/diff/{range}": {
      "get": {
        "summary": "Diff between two refs/OIDs",
        "operationId": "diff",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "name": "range", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Two ref-ish endpoints joined by `..`, e.g. `v1.0..master`. Branch/tag names and OIDs only; revspec operators (`~`, `^`, `@{…}`) are rejected." }
        ],
        "responses": {
          "200": { "description": "File changes", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DiffResult" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/patch/{range}": {
      "get": {
        "summary": "Unified diff as plain text",
        "operationId": "patch",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "name": "range", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Either two ref-ish endpoints joined by `..` (e.g. `v1.0..master`), or by `...` to diff from their merge base, or a single committish — diffed against its first parent (root commit → against the empty tree). Branch/tag names and OIDs only; revspec operators (`~`, `^`, `@{…}`) are rejected." }
        ],
        "responses": {
          "200": { "description": "git-style unified diff. Binary or >4 MiB blobs appear as `Binary files … differ` markers; output over `max_patch_bytes` (default 2 MiB, 0 = unlimited) is cut with a trailing `# gitoskop: patch truncated` line.", "content": { "text/plain": { "schema": { "type": "string" } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/tree/{ref}/{path}": {
      "get": {
        "summary": "Directory listing within a tree",
        "operationId": "tree",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "$ref": "#/components/parameters/InRepoPath" }
        ],
        "responses": {
          "200": { "description": "Tree listing", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TreeListing" } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/{repo}/blob/{ref}/{path}": {
      "get": {
        "summary": "File content as JSON",
        "operationId": "blob",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "$ref": "#/components/parameters/InRepoPath" }
        ],
        "responses": {
          "200": { "description": "Blob", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BlobInfo" } } } },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/TooLarge" }
        }
      }
    },
    "/{repo}/raw/{ref}/{path}": {
      "get": {
        "summary": "Raw file bytes",
        "description": "Text is served inline as `text/plain`; binary content (NUL bytes) as `application/octet-stream` with `Content-Disposition: attachment`. Always `X-Content-Type-Options: nosniff`, never `text/html`.",
        "operationId": "raw",
        "parameters": [
          { "$ref": "#/components/parameters/Repo" },
          { "$ref": "#/components/parameters/Ref" },
          { "$ref": "#/components/parameters/InRepoPath" }
        ],
        "responses": {
          "200": { "description": "Raw bytes", "content": { "text/plain": { "schema": { "type": "string" } }, "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } } },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/TooLarge" }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "Repo": {
        "name": "repo", "in": "path", "required": true,
        "schema": { "type": "string" },
        "description": "Repository path `<label>/<relpath>` (may contain slashes, not percent-encoded), e.g. `pkg/gears/g/git.git`."
      },
      "Ref": {
        "name": "ref", "in": "path", "required": true,
        "schema": { "type": "string" },
        "description": "Branch, tag or OID. May contain slashes (e.g. `old/sisyphus-task79111`). Restricted to ref-name/hex characters."
      },
      "InRepoPath": {
        "name": "path", "in": "path", "required": false,
        "schema": { "type": "string" },
        "description": "Path within the repository tree (may contain slashes). Empty means the tree root."
      }
    },
    "responses": {
      "NotFound": { "description": "Unknown repository, ref or path", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "BadRequest": { "description": "Invalid parameters", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "TooLarge": { "description": "Blob exceeds the configured size cap", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "PatchTooLarge": { "description": "format=patch and the diff exceeds max_patch_bytes", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": { "type": "string", "examples": ["not_found", "bad_request", "too_large", "internal"] },
              "message": { "type": "string" }
            }
          }
        }
      },
      "RootsList": {
        "type": "object",
        "required": ["roots"],
        "properties": {
          "roots": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["label", "repos"],
              "properties": {
                "label": { "type": "string" },
                "clone_base": { "type": "string", "description": "Base URL the WebUI shows for `git clone` (`<clone_base>/<relpath>`, relpath under the root without the label); absent when the root has no clone endpoint configured. gitoskop itself does not serve smart HTTP." },
                "repos": { "type": "integer", "description": "Number of indexed repos under this root" }
              }
            }
          }
        }
      },
      "SearchResults": {
        "type": "object",
        "required": ["results", "total", "dirs"],
        "properties": {
          "results": { "type": "array", "items": { "type": "string" }, "description": "Repository paths `<label>/<relpath>`; hits whose basename (sans `.git`) equals `q` come first, and within each relevance tier the freshest `last_commit` wins (undated last). Note only the returned page is date-sorted." },
          "total": { "type": "integer", "description": "Total number of repo matches; `results.length < total` means the page was truncated — paginate with `offset`." },
          "dirs": { "type": "array", "items": { "type": "string" }, "description": "Matching directory paths (parent prefixes of repo paths, root labels excluded), exact-basename matches first then alphabetical; unpaginated, capped at `limit`." }
        }
      },
      "BlameGroup": {
        "type": "object",
        "required": ["oid", "author", "time", "subject", "start", "len"],
        "properties": {
          "oid": { "type": "string", "description": "Introducing commit, 40-char hex" },
          "author": { "type": "string", "description": "`Name <email>`" },
          "time": { "type": "integer", "format": "int64", "description": "Committer time, Unix seconds" },
          "subject": { "type": "string" },
          "start": { "type": "integer", "description": "1-based first line of the group" },
          "len": { "type": "integer", "description": "Lines in the group" },
          "boundary": { "type": "boolean", "description": "Present and true for boundary (shallow/grafted) commits — git blame's `^` marker" },
          "src": { "type": "string", "description": "Source file name when the hunk arrived through a rename" }
        }
      },
      "Recipe": {
        "type": "object",
        "required": ["kind", "file", "inputs", "patches"],
        "properties": {
          "kind": { "type": "string", "enum": ["gear", "spec"] },
          "file": { "type": "string", "description": "The recipe file: the rules, or the spec of a `spec` tree" },
          "spec": { "type": "string" },
          "name": { "type": "string" },
          "version": { "type": "string" },
          "release": { "type": "string" },
          "inputs": { "type": "array", "items": { "$ref": "#/components/schemas/RecipeInput" } },
          "built_from": { "$ref": "#/components/schemas/RecipeSide" },
          "upstream": { "$ref": "#/components/schemas/RecipeSide" },
          "patches": { "type": "integer", "description": "Patch files a copy directive ships, applied at build time" }
        }
      },
      "RecipeInput": {
        "type": "object",
        "required": ["directive", "kind", "written"],
        "properties": {
          "directive": { "type": "string", "description": "As written: tar, tar.gz, diff, copy, …" },
          "kind": { "type": "string", "enum": ["tree", "diff", "files", "unknown"] },
          "written": { "type": "string", "description": "The directive's arguments as written" },
          "from": { "$ref": "#/components/schemas/RecipeSide" },
          "to": { "$ref": "#/components/schemas/RecipeSide" },
          "output": { "type": "string", "description": "The file the directive produces, by the name the spec uses" },
          "unresolved": { "type": "string", "description": "A tree path the resolver could not follow, as written" }
        }
      },
      "RecipeSide": {
        "type": "object",
        "required": ["commit", "path", "named", "present"],
        "properties": {
          "commit": { "type": "string" },
          "path": { "type": "string", "description": "Directory within the commit; empty for its root" },
          "named": { "type": "string", "description": "How the recipe named the commit: this tree, commit, tag X, stored tag X, ref X" },
          "present": { "type": "boolean", "description": "False when the commit is not in the repository" }
        }
      },
      "GrepFile": {
        "type": "object",
        "required": ["path", "matches"],
        "properties": {
          "path": { "type": "string", "description": "File path within the repo" },
          "matches": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["n", "line"],
              "properties": {
                "n": { "type": "integer", "description": "1-based line number" },
                "line": { "type": "string", "description": "Matching line, truncated to 300 chars" }
              }
            }
          }
        }
      },
      "CommitInfo": {        "type": "object",
        "required": ["oid", "author", "committer", "time", "subject"],
        "properties": {
          "oid": { "type": "string", "description": "40-char hex OID" },
          "author": { "type": "string", "description": "`Name <email>`" },
          "committer": { "type": "string", "description": "`Name <email>`" },
          "time": { "type": "integer", "format": "int64", "description": "Committer time, Unix seconds" },
          "subject": { "type": "string" }
        }
      },
      "RefEntry": {
        "type": "object",
        "required": ["name", "target"],
        "properties": {
          "name": { "type": "string" },
          "target": { "type": "string", "description": "40-char hex OID the ref resolves to" },
          "merge_status": { "type": "string", "enum": ["merged", "unmerged"], "description": "Per-ref verdict vs the upstream pair: merged = the ref's tip is reachable from some ref of the pair repo. Omitted when the repo's root has no upstream, no pair exists, or the pair is unavailable." },
          "merge_with": { "type": "string", "description": "Repo url this ref's merge verdict came from: the same-basename pair, or any upstream repo where the ref's target is a ref tip (tag auto-detection). Omitted when merge_status is absent or unmerged." }
        }
      },
      "RepoSummary": {
        "type": "object",
        "required": ["default_branch", "last_commit", "branches", "tags", "size_bytes"],
        "properties": {
          "default_branch": { "type": "string", "description": "Branch whose tip has the newest committer time; ties break by `HEAD`, then ref name. HEAD alone is not trusted — bare repos are often served with `HEAD` left at a never-pushed `master`, which then matches no branch" },
          "last_commit": { "$ref": "#/components/schemas/CommitInfo", "description": "Tip commit of `default_branch`" },
          "branches": { "type": "integer", "description": "Branch count" },
          "tags": { "type": "integer", "description": "Tag count" },
          "size_bytes": { "type": "integer", "format": "int64", "description": "Approximate on-disk size" },
          "merge_status": {
            "type": "string",
            "enum": ["merged", "unmerged", "no_upstream", "empty"],
            "description": "Only for repos under a root with `upstream` configured. Verdicts are name-independent: a ref counts as merged when its commit is any ref tip of any repo in an upstream-capable root. The repo status is the union over its refs (branches and tags, tags peeled to commits): merged = every ref target is a ref tip somewhere upstream — deleting this repo loses nothing; unmerged = at least one ref target is not a tip of any repo in the root; empty = the repo has no refs. Omitted only when the repo's root has no `upstream` configured or the repo's own refs are unreadable. `no_upstream` is retired (never emitted)."
          },
          "merge_with": {
            "type": "string",
            "description": "Url of the pair repo merge_status was computed against (e.g. \"packages/gears/I/ImageMagick.git\"). Present only when merge_status is merged or unmerged."
          }
        }
      },
      "BrowseEntry": {
        "oneOf": [
          {
            "type": "object",
            "required": ["kind", "name"],
            "properties": {
              "kind": { "type": "string", "const": "dir" },
              "name": { "type": "string" }
            }
          },
          {
            "type": "object",
            "required": ["kind", "name"],
            "properties": {
              "kind": { "type": "string", "const": "repo" },
              "name": { "type": "string" },
              "repo": { "$ref": "#/components/schemas/RepoSummary", "description": "Omitted when listed with meta=0 or when the repo's meta is unavailable." }
            }
          }
        ]
      },
      "BrowseListing": {
        "type": "object",
        "required": ["path", "entries"],
        "properties": {
          "path": { "type": "string" },
          "entries": { "type": "array", "items": { "$ref": "#/components/schemas/BrowseEntry" } }
        }
      },
      "FileChange": {
        "type": "object",
        "required": ["path", "status", "insertions", "deletions"],
        "properties": {
          "path": { "type": "string" },
          "status": { "type": "string", "enum": ["A", "M", "D"] },
          "insertions": { "type": "integer", "description": "Lines added; 0 for binary or oversized (>4 MiB) blobs" },
          "deletions": { "type": "integer", "description": "Lines removed; 0 for binary or oversized (>4 MiB) blobs" }
        }
      },
      "CommitDetail": {
        "type": "object",
        "description": "CommitInfo fields are flattened in alongside parents/files/truncated.",
        "required": ["oid", "author", "committer", "time", "subject", "parents", "files", "truncated"],
        "properties": {
          "oid": { "type": "string" },
          "author": { "type": "string" },
          "committer": { "type": "string" },
          "time": { "type": "integer", "format": "int64" },
          "subject": { "type": "string" },
          "parents": { "type": "array", "items": { "type": "string" } },
          "files": { "type": "array", "items": { "$ref": "#/components/schemas/FileChange" } },
          "truncated": { "type": "boolean", "description": "true if the file list was capped at 50000" }
        }
      },
      "ShowResponse": {
        "type": "object",
        "description": "CommitDetail fields are flattened in alongside message and patch.",
        "required": ["oid", "author", "committer", "time", "subject", "parents", "files", "truncated", "message", "patch"],
        "properties": {
          "oid": { "type": "string" },
          "author": { "type": "string" },
          "committer": { "type": "string" },
          "time": { "type": "integer", "format": "int64" },
          "subject": { "type": "string" },
          "parents": { "type": "array", "items": { "type": "string" } },
          "files": { "type": "array", "items": { "$ref": "#/components/schemas/FileChange" } },
          "truncated": { "type": "boolean", "description": "true if the file list was capped at 50000" },
          "message": { "type": "string", "description": "Full commit message (subject + body)." },
          "patch": { "type": "string", "description": "Unified diff vs the first parent; lossy UTF-8 (JSON is UTF-8 only — byte-exact output lives at ?format=patch)." }
        }
      },
      "DiffResult": {
        "type": "object",
        "required": ["files", "truncated"],
        "properties": {
          "files": { "type": "array", "items": { "$ref": "#/components/schemas/FileChange" } },
          "truncated": { "type": "boolean" }
        }
      },
      "TreeEntry": {
        "type": "object",
        "required": ["name", "kind", "mode", "oid"],
        "properties": {
          "name": { "type": "string" },
          "kind": { "type": "string", "enum": ["blob", "tree"] },
          "mode": { "type": "string", "description": "Octal mode, e.g. `100644`" },
          "oid": { "type": "string" },
          "size": { "type": ["integer", "null"], "format": "int64" }
        }
      },
      "TreeListing": {
        "type": "object",
        "required": ["entries", "truncated"],
        "properties": {
          "entries": { "type": "array", "items": { "$ref": "#/components/schemas/TreeEntry" } },
          "truncated": { "type": "boolean", "description": "true if the listing was capped at 50000" }
        }
      },
      "BlobInfo": {
        "type": "object",
        "required": ["oid", "size", "binary"],
        "properties": {
          "oid": { "type": "string" },
          "size": { "type": "integer", "format": "int64" },
          "binary": { "type": "boolean" },
          "text": { "type": ["string", "null"], "description": "File content; null when binary" }
        }
      }
    }
  }
}
