Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,48 @@ All notable changes to this project will be documented in this file.

## Unreleased

### Breaking changes

The license-header configuration model has changed. The style-keyed `[mapping.STYLE_NAME]` blocks and the `useDefaultMapping` option are removed; configure file handling with a per-language `[[headers]]` rule list instead:

* Replace each `[mapping.STYLE] { extensions = ["x"], filenames = ["y"] }` with:
```toml
[[headers]]
extensions = ["x"]
filenames = ["y"]
styles = ["STYLE"]
```
* Default language-to-style rules stay active; declare `[[headers]]` only to override or extend them. `useDefaultMapping` is renamed to `useDefaultHeaders`.

`hawkeye format` no longer stacks a duplicate header when a file already has one written in a comment style different from the configured one. An existing header is detected by a style-agnostic scan for the configured `keywords`. Each `[[headers]]` rule chooses what happens to such a file via `existingStrategy`:

* `"replace"` (default): remove the existing header and write the preferred style. When a rule lists more than one style, a header in any listed style is removed, so it migrates to the preferred (first) style instead of being duplicated. If the file looks licensed but the command cannot normalize it to the preferred style (the header is in an unlisted comment style, or only a stray keyword is present), the file is left unchanged and the run fails (see the breaking note below).
* `"skip"`: leave the file untouched and report it; the run still succeeds (exit 0).
* `"error"`: fail the run.

Detection is a keyword scan, so it has a blind spot in the other direction too: a real notice that contains none of the configured `keywords` (an MIT/BSD notice with no `copyright` line, or the keyword-less tail of a header split by an inserted blank line) is not detected and is left below the rewritten header. Tune `keywords` if this or the stray-keyword case bites.

The `keywords` scan is now case-insensitive as documented: configured keywords are lowercased before matching, so a custom `keywords = ["Copyright"]` now matches a `copyright` notice. In 6.x a capitalized custom keyword silently never matched. The default (`copyright`) is unaffected.

`existingStrategy` governs `hawkeye format` only; `hawkeye check` and `hawkeye remove` ignore it.

Under `existingStrategy = "replace"`, a license-looking header that cannot be normalized to the preferred style now fails the run with a non-zero exit code. In 6.x the file was left unchanged and the run still exited 0, silently passing an un-normalized file through CI. The file is still left untouched; only the exit code changed. `"skip"` is unaffected and still exits 0.

`hawkeye check` now reports files that carry a non-matching existing header (a stale header in a recognized style, or a notice in a style the rule does not list) as a distinct `foreign` category, separate from files missing a header entirely. Because detection includes the same keyword scan, a file that has no header but contains a stray keyword near the top is reported as `foreign` rather than `missing`.

The `--output` JSON uses one grammar across `check`, `format`, and `remove`: every result field that lists files is a list of file paths categorized by the field name (`unknown`, `missing`, `foreign`, `skipped`, `conflict`, `removed`). `format`'s `updated` is the same list shape but its entries are suffixed `path=added` or `path=replaced`. `remove`'s `removed` entries are now bare file paths (previously `path=removed`), so scripts that split on `=` must be updated. `format` and `remove` additionally carry a boolean `dry_run` field (not a path list).

The `foreign` field means different things per command. `check.foreign` is any non-matching existing header (stale, listed-foreign-style, or unlisted), found by the structural parse or the keyword scan. `format.foreign` and `remove.foreign` are narrower: only what the command could not handle - an unlisted comment style, or a stray keyword (`format` could not normalize to the preferred style; `remove` could not locate a removable block). A stale or listed-foreign-style header is migrated/removed instead, so it never appears in their `foreign` field.

`hawkeye format` and `hawkeye remove` now write files atomically (write a sibling temp file, fsync, then rename over the target) instead of editing in place. Consequences:

* If a target file is a symlink, it is replaced by a regular file (the 6.x in-place write followed the link). The replacement carries the link target's permission bits.
* Writing now hinges on the parent directory rather than the file. Creating the temp file and renaming it over the target need write permission on the directory, not on the file, so a read-only source file can now be rewritten (the rename replaces the directory entry), while a writable file in a read-only directory now fails the run instead of being rewritten.

File permissions are preserved on a best-effort basis; ownership and ACLs are not.

