Skip to content

Commit 10cb3be

Browse files
Update feature added for headers command. (#181)
* Extend header functionality to update years. * Updated readme, year logic in license command and added tests for headers update * Preserve add Headers behaviour for header updates * Minor Linting fix * Fix git path resolution, LICENSE update logic, and pattern matching consistency * Readme Updates * Unexport internal copyright extraction and git helper functions * Readme updates. * Readme Formatting * Year-2 update enhancements(File commit comparision to ignore header changes) * Added test cases for calculateYearUpdates method * Update builtin help commands. * Decoupling update and walk logic for faster processing. * accept unprefixed LICENSE copyright lines; error message on year updates in plan mode * Update Readme file
1 parent 421a509 commit 10cb3be

File tree

7 files changed

+1778
-18
lines changed

7 files changed

+1778
-18
lines changed

README.md

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
This repo provides utilities for managing copyright headers and license files
44
across many repos at scale.
55

6-
You can use it to add or validate copyright headers on source code files, add a
7-
LICENSE file to a repo, report on what licenses repos are using, and more.
6+
Features:
7+
- Add or validate copyright headers on source code files
8+
- Add and/or manage LICENSE files with git-aware copyright year detection
9+
- Report on licenses used across multiple repositories
10+
- Automate compliance checks in CI/CD pipelines
811

912
## Getting Started
1013

@@ -33,7 +36,7 @@ Usage:
3336
copywrite [command]
3437
3538
Common Commands:
36-
headers Adds missing copyright headers to all source code files
39+
headers Adds missing copyright headers and updates existing headers' year information.
3740
init Generates a .copywrite.hcl config for a new project
3841
license Validates that a LICENSE file is present and remediates any issues if found
3942
@@ -62,8 +65,18 @@ scan all files in your repo and copyright headers to any that are missing:
6265
copywrite headers --spdx "MPL-2.0"
6366
```
6467

65-
You may omit the `--spdx` flag if you add a `.copywrite.hcl` config, as outlined
66-
[here](#config-structure).
68+
The `copywrite license` command validates and manages LICENSE files with git-aware copyright years:
69+
70+
```sh
71+
copywrite license --spdx "MPL-2.0"
72+
```
73+
74+
**Copyright Year Behavior:**
75+
- **Start Year**: Auto-detected from config file and if not found defaults to repository's first commit
76+
- **End Year**: Set to current year when an update is triggered (git history only determines if update is needed)
77+
- **Update Trigger**: Git detects if source code file was modified since the copyright end year
78+
79+
You may omit the `--spdx` flag if you add a `.copywrite.hcl` config, as outlined [here](#config-structure).
6780

6881
### `--plan` Flag
6982

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

88+
## Technical Details
89+
90+
### Copyright Year Logic
91+
92+
**Source File Headers:**
93+
- End year: Set to current year when file's source code is modified
94+
- Git history determines if update is needed (compares file's last commit year to copyright end year)
95+
- When triggered, end year updates to current year
96+
- Ignores copyright header updates made to a file as it is not source code change.
97+
98+
**LICENSE Files:**
99+
- End year: Set to current year when any project file is modified
100+
- Git history determines if update is needed (compares repo's last commit year to copyright end year)
101+
- When triggered, end year updates to current year
102+
- Preserves historical accuracy for archived projects (no forced updates)
103+
104+
**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.
105+
75106
## Config Structure
76107

77108
> :bulb: You can automatically generate a new `.copywrite.hcl` config with the
@@ -99,8 +130,8 @@ project {
99130
100131
# (OPTIONAL) Represents the year that the project initially began
101132
# This is used as the starting year in copyright statements
102-
# If set and different from current year, headers will show: "copyright_year, current_year"
103-
# If set and same as current year, headers will show: "current_year"
133+
# If set and different from current year, headers will show: "copyright_year, year-2"
134+
# If set and same as year-2, headers will show: "copyright_year"
104135
# If not set (0), the tool will auto-detect from git history (first commit year)
105136
# If auto-detection fails, it will fallback to current year only
106137
# Default: 0 (auto-detect)

addlicense/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ func walk(ch chan<- *file, start string, logger *log.Logger) error {
280280
if fi.IsDir() {
281281
return nil
282282
}
283-
if fileMatches(path, ignorePatterns) {
283+
if FileMatches(path, ignorePatterns) {
284284
// The [DEBUG] level is inferred by go-hclog as a debug statement
285285
logger.Printf("[DEBUG] skipping: %s", path)
286286
return nil
@@ -290,9 +290,9 @@ func walk(ch chan<- *file, start string, logger *log.Logger) error {
290290
})
291291
}
292292

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

298298
if runtime.GOOS == "windows" {

addlicense/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ func TestFileMatches(t *testing.T) {
471471

472472
for _, tt := range tests {
473473
patterns := []string{tt.pattern}
474-
if got := fileMatches(tt.path, patterns); got != tt.wantMatch {
474+
if got := FileMatches(tt.path, patterns); got != tt.wantMatch {
475475
t.Errorf("fileMatches(%q, %q) returned %v, want %v", tt.path, patterns, got, tt.wantMatch)
476476
}
477477
}

cmd/headers.go

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ package cmd
66
import (
77
"fmt"
88
"os"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
"sync"
13+
"sync/atomic"
914

1015
"github.com/hashicorp/copywrite/addlicense"
16+
"github.com/hashicorp/copywrite/licensecheck"
1117
"github.com/hashicorp/go-hclog"
1218
"github.com/jedib0t/go-pretty/v6/text"
1319
"github.com/samber/lo"
@@ -21,9 +27,13 @@ var (
2127

2228
var headersCmd = &cobra.Command{
2329
Use: "headers",
24-
Short: "Adds missing copyright headers to all source code files",
30+
Short: "Adds missing copyright headers and updates existing headers' year information in all source code files",
2531
Long: `Recursively checks for all files in the given directory and subdirectories,
26-
adding copyright statements and license headers to any that are missing them.
32+
adding copyright statements and license headers to any that are missing them and
33+
updating the year information in existing headers based on git history.
34+
35+
By default, the command will modify files in place. To perform a dry-run without
36+
modifying any files, use the --plan flag.
2737
2838
Autogenerated files and common file types that don't support headers (e.g., prose)
2939
will automatically be exempted. Any other files or folders should be added to the
@@ -87,10 +97,23 @@ config, see the "copywrite init" command.`,
8797
".github/workflows/**",
8898
".github/dependabot.yml",
8999
"**/node_modules/**",
100+
".copywrite.hcl",
90101
}
91102
ignoredPatterns := lo.Union(conf.Project.HeaderIgnore, autoSkippedPatterns)
92103

93-
// Construct the configuration addLicense needs to properly format headers
104+
// STEP 1: Update existing copyright headers
105+
gha.StartGroup("Updating existing copyright headers:")
106+
updatedCount, anyFileUpdated, licensePath := updateExistingHeaders(cmd, ignoredPatterns, plan)
107+
gha.EndGroup()
108+
if updatedCount > 0 {
109+
if plan {
110+
cmd.Printf("\n%s\n\n", text.FgYellow.Sprintf("[DRY RUN] Would update %d file(s) with new copyright years", updatedCount))
111+
} else {
112+
cmd.Printf("\n%s\n\n", text.FgGreen.Sprintf("Successfully updated %d file(s) with new copyright years", updatedCount))
113+
}
114+
}
115+
116+
// STEP 2: Construct the configuration addLicense needs to properly format headers
94117
licenseData := addlicense.LicenseData{
95118
Year: conf.FormatCopyrightYears(), // Format year(s) for copyright statements
96119
Holder: conf.Project.CopyrightHolder,
@@ -112,10 +135,33 @@ config, see the "copywrite init" command.`,
112135
// cobra.CheckErr on the return, which will indeed output to stderr and
113136
// return a non-zero error code.
114137

115-
gha.StartGroup("The following files are missing headers:")
116-
err := addlicense.Run(ignoredPatterns, "only", licenseData, "", verbose, plan, []string{"."}, stdcliLogger)
138+
// STEP 3: Add missing headers
139+
gha.StartGroup("Adding missing copyright headers:")
140+
var err error
141+
// In dry-run mode, if updateExistingHeaders found files that would be
142+
// updated (year bumps), treat that as an error so the command exits
143+
// non-zero to indicate work would be performed.
144+
if plan && updatedCount > 0 {
145+
err = fmt.Errorf("[DRY RUN] %d file(s) would be updated with new copyright years", updatedCount)
146+
}
147+
runErr := addlicense.Run(ignoredPatterns, "only", licenseData, "", verbose, plan, []string{"."}, stdcliLogger)
148+
if err != nil && runErr != nil {
149+
err = fmt.Errorf("%v; %v", err, runErr)
150+
} else if err == nil {
151+
err = runErr
152+
}
117153
gha.EndGroup()
118154

155+
// STEP 4: Update LICENSE file if any files were modified (either updated or added headers)
156+
// In plan mode: if addlicense found missing headers (returns error), assume files would be modified
157+
// In normal mode: if addlicense succeeded, assume files were modified
158+
if runErr != nil || (!plan && runErr == nil) {
159+
anyFileUpdated = true
160+
}
161+
162+
updateLicenseFile(cmd, licensePath, anyFileUpdated, plan)
163+
164+
// Check for errors after LICENSE file update so we still show what would happen
119165
cobra.CheckErr(err)
120166
},
121167
}
@@ -131,3 +177,125 @@ func init() {
131177
headersCmd.Flags().StringP("spdx", "s", "", "SPDX-compliant license identifier (e.g., 'MPL-2.0')")
132178
headersCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
133179
}
180+
181+
// updateExistingHeaders walks through files and updates copyright headers based on config and git history
182+
// Returns the count of updated files, a boolean indicating if any file was updated, and the LICENSE file path (if found)
183+
func updateExistingHeaders(cmd *cobra.Command, ignoredPatterns []string, dryRun bool) (int, bool, string) {
184+
targetHolder := conf.Project.CopyrightHolder
185+
if targetHolder == "" {
186+
targetHolder = "IBM Corp."
187+
}
188+
189+
configYear := conf.Project.CopyrightYear
190+
updatedCount := 0
191+
anyFileUpdated := false
192+
var licensePath string
193+
194+
// Producer/consumer: walk files (producer) and process them with a bounded
195+
// worker pool (consumers). This preserves existing semantics while
196+
// bounding concurrency and allowing the walk to run ahead of processors.
197+
ch := make(chan string, 1000)
198+
199+
var wg sync.WaitGroup
200+
var updatedCount64 int64
201+
var anyFileUpdatedFlag int32
202+
var mu sync.Mutex
203+
204+
workers := runtime.NumCPU() * 4
205+
if workers < 2 {
206+
workers = 2
207+
}
208+
209+
// Start worker pool
210+
wg.Add(workers)
211+
for i := 0; i < workers; i++ {
212+
go func() {
213+
defer wg.Done()
214+
for path := range ch {
215+
// capture base and skip LICENSE files here as well
216+
base := filepath.Base(path)
217+
if strings.EqualFold(base, "LICENSE") || strings.EqualFold(base, "LICENSE.TXT") || strings.EqualFold(base, "LICENSE.MD") {
218+
mu.Lock()
219+
if licensePath == "" {
220+
licensePath = path
221+
}
222+
mu.Unlock()
223+
continue
224+
}
225+
226+
if !dryRun {
227+
updated, err := licensecheck.UpdateCopyrightHeader(path, targetHolder, configYear, false)
228+
if err == nil && updated {
229+
cmd.Printf(" %s\n", path)
230+
atomic.AddInt64(&updatedCount64, 1)
231+
atomic.StoreInt32(&anyFileUpdatedFlag, 1)
232+
}
233+
} else {
234+
needsUpdate, err := licensecheck.NeedsUpdate(path, targetHolder, configYear, false)
235+
if err == nil && needsUpdate {
236+
cmd.Printf(" %s\n", path)
237+
atomic.AddInt64(&updatedCount64, 1)
238+
atomic.StoreInt32(&anyFileUpdatedFlag, 1)
239+
}
240+
}
241+
}
242+
}()
243+
}
244+
245+
// Producer: walk the tree and push files onto the channel
246+
go func() {
247+
_ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
248+
if err != nil || info.IsDir() {
249+
return nil
250+
}
251+
252+
// Check if file should be ignored
253+
if addlicense.FileMatches(path, ignoredPatterns) {
254+
return nil
255+
}
256+
257+
// Non-ignored file -> enqueue for processing. If channel is full,
258+
// this will block until a worker consumes entries, which is fine.
259+
ch <- path
260+
return nil
261+
})
262+
close(ch)
263+
}()
264+
265+
// wait for workers to finish
266+
wg.Wait()
267+
268+
// finalize counts
269+
updatedCount = int(atomic.LoadInt64(&updatedCount64))
270+
anyFileUpdated = atomic.LoadInt32(&anyFileUpdatedFlag) != 0
271+
272+
return updatedCount, anyFileUpdated, licensePath
273+
}
274+
275+
// updateLicenseFile updates the LICENSE file with current year if any files were modified
276+
func updateLicenseFile(cmd *cobra.Command, licensePath string, anyFileUpdated bool, dryRun bool) {
277+
// If no LICENSE file was found during the walk, nothing to do
278+
if licensePath == "" {
279+
return
280+
}
281+
282+
targetHolder := conf.Project.CopyrightHolder
283+
if targetHolder == "" {
284+
targetHolder = "IBM Corp."
285+
}
286+
287+
configYear := conf.Project.CopyrightYear
288+
289+
// Update LICENSE file, forcing current year if any file was updated
290+
if !dryRun {
291+
updated, err := licensecheck.UpdateCopyrightHeader(licensePath, targetHolder, configYear, anyFileUpdated)
292+
if err == nil && updated {
293+
cmd.Printf("\nUpdated LICENSE file: %s\n", licensePath)
294+
}
295+
} else {
296+
needsUpdate, err := licensecheck.NeedsUpdate(licensePath, targetHolder, configYear, anyFileUpdated)
297+
if err == nil && needsUpdate {
298+
cmd.Printf("\n[DRY RUN] Would update LICENSE file: %s\n", licensePath)
299+
}
300+
}
301+
}

cmd/license.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"errors"
88
"fmt"
99
"path/filepath"
10+
"strconv"
11+
"time"
1012

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

6567
cmd.Printf("Licensing under the following terms: %s\n", conf.Project.License)
66-
cmd.Printf("Using copyright years: %v\n", conf.FormatCopyrightYears())
68+
69+
// Determine appropriate copyright years for LICENSE file
70+
licenseYears := determineLicenseCopyrightYears(dirPath)
71+
72+
cmd.Printf("Using copyright years: %v\n", licenseYears)
6773
cmd.Printf("Using copyright holder: %v\n\n", conf.Project.CopyrightHolder)
6874

69-
copyright := "Copyright " + conf.FormatCopyrightYears() + " " + conf.Project.CopyrightHolder
75+
copyright := "Copyright " + conf.Project.CopyrightHolder + " " + licenseYears
7076

7177
licenseFiles, err := licensecheck.FindLicenseFiles(dirPath)
7278
if err != nil {
@@ -175,3 +181,34 @@ func init() {
175181
licenseCmd.Flags().StringP("spdx", "s", "", "SPDX License Identifier indicating what the LICENSE file should represent")
176182
licenseCmd.Flags().StringP("copyright-holder", "c", "", "Copyright holder (default \"IBM Corp.\")")
177183
}
184+
185+
// determineLicenseCopyrightYears determines the appropriate copyright year range for LICENSE file
186+
// Uses git history to get the start year (first commit) and end year (last commit)
187+
func determineLicenseCopyrightYears(dirPath string) string {
188+
currentYear := time.Now().Year()
189+
startYear := conf.Project.CopyrightYear
190+
191+
// If no start year configured, try to auto-detect from git
192+
if startYear == 0 {
193+
if detectedYear, err := licensecheck.GetRepoFirstCommitYear(dirPath); err == nil && detectedYear > 0 {
194+
startYear = detectedYear
195+
} else {
196+
// Fallback to current year
197+
return strconv.Itoa(currentYear)
198+
}
199+
}
200+
201+
// Determine end year from repository's last commit year
202+
endYear := currentYear // Default fallback
203+
if lastRepoCommitYear, err := licensecheck.GetRepoLastCommitYear(dirPath); err == nil && lastRepoCommitYear > 0 && lastRepoCommitYear <= currentYear {
204+
endYear = lastRepoCommitYear
205+
}
206+
207+
// If start year equals end year, return single year
208+
if startYear == endYear {
209+
return strconv.Itoa(endYear)
210+
}
211+
212+
// Return year range: "startYear, endYear"
213+
return fmt.Sprintf("%d, %d", startYear, endYear)
214+
}

0 commit comments

Comments
 (0)