diff --git a/.github/workflows/golangci.yml b/.github/workflows/golangci.yml index 967c4f0..441d0d0 100644 --- a/.github/workflows/golangci.yml +++ b/.github/workflows/golangci.yml @@ -36,6 +36,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + fetch-depth: 0 # Fetch full git history for copyright year auto-detection - name: Install Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 diff --git a/README.md b/README.md index 8a08b55..f288531 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ project { # 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 not set (0), it will be auto-detected from GitHub or use current year only - # Default: + # 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) # copyright_year = 0 # (OPTIONAL) A list of globs that should not have copyright or license headers . diff --git a/addlicense/tmpl.go b/addlicense/tmpl.go index 8eba269..d9e9693 100644 --- a/addlicense/tmpl.go +++ b/addlicense/tmpl.go @@ -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 (c){{ 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 (c){{ if .Holder }} {{.Holder}}{{ end }}{{ if .Year }} {{.Year}}{{ end }}` const spdxSuffix = "\n\nSPDX-License-Identifier: {{.SPDXID}}" diff --git a/config/config.go b/config/config.go index 320676d..6e9a806 100644 --- a/config/config.go +++ b/config/config.go @@ -6,8 +6,10 @@ package config import ( "fmt" "os" + "os/exec" "path/filepath" "strconv" + "strings" "time" "github.com/knadh/koanf" @@ -240,22 +242,64 @@ func (c *Config) GetConfigPath() string { return c.absCfgPath } +// detectFirstCommitYear attempts to auto-detect the first commit year from git history. +// Returns 0 if detection fails or git is not available. +func (c *Config) detectFirstCommitYear() int { + // Try to get the year of the first commit + cmd := exec.Command("git", "log", "--reverse", "--format=%ad", "--date=format:%Y") + cmd.Dir = filepath.Dir(c.absCfgPath) + + // If no config path set, use current directory + if c.absCfgPath == "" { + if wd, err := os.Getwd(); err == nil { + cmd.Dir = wd + } + } + + output, err := cmd.Output() + if err != nil { + // Git command failed (not a git repo, git not installed, etc.) + return 0 + } + + // Parse the first line (first commit year) + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 0 || lines[0] == "" { + return 0 + } + + year, err := strconv.Atoi(strings.TrimSpace(lines[0])) + if err != nil || year < 1970 || year > time.Now().Year() { + // Invalid year + return 0 + } + + return year +} + // FormatCopyrightYears returns a formatted year string for copyright statements. -// If copyrightYear is 0 or equals current year, returns current year only. +// If copyrightYear is 0, attempts to auto-detect from git history. +// If copyrightYear equals current year, returns current year only. // Otherwise returns "copyrightYear, currentYear" format (e.g., "2023, 2025"). func (c *Config) FormatCopyrightYears() string { currentYear := time.Now().Year() - - // If no copyright year is set, use current year only - if c.Project.CopyrightYear == 0 { - return strconv.Itoa(currentYear) + copyrightYear := c.Project.CopyrightYear + + // If no copyright year is set, try auto-detection from git + if copyrightYear == 0 { + if detectedYear := c.detectFirstCommitYear(); detectedYear > 0 { + copyrightYear = detectedYear + } else { + // Fallback to current year if auto-detection fails + return strconv.Itoa(currentYear) + } } // If copyright year equals current year, return single year - if c.Project.CopyrightYear == currentYear { + if copyrightYear == currentYear { return strconv.Itoa(currentYear) } // Return year range: "startYear, currentYear" - return fmt.Sprintf("%d, %d", c.Project.CopyrightYear, currentYear) + return fmt.Sprintf("%d, %d", copyrightYear, currentYear) } diff --git a/config/config_test.go b/config/config_test.go index 78b754b..b3f5f2c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -5,6 +5,7 @@ package config import ( "fmt" + "os" "path/filepath" "strconv" "strings" @@ -381,11 +382,6 @@ func Test_FormatCopyrightYears(t *testing.T) { copyrightYear int expectedOutput string }{ - { - description: "No copyright year set (0) should return current year only", - copyrightYear: 0, - expectedOutput: strconv.Itoa(currentYear), - }, { description: "Copyright year equals current year should return single year", copyrightYear: currentYear, @@ -414,3 +410,52 @@ func Test_FormatCopyrightYears(t *testing.T) { }) } } + +func Test_FormatCopyrightYears_AutoDetect(t *testing.T) { + currentYear := time.Now().Year() + + t.Run("Auto-detect from git when copyright_year not set", func(t *testing.T) { + c := MustNew() + c.Project.CopyrightYear = 0 + + // Set config path to this repo's directory for git detection + c.absCfgPath = filepath.Join(getCurrentDir(t), ".copywrite.hcl") + + actualOutput := c.FormatCopyrightYears() + + // Should auto-detect and return a year range (this repo was created before 2025) + // The format should be "YYYY, currentYear" where YYYY < currentYear + assert.Contains(t, actualOutput, ",", "Should contain year range when auto-detected from git") + assert.Contains(t, actualOutput, strconv.Itoa(currentYear), "Should contain current year") + + // Parse and validate the detected year + parts := strings.Split(actualOutput, ", ") + if len(parts) == 2 { + detectedYear, err := strconv.Atoi(parts[0]) + assert.Nil(t, err, "First part should be a valid year") + assert.True(t, detectedYear >= 2020 && detectedYear <= currentYear, + "Detected year should be reasonable (between 2020 and current year)") + } + }) + + t.Run("Fallback to current year when git not available", func(t *testing.T) { + c := MustNew() + c.Project.CopyrightYear = 0 + + // Set config path to non-existent directory (git will fail) + c.absCfgPath = "/nonexistent/path/.copywrite.hcl" + + actualOutput := c.FormatCopyrightYears() + + // Should fallback to current year only + assert.Equal(t, strconv.Itoa(currentYear), actualOutput, + "Should fallback to current year when git detection fails") + }) +} + +// Helper function to get current directory +func getCurrentDir(t *testing.T) string { + dir, err := os.Getwd() + assert.Nil(t, err, "Should be able to get current directory") + return dir +}