Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cb05c02
docs(issues): add project dictionary formatter specification
josecelano Jul 22, 2026
319ef20
feat(git): format project dictionary before commits
josecelano Jul 22, 2026
aa2f856
fix(git): address dictionary formatter review feedback
josecelano Jul 22, 2026
2cc5583
docs(review): track PR #2020 Copilot suggestions
josecelano Jul 22, 2026
4c8becd
fix(git): report dictionary temp file failures
josecelano Jul 22, 2026
14ef83c
docs(issues): align #2019 spec layout
josecelano Jul 22, 2026
e83ae97
docs(issues): remove legacy #2019 spec
josecelano Jul 22, 2026
895117e
fix(git): retain pre-commit step exit codes
josecelano Jul 22, 2026
641c06f
fix(git): support portable dictionary formatting
josecelano Jul 22, 2026
03aa7d5
docs(review): complete PR #2020 suggestions audit
josecelano Jul 22, 2026
b2db6a5
fix(git): keep formatter tests portable
josecelano Jul 22, 2026
8c8b097
docs(review): record PR #2020 follow-up responses
josecelano Jul 22, 2026
577c333
fix(git): retain hook infrastructure exit codes
josecelano Jul 22, 2026
441f1bd
docs(review): finalize PR #2020 suggestions audit
josecelano Jul 22, 2026
55def23
docs(issues): link #2019 to PR #2020
josecelano Jul 22, 2026
458be87
docs(review): record final PR #2020 responses
josecelano Jul 22, 2026
e5b2f95
docs(review): correct tracker thread ID
josecelano Jul 22, 2026
72108e8
docs(review): record PR #2020 thread resolution
josecelano Jul 22, 2026
35cc02a
chore(cspell): canonicalize project dictionary ordering
josecelano Jul 22, 2026
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
6 changes: 4 additions & 2 deletions .github/skills/dev/git-workflow/commit-changes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,10 @@ Verify these by hand before committing:
`docs/` pages reflect the change
- **`AGENTS.md` updated**: if architecture, package structure, or key workflows changed, the
relevant `AGENTS.md` file is updated
- **New technical terms added to `project-words.txt`**: any new jargon or identifiers that
cspell does not know about are added alphabetically
- **New technical terms added to `project-words.txt`**: run
`./contrib/dev-tools/git/format-project-words.sh` after adding jargon or identifiers that cspell
does not know. The pre-commit hook does this automatically, aborting for deliberate restaging if
it changes the dictionary.

### Debugging a Failing Run

Expand Down
5 changes: 3 additions & 2 deletions .github/skills/dev/git-workflow/run-linters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ taplo fmt **/*.toml # Auto-fix TOML formatting

### Spell Check Errors (cspell)

For legitimate technical terms not in dictionaries, add them to `project-words.txt`
(alphabetical order, one per line).
For legitimate technical terms not in dictionaries, add them to `project-words.txt` (one per line)
and run `./contrib/dev-tools/git/format-project-words.sh`. The pre-commit hook runs the formatter
automatically and requests restaging if it changes the dictionary.

### Shell Script Errors (shellcheck)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ Key formatting settings:
**Dictionary**: `project-words.txt`
**Run**: `linter cspell`

Add technical terms to `project-words.txt` (alphabetical order, one per line).
Add technical terms to `project-words.txt` (one per line), then run
`./contrib/dev-tools/git/format-project-words.sh`. The formatter uses `LC_ALL=C sort -u`;
the pre-commit hook runs it automatically and requests restaging if it changes the dictionary.

## Configuration Linters

Expand Down
22 changes: 18 additions & 4 deletions .github/skills/dev/git-workflow/run-pre-commit-checks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,22 @@ TORRUST_GIT_HOOKS_LOG_DIR=.tmp ./contrib/dev-tools/git/hooks/pre-commit.sh

The script runs these steps in order:

1. `cargo machete` - unused dependency check
2. `linter all` - all linters (markdown, YAML, TOML, clippy, rustfmt, shellcheck, cspell)
3. `cargo test --doc --workspace` - documentation tests
1. `./contrib/dev-tools/git/format-project-words.sh` - formats `project-words.txt` with
`LC_ALL=C sort -u`
2. `cargo machete --with-metadata` - unused dependency check
3. `cargo deny check bans` - workspace layer-boundary dependency check
4. `linter all` - all linters (markdown, YAML, TOML, clippy, rustfmt, shellcheck, cspell)
5. `cargo test --doc --workspace` - documentation tests

If the formatter changes the dictionary, the hook exits non-zero before the verification steps.
Stage `project-words.txt` and retry the commit. Run the formatter independently with:

```bash
./contrib/dev-tools/git/format-project-words.sh
```

This is an interim action related to EPIC #2003 and may be replaced or refactored after its
automation design decision.

## Output Modes

Expand Down Expand Up @@ -117,7 +130,8 @@ Verify these by hand before committing:
- **Self-review the diff**: read through `git diff --staged` for debug artifacts or unintended changes
- **Documentation updated**: if public API or behaviour changed, doc comments and `docs/` pages reflect it
- **`AGENTS.md` updated**: if architecture or key workflows changed, the relevant `AGENTS.md` is updated
- **New technical terms in `project-words.txt`**: new jargon added alphabetically
- **New technical terms in `project-words.txt`**: run the formatter after adding new jargon; the
hook will also format it automatically and request restaging when needed
- **Branch name validation**: if the branch uses an issue-number prefix (e.g. `42-some-description`),
verify that `docs/issues/open/` contains a matching spec file or directory. This prevents committing
under a non-existent, closed, or wrong issue number.
Expand Down
44 changes: 44 additions & 0 deletions contrib/dev-tools/git/format-project-words.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Format the repository cspell dictionary with deterministic ordering and exact de-duplication.

set -uo pipefail

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
PROJECT_ROOT=$(cd -- "${SCRIPT_DIR}/../../.." && pwd)
DICTIONARY_PATH="${PROJECT_ROOT}/project-words.txt"

if [[ ! -f "${DICTIONARY_PATH}" ]]; then
printf 'Error: project dictionary not found: %s\n' "${DICTIONARY_PATH}" >&2
exit 2
fi

if ! temporary_dictionary=$(mktemp "${DICTIONARY_PATH}.XXXXXX"); then
printf 'Error: failed to create a temporary project dictionary: %s\n' "${DICTIONARY_PATH}" >&2
exit 2
fi

trap 'rm -f "${temporary_dictionary}"' EXIT

if ! cp -p "${DICTIONARY_PATH}" "${temporary_dictionary}"; then
printf 'Error: failed to preserve project dictionary metadata: %s\n' "${DICTIONARY_PATH}" >&2
exit 2
fi

if ! LC_ALL=C sort -u "${DICTIONARY_PATH}" >"${temporary_dictionary}"; then
printf 'Error: failed to format project dictionary: %s\n' "${DICTIONARY_PATH}" >&2
exit 2
fi

if cmp -s "${DICTIONARY_PATH}" "${temporary_dictionary}"; then
printf 'project-words.txt is already formatted.\n'
exit 0
fi

if ! mv "${temporary_dictionary}" "${DICTIONARY_PATH}"; then
printf 'Error: failed to update project dictionary: %s\n' "${DICTIONARY_PATH}" >&2
exit 2
fi

printf 'Formatted project-words.txt with LC_ALL=C sort -u.\n'
printf "Stage 'project-words.txt' and retry the commit.\n"
exit 1
16 changes: 14 additions & 2 deletions contrib/dev-tools/git/hooks/pre-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# AI agents: set a per-command timeout of at least 3 minutes before invoking this script.
#
# All steps must pass (exit 0) before committing.
# The formatter is an intentionally small interim action while EPIC #2003 determines
# the repository's long-term automation architecture. It exits 1 after rewriting the
# dictionary so this hook aborts and the contributor can deliberately stage the change.
#
# TODO: Implement branch-name validation in the Rust git-hooks binary (#1843).
# When the branch uses an issue-number prefix (e.g. "42-some-description"), verify that
Expand All @@ -24,6 +27,7 @@ set -uo pipefail
# Each step: "description|command"

declare -a STEPS=(
"Formatting project dictionary|./contrib/dev-tools/git/format-project-words.sh"
"Checking for unused dependencies (cargo machete --with-metadata)|cargo machete --with-metadata"
"Checking workspace layer boundary bans (cargo deny check bans)|cargo deny check bans"
"Running all linters|linter all"
Expand Down Expand Up @@ -329,6 +333,7 @@ TOTAL_STEPS=${#STEPS[@]}
overall_status="pass"
exit_code=0
failed_step_name=""
failed_step_exit_code=0

if [[ "${FORMAT}" == "text" ]]; then
echo "Running pre-commit checks..."
Expand All @@ -337,10 +342,14 @@ fi

for i in "${!STEPS[@]}"; do
IFS='|' read -r description command <<< "${STEPS[$i]}"
if ! run_step $((i + 1)) "${TOTAL_STEPS}" "${description}" "${command}"; then
if run_step $((i + 1)) "${TOTAL_STEPS}" "${description}" "${command}"; then
step_exit_code=0
else
step_exit_code=$?
overall_status="fail"
exit_code=1
exit_code=${step_exit_code}
failed_step_name="${description}"
failed_step_exit_code=${step_exit_code}
break
fi
done
Expand All @@ -364,6 +373,9 @@ fi
echo
echo "=========================================="
echo "FAILED: Pre-commit checks failed!"
if [[ "${failed_step_name}" == "Formatting project dictionary" && "${failed_step_exit_code}" -eq 1 ]]; then
echo "The formatter changed project-words.txt. Stage 'project-words.txt' and retry the commit."
fi
echo "Fix the errors above before committing."
echo "=========================================="
exit 1
206 changes: 206 additions & 0 deletions contrib/dev-tools/git/tests/test-format-project-words.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Integration tests for the project dictionary formatter and pre-commit orchestration.

set -euo pipefail

PROJECT_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd)
TEST_DIRECTORY=$(mktemp -d "${TMPDIR:-/tmp}/test-format-project-words.XXXXXX")
trap 'rm -rf "${TEST_DIRECTORY}"' EXIT

create_fixture() {
local fixture_name=$1
local fixture_root="${TEST_DIRECTORY}/${fixture_name}"

mkdir -p "${fixture_root}/contrib/dev-tools/git/hooks" "${fixture_root}/bin" "${fixture_root}/logs"
cp "${PROJECT_ROOT}/contrib/dev-tools/git/format-project-words.sh" "${fixture_root}/contrib/dev-tools/git/"
cp "${PROJECT_ROOT}/contrib/dev-tools/git/hooks/pre-commit.sh" "${fixture_root}/contrib/dev-tools/git/hooks/"
chmod +x \
"${fixture_root}/contrib/dev-tools/git/format-project-words.sh" \
"${fixture_root}/contrib/dev-tools/git/hooks/pre-commit.sh"

printf '%s\n' "${fixture_root}"
}

create_successful_command_stubs() {
local fixture_root=$1

cat >"${fixture_root}/bin/cargo" <<'EOF'
#!/usr/bin/env bash
printf 'cargo %s\n' "$*" >>"${TEST_COMMAND_LOG}"
EOF
cat >"${fixture_root}/bin/linter" <<'EOF'
#!/usr/bin/env bash
printf 'linter %s\n' "$*" >>"${TEST_COMMAND_LOG}"
EOF
chmod +x "${fixture_root}/bin/cargo" "${fixture_root}/bin/linter"
}

it_should_sort_and_remove_exact_duplicates_when_dictionary_requires_formatting() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "formatter-changed")
printf 'zebra\nAlpha\nalpha\nAlpha\n' >"${fixture_root}/project-words.txt"

# Act
if "${fixture_root}/contrib/dev-tools/git/format-project-words.sh" >"${fixture_root}/formatter-output.txt" 2>&1; then
printf 'Expected formatter to report a changed dictionary.\n' >&2
return 1
fi

# Assert
diff -u "${fixture_root}/project-words.txt" <(printf 'Alpha\nalpha\nzebra\n')
grep -F -q 'Formatted project-words.txt with LC_ALL=C sort -u.' "${fixture_root}/formatter-output.txt"
}

it_should_report_success_when_dictionary_is_already_formatted() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "formatter-unchanged")
printf 'Alpha\nalpha\nzebra\n' >"${fixture_root}/project-words.txt"

# Act
"${fixture_root}/contrib/dev-tools/git/format-project-words.sh" >"${fixture_root}/formatter-output.txt"

# Assert
grep -F -q 'project-words.txt is already formatted.' "${fixture_root}/formatter-output.txt"
}

it_should_report_a_temp_file_creation_failure() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "formatter-mktemp-failure")
printf 'Alpha\nzebra\n' >"${fixture_root}/project-words.txt"
cat >"${fixture_root}/bin/mktemp" <<'EOF'
#!/usr/bin/env bash
exit 1
EOF
chmod +x "${fixture_root}/bin/mktemp"

# Act
if PATH="${fixture_root}/bin:${PATH}" "${fixture_root}/contrib/dev-tools/git/format-project-words.sh" >"${fixture_root}/formatter-output.txt" 2>&1; then
printf 'Expected formatter to fail when it cannot create its temporary dictionary.\n' >&2
return 1
fi

# Assert
grep -F -q 'Error: failed to create a temporary project dictionary:' "${fixture_root}/formatter-output.txt"
}

it_should_abort_pre_commit_and_request_restaging_when_dictionary_is_formatted() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "hook-changed")
printf 'zebra\nAlpha\nAlpha\n' >"${fixture_root}/project-words.txt"
create_successful_command_stubs "${fixture_root}"

# Act
if (
cd "${fixture_root}" || exit
PATH="${fixture_root}/bin:${PATH}" \
TEST_COMMAND_LOG="${fixture_root}/commands.log" \
TORRUST_GIT_HOOKS_LOG_DIR="${fixture_root}/logs" \
./contrib/dev-tools/git/hooks/pre-commit.sh >"${fixture_root}/hook-output.txt" 2>&1
); then
printf 'Expected pre-commit hook to abort after formatting the dictionary.\n' >&2
return 1
fi

# Assert
diff -u "${fixture_root}/project-words.txt" <(printf 'Alpha\nzebra\n')
grep -F -q "Stage 'project-words.txt' and retry the commit" "${fixture_root}/hook-output.txt"
[[ ! -e "${fixture_root}/commands.log" ]]
}

it_should_not_mislabel_log_creation_failures_as_dictionary_changes() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "hook-log-mktemp-failure")
printf 'Alpha\nzebra\n' >"${fixture_root}/project-words.txt"
create_successful_command_stubs "${fixture_root}"
cat >"${fixture_root}/bin/mktemp" <<'EOF'
#!/usr/bin/env bash
if [[ "$1" == *pre-commit-* ]]; then
exit 1
fi
exec /usr/bin/mktemp "$@"
EOF
chmod +x "${fixture_root}/bin/mktemp"

# Act
if (
cd "${fixture_root}" || exit
PATH="${fixture_root}/bin:${PATH}" \
TEST_COMMAND_LOG="${fixture_root}/commands.log" \
TORRUST_GIT_HOOKS_LOG_DIR="${fixture_root}/logs" \
./contrib/dev-tools/git/hooks/pre-commit.sh >"${fixture_root}/hook-output.txt" 2>&1
); then
printf 'Expected pre-commit hook to fail when it cannot create a step log.\n' >&2
return 1
fi

# Assert
grep -F -q "Error: failed to create a temporary log file in '${fixture_root}/logs'." "${fixture_root}/hook-output.txt"
! grep -F -q "The formatter changed project-words.txt." "${fixture_root}/hook-output.txt"
}

it_should_report_infrastructure_failures_with_their_exit_code_in_json() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "hook-log-mktemp-failure-json")
printf 'Alpha\nzebra\n' >"${fixture_root}/project-words.txt"
create_successful_command_stubs "${fixture_root}"
cat >"${fixture_root}/bin/mktemp" <<'EOF'
#!/usr/bin/env bash
if [[ "$1" == *pre-commit-* ]]; then
exit 2
fi
exec /usr/bin/mktemp "$@"
EOF
chmod +x "${fixture_root}/bin/mktemp"

# Act
if (
cd "${fixture_root}" || exit
PATH="${fixture_root}/bin:${PATH}" \
TEST_COMMAND_LOG="${fixture_root}/commands.log" \
TORRUST_GIT_HOOKS_LOG_DIR="${fixture_root}/logs" \
./contrib/dev-tools/git/hooks/pre-commit.sh --format=json >"${fixture_root}/hook-output.txt" 2>&1
); then
printf 'Expected pre-commit hook to fail when it cannot create a step log.\n' >&2
return 1
fi

# Assert
grep -F -q '"exit_code": 2' "${fixture_root}/hook-output.txt"
}

it_should_continue_pre_commit_checks_when_dictionary_is_already_formatted() {
# Arrange
local fixture_root
fixture_root=$(create_fixture "hook-unchanged")
printf 'Alpha\nzebra\n' >"${fixture_root}/project-words.txt"
create_successful_command_stubs "${fixture_root}"

# Act
(
cd "${fixture_root}" || exit
PATH="${fixture_root}/bin:${PATH}" \
TEST_COMMAND_LOG="${fixture_root}/commands.log" \
TORRUST_GIT_HOOKS_LOG_DIR="${fixture_root}/logs" \
./contrib/dev-tools/git/hooks/pre-commit.sh >"${fixture_root}/hook-output.txt"
)

# Assert
[[ $(wc -l <"${fixture_root}/commands.log") -eq 4 ]]
grep -F -q 'SUCCESS: All pre-commit checks passed!' "${fixture_root}/hook-output.txt"
}

it_should_sort_and_remove_exact_duplicates_when_dictionary_requires_formatting
it_should_report_success_when_dictionary_is_already_formatted
it_should_report_a_temp_file_creation_failure
it_should_abort_pre_commit_and_request_restaging_when_dictionary_is_formatted
it_should_not_mislabel_log_creation_failures_as_dictionary_changes
it_should_report_infrastructure_failures_with_their_exit_code_in_json
it_should_continue_pre_commit_checks_when_dictionary_is_already_formatted

printf 'All formatter and pre-commit hook tests passed.\n'
Loading
Loading