No fake precision. Here is exactly what happens.
GitHub's /languages endpoint reports bytes per language, computed by Linguist.
Bytes are not lines, and Linguist's exclusions are not ours. We read the actual file contents and
count actual lines.
Your input is resolved to a repository, then to a ref (your branch/tag/sha, or the default branch),
then to a single 40-character commit sha. Everything after that is computed against that sha, and the
sha is displayed with the result. Two people counting the same sha get the same numbers, forever
— that is what the counter_version field is for: it changes when the algorithm
changes, so cached results can never silently mean something different.
We list the repository with the Git Trees API (?recursive=1) and drop entries before
fetching anything. A file is skipped when it is:
node_modules, vendor, third_party, dist,
build, target, .next, venv,
__pycache__, Pods, coverage and friends;*.min.js,
*.min.css), source maps, protobuf/codegen output (*.pb.go,
*_pb2.py, *.g.dart, *_generated.ts…), snapshot
files, and lockfiles;
Lockfile policy: lockfiles are skipped by default and reported under
“generated”. They are machine-written dependency graphs, and one
package-lock.json can be larger than an entire project. Tick “count
lockfiles” on the form (or pass ?lockfiles=1) to include them; they are then
counted as data, with every non-blank line as code. The same applies to
“count vendored dirs” / ?vendored=1.
Each line lands in exactly one bucket, so lines = code + comment + blank:
| blank | Nothing but whitespace — including blank lines inside a block comment (this matches cloc). |
|---|---|
| comment | Non-blank, and every non-whitespace character on it was inside a comment. |
| code | Anything else. A line with code and a trailing comment counts as code. |
The classifier is a single-pass state machine that tracks string literals, so // inside
"https://example.com" is not a comment, and # inside a shell string is not
either. Block comments nest where the language nests them (Rust, Swift, Haskell, Julia, Nim,
OCaml…). Python and Elixir triple-quoted strings count as comments when they open a
line (docstrings) and as code when they appear mid-expression. In JavaScript and TypeScript it also
tracks regex literals (so /^([^\/]+:\/)?\/*$/ does not open a block comment) and
template-literal ${…} interpolation, including templates nested inside it.
Checked against cloc over a ~30,000 line corpus: 125 of 127 files matched exactly on
code/comment/blank, and both disagreements were cases where cloc is wrong — a
regex literal containing /*, and comment markers inside a string literal.
JavaScript, TypeScript, JSX/TSX, Python, Go, Rust, Java, Kotlin, Scala, C, C++, C#, Objective-C, Swift, Ruby, PHP, Perl, CSS/SCSS/Sass/Less, HTML/XML/Vue/Svelte/Astro, Shell, Fish, PowerShell, Batch, Lua, SQL, Haskell, Elm, PureScript, OCaml, F#, Clojure, Lisp, Scheme, Racket, Elixir, Erlang, Julia, Nim, Zig, Dart, Groovy, R, MATLAB, Fortran, Assembly, Terraform/HCL, Nix, YAML, TOML, INI, Makefile, CMake, Dockerfile, GraphQL, Protocol Buffers, Solidity, Verilog, VHDL, Vim script, CoffeeScript, Visual Basic, Pascal, Markdown, and more.
JSON, CSV, plain text, .env, ignore files, checksum files, diffs, reStructuredText,
AsciiDoc, TeX, Jupyter notebooks and unrecognised extensions are counted as blank vs
non-blank only — every non-blank line is reported as code, comments as zero. Every
result page lists exactly which languages this applied to. We would rather say “we did not
detect comments here” than invent a number.
<script> and <style> regions
switch to JavaScript and CSS rules respectively. Inline event handler attributes are counted as
HTML. Templating languages embedded in HTML (ERB, EJS, Blade) are treated as HTML.# inside a shell heredoc may be reported as a comment.R"delim(...)delim" in
C++, r#"..."# in Rust) are approximated by the plain quoted form./* or // does not open a comment. Whether a
/ starts a regex or is division is decided by the preceding token, which is a
heuristic: an exotic construct such as a < /re/.source is read as division.Two strategies, picked automatically per repository:
| blobs | Up to a few dozen files: one Git Blobs API request each, run through a bounded concurrency pool with exponential backoff on 403/429. |
|---|---|
| tarball | Everything bigger: one request for the repository archive at the pinned sha, gunzipped and parsed as a stream. Cloudflare caps sub-requests per request, and a single archive is faster anyway. Nothing is ever cloned or executed. |
If GitHub reports the recursive tree as truncated (it caps around 100k entries), we say so in the result and enumerate from the archive instead, so totals stay complete.
Counting is CPU-bound, and this app runs on Cloudflare Workers, which cap CPU time per request. Past a certain size the server refuses up front — it tells you how much text it found and roughly how much CPU that needs — rather than being cut off mid-count.
When that happens the page offers to count it in your browser instead. The server streams the repository archive through without reading it (piping bytes costs no measurable CPU), and your browser does the decompression, tar parsing and classification, using the exact same code this server runs. Numbers are identical; there is no second implementation.
Trade-offs: the archive downloads to your machine, and the result is not cached or shareable, because the server never saw it. We do not accept counts computed elsewhere — that would let anyone put made-up numbers into the shared cache.
Results are cached in Cloudflare KV under owner/repo@sha plus the counter version and
your option flags, for 7 days. Because the key is an immutable commit, a cache hit can never be
stale. Branch→sha lookups are cached for 60 seconds, so counting a moving branch always
re-resolves promptly. Every result is labelled cached or
fresh. Tick “ignore cache” to force a recount.
Defaults, visible at /api/meta: 20,000 files, 64 MiB of decoded text, 4 MiB per file, 20 counts per minute per IP for anonymous users. Hitting a cap produces a warning on the result — partial totals are always labelled as partial.
POST /api/count
{"url": "https://github.com/owner/repo", "ref": "main"}
GET /api/count/{owner}/{repo}?ref=main&lockfiles=1&vendored=1&fresh=1
GET /api/stream?input=owner/repo&ref=main (server-sent events)
GET /api/meta (limits, versions)
GET /r/{owner}/{repo}/{sha} (shareable HTML result)
GET /api/resolve?input=owner/repo (repo + pinned sha, no counting)
GET /api/archive/{owner}/{repo}/{sha} (tarball passthrough)