add del command

add tests for all commands

wrap entry point with fang

add --version flag
target to Taskfile
This commit is contained in:
2026-03-29 21:15:56 +01:00
parent 8f6a5d4472
commit 8059255d52
16 changed files with 520 additions and 80 deletions

View File

@@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"io"
"slices"
"github.com/spf13/cobra"
)
@@ -15,27 +16,37 @@ var addCmd = &cobra.Command{
This is useful for excluding files or directories from version control without modifying the .gitignore file.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
f, ok := FileFromContext(cmd.Context())
ctx, ok := ContextObjectFromContext(cmd.Context())
if !ok {
return fmt.Errorf("no exclude file found in context")
}
return runAddCommand(f, args)
return runAddCommand(ctx.Out, ctx.File, args)
},
}
func init() {
rootCmd.AddCommand(addCmd)
RootCmd.AddCommand(addCmd)
}
func runAddCommand(f io.Writer, args []string) error {
for _, pattern := range args {
if _, err := fmt.Fprintln(f, pattern); err != nil {
return fmt.Errorf("error writing to exclude file: %w", err)
}
// runAddCommand adds the specified patterns to the exclude file, ensuring no duplicates
// It handles both file and in-memory buffer cases for testing
func runAddCommand(out io.Writer, f io.ReadWriter, args []string) error {
existingPatterns, err := readExistingPatterns(f)
if err != nil {
return fmt.Errorf("error reading existing patterns: %v", err)
}
fmt.Println("Patterns added to .git/info/exclude file:")
for _, pattern := range args {
fmt.Println(" -", pattern)
if slices.Contains(existingPatterns, pattern) {
fmt.Fprintf(out, "Pattern '%s' already exists in the exclude file. Skipping.\n", pattern)
continue
}
_, err := fmt.Fprintln(f, pattern)
if err != nil {
return fmt.Errorf("error writing to exclude file: %v", err)
}
fmt.Fprintf(out, "Added pattern '%s' to the exclude file.\n", pattern)
}
return nil
}

50
cmd/add_test.go Normal file
View File

@@ -0,0 +1,50 @@
package cmd
import (
"bytes"
"testing"
)
func TestRunAddCommand(t *testing.T) {
tests := []struct {
name string
existing string
args []string
expectedOutput string
}{
{
name: "Add new patterns",
existing: "",
args: []string{"*.log", "temp/"},
expectedOutput: "Added pattern '*.log' to the exclude file.\nAdded pattern 'temp/' to the exclude file.\n",
},
{
name: "Add duplicate patterns",
existing: "*.log\ntemp/\n",
args: []string{"*.log", "temp/"},
expectedOutput: "Pattern '*.log' already exists in the exclude file. Skipping.\nPattern 'temp/' already exists in the exclude file. Skipping.\n",
},
{
name: "Add mix of new and duplicate patterns",
existing: "*.log\n",
args: []string{"*.log", "temp/"},
expectedOutput: "Pattern '*.log' already exists in the exclude file. Skipping.\nAdded pattern 'temp/' to the exclude file.\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
f := bytes.NewBufferString(tt.existing)
err := runAddCommand(&buf, f, tt.args)
if err != nil {
t.Fatalf("runAddCommand returned an error: %v", err)
}
if buf.String() != tt.expectedOutput {
t.Errorf("Expected output:\n%s\nGot:\n%s", tt.expectedOutput, buf.String())
}
})
}
}

View File

@@ -2,20 +2,27 @@ package cmd
import (
"context"
"io"
"os"
)
type contextKey string
const fileContextKey contextKey = "excludeFile"
const contextObjectKey = contextKey("contextObject")
// ContextWithFile returns a new context with the given file set.
func ContextWithFile(ctx context.Context, file *os.File) context.Context {
return context.WithValue(ctx, fileContextKey, file)
type contextObject struct {
File *os.File
Out io.Writer
}
// FileFromContext retrieves the file from the context, if it exists.
func FileFromContext(ctx context.Context) (*os.File, bool) {
file, ok := ctx.Value(fileContextKey).(*os.File)
return file, ok
func createContext(file *os.File, out io.Writer) context.Context {
return context.WithValue(context.Background(), contextObjectKey, &contextObject{
File: file,
Out: out,
})
}
func ContextObjectFromContext(ctx context.Context) (*contextObject, bool) {
obj, ok := ctx.Value(contextObjectKey).(*contextObject)
return obj, ok
}

86
cmd/del.go Normal file
View File

@@ -0,0 +1,86 @@
package cmd
import (
"fmt"
"io"
"slices"
"github.com/spf13/cobra"
)
// delCmd represents the del command
var delCmd = &cobra.Command{
Use: "del",
Short: "Delete a pattern from the exclude file",
Long: `The del command removes a specified pattern from the .git/info/exclude file.
This is useful for un-excluding files or directories that were previously excluded.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, ok := ContextObjectFromContext(cmd.Context())
if !ok {
return fmt.Errorf("no exclude file found in context")
}
if len(args) == 0 {
return fmt.Errorf("no pattern provided to delete")
}
pattern := args[0]
return runDelCommand(ctx.Out, ctx.File, pattern)
},
}
func init() {
RootCmd.AddCommand(delCmd)
}
// runDelCommand deletes the specified pattern from the exclude file and writes the updated content back
// It handles both file and in-memory buffer cases for testing
func runDelCommand(out io.Writer, f any, pattern string) error {
r, ok := f.(io.Reader)
if !ok {
return fmt.Errorf("provided file does not support Reader")
}
existingPatterns, err := readExistingPatterns(r)
if err != nil {
return fmt.Errorf("error reading existing patterns: %v", err)
}
if !slices.Contains(existingPatterns, pattern) {
fmt.Fprintf(out, "Pattern '%s' not found in the exclude file. Nothing to delete.\n", pattern)
return nil
}
var updatedPatterns []string
for _, p := range existingPatterns {
if p != pattern {
updatedPatterns = append(updatedPatterns, p)
}
}
var w io.Writer
if t, ok := f.(truncater); ok {
if err := t.Truncate(0); err != nil {
return fmt.Errorf("error truncating exclude file: %w", err)
}
if s, ok := f.(io.Seeker); ok {
if _, err := s.Seek(0, 0); err != nil {
return fmt.Errorf("error seeking to the beginning of exclude file: %w", err)
}
}
w, _ = f.(io.Writer)
} else if buf, ok := f.(interface{ Reset() }); ok {
buf.Reset()
w, _ = f.(io.Writer)
} else {
return fmt.Errorf("provided file does not support writing")
}
if err := writeDefaultExcludeContent(w); err != nil {
return fmt.Errorf("error writing default exclude content: %w", err)
}
for _, p := range updatedPatterns {
if _, err := fmt.Fprintln(w, p); err != nil {
return fmt.Errorf("error writing updated patterns to exclude file: %v", err)
}
}
fmt.Fprintf(out, "Deleted pattern '%s' from the exclude file.\n", pattern)
return nil
}

49
cmd/del_test.go Normal file
View File

@@ -0,0 +1,49 @@
package cmd
import (
"bytes"
"testing"
)
func TestRunDelCommand(t *testing.T) {
tests := []struct {
name string
initialContent string
patternToDelete string
expectedOutput string
expectedContent string
}{
{
name: "Delete existing pattern",
initialContent: defaultExcludeFileContent + "node_modules\n.DS_Store\n",
patternToDelete: "node_modules",
expectedOutput: defaultExcludeFileContent + ".DS_Store\n" + "Deleted pattern 'node_modules' from the exclude file.\n",
},
{
name: "Delete non-existing pattern",
initialContent: defaultExcludeFileContent + "node_modules\n.DS_Store\n",
patternToDelete: "dist",
expectedOutput: "Pattern 'dist' not found in the exclude file. Nothing to delete.\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
buf.WriteString(tt.initialContent)
err := runDelCommand(&buf, &buf, tt.patternToDelete)
if err != nil {
t.Fatalf("runDelCommand returned an error: %v", err)
}
if buf.String() != tt.expectedOutput {
t.Errorf(
"Expected output and content:\n%s\nGot:\n%s",
tt.expectedOutput,
buf.String(),
)
}
})
}
}

View File

@@ -18,31 +18,36 @@ that are not empty and do not start with a comment (#).
This is useful for reviewing which files or directories are currently excluded from version control.`,
Args: cobra.NoArgs, // No arguments expected
RunE: func(cmd *cobra.Command, args []string) error {
f, ok := FileFromContext(cmd.Context())
ctx, ok := ContextObjectFromContext(cmd.Context())
if !ok {
return fmt.Errorf("no exclude file found in context")
}
return runListCommand(f, args)
return runListCommand(ctx.Out, ctx.File)
},
}
func init() {
rootCmd.AddCommand(listCmd)
RootCmd.AddCommand(listCmd)
}
// runListCommand is the function that will be executed when the list command is called
func runListCommand(f io.Reader, _ []string) error {
// Read from the exclude file line by line
func runListCommand(out io.Writer, f io.Reader) error {
var count int
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line != "" && !strings.HasPrefix(line, "#") {
fmt.Println(line) // Print each non-empty, non-comment line
fmt.Fprintln(out, line)
count++
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading exclude file: %w", err)
}
if count == 0 {
fmt.Fprintln(out, "No patterns found in the exclude file.")
}
return nil
}

46
cmd/list_test.go Normal file
View File

@@ -0,0 +1,46 @@
package cmd
import (
"bytes"
"testing"
)
func TestRunListCommand(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "Empty file",
input: defaultExcludeFileContent,
expected: "No patterns found in the exclude file.\n",
},
{
name: "Exclude file with patterns",
input: defaultExcludeFileContent + "node_modules/\nbuild/\n# This is a comment\n",
expected: "node_modules/\nbuild/\n",
},
{
name: "Exclude file with only comments and empty lines",
input: defaultExcludeFileContent + "# Comment 1\n# Comment 2\n\n",
expected: "No patterns found in the exclude file.\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := bytes.NewBufferString(tt.input)
var output bytes.Buffer
err := runListCommand(&output, reader)
if err != nil {
t.Fatalf("runListCommand returned an error: %v", err)
}
if output.String() != tt.expected {
t.Errorf("Expected output:\n%q\nGot:\n%q", tt.expected, output.String())
}
})
}
}

View File

@@ -3,7 +3,7 @@ package cmd
import (
_ "embed"
"fmt"
"os"
"io"
"github.com/spf13/cobra"
)
@@ -15,36 +15,42 @@ var resetCmd = &cobra.Command{
Long: `The reset command clears all patterns from the .git/info/exclude file.
This is useful for starting fresh or removing all exclusions at once.`,
RunE: func(cmd *cobra.Command, _ []string) error {
f, ok := FileFromContext(cmd.Context())
ctx, ok := ContextObjectFromContext(cmd.Context())
if !ok {
return fmt.Errorf("no exclude file found in context")
}
return runResetCommand(f)
return resetAndWriteExcludeFile(ctx.File)
},
}
func init() {
rootCmd.AddCommand(resetCmd)
RootCmd.AddCommand(resetCmd)
}
//go:embed template/exclude
var defaultExcludeFile string
// Truncate and seek to beginning
type truncater interface{ Truncate(size int64) error }
// runResetCommand clears the exclude file
func runResetCommand(f *os.File) error {
// Clear the exclude file by truncating it
if err := f.Truncate(0); err != nil {
// resetAndWriteExcludeFile truncates and resets the file, then writes the default content
func resetAndWriteExcludeFile(f any) error {
// Try to assert to io.ReadWriteSeeker for file operations
rws, ok := f.(io.ReadWriteSeeker)
if !ok {
// If not a file, try as io.Writer (for test buffers)
if w, ok := f.(io.Writer); ok {
return writeDefaultExcludeContent(w)
}
return fmt.Errorf("provided file does not support ReadWriteSeeker or Writer")
}
t, ok := f.(truncater)
if !ok {
return fmt.Errorf("provided file does not support Truncate")
}
if err := t.Truncate(0); err != nil {
return fmt.Errorf("error truncating exclude file: %w", err)
}
// Reset the file pointer to the beginning
if _, err := f.Seek(0, 0); err != nil {
if _, err := rws.Seek(0, 0); err != nil {
return fmt.Errorf("error seeking to the beginning of exclude file: %w", err)
}
// Write the default exclude patterns to the file
if _, err := f.WriteString(defaultExcludeFile); err != nil {
return fmt.Errorf("error writing default exclude file: %w", err)
}
return nil
return writeDefaultExcludeContent(rws)
}

28
cmd/reset_test.go Normal file
View File

@@ -0,0 +1,28 @@
package cmd
import (
"bytes"
"io"
"testing"
)
func TestRunResetCommand(t *testing.T) {
var buf bytes.Buffer
if err := resetAndWriteExcludeFile(&buf); err != nil {
t.Fatalf("resetAndWriteExcludeFile failed: %v", err)
}
resetContent, err := io.ReadAll(&buf)
if err != nil {
t.Fatalf("failed to read from temp file: %v", err)
}
if string(resetContent) != defaultExcludeFileContent {
t.Errorf(
"unexpected content after reset:\nGot:\n%s\nExpected:\n%s",
string(resetContent),
defaultExcludeFileContent,
)
}
}

View File

@@ -1,56 +1,58 @@
package cmd
import (
"context"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
const defaultExcludeFileContent = `# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~
`
// RootCmd represents the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "exclude",
Short: "A command line tool to manage .git/info/exclude files",
Long: `Exclude is a command line tool designed to help you manage your .git/info/exclude files.
It allows you to add, list, and remove patterns from the exclude file easily.
It allows you to add, list, and delete patterns from the exclude file easily.
This tool is particularly useful for developers who want to keep their repository clean
by excluding certain files or directories from version control.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// This function runs before any command is executed.
// You can use it to set up global configurations or checks.
// For example, you might want to check if the .git directory exists
if _, err := os.Stat(".git"); os.IsNotExist(err) {
cmd.Println("Error: This command must be run in a Git repository.")
os.Exit(1)
}
f, err := os.OpenFile(".git/info/exclude", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
path, err := cmd.Flags().GetString("path")
if err != nil {
cmd.Println("Error getting path flag:", err)
os.Exit(1)
}
f, err := os.OpenFile(filepath.Join(path, "exclude"), os.O_RDWR|os.O_APPEND, 0644)
if err != nil {
cmd.Println("Error opening .git/info/exclude file:", err)
os.Exit(1)
}
ctx := context.WithValue(context.Background(), fileContextKey, f)
ctx := createContext(f, cmd.OutOrStdout())
cmd.SetContext(ctx)
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
if f, ok := FileFromContext(cmd.Context()); ok {
defer f.Close()
if obj, ok := ContextObjectFromContext(cmd.Context()); ok {
defer obj.File.Close()
}
},
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
RootCmd.PersistentFlags().
StringP("path", "p", ".git/info/", "Path the exclude file resides in (default is .git/info/)")
}

View File

@@ -1,6 +0,0 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

32
cmd/util.go Normal file
View File

@@ -0,0 +1,32 @@
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
)
// readExistingPatterns reads the existing patterns from the exclude file, ignoring comments and empty lines
func readExistingPatterns(f io.Reader) ([]string, error) {
var patterns []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line != "" && !strings.HasPrefix(line, "#") {
patterns = append(patterns, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error scanning exclude file: %v", err)
}
return patterns, nil
}
// writeDefaultExcludeContent writes the default exclude content to the writer
func writeDefaultExcludeContent(w io.Writer) error {
if _, err := w.Write([]byte(defaultExcludeFileContent)); err != nil {
return fmt.Errorf("error writing default exclude file: %w", err)
}
return nil
}