1
0
Fork 0

Apply standard Go formatting and update project documentation

- Run go fmt on all Go files to ensure consistent formatting
- Add official Go tooling commands to CLAUDE.md for code quality
- Update project status to reflect current implementation state
This commit is contained in:
Andrew Tomaka 2025-06-12 22:01:45 -04:00
parent 1265f9fb07
commit bde7aeed90
Signed by: atomaka
GPG key ID: 61209BF70A5B18BE
7 changed files with 104 additions and 72 deletions

32
CLAUDE.md Normal file
View file

@ -0,0 +1,32 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a CLI tool called "collect" that recursively collects files matching specific criteria, maintains their file structure, and archives them for backup purposes.
## Project Status
The collect CLI tool has been implemented with the following features:
- Multiple `--name` flags for exact filename matching
- Multiple `--match` flags for directory pattern matching
- Support for combining `--name` and `--match` flags with OR logic
- Preserving original directory structure in the output
- Creating archives in tar.gz or zip format
## Implementation Notes
The tool accepts:
1. Multiple `--name` and `--match` flags in any combination
2. Two positional arguments: source directory and output archive path
3. Archive format determined by output file extension (.tgz/.tar.gz or .zip)
4. Recursive directory traversal while preserving original structure
## Code Quality
Before committing changes, run these official Go tools:
- `go fmt ./...` - Format all Go files to standard style
- `go vet ./...` - Check for common mistakes and suspicious code
- `go test ./...` - Run unit tests (when available)
- `./test.sh` - Run integration tests

View file

@ -5,4 +5,4 @@ import "github.com/atomaka/collect/collector"
// Archiver defines the interface for creating archives // Archiver defines the interface for creating archives
type Archiver interface { type Archiver interface {
Create(outputPath string, files []collector.FileEntry) error Create(outputPath string, files []collector.FileEntry) error
} }

View file

@ -7,7 +7,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"github.com/atomaka/collect/collector" "github.com/atomaka/collect/collector"
) )
@ -27,22 +27,22 @@ func (a *TarArchiver) Create(outputPath string, files []collector.FileEntry) err
return fmt.Errorf("failed to create output file: %w", err) return fmt.Errorf("failed to create output file: %w", err)
} }
defer outFile.Close() defer outFile.Close()
// Create gzip writer // Create gzip writer
gzipWriter := gzip.NewWriter(outFile) gzipWriter := gzip.NewWriter(outFile)
defer gzipWriter.Close() defer gzipWriter.Close()
// Create tar writer // Create tar writer
tarWriter := tar.NewWriter(gzipWriter) tarWriter := tar.NewWriter(gzipWriter)
defer tarWriter.Close() defer tarWriter.Close()
// Add each file to the archive // Add each file to the archive
for _, file := range files { for _, file := range files {
if err := a.addFileToTar(tarWriter, file); err != nil { if err := a.addFileToTar(tarWriter, file); err != nil {
return fmt.Errorf("failed to add file %s: %w", file.Path, err) return fmt.Errorf("failed to add file %s: %w", file.Path, err)
} }
} }
return nil return nil
} }
@ -59,28 +59,28 @@ func (a *TarArchiver) addFileToTar(tw *tar.Writer, file collector.FileEntry) err
return err return err
} }
defer f.Close() defer f.Close()
// Get file info // Get file info
info, err := f.Stat() info, err := f.Stat()
if err != nil { if err != nil {
return err return err
} }
// Create tar header // Create tar header
header, err := tar.FileInfoHeader(info, "") header, err := tar.FileInfoHeader(info, "")
if err != nil { if err != nil {
return err return err
} }
// Use the relative path in the archive // Use the relative path in the archive
header.Name = filepath.ToSlash(file.Path) header.Name = filepath.ToSlash(file.Path)
// Write header // Write header
if err := tw.WriteHeader(header); err != nil { if err := tw.WriteHeader(header); err != nil {
return err return err
} }
// Copy file contents // Copy file contents
_, err = io.Copy(tw, f) _, err = io.Copy(tw, f)
return err return err
} }

View file

