How we count

No fake precision. Here is exactly what happens.


1. We do not use GitHub's language bar

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.

2. Pinned to one commit

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.

3. Which files are counted

We list the repository with the Git Trees API (?recursive=1) and drop entries before fetching anything. A file is skipped when it is:

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.

4. How a line is classified

Each line lands in exactly one bucket, so lines = code + comment + blank:

blankNothing but whitespace — including blank lines inside a block comment (this matches cloc).
commentNon-blank, and every non-whitespace character on it was inside a comment.
codeAnything 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.

Languages with full comment rules

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.

Languages without comment rules

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.

Known limitations

5. How the content is fetched

Two strategies, picked automatically per repository:

blobsUp to a few dozen files: one Git Blobs API request each, run through a bounded concurrency pool with exponential backoff on 403/429.
tarballEverything 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.

6. Big repositories

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.

7. Caching

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.

8. Limits

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.

9. API

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)