Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Strip the noise. Keep the code. Lint what's left.
uncomment is a comment remover and a comment linter, both built on tree-sitter's AST — so both are 100% accurate and never touch comment-like text inside strings. Removal keeps what matters by default (TODO/FIXME, docs, and linting directives) across 300+ languages; linting checks that tag comments (TODO, FIXME, HACK, XXX) carry a real issue key instead of going stale. Either one can gate a commit or a pull request — scoped to a whole tree, to what a branch changed, or line by line — with parallel processing and a safe dry-run mode.
AST-accurate · 306 languages · zero false positives · tag-comment linting · changed-lines scoping · parallel · dry-run
Install · Features · Usage · Linting · GitHub Action · Configuration · How it works · Contributing
Why uncomment
Regex-based comment strippers guess. They delete a // inside a string literal, mangle a URL in a
docstring, or leave a linting directive your CI depends on. uncomment doesn't guess: it parses your
code into a real syntax tree and acts only on the nodes that are genuinely comments.
Originally built to clean up AI-generated code drowning in explanatory comments, it now works on
anything with a tree-sitter grammar — and it grew a second job alongside the first. Deleting
comments and keeping the ones that track work are the same problem seen from two sides: both need
to know, precisely, which comments exist and what they say. uncomment removes; uncomment lint
checks that a TODO or FIXME still points at a live issue key instead of quietly rotting; uncomment --check gates a commit or a pull request on either policy without writing anything.
Features
- 100% accurate — tree-sitter AST parsing identifies comments structurally, not by pattern matching
- No false positives — never removes or misreads comment-like content inside strings
- Smart preservation — keeps TODO/FIXME, docs, and language-specific linting directives by default
- Tag-comment linting —
uncomment lintchecks TODO/FIXME/HACK/XXX for a canonical form and a real issue key, with--fixand a baseline for adopting it on an existing codebase - Gate-ready —
--checkandlintboth exit non-zero on a violation, scoped to a whole tree,--changed-only/--changed-linesagainst a base ref, or--staged - 306 languages — powered by tree-sitter-language-pack, every grammar compiled into the binary
- Parallel — multi-threaded processing that scales across cores
- Safe — dry-run mode with line-by-line diffs previews every change before you write
- Configurable — hierarchical TOML config with a smart
initcommand - Built-in benchmarking — optional performance analysis and profiling tools
Installation
| Channel | Command |
|---|---|
| Homebrew (macOS/Linux) | brew tap goldziher/tap && brew install uncomment |
| Cargo (Rust) | cargo install uncomment |
| npm (Node.js) | npm install -g uncomment-cli |
| pip (Python) | pip install uncomment |
Prefer prebuilt binaries? cargo binstall uncomment downloads a
release archive instead of compiling from source.
Run without installing:
Add --dry-run to preview changes before writing.
Requires Rust 1.70+. npm and pip packages download pre-built binaries automatically.
Quick Start
# Generate a configuration file tuned to your project
# Remove comments from a directory
# Preview changes as a diff, write nothing
Usage
# Single file
# Multiple files / globs
# Also strip doc comments and docstrings
# Also remove TODO and FIXME comments (preserved by default)
# Add custom patterns to preserve
# Skip paths entirely, whatever the comments in them
# Process an entire tree with all CPU cores
# Fail (exit 1) if any comment would be removed, writing nothing
Run uncomment --help for the full, grouped list of options.
The init command detects the languages in your project and writes a matching .uncomment.toml:
# Smart detection — includes only the languages it finds
# Every global and pattern option, fully commented
# Interactive selection
# Custom output location / overwrite
Development binaries for benchmarking and profiling are gated behind the bench-tools feature so
they are not installed for regular users:
# Install with extras
# Or run locally
Supported Languages
uncomment ships with 55 built-in language configurations and can process any of the 306 languages in tree-sitter-language-pack — every grammar is compiled into the binary, so nothing is downloaded, built or cached at runtime, and any language can be added via configuration.
Python (.py, .pyw, .pyi, .pyx, .pxd) · JavaScript (.js, .jsx, .mjs, .cjs) ·
TypeScript (.ts, .mts, .cts, .d.ts, .d.mts, .d.cts) · TSX (.tsx) · Rust (.rs) ·
Go (.go) · Go templates (.tpl, .tmpl, .gotmpl, .gohtml) · Java (.java) ·
C (.c, .h) · C++ (.cpp, .cc, .cxx, .hpp, .hxx) · C# (.cs) ·
Ruby (.rb, .rake, .gemspec) · PHP (.php, .phtml) · Elixir (.ex, .exs) · TOML (.toml) ·
JSON (.json) · JSON with Comments (.jsonc) · YAML (.yml, .yaml) ·
HCL/Terraform (.hcl, .tf, .tfvars) · Makefile (Makefile, makefile, GNUmakefile, .mk) ·
Shell/Bash (.sh, .bash, .zsh, .bashrc, .zshrc, .zshenv) · Haskell (.hs, .lhs) · HTML (.html, .htm, .xhtml) ·
CSS (.css) · XML (.xml, .xsd, .xsl, .xslt, .svg) · SQL (.sql) · Kotlin (.kt, .kts) ·
Objective-C (.m) · Swift (.swift) · Lua (.lua) · Nix (.nix) · PowerShell (.ps1, .psm1, .psd1) ·
Protobuf (.proto) · INI-like configs (.ini, .cfg, .conf) · Dockerfile (Dockerfile, Dockerfile.*) ·
Starlark/Bazel (BUILD, BUILD.bazel, WORKSPACE, WORKSPACE.bazel, WORKSPACE.bzlmod, MODULE.bazel, .bzl, .bazel, .star) ·
Java properties (.properties) ·
Markdown (.md, .markdown, .mdown, .mkd) ·
Scala (.scala, .sc) · Dart (.dart) · R (.r, .R) · Julia (.jl) · Zig (.zig) ·
Clojure (.clj, .cljs, .cljc, .edn) · Elm (.elm) · Erlang (.erl, .hrl) · Vue (.vue) ·
Svelte (.svelte) · SCSS (.scss) · LaTeX (.tex, .sty, .cls) · Fish (.fish) ·
Perl (.pl, .pm) · Groovy (.groovy, .gradle) · OCaml (.ml, .mli) ·
Fortran (.f90, .f95, .f03, .f08)
Objective-C uses the objc grammar. The .m extension is also used by MATLAB;
Uncomment treats it as Objective-C by default. When processing a mixed project,
pass only the Objective-C paths. Headers (.h) retain the C configuration;
Objective-C++ (.mm) is not included in built-in support.
Markdown has no comment of its own — <!-- … --> is raw HTML to CommonMark, and the grammar reports
it as the same html_block that carries a <div align="center"> badge row. A block is treated as a
comment only when it is nothing but one comment, so embedded HTML, a <!DOCTYPE>, an unterminated
<!-- (which runs to the end of the document) and <!-- x --><div>kept</div> are all left alone. A
comment inside a paragraph or a table cell is inline content rather than an html_block, so it is
out of reach; .mdx is not supported at all, because no grammar in the pack parses {/* … */}.
In a Go template a comment is only a comment inside an action, so a bare /* … */ is literal output
text and {{/* … */}} is the only form. The grammar models the {{ and }} as siblings of the
comment rather than as part of it, so the whole action is removed — deleting only the comment would
leave {{}}, which Helm refuses to render. One spacing caveat: {{ /* spaced */ }} and {{-/* x */-}}
produce no comment node at all and are left alone. A comment with code on both sides keeps the
spaces around it (hello {{/* x */}} world → hello world), because in a template that whitespace is
rendered output. One that ends the line does not: hello {{/* x */}} leaves hello, since a trailing
comment takes its separator with it like any other language's.
Preservation Rules
Certain comments are never removed by default — uncomment protects the ones your tooling and teammates rely on.
Always preserved:
- Comments containing
~keep TODO(unless--remove-todo),FIXME(unless--remove-fixme)- Documentation comments (unless
--remove-doc)
The ~keep marker
Put ~keep on a //-style comment, not on a /// or /** */ doc comment. ~keep is
plain comment text, so a marker written inside a doc comment is republished by every tool
that consumes doc comments — rustdoc, OpenAPI schemas, generated API clients, editor
hover text.
A marker on its own line protects the comment directly beneath it, which keeps it out of anything rendered:
// ~keep
/// Parent element ID for hierarchical relationships.
pub parent_id: ,
The marker also extends across a contiguous run of comments, so one ~keep protects a
whole multi-line block. A blank line or any code between comments ends the run.
Inside a doc comment the marker is usually redundant, since doc comments are preserved
anyway unless --remove-doc is set. uncomment strips such a marker from the doc text and
reports it (stripped 2 redundant ~keep markers), leaving the comment itself untouched.
Two cases are left alone: a marker is kept when --remove-doc is set, because there it is
the only thing protecting that doc comment, and prose about the marker is never rewritten
— a line containing backticks, or a commented-out code sample, is read as documentation
rather than as a directive.
| Language | Directives |
|---|---|
| Go | //nolint, //golangci-lint, //staticcheck, //go:generate |
| Python | # noqa, # type: ignore, # mypy:, # pyright:, # ruff:, # pylint:, # flake8:, # fmt: off/on, # black:, # isort:, # bandit:, # nosec, # pyre-ignore |
| JS/TS | eslint-disable*, @ts-ignore, @ts-expect-error, @ts-nocheck, /// <reference, prettier-ignore, biome-ignore, deno-lint-ignore, v8/c8/istanbul ignore |
| Rust | #[allow], #[deny], #[warn], #[forbid], #[cfg], clippy::, #[rustfmt::skip] |
| Java | @SuppressWarnings, @SuppressFBWarnings, //noinspection, // checkstyle: |
| C/C++ | // NOLINT, // NOLINTNEXTLINE, #pragma, // clang-format off/on |
| Shell | # shellcheck disable, # hadolint ignore |
| YAML | # yamllint disable/enable |
| HCL/Terraform | # tfsec:ignore, # checkov:skip, # trivy:ignore, # tflint-ignore |
| Ruby | # rubocop:disable/enable, # reek:, # standard:disable/enable |
| Markdown | <!-- markdownlint-* -->, <!-- prettier-ignore -->, <!-- vale off/on -->, <!-- START/END doctoc --> |
Subcommands
uncomment scan — comment inventory
Reports every comment in the tree with the verdict a real run would reach, as JSONL, JSON or text. Each comment gets a stable id that survives edits elsewhere in the file.
# Inventory everything
# Only the comments a run would remove
# Collapse identical comments into groups, sorted by frequency
Flags:
--format jsonl|json|text— output format (default: jsonl)--only removable|preserved|all— filter by verdict (default: all)--group-identical— collapse equivalent comments into one record per distinct text, with a site count and list-o FILE— write report to FILE instead of stdout
ID scheme: IDs are derived from the file path, comment bytes, and occurrence index within the file — no line numbers, so an id survives edits above the comment. Collisions within a file are widened to the full 32-hex identifier; cross-file collisions widen the later claimant.
uncomment keep — write ~keep markers
Applies ~keep markers to comments selected by id, substring, or verdict. A line comment gets ~keep appended; a block, doc or docstring comment gets a marker line directly above it, written with the language's plain line-comment token — or with its block pair (/* ~keep */, <!-- ~keep -->) when the language has no line form. A block, doc or docstring comment sharing its line with code cannot be marked at all: a marker line above it would attach to the line of code, so the comment is reported as unmarkable instead.
# Mark everything a scan reported as removable
# Mark specific comments by id
# Mark every comment whose text contains a substring
# Mark everything that would be removed
Flags:
--from FILE— read comment ids from a scan output file (JSONL or JSON; every field butidis ignored)--id ID— mark the comment with this id (repeatable)--match SUBSTRING— mark every comment whose text contains SUBSTRING--all-removable— mark every comment a default run would remove--skip-missing— warn about ids that no longer resolve instead of failing
Why a marker line above for docstrings: A Python docstring is the string node that becomes __doc__ at runtime, so editing its bytes changes what the program reports about itself. A marker line written above the docstring is a plain comment that uncomment reads but the runtime ignores.
uncomment --check — fail when a comment would be removed
Runs exactly as --dry-run does and writes nothing, but reports every comment the run would remove as a violation and exits non-zero, so the policy "every comment is either kept on purpose or gone" can gate a commit or a pull request. A comment passes when uncomment's own rules keep it: a ~keep marker, a linting directive, a documentation comment, a configured preserve pattern, or TODO/FIXME while those are preserved.
# Everything under src/
# Only files a branch changed, or only the lines it changed
# Only what the next commit changes, line by line (for a pre-commit hook)
Each violation is one line, sorted by path, then line:
src/main.rs:2:5: // strip me
src/main.rs:3:16: // trailing note
✗ 2 removable comment(s) in 1 file(s) (1 file(s) checked)
Exit codes: 0 nothing would be removed, 1 something would, 2 the check could not be completed — bad arguments, a rejected config, a file that could not be read, or a failed git diff — so a broken gate never reads as a pass.
Flags:
--check— report instead of rewrite; rejects--diff, and makes--dry-runredundant--format text|json— output format; JSON has the sameviolations/notes/summaryshape asuncomment lint--changed-only— check only files changed against--base(default:origin/HEAD, elsemain)--changed-lines— report only comments on lines the diff added or rewrote (implies--changed-only); this is what makes the gate adoptable on a codebase that already has comments, because editing a legacy file does not make its old comments your problem--staged— diff the index againstHEADinstead of--base--base REF— base ref for--changed-onlyand--changed-lines
Excluded paths and files of unsupported types are skipped without a word, so a hook can pass every staged file. --quiet prints only the summary. See Git Hooks for a lefthook and a GitHub Actions setup.
uncomment lint — tag comment linting
Checks tag comments (TODO, FIXME, HACK, XXX) against the convention configured under [lint]: that the tag is canonical, that it carries an issue key, and that the key is not the issue the current branch is working on. Removes nothing, exits 1 on violations, and works as a pre-commit hook.
# Check comments, report violations, write nothing
# Fix what can be fixed (canonical tags, injecting --todo-key)
# Check only what the branch touched
# Record current violations; future runs treat them as informational
Flags:
--fix— rewrite what can be rewritten (canonical tag form, injecting--todo-keywhere missing)--todo-key KEY— issue key to insert into tag comments that have none (with--fix)--changed-only— lint only files changed against--base's merge-base withHEAD(default:origin/HEAD, elsemain), comparing against the working tree — staged, unstaged and untracked (but not ignored) edits included, not just what has been committed--changed-lines— report only violations on lines changed against--base(implies--changed-only, and the same working-tree comparison — an untracked file counts every line)--staged— diff the index againstHEADinstead of--base--base REF— base ref for--changed-onlyand--changed-lines--baseline FILE— treat violations recorded here as informational--write-baseline— record every current violation in the baseline file and exit 0--format text|json— output format (default: text)
Five rules:
tag-not-canonical— a tag not written the waycanonical_tagis written. That covers two defects and the message says which: a different spelling, reported asFIXMEshould be written asTODO, and the canonical spelling in the wrong casing, reported astodoisTODOwritten with the wrong casing.--fixrewrites both.todo-missing-key— no issue key at all: not in a group, not spelled out nearbytodo-key-not-upper-case— a key was found but is not upper case, reported as issue keyamvp-12must be upper case.--fixrecases it in place.tag-form-not-canonical— a key was found near the tag, but the tag is not written asTAG(KEY):around it: the key sits bare in the text (TODO AMVP-12: x,TODO: AMVP-12 x), in brackets (TODO [AMVP-12] x), or the tag itself is wrapped in its own(...).--fixrewrites the whole form — the wrap, any bracket, and a key already in the leading text — into the singleTAG(KEY):prefix, reusing the key that was already there rather than inserting a second one from--todo-key. A leftover from an earlier, careless rewrite, such as(TODO(AMVP-1):):, is reported and cleaned up the same way.todo-self-reference— the key must not be the issue the current branch is for (that issue closes when the branch merges, leaving the TODO pointing at a dead ticket)
Casing: tags are matched regardless of casing, so fixme, Todo and xXx are tags and each is a tag-not-canonical violation whose --fix normalises it to canonical_tag. A key is recognised the same way: TODO(amvp-1) and todo(amvp-1) both count as keyed, but a lower-case key is still wrong — it is todo-key-not-upper-case, not todo-missing-key, and --fix recases it without touching anything else. key_pattern governs this canonical TAG(KEY): group and can be replaced with a custom pattern; the bare-key forms tag-form-not-canonical also recognises (TODO AMVP-12: x, TODO: AMVP-12 x, TODO [AMVP-12] x) always use the default AMVP-12-shaped key, regardless of a custom key_pattern. Set case_sensitive_tags = true to go back to matching only the literal casing in tags, where todo: is not a tag at all.
Tag position: a tag word counts only where a tag is written, not where a sentence mentions one. It is a tag when it opens its comment — everything before it on the line is delimiter or decoration (//, #, /*, {/*, a * continuation, a - bullet) — or when it opens a trailing segment of the comment, as in # noqa: T201 # TODO: fix. In that head position any casing counts, so # todo: fix and * Hack: works around the driver are tags. Further into a comment, only the exact spelling in tags can be a tag, and only when it reads as one: followed by :, ( or a spaced dash (# Socks handling - TODO: add a failure type), or opening a clause after a label or punctuation (# Note: TODO later, // biome-ignore lint/x: TODO remove, # fine for now, TODO revisit). Everything else is prose and is neither reported nor touched by --fix: # Line contains TODO, # Missing author in TODO, ?tenant_id=XXX, {"uuid": XXX}, and any tag word in backticks or quotes (`TODO`, 'hack'). The cost is a tag written as a bare trailing word — # remove later TODO — which reads exactly like # Line contains TODO and is not reported.
Doc comments: docstrings and doc comments (///, //!, /** */, Python docstrings) are not inspected by default. They describe an interface, and a tag word in one — "Returns the TODO list", {"uuid": XXX} will be returned — is far more often part of that description than tracked work. A plain comment that a language handler files as documentation only by position, such as Go's // TODO directly above a func, is still linted, and so is a ## TODO: comment, which in Python, YAML and shell is emphasis rather than Doxygen. Set include_doc_comments = true to lint documentation too; the tag-position rule still applies inside it.
A sigil is a separate question from casing and always has been: \b sits between @ and the tag, so # @TODO: x is a tag today and # @todo: x becomes one now. --fix rewrites the tag token alone and leaves the sigil, giving # @TODO: x. If @todo is prose you do not want linted, case_sensitive_tags = true is not the lever — drop TODO from tags or baseline the occurrences.
Opt-in: Linting is off by default. Enable it per file tree with [lint] in .uncomment.toml:
[]
= true
= '^\s*(?:TODO|FIXME|HACK|XXX)\((?<key>[A-Z][A-Z0-9]+-\d+)\)\s*:'
# Optional, default false. With `true`, only the literal casing in `tags` is a tag,
# so `todo:` and `Fixme:` are not flagged at all.
= true
# Optional, default false. With `true`, docstrings and doc comments are linted too.
= true
Note what enabled = true alone commits you to: with the default tags and canonical_tag, every existing FIXME, HACK and XXX in the tree becomes two violations — one for the tag, one for the missing key — and --fix rewrites the tag to TODO. Every miscased tag counts too, todo: and Fixme: included. On an existing codebase, reach for --changed-only or --write-baseline first.
Large-repo workflow
On a large codebase, deciding which comments to keep is a batch process: scan once, filter the report, then mark what the filter selected.
Two scans, because the two reports answer different questions. A grouped report is for deciding — one judgement per distinct wording instead of hundreds. keep cannot act on it: a grouped record's id is a group id derived from the normalized text, with no path and no occurrence index, so it resolves to no single comment and the run fails with every id unresolved (--skip-missing downgrades that to a warning). Feed keep --from the ungrouped report.
# 1. Read the decision surface: one record per distinct comment, by frequency
# 2. Inventory every site, which is what keep consumes
# 3. Filter scan.jsonl — delete the lines for comments that should be removed,
# keep the lines for comments that should be kept. Any JSON-aware tool works;
# the only field `keep` reads is `id`.
# 4. Apply markers to the comments the filtered report selected
# 5. Run the real removal
# 6. Verify: a second scan reports 0 removable comments
Configuration
uncomment reads hierarchical TOML configuration. Precedence, lowest to highest:
- Built-in defaults
- Global config at
uncomment/config.tomlunder the platform config directory (dirs::config_dir()) —$XDG_CONFIG_HOMEor~/.configon Linux,~/Library/Application Supporton macOS,%APPDATA%on Windows - Local
.uncomment.tomlfiles (from repository root toward the file; inner beats outer) - Pattern matches (
[patterns."glob"]) within those configs, last match wins - Language-specific settings (
[languages.name]) - Command-line flags
A directory's config file is named .uncomment.toml. Two earlier names are still read — .uncommentrc.toml, then uncomment.toml — so an existing config keeps working, but each is deprecated and a run that loads one says so once on stderr. A directory holding more than one of the three uses the highest-precedence name outright; the others are ignored rather than merged.
[lint] layers differently from the rest: a nested table amends the one above it key by key, each severity in [lint.rules] included, so a subdirectory can switch one rule off and still inherit enabled, tags and key_pattern from above.
[languages.*] does not follow step 3 either. The language registry is built once, before any file is read, from the configs in the invocation directory and its ancestors plus every config found under the paths being processed — deepest declaration wins. (--config FILE replaces that discovery entirely.) A config reached only later, during per-file resolution, comes too late: its [languages] section is ignored, with a warning naming the directory.
CLI flags are one-directional: --remove-doc sets remove_docs = true but an unset flag never clobbers a config-file value back to false.
exclude does not layer like the rest either — it is a union. A file matching any exclude glob in force is not collected by any subcommand, and a nested config can add an exclusion but never withdraw one. Repeating --exclude GLOB adds to the configured list rather than replacing it, and naming an excluded path on the command line does not override it: the setting says which paths the project never wants read, not which ones this invocation skips. Globs use the same dialect as [patterns."<glob>"] keys — * does not cross a /, ** does — and are anchored the same way, relative to the directory holding the config file that declared them (relative to the invocation directory for --exclude, and for a --config FILE config). exclude = ["vendor/**"] also keeps the directory walk out of vendor rather than descending it and discarding the results. Like [languages.*], exclude has to be known before collection, so it is read from the same set of configs: the invocation directory and its ancestors plus every config found under the paths being processed.
Every table rejects unknown keys, and a project config that does not parse stops the run instead of degrading to built-in defaults — so a typo such as enable for enabled in [lint] fails every subcommand, not just lint.
[]
= false
= false
= false
= ["IMPORTANT", "NOTE", "WARNING"]
= ["playground/**", "thirdparty/**"]
= true
= true
[]
= "Python"
= ["py", "pyw", "pyi"]
= ["comment"]
= ["string"]
= ["noqa", "type:", "pragma:", "pylint:"]
[]
# Keep all comments in test files
= false
= false
= false
Any of the 306 tree-sitter-language-pack languages works. The grammar is already in the binary, so
there is no grammar to fetch or build. The grammar is looked up by the name field lowercased — not
by the section key — so name has to be the pack's own name for the language, or the name of a
built-in you are overriding. A name that matches neither is reported on stderr rather than
silently ignored:
[]
= "Hare"
= ["ha"]
= ["comment"]
= ["TODO", "FIXME"]
A language can claim whole filenames instead of, or as well as, extensions — the only way to reach a file that has no extension at all:
[]
= "Starlark"
= ["bzl", "bazel", "star"]
= ["BUILD", "BUILD.bazel", "WORKSPACE", "MODULE.bazel", "Tiltfile"]
= ["comment"]
filenames is matched case-sensitively and consulted before any extension rule, so a claim on
BUILD.bazel decides that file regardless of what else claims .bazel. Case matters because
BUILD and build are different files to Bazel, and folding the key would hand a build shell
script to a Starlark parser. An entry ending in .* claims every name starting with the part before
the * — filenames = ["Dockerfile.*"] reaches Dockerfile.prod — and where two such prefixes
overlap the longer one wins, never whichever was registered first. A [languages.*] section needs at
least one extensions or filenames entry; with neither it claims no file at all and is rejected
rather than silently doing nothing.
How It Works
Unlike regex-based tools, uncomment builds a proper Abstract Syntax Tree of your code with tree-sitter, so it distinguishes:
- Real comments vs comment-like content in strings
- Documentation comments vs regular comments
- Inline comments vs standalone comments
- Language-specific metadata that must be preserved
The pipeline is modular: a language registry (51 built-ins, plus any other compiled-in grammar named in config) feeds an AST visitor that finds comment nodes, a preservation engine decides what to keep, and an output generator emits clean code.
Git Hooks
repos:
- repo: https://github.com/Goldziher/uncomment
rev: v3.5.0
hooks:
- id: uncomment
Use id: uncomment-check instead to fail the commit on any comment a staged change adds that uncomment would remove, rewriting nothing.
pre-commit:
commands:
uncomment:
run: uncomment {staged_files}
stage_fixed: true
To block the commit instead of rewriting the files, check them. --changed-lines --staged asks only about the lines the commit changes, so touching a legacy file does not fail on the comments already in it:
pre-commit:
commands:
uncomment-check:
run: uncomment --check --staged --changed-lines {staged_files}
Drop --staged --changed-lines to hold every staged file to the policy in full.
Use the GitHub Action below instead of installing the CLI by hand — it downloads and caches a matching release binary and turns violations into inline PR annotations.
GitHub Action
Goldziher/uncomment ships a composite action that installs a release binary — cached, checksum-verified,
no compilation — and runs it, with violations reported as inline PR annotations via a
problem matcher.
The binary is self-contained (every tree-sitter grammar is compiled in statically), so nothing about
it touches the network beyond the one release download.
- uses: Goldziher/uncomment@v3.10.0
with:
args: "lint ." # or "--check .", or plain removal args
Lint an entire repository, failing the build on a violation:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: Goldziher/uncomment@v3.10.0
with:
args: "lint ."
Lint (or check) only the lines a pull request changed: --changed-lines diffs against the
merge base, so the checkout needs history back to it — fetch-depth: 0 is the simple way to
guarantee that. The action passes the pull request's base SHA to --base for you.
on: pull_request
jobs:
lint-changed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: Goldziher/uncomment@v3.10.0
with:
args: "lint ."
changed-lines: "true"
Set changed-only: "true" instead to scope by whole file rather than by line, or add
args: "--check ." to gate removable comments instead of (or alongside, as a second step) tag
lint.
Install only, and run uncomment yourself:
- uses: Goldziher/uncomment@v3.10.0
id: uncomment
with:
install-only: "true"
- run: uncomment --version # ${{ steps.uncomment.outputs.version }} is also available
Inputs (all optional):
| Input | Default | Description |
|---|---|---|
version |
resolved from the tag the action is pinned to, else latest |
uncomment version to install, with or without a leading v |
args |
lint . |
Arguments passed to uncomment, as one space-separated string |
working-directory |
. |
Directory to run uncomment in |
install-only |
false |
Install and put uncomment on PATH, but don't run it |
changed-only |
false |
Append --changed-only |
changed-lines |
false |
Append --changed-lines (implies changed-only) |
staged |
false |
Append --staged |
base |
PR base SHA on pull_request, else uncomment's own default |
Base ref/SHA for --changed-only/--changed-lines |
token |
${{ github.token }} |
Token used to resolve latest and download the release asset |
Outputs: version (the version installed) and path (absolute path to the binary).
The action is a plain composite action (no bundled JavaScript) that resolves the right release
asset for the runner's OS/arch, verifies its published checksum, caches it with actions/cache
keyed by version, OS and arch, and adds it to PATH.
Performance
AST parsing costs a little more than regex, but the tool is fast and scales well with threads.
- Small files (<1000 lines): ~20-30ms
- Large files (>10000 lines): ~100-200ms
| Threads | Files/second | Speedup |
|---|---|---|
| 1 | 1,500 | 1.0× |
| 4 | 3,900 | 2.6× |
| 8 | 5,100 | 3.4× |
Benchmarked on a large enterprise codebase of ~5,000 mixed-language files. Measure your own with
the built-in benchmark and profile tools (see optional benchmarking tools).
Development
See CONTRIBUTING.md for local development, automation hooks, and release
procedures.
Contributing
Issues and pull requests are welcome. If uncomment is useful to you, consider sponsoring development — it helps keep the project maintained for the community.