From 28e3fcea5d0a41cb6c58c276c178f75013066c5d Mon Sep 17 00:00:00 2001 From: Jack Lau Date: Thu, 15 Oct 2026 01:01:48 -0400 Subject: [PATCH] feat(projectkey): route by a .devbrain-project marker when a folder has no usable remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing is keyed by the git origin remote; everything else lands in `miscellaneous`. Scratch folders, local-only repos and synced project directories therefore share one namespace — logs, brain pages and the TODO queue mix. DEVBRAIN_PROJECT cannot fix it: capture is sweep-based, so the flusher's environment, not the session's, decides. A `.devbrain-project` file names the project key on its first line. ProjectKey consults it after $DEVBRAIN_PROJECT and the data-repo guard and before the remote, walking up from cwd like `.git` so subdirectories inherit it. Keys are sanitised and canonicalised through project-aliases like remote keys; blank or dot-only values are ignored so a marker can never resolve to projects/.. The remote path is unchanged (shared canonicalFor helper). Co-Authored-By: Claude Fable 5.1 --- README.md | 4 ++- internal/projectkey/marker_test.go | 51 ++++++++++++++++++++++++++++ internal/projectkey/projectkey.go | 54 ++++++++++++++++++++++++++---- 3 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 internal/projectkey/marker_test.go diff --git a/README.md b/README.md index 3948fe8..5777fc4 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ and Codex transcript into the log, then commits and pushes it to your configured Capture reads the agents' own on-disk transcripts — no capture hooks, nothing to trust or break; if the machine sleeps, the next sweep catches up. Files land within a minute; the git commit+push batches to every 15 so history stays readable. The log is keyed by the repo's -**git remote**, so all worktrees collapse to one project. devbrain is a single binary; your prompts and brain live +**git remote**, so all worktrees collapse to one project. A folder with no usable remote (or one that +should route elsewhere) can name its project on the first line of a `.devbrain-project` file, found by +walking up from the working directory like `.git`; otherwise it lands in `miscellaneous`. devbrain is a single binary; your prompts and brain live in a *separate* private store at `~/devbrain-data` that you own — nothing leaves your machine until you add a remote or opt into embeddings. Full design in [`DESIGN.md`](DESIGN.md). diff --git a/internal/projectkey/marker_test.go b/internal/projectkey/marker_test.go new file mode 100644 index 0000000..db91eb8 --- /dev/null +++ b/internal/projectkey/marker_test.go @@ -0,0 +1,51 @@ +package projectkey + +import ( + "os" + "path/filepath" + "testing" +) + +// A directory with no usable git remote can name its project with a +// .devbrain-project marker file instead of landing in "miscellaneous". +func TestProjectKeyMarkerFile(t *testing.T) { + t.Setenv("DEVBRAIN_DATA", t.TempDir()) + t.Setenv("DEVBRAIN_PROJECT", "") + + dir := t.TempDir() // not a git repo at all + if err := os.WriteFile(filepath.Join(dir, ".devbrain-project"), []byte("Local Research\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := ProjectKey(dir); got != "local-research" { + t.Errorf("marker ProjectKey = %q, want %q", got, "local-research") + } + + // a subdirectory inherits the nearest marker above it + sub := filepath.Join(dir, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if got := ProjectKey(sub); got != "local-research" { + t.Errorf("subdir ProjectKey = %q, want %q", got, "local-research") + } + + // a marker inside a repo with a remote wins over the remote-derived key + repo := initRepo(t, "https://github.com/acme/widgets.git") + if err := os.WriteFile(filepath.Join(repo, ".devbrain-project"), []byte("acme__platform"), 0o644); err != nil { + t.Fatal(err) + } + if got := ProjectKey(repo); got != "acme__platform" { + t.Errorf("marker-in-repo ProjectKey = %q, want %q", got, "acme__platform") + } + + // blank and dot-only markers are ignored, never a traversal into projects/.. + for _, bad := range []string{" \n", "..", "./"} { + d := t.TempDir() + if err := os.WriteFile(filepath.Join(d, ".devbrain-project"), []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + if got := ProjectKey(d); got != "miscellaneous" { + t.Errorf("marker %q ProjectKey = %q, want miscellaneous", bad, got) + } + } +} diff --git a/internal/projectkey/projectkey.go b/internal/projectkey/projectkey.go index 454a6a2..5fbc276 100644 --- a/internal/projectkey/projectkey.go +++ b/internal/projectkey/projectkey.go @@ -48,8 +48,10 @@ func gitOutput(dir string, args ...string) string { } // ProjectKey maps cwd to its projects/ folder name (bash port). -// $DEVBRAIN_PROJECT overrides; a repo with no remote (or a local-path remote, -// which has no owner/repo shape) lands in the shared "miscellaneous" bucket. +// $DEVBRAIN_PROJECT overrides; then the nearest .devbrain-project marker file +// above cwd (see markerProject); then the git origin remote. A directory with +// none of those (or a local-path remote, which has no owner/repo shape) lands +// in the shared "miscellaneous" bucket. // It returns "" for the devbrain data repo itself (see InDataRepo): the data // repo has its own git remote, but treating it as a project would let a session // that cd'd into it mint a bogus projects// folder. Callers treat "" @@ -61,6 +63,9 @@ func ProjectKey(cwd string) string { if InDataRepo(cwd) { return "" } + if key := markerProject(cwd); key != "" { + return canonicalFor(cwd, key) + } remote := gitOutput(cwd, "remote", "get-url", "origin") // Ignore a local-path origin: its folders aren't an owner/repo. for _, p := range []string{"/", "./", "../", "~", "file://"} { @@ -84,15 +89,50 @@ func ProjectKey(cwd string) string { } } if owner != "" && repo != "" { - key := Sanitize(owner + "__" + repo) - if data, err := config.ResolveDataDirFor(cwd); err == nil { - key = Canonical(key, Aliases(data)) - } - return key + return canonicalFor(cwd, Sanitize(owner+"__"+repo)) } return "miscellaneous" } +// canonicalFor applies the data repo's project-aliases to a resolved key, so a +// marker or a remote naming a renamed/forked project routes to its canonical +// folder the same way. +func canonicalFor(cwd, key string) string { + if data, err := config.ResolveDataDirFor(cwd); err == nil { + key = Canonical(key, Aliases(data)) + } + return key +} + +// MarkerFile is the per-directory routing override: a file whose first line +// names the project key for a working tree that has no usable git remote +// (scratch folders, local-only repos, synced directories) or that should route +// somewhere other than its remote implies. It is found by walking up from cwd, +// the way git finds .git, so subdirectories inherit it. +const MarkerFile = ".devbrain-project" + +// markerProject returns the sanitized key named by the nearest MarkerFile at or +// above cwd, or "" when there is none or it names nothing usable: blank, or +// only dots, which would otherwise resolve to projects/.. and escape the tree. +func markerProject(cwd string) string { + dir := resolvePath(cwd) + for { + if b, err := os.ReadFile(filepath.Join(dir, MarkerFile)); err == nil { + first, _, _ := strings.Cut(string(b), "\n") + key := Sanitize(strings.TrimSpace(first)) + if strings.Trim(key, ".") == "" { + return "" + } + return key + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + // OriginRemote returns cwd's git origin URL ("" outside a repo or without one). func OriginRemote(cwd string) string { return gitOutput(cwd, "remote", "get-url", "origin")