package client import ( "bytes" "os" "path" "path/filepath" "slices" "strings" "lodge/internal/gitrepo" "lodge/internal/protocol" ) const ( RulesFile = ".lodge" sharedKeyword = "shared" branchKeyword = "branch" ignoreKeyword = "ignore" excludeBegin = "# lodge begin" excludeEnd = "# lodge end" DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n" ) type Rule struct { Path string `json:"path"` Shared bool `json:"shared"` Ignore bool `json:"ignore,omitempty"` } type Rules []Rule func parseRules(text string) Rules { rules := Rules{{Path: RulesFile, Shared: true}} for _, line := range strings.Split(text, "\n") { fields := strings.Fields(line) if len(fields) != 2 || !slices.Contains([]string{sharedKeyword, branchKeyword, ignoreKeyword}, fields[0]) { continue } rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/")) if protocol.SafePath(rulePath) { rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword, Ignore: fields[0] == ignoreKeyword}) } } return rules } func LoadRules(root string) Rules { data, err := os.ReadFile(filepath.Join(root, RulesFile)) if err != nil { return parseRules(DefaultRules) } return parseRules(string(data)) } func (rules Rules) synced() Rules { return slices.DeleteFunc(slices.Clone(rules), func(rule Rule) bool { return rule.Ignore }) } func (rules Rules) paths() []string { synced := rules.synced() paths := make([]string, len(synced)) for i, rule := range synced { paths[i] = rule.Path } return paths } func (rules Rules) covers(file string, shared bool) bool { best := Rule{} for _, rule := range rules.synced() { if within(file, rule.Path) && len(rule.Path) > len(best.Path) { best = rule } } return best.Path != "" && best.Shared == shared && !rules.ignored(file) } func (rules Rules) ignored(file string) bool { return file != RulesFile && slices.ContainsFunc(rules, func(rule Rule) bool { return rule.Ignore && rule.matches(file) }) } func (rule Rule) matches(file string) bool { for name := file; name != "." && name != "/"; name = path.Dir(name) { target := name if !strings.Contains(rule.Path, "/") { target = path.Base(name) } if matched, _ := path.Match(rule.Path, target); matched { return true } } return false } func within(path, dir string) bool { return path == dir || strings.HasPrefix(path, dir+"/") } func writeExclude(repo *gitrepo.Repo, rules Rules) error { file := filepath.Join(repo.CommonDir, "info", "exclude") current, _ := os.ReadFile(file) kept := current if before, rest, found := bytes.Cut(current, []byte(excludeBegin)); found { _, after, _ := bytes.Cut(rest, []byte(excludeEnd+"\n")) kept = append(bytes.Clone(before), after...) } if len(kept) > 0 && !bytes.HasSuffix(kept, []byte("\n")) { kept = append(kept, '\n') } block := excludeBegin + "\n/" + strings.Join(rules.paths(), "\n/") + "\n" + excludeEnd + "\n" kept = append(kept, block...) if bytes.Equal(kept, current) { return nil } return writeFile(file, kept, 0o644) }