diff options
| author | 2026-02-20 19:06:13 +0800 | |
|---|---|---|
| committer | 2026-02-20 19:07:14 +0800 | |
| commit | aa513c069c1418734aea894dc944e27c6a78a3bb (patch) | |
| tree | 687f0a11bb550fa088fd82a98ceb8979bbc35f69 /config | |
| parent | Comment on prior reverts removing the pack writing API (diff) | |
| signature | No signature | |
Delete everything, I'm redesigning this.
I'll stop using a flat package and make things much more modular.
And also experiment with streaming APIs so large blobs don't OOM us.
Diffstat (limited to 'config')
| -rw-r--r-- | config/config.go | 498 | ||||
| -rw-r--r-- | config/config_test.go | 323 |
2 files changed, 0 insertions, 821 deletions
diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 1344c890..00000000 --- a/config/config.go +++ /dev/null @@ -1,498 +0,0 @@ -// Package config provides routines to parse Git configuration files. -package config - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io" - "strings" - "unicode" -) - -// Config holds all parsed configuration entries from a Git config file. -// -// A Config preserves the ordering of entries as they appeared in the source. -// -// Lookups are matched case-insensitively for section and key names, and -// subsections must match exactly. -// -// Includes aren't supported yet; they will be supported in a later revision. -type Config struct { - entries []ConfigEntry -} - -// ConfigEntry represents a single parsed configuration directive. -type ConfigEntry struct { - // The section name in canonical lowercase form. - Section string - // The subsection name, retaining the exact form parsed from the input. - Subsection string - // The key name in canonical lowercase form. - Key string - // The interpreted value of the configuration entry, including unescaped - // characters where appropriate. - Value string -} - -// ParseConfig reads and parses Git configuration entries from r. -func ParseConfig(r io.Reader) (*Config, error) { - parser := &configParser{ - reader: bufio.NewReader(r), - lineNum: 1, - } - return parser.parse() -} - -// Get retrieves the first value for a given section, optional subsection, and key. -// Returns an empty string if not found. -func (c *Config) Get(section, subsection, key string) string { - section = strings.ToLower(section) - key = strings.ToLower(key) - for _, entry := range c.entries { - if strings.EqualFold(entry.Section, section) && - entry.Subsection == subsection && - strings.EqualFold(entry.Key, key) { - return entry.Value - } - } - return "" -} - -// GetAll retrieves all values for a given section, optional subsection, and key. -func (c *Config) GetAll(section, subsection, key string) []string { - section = strings.ToLower(section) - key = strings.ToLower(key) - var values []string - for _, entry := range c.entries { - if strings.EqualFold(entry.Section, section) && - entry.Subsection == subsection && - strings.EqualFold(entry.Key, key) { - values = append(values, entry.Value) - } - } - return values -} - -// Entries returns a copy of all parsed configuration entries in the order they -// appeared. Modifying the returned slice does not affect the Config. -func (c *Config) Entries() []ConfigEntry { - result := make([]ConfigEntry, len(c.entries)) - copy(result, c.entries) - return result -} - -type configParser struct { - reader *bufio.Reader - lineNum int - currentSection string - currentSubsec string - peeked rune - hasPeeked bool -} - -func (p *configParser) parse() (*Config, error) { - cfg := &Config{} - - if err := p.skipBOM(); err != nil { - return nil, err - } - - for { - ch, err := p.nextChar() - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - - // Skip whitespace and newlines - if ch == '\n' || unicode.IsSpace(ch) { - continue - } - - // Comments - if ch == '#' || ch == ';' { - if err := p.skipToEOL(); err != nil && err != io.EOF { - return nil, err - } - continue - } - - // Section header - if ch == '[' { - if err := p.parseSection(); err != nil { - return nil, fmt.Errorf("furgit: config: line %d: %w", p.lineNum, err) - } - continue - } - - // Key-value pair - if unicode.IsLetter(ch) { - p.unreadChar(ch) - if err := p.parseKeyValue(cfg); err != nil { - return nil, fmt.Errorf("furgit: config: line %d: %w", p.lineNum, err) - } - continue - } - - return nil, fmt.Errorf("furgit: config: line %d: unexpected character %q", p.lineNum, ch) - } - - return cfg, nil -} - -func (p *configParser) nextChar() (rune, error) { - if p.hasPeeked { - p.hasPeeked = false - return p.peeked, nil - } - - ch, _, err := p.reader.ReadRune() - if err != nil { - return 0, err - } - - if ch == '\r' { - next, _, err := p.reader.ReadRune() - if err == nil && next == '\n' { - ch = '\n' - } else if err == nil { - // Weird but ok - _ = p.reader.UnreadRune() - } - } - - if ch == '\n' { - p.lineNum++ - } - - return ch, nil -} - -func (p *configParser) unreadChar(ch rune) { - p.peeked = ch - p.hasPeeked = true - if ch == '\n' && p.lineNum > 1 { - p.lineNum-- - } -} - -func (p *configParser) skipBOM() error { - first, _, err := p.reader.ReadRune() - if err == io.EOF { - return nil - } - if err != nil { - return err - } - if first != '\uFEFF' { - _ = p.reader.UnreadRune() - } - return nil -} - -func (p *configParser) skipToEOL() error { - for { - ch, err := p.nextChar() - if err != nil { - return err - } - if ch == '\n' { - return nil - } - } -} - -func (p *configParser) parseSection() error { - var name bytes.Buffer - - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF in section header") - } - - if ch == ']' { - section := name.String() - if !isValidSection(section) { - return fmt.Errorf("invalid section name: %q", section) - } - p.currentSection = strings.ToLower(section) - p.currentSubsec = "" - return nil - } - - if unicode.IsSpace(ch) { - return p.parseExtendedSection(&name) - } - - if !isKeyChar(ch) && ch != '.' { - return fmt.Errorf("invalid character in section name: %q", ch) - } - - name.WriteRune(unicode.ToLower(ch)) - } -} - -func (p *configParser) parseExtendedSection(sectionName *bytes.Buffer) error { - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF in section header") - } - if !unicode.IsSpace(ch) { - if ch != '"' { - return errors.New("expected quote after section name") - } - break - } - } - - var subsec bytes.Buffer - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF in subsection") - } - - if ch == '\n' { - return errors.New("newline in subsection") - } - - if ch == '"' { - break - } - - if ch == '\\' { - next, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF after backslash in subsection") - } - if next == '\n' { - return errors.New("newline after backslash in subsection") - } - subsec.WriteRune(next) - } else { - subsec.WriteRune(ch) - } - } - - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF after subsection") - } - if ch != ']' { - return fmt.Errorf("expected ']' after subsection, got %q", ch) - } - - section := sectionName.String() - if !isValidSection(section) { - return fmt.Errorf("invalid section name: %q", section) - } - - p.currentSection = strings.ToLower(section) - p.currentSubsec = subsec.String() - return nil -} - -func (p *configParser) parseKeyValue(cfg *Config) error { - if p.currentSection == "" { - return errors.New("key-value pair before any section header") - } - - var key bytes.Buffer - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF reading key") - } - - if ch == '=' || ch == '\n' || unicode.IsSpace(ch) { - p.unreadChar(ch) - break - } - - if !isKeyChar(ch) { - return fmt.Errorf("invalid character in key: %q", ch) - } - - key.WriteRune(unicode.ToLower(ch)) - } - - keyStr := key.String() - if len(keyStr) == 0 { - return errors.New("empty key name") - } - if !unicode.IsLetter(rune(keyStr[0])) { - return errors.New("key must start with a letter") - } - - for { - ch, err := p.nextChar() - if err == io.EOF { - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Value: "true", - }) - return nil - } - if err != nil { - return err - } - - if ch == '\n' { - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Value: "true", - }) - return nil - } - - if ch == '#' || ch == ';' { - if err := p.skipToEOL(); err != nil && err != io.EOF { - return err - } - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Value: "true", - }) - return nil - } - - if ch == '=' { - break - } - - if !unicode.IsSpace(ch) { - return fmt.Errorf("unexpected character after key: %q", ch) - } - } - - value, err := p.parseValue() - if err != nil { - return err - } - - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Value: value, - }) - - return nil -} - -func (p *configParser) parseValue() (string, error) { - var value bytes.Buffer - var inQuote bool - var inComment bool - trimLen := 0 - - for { - ch, err := p.nextChar() - if err == io.EOF { - if inQuote { - return "", errors.New("unexpected EOF in quoted value") - } - if trimLen > 0 { - return value.String()[:trimLen], nil - } - return value.String(), nil - } - if err != nil { - return "", err - } - - if ch == '\n' { - if inQuote { - return "", errors.New("newline in quoted value") - } - if trimLen > 0 { - return value.String()[:trimLen], nil - } - return value.String(), nil - } - - if inComment { - continue - } - - if unicode.IsSpace(ch) && !inQuote { - if trimLen == 0 && value.Len() > 0 { - trimLen = value.Len() - } - if value.Len() > 0 { - value.WriteRune(ch) - } - continue - } - - if !inQuote && (ch == '#' || ch == ';') { - inComment = true - continue - } - - if trimLen > 0 { - trimLen = 0 - } - - if ch == '\\' { - next, err := p.nextChar() - if err == io.EOF { - return "", errors.New("unexpected EOF after backslash") - } - if err != nil { - return "", err - } - - switch next { - case '\n': - continue - case 'n': - value.WriteRune('\n') - case 't': - value.WriteRune('\t') - case 'b': - value.WriteRune('\b') - case '\\', '"': - value.WriteRune(next) - default: - return "", fmt.Errorf("invalid escape sequence: \\%c", next) - } - continue - } - - if ch == '"' { - inQuote = !inQuote - continue - } - - value.WriteRune(ch) - } -} - -func isValidSection(s string) bool { - if len(s) == 0 { - return false - } - for _, ch := range s { - if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) && ch != '-' && ch != '.' { - return false - } - } - return true -} - -func isKeyChar(ch rune) bool { - return unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '-' -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 4296535f..00000000 --- a/config/config_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package config - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func setupTestRepo(t *testing.T) (string, func()) { - t.Helper() - tempDir, err := os.MkdirTemp("", "furgit-config-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - cleanup := func() { - _ = os.RemoveAll(tempDir) - } - - cmd := exec.Command("git", "init", "--object-format=sha256", "--bare", tempDir) - cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") - if output, err := cmd.CombinedOutput(); err != nil { - cleanup() - t.Fatalf("failed to init git repo: %v\n%s", err, output) - } - - return tempDir, cleanup -} - -func gitConfig(t *testing.T, dir string, args ...string) { - t.Helper() - fullArgs := append([]string{"config"}, args...) - cmd := exec.Command("git", fullArgs...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git config %v failed: %v\n%s", args, err, output) - } -} - -func gitConfigGet(t *testing.T, dir, key string) string { - t.Helper() - cmd := exec.Command("git", "config", "--get", key) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") - output, err := cmd.CombinedOutput() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} - -func TestConfigAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "core.bare", "true") - gitConfig(t, repoPath, "core.filemode", "false") - gitConfig(t, repoPath, "user.name", "John Doe") - gitConfig(t, repoPath, "user.email", "john@example.com") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - if got := cfg.Get("core", "", "bare"); got != "true" { - t.Errorf("core.bare: got %q, want %q", got, "true") - } - if got := cfg.Get("core", "", "filemode"); got != "false" { - t.Errorf("core.filemode: got %q, want %q", got, "false") - } - if got := cfg.Get("user", "", "name"); got != "John Doe" { - t.Errorf("user.name: got %q, want %q", got, "John Doe") - } - if got := cfg.Get("user", "", "email"); got != "john@example.com" { - t.Errorf("user.email: got %q, want %q", got, "john@example.com") - } -} - -func TestConfigSubsectionAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "remote.origin.url", "https://example.com/repo.git") - gitConfig(t, repoPath, "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - if got := cfg.Get("remote", "origin", "url"); got != "https://example.com/repo.git" { - t.Errorf("remote.origin.url: got %q, want %q", got, "https://example.com/repo.git") - } - if got := cfg.Get("remote", "origin", "fetch"); got != "+refs/heads/*:refs/remotes/origin/*" { - t.Errorf("remote.origin.fetch: got %q, want %q", got, "+refs/heads/*:refs/remotes/origin/*") - } -} - -func TestConfigMultiValueAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "--add", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main") - gitConfig(t, repoPath, "--add", "remote.origin.fetch", "+refs/heads/dev:refs/remotes/origin/dev") - gitConfig(t, repoPath, "--add", "remote.origin.fetch", "+refs/tags/*:refs/tags/*") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - fetches := cfg.GetAll("remote", "origin", "fetch") - if len(fetches) != 3 { - t.Fatalf("expected 3 fetch values, got %d", len(fetches)) - } - - expected := []string{ - "+refs/heads/main:refs/remotes/origin/main", - "+refs/heads/dev:refs/remotes/origin/dev", - "+refs/tags/*:refs/tags/*", - } - for i, want := range expected { - if fetches[i] != want { - t.Errorf("fetch[%d]: got %q, want %q", i, fetches[i], want) - } - } -} - -func TestConfigCaseInsensitiveAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "Core.Bare", "true") - gitConfig(t, repoPath, "CORE.FileMode", "false") - - gitVerifyBare := gitConfigGet(t, repoPath, "core.bare") - gitVerifyFilemode := gitConfigGet(t, repoPath, "core.filemode") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - if got := cfg.Get("core", "", "bare"); got != gitVerifyBare { - t.Errorf("core.bare: got %q, want %q (from git)", got, gitVerifyBare) - } - if got := cfg.Get("CORE", "", "BARE"); got != gitVerifyBare { - t.Errorf("CORE.BARE: got %q, want %q (from git)", got, gitVerifyBare) - } - if got := cfg.Get("core", "", "filemode"); got != gitVerifyFilemode { - t.Errorf("core.filemode: got %q, want %q (from git)", got, gitVerifyFilemode) - } -} - -func TestConfigBooleanAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "test.flag1", "true") - gitConfig(t, repoPath, "test.flag2", "false") - gitConfig(t, repoPath, "test.flag3", "yes") - gitConfig(t, repoPath, "test.flag4", "no") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - tests := []struct { - key string - want string - }{ - {"flag1", gitConfigGet(t, repoPath, "test.flag1")}, - {"flag2", gitConfigGet(t, repoPath, "test.flag2")}, - {"flag3", gitConfigGet(t, repoPath, "test.flag3")}, - {"flag4", gitConfigGet(t, repoPath, "test.flag4")}, - } - - for _, tt := range tests { - if got := cfg.Get("test", "", tt.key); got != tt.want { - t.Errorf("test.%s: got %q, want %q (from git)", tt.key, got, tt.want) - } - } -} - -func TestConfigComplexValuesAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "test.spaced", "value with spaces") - gitConfig(t, repoPath, "test.special", "value=with=equals") - gitConfig(t, repoPath, "test.path", "/path/to/something") - gitConfig(t, repoPath, "test.number", "12345") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - tests := []string{"spaced", "special", "path", "number"} - for _, key := range tests { - want := gitConfigGet(t, repoPath, "test."+key) - if got := cfg.Get("test", "", key); got != want { - t.Errorf("test.%s: got %q, want %q (from git)", key, got, want) - } - } -} - -func TestConfigEntriesAgainstGit(t *testing.T) { - repoPath, cleanup := setupTestRepo(t) - defer cleanup() - - gitConfig(t, repoPath, "core.bare", "true") - gitConfig(t, repoPath, "core.filemode", "false") - gitConfig(t, repoPath, "user.name", "Test User") - - cfgFile, err := os.Open(filepath.Join(repoPath, "config")) - if err != nil { - t.Fatalf("failed to open config: %v", err) - } - defer func() { _ = cfgFile.Close() }() - - cfg, err := ParseConfig(cfgFile) - if err != nil { - t.Fatalf("ParseConfig failed: %v", err) - } - - entries := cfg.Entries() - if len(entries) < 3 { - t.Errorf("expected at least 3 entries, got %d", len(entries)) - } - - found := make(map[string]bool) - for _, entry := range entries { - key := entry.Section + "." + entry.Key - if entry.Subsection != "" { - key = entry.Section + "." + entry.Subsection + "." + entry.Key - } - found[key] = true - - gitValue := gitConfigGet(t, repoPath, key) - if entry.Value != gitValue { - t.Errorf("entry %s: got value %q, git has %q", key, entry.Value, gitValue) - } - } -} - -func TestConfigErrorCases(t *testing.T) { - tests := []struct { - name string - config string - }{ - { - name: "key before section", - config: "bare = true", - }, - { - name: "invalid section character", - config: "[core/invalid]", - }, - { - name: "unterminated section", - config: "[core", - }, - { - name: "unterminated quote", - config: "[core]\n\tbare = \"true", - }, - { - name: "invalid escape", - config: "[core]\n\tvalue = \"test\\x\"", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := strings.NewReader(tt.config) - _, err := ParseConfig(r) - if err == nil { - t.Errorf("expected error for %s", tt.name) - } - }) - } -} |
