Sync Exclusions Feature - Implementation Plan
This document provides a step-by-step implementation guide for adding sync exclusion support to `template-sync.sh`. The feature allows users to configure glob patterns in the manifest that prevent specific paths from being synced.
Overview
Sync Exclusions Feature - Implementation Plan
Overview
This document provides a step-by-step implementation guide for adding sync exclusion support to template-sync.sh. The feature allows users to configure glob patterns in the manifest that prevent specific paths from being synced.
Design document: docs/template-sync/sync-exclusions/design.md
Prerequisites
Before implementing, the developer should be familiar with:
- The existing
template-sync.shscript structure (seedocs/template-sync/implementation.md) - How
compare_files()works (lines 682-766 in.github/scripts/template-sync.sh) - How
generate_diff_report()andgenerate_markdown_summary()work (lines 786-956) - The test patterns in
test/test-template-sync.shandtest/helpers.sh - The manifest schema at
docs/template-sync/template-state-schema.json
File Inventory
Files to Modify
| File | Changes |
|---|---|
.github/scripts/template-sync.sh | Add SYNC_EXCLUSIONS and EXCLUDED_FILES globals, is_excluded() function, load exclusions in read_manifest(), validate in validate_manifest(), filter in compare_files(), report in generate_diff_report() and generate_markdown_summary() |
docs/template-sync/template-state-schema.json | Add sync_exclusions property definition |
test/test-template-sync.sh | Add tests for is_excluded(), exclusion loading, compare_files() with exclusions, and report output |
test/fixtures/manifests/valid-manifest.json | Optionally add sync_exclusions field (or create a new fixture) |
README.md | Add documentation for configuring sync exclusions |
CLAUDE.md | Mention sync exclusions in the Template Sync section |
docs/template-sync/design.md | Update the Limitations section (item 1: "All-or-nothing updates" is now partially addressed) |
Files to Create
| File | Purpose |
|---|---|
test/fixtures/manifests/valid-manifest-with-exclusions.json | Test fixture with sync_exclusions populated |
Implementation Tasks
Task 1: Add Global Variables
Goal: Declare the new global arrays for exclusion tracking.
File: .github/scripts/template-sync.sh
Location: Global Configuration section (after line 73, where UNCHANGED_FILES=() is declared)
Changes:
- Add
EXCLUDED_FILES=()array for tracking excluded file paths - Add
SYNC_EXCLUSIONS=()array for storing patterns loaded from the manifest
Context: The existing globals follow this pattern (lines 69-73):
ADDED_FILES=()
MODIFIED_FILES=()
DELETED_FILES=()
UNCHANGED_FILES=()
Task 2: Add is_excluded() Helper Function
Goal: Implement the core pattern matching function.
File: .github/scripts/template-sync.sh
Location: Helper Functions section (after format_languages_yaml(), around line 148)
Implementation notes:
- Function takes a single argument: the project-relative file path (e.g.,
.claude/commands/cove/cove.md) - Iterates over the global
SYNC_EXCLUSIONSarray - Uses a bash
casestatement for glob matching (idiomatic,*matches slashes incase) - Returns 0 (true/excluded) on match, 1 (false/not excluded) on no match
- If
SYNC_EXCLUSIONSis empty, the loop body never executes and the function returns 1 immediately
Important: The glob pattern must be unquoted in the case clause for bash to perform glob expansion. E.g., $pattern not "$pattern".
Task 3: Load Exclusions in read_manifest()
Goal: Parse sync_exclusions from the manifest JSON into the SYNC_EXCLUSIONS bash array.
File: .github/scripts/template-sync.sh
Location: At the end of read_manifest(), after line 241 (log_info "Manifest loaded: $MANIFEST_PATH")
Implementation notes:
- Use
jq -e '.sync_exclusions'to check if the field exists - If it exists, use
mapfile -t SYNC_EXCLUSIONS < <(jq -r '.sync_exclusions[]' "$MANIFEST_PATH")to safely load the array - If it doesn't exist,
SYNC_EXCLUSIONSremains empty (the default from globals) mapfile(akareadarray) is the safest way to convert newline-delimited output to a bash array - it handles spaces in patterns correctly- Log the count of loaded exclusions (e.g.,
log_info "Loaded N sync exclusion pattern(s)") only when N > 0
Task 4: Validate Exclusions in validate_manifest()
Goal: Validate the sync_exclusions field structure when present.
File: .github/scripts/template-sync.sh
Location: At the end of validate_manifest(), before line 298 (log_success "Manifest validation passed")
Implementation notes:
- Only validate if the field exists (
jq -e '.sync_exclusions') - Check that it's an array:
jq -e '.sync_exclusions | type == "array"' - Check that all elements are strings:
jq -e '.sync_exclusions | all(type == "string")' - If validation fails,
log_errorwith a descriptive message andexit 1 - If the field is absent, skip validation entirely (it's optional)
Task 5: Integrate Exclusions into compare_files()
Goal: Filter excluded files during the file comparison walk.
File: .github/scripts/template-sync.sh
Location: Two injection points within compare_files() (lines 682-766)
Injection Point 1 - Staging file walk (after line 722, before line 724):
The current code at line 722 constructs display_path:
local display_path="$project_dir/$relative_path"
Insert the exclusion check immediately after this line, before the if [[ ! -f "$project_file" ]] block on line 724. If is_excluded "$display_path" returns true, append $display_path to EXCLUDED_FILES and continue to skip this file entirely.
Injection Point 2 - Project file walk (after line 755, before line 757):
The current code at line 755 constructs display_path:
local display_path="$project_dir/$relative_path"
Insert the exclusion check immediately after this line, before the if [[ ! -f "$staging_file" ]] check on line 757. If is_excluded "$display_path" returns true, continue (don't add to EXCLUDED_FILES here to avoid double-counting since the staging walk already recorded it; but if the file only exists locally and not in staging, this path won't be reached from the staging walk, so we should still add to EXCLUDED_FILES).
Important edge case: A file might exist in the project but NOT in staging (deletion candidate). If it matches an exclusion pattern, it should be added to EXCLUDED_FILES and skipped. However, a file that exists in BOTH staging and project will already have been recorded in EXCLUDED_FILES during the staging walk, so we should avoid double-counting. The simplest approach: always add to EXCLUDED_FILES in the project walk too, and deduplicate later if needed - or accept that the count might include duplicates (since the staging walk and project walk cover different scenarios, duplicates only occur for files that exist in both places and are excluded, which is fine to report).
Simpler approach: Don't add to EXCLUDED_FILES in the project walk at all. The staging walk already covers files that exist upstream. The project walk only finds files that exist locally but not upstream - and if such a file matches an exclusion pattern, it was intentionally excluded from upstream, so we don't need to report it (it's not an upstream file being excluded, it's a local-only file).
Recommended approach: Only add to EXCLUDED_FILES in the staging walk (Injection Point 1). In the project walk (Injection Point 2), just continue without recording. This avoids duplicates and keeps the report focused on "upstream files that were excluded from sync."
Update the summary log on line 765: Add excluded count to the log message.
Task 6: Update generate_diff_report()
Goal: Show excluded files in the human-readable report and CI output.
File: .github/scripts/template-sync.sh
Location: Within generate_diff_report() (lines 786-908)
Changes:
-
CI mode output (lines 793-819): Add
excluded_count=${#EXCLUDED_FILES[@]}to both theGITHUB_OUTPUTblock and the stdout block. Do NOT include excluded count intotal_changesorhas_changes. -
Summary section (lines 841-846): Add an "Excluded" line after "Unchanged":
Excluded: NUse a distinct color (e.g.,
CYANor the default/no color) to differentiate from the change categories. -
Excluded files list (after the deleted files section, before the closing separator on line 900): Add a new section that lists excluded files, similar to the added/modified/deleted sections. Use a distinct marker character.
Task 7: Update generate_markdown_summary()
Goal: Include excluded files in the PR body markdown.
File: .github/scripts/template-sync.sh
Location: Within generate_markdown_summary() (lines 911-956)
Changes:
-
Summary table (lines 923-927): Add a row for excluded files:
| Excluded | N | -
Excluded files section (after the deleted files section): Add a new markdown section listing excluded files, similar to the existing Added/Modified/Deleted sections. Include a note explaining that these files were skipped due to
sync_exclusionsin the manifest.
Task 8: Update Schema Documentation
Goal: Document the new sync_exclusions field in the JSON schema.
File: docs/template-sync/template-state-schema.json
Changes:
- Add
sync_exclusionsto thepropertiesobject (NOT torequired) - Type:
arrayofstringitems - Description: explain it's for glob patterns of project-relative paths to exclude from sync
- Default:
[](empty array) - Add examples showing common patterns
Note: Since the field is optional and not in required, the additionalProperties: false constraint means we MUST add it to properties or existing manifests with the field would fail schema validation.
Task 9: Create Test Fixture
Goal: Create a manifest fixture with exclusions for testing.
File: test/fixtures/manifests/valid-manifest-with-exclusions.json
Content: Copy of valid-manifest.json with an added sync_exclusions array containing 2-3 patterns (e.g., .claude/commands/cove/*, .claude/skills/cove/*).
Task 10: Write Tests
Goal: Comprehensive test coverage for the exclusion feature.
File: test/test-template-sync.sh
Location: Add a new section (e.g., "Section 10: Sync Exclusions") before print_summary.
Tests to add:
-
is_excludedreturns false when no exclusions are configured- Set
SYNC_EXCLUSIONS=(), callis_excluded ".claude/settings.json", assert exit code 1
- Set
-
is_excludedmatches exact path- Set
SYNC_EXCLUSIONS=(".claude/commands/cove/cove.md"), call with matching path, assert exit code 0
- Set
-
is_excludedmatches glob pattern with wildcard- Set
SYNC_EXCLUSIONS=(".claude/commands/cove/*"), call with.claude/commands/cove/cove.md, assert exit code 0
- Set
-
is_excludeddoes not match non-matching path- Set
SYNC_EXCLUSIONS=(".claude/commands/cove/*"), call with.claude/commands/tm/list.md, assert exit code 1
- Set
-
is_excludedmatches across directory separators- Set
SYNC_EXCLUSIONS=(".claude/commands/cove/*"), call with.claude/commands/cove/subdir/file.md, assert exit code 0
- Set
-
read_manifestloads exclusions from manifest- Use the
valid-manifest-with-exclusions.jsonfixture - Call
read_manifest, assertSYNC_EXCLUSIONSarray is populated with correct values
- Use the
-
read_manifesthandles missing exclusions field gracefully- Use existing
valid-manifest.jsonfixture (nosync_exclusions) - Call
read_manifest, assertSYNC_EXCLUSIONSis empty
- Use existing
-
validate_manifestaccepts valid exclusions array- Use fixture with valid
sync_exclusions - Call
validate_manifest, assert no error (exit code 0)
- Use fixture with valid
-
validate_manifestaccepts manifest without exclusions- Use existing
valid-manifest.json - Call
validate_manifest, assert no error
- Use existing
-
compare_filesexcludes matching files from ADDED- Set up staging with files matching an exclusion pattern
- Set up project without those files (would normally be ADDED)
- Run
compare_files, assert matching files are inEXCLUDED_FILESnotADDED_FILES
-
compare_filesexcludes matching files from MODIFIED- Set up staging and project with same file but different content, matching exclusion
- Run
compare_files, assert file is inEXCLUDED_FILESnotMODIFIED_FILES
-
compare_filesskips excluded files in deletion detection- Set up project with files matching exclusion, staging without them
- Run
compare_files, assert files are NOT inDELETED_FILES
-
generate_diff_reportshows excluded count- Set
EXCLUDED_FILESwith entries, callgenerate_diff_report - Assert output contains "Excluded"
- Set
-
generate_diff_reportCI mode includes excluded_count- Set CI_MODE=true, set
EXCLUDED_FILES, callgenerate_diff_report - Assert output contains
excluded_count=N
- Set CI_MODE=true, set
Test patterns to follow: Match the existing test style in test/test-template-sync.sh:
- Use
log_testfor test case headers - Use
reset_globalsbefore each test (update this function to also resetEXCLUDED_FILESandSYNC_EXCLUSIONS) - Use
create_temp_dirfor temp directories - Use
assert_equals,assert_output_containsfor assertions - Use
pushd/popdto set working directory forcompare_filestests
Important: The reset_globals helper (if it exists) needs to be updated to also reset EXCLUDED_FILES=() and SYNC_EXCLUSIONS=().
Task 11: Update User-Facing Documentation
Goal: Document the sync exclusions feature for users.
Files to update:
-
README.md(around line 224-236, the "What's Preserved" section):- Add a bullet point about sync exclusions
- Add a new subsection "Configuring Sync Exclusions" after "What's Preserved" that shows:
- How to add exclusions to the manifest
- Example patterns
- Where excluded files appear in the report
-
CLAUDE.md(Template Sync section):- Mention
sync_exclusionsin the manifest description - Add a brief note about configuring exclusions
- Mention
-
docs/template-sync/design.md:- Update the "Limitations" section (line 271): change item 1 from "All-or-nothing updates" to note that sync exclusions now allow selective file exclusion
- Update the "File Classification" section to mention the user-excluded category
- Add a reference to the sync-exclusions design doc
Implementation Order
Recommended sequence:
- Task 1: Add globals (foundation - 2 lines)
- Task 2: Add
is_excluded()function (core logic) - Task 3: Load exclusions in
read_manifest()(wiring) - Task 4: Validate in
validate_manifest()(safety) - Task 5: Integrat