Skip to content
671 changes: 671 additions & 0 deletions addlicense/main.go

Large diffs are not rendered by default.

735 changes: 735 additions & 0 deletions addlicense/main_test.go

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions addlicense/tmpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ const tmplMPL = `This Source Code Form is subject to the terms of the Mozilla Pu
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.`

const tmplSPDX = `Copyright (c){{ if .Year }} {{.Year}}{{ end }}{{ if .Holder }} {{.Holder}}{{ end }}
const tmplSPDX = `Copyright{{ if .Holder }} {{.Holder}}{{ end }}{{ if .Year }} {{.Year}}{{ end }}
{{ if .SPDXID }}SPDX-License-Identifier: {{.SPDXID}}{{ end }}`

const tmplCopyrightOnly = `Copyright (c){{ if .Year }} {{.Year}}{{ end }}{{ if .Holder }} {{.Holder}}{{ end }}`
const tmplCopyrightOnly = `Copyright{{ if .Holder }} {{.Holder}}{{ end }}{{ if .Year }} {{.Year}}{{ end }}`

const spdxSuffix = "\n\nSPDX-License-Identifier: {{.SPDXID}}"
62 changes: 62 additions & 0 deletions addlicense/tmpl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func init() {
template.Must(template.New("").Parse(tmplMIT))
template.Must(template.New("").Parse(tmplBSD))
template.Must(template.New("").Parse(tmplMPL))
template.Must(template.New("").Parse(tmplSPDX))
template.Must(template.New("").Parse(tmplCopyrightOnly))
}

func TestFetchTemplate(t *testing.T) {
Expand Down Expand Up @@ -124,6 +126,14 @@ func TestFetchTemplate(t *testing.T) {
tmplSPDX,
nil,
},
{
"copyright-only template for unknown license",
"unknown",
"",
spdxOff,
tmplCopyrightOnly,
nil,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -178,6 +188,58 @@ func TestExecuteTemplate(t *testing.T) {
"", "", "",
"A&Z\n\n",
},

// Test tmplSPDX template execution
{
tmplSPDX,
LicenseData{Holder: "HashiCorp, Inc.", Year: "2023", SPDXID: "MPL-2.0"},
"", "", "",
"Copyright HashiCorp, Inc. 2023\nSPDX-License-Identifier: MPL-2.0\n\n",
},
{
tmplSPDX,
LicenseData{Holder: "IBM Corp.", Year: "2020, 2023", SPDXID: "Apache-2.0"},
"", "", "",
"Copyright IBM Corp. 2020, 2023\nSPDX-License-Identifier: Apache-2.0\n\n",
},
{
tmplSPDX,
LicenseData{Year: "2023", SPDXID: "MIT"},
"", "", "",
"Copyright 2023\nSPDX-License-Identifier: MIT\n\n",
},

// Test tmplCopyrightOnly template execution
{
tmplCopyrightOnly,
LicenseData{Holder: "HashiCorp, Inc.", Year: "2023"},
"", "", "",
"Copyright HashiCorp, Inc. 2023\n\n",
},
{
tmplCopyrightOnly,
LicenseData{Holder: "IBM Corp.", Year: "2020, 2023"},
"", "", "",
"Copyright IBM Corp. 2020, 2023\n\n",
},
{
tmplCopyrightOnly,
LicenseData{Year: "2023"},
"", "", "",
"Copyright 2023\n\n",
},
{
tmplCopyrightOnly,
LicenseData{Holder: "Some Corp."},
"", "", "",
"Copyright Some Corp.\n\n",
},
{
tmplCopyrightOnly,
LicenseData{},
"", "", "",
"Copyright\n\n",
},
}

for _, tt := range tests {
Expand Down
21 changes: 19 additions & 2 deletions cmd/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ config, see the "copywrite init" command.`,
mapping := map[string]string{
`spdx`: `project.license`,
`copyright-holder`: `project.copyright_holder`,
`year1`: `project.copyright_year1`,
`year2`: `project.copyright_year2`,
}

