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
2 changes: 1 addition & 1 deletion .copywrite.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ schema_version = 1

project {
license = "MPL-2.0"
copyright_year = 2022
copyright_year = 2023

# (OPTIONAL) A list of globs that should not have copyright/license headers.
# Supports doublestar glob patterns for more flexibility in defining which
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ Flags:
Use "copywrite [command] --help" for more information about a command.
```

### Automatic Copyright Holder Migration

The `copywrite headers` command automatically detects and updates old copyright
holders (such as "HashiCorp, Inc.") to the configured holder (default: "IBM Corp.")
while preserving existing year information and updating year ranges.

This ensures that:

- Old headers from merged PRs are automatically corrected
- Manually copied headers are updated
- Year ranges are kept current

No additional flags are needed - the migration happens automatically as part of
the normal headers command execution.

To get started with Copywrite on a new project, run `copywrite init`, which will
interactively help generate a `.copywrite.hcl` config file to add to Git.

Expand Down
157 changes: 155 additions & 2 deletions addlicense/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,37 @@ func processFile(f *file, t *template.Template, license LicenseData, checkonly b
logger.Printf("%s\n", f.path)
return errors.New("missing license header")
}
// Also check if existing files would need copyright holder updates
wouldUpdate, err := wouldUpdateLicenseHolder(f.path, license)
if err != nil {
logger.Printf("%s: %v", f.path, err)
return err
}
if wouldUpdate {
logger.Printf("%s (would update copyright holder)\n", f.path)
return errors.New("copyright holder would be updated")
}
} else {
// First, try to add a license if missing
modified, err := addLicense(f.path, f.mode, t, license)
if err != nil {
logger.Printf("%s: %v", f.path, err)
return err
}
if verbose && modified {
logger.Printf("%s modified", f.path)

// If file wasn't modified (already had a license), try to update the holder
if !modified {
updated, err := updateLicenseHolder(f.path, f.mode, license)
if err != nil {
logger.Printf("%s: %v", f.path, err)
return err
}
Comment on lines +276 to +280
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this is only getting triggered in this branch/PR but when I run copywrite headers against a repository with a directory in it - perhaps a directory name of a specific pattern - it fails here with the following error:

Error: read releases/testdata/mock_api_tf_0_14_with_prereleases/terraform/0.14.11: is a directory

https://github.com/hashicorp/hc-install

It puzzles me why does a directory even get this far past

if fi.IsDir() {
return nil
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this:
Solution: Added isDirectory() helper using os.Stat() (follows symlinks) in both migration functions. Now directories are skipped gracefully instead of crashing.

Commit: 0a3f20d - please test with the updated branch.

if updated {

logger.Printf("%s (copyright holder updated)", f.path)
}
} else {
logger.Printf("%s (license header added)", f.path)
}
}
return nil
Expand Down Expand Up @@ -340,6 +363,136 @@ func addLicense(path string, fmode os.FileMode, tmpl *template.Template, data Li
return true, os.WriteFile(path, b, fmode)
}

// isDirectory checks if the given path points to a directory (including through symlinks)
func isDirectory(path string) (bool, error) {
fi, err := os.Stat(path)
if err != nil {
return false, err
}
return fi.IsDir(), nil
}

// updateLicenseHolder checks if a file contains old copyright holders
// (like "HashiCorp, Inc.") and updates them to the new holder while
// preserving years and other header information.
// Returns true if the file was updated.
func updateLicenseHolder(path string, fmode os.FileMode, newData LicenseData) (bool, error) {
// Skip directories and symlinks to directories
isDir, err := isDirectory(path)
if err != nil {
return false, err
}
if isDir {
return false, nil
}

b, err := os.ReadFile(path)
if err != nil {
return false, err
}

// Define old holder patterns to detect and replace
oldHolders := []string{
"HashiCorp, Inc.",
"HashiCorp Inc\\.?", // Match "HashiCorp Inc" with optional period
"HashiCorp",
}

updated := b
changed := false

for _, oldHolder := range oldHolders {
// Build regex to match various copyright formats:
// - "Copyright (c) HashiCorp, Inc. 2023"
// - "Copyright 2023 HashiCorp, Inc."
// - "Copyright HashiCorp, Inc. 2023, 2025"
// - "Copyright (c) 2023 HashiCorp, Inc."
// - "<!-- Copyright (c) HashiCorp, Inc. 2023 -->"
pattern := regexp.MustCompile(
`(?im)^(\s*(?://|#|/\*+|\*|<!--)\s*)` + // Comment prefix (group 1)
`(Copyright\s*(?:\(c\)\s*)?)` + // "Copyright" with optional (c) (group 2)
`(?:(\d{4}(?:,\s*\d{4})?)\s+)?` + // Optional years before holder (group 3)
`(` + oldHolder + `)` + // Old holder name (group 4) - now not using QuoteMeta for regex patterns
`(?:\s+(\d{4}(?:,\s*\d{4})?))?` + // Optional years after holder (group 5)
`(\s*(?:-->)?\s*)$`, // Trailing whitespace and optional HTML comment close (group 6)
)

// Replace with new format: "Copyright IBM Corp. YYYY, YYYY"
updated = pattern.ReplaceAllFunc(updated, func(match []byte) []byte {
// Extract the comment prefix from the match
submatch := pattern.FindSubmatch(match)
if submatch == nil {
return match
}

commentPrefix := string(submatch[1])
trailingSpace := string(submatch[6])

// Build new copyright line
newLine := commentPrefix + "Copyright"
if newData.Holder != "" {
newLine += " " + newData.Holder
}
if newData.Year != "" {
newLine += " " + newData.Year
}
newLine += trailingSpace

changed = true
return []byte(newLine)
})
}

if !changed {
return false, nil
}

return true, os.WriteFile(path, updated, fmode)
}

// wouldUpdateLicenseHolder checks if a file would need copyright holder updates
// without actually modifying the file. Used for plan/dry-run mode.
func wouldUpdateLicenseHolder(path string, newData LicenseData) (bool, error) {
// Skip directories and symlinks to directories
isDir, err := isDirectory(path)
if err != nil {
return false, err
}
if isDir {
return false, nil
}

b, err := os.ReadFile(path)
if err != nil {
return false, err
}

// Define old holder patterns to detect
oldHolders := []string{
"HashiCorp, Inc.",
"HashiCorp Inc\\.?", // Match "HashiCorp Inc" with optional period
"HashiCorp",
}

for _, oldHolder := range oldHolders {
// Build regex to match various copyright formats
pattern := regexp.MustCompile(
`(?im)^(\s*(?://|#|/\*+|\*|<!--)\s*)` + // Comment prefix (group 1)
`(Copyright\s*(?:\(c\)\s*)?)` + // "Copyright" with optional (c) (group 2)
`(?:(\d{4}(?:,\s*\d{4})?)\s+)?` + // Optional years before holder (group 3)
`(` + oldHolder + `)` + // Old holder name (group 4)
`(?:\s+(\d{4}(?:,\s*\d{4})?))?` + // Optional years after holder (group 5)
`(\s*(?:-->)?\s*)$`, // Trailing whitespace and optional HTML comment close (group 6)
)

if pattern.Match(b) {
return true, nil
}
}

return false, nil
}

// fileHasLicense reports whether the file at path contains a license header.
func fileHasLicense(path string) (bool, error) {
b, err := os.ReadFile(path)
Expand Down
Loading
Loading