Skip to content
Open
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
4 changes: 3 additions & 1 deletion commands/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ var Header = &cobra.Command{
Use: "header",
Aliases: []string{"h"},
Short: "License header related commands; e.g. check, fix, etc.",
Long: "`header` command walks the specified paths recursively and checks if the specified files have the license header in the config file.",
Long: "`header` command walks the specified paths and checks if the specified " +
"files have the license header in the config file. " +
"Accepts files, directories, and glob patterns.",
}

func init() {
Expand Down
17 changes: 14 additions & 3 deletions commands/header_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,28 @@ import (
)

var CheckCommand = &cobra.Command{
Use: "check",
Use: "check [paths...]",
Aliases: []string{"c"},
Long: "check command walks the specified paths recursively and checks if the specified files have the license header in the config file.",
Long: "check command walks the specified paths recursively and checks if the " +
"specified files have the license header in the config file. " +
"Accepts files, directories, and glob patterns. " +
"If no paths are specified, checks the current directory " +
"recursively as defined in the config file.",
RunE: func(_ *cobra.Command, args []string) error {
hasErrors := false
for _, h := range Config.Headers() {
var result header.Result

if len(args) > 0 {
// Filter args by the paths in this header config
var filteredArgs []string
for _, arg := range args {
if header.MatchPaths(arg, h.Paths) {
filteredArgs = append(filteredArgs, arg)
}
}
Comment on lines +46 to +51
Copy link
Member

Choose a reason for hiding this comment

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

Hi, I think when we pass the paths to the CLI args we intend to check that files regardless of the config file. The args here should be highest priority even if they are not configured in config file

logger.Log.Debugln("Overriding paths with command line args.")
h.Paths = args
h.Paths = filteredArgs
}

if err := header.Check(h, &result); err != nil {
Expand Down
16 changes: 13 additions & 3 deletions commands/header_fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,27 @@ import (
)

var FixCommand = &cobra.Command{
Use: "fix",
Use: "fix [paths...]",
Aliases: []string{"f"},
Long: "fix command walks the specified paths recursively and fix the license header if the specified files don't have the license header.",
Long: "fix command walks the specified paths recursively and fixes the license " +
"header if the specified files don't have the license header. " +
"Accepts individual file paths as arguments, " +
"or directories and glob patterns via the config file. " +
"If no paths are specified, fixes the current directory " +
"recursively as defined in the config file.",
RunE: func(_ *cobra.Command, args []string) error {
var errors []string
for _, h := range Config.Headers() {
var result header.Result
var files []string

if len(args) > 0 {
files = args
// Filter args by the paths in this header config
for _, arg := range args {
if header.MatchPaths(arg, h.Paths) {
files = append(files, arg)
}
}
} else if err := header.Check(h, &result); err != nil {
return err
} else {
Expand Down
52 changes: 45 additions & 7 deletions pkg/header/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import (
"golang.org/x/sync/errgroup"
)

const currentDir = "./"

// Check checks the license headers of the specified paths/globs.
func Check(config *ConfigHeader, result *Result) error {
fileList, err := listFiles(config)
Expand Down Expand Up @@ -67,13 +69,13 @@ func Check(config *ConfigHeader, result *Result) error {
func listFiles(config *ConfigHeader) ([]string, error) {
var fileList []string

repo, err := git.PlainOpen("./")
repo, err := git.PlainOpen(currentDir)

if err != nil { // we're not in a Git workspace, fallback to glob paths
var localFileList []string
for _, pattern := range config.Paths {
if pattern == "." {
pattern = "./"
pattern = currentDir
}
files, err := doublestar.Glob(pattern)
if err != nil {
Expand Down Expand Up @@ -134,12 +136,15 @@ func listFiles(config *ConfigHeader) ([]string, error) {
}

seen := make(map[string]bool)
for _, file := range candidates {
if !seen[file] {
seen[file] = true
_, err := os.Stat(file)
for _, candidate := range candidates {
if !seen[candidate] {
seen[candidate] = true
_, err := os.Stat(candidate)
if err == nil {
fileList = append(fileList, file)
// Filter candidates by the paths/patterns specified in config
if MatchPaths(candidate, config.Paths) {
fileList = append(fileList, candidate)
}
} else if !os.IsNotExist(err) {
return nil, err
}
Expand All @@ -150,6 +155,39 @@ func listFiles(config *ConfigHeader) ([]string, error) {
return fileList, nil
}

func MatchPaths(file string, patterns []string) bool {
for _, pattern := range patterns {
if pattern == "." {
pattern = currentDir
}
matched, err := doublestar.Match(pattern, file)
if err == nil && matched {
return true
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The matchPaths function doesn't handle directory patterns consistently with the existing tryMatchPatten function in config.go. When a user specifies a directory pattern like "pkg/header/", it should match files within that directory (e.g., "pkg/header/check.go"), but the current implementation using only doublestar.Match will return false.

The tryMatchPatten function (lines 132-153 in pkg/header/config.go) includes additional logic after doublestar.Match to handle directory patterns by checking if the file path has the pattern as a prefix. This same logic should be included in matchPaths to ensure consistent behavior between git and non-git repositories.

Without this, users who specify directory paths (with or without trailing slashes) will get different behavior in git vs non-git repositories.

Suggested change
}
}
// Fallback for directory patterns (e.g., "pkg/header/" or "pkg/header")
// to behave consistently with tryMatchPatten in config.go.
if err == nil {
dirPattern := pattern
// Ensure the pattern ends with a path separator so it clearly denotes a directory.
if !strings.HasSuffix(dirPattern, "/") && !strings.HasSuffix(dirPattern, string(os.PathSeparator)) {
dirPattern = dirPattern + "/"
}
// Normalize both paths to use '/' so prefix matching is consistent across platforms.
normalizedDir := strings.ReplaceAll(dirPattern, "\\", "/")
normalizedFile := strings.ReplaceAll(file, "\\", "/")
if strings.HasPrefix(normalizedFile, normalizedDir) {
return true
}
}

Copilot uses AI. Check for mistakes.
// Fallback for directory patterns (e.g., "pkg/header/" or "pkg/header")
// to behave consistently with tryMatchPatten in config.go.
// Ensure that pattern ends with a path separator so it clearly denotes a directory.
if strings.HasSuffix(pattern, "/") ||
strings.HasSuffix(pattern, string(os.PathSeparator)) {
dirPattern := strings.TrimRight(pattern, "/")
// Match if file's directory name equals pattern (exact directory match)
if stat, err := os.Stat(file); err == nil {
if stat.Name() == dirPattern {
return true
}
}
// Match if file is under the directory (prefix match)
// Normalize both paths to use '/' so prefix matching is consistent across platforms.
normalizedDir := strings.ReplaceAll(pattern, "\\", "/")
normalizedFile := strings.ReplaceAll(file, "\\", "/")
if strings.HasPrefix(normalizedFile, normalizedDir) {
return true
}
}
}
return false
}

func addIgnorePatterns(t *git.Worktree) {
if ignorePattens, err := gitignore.LoadGlobalPatterns(osfs.New("")); err == nil {
t.Excludes = append(t.Excludes, ignorePattens...)
Expand Down
73 changes: 73 additions & 0 deletions pkg/header/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,76 @@ func TestListFilesWithWorktreeDetachedHEAD(t *testing.T) {
t.Error("Expected to find files with valid commit")
}
}

func TestMatchPaths(t *testing.T) {
tests := []struct {
name string
file string
patterns []string
expected bool
}{
{
name: "Exact file match",
file: "test.go",
patterns: []string{"test.go"},
expected: true,
},
{
name: "Glob pattern match",
file: "test.go",
patterns: []string{"*.go"},
expected: true,
},
{
name: "Double-star glob pattern match",
file: "pkg/header/check.go",
patterns: []string{"**/*.go"},
expected: true,
},
{
name: "Multiple patterns with match",
file: "test.go",
patterns: []string{"*.java", "*.go", "*.py"},
expected: true,
},
{
name: "Multiple patterns without match",
file: "test.go",
patterns: []string{"*.java", "*.py"},
expected: false,
},
{
name: "Directory pattern with trailing slash",
file: "pkg/header/check.go",
patterns: []string{"pkg/header/"},
expected: true,
},
{
name: "Directory pattern without trailing slash",
file: "pkg/header/check.go",
patterns: []string{"pkg/header}"},
expected: false,
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

This test case expects that a directory pattern "pkg/header/" should NOT match the file "pkg/header/check.go". However, this is inconsistent with the existing tryMatchPatten function in pkg/header/config.go (lines 132-153), which includes logic to match files within a specified directory.

In the non-git code path, when a user specifies "pkg/header/" as a path, doublestar.Glob expands it to the directory, and walkFile recursively finds all files within it. For consistency, the git code path should behave the same way - files within "pkg/header/" should match the pattern "pkg/header/".

This test case should expect true, not false, and the matchPaths function should be updated to include directory prefix matching logic similar to tryMatchPatten.

Suggested change
expected: false,
expected: true,

Copilot uses AI. Check for mistakes.
},
{
name: "Dot pattern",
file: "test.go",
patterns: []string{"."},
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MatchPaths(tt.file, tt.patterns)
if result != tt.expected {
t.Errorf(
"MatchPaths(%q, %v) = %v, want %v",
tt.file,
tt.patterns,
result,
tt.expected,
)
}
})
}
}