// update the running config with any command-line flags
Expand Down Expand Up @@ -91,8 +93,21 @@ config, see the "copywrite init" command.`,
ignoredPatterns := lo.Union(conf.Project.HeaderIgnore, autoSkippedPatterns)

// Construct the configuration addLicense needs to properly format headers
yearRange := ""
if conf.Project.CopyrightYear1 > 0 && conf.Project.CopyrightYear2 > 0 {
if conf.Project.CopyrightYear1 == conf.Project.CopyrightYear2 {
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear1)
} else {
yearRange = fmt.Sprintf("%d, %d", conf.Project.CopyrightYear1, conf.Project.CopyrightYear2)
}
} else if conf.Project.CopyrightYear1 > 0 {
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear1)
} else if conf.Project.CopyrightYear2 > 0 {
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear2)
}

licenseData := addlicense.LicenseData{
Year: "", // by default, we don't include a year in copyright statements
Year: yearRange,
Holder: conf.Project.CopyrightHolder,
SPDXID: conf.Project.License,
}
Expand Down Expand Up @@ -129,5 +144,7 @@ func init() {

// These flags will get mapped to keys in the the global Config
headersCmd.Flags().StringP("spdx", "s", "", "SPDX-compliant license identifier (e.g., 'MPL-2.0')")
headersCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"HashiCorp, Inc.\")")
headersCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
headersCmd.Flags().IntP("year1", "", 0, "Start year for copyright range (e.g., 2020)")
headersCmd.Flags().IntP("year2", "", 0, "End year for copyright range (e.g., 2025)")
}
46 changes: 39 additions & 7 deletions cmd/license.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"path/filepath"
"strconv"

"github.com/hashicorp/copywrite/github"
"github.com/hashicorp/copywrite/licensecheck"
Expand Down Expand Up @@ -35,6 +34,8 @@ var licenseCmd = &cobra.Command{
mapping := map[string]string{
`spdx`: `project.license`,
`year`: `project.copyright_year`,
`year1`: `project.copyright_year1`,
`year2`: `project.copyright_year2`,
`copyright-holder`: `project.copyright_holder`,
}

Expand All @@ -44,10 +45,13 @@ var licenseCmd = &cobra.Command{
cobra.CheckErr(err)

// Input Validation
if conf.Project.CopyrightYear == 0 {
errYearNotFound := errors.New("unable to automatically determine copyright year: Please specify it manually in the config or via the --year flag")
// Check if we have year information from new year1/year2 flags or legacy year flag
hasYearInfo := conf.Project.CopyrightYear > 0 || conf.Project.CopyrightYear1 > 0 || conf.Project.CopyrightYear2 > 0

cliLogger.Info("Copyright year was not supplied via config or via the --year flag. Attempting to infer from the year the GitHub repo was created.")
if !hasYearInfo {
errYearNotFound := errors.New("unable to automatically determine copyright year: Please specify it manually in the config or via the --year, --year1, or --year2 flag")

cliLogger.Info("Copyright year was not supplied via config or via the --year/--year1/--year2 flags. Attempting to infer from the year the GitHub repo was created.")
repo, err := github.DiscoverRepo()
if err != nil {
cobra.CheckErr(fmt.Errorf("%v: %w", errYearNotFound, err))
Expand All @@ -64,10 +68,36 @@ 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 year of initial copyright: %v\n", conf.Project.CopyrightYear)

// Construct the year range similar to headers command
yearRange := ""
if conf.Project.CopyrightYear1 > 0 && conf.Project.CopyrightYear2 > 0 {
if conf.Project.CopyrightYear1 == conf.Project.CopyrightYear2 {
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear1)
} else {
yearRange = fmt.Sprintf("%d, %d", conf.Project.CopyrightYear1, conf.Project.CopyrightYear2)
}
} else if conf.Project.CopyrightYear1 > 0 {
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear1)
} else if conf.Project.CopyrightYear2 > 0 {
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear2)
} else if conf.Project.CopyrightYear > 0 {
// Fallback to legacy single year for backward compatibility
yearRange = fmt.Sprintf("%d", conf.Project.CopyrightYear)
}

if yearRange != "" {
cmd.Printf("Using year of initial copyright: %v\n", yearRange)
}
cmd.Printf("Using copyright holder: %v\n\n", conf.Project.CopyrightHolder)

copyright := "Copyright (c) " + strconv.Itoa(conf.Project.CopyrightYear) + " " + conf.Project.CopyrightHolder
// Use the same format as headers command: "Copyright [HOLDER] [YEAR_RANGE]"
var copyright string
if yearRange != "" {
copyright = "Copyright " + conf.Project.CopyrightHolder + " " + yearRange
} else {
copyright = "Copyright " + conf.Project.CopyrightHolder
}

licenseFiles, err := licensecheck.FindLicenseFiles(dirPath)
if err != nil {
Expand Down Expand Up @@ -173,6 +203,8 @@ func init() {
// These flags will get mapped to keys in the the global Config
// TODO: eventually, the copyrightYear should be dynamically inferred from the repo
licenseCmd.Flags().IntP("year", "y", 0, "Year that the copyright statement should include")
licenseCmd.Flags().IntP("year1", "", 0, "Start year for copyright range (e.g., 2020)")
licenseCmd.Flags().IntP("year2", "", 0, "End year for copyright range (e.g., 2025)")
licenseCmd.Flags().StringP("spdx", "s", "", "SPDX License Identifier indicating what the LICENSE file should represent")
licenseCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"HashiCorp, Inc.\")")
licenseCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
}
155 changes: 155 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright IBM Corp. 2017, 2025
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"fmt"
"os"

"github.com/hashicorp/copywrite/addlicense"
"github.com/hashicorp/go-hclog"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/samber/lo"
"github.com/spf13/cobra"
)

var updateCmd = &cobra.Command{
Use: "update",
Short: "Updates copyright headers in all source code files",
Long: `Recursively checks for all files in the given directory and subdirectories,
adding copyright statements and license headers to any that are missing them,
and replacing existing "Copyright (c) HashiCorp, Inc." headers with IBM headers.