@ -6,7 +6,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"github.com/atomaka/collect/collector" "github.com/atomaka/collect/collector"
) )
@ -26,18 +26,18 @@ func (a *ZipArchiver) Create(outputPath string, files []collector.FileEntry) err
return fmt.Errorf("failed to create output file: %w", err) return fmt.Errorf("failed to create output file: %w", err)
} }
defer outFile.Close() defer outFile.Close()
// Create zip writer // Create zip writer
zipWriter := zip.NewWriter(outFile) zipWriter := zip.NewWriter(outFile)
defer zipWriter.Close() defer zipWriter.Close()
// Add each file to the archive // Add each file to the archive
for _, file := range files { for _, file := range files {
if err := a.addFileToZip(zipWriter, file); err != nil { if err := a.addFileToZip(zipWriter, file); err != nil {
return fmt.Errorf("failed to add file %s: %w", file.Path, err) return fmt.Errorf("failed to add file %s: %w", file.Path, err)
} }
} }
return nil return nil
} }
@ -54,32 +54,32 @@ func (a *ZipArchiver) addFileToZip(zw *zip.Writer, file collector.FileEntry) err
return err return err
} }
defer f.Close() defer f.Close()
// Get file info // Get file info
info, err := f.Stat() info, err := f.Stat()
if err != nil { if err != nil {
return err return err
} }
// Create zip file header // Create zip file header
header, err := zip.FileInfoHeader(info) header, err := zip.FileInfoHeader(info)
if err != nil { if err != nil {
return err return err
} }
// Use the relative path in the archive // Use the relative path in the archive
header.Name = filepath.ToSlash(file.Path) header.Name = filepath.ToSlash(file.Path)
// Set compression method // Set compression method
header.Method = zip.Deflate header.Method = zip.Deflate
// Create writer for this file // Create writer for this file
writer, err := zw.CreateHeader(header) writer, err := zw.CreateHeader(header)
if err != nil { if err != nil {
return err return err
} }
// Copy file contents // Copy file contents
_, err = io.Copy(writer, f) _, err = io.Copy(writer, f)
return err return err
} }

View file

@ -33,7 +33,7 @@ func (c *Collector) Collect(sourceDir string) ([]FileEntry, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err) return nil, fmt.Errorf("failed to get absolute path: %w", err)
} }
// Check if source directory exists // Check if source directory exists
info, err := os.Stat(absSourceDir) info, err := os.Stat(absSourceDir)
if err != nil { if err != nil {
@ -42,9 +42,9 @@ func (c *Collector) Collect(sourceDir string) ([]FileEntry, error) {
if !info.IsDir() { if !info.IsDir() {
return nil, fmt.Errorf("source path is not a directory: %s", sourceDir) return nil, fmt.Errorf("source path is not a directory: %s", sourceDir)
} }
var files []FileEntry var files []FileEntry
err = filepath.WalkDir(absSourceDir, func(path string, d fs.DirEntry, err error) error { err = filepath.WalkDir(absSourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
// Log permission errors but continue walking // Log permission errors but continue walking
@ -54,7 +54,7 @@ func (c *Collector) Collect(sourceDir string) ([]FileEntry, error) {
} }
return err return err
} }
// Get file info // Get file info
info, err := d.Info() info, err := d.Info()
if err != nil { if err != nil {
@ -64,12 +64,12 @@ func (c *Collector) Collect(sourceDir string) ([]FileEntry, error) {
} }
return err return err
} }
// Skip symlinks // Skip symlinks
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
return nil return nil
} }
// Check if this file should be included // Check if this file should be included
if c.matcher.ShouldInclude(path, info) { if c.matcher.ShouldInclude(path, info) {
// Calculate relative path from source directory // Calculate relative path from source directory
@ -77,42 +77,42 @@ func (c *Collector) Collect(sourceDir string) ([]FileEntry, error) {
if err != nil { if err != nil {
return fmt.Errorf("failed to get relative path: %w", err) return fmt.Errorf("failed to get relative path: %w", err)
} }
// Clean the relative path to ensure consistent formatting // Clean the relative path to ensure consistent formatting
relPath = filepath.ToSlash(relPath) relPath = filepath.ToSlash(relPath)
files = append(files, FileEntry{ files = append(files, FileEntry{
Path: relPath, Path: relPath,
FullPath: path, FullPath: path,
}) })
} }
return nil return nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("walk error: %w", err) return nil, fmt.Errorf("walk error: %w", err)
} }
// Check if any files were found // Check if any files were found
if len(files) == 0 { if len(files) == 0 {
return nil, fmt.Errorf("no files found matching criteria") return nil, fmt.Errorf("no files found matching criteria")
} }
return files, nil return files, nil
} }
// GetArchiveFormat determines the archive format from the filename // GetArchiveFormat determines the archive format from the filename
func GetArchiveFormat(filename string) string { func GetArchiveFormat(filename string) string {
lower := strings.ToLower(filename) lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") { if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return "tar.gz" return "tar.gz"
} }
if strings.HasSuffix(lower, ".zip") { if strings.HasSuffix(lower, ".zip") {
return "zip" return "zip"
} }
return "" return ""
} }

View file

