Skip to content

Commit 376a04c

Browse files
CreatorHeadclaude
andauthored
Feat/ignore year1 header behavior (#213)
* Add ignore_year2 behavior with header creation/update edge-case handling * Fix year-format API regression and remove ignore_year2 fixture dependency * Remove ignore_year2 and add ignore_year1 behavioral tests - Remove all ignore_year2/IgnoreYear2 references from config, tests, init template, and README - Add explicit tests documenting that ignore_year1 suppresses start-year updates on existing headers but does not affect new-file header creation (addlicense always uses config year) - Fix stale extra argument in TestCalculateYearUpdates left over from ignore_year2 removal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Document ignore_year1 effect on LICENSE file headers in README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Remove redundant FormatCopyrightYears in favour of FormatCopyrightYearsForNewHeaders Both were identical wrappers; FormatCopyrightYears had no production callers. Consolidate to the single function used in production and update tests. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 898e9c9 commit 376a04c

7 files changed

Lines changed: 179 additions & 41 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ This repo provides utilities for managing copyright headers and license files
44
across many repos at scale.
55

66
Features:
7+
78
- Add or validate copyright headers on source code files
89
- Add and/or manage LICENSE files with git-aware copyright year detection
910
- Report on licenses used across multiple repositories
@@ -87,6 +88,7 @@ copywrite license --spdx "MPL-2.0"
8788
```
8889

8990
**Copyright Year Behavior:**
91+
9092
- **Start Year**: Auto-detected from config file and if not found defaults to repository's first commit
9193
- **End Year**: Set to current year when an update is triggered (git history only determines if update is needed)
9294
- **Update Trigger**: Git detects if source code file was modified since the copyright end year
@@ -105,15 +107,19 @@ to validate if a repo is in compliance or not.
105107
### Copyright Year Logic
106108

107109
**Source File Headers:**
110+
108111
- End year: Set to current year when file's source code is modified
109112
- Git history determines if update is needed (compares file's last commit year to copyright end year)
110113
- When triggered, end year updates to current year
114+
- If project.ignore_year1 is true, start-year updates are skipped
111115

112116
**LICENSE Files:**
117+
113118
- End year: Set to current year when any project file is modified
114119
- Git history determines if update is needed (compares repo's last commit year to copyright end year)
115120
- When triggered, end year updates to current year
116121
- Preserves historical accuracy for archived projects (no forced updates)
122+
- If project.ignore_year1 is true, start-year updates are skipped (same behaviour as source file headers)
117123

118124
**Key Distinction:** Git history is used as a trigger to determine *whether* an update is needed, but the actual end year value is always set to the current year when an update occurs.
119125

@@ -151,6 +157,11 @@ project {
151157
# Default: 0 (auto-detect)
152158
# copyright_year = 0
153159
160+
# (OPTIONAL) Ignore updates to the first year (start year) in copyright ranges.
161+
# This does not change how end year is resolved.
162+
# Default: false
163+
# ignore_year1 = false
164+
154165
# (OPTIONAL) A list of globs that should not have copyright or license headers .
155166
# Supports doublestar glob patterns for more flexibility in defining which
156167
# files or folders should be ignored
@@ -196,7 +207,6 @@ Note: Using fetch-depth parameter is mandatory as the tool will not be able to e
196207
**Impact of not updating year information:**
197208
If year information is not updated time to time, then the repo can be out of compliance. IBM policy suggests keeping source code files updated with latest year of code changes in a source code file.
198209

199-
200210
```yaml
201211
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
202212
with:

cmd/headers.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ config, see the "copywrite init" command.`,
116116

117117
// STEP 2: Construct the configuration addLicense needs to properly format headers
118118
licenseData := addlicense.LicenseData{
119-
Year: conf.FormatCopyrightYears(), // Format year(s) for copyright statements
119+
Year: conf.FormatCopyrightYearsForNewHeaders(), // New headers should use full year format
120120
Holder: conf.Project.CopyrightHolder,
121121
SPDXID: conf.Project.License,
122122
}
@@ -189,6 +189,7 @@ func updateExistingHeaders(cmd *cobra.Command, ignoredPatterns []string, dryRun
189189
}
190190

191191
configYear := conf.Project.CopyrightYear
192+
ignoreYear1 := conf.Project.IgnoreYear1
192193
repoFirstYear, _ := licensecheck.GetRepoFirstCommitYear(".")
193194

194195
// Open git repository once for all file operations
@@ -232,14 +233,14 @@ func updateExistingHeaders(cmd *cobra.Command, ignoredPatterns []string, dryRun
232233
}
233234

234235
if !dryRun {
235-
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(path, targetHolder, configYear, false, repoFirstYear, repoRoot)
236+
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(path, targetHolder, configYear, false, ignoreYear1, repoFirstYear, repoRoot)
236237
if err == nil && updated {
237238
cmd.Printf(" %s\n", path)
238239
atomic.AddInt64(&updatedCount64, 1)
239240
atomic.StoreInt32(&anyFileUpdatedFlag, 1)
240241
}
241242
} else {
242-
needsUpdate, err := licensecheck.NeedsUpdateWithCache(path, targetHolder, configYear, false, repoFirstYear, repoRoot)
243+
needsUpdate, err := licensecheck.NeedsUpdateWithCache(path, targetHolder, configYear, false, ignoreYear1, repoFirstYear, repoRoot)
243244
if err == nil && needsUpdate {
244245
cmd.Printf(" %s\n", path)
245246
atomic.AddInt64(&updatedCount64, 1)
@@ -294,18 +295,19 @@ func updateLicenseFile(cmd *cobra.Command, licensePath string, anyFileUpdated bo
294295

295296
repoFirstYear, _ := licensecheck.GetRepoFirstCommitYear(".")
296297
configYear := conf.Project.CopyrightYear
298+
ignoreYear1 := conf.Project.IgnoreYear1
297299

298300
// Open git repository for LICENSE file operations
299301
repoRoot, _ := licensecheck.GetRepoRoot(".")
300302

301303
// Update LICENSE file, forcing current year if any file was updated
302304
if !dryRun {
303-
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(licensePath, targetHolder, configYear, anyFileUpdated, repoFirstYear, repoRoot)
305+
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(licensePath, targetHolder, configYear, anyFileUpdated, ignoreYear1, repoFirstYear, repoRoot)
304306
if err == nil && updated {
305307
cmd.Printf("\nUpdated LICENSE file: %s\n", licensePath)
306308
}
307309
} else {
308-
needsUpdate, err := licensecheck.NeedsUpdateWithCache(licensePath, targetHolder, configYear, anyFileUpdated, repoFirstYear, repoRoot)
310+
needsUpdate, err := licensecheck.NeedsUpdateWithCache(licensePath, targetHolder, configYear, anyFileUpdated, ignoreYear1, repoFirstYear, repoRoot)
309311
if err == nil && needsUpdate {
310312
cmd.Printf("\n[DRY RUN] Would update LICENSE file: %s\n", licensePath)
311313
}

cmd/init.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ project {
135135
license = "{{.Project.License}}"
136136
copyright_year = {{.Project.CopyrightYear}}
137137
138+
# (OPTIONAL) If true, ignore updating the first year (start year) in copyright ranges.
139+
# End-year logic remains unchanged.
140+
# Default: false
141+
# ignore_year1 = false
142+
138143
# (OPTIONAL) A list of globs that should not have copyright/license headers.
139144
# Supports doublestar glob patterns for more flexibility in defining which
140145
# files or folders should be ignored

config/config.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ var (
3131
type Project struct {
3232
CopyrightYear int `koanf:"copyright_year"`
3333
CopyrightHolder string `koanf:"copyright_holder"`
34+
IgnoreYear1 bool `koanf:"ignore_year1"`
3435
HeaderIgnore []string `koanf:"header_ignore"`
3536
License string `koanf:"license"`
3637

@@ -277,11 +278,7 @@ func (c *Config) detectFirstCommitYear() int {
277278
return year
278279
}
279280

280-
// FormatCopyrightYears returns a formatted year string for copyright statements.
281-
// If copyrightYear is 0, attempts to auto-detect from git history.
282-
// If copyrightYear equals current year, returns current year only.
283-
// Otherwise returns "copyrightYear, currentYear" format (e.g., "2023, 2025").
284-
func (c *Config) FormatCopyrightYears() string {
281+
func (c *Config) formatCopyrightYears() string {
285282
currentYear := time.Now().Year()
286283
copyrightYear := c.Project.CopyrightYear
287284

@@ -303,3 +300,8 @@ func (c *Config) FormatCopyrightYears() string {
303300
// Return year range: "startYear, currentYear"
304301
return fmt.Sprintf("%d, %d", copyrightYear, currentYear)
305302
}
303+
304+
// FormatCopyrightYearsForNewHeaders returns a formatted year string for adding missing headers.
305+
func (c *Config) FormatCopyrightYearsForNewHeaders() string {
306+
return c.formatCopyrightYears()
307+
}

config/config_test.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func Test_LoadConfMap(t *testing.T) {
3939
mp := map[string]interface{}{
4040
"schema_version": 12,
4141
"project.copyright_year": 9001,
42+
"project.ignore_year1": true,
4243
"project.license": "MPL-2.0",
4344
"dispatch.ignored_repos": []string{"foo", "bar"},
4445
}
@@ -54,6 +55,7 @@ func Test_LoadConfMap(t *testing.T) {
5455
Project: Project{
5556
CopyrightHolder: "IBM Corp.",
5657
CopyrightYear: 9001,
58+
IgnoreYear1: true,
5759
License: "MPL-2.0",
5860
},
5961
Dispatch: Dispatch{
@@ -374,7 +376,7 @@ func Test_GetConfigPath(t *testing.T) {
374376
assert.Equal(t, abs, actualOutput.GetConfigPath(), "Loaded config should return abs file path")
375377
}
376378

377-
func Test_FormatCopyrightYears(t *testing.T) {
379+
func Test_FormatCopyrightYearsForNewHeaders(t *testing.T) {
378380
currentYear := time.Now().Year()
379381

380382
tests := []struct {
@@ -404,14 +406,15 @@ func Test_FormatCopyrightYears(t *testing.T) {
404406
c := MustNew()
405407
c.Project.CopyrightYear = tt.copyrightYear
406408

407-
actualOutput := c.FormatCopyrightYears()
409+
actualOutput := c.FormatCopyrightYearsForNewHeaders()
408410

409411
assert.Equal(t, tt.expectedOutput, actualOutput, tt.description)
410412
})
411413
}
414+
412415
}
413416

414-
func Test_FormatCopyrightYears_AutoDetect(t *testing.T) {
417+
func Test_FormatCopyrightYearsForNewHeaders_AutoDetect(t *testing.T) {
415418
currentYear := time.Now().Year()
416419

417420
t.Run("Auto-detect from git when copyright_year not set", func(t *testing.T) {
@@ -421,7 +424,7 @@ func Test_FormatCopyrightYears_AutoDetect(t *testing.T) {
421424
// Set config path to this repo's directory for git detection
422425
c.absCfgPath = filepath.Join(getCurrentDir(t), ".copywrite.hcl")
423426

424-
actualOutput := c.FormatCopyrightYears()
427+
actualOutput := c.FormatCopyrightYearsForNewHeaders()
425428

426429
// Should auto-detect and return a year range (this repo was created before 2025)
427430
// The format should be "YYYY, currentYear" where YYYY < currentYear
@@ -445,14 +448,30 @@ func Test_FormatCopyrightYears_AutoDetect(t *testing.T) {
445448
// Set config path to non-existent directory (git will fail)
446449
c.absCfgPath = "/nonexistent/path/.copywrite.hcl"
447450

448-
actualOutput := c.FormatCopyrightYears()
451+
actualOutput := c.FormatCopyrightYearsForNewHeaders()
449452

450453
// Should fallback to current year only
451454
assert.Equal(t, strconv.Itoa(currentYear), actualOutput,
452455
"Should fallback to current year when git detection fails")
453456
})
454457
}
455458

459+
// Test_FormatCopyrightYearsForNewHeaders verifies that ignore_year1 does NOT suppress
460+
// the config year when creating brand-new copyright headers. New files always receive
461+
// the full "configYear, currentYear" string from the .hcl copyright_year setting.
462+
func Test_FormatCopyrightYearsForNewHeaders_IgnoreYear1DoesNotAffectNewHeaders(t *testing.T) {
463+
currentYear := time.Now().Year()
464+
465+
c := MustNew()
466+
c.Project.CopyrightYear = 2015
467+
c.Project.IgnoreYear1 = true
468+
469+
actualOutput := c.FormatCopyrightYearsForNewHeaders()
470+
471+
assert.Equal(t, fmt.Sprintf("2015, %d", currentYear), actualOutput,
472+
"ignore_year1 must not affect new-header year format; config year should always be used")
473+
}
474+
456475
// Helper function to get current directory
457476
func getCurrentDir(t *testing.T) string {
458477
dir, err := os.Getwd()

licensecheck/update.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,15 @@ func calculateYearUpdates(
369369
lastCommitYear int,
370370
currentYear int,
371371
forceCurrentYear bool,
372+
ignoreYear1 bool,
372373
) (bool, int, int) {
373374
shouldUpdate := false
374375
newStartYear := info.StartYear
375376
newEndYear := info.EndYear
377+
hasNoYears := info.StartYear == 0 && info.EndYear == 0
376378

377379
// Condition 1: Update start year if canonical year differs from file's start year
378-
if canonicalStartYear > 0 && info.StartYear != canonicalStartYear {
380+
if (!ignoreYear1 || hasNoYears) && canonicalStartYear > 0 && info.StartYear != canonicalStartYear {
379381
newStartYear = canonicalStartYear
380382
shouldUpdate = true
381383
}
@@ -394,6 +396,23 @@ func calculateYearUpdates(
394396
shouldUpdate = true
395397
}
396398

399+
// If the header has no years at all, ensure we write a complete year format.
400+
if hasNoYears {
401+
if newStartYear == 0 {
402+
if canonicalStartYear > 0 {
403+
newStartYear = canonicalStartYear
404+
} else {
405+
newStartYear = currentYear
406+
}
407+
shouldUpdate = true
408+
}
409+
410+
if newEndYear == 0 {
411+
newEndYear = currentYear
412+
shouldUpdate = true
413+
}
414+
}
415+
397416
return shouldUpdate, newStartYear, newEndYear
398417
}
399418

@@ -544,6 +563,7 @@ func evaluateCopyrightUpdates(
544563
lastCommitYear int,
545564
currentYear int,
546565
forceCurrentYear bool,
566+
ignoreYear1 bool,
547567
repoFirstYear int,
548568
) []*struct {
549569
info *CopyrightInfo
@@ -570,7 +590,7 @@ func evaluateCopyrightUpdates(
570590
}
571591

572592
shouldUpdate, newStartYear, newEndYear := calculateYearUpdates(
573-
info, canonicalStartYear, lastCommitYear, currentYear, forceCurrentYear,
593+
info, canonicalStartYear, lastCommitYear, currentYear, forceCurrentYear, ignoreYear1,
574594
)
575595

576596
if shouldUpdate {
@@ -592,17 +612,17 @@ func evaluateCopyrightUpdates(
592612
// UpdateCopyrightHeader updates all copyright headers in a file if needed
593613
// If forceCurrentYear is true, forces end year to current year regardless of git history
594614
// Returns true if the file was modified
595-
func UpdateCopyrightHeader(filePath string, targetHolder string, configYear int, forceCurrentYear bool) (bool, error) {
615+
func UpdateCopyrightHeader(filePath string, targetHolder string, configYear int, forceCurrentYear bool, ignoreYear1 bool) (bool, error) {
596616
repoRoot, _ := GetRepoRoot(filepath.Dir(filePath))
597617
repoFirstYear, _ := GetRepoFirstCommitYear(filepath.Dir(filePath))
598-
return UpdateCopyrightHeaderWithCache(filePath, targetHolder, configYear, forceCurrentYear, repoFirstYear, repoRoot)
618+
return UpdateCopyrightHeaderWithCache(filePath, targetHolder, configYear, forceCurrentYear, ignoreYear1, repoFirstYear, repoRoot)
599619
}
600620

601621
// UpdateCopyrightHeaderWithCache updates all copyright headers in a file if needed
602622
// If forceCurrentYear is true, forces end year to current year regardless of git history
603623
// repoFirstYear and repoRoot can be provided to avoid repeated git lookups when processing multiple files
604624
// Returns true if the file was modified
605-
func UpdateCopyrightHeaderWithCache(filePath string, targetHolder string, configYear int, forceCurrentYear bool, repoFirstYear int, repoRoot string) (bool, error) {
625+
func UpdateCopyrightHeaderWithCache(filePath string, targetHolder string, configYear int, forceCurrentYear bool, ignoreYear1 bool, repoFirstYear int, repoRoot string) (bool, error) {
606626
// Skip .copywrite.hcl config file
607627
if filepath.Base(filePath) == ".copywrite.hcl" {
608628
return false, nil
@@ -641,7 +661,7 @@ func UpdateCopyrightHeaderWithCache(filePath string, targetHolder string, config
641661

642662
// Evaluate which copyrights need updating
643663
updates := evaluateCopyrightUpdates(
644-
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, repoFirstYear,
664+
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, ignoreYear1, repoFirstYear,
645665
)
646666

647667
if len(updates) == 0 {
@@ -695,17 +715,17 @@ func UpdateCopyrightHeaderWithCache(filePath string, targetHolder string, config
695715
// NeedsUpdate checks if a file would be updated without actually modifying it
696716
// If forceCurrentYear is true, forces end year to current year regardless of git history
697717
// Returns true if the file has copyrights matching targetHolder that need year updates
698-
func NeedsUpdate(filePath string, targetHolder string, configYear int, forceCurrentYear bool) (bool, error) {
718+
func NeedsUpdate(filePath string, targetHolder string, configYear int, forceCurrentYear bool, ignoreYear1 bool) (bool, error) {
699719
repoRoot, _ := GetRepoRoot(filepath.Dir(filePath))
700720
repoFirstYear, _ := GetRepoFirstCommitYear(filepath.Dir(filePath))
701-
return NeedsUpdateWithCache(filePath, targetHolder, configYear, forceCurrentYear, repoFirstYear, repoRoot)
721+
return NeedsUpdateWithCache(filePath, targetHolder, configYear, forceCurrentYear, ignoreYear1, repoFirstYear, repoRoot)
702722
}
703723

704724
// NeedsUpdateWithCache checks if a file would be updated without actually modifying it
705725
// If forceCurrentYear is true, forces end year to current year regardless of git history
706726
// repoFirstYear and repoRoot can be provided to avoid repeated git lookups when processing multiple files
707727
// Returns true if the file has copyrights matching targetHolder that need year updates
708-
func NeedsUpdateWithCache(filePath string, targetHolder string, configYear int, forceCurrentYear bool, repoFirstCommitYear int, repoRoot string) (bool, error) {
728+
func NeedsUpdateWithCache(filePath string, targetHolder string, configYear int, forceCurrentYear bool, ignoreYear1 bool, repoFirstCommitYear int, repoRoot string) (bool, error) {
709729
// Skip .copywrite.hcl config file
710730
if filepath.Base(filePath) == ".copywrite.hcl" {
711731
return false, nil
@@ -740,7 +760,7 @@ func NeedsUpdateWithCache(filePath string, targetHolder string, configYear int,
740760

741761
// Evaluate which copyrights need updating
742762
updates := evaluateCopyrightUpdates(
743-
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, repoFirstCommitYear,
763+
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, ignoreYear1, repoFirstCommitYear,
744764
)
745765

746766
return len(updates) > 0, nil

0 commit comments

Comments
 (0)