Files
fish-config/CONTRIBUTING.md
T
rootiest aed37d5a75 docs(contributing): note that mirror labels now sync automatically
The mirror section told contributors a label added on Gitea "must be
created on the mirror too -- no automation does it for you." That's no
longer true, and a stale instruction to do something by hand is worse than
none, since it invites a manual edit that the next scheduled sync would
overwrite anyway.

Describe what the sync actually does: what it creates, updates, and prunes,
that an in-use label is never deleted, the --dry-run and --self-test flags,
and the rename caveat that follows from matching labels by name. Also
record the GH_MIRROR_TOKEN secret the workflow needs and its exact scopes,
since that's the one part of this that can't be automated.

The exclusive-labels caveat below it is unaffected and stays as written.
2026-08-31 23:32:16 -04:00

571 lines
26 KiB
Markdown
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.
# Contributing to fish-config
This is a personal dotfiles repo, but it's run with practices meant to scale
to a team of contributors, not just one person's solo habits. This document
formalizes those practices so they live somewhere durable instead of only in
commit history and conversation memory. It will grow as the project does —
treat it as a living document, not a final word.
## Table of Contents
- [Getting Started](#getting-started)
- [Issues](#issues)
- [Branching & Pull Requests](#branching--pull-requests)
- [Labels](#labels)
- [Commit Conventions](#commit-conventions)
- [Fish Coding Standards](#fish-coding-standards)
- [Opinionated Components](#opinionated-components)
- [Documentation Pipeline](#documentation-pipeline)
- [Testing](#testing)
- [Secrets & Machine-Specific Config](#secrets--machine-specific-config)
- [License](#license)
---
## Getting Started
You'll need [fish 4.x](https://fishshell.com/). Clone the repo and run the
test suite to confirm your environment is sane:
```fish
fish tests/run-tests.fish
```
If you're touching anything under `docs/manual/`, you'll also want `pandoc`,
`python3-yaml`, and Node 24+ to exercise the full doc-build pipeline locally
(see [Documentation Pipeline](#documentation-pipeline)) — otherwise CI will
catch problems on push.
## Issues
Issues live on the Gitea repo. Three templates cover the common cases, each
pre-applying its `Kind/` label; blank issues stay enabled for everything else
— a chore, a refactor, a question, a tracking issue.
| Template | Format | Use it for | Applies |
|---|---|---|---|
| **Bug report** | web form | Something is broken or behaves unexpectedly | `Kind/Bug` |
| **Feature or enhancement request** | markdown | New functionality, or an improvement to what exists | `Kind/Feature` |
| **Documentation issue** | markdown | The manual, man page, `config-help`, or docs site is wrong, missing, or unclear | `Kind/Documentation` |
They live in `.github/ISSUE_TEMPLATE/`, next to the PR template, so the
GitHub mirror offers the same set. The bug report is a Gitea *issue form* —
a real web form with required fields — because a bug report missing its
version, reproduction, or full error text can't be acted on, and a form
refuses to submit without them. The other two are markdown templates in the
same comment-guided style as `.github/PULL_REQUEST_TEMPLATE.md`, since what
they ask for is open-ended prose that structure would only get in the way of.
GitHub reads these same files on the mirror, and its schema differs from
Gitea's in two places, so both are pinned to the spelling that works on both
and each file says so in a comment: the chooser config must be `config.yml`
(GitHub ignores `config.yaml`), and `bug.yml` declares `description:` rather
than `about:` (GitHub requires it; Gitea accepts it as an alias). The two
markdown templates keep `about:`, which is correct for their format on both.
### Issue titles
**Issue titles are plain descriptions of the problem, not Conventional
Commits subjects.**
```text
mv clobbers a symlink when the target exists ← yes
fix(mv): prompt before replacing an existing symlink ← no
```
An issue states a problem; a commit states a change. The type and scope that
`fix(mv):` would carry are already on the issue as its `Kind/` and `Area/`
labels, and the conventional subject belongs on the PR that closes it, where
it becomes the commit message. Writing the fix into the title also presumes
one, which is the wrong end to start from for anything still being diagnosed.
### What an issue owes
- **A bug** needs a reproduction someone else can paste and run, starting
from a fresh shell, plus the complete error output. A stale function
definition in a long-lived session is the most common false alarm, so
confirm it survives `exec fish` first. `Status/Need More Info` is where
reports without a reproduction end up.
- **A feature** needs `## Acceptance criteria` — the checkbox list of what
must be true for the issue to close. It is the issue-side counterpart to a
PR's `## Verification`: a definition of done agreed before the work starts
rather than argued about after, and the PR's checks usually grow out of it.
- **A docs issue** needs to name the `docs/manual/**` source, not just the
page where the problem showed up. `docs/fish-config.md` and
`docs/fish-config.1` are generated, and a fix applied there is overwritten
by the next CI run — see [Documentation
Pipeline](#documentation-pipeline).
### Triage
Reporters aren't expected to label anything. Contributors without push access
can't, and the templates apply the `Kind/` label by themselves; the rest is
the maintainer's job when the issue is triaged — add the `Area/` label (the
bug form's **Area** dropdown is how a reporter tells you, since no forge can
map a form field to a label), set a `Priority/` if it isn't ordinary, and
apply `Reviewed/Confirmed` once a bug actually reproduces. See
[Labels](#labels).
When a PR resolves an issue it closes it with a trailing `Closes #N` line —
see [Pull request descriptions](#pull-request-descriptions).
## Branching & Pull Requests
**If you don't have push access to this repo**, fork it and open your PR
from a branch on your fork back to `main` here — everything below about
branch naming and commit hygiene still applies, it just happens on your
fork instead of a branch of this repo directly. The rest of this section
assumes you *do* have push access (maintainers, regular contributors).
- **Branch off `main` before starting work.** Don't accumulate uncommitted
changes directly on `main`. (If you already started editing before
branching, that's fine — create the branch now, before your first commit;
branching doesn't touch the working tree.)
- **Merge target is `main`, via PR.** Contributors open the PR; the repo
owner merges it. Don't merge your own PR.
- **Label every PR.** At minimum one `Kind/` and one `Area/`, same as an
issue — see [Labels](#labels). If you can't set labels, say what the
change is in the description and a maintainer applies them.
- **Don't merge until the `## Verification` checklist is fully checked.**
Unchecked boxes are outstanding manual checks, not decoration. See
[Pull request descriptions](#pull-request-descriptions) below.
- **Prefix in-progress PRs with `WIP:`.** If the branch still has code
changes coming, open it as `WIP: type(scope): description`. Gitea
recognizes the prefix, flags the PR as a draft, and refuses to merge it
until the prefix is removed; drop it once the branch is complete.
`WIP:` signals **more changes are coming** — not "done but unverified".
A finished branch waiting on manual checks is an ordinary PR whose
`## Verification` boxes aren't all ticked yet; that's already the merge
gate above and doesn't need the prefix. The two are independent: a PR
can be WIP with everything ticked, or complete with checks outstanding.
- **Keep feature branches focused.** If you stumble onto something unrelated
to your current task while working (a pre-existing bug, a stray cleanup),
don't fold it into the same commit or PR. Handle it with one of these,
in order of preference:
1. **Separate branch, separate PR, merged to `main` independently.** The
default for anything that doesn't overlap the code your current branch
touches. The two PRs review and merge independently, in either order.
2. **Separate branch and PR, then sync `main` back into your feature
branch** once it merges. Use this only when the unrelated fix actually
touches the same file/function as your feature branch, or your feature
branch depends on the corrected behavior to work or test correctly.
3. **Commit directly to `main`.** Reserved for changes too small to
justify a branch, or genuinely urgent fixes. **Always ask for explicit
approval before doing this** — there's no standing exception, no matter
how trivial the change looks.
### Pull request descriptions
Fill in `.github/PULL_REQUEST_TEMPLATE.md` — Gitea pre-loads it into the
description box when you open a PR. Every PR carries, in this order:
- **`## Summary`** — what changed and why, as a short paragraph, 2-5
bullets, or both. Name concrete paths and identifiers in backticks, and
explain the reasoning rather than restating the diff.
- **Optional `##` sections** — add what the change actually needs
(`Root cause`, `Why`, `How it works`, `Behavior`, `Docs`, `Notes`,
`Scope note`, `Opinionated guard (C1-C6)`), and skip them entirely for a
straightforward change. A breaking change (title ending in `!` before
the colon) must include `## ⚠️ Breaking Change` with the migration path.
- **`## Verification`** — always last. Every check this change needs, as a
checkbox list, each with the exact command and its expected result.
A **checked** box means verified, whether programmatically (test suite,
linter, docs verifier, CI) or by hand; check those off before opening the
PR. An **unchecked** box is an outstanding manual check the reviewer
still has to perform — leave anything you couldn't verify yourself
unchecked rather than dropping it.
**This list is the merge gate: a PR isn't merged until every box is
checked.** Only list checks that can actually be resolved — one nobody
can run blocks the PR indefinitely. Put genuinely unverifiable caveats,
assumptions, and known limitations in `## Notes` instead, where they
inform the review without gating it.
When a PR resolves a tracked issue, close it with a trailing `Closes #42`
line at the end of `## Summary` — not at the very bottom of the body, since
`## Verification` is always last. `Fixes #N` and `Resolves #N` behave
identically. Repeat the keyword for each issue (`Closes #42, closes #43`); a
bare `#43` is only a link and won't close anything. To point at a related
issue that should stay open, drop the keyword and use `Refs #42`. Leave the
line out entirely when no issue is involved.
## Labels
**Every issue and every pull request carries exactly one `Kind/` label and at
least one `Area/` label.** Everything else is optional, and most of it is
applied by a maintainer at triage rather than by whoever opened the thing.
Labels are scoped: the `Group/Name` form renders as a two-tone chip in Gitea,
and for the three *exclusive* groups below Gitea enforces one-at-a-time by
swapping the old label out when you apply a new one.
### `Kind/` — what this is
Required, and by convention exactly one. Gitea doesn't enforce one-of here,
so pick the dominant character of the change instead of stacking two.
| Label | For |
|---|---|
| `Kind/Bug` | Something is not working |
| `Kind/Feature` | New functionality |
| `Kind/Enhancement` | Improves functionality that already exists |
| `Kind/Documentation` | Documentation changes |
| `Kind/Testing` | The test suite itself |
| `Kind/Refactor` | Restructures code without changing behavior |
| `Kind/Chore` | Tooling, dependencies, housekeeping |
| `Kind/Performance` | Makes existing behavior faster or lighter |
| `Kind/Security` | A security issue |
These deliberately mirror the Conventional Commits types in [Commit
Conventions](#commit-conventions), so a PR's label and its title agree:
`fix` → `Kind/Bug`, `feat` → `Kind/Feature` or `Kind/Enhancement`, `docs` →
`Kind/Documentation`, `test` → `Kind/Testing`, `refactor` →
`Kind/Refactor`, `chore` → `Kind/Chore`, `perf` → `Kind/Performance`.
### `Area/` — what it touches
Required, and non-exclusive on purpose: a change that adds a function, its
completions, and a manual entry gets all three.
| Label | Covers |
|---|---|
| `Area/Functions` | `functions/` |
| `Area/Completions` | `completions/` |
| `Area/Config` | `config.fish`, `conf.d/` — startup and environment |
| `Area/Docs` | `docs/manual/` and the generated manual, man page, and site |
| `Area/Tests` | `tests/` |
| `Area/CI` | `.github/workflows/` and repository automation |
| `Area/Integrations` | `integrations/` |
| `Area/Prompt & Theme` | `themes/` and prompt appearance |
| `Area/Components` | The opinionated-component system (C1-C6) |
| `Area/Scripts` | `scripts/` |
`Area/` is what makes the tracker searchable: it answers "what's still
outstanding in the docs pipeline?" in a way `Kind/` never can. Two edges
worth naming — `Area/Docs` covers the documentation *and its pipeline*, so
`README.md` and this file count even though they sit outside `docs/`; and
`Area/Components` is for the C1-C6 machinery itself, not for every function
that happens to carry a `# COMPONENT` header.
### `Compat/Breaking`
Applied to **any PR whose title carries `!` before the colon**, and to any
issue proposing a change that would. It travels with the `## ⚠️ Breaking
Change` section that such a PR must already include — see [Pull request
descriptions](#pull-request-descriptions).
### `Priority/` — exclusive, maintainer-applied
`Priority/Critical`, `Priority/High`, `Priority/Medium`, `Priority/Low`.
**No priority label means ordinary priority.** Labeling everything defeats
the point, so leave it off unless the item is genuinely more or less urgent
than the rest of the queue.
### `Reviewed/` — exclusive, maintainer-applied
`Reviewed/Confirmed` goes on a bug that has actually been reproduced —
that's the signal separating a report from a known defect.
`Reviewed/Duplicate`, `Reviewed/Invalid`, and `Reviewed/Won't Fix` accompany
closing an issue, always with a comment saying why; a close with only a
label on it is not an explanation.
### `Status/` — exclusive, maintainer-applied
`Status/Blocked`, `Status/Need More Info`, `Status/Abandoned`. These describe
the item's current state, so remove one as soon as it stops being true — a
stale `Status/Need More Info` on an issue that got its answer is worse than
no label, because it reads as still waiting.
### `good first issue` and `help wanted`
Invitations to contributors, applied by a maintainer. Both are deliberately
**unscoped**: they'd be a natural fit under `Status/`, but that group is
exclusive, and an issue is quite often both blocked on something *and* open
for someone to pick up. Keeping them outside the group lets them coexist
with a real status.
Use `good first issue` for work that is genuinely self-contained — a clear
acceptance criterion, one or two files, no need to understand the
opinionated-component system first.
### The GitHub mirror
The repo is mirrored to
[github.com/rootiest/fish-config](https://github.com/rootiest/fish-config),
and **the mirror carries the same labels, by the same names**. That isn't
cosmetic: GitHub reads the same `.github/ISSUE_TEMPLATE/` files, and a
`labels:` entry naming a label that doesn't exist on that side is silently
dropped rather than reported.
Mirroring copies files, not repository settings, so labels don't travel with
a push. **`.github/workflows/sync-labels.yml` closes that gap**: it runs
`scripts/sync-labels.py` on a daily schedule, and again whenever the script
itself changes, to make GitHub match Gitea. Manage labels here, in the Gitea
UI, and the mirror catches up within a day — or dispatch the workflow by
hand for it to happen now. Nothing needs doing on the GitHub side.
The sync creates what's missing and corrects color or description drift,
and it deletes an extra label on the mirror **only when no issue or PR there
carries it**; one that's in use is reported with its count and left for a
human to decide about. Run the script with `--dry-run` to see the plan
without changing anything, or `--self-test` to check its diff logic offline
— both are useful before editing it. Because labels are matched by name,
renaming one on Gitea reads as a delete plus a create: the new name appears
on the mirror, and the old one is pruned only if it's unused.
The workflow needs a GitHub token in this repo's Actions secrets as
`GH_MIRROR_TOKEN`, scoped to the mirror with **Issues: read and write**
(GitHub files labels under Issues) and **Pull requests: read** (so the
in-use check sees labels on PRs). The job fails with an explicit message if
it's missing rather than quietly doing nothing.
One behavioral difference to keep in mind: **GitHub has no exclusive
labels.** Gitea enforces one-at-a-time on `Priority/`, `Reviewed/`, and
`Status/` by swapping the old label out; on the mirror those are ordinary
labels and nothing stops two of a group coexisting, so there the one-of rule
holds by convention alone.
Issues and pull requests belong on the canonical Gitea repo — the template
chooser links there first, on both sides. The mirror's tracker stays open so
that a report which lands there anyway isn't lost, not because it's a second
supported front door.
## Commit Conventions
Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org/):
```
type(scope): description
```
Types currently in use in this repo: `feat`, `fix`, `docs`, `test`, `chore`,
`perf`. The scope is usually the function, component, or subsystem touched
(e.g. `feat(help): ...`, `fix(scrub): ...`, `chore(docs): ...`). Look at
`git log` for recent examples before picking a type/scope for something
novel.
## Fish Coding Standards
### File header
Every hand-authored `.fish` file starts with:
```fish
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
```
(A few completion scripts adapted directly from a tool's own upstream
completions keep that tool's original attribution comment instead of this
header — match whichever convention the specific file already follows. New
files use the standard header above.)
### Public function documentation header
Every user-facing function gets a machine-parsed comment header directly
above its `function` line. This header is the single source of truth for
the generated manual (`docs/fish-config.md` / the man page / the docs
site) — see [Documentation Pipeline](#documentation-pipeline) for how it
gets consumed. The parser (`docs/manualtools.py`) recognizes these labels,
all optional except where noted:
| Label | Purpose |
|---|---|
| `CATEGORY` | **Required to appear in the manual at all** — see below. |
| `COMPONENT` | Only for functions gated by the [opinionated-component system](#opinionated-components). |
| `DEPENDENCIES` | Other functions this one calls that a reader may want to look up. |
| `SYNOPSIS` | One-line usage form. |
| `DESCRIPTION` | Prose description; can span multiple paragraphs. |
| `ARGUMENTS` | Flags/positional args, one per line. |
| `EXIT STATUS` | Exit codes and what they mean. |
| `RETURNS` | For functions used for their output/return value rather than exit status. |
| `EXAMPLE` | One or more realistic invocations. |
| `NOTES` | Anything else worth flagging (fallback behavior, caveats, gotchas). |
A full example (`functions/claude.fish`):
```fish
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# CATEGORY
# 12-ai-and-developer-tools
#
# COMPONENT
# aliases/dev-tools
#
# DEPENDENCIES
# agents-init
#
# SYNOPSIS
# claude [ARGS...]
#
# DESCRIPTION
# Wrapper for the claude CLI that ensures the AGENTS/ sub-repository is
# initialized and any agent-made changes are committed before launch.
# ...
```
**`CATEGORY` is the opt-in gate for the manual:** a function with no
`# CATEGORY` line produces no manual entry at all — this is how bundled
plugin internals and prompt guts stay out of user-facing docs without an
exclusion list. `CATEGORY` must exactly match one of the existing
`docs/manual/05-functions/NN-*.md` stubs:
```
01-file-and-directory 08-terminal-management
02-navigation 09-clipboard
03-editors-and-viewers 10-network
04-git-and-version-control 11-pager-and-logging
05-package-management 12-ai-and-developer-tools
06-dependency-management 13-media-and-utilities
07-system-and-monitoring 14-miscellaneous
```
If your function genuinely doesn't fit any of these, add a new
`docs/manual/05-functions/NN-your-category.md` stub (with frontmatter
matching its siblings) rather than force-fitting it into an existing one.
### Private/internal helper functions
Functions named with a leading `_` (e.g. `_agents_init_ensure_gitignore`,
`__fish_config_op_enabled`) are excluded from the manual unconditionally,
regardless of whether they carry a `CATEGORY` line — so they generally don't
have one. They should still carry the standard file header, and a lighter
`SYNOPSIS`/`DESCRIPTION` comment is encouraged wherever the function's
purpose or calling convention isn't obvious from its body, matching the
convention already used across `__fish_config_op_*.fish` and similar files.
### Function declaration
Give every function a `--description`, since it's what shows up in `fish -c
'functions'`/completions and other introspection:
```fish
function my_function --description 'Short, imperative description'
```
### Colored `--help` output
Every function with a `-h`/`--help` flag uses this standardized color
palette (established across the codebase 2026-08-22):
```fish
set -l c_head (set_color --bold cyan) # section headers
set -l c_cmd (set_color --bold) # the command name itself (theme-adaptive)
set -l c_flag (set_color yellow) # flags
set -l c_arg (set_color cyan) # required-argument placeholders
set -l c_dim (set_color brblack) # optional-argument placeholders
set -l c_ok (set_color green) # success/positive status
set -l c_warn (set_color yellow) # warnings
set -l c_err (set_color red) # errors
set -l c_reset (set_color normal)
```
Not every function needs every variable — pull in only the ones your help
text actually uses. Structure the usage block as `Usage:`, then sections
for arguments/flags/examples as needed; see `functions/rand_string.fish` or
any recently-touched function for a full worked example.
## Opinionated Components
Some functionality in this config is classified into one of six toggleable
categories (C1–C6: aliases, autoexec, overrides, integrations, logging,
greeting) plus sub-categories, so users can disable pieces of it via
`config-settings` or universal variables. If you're adding something that
shadows a builtin, runs at startup, overrides a key binding/environment
variable, or otherwise falls under an existing category, tag it with a
`# COMPONENT` header (`<category>/<subcategory>`, e.g.
`overrides/key-bindings`) and guard it with `__fish_config_op_enabled`. See
the [README's Minimal Mode section](README.md#minimal-mode) for the full
category list and toggle semantics, and
`AGENTS/specs/2026-08-17-opinionated-component-subcategories-design.md` for
the underlying design. Most new functions are *not* opinionated components
— only tag something if it fits an existing category; this isn't something
to force.
## Documentation Pipeline
`docs/manual/` is the single source of truth for the user manual, man page,
and documentation site. **Never hand-edit `docs/fish-config.md` or
`docs/fish-config.1`** — they're generated by `docs/build-manual.py` from
`docs/manual/**` plus every function's doc-header (see above), verified by
`docs/verify-manual.py`, and auto-committed by CI on push to `main`
(`chore(docs): regenerate manual, man page, and component registry`).
If you're changing something under `docs/manual/` directly, or adding a
function whose `CATEGORY`/`COMPONENT` header should surface new content, you
can build and check it locally before pushing:
```fish
python3 docs/build-manual.py --concat -o docs/fish-config.md
python3 docs/verify-manual.py
```
CI runs the same verification and regenerates the site/man page — a broken
manual won't get published, but running it locally saves a round trip.
Write doc-headers as plain text — no backticks. `-a/--all`,
`__fish_config_op_aliases` and `~/.config/fish/config.fish` are typed
bare, because the header is also read as-is by `config-help` and by
anyone opening the file. `docs/codespans.py` adds the inline code spans
when it renders, so the SSOT never carries them; see
`docs/site/README.md` for which shapes it recognises. That pass runs for
every output — the site, the man page and `config-help` — so a token is
typeset the same way wherever it is read.
Two rules apply to backticks you write under `docs/manual/` as well:
- **Never inside an indented block.** A four-space block is verbatim in
every renderer, so a backtick there is a literal character on the page
rather than markup.
- **Never wrapped across a line break.** Markdown accepts a span split
over two lines, but `config-help` pairs backticks one line at a time
and would show the halves literally. Reflow the sentence instead.
`docs/verify-manual.py` enforces both.
## Testing
```fish
fish tests/run-tests.fish
```
Runs before every push and gates the documentation build in CI. Two phases:
1. **Syntax lint** — every tracked `.fish` file (`config.fish`, `functions/`,
`conf.d/`, `completions/`, `integrations/`) is checked with `fish -n`.
2. **Sandboxed functional checks** — the config is copied into a throwaway
`HOME`/`XDG_CONFIG_HOME` sandbox (never this checkout itself, since it
doubles as a real `~/.config/fish`) and loaded as an isolated interactive
session. `tests/functional.fish` then runs against foundational behavior:
XDG/PATH/CDPATH setup, key bindings, abbreviations, core functions, the
opinionated-component registry, and more.
To add a new functional check, add a `test_*` function to
`tests/functional.fish` — it's picked up automatically by
`functional_test_main`, no registration needed. Return 0 on pass, non-zero
on fail; print a short diagnostic on failure.
## Secrets & Machine-Specific Config
**Nothing containing credentials, tokens, personal identifiers, or a
specific machine's paths belongs in this repo — not even in a PR.** That
kind of thing lives in each user's own private overlay
(`~/.config/.user-dots/fish/secrets.fish` and `local.fish`), which is
git-ignored by design. If you're writing something that needs a secret or a
machine-specific path, source it from there rather than hardcoding it.
Everything else — general-purpose, reusable across machines — belongs in
the tracked repo as normal. See [README's Personalization
section](README.md#personalization) and
[`docs/manual/07-customization.md`](docs/manual/07-customization.md) for the
full mechanics.
## License
This project is licensed under AGPL-3.0-or-later (see `LICENSE`). Every new
hand-authored file needs the SPDX header shown in [File
header](#file-header) above.