@ -40,7 +40,7 @@ type PatternMatcher struct {
func NewPatternMatcher(pattern string) *PatternMatcher { func NewPatternMatcher(pattern string) *PatternMatcher {
// Remove trailing slash if present // Remove trailing slash if present
pattern = strings.TrimSuffix(pattern, "/") pattern = strings.TrimSuffix(pattern, "/")
return &PatternMatcher{ return &PatternMatcher{
pattern: pattern, pattern: pattern,
matchedDirs: make(map[string]bool), matchedDirs: make(map[string]bool),
@ -58,27 +58,27 @@ func (m *PatternMatcher) ShouldInclude(path string, info os.FileInfo) bool {
} }
return false // Don't include the directory itself, only files within return false // Don't include the directory itself, only files within
} }
// For files, check if any parent directory is in the matched set // For files, check if any parent directory is in the matched set
dir := filepath.Dir(path) dir := filepath.Dir(path)
for { for {
if m.matchedDirs[dir] { if m.matchedDirs[dir] {
return true return true
} }
// Also check if this directory matches the pattern (in case we haven't seen it yet) // Also check if this directory matches the pattern (in case we haven't seen it yet)
if matched, err := m.dirMatchesPattern(dir); err == nil && matched { if matched, err := m.dirMatchesPattern(dir); err == nil && matched {
m.matchedDirs[dir] = true m.matchedDirs[dir] = true
return true return true
} }
parent := filepath.Dir(dir) parent := filepath.Dir(dir)
if parent == dir || parent == "." { if parent == dir || parent == "." {
break break
} }
dir = parent dir = parent
} }
return false return false
} }
@ -86,26 +86,26 @@ func (m *PatternMatcher) ShouldInclude(path string, info os.FileInfo) bool {
func (m *PatternMatcher) dirMatchesPattern(dirPath string) (bool, error) { func (m *PatternMatcher) dirMatchesPattern(dirPath string) (bool, error) {
// Get the directory name // Get the directory name
dirName := filepath.Base(dirPath) dirName := filepath.Base(dirPath)
// For simple patterns (no path separators), just match the directory name // For simple patterns (no path separators), just match the directory name
if len(m.patternSegments) == 1 { if len(m.patternSegments) == 1 {
return filepath.Match(m.pattern, dirName) return filepath.Match(m.pattern, dirName)
} }
// For complex patterns, we need to match the full path segments // For complex patterns, we need to match the full path segments
pathSegments := strings.Split(dirPath, string(os.PathSeparator)) pathSegments := strings.Split(dirPath, string(os.PathSeparator))
// Try to match the pattern segments against the path segments // Try to match the pattern segments against the path segments
if len(pathSegments) < len(m.patternSegments) { if len(pathSegments) < len(m.patternSegments) {
return false, nil return false, nil
} }
// Check each pattern segment against the corresponding path segment // Check each pattern segment against the corresponding path segment
for i := 0; i < len(m.patternSegments); i++ { for i := 0; i < len(m.patternSegments); i++ {
// Start from the end of both slices // Start from the end of both slices
patternIdx := len(m.patternSegments) - 1 - i patternIdx := len(m.patternSegments) - 1 - i
pathIdx := len(pathSegments) - 1 - i pathIdx := len(pathSegments) - 1 - i
matched, err := filepath.Match(m.patternSegments[patternIdx], pathSegments[pathIdx]) matched, err := filepath.Match(m.patternSegments[patternIdx], pathSegments[pathIdx])
if err != nil { if err != nil {
return false, err return false, err
@ -114,7 +114,7 @@ func (m *PatternMatcher) dirMatchesPattern(dirPath string) (bool, error) {
return false, nil return false, nil
} }
} }
return true, nil return true, nil
} }
@ -136,4 +136,4 @@ func (m *CompositeMatcher) ShouldInclude(path string, info os.FileInfo) bool {
} }
} }
return false return false
} }

42
main.go
View file

@ -6,16 +6,16 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/atomaka/collect/archiver" "github.com/atomaka/collect/archiver"
"github.com/atomaka/collect/collector" "github.com/atomaka/collect/collector"
) )
const ( const (
exitSuccess = 0 exitSuccess = 0
exitNoFiles = 1 exitNoFiles = 1
exitArchiveError = 2 exitArchiveError = 2
exitInvalidArgs = 3 exitInvalidArgs = 3
) )
// stringSlice is a custom flag type that accumulates string values // stringSlice is a custom flag type that accumulates string values
@ -34,10 +34,10 @@ func main() {
// Define flags using custom type for multiple values // Define flags using custom type for multiple values
var nameFlags stringSlice var nameFlags stringSlice
var matchFlags stringSlice var matchFlags stringSlice
flag.Var(&nameFlags, "name", "Match exact filename (can be specified multiple times)") flag.Var(&nameFlags, "name", "Match exact filename (can be specified multiple times)")
flag.Var(&matchFlags, "match", "Match directory pattern (can be specified multiple times)") flag.Var(&matchFlags, "match", "Match directory pattern (can be specified multiple times)")
// Custom usage message // Custom usage message
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [--name <filename>]... [--match <pattern>]... <source-dir> <output-archive>\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Usage: %s [--name <filename>]... [--match <pattern>]... <source-dir> <output-archive>\n\n", os.Args[0])
@ -49,16 +49,16 @@ func main() {
fmt.Fprintf(os.Stderr, " %s --match 'aet-*/' ./ backup.zip\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s --match 'aet-*/' ./ backup.zip\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --name .mise.toml --name README.md --match 'test-*' ./ backup.tgz\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s --name .mise.toml --name README.md --match 'test-*' ./ backup.tgz\n", os.Args[0])
} }
flag.Parse() flag.Parse()
// Validate flags // Validate flags
if len(nameFlags) == 0 && len(matchFlags) == 0 { if len(nameFlags) == 0 && len(matchFlags) == 0 {
fmt.Fprintf(os.Stderr, "Error: At least one --name or --match must be specified\n\n") fmt.Fprintf(os.Stderr, "Error: At least one --name or --match must be specified\n\n")
flag.Usage() flag.Usage()
os.Exit(exitInvalidArgs) os.Exit(exitInvalidArgs)
} }
// Check positional arguments // Check positional arguments
args := flag.Args() args := flag.Args()
if len(args) != 2 { if len(args) != 2 {
@ -66,30 +66,30 @@ func main() {
flag.Usage() flag.Usage()
os.Exit(exitInvalidArgs) os.Exit(exitInvalidArgs)
} }
sourceDir := args[0] sourceDir := args[0]
outputPath := args[1] outputPath := args[1]
// Determine archive format // Determine archive format
format := collector.GetArchiveFormat(outputPath) format := collector.GetArchiveFormat(outputPath)
if format == "" { if format == "" {
fmt.Fprintf(os.Stderr, "Error: Unsupported archive format. Use .tar.gz, .tgz, or .zip\n") fmt.Fprintf(os.Stderr, "Error: Unsupported archive format. Use .tar.gz, .tgz, or .zip\n")
os.Exit(exitInvalidArgs) os.Exit(exitInvalidArgs)
} }
// Create matchers // Create matchers
var matchers []collector.Matcher var matchers []collector.Matcher
// Add name matchers // Add name matchers
for _, name := range nameFlags { for _, name := range nameFlags {
matchers = append(matchers, collector.NewNameMatcher(name)) matchers = append(matchers, collector.NewNameMatcher(name))
} }
// Add pattern matchers // Add pattern matchers
for _, pattern := range matchFlags { for _, pattern := range matchFlags {
matchers = append(matchers, collector.NewPatternMatcher(pattern)) matchers = append(matchers, collector.NewPatternMatcher(pattern))
} }
// Create a composite matcher if we have multiple matchers, otherwise use the single one // Create a composite matcher if we have multiple matchers, otherwise use the single one
var matcher collector.Matcher var matcher collector.Matcher
if len(matchers) == 1 { if len(matchers) == 1 {
@ -97,7 +97,7 @@ func main() {
} else { } else {
matcher = collector.NewCompositeMatcher(matchers) matcher = collector.NewCompositeMatcher(matchers)
} }
// Create collector and collect files // Create collector and collect files
c := collector.New(matcher) c := collector.New(matcher)
files, err := c.Collect(sourceDir) files, err := c.Collect(sourceDir)
@ -108,10 +108,10 @@ func main() {
} }
os.Exit(exitArchiveError) os.Exit(exitArchiveError)
} }
// Report number of files found // Report number of files found
fmt.Printf("Found %d files to archive\n", len(files)) fmt.Printf("Found %d files to archive\n", len(files))
// Create appropriate archiver // Create appropriate archiver
var arch archiver.Archiver var arch archiver.Archiver
switch format { switch format {
@ -120,14 +120,14 @@ func main() {
case "zip": case "zip":
arch = archiver.NewZipArchiver() arch = archiver.NewZipArchiver()
} }
// Create archive // Create archive
if err := arch.Create(outputPath, files); err != nil { if err := arch.Create(outputPath, files); err != nil {
fmt.Fprintf(os.Stderr, "Error creating archive: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating archive: %v\n", err)
os.Exit(exitArchiveError) os.Exit(exitArchiveError)
} }
// Get absolute path for cleaner output // Get absolute path for cleaner output
absOutput, _ := filepath.Abs(outputPath) absOutput, _ := filepath.Abs(outputPath)
fmt.Printf("Archive created successfully: %s\n", absOutput) fmt.Printf("Archive created successfully: %s\n", absOutput)
} }