diff --git a/fs.go b/fs.go index 68c8e13..49c7a58 100644 --- a/fs.go +++ b/fs.go @@ -3,12 +3,16 @@ package main import ( "bytes" "errors" + "fmt" "os" "os/exec" + "path" + "strings" ) type FS interface { Open(name string) (File, error) + Paths(name string) ([]string, error) } type File interface { @@ -47,3 +51,20 @@ func (g *gitfs) Open(name string) (File, error) { Buffer: bytes.NewBuffer(buf), }, nil } + +// Paths returns the paths of every file with the given name at the revision +func (g *gitfs) Paths(name string) ([]string, error) { + cmd := exec.Command("git", "-C", g.cwd, "ls-tree", "-r", "-z", "--name-only", g.rev) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("error listing files in %s: %w", g.rev, err) + } + + paths := []string{} + for _, file := range strings.Split(string(out), "\x00") { + if file != "" && path.Base(file) == name { + paths = append(paths, file) + } + } + return paths, nil +} diff --git a/main.go b/main.go index 46acd49..32342fd 100644 --- a/main.go +++ b/main.go @@ -459,9 +459,19 @@ func readLines(b []byte) ([]string, error) { } func notifications(fs FS, paths []string, notifyFilename string) (map[string][]string, error) { + ruleFilePaths, err := fs.Paths(notifyFilename) + if err != nil { + return nil, err + } + + ruleFileCache := make(map[string]*ruleset, len(ruleFilePaths)) + for _, ruleFilePath := range ruleFilePaths { + ruleFileCache[ruleFilePath] = nil + } + notifications := map[string][]string{} for _, path := range paths { - subs, err := subscribers(fs, path, notifyFilename) + subs, err := subscribers(fs, ruleFileCache, path, notifyFilename) if err != nil { return nil, err } @@ -474,61 +484,108 @@ func notifications(fs FS, paths []string, notifyFilename string) (map[string][]s return notifications, nil } -func subscribers(fs FS, path string, notifyFilename string) ([]string, error) { - fmt.Fprintf(verbose, "analyzing subscribers in %s files\n", notifyFilename) +// rule is a single parsed line of a rule file +type rule struct { + pattern *regexp.Regexp + subscribers []string +} + +// ruleset represents a parsed rule file +type ruleset struct { + rules []rule +} + +// subscribers returns the subscribers of every rule matching the given path +func (r *ruleset) subscribers(path string) []string { subscribers := []string{} + for _, rule := range r.rules { + if rule.pattern.MatchString(path) { + subscribers = append(subscribers, rule.subscribers...) + } + } + return subscribers +} + +// subscribers returns the subscribers to notify about a change to path +func subscribers(fs FS, ruleFileCache map[string]*ruleset, path string, notifyFilename string) ([]string, error) { + seen := map[string]struct{}{} parts := strings.Split(path, string(os.PathSeparator)) for i := range parts { base := filepath.Join(parts[:i]...) - rulefilepath := filepath.Join(base, notifyFilename) + ruleFilePath := filepath.Join(base, notifyFilename) - rulefile, err := fs.Open(rulefilepath) - if err != nil { - if err == os.ErrNotExist { - continue + ruleFile, ok := ruleFileCache[ruleFilePath] + if !ok { + // No rule file in this directory + continue + } + + if ruleFile == nil { + loaded, err := readRuleset(fs, ruleFilePath) + if err != nil { + return nil, err } + ruleFileCache[ruleFilePath] = loaded + ruleFile = loaded + } + + rel, err := filepath.Rel(base, path) + if err != nil { return nil, err } - scanner := bufio.NewScanner(rulefile) - for scanner.Scan() { - rule := scanner.Text() - if rule != "" && rule[0] == '#' { - // skip comment - continue - } + for _, subscriber := range ruleFile.subscribers(rel) { + seen[subscriber] = struct{}{} + } + } - fields := strings.Fields(rule) - switch len(fields) { - case 0: - // skip blank line - continue - case 1: - return nil, fmt.Errorf("expected at least two fields for rule in %s: %s", rulefilepath, rule) - } + subscribers := make([]string, 0, len(seen)) + for subscriber := range seen { + subscribers = append(subscribers, subscriber) + } + return subscribers, nil +} - rel, err := filepath.Rel(base, path) - if err != nil { - return nil, err - } +func readRuleset(fs FS, path string) (*ruleset, error) { + fmt.Fprintf(verbose, "reading %s\n", path) - re, err := patternToRegexp(fields[0]) - if err != nil { - return nil, fmt.Errorf("invalid pattern in %s: %s: %w", rulefilepath, rule, err) - } + rulefile, err := fs.Open(path) + if err != nil { + return nil, err + } - if re.MatchString(rel) { - subscribers = append(subscribers, fields[1:]...) - } + rules := ruleset{} + scanner := bufio.NewScanner(rulefile) + for scanner.Scan() { + line := scanner.Text() + if line != "" && line[0] == '#' { + // skip comment + continue } - if err := scanner.Err(); err != nil { - return nil, err + fields := strings.Fields(line) + switch len(fields) { + case 0: + // skip blank line + continue + case 1: + return nil, fmt.Errorf("expected at least two fields for rule in %s: %s", path, line) + } + + pattern, err := patternToRegexp(fields[0]) + if err != nil { + return nil, fmt.Errorf("invalid pattern in %s: %s: %w", path, line, err) } + + rules.rules = append(rules.rules, rule{pattern: pattern, subscribers: fields[1:]}) } - return subscribers, nil + if err := scanner.Err(); err != nil { + return nil, err + } + + return &rules, nil } func patternToRegexp(pattern string) (*regexp.Regexp, error) { diff --git a/main_test.go b/main_test.go index fc1ca9e..f5c75c8 100644 --- a/main_test.go +++ b/main_test.go @@ -726,3 +726,13 @@ func (m memfs) Open(name string) (File, error) { return mf, nil } + +func (m memfs) Paths(name string) ([]string, error) { + paths := []string{} + for path := range m { + if filepath.Base(path) == name { + paths = append(paths, path) + } + } + return paths, nil +}