feat(client): ignore rule in .lodge
This commit is contained in:
1 parent
4207f7e6f8
commit
45c81cfcaf
8 files changed
+146
-18
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user