Skip to content
Merged
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This repo provides utilities for managing copyright headers and license files
across many repos at scale.

Features:

- Add or validate copyright headers on source code files
- Add and/or manage LICENSE files with git-aware copyright year detection
- Report on licenses used across multiple repositories
Expand Down Expand Up @@ -87,6 +88,7 @@ copywrite license --spdx "MPL-2.0"
```

**Copyright Year Behavior:**

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

**Source File Headers:**

- End year: Set to current year when file's source code is modified
- Git history determines if update is needed (compares file's last commit year to copyright end year)
- When triggered, end year updates to current year
- If project.ignore_year1 is true, start-year updates are skipped
Comment thread
ssagarverma marked this conversation as resolved.

**LICENSE Files:**

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

**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.

Expand Down Expand Up @@ -151,6 +157,11 @@ project {
# Default: 0 (auto-detect)
# copyright_year = 0

# (OPTIONAL) Ignore updates to the first year (start year) in copyright ranges.
# This does not change how end year is resolved.
# Default: false
# ignore_year1 = false

# (OPTIONAL) A list of globs that should not have copyright or license headers .
# Supports doublestar glob patterns for more flexibility in defining which
# files or folders should be ignored
Expand Down Expand Up @@ -196,7 +207,6 @@ Note: Using fetch-depth parameter is mandatory as the tool will not be able to e
**Impact of not updating year information:**
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.


```yaml
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
Expand Down
12 changes: 7 additions & 5 deletions cmd/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ config, see the "copywrite init" command.`,

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

configYear := conf.Project.CopyrightYear
ignoreYear1 := conf.Project.IgnoreYear1
repoFirstYear, _ := licensecheck.GetRepoFirstCommitYear(".")

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

if !dryRun {
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(path, targetHolder, configYear, false, repoFirstYear, repoRoot)
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(path, targetHolder, configYear, false, ignoreYear1, repoFirstYear, repoRoot)
if err == nil && updated {
cmd.Printf(" %s\n", path)
atomic.AddInt64(&updatedCount64, 1)
atomic.StoreInt32(&anyFileUpdatedFlag, 1)
}
} else {
needsUpdate, err := licensecheck.NeedsUpdateWithCache(path, targetHolder, configYear, false, repoFirstYear, repoRoot)
needsUpdate, err := licensecheck.NeedsUpdateWithCache(path, targetHolder, configYear, false, ignoreYear1, repoFirstYear, repoRoot)
if err == nil && needsUpdate {
cmd.Printf(" %s\n", path)
atomic.AddInt64(&updatedCount64, 1)
Expand Down Expand Up @@ -294,18 +295,19 @@ func updateLicenseFile(cmd *cobra.Command, licensePath string, anyFileUpdated bo

repoFirstYear, _ := licensecheck.GetRepoFirstCommitYear(".")
configYear := conf.Project.CopyrightYear
ignoreYear1 := conf.Project.IgnoreYear1

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

// Update LICENSE file, forcing current year if any file was updated
if !dryRun {
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(licensePath, targetHolder, configYear, anyFileUpdated, repoFirstYear, repoRoot)
updated, err := licensecheck.UpdateCopyrightHeaderWithCache(licensePath, targetHolder, configYear, anyFileUpdated, ignoreYear1, repoFirstYear, repoRoot)
if err == nil && updated {
cmd.Printf("\nUpdated LICENSE file: %s\n", licensePath)
}
} else {
needsUpdate, err := licensecheck.NeedsUpdateWithCache(licensePath, targetHolder, configYear, anyFileUpdated, repoFirstYear, repoRoot)
needsUpdate, err := licensecheck.NeedsUpdateWithCache(licensePath, targetHolder, configYear, anyFileUpdated, ignoreYear1, repoFirstYear, repoRoot)
if err == nil && needsUpdate {
cmd.Printf("\n[DRY RUN] Would update LICENSE file: %s\n", licensePath)
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ project {
license = "{{.Project.License}}"
copyright_year = {{.Project.CopyrightYear}}

# (OPTIONAL) If true, ignore updating the first year (start year) in copyright ranges.
# End-year logic remains unchanged.
# Default: false
# ignore_year1 = false

# (OPTIONAL) A list of globs that should not have copyright/license headers.
# Supports doublestar glob patterns for more flexibility in defining which
# files or folders should be ignored
Expand Down
12 changes: 7 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
type Project struct {
CopyrightYear int `koanf:"copyright_year"`
CopyrightHolder string `koanf:"copyright_holder"`
IgnoreYear1 bool `koanf:"ignore_year1"`
HeaderIgnore []string `koanf:"header_ignore"`
License string `koanf:"license"`

Expand Down Expand Up @@ -277,11 +278,7 @@ func (c *Config) detectFirstCommitYear() int {
return year
}

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

Expand All @@ -303,3 +300,8 @@ func (c *Config) FormatCopyrightYears() string {
// Return year range: "startYear, currentYear"
return fmt.Sprintf("%d, %d", copyrightYear, currentYear)
}

// FormatCopyrightYearsForNewHeaders returns a formatted year string for adding missing headers.
func (c *Config) FormatCopyrightYearsForNewHeaders() string {
return c.formatCopyrightYears()
}
29 changes: 24 additions & 5 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func Test_LoadConfMap(t *testing.T) {
mp := map[string]interface{}{
"schema_version": 12,
"project.copyright_year": 9001,
"project.ignore_year1": true,
"project.license": "MPL-2.0",
"dispatch.ignored_repos": []string{"foo", "bar"},
}
Expand All @@ -54,6 +55,7 @@ func Test_LoadConfMap(t *testing.T) {
Project: Project{
CopyrightHolder: "IBM Corp.",
CopyrightYear: 9001,
IgnoreYear1: true,
License: "MPL-2.0",
},
Dispatch: Dispatch{
Expand Down Expand Up @@ -374,7 +376,7 @@ func Test_GetConfigPath(t *testing.T) {
assert.Equal(t, abs, actualOutput.GetConfigPath(), "Loaded config should return abs file path")
}

func Test_FormatCopyrightYears(t *testing.T) {
func Test_FormatCopyrightYearsForNewHeaders(t *testing.T) {
currentYear := time.Now().Year()

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

actualOutput := c.FormatCopyrightYears()
actualOutput := c.FormatCopyrightYearsForNewHeaders()

assert.Equal(t, tt.expectedOutput, actualOutput, tt.description)
})
}

}

func Test_FormatCopyrightYears_AutoDetect(t *testing.T) {
func Test_FormatCopyrightYearsForNewHeaders_AutoDetect(t *testing.T) {
currentYear := time.Now().Year()

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

actualOutput := c.FormatCopyrightYears()
actualOutput := c.FormatCopyrightYearsForNewHeaders()

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

actualOutput := c.FormatCopyrightYears()
actualOutput := c.FormatCopyrightYearsForNewHeaders()

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

// Test_FormatCopyrightYearsForNewHeaders verifies that ignore_year1 does NOT suppress
// the config year when creating brand-new copyright headers. New files always receive
// the full "configYear, currentYear" string from the .hcl copyright_year setting.
func Test_FormatCopyrightYearsForNewHeaders_IgnoreYear1DoesNotAffectNewHeaders(t *testing.T) {
currentYear := time.Now().Year()

c := MustNew()
c.Project.CopyrightYear = 2015
c.Project.IgnoreYear1 = true

actualOutput := c.FormatCopyrightYearsForNewHeaders()

assert.Equal(t, fmt.Sprintf("2015, %d", currentYear), actualOutput,
"ignore_year1 must not affect new-header year format; config year should always be used")
}

// Helper function to get current directory
func getCurrentDir(t *testing.T) string {
dir, err := os.Getwd()
Expand Down
40 changes: 30 additions & 10 deletions licensecheck/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,13 +369,15 @@ func calculateYearUpdates(
lastCommitYear int,
currentYear int,
forceCurrentYear bool,
ignoreYear1 bool,
) (bool, int, int) {
shouldUpdate := false
newStartYear := info.StartYear
newEndYear := info.EndYear
hasNoYears := info.StartYear == 0 && info.EndYear == 0

// Condition 1: Update start year if canonical year differs from file's start year
if canonicalStartYear > 0 && info.StartYear != canonicalStartYear {
if (!ignoreYear1 || hasNoYears) && canonicalStartYear > 0 && info.StartYear != canonicalStartYear {
newStartYear = canonicalStartYear
shouldUpdate = true
}
Expand All @@ -394,6 +396,23 @@ func calculateYearUpdates(
shouldUpdate = true
}

// If the header has no years at all, ensure we write a complete year format.
if hasNoYears {
if newStartYear == 0 {
if canonicalStartYear > 0 {
newStartYear = canonicalStartYear
} else {
newStartYear = currentYear
}
shouldUpdate = true
}

if newEndYear == 0 {
newEndYear = currentYear
shouldUpdate = true
}
}

return shouldUpdate, newStartYear, newEndYear
}

Expand Down Expand Up @@ -544,6 +563,7 @@ func evaluateCopyrightUpdates(
lastCommitYear int,
currentYear int,
forceCurrentYear bool,
ignoreYear1 bool,
repoFirstYear int,
) []*struct {
info *CopyrightInfo
Expand All @@ -570,7 +590,7 @@ func evaluateCopyrightUpdates(
}

shouldUpdate, newStartYear, newEndYear := calculateYearUpdates(
info, canonicalStartYear, lastCommitYear, currentYear, forceCurrentYear,
info, canonicalStartYear, lastCommitYear, currentYear, forceCurrentYear, ignoreYear1,
)

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

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

// Evaluate which copyrights need updating
updates := evaluateCopyrightUpdates(
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, repoFirstYear,
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, ignoreYear1, repoFirstYear,
)

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

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

// Evaluate which copyrights need updating
updates := evaluateCopyrightUpdates(
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, repoFirstCommitYear,
copyrights, targetHolder, configYear, lastCommitYear, currentYear, forceCurrentYear, ignoreYear1, repoFirstCommitYear,
)

return len(updates) > 0, nil
Expand Down
Loading
Loading