feat(client): ignore rule in .lodge

This commit is contained in:
hh committed 2026-10-02 21:59:27 +00:00
1 parent 4207f7e6f8
commit 45c81cfcaf
8 files changed
+146 -18

No files matched your search

+3 -1
View File
@@ -25,9 +25,11 @@ A repository is identified by the hash of its root commit, so nothing is registe
shared docs/agents shared docs/agents
shared CLAUDE.local.md shared CLAUDE.local.md
branch .agent branch .agent
ignore vault/.obsidian/workspace*.json
ignore .DS_Store
``` ```
Synced paths are hidden through `.git/info/exclude`; `.gitignore` is never touched, and files tracked by git are never synced. When two machines edit the same file, the later edit wins and the other version stays in history. `ignore` keeps matching files on the machine that has them: they are never pushed, pulled or watched. Its pattern is a glob over the whole path, or over any single name when it has no slash. Synced paths are hidden through `.git/info/exclude`; `.gitignore` is never touched, and files tracked by git are never synced. When two machines edit the same file, the later edit wins and the other version stays in history.
## Adding a machine ## Adding a machine
+4 -1
View File
@@ -169,7 +169,10 @@ func showStatus(ctx context.Context, c *client.Client, dir string, asJSON bool)
fmt.Printf("%s\nkey %s, branch %s, revision %d\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision) fmt.Printf("%s\nkey %s, branch %s, revision %d\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision)
for _, rule := range report.Rules { for _, rule := range report.Rules {
kind := "branch" kind := "branch"
if rule.Shared { switch {
case rule.Ignore:
kind = "ignore"
case rule.Shared:
kind = "shared" kind = "shared"
} }
fmt.Printf(" %s %s\n", kind, rule.Path) fmt.Printf(" %s %s\n", kind, rule.Path)
+5 -4
View File
@@ -112,8 +112,8 @@ func (t tree) relevant(root, name string) bool {
return false return false
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
return slices.ContainsFunc(t.rules, func(rule Rule) bool { return !t.rules.ignored(rel) && slices.ContainsFunc(t.rules, func(rule Rule) bool {
return within(rel, rule.Path) || within(rule.Path, rel) return !rule.Ignore && (within(rel, rule.Path) || within(rule.Path, rel))
}) })
} }
@@ -178,14 +178,15 @@ func (d *daemon) watch(root string) {
} }
rules := LoadRules(repo.Root) rules := LoadRules(repo.Root)
dirs := map[string]bool{repo.GitDir: true} dirs := map[string]bool{repo.GitDir: true}
for _, rule := range rules { for _, rule := range rules.synced() {
target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
dirs[nearestDir(filepath.Dir(target), repo.Root)] = true dirs[nearestDir(filepath.Dir(target), repo.Root)] = true
_ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error { _ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error {
if err != nil || !entry.IsDir() { if err != nil || !entry.IsDir() {
return nil return nil
} }
if entry.Name() == ".git" { rel, _ := filepath.Rel(repo.Root, path)
if entry.Name() == ".git" || rules.ignored(filepath.ToSlash(rel)) {
return filepath.SkipDir return filepath.SkipDir
} }
dirs[path] = true dirs[path] = true
+29
View File
@@ -55,6 +55,35 @@ func TestDaemonWatchesOnlyRulePaths(t *testing.T) {
} }
} }
func TestDaemonSkipsIgnoredPaths(t *testing.T) {
m, _ := setup(t)
watcher, err := fsnotify.NewWatcher()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { watcher.Close() })
d := &daemon{client: m.client, watcher: watcher, owners: map[string]string{}, trees: map[string]tree{}}
write(t, m.root, RulesFile, "shared vault\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore vault/.trash\n")
write(t, m.root, "vault/.obsidian/app.json", "settings")
write(t, m.root, "vault/.trash/old.md", "old")
d.watch(m.root)
if _, watched := d.owners[filepath.Join(m.root, "vault/.trash")]; watched {
t.Fatal("ignored directory is watched")
}
for name, want := range map[string]bool{
"vault/note.md": true,
"vault/.obsidian/app.json": true,
"vault/.obsidian/workspace.json": false,
"vault/.obsidian/workspace-mobile.json": false,
"vault/.DS_Store": false,
"vault/.trash/old.md": false,
} {
if got := d.trees[m.root].relevant(m.root, filepath.Join(m.root, name)); got != want {
t.Errorf("relevant(%s) = %v, want %v", name, got, want)
}
}
}
func TestDaemonForgetsVanishedWorktrees(t *testing.T) { func TestDaemonForgetsVanishedWorktrees(t *testing.T) {
c := &Client{DataDir: t.TempDir()} c := &Client{DataDir: t.TempDir()}
if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil { if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil {
+31 -6
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"lodge/internal/gitrepo" "lodge/internal/gitrepo"
@@ -15,6 +16,7 @@ const (
RulesFile = ".lodge" RulesFile = ".lodge"
sharedKeyword = "shared" sharedKeyword = "shared"
branchKeyword = "branch" branchKeyword = "branch"
ignoreKeyword = "ignore"
excludeBegin = "# lodge begin" excludeBegin = "# lodge begin"
excludeEnd = "# lodge end" excludeEnd = "# lodge end"
DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n" DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n"
@@ -23,6 +25,7 @@ const (
type Rule struct { type Rule struct {
Path string `json:"path"` Path string `json:"path"`
Shared bool `json:"shared"` Shared bool `json:"shared"`
Ignore bool `json:"ignore,omitempty"`
} }
type Rules []Rule type Rules []Rule
@@ -31,12 +34,12 @@ func parseRules(text string) Rules {
rules := Rules{{Path: RulesFile, Shared: true}} rules := Rules{{Path: RulesFile, Shared: true}}
for _, line := range strings.Split(text, "\n") { for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != sharedKeyword && fields[0] != branchKeyword { if len(fields) != 2 || !slices.Contains([]string{sharedKeyword, branchKeyword, ignoreKeyword}, fields[0]) {
continue continue
} }
rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/")) rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/"))
if protocol.SafePath(rulePath) { if protocol.SafePath(rulePath) {
rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword}) rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword, Ignore: fields[0] == ignoreKeyword})
} }
} }
return rules return rules
@@ -50,9 +53,14 @@ func LoadRules(root string) Rules {
return parseRules(string(data)) 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 { func (rules Rules) paths() []string {
paths := make([]string, len(rules)) synced := rules.synced()
for i, rule := range rules { paths := make([]string, len(synced))
for i, rule := range synced {
paths[i] = rule.Path paths[i] = rule.Path
} }
return paths return paths
@@ -60,12 +68,29 @@ func (rules Rules) paths() []string {
func (rules Rules) covers(file string, shared bool) bool { func (rules Rules) covers(file string, shared bool) bool {
best := Rule{} best := Rule{}
for _, rule := range rules { for _, rule := range rules.synced() {
if within(file, rule.Path) && len(rule.Path) > len(best.Path) { if within(file, rule.Path) && len(rule.Path) > len(best.Path) {
best = rule best = rule
} }
} }
return best.Path != "" && best.Shared == shared 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 { func within(path, dir string) bool {
+1 -1
View File
@@ -15,7 +15,7 @@ Run `lodge status` inside the repository to see the rules in effect. The default
- `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch. - `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch.
- `CLAUDE.local.md` is shared across branches. - `CLAUDE.local.md` is shared across branches.
The rules live in `.lodge` at the repository root, one per line, as `shared <path>` or `branch <path>`. Paths are relative to the root, no globs. The file itself is synced too. The rules live in `.lodge` at the repository root, one per line, as `shared <path>`, `branch <path>` or `ignore <pattern>`. Paths are relative to the root. `ignore` keeps matching files local to each machine, for editor state and similar noise: the pattern is a glob over the whole path, or over any single name when it has no `/`, as in `ignore vault/.obsidian/workspace*.json` or `ignore .DS_Store`. The file itself is synced too.
## Rules of conduct ## Rules of conduct
+7 -5
View File
@@ -114,20 +114,20 @@ func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, err
func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles { func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles {
found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())} found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())}
for _, rule := range rules { for _, rule := range rules.synced() {
start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
_ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error { _ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil return nil
} }
if entry.IsDir() && entry.Name() == ".git" { rel, _ := filepath.Rel(repo.Root, full)
rel = filepath.ToSlash(rel)
if entry.IsDir() && (entry.Name() == ".git" || rules.ignored(rel)) {
return filepath.SkipDir return filepath.SkipDir
} }
if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) { if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) {
return nil return nil
} }
rel, _ := filepath.Rel(repo.Root, full)
rel = filepath.ToSlash(rel)
if found.tracked[rel] || !rules.covers(rel, rule.Shared) { if found.tracked[rel] || !rules.covers(rel, rule.Shared) {
return nil return nil
} }
@@ -257,7 +257,9 @@ func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState
} }
continue continue
} }
relevant := protocol.SafePath relevant := func(path string) bool {
return protocol.SafePath(path) && !rules.ignored(path)
}
if active[space] != nil { if active[space] != nil {
relevant = func(path string) bool { relevant = func(path string) bool {
return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path] return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path]
+66
View File
@@ -207,6 +207,72 @@ func TestRulesFileTravelsAndHostileServerPathsAreIgnored(t *testing.T) {
} }
} }
func TestIgnoredFilesNeverTravel(t *testing.T) {
mac, dell := setup(t)
rules := "shared vault\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore vault/cache\n"
write(t, mac.root, RulesFile, rules)
write(t, mac.root, "vault/note.md", "note")
write(t, mac.root, "vault/.obsidian/app.json", "settings")
write(t, mac.root, "vault/.obsidian/workspace.json", "mac layout")
write(t, mac.root, "vault/.obsidian/workspace-mobile.json", "mac mobile")
write(t, mac.root, "vault/deep/.DS_Store", "finder")
write(t, mac.root, "vault/cache/index.bin", "cache")
if res := mac.sync(); res.Pushed != 3 {
t.Fatalf("pushed %d files, want the rules, the note and app.json", res.Pushed)
}
write(t, dell.root, "vault/.obsidian/workspace.json", "dell layout")
dell.sync()
dell.expect("vault/note.md", "note")
dell.expect("vault/.obsidian/app.json", "settings")
dell.expect("vault/.obsidian/workspace.json", "dell layout")
dell.expect("vault/.obsidian/workspace-mobile.json", "")
dell.expect("vault/deep/.DS_Store", "")
dell.expect("vault/cache/index.bin", "")
hash, _ := mac.client.cacheBlob([]byte("pushed by an old client"))
if err := mac.client.uploadBlob(context.Background(), hash); err != nil {
t.Fatal(err)
}
push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: "vault/.obsidian/workspace.json", Hash: hash}}}
if err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil); err != nil {
t.Fatal(err)
}
mac.sync()
dell.sync()
mac.expect("vault/.obsidian/workspace.json", "mac layout")
dell.expect("vault/.obsidian/workspace.json", "dell layout")
exclude, _ := os.ReadFile(filepath.Join(dell.root, ".git/info/exclude"))
if strings.Contains(string(exclude), "workspace") || strings.Contains(string(exclude), "DS_Store") {
t.Fatalf("ignore rules leaked into exclude:\n%s", exclude)
}
}
func TestIgnoreRuleMatching(t *testing.T) {
rules := parseRules("shared vault\nbranch .agent\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore *.tmp\nignore .lodge\n")
for file, want := range map[string]bool{
"vault/note.md": true,
"vault/.obsidian/workspace.json": false,
"vault/.obsidian/workspace-mobile.json": false,
"vault/.obsidian/workspaces.json": false,
"vault/.obsidian/app.json": true,
"vault/sub/.obsidian/workspace.json": true,
"vault/.DS_Store": false,
"vault/a/b/.DS_Store": false,
"vault/.DS_Store/inside": false,
"vault/draft.tmp": false,
RulesFile: true,
} {
if got := rules.covers(file, true); got != want {
t.Errorf("covers(%s) = %v, want %v", file, got, want)
}
}
if rules.covers(".agent/.DS_Store", false) || !rules.covers(".agent/plan.md", false) {
t.Error("ignore rules must apply to branch paths too")
}
}
func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) { func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) {
mac, _ := setup(t) mac, _ := setup(t)
write(t, mac.root, "docs/agents/plan.md", "v1") write(t, mac.root, "docs/agents/plan.md", "v1")