Skip to content
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
This repo provides utilities for managing copyright headers and license files
across many repos at scale.

You can use it to add or validate copyright headers on source code files, add a
LICENSE file to a repo, report on what licenses repos are using, and more.
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
- Automate compliance checks in CI/CD pipelines

## Getting Started

Expand Down Expand Up @@ -33,7 +36,7 @@ Usage:
copywrite [command]

Common Commands:
headers Adds missing copyright headers to all source code files
headers Adds missing copyright headers and updates existing headers' year information.
init Generates a .copywrite.hcl config for a new project
license Validates that a LICENSE file is present and remediates any issues if found

Expand Down Expand Up @@ -62,8 +65,18 @@ scan all files in your repo and copyright headers to any that are missing:
copywrite headers --spdx "MPL-2.0"
```

You may omit the `--spdx` flag if you add a `.copywrite.hcl` config, as outlined
[here](#config-structure).
The `copywrite license` command validates and manages LICENSE files with git-aware copyright years:

```sh
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 file was modified since the copyright end year

You may omit the `--spdx` flag if you add a `.copywrite.hcl` config, as outlined [here](#config-structure).

### `--plan` Flag

Expand All @@ -72,6 +85,23 @@ performs a dry-run and will outline what changes would be made. This flag also
returns a non-zero exit code if any changes are needed. As such, it can be used
to validate if a repo is in compliance or not.

## Technical Details

### Copyright Year Logic

**Source File Headers:**
- End year: Set to current year when file 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

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

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

## Config Structure

> :bulb: You can automatically generate a new `.copywrite.hcl` config with the
Expand Down Expand Up @@ -99,8 +129,8 @@ project {

# (OPTIONAL) Represents the year that the project initially began
# This is used as the starting year in copyright statements
# If set and different from current year, headers will show: "copyright_year, current_year"
# If set and same as current year, headers will show: "current_year"
# If set and different from current year, headers will show: "copyright_year, year-2"
# If set and same as year-2, headers will show: "copyright_year"
# If not set (0), the tool will auto-detect from git history (first commit year)
# If auto-detection fails, it will fallback to current year only
# Default: 0 (auto-detect)
Expand Down
6 changes: 3 additions & 3 deletions addlicense/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func walk(ch chan<- *file, start string, logger *log.Logger) error {
if fi.IsDir() {
return nil
}
if fileMatches(path, ignorePatterns) {
if FileMatches(path, ignorePatterns) {
// The [DEBUG] level is inferred by go-hclog as a debug statement
logger.Printf("[DEBUG] skipping: %s", path)
return nil
Expand All @@ -290,9 +290,9 @@ func walk(ch chan<- *file, start string, logger *log.Logger) error {
})
}

// fileMatches determines if path matches one of the provided file patterns.
// FileMatches determines if path matches one of the provided file patterns.
// Patterns are assumed to be valid.
func fileMatches(path string, patterns []string) bool {
func FileMatches(path string, patterns []string) bool {
for _, p := range patterns {

if runtime.GOOS == "windows" {
Expand Down
2 changes: 1 addition & 1 deletion addlicense/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ func TestFileMatches(t *testing.T) {

for _, tt := range tests {
patterns := []string{tt.pattern}
if got := fileMatches(tt.path, patterns); got != tt.wantMatch {
if got := FileMatches(tt.path, patterns); got != tt.wantMatch {
t.Errorf("fileMatches(%q, %q) returned %v, want %v", tt.path, patterns, got, tt.wantMatch)
}
}
Expand Down
114 changes: 112 additions & 2 deletions cmd/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/hashicorp/copywrite/addlicense"
"github.com/hashicorp/copywrite/licensecheck"
"github.com/hashicorp/go-hclog"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/samber/lo"
Expand Down Expand Up @@ -87,10 +90,23 @@ config, see the "copywrite init" command.`,
".github/workflows/**",
".github/dependabot.yml",
"**/node_modules/**",
".copywrite.hcl",
}
ignoredPatterns := lo.Union(conf.Project.HeaderIgnore, autoSkippedPatterns)

// Construct the configuration addLicense needs to properly format headers
// STEP 1: Update existing copyright headers
gha.StartGroup("Updating existing copyright headers:")
updatedCount, anyFileUpdated, licensePath := updateExistingHeaders(cmd, ignoredPatterns, plan)
gha.EndGroup()
if updatedCount > 0 {
if plan {
cmd.Printf("\n%s\n\n", text.FgYellow.Sprintf("[DRY RUN] Would update %d file(s) with new copyright years", updatedCount))
} else {
cmd.Printf("\n%s\n\n", text.FgGreen.Sprintf("Successfully updated %d file(s) with new copyright years", updatedCount))
}
}

// STEP 2: Construct the configuration addLicense needs to properly format headers
licenseData := addlicense.LicenseData{
Year: conf.FormatCopyrightYears(), // Format year(s) for copyright statements
Holder: conf.Project.CopyrightHolder,
Expand All @@ -112,10 +128,21 @@ config, see the "copywrite init" command.`,
// cobra.CheckErr on the return, which will indeed output to stderr and
// return a non-zero error code.

gha.StartGroup("The following files are missing headers:")
// STEP 3: Add missing headers
gha.StartGroup("Adding missing copyright headers:")
err := addlicense.Run(ignoredPatterns, "only", licenseData, "", verbose, plan, []string{"."}, stdcliLogger)
gha.EndGroup()

// STEP 4: Update LICENSE file if any files were modified (either updated or added headers)
// In plan mode: if addlicense found missing headers (returns error), assume files would be modified
// In normal mode: if addlicense succeeded, assume files were modified
if err != nil || (!plan && err == nil) {
anyFileUpdated = true
}

updateLicenseFile(cmd, licensePath, anyFileUpdated, plan)

// Check for errors after LICENSE file update so we still show what would happen
cobra.CheckErr(err)
},
}
Expand All @@ -131,3 +158,86 @@ func init() {
headersCmd.Flags().StringP("spdx", "s", "", "SPDX-compliant license identifier (e.g., 'MPL-2.0')")
headersCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
}

// updateExistingHeaders walks through files and updates copyright headers based on config and git history
// Returns the count of updated files, a boolean indicating if any file was updated, and the LICENSE file path (if found)
func updateExistingHeaders(cmd *cobra.Command, ignoredPatterns []string, dryRun bool) (int, bool, string) {
targetHolder := conf.Project.CopyrightHolder
if targetHolder == "" {
targetHolder = "IBM Corp."
}

configYear := conf.Project.CopyrightYear
updatedCount := 0
anyFileUpdated := false
var licensePath string

// Walk through all files in current directory
_ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}

// Check if file should be ignored
if addlicense.FileMatches(path, ignoredPatterns) {
return nil
}

// Capture LICENSE file path but skip processing it here - it will be handled separately
base := filepath.Base(path)
if strings.EqualFold(base, "LICENSE") || strings.EqualFold(base, "LICENSE.TXT") || strings.EqualFold(base, "LICENSE.MD") {
licensePath = path
return nil
}

// Try to update copyright in this file
if !dryRun {
updated, err := licensecheck.UpdateCopyrightHeader(path, targetHolder, configYear, false)
if err == nil && updated {
cmd.Printf(" %s\n", path)
updatedCount++
anyFileUpdated = true
}
} else {
// In dry-run mode, check if update would happen
needsUpdate, err := licensecheck.NeedsUpdate(path, targetHolder, configYear, false)
if err == nil && needsUpdate {
cmd.Printf(" %s\n", path)
updatedCount++
anyFileUpdated = true
}
}

return nil
})

return updatedCount, anyFileUpdated, licensePath
}

// updateLicenseFile updates the LICENSE file with current year if any files were modified
func updateLicenseFile(cmd *cobra.Command, licensePath string, anyFileUpdated bool, dryRun bool) {
// If no LICENSE file was found during the walk, nothing to do
if licensePath == "" {
return
}

targetHolder := conf.Project.CopyrightHolder
if targetHolder == "" {
targetHolder = "IBM Corp."
}

configYear := conf.Project.CopyrightYear

// Update LICENSE file, forcing current year if any file was updated
if !dryRun {
updated, err := licensecheck.UpdateCopyrightHeader(licensePath, targetHolder, configYear, anyFileUpdated)
if err == nil && updated {
cmd.Printf("\nUpdated LICENSE file: %s\n", licensePath)
}
} else {
needsUpdate, err := licensecheck.NeedsUpdate(licensePath, targetHolder, configYear, anyFileUpdated)
if err == nil && needsUpdate {
cmd.Printf("\n[DRY RUN] Would update LICENSE file: %s\n", licensePath)
}
}
}
41 changes: 39 additions & 2 deletions cmd/license.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"path/filepath"
"strconv"
"time"

"github.com/hashicorp/copywrite/github"
"github.com/hashicorp/copywrite/licensecheck"
Expand Down Expand Up @@ -63,10 +65,14 @@ var licenseCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {

cmd.Printf("Licensing under the following terms: %s\n", conf.Project.License)
cmd.Printf("Using copyright years: %v\n", conf.FormatCopyrightYears())

// Determine appropriate copyright years for LICENSE file
licenseYears := determineLicenseCopyrightYears(dirPath)

cmd.Printf("Using copyright years: %v\n", licenseYears)
cmd.Printf("Using copyright holder: %v\n\n", conf.Project.CopyrightHolder)

copyright := "Copyright " + conf.FormatCopyrightYears() + " " + conf.Project.CopyrightHolder
copyright := "Copyright " + conf.Project.CopyrightHolder + " " + licenseYears

licenseFiles, err := licensecheck.FindLicenseFiles(dirPath)
if err != nil {
Expand Down Expand Up @@ -175,3 +181,34 @@ func init() {
licenseCmd.Flags().StringP("spdx", "s", "", "SPDX License Identifier indicating what the LICENSE file should represent")
licenseCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
}

// determineLicenseCopyrightYears determines the appropriate copyright year range for LICENSE file
// Uses git history to get the start year (first commit) and end year (last commit)
func determineLicenseCopyrightYears(dirPath string) string {
currentYear := time.Now().Year()
startYear := conf.Project.CopyrightYear

// If no start year configured, try to auto-detect from git
if startYear == 0 {
if detectedYear, err := licensecheck.GetRepoFirstCommitYear(dirPath); err == nil && detectedYear > 0 {
startYear = detectedYear
} else {
// Fallback to current year
return strconv.Itoa(currentYear)
}
}

// Determine end year from repository's last commit year
endYear := currentYear // Default fallback
if lastRepoCommitYear, err := licensecheck.GetRepoLastCommitYear(dirPath); err == nil && lastRepoCommitYear > 0 && lastRepoCommitYear <= currentYear {
endYear = lastRepoCommitYear
}

// If start year equals end year, return single year
if startYear == endYear {
return strconv.Itoa(endYear)
}

// Return year range: "startYear, endYear"
return fmt.Sprintf("%d, %d", startYear, endYear)
}
Loading
Loading