`hawkeye` now processes a file matched by a `.gitignore` rule when that file is tracked in the Git index (for example, force-added with `git add -f`); 6.x skipped every gitignore-matched file (the #209 fix - a force-added file should still get a header). On upgrade such a file is checked for the first time and can newly appear in `missing` or `foreign`, failing a previously-green run. List the newly-processed set with `git ls-files | git check-ignore --stdin --no-index` and add any you do not want checked to `excludes`.

## [6.5.1] 2026-02-14

### Bug fixes
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ members = ["cli", "fmt"]
resolver = "2"

[workspace.package]
version = "6.5.1"
version = "7.0.0"
edition = "2021"
authors = ["tison <wander4096@gmail.com>"]
readme = "README.md"
Expand All @@ -26,7 +26,7 @@ repository = "https://github.com/korandoru/hawkeye/"
rust-version = "1.90.0"

[workspace.dependencies]
hawkeye-fmt = { version = "=6.5.1", path = "fmt" }
hawkeye-fmt = { version = "=7.0.0", path = "fmt" }

build-data = { version = "0.3.0" }
clap = { version = "4.5.23", features = ["derive"] }
Expand Down
51 changes: 38 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ To check license headers in GitHub Actions, add a step in your GitHub workflow:

```yaml
- name: Check License Header
uses: korandoru/hawkeye@v6
uses: korandoru/hawkeye@v7
```

### Docker
Expand Down Expand Up @@ -77,7 +77,7 @@ cargo install hawkeye
Instead of `cargo install`, you can install `hawkeye` as a prebuilt binary by:

```shell
export VERSION=v6.0.0
export VERSION=v7.0.0
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/korandoru/hawkeye/releases/download/$VERSION/hawkeye-installer.sh | sh
```

Expand All @@ -101,6 +101,12 @@ docker build . -t hawkeye

### Config file

> [!IMPORTANT]
> HawkEye 7.0 replaced the `[mapping.STYLE]` blocks and `useDefaultMapping` with a
> per-language `[[headers]]` rule list (see `headers` below). Replace each
> `[mapping.FOO] { extensions = [...] }` with a `[[headers]]` rule carrying
> `styles = ["FOO"]`, and rename `useDefaultMapping` to `useDefaultHeaders`.

```toml
# Base directory for the whole execution.
# All relative paths is based on this path.
Expand Down Expand Up @@ -145,25 +151,42 @@ excludes = ["..."]
# default: ["copyright"]
keywords = ["copyright", "..."]

# Whether you use the default mapping. Check DocumentType.defaultMapping() for the completed list.
# Whether to use the built-in language-to-style rules. Check DocumentType defaults for the completed list.
# default: true
useDefaultMapping = true
useDefaultHeaders = true

# Paths to additional header style files. The model of user-defined header style can be found below.
# default: empty
additionalHeaders = ["..."]

# Mapping rules (repeated).
# Header rules (repeated). Each rule binds file patterns to one or more comment styles and
# decides what to do when a file already has a header.
#
# The key of a mapping rule is a header style type (case-insensitive).
# A style name references a built-in HeaderType or one defined in `additionalHeaders`
# (case-insensitive). styles[0] is the preferred style that `format` writes; any further
# styles are also recognized for removal, so a header already written in one of them is
# migrated to the preferred style instead of being duplicated.
#
# Available header style types consist of those defined at `HeaderType` and user-defined ones in `additionalHeaders`.
# The name of header style type is case-insensitive.
# User rules take precedence over the built-in defaults (matched first; exact filename
# before extension).
[[headers]]
extensions = ["..."] # e.g. "cc"
filenames = ["..."] # e.g. "Dockerfile.native"
styles = ["STYLE_NAME"] # preferred first

# What to do when a file already has a header that is not an exact match for the preferred
# style (an existing header is detected by a style-agnostic scan for `keywords`). This applies
# to `format` only; `check` and `remove` ignore it.
# "replace" - remove the existing header (any listed style) and write the preferred one (default)
# "skip" - leave the file untouched and report it
# "error" - fail the run
#
# If useDefaultMapping is true, the mapping rules defined here can override the default one.
[mapping.STYLE_NAME]
filenames = ["..."] # e.g. "Dockerfile.native"
extensions = ["..."] # e.g. "cc"
# Because detection is a keyword scan, a stray occurrence of a keyword (default "copyright") near
# the top of an otherwise unheadered file looks like an existing header: "skip" then silently
# leaves it without a header, and "error" fails the run. The reverse also holds: a real notice
# that contains none of the `keywords` is not detected, so "replace" adds a second header on top
# of it. Tune `keywords` if either case bites.
existingStrategy = "replace"

# Properties to fulfill the template.
# For a defined key-value pair, you can use {{props["key"]}} in the header template, which will be
Expand All @@ -177,7 +200,9 @@ inceptionYear = 2023

# Options to configure Git features.
[git]
# If enabled, do not process files that are ignored by Git; possible value: ['auto', 'enable', 'disable']
# If enabled, do not process files that are ignored by Git AND untracked; a file matched by a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it? Untracked files that do not ignored should be processed always.

# .gitignore rule but tracked in the index (e.g. force-added with `git add -f`) is still processed.
# possible value: ['auto', 'enable', 'disable']
# 'auto' means this feature tries to be enabled with:
# * gix - if `basedir` is in a Git repository.
# * ignore crate's gitignore rules - if `basedir` is not in a Git repository.
Expand Down
Loading
Loading