diff --git a/.copywrite.hcl b/.copywrite.hcl index 31a0a9d..a868834 100644 --- a/.copywrite.hcl +++ b/.copywrite.hcl @@ -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 diff --git a/README.md b/README.md index fb2224c..e9dfd4a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/addlicense/main.go b/addlicense/main.go index 8c6f603..a12c6fa 100644 --- a/addlicense/main.go +++ b/addlicense/main.go @@ -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 + } + if updated { + + logger.Printf("%s (copyright holder updated)", f.path) + } + } else { + logger.Printf("%s (license header added)", f.path) } } return nil @@ -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." + // - "" + pattern := regexp.MustCompile( + `(?im)^(\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*)$`, // 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) diff --git a/addlicense/main_test.go b/addlicense/main_test.go index ecd6560..86a9570 100644 --- a/addlicense/main_test.go +++ b/addlicense/main_test.go @@ -417,6 +417,145 @@ func TestHasLicense(t *testing.T) { } } +func TestUpdateLicenseHolder(t *testing.T) { + data := LicenseData{Holder: "IBM Corp.", Year: "2023, 2026", SPDXID: "MPL-2.0"} + + tests := []struct { + name string + content string + wantContent string + wantUpdated bool + }{ + { + name: "Update HashiCorp, Inc. with year after", + content: "// Copyright (c) HashiCorp, Inc. 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "Update HashiCorp, Inc. with year before", + content: "// Copyright 2023 HashiCorp, Inc.\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "Update HashiCorp without Inc", + content: "// Copyright HashiCorp 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "Update HashiCorp without (c) symbol", + content: "// Copyright HashiCorp, Inc. 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "Update with Python comment style", + content: "# Copyright (c) HashiCorp, Inc. 2023\n\nprint('hello')", + wantContent: "# Copyright IBM Corp. 2023, 2026\n\nprint('hello')", + wantUpdated: true, + }, + { + name: "Update with block comment style", + content: "/*\n * Copyright (c) HashiCorp, Inc. 2023\n */\n\nint main() {}", + wantContent: "/*\n * Copyright IBM Corp. 2023, 2026\n */\n\nint main() {}", + wantUpdated: true, + }, + { + name: "Update with HTML comment style", + content: "\n\n", + wantContent: "\n\n", + wantUpdated: true, + }, + { + name: "Update HashiCorp Inc without comma", + content: "// Copyright HashiCorp Inc 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "Update with year range", + content: "// Copyright (c) HashiCorp, Inc. 2020, 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "No update when different holder", + content: "// Copyright (c) Google LLC 2023\n\npackage main", + wantContent: "// Copyright (c) Google LLC 2023\n\npackage main", + wantUpdated: false, + }, + { + name: "No update when no copyright", + content: "package main\n\nfunc main() {}", + wantContent: "package main\n\nfunc main() {}", + wantUpdated: false, + }, + { + name: "No update for HashiCorp in code body", + content: "package main\n\n// This mentions HashiCorp, Inc.\nfunc main() {}", + wantContent: "package main\n\n// This mentions HashiCorp, Inc.\nfunc main() {}", + wantUpdated: false, + }, + { + name: "Update case insensitive", + content: "// Copyright (c) HASHICORP, INC. 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + { + name: "Update with extra whitespace", + content: "// Copyright (c) HashiCorp, Inc. 2023\n\npackage main", + wantContent: "// Copyright IBM Corp. 2023, 2026\n\npackage main", + wantUpdated: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp file + tmpfile, err := os.CreateTemp("", "test-*.go") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Remove(tmpfile.Name()); err != nil { + t.Logf("Failed to remove temp file: %v", err) + } + }() + + // Write test content + if _, err := tmpfile.Write([]byte(tt.content)); err != nil { + t.Fatal(err) + } + if err := tmpfile.Close(); err != nil { + t.Fatal(err) + } + + // Run update + updated, err := updateLicenseHolder(tmpfile.Name(), 0644, data) + if err != nil { + t.Fatalf("updateLicenseHolder() error = %v", err) + } + + if updated != tt.wantUpdated { + t.Errorf("updateLicenseHolder() updated = %v, want %v", updated, tt.wantUpdated) + } + + // Read result + result, err := os.ReadFile(tmpfile.Name()) + if err != nil { + t.Fatal(err) + } + + if string(result) != tt.wantContent { + t.Errorf("updateLicenseHolder() result:\ngot:\n%s\n\nwant:\n%s", result, tt.wantContent) + } + }) + } +} + func TestFileMatches(t *testing.T) { tests := []struct { pattern string @@ -476,3 +615,358 @@ func TestFileMatches(t *testing.T) { } } } + +func TestWouldUpdateLicenseHolder(t *testing.T) { + tests := []struct { + name string + content string + license LicenseData + expected bool + }{ + { + name: "Would update HashiCorp, Inc.", + content: "// Copyright (c) 2023 HashiCorp, Inc.\n", + license: LicenseData{Holder: "IBM Corp.", Year: "2026"}, + expected: true, + }, + { + name: "Would update HashiCorp Inc without comma", + content: "// Copyright 2023 HashiCorp Inc\n", + license: LicenseData{Holder: "IBM Corp.", Year: "2026"}, + expected: true, + }, + { + name: "Would not update different holder", + content: "// Copyright 2023 Google LLC\n", + license: LicenseData{Holder: "IBM Corp.", Year: "2026"}, + expected: false, + }, + { + name: "Would not update no copyright", + content: "// This is just a comment\n", + license: LicenseData{Holder: "IBM Corp.", Year: "2026"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpfile, err := os.CreateTemp("", "test") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Remove(tmpfile.Name()); err != nil { + t.Logf("Failed to remove temp file: %v", err) + } + }() + + if _, err := tmpfile.Write([]byte(tt.content)); err != nil { + t.Fatal(err) + } + if err := tmpfile.Close(); err != nil { + t.Fatal(err) + } + + got, err := wouldUpdateLicenseHolder(tmpfile.Name(), tt.license) + if err != nil { + t.Fatalf("wouldUpdateLicenseHolder returned error: %v", err) + } + if got != tt.expected { + t.Errorf("wouldUpdateLicenseHolder() = %v, expected %v", got, tt.expected) + } + }) + } +} + +func TestIsDirectory(t *testing.T) { + // Create temporary directory for test + tmpDir := tempDir(t) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Failed to remove temp dir: %v", err) + } + }() + + // Test regular file + tmpfile, err := os.CreateTemp(tmpDir, "test-file-*.txt") + if err != nil { + t.Fatal(err) + } + if err := tmpfile.Close(); err != nil { + t.Fatal(err) + } + + // Test regular directory + testDir := filepath.Join(tmpDir, "test-directory") + if err := os.Mkdir(testDir, 0755); err != nil { + t.Fatal(err) + } + + // Test symlink to file + symlinkToFile := filepath.Join(tmpDir, "symlink-to-file") + if err := os.Symlink(tmpfile.Name(), symlinkToFile); err != nil { + t.Fatal(err) + } + + // Test symlink to directory + symlinkToDir := filepath.Join(tmpDir, "symlink-to-dir") + if err := os.Symlink(testDir, symlinkToDir); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + expected bool + wantErr bool + }{ + { + name: "Regular file", + path: tmpfile.Name(), + expected: false, + wantErr: false, + }, + { + name: "Regular directory", + path: testDir, + expected: true, + wantErr: false, + }, + { + name: "Symlink to file", + path: symlinkToFile, + expected: false, + wantErr: false, + }, + { + name: "Symlink to directory", + path: symlinkToDir, + expected: true, + wantErr: false, + }, + { + name: "Non-existent path", + path: filepath.Join(tmpDir, "does-not-exist"), + expected: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := isDirectory(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("isDirectory() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.expected { + t.Errorf("isDirectory() = %v, expected %v", got, tt.expected) + } + }) + } +} + +func TestUpdateLicenseHolderSkipsDirectories(t *testing.T) { + // Create temporary directory for test + tmpDir := tempDir(t) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Failed to remove temp dir: %v", err) + } + }() + + data := LicenseData{Holder: "IBM Corp.", Year: "2026", SPDXID: "MPL-2.0"} + + // Test regular directory + testDir := filepath.Join(tmpDir, "test-directory") + if err := os.Mkdir(testDir, 0755); err != nil { + t.Fatal(err) + } + + // Test symlink to directory + symlinkToDir := filepath.Join(tmpDir, "symlink-to-dir") + if err := os.Symlink(testDir, symlinkToDir); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + }{ + { + name: "Regular directory", + path: testDir, + }, + { + name: "Symlink to directory", + path: symlinkToDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, err := updateLicenseHolder(tt.path, 0755, data) + if err != nil { + t.Fatalf("updateLicenseHolder() should not error on directories: %v", err) + } + if updated { + t.Errorf("updateLicenseHolder() should not update directories, got updated=true") + } + }) + } +} + +func TestWouldUpdateLicenseHolderSkipsDirectories(t *testing.T) { + // Create temporary directory for test + tmpDir := tempDir(t) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Failed to remove temp dir: %v", err) + } + }() + + data := LicenseData{Holder: "IBM Corp.", Year: "2026", SPDXID: "MPL-2.0"} + + // Test regular directory + testDir := filepath.Join(tmpDir, "test-directory") + if err := os.Mkdir(testDir, 0755); err != nil { + t.Fatal(err) + } + + // Test symlink to directory + symlinkToDir := filepath.Join(tmpDir, "symlink-to-dir") + if err := os.Symlink(testDir, symlinkToDir); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + }{ + { + name: "Regular directory", + path: testDir, + }, + { + name: "Symlink to directory", + path: symlinkToDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wouldUpdate, err := wouldUpdateLicenseHolder(tt.path, data) + if err != nil { + t.Fatalf("wouldUpdateLicenseHolder() should not error on directories: %v", err) + } + if wouldUpdate { + t.Errorf("wouldUpdateLicenseHolder() should not update directories, got wouldUpdate=true") + } + }) + } +} + +func TestDirectorySkippingRegressionTest(t *testing.T) { + // Regression test for: Fix 'is a directory' error in copyright holder migration + // This test simulates the scenario that was causing crashes where filepath.Walk + // encounters symlinks to directories, such as version directories in test fixtures + + // Create temporary directory structure similar to test fixtures + tmpDir := tempDir(t) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Logf("Failed to remove temp dir: %v", err) + } + }() + + data := LicenseData{Holder: "IBM Corp.", Year: "2023, 2026", SPDXID: "MPL-2.0"} + + // Create test fixture structure + testFixtureDir := filepath.Join(tmpDir, "test-fixture") + if err := os.Mkdir(testFixtureDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a version directory (like "v1.2.3") + versionDir := filepath.Join(testFixtureDir, "v1.2.3") + if err := os.Mkdir(versionDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a symlink to the version directory (this was causing the crash) + symlinkToVersion := filepath.Join(testFixtureDir, "latest") + if err := os.Symlink("v1.2.3", symlinkToVersion); err != nil { + t.Fatal(err) + } + + // Add a test file with HashiCorp copyright in the version directory + testFile := filepath.Join(versionDir, "test.go") + testContent := "// Copyright (c) HashiCorp, Inc. 2023\n\npackage main\n\nfunc main() {}" + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + // Walk the directory structure like the real code would + var pathsToUpdate []string + err := filepath.Walk(testFixtureDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories (this is where the bug was) + isDir, dirErr := isDirectory(path) + if dirErr != nil { + return dirErr + } + if isDir { + return nil // Skip directories and symlinks to directories + } + + // Check if this file would be updated + wouldUpdate, checkErr := wouldUpdateLicenseHolder(path, data) + if checkErr != nil { + t.Fatalf("wouldUpdateLicenseHolder failed on %s: %v", path, checkErr) + } + + if wouldUpdate { + pathsToUpdate = append(pathsToUpdate, path) + } + + return nil + }) + + if err != nil { + t.Fatalf("filepath.Walk should not fail with proper directory skipping: %v", err) + } + + // Verify we found the test file but skipped directories + if len(pathsToUpdate) != 1 { + t.Fatalf("Expected to find 1 file to update, found %d: %v", len(pathsToUpdate), pathsToUpdate) + } + + if pathsToUpdate[0] != testFile { + t.Errorf("Expected to find test file %s, found %s", testFile, pathsToUpdate[0]) + } + + // Verify we can actually update the file without crashes + updated, err := updateLicenseHolder(pathsToUpdate[0], 0644, data) + if err != nil { + t.Fatalf("updateLicenseHolder should not fail: %v", err) + } + + if !updated { + t.Errorf("updateLicenseHolder should have updated the file") + } + + // Verify content was updated correctly + result, err := os.ReadFile(pathsToUpdate[0]) + if err != nil { + t.Fatal(err) + } + + expectedContent := "// Copyright IBM Corp. 2023, 2026\n\npackage main\n\nfunc main() {}" + if string(result) != expectedContent { + t.Errorf("File content not updated correctly:\ngot:\n%s\n\nwant:\n%s", result, expectedContent) + } +}