diff --git a/ignorefile/ignorefile.go b/ignorefile/ignorefile.go new file mode 100644 index 0000000..94ea5a0 --- /dev/null +++ b/ignorefile/ignorefile.go @@ -0,0 +1,73 @@ +package ignorefile + +import ( + "bufio" + "bytes" + "io" + "path/filepath" + "strings" +) + +// ReadAll reads an ignore file from a reader and returns the list of file +// patterns to ignore, applying the following rules: +// +// - An UTF8 BOM header (if present) is stripped. +// - Lines starting with "#" are considered comments and are skipped. +// +// For remaining lines: +// +// - Leading and trailing whitespace is removed from each ignore pattern. +// - It uses [filepath.Clean] to get the shortest/cleanest path for +// ignore patterns. +// - Leading forward-slashes ("/") are removed from ignore patterns, +// so "/some/path" and "some/path" are considered equivalent. +func ReadAll(reader io.Reader) ([]string, error) { + if reader == nil { + return nil, nil + } + + var excludes []string + currentLine := 0 + utf8bom := []byte{0xEF, 0xBB, 0xBF} + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + scannedBytes := scanner.Bytes() + // We trim UTF8 BOM + if currentLine == 0 { + scannedBytes = bytes.TrimPrefix(scannedBytes, utf8bom) + } + pattern := string(scannedBytes) + currentLine++ + // Lines starting with # (comments) are ignored before processing + if strings.HasPrefix(pattern, "#") { + continue + } + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + // normalize absolute paths to paths relative to the context + // (taking care of '!' prefix) + invert := pattern[0] == '!' + if invert { + pattern = strings.TrimSpace(pattern[1:]) + } + if len(pattern) > 0 { + pattern = filepath.Clean(pattern) + pattern = filepath.ToSlash(pattern) + if len(pattern) > 1 && pattern[0] == '/' { + pattern = pattern[1:] + } + } + if invert { + pattern = "!" + pattern + } + + excludes = append(excludes, pattern) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return excludes, nil +} diff --git a/ignorefile/ignorefile_test.go b/ignorefile/ignorefile_test.go new file mode 100644 index 0000000..c35291d --- /dev/null +++ b/ignorefile/ignorefile_test.go @@ -0,0 +1,54 @@ +package ignorefile + +import ( + "strings" + "testing" +) + +func TestReadAll(t *testing.T) { + actual, err := ReadAll(nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if entries := len(actual); entries != 0 { + t.Fatalf("Expected to have zero entries, got %d", entries) + } + + const content = `test1 +/test2 +/a/file/here + +lastfile +# this is a comment +! /inverted/abs/path +! +! ` + + expected := []string{ + "test1", + "test2", // according to https://docs.docker.com/engine/reference/builder/#dockerignore-file, /foo/bar should be treated as foo/bar + "a/file/here", // according to https://docs.docker.com/engine/reference/builder/#dockerignore-file, /foo/bar should be treated as foo/bar + "lastfile", + "!inverted/abs/path", + "!", + "!", + } + + actual, err = ReadAll(strings.NewReader(content)) + if err != nil { + t.Error(err) + } + + if len(actual) != len(expected) { + t.Errorf("Expected %d entries, got %v", len(expected), len(actual)) + } + for i, expectedLine := range expected { + if i >= len(actual) { + t.Errorf(`missing line %d: expected: "%s", got none`, i+1, expectedLine) + continue + } + if actual[i] != expectedLine { + t.Errorf(`line %d: expected: "%s", got: "%s"`, i+1, expectedLine, actual[i]) + } + } +}