Autogenerated files and common file types that don't support headers (e.g., prose)
will automatically be exempted. Any other files or folders should be added to the
header_ignore list in your project's .copywrite.hcl config. For help adding a
config, see the "copywrite init" command.`,
GroupID: "common", // Let's put this command in the common section of the help
PreRun: func(cmd *cobra.Command, args []string) {
// Change directory if needed
if dirPath != "." {
err := os.Chdir(dirPath)
cobra.CheckErr(err)
}

// Map command flags to config keys
mapping := map[string]string{
`spdx`: `project.license`,
`copyright-holder`: `project.copyright_holder`,
`year1`: `project.copyright_year1`,
`year2`: `project.copyright_year2`,
}

// update the running config with any command-line flags
clobberWithDefaults := false
err := conf.LoadCommandFlags(cmd.Flags(), mapping, clobberWithDefaults)
if err != nil {
cliLogger.Error("Error merging configuration", err)
}
cobra.CheckErr(err)

// Input Validation
isValidSPDX := addlicense.ValidSPDX(conf.Project.License)
if conf.Project.License != "" && !isValidSPDX {
err := fmt.Errorf("invalid SPDX license identifier: %s", conf.Project.License)
cliLogger.Error("Error validating SPDX license", err)
cobra.CheckErr(err)
}
},
Run: func(cmd *cobra.Command, args []string) {
if plan {
cmd.Print(text.FgYellow.Sprint("Executing in dry-run mode. Rerun without the `--plan` flag to apply changes.\n\n"))
}

if conf.Project.License == "" {
cmd.Printf("The --spdx flag was not specified, omitting SPDX license statements.\n\n")
} else {
cmd.Printf("Using license identifier: %s\n", conf.Project.License)
}
cmd.Printf("Using copyright holder: %v\n\n", conf.Project.CopyrightHolder)

if len(conf.Project.HeaderIgnore) == 0 {
cmd.Println("The project.header_ignore list was left empty in config. Processing all files by default.")
} else {
gha.StartGroup("Exempting the following search patterns:")
for _, v := range conf.Project.HeaderIgnore {
cmd.Println(text.FgCyan.Sprint(v))
}
gha.EndGroup()
}
cmd.Println("")

// Append default ignored search patterns (e.g., GitHub Actions workflows)
autoSkippedPatterns := []string{
".github/workflows/**",
".github/dependabot.yml",
"**/node_modules/**",
}
ignoredPatterns := lo.Union(conf.Project.HeaderIgnore, autoSkippedPatterns)

// Construct the configuration addLicense needs to properly format headers
yearRange := ""
if conf.Project.CopyrightYear1 > 0 && conf.Project.CopyrightYear2 > 0 {
if conf.Project.CopyrightYear1 == conf.Project.CopyrightYear2 {
// Use special marker to indicate both years were explicitly provided (even if same)
yearRange = fmt.Sprintf("EXPLICIT_BOTH:%d", conf.Project.CopyrightYear1)
} else {
yearRange = fmt.Sprintf("%d, %d", conf.Project.CopyrightYear1, conf.Project.CopyrightYear2)
}
} else if conf.Project.CopyrightYear1 > 0 {
// Mark that only year1 was provided
yearRange = fmt.Sprintf("YEAR1_ONLY:%d", conf.Project.CopyrightYear1)
} else if conf.Project.CopyrightYear2 > 0 {
// Mark that only year2 was provided
yearRange = fmt.Sprintf("YEAR2_ONLY:%d", conf.Project.CopyrightYear2)
}

licenseData := addlicense.LicenseData{
Year: yearRange,
Holder: conf.Project.CopyrightHolder,
SPDXID: conf.Project.License,
}

verbose := true

// Wrap hclogger to use standard lib's log.Logger
stdcliLogger := cliLogger.StandardLogger(&hclog.StandardLoggerOptions{
// InferLevels must be true so that addLicense can set the log level via
// log prefix, e.g. logger.Println("[DEBUG] this is inferred as a debug log")
InferLevels: true,
})

// WARNING: because of the way we redirect cliLogger to os.Stdout, anything
// prefixed with "[ERROR]" will not implicitly be written to stderr.
// However, we propagate errors upward from addlicense and then run a
// cobra.CheckErr on the return, which will indeed output to stderr and
// return a non-zero error code.

// Use command arguments if provided, otherwise default to current directory
patterns := args
if len(patterns) == 0 {
patterns = []string{"."}
}

gha.StartGroup("The following files are being updated with headers:")
err := addlicense.RunUpdate(ignoredPatterns, "only", licenseData, "", verbose, plan, patterns, stdcliLogger)
gha.EndGroup()

cobra.CheckErr(err)
},
}

func init() {
rootCmd.AddCommand(updateCmd)

// These flags are only locally relevant
updateCmd.Flags().StringVarP(&dirPath, "dirPath", "d", ".", "Path to the directory in which you wish to update headers")
updateCmd.Flags().BoolVar(&plan, "plan", false, "Performs a dry-run, printing the names of all files that would be updated")

// These flags will get mapped to keys in the the global Config
updateCmd.Flags().StringP("spdx", "s", "", "SPDX-compliant license identifier (e.g., 'MPL-2.0')")
updateCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
updateCmd.Flags().IntP("year1", "", 0, "Start year for copyright range (e.g., 2020)")
updateCmd.Flags().IntP("year2", "", 0, "End year for copyright range (e.g., 2025)")
}
4 changes: 3 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ var (
// a specific project/repo
type Project struct {
CopyrightYear int `koanf:"copyright_year"`
CopyrightYear1 int `koanf:"copyright_year1"`
CopyrightYear2 int `koanf:"copyright_year2"`
CopyrightHolder string `koanf:"copyright_holder"`
HeaderIgnore []string `koanf:"header_ignore"`
License string `koanf:"license"`
Expand Down Expand Up @@ -88,7 +90,7 @@ func New() (*Config, error) {
// Preload default config values
defaults := map[string]interface{}{
"schema_version": 1,
"project.copyright_holder": "HashiCorp, Inc.",
"project.copyright_holder": "IBM Corp.",
}
err := c.LoadConfMap(defaults)
if err != nil {
Expand Down
Loading
Loading