Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
51 changes: 51 additions & 0 deletions internal/projectkey/marker_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
54 changes: 47 additions & 7 deletions internal/projectkey/projectkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ func gitOutput(dir string, args ...string) string {
}

// ProjectKey maps cwd to its projects/<key> 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/<data-repo>/ folder. Callers treat ""
Expand All @@ -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://"} {
Expand All @@ -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")
Expand Down
Loading