When a medium-sized Swift repository runs a full style check on a cloud Mac, the main bottleneck is often not the tools themselves. The real problem is that every pull request rescans unchanged source files, generated directories, and external dependencies. Worse, developers may use different tool versions locally and in CI, causing the same line of code to produce different results. The answer is not to disable rules, but to keep tool versions, configuration files, and diff calculation logic together in the repository.
Separate the Responsibilities of the Two Tools
SwiftFormat is best suited to deterministic formatting that can be fixed automatically, such as indentation, line wrapping, and redundant whitespace. SwiftLint is better for detecting risky patterns such as forced casts, overly long functions, and naming violations. If both tools enforce the same rule, developers may format their code only to have it rejected immediately afterward by static analysis.
Start by creating a simple rule ownership table:
| Check type | Responsible tool | Pull request behavior |
|---|---|---|
| Indentation, whitespace, and argument wrapping | SwiftFormat | Check only; do not rewrite automatically |
| Forced casts and forced unwraps | SwiftLint | Fail and report the file location |
| Generated code and external dependencies | Excluded from both | Do not include in the changed-file list |
| Legacy warnings | SwiftLint baseline | Block only newly introduced issues |
Do not run commands that rewrite source files directly in CI. The gate should only report differences. Developers should fix them locally and commit the result so that the repository state remains reproducible.
Pin Versions and Rules in the Repository
Do not rely on whichever tool versions happen to be installed on the cloud Mac. First select versions that the team has validated, then record the expected versions in the script. The following examples use SwiftFormat 0.54.3 and SwiftLint 0.55.1; replace them with the versions validated for your own project.
Keep .swiftformat concise:
--swiftversion 5.10
--indent 4
--wraparguments before-first
--exclude .build,DerivedData,Vendor,Generated
Use .swiftlint.yml to define the source scope and excluded directories explicitly:
included:
- Sources
- Tests
excluded:
- .build
- DerivedData
- Vendor
- Generated
only_rules:
- force_cast
- force_try
- trailing_whitespace
- unused_import
Rule files must go through code review alongside the source. Before adding a rule, run a full-repository scan on a separate branch to measure the number of warnings, then decide whether to fix them all at once or establish a baseline. Do not feed hundreds of legacy warnings directly into the pull request gate, or the team will eventually learn to ignore failures.
Extract the Swift Diff Correctly
Using git diff HEAD^ directly works only for branches containing a single commit. When a pull request contains multiple commits or has been rebased, that approach can miss files. A more reliable method is to calculate the common ancestor of the current commit and the target branch, then collect added, copied, modified, and renamed files.
set -euo pipefail
expected_format="0.54.3"
expected_lint="0.55.1"
[[ "$(swiftformat --version)" == "$expected_format" ]]
[[ "$(swiftlint version)" == "$expected_lint" ]]
base_ref="${LINT_BASE_REF:-origin/main}"
merge_base="$(git merge-base HEAD "$base_ref")"
changed=("${(@0)$(git diff --name-only -z --diff-filter=ACMR "$merge_base" HEAD)}")
swift_files=()
for file in "${changed[@]}"; do
[[ "$file" == *.swift ]] || continue
[[ "$file" == .build/* ]] && continue
[[ "$file" == DerivedData/* ]] && continue
[[ "$file" == Vendor/* ]] && continue
[[ "$file" == Generated/* ]] && continue
swift_files+=("$file")
done
(( ${#swift_files[@]} > 0 )) || exit 0
swiftformat --lint "${swift_files[@]}"
swiftlint lint --strict --force-exclude "${swift_files[@]}"
The script uses a null-delimited file list, so paths containing spaces are not split incorrectly. --diff-filter=ACMR excludes deleted files, preventing the checking tools from receiving paths that no longer exist. The target branch name is supplied through an environment variable, allowing the same script to run against both the default branch and release branches.
Handle Shallow Clones
If git merge-base cannot find a common ancestor, the script is usually not at fault. The CI checkout likely contains too little commit history. Increase the checkout depth and make sure the target branch reference exists before running the script. Do not fall back to HEAD^, because that would make the gate produce inconsistent results across different branch structures.
Make Failures Traceable and Reproducible
When the gate fails, the log should answer at least three questions: which tool versions were used, which base commit was used for comparison, and which files were checked. A version mismatch should stop the job immediately rather than allowing it to continue and generate a large number of formatting differences.
To reproduce the result locally, developers only need to fetch the target branch reference and run the same script:
git fetch origin main
LINT_BASE_REF=origin/main zsh Scripts/lint-changed-swift.zsh
A common mistake is to place a checking command in a pipeline and then read the wrong exit code. Another is to use || true to produce cleaner logs, inadvertently swallowing genuine failures. With set -euo pipefail enabled, any nonzero status returned by a tool terminates the job. If CI needs to post-process the logs, save the original exit code first, print the summary, and then exit with the saved value.
Control Long-Term Costs with a Two-Layer Policy
An incremental gate guarantees only that the current change introduces no new issues; it does not prove that all existing code complies with the current rules. Split the checks into two layers:
- Run diff-based checks for every pull request. They should be fast, stable, and fully reproducible locally.
- Run a scheduled full-repository check to detect baseline drift, obsolete exclusions, and unresolved legacy warnings.
- Submit rule upgrades separately instead of mixing them with product changes, making large formatting diffs easier to review.
- Generated directories must have a clearly defined source. Exclude them in the tool configuration and filter them out in the diff script so that task execution order does not affect the result.
Before rollout, verify once more that the target branch reference exists, the tool versions match exactly, renamed files are included, deleted files are excluded, paths containing spaces are handled safely, and the script exits successfully when there are no Swift changes. Once these checks are complete, the style gate becomes a reliable commit boundary rather than a script that fails unpredictably.
Frequently asked questions
Do incremental checks eliminate the need for full-repository linting?
No. Incremental checks provide fast pull request feedback, while a scheduled full scan detects rule drift and violations in untouched legacy files.
Should SwiftFormat and SwiftLint enforce the same rules?
No. Let SwiftFormat own deterministic formatting and use SwiftLint for risky patterns and team policies. Assign overlapping rules to one tool to prevent conflicting results.
Run your next development or build task on a cloud Mac mini
Choose from two M4 configurations, four rental periods, and five available locations. Actual availability is subject to the real-time status returned by the console.