From 45c81cfcafab06ffe4e011cedf58fc659fcf92f7 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 2 Oct 2026 21:59:27 +0000 Subject: [PATCH] feat(client): ignore rule in .lodge --- README.md | 4 +- backend/cmd/lodge/main.go | 5 +- backend/internal/client/daemon.go | 9 ++-- backend/internal/client/daemon_test.go | 29 +++++++++++ backend/internal/client/rules.go | 37 ++++++++++++--- backend/internal/client/skill.md | 2 +- backend/internal/client/sync.go | 12 +++-- backend/internal/client/sync_test.go | 66 ++++++++++++++++++++++++++ 8 files changed, 146 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0bcf11c..b0ae1da 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@ A repository is identified by the hash of its root commit, so nothing is registe shared docs/agents shared CLAUDE.local.md 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 diff --git a/backend/cmd/lodge/main.go b/backend/cmd/lodge/main.go index 0a8292b..9d6a107 100644 --- a/backend/cmd/lodge/main.go +++ b/backend/cmd/lodge/main.go @@ -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) for _, rule := range report.Rules { kind := "branch" - if rule.Shared { + switch { + case rule.Ignore: + kind = "ignore" + case rule.Shared: kind = "shared" } fmt.Printf(" %s %s\n", kind, rule.Path) diff --git a/backend/internal/client/daemon.go b/backend/internal/client/daemon.go index 913de9f..8438131 100644 --- a/backend/internal/client/daemon.go +++ b/backend/internal/client/daemon.go @@ -112,8 +112,8 @@ func (t tree) relevant(root, name string) bool { return false } rel = filepath.ToSlash(rel) - return slices.ContainsFunc(t.rules, func(rule Rule) bool { - return within(rel, rule.Path) || within(rule.Path, rel) + return !t.rules.ignored(rel) && slices.ContainsFunc(t.rules, func(rule Rule) bool { + 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) 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)) dirs[nearestDir(filepath.Dir(target), repo.Root)] = true _ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error { if err != nil || !entry.IsDir() { return nil } - if entry.Name() == ".git" { + rel, _ := filepath.Rel(repo.Root, path) + if entry.Name() == ".git" || rules.ignored(filepath.ToSlash(rel)) { return filepath.SkipDir } dirs[path] = true diff --git a/backend/internal/client/daemon_test.go b/backend/internal/client/daemon_test.go index 3f36814..54a6632 100644 --- a/backend/internal/client/daemon_test.go +++ b/backend/internal/client/daemon_test.go @@ -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) { c := &Client{DataDir: t.TempDir()} if err := writeJSONFile(c.registryPath(), []string{filepath.Join(t.TempDir(), "gone")}); err != nil { diff --git a/backend/internal/client/rules.go b/backend/internal/client/rules.go index 13bb4c8..8a21318 100644 --- a/backend/internal/client/rules.go +++ b/backend/internal/client/rules.go @@ -5,6 +5,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "lodge/internal/gitrepo" @@ -15,6 +16,7 @@ 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" @@ -23,6 +25,7 @@ const ( type Rule struct { Path string `json:"path"` Shared bool `json:"shared"` + Ignore bool `json:"ignore,omitempty"` } type Rules []Rule @@ -31,12 +34,12 @@ 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 || fields[0] != sharedKeyword && fields[0] != branchKeyword { + 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}) + rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword, Ignore: fields[0] == ignoreKeyword}) } } return rules @@ -50,9 +53,14 @@ func LoadRules(root string) Rules { 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 { - paths := make([]string, len(rules)) - for i, rule := range rules { + synced := rules.synced() + paths := make([]string, len(synced)) + for i, rule := range synced { paths[i] = rule.Path } return paths @@ -60,12 +68,29 @@ func (rules Rules) paths() []string { func (rules Rules) covers(file string, shared bool) bool { best := Rule{} - for _, rule := range rules { + for _, rule := range rules.synced() { if within(file, rule.Path) && len(rule.Path) > len(best.Path) { 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 { diff --git a/backend/internal/client/skill.md b/backend/internal/client/skill.md index 52bdde4..e2a9e9a 100644 --- a/backend/internal/client/skill.md +++ b/backend/internal/client/skill.md @@ -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. - `CLAUDE.local.md` is shared across branches. -The rules live in `.lodge` at the repository root, one per line, as `shared ` or `branch `. 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 `, `branch ` or `ignore `. 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 diff --git a/backend/internal/client/sync.go b/backend/internal/client/sync.go index 8896523..8e06941 100644 --- a/backend/internal/client/sync.go +++ b/backend/internal/client/sync.go @@ -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 { 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)) _ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error { if err != 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 } if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) { return nil } - rel, _ := filepath.Rel(repo.Root, full) - rel = filepath.ToSlash(rel) if found.tracked[rel] || !rules.covers(rel, rule.Shared) { return nil } @@ -257,7 +257,9 @@ func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState } continue } - relevant := protocol.SafePath + relevant := func(path string) bool { + return protocol.SafePath(path) && !rules.ignored(path) + } if active[space] != nil { relevant = func(path string) bool { return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path] diff --git a/backend/internal/client/sync_test.go b/backend/internal/client/sync_test.go index ad18761..896680f 100644 --- a/backend/internal/client/sync_test.go +++ b/backend/internal/client/sync_test.go @@ -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) { mac, _ := setup(t) write(t, mac.root, "docs/agents/plan.md", "v1")