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
72 changes: 72 additions & 0 deletions docs/plans/2026-06-14-fdr-0019-scoped-repos-prototype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# FDR-0019 Scoped Repo Resolution --- Dodder-Side Prototype

Status: prototype (dodder-only, ahead of the madder env_dir.RepoId
change FDR-0019 calls for). See
`docs/features/0019-scoped-repo-resolution.md` for the full design.

## Why a dodder-only prototype

The full FDR routes the repo-id grammar (name + scope prefix +
dot-depth) and the `repos/<name>/` layout through madder's
`env_dir.RepoId`, which dodder consumes. Madder's `RepoId` is still the
FDR-0003 location-only selector (`.`, `/`, empty) and lives in a
separate repo. This prototype implements the user-visible slice
entirely inside dodder so the feature can be exercised before the
madder change lands, then folded onto the real type later.

## What it does

- **Grammar.** New `internal/bravo/repo_id.Id` parses the FDR grammar:
`name` (user), `.name` / `..name` (cwd, dot-depth), `//name`
(system), `/name` (system; remote-first not implemented), `~name`
(parse-only user alias), plus the legacy nameless `.` / `/` / empty.
`String()` renders the canonical single-dot form. Covered by
`repo_id` unit tests.
- **Layout.** `env_dir.NestUnderRepoName` appends `repos/<name>/` to
the dodder XDG category dirs (data/config/state/cache/runtime), so a
named repo's whole metadata tree --- config-seed, object index,
inventory-list log, lock --- nests automatically. Wired into the
three env-construction sites: `MakeEnvRepo`, `OnTheFirstDay`
(genesis), and `MakeLocalWorkingCopy`.
- **CLI / env.** The global `-repo_id` flag and `DODDER_REPO_ID` accept
the full grammar; `dodder init -repo_id <name> ...` creates a named
repo; reads address it by the same id.
- **Shared blob store.** Genesis tolerates a pre-existing default blob
store config (idempotent write), so named repos in one scope share
madder's content-addressed blob pool while keeping independent
metadata/index/identity.

## Deliberate limitations (vs. the full FDR)

- **Blobs are shared, not isolated.** Madder re-derives the blob-store
XDG via `CloneWithUtilityName`, discarding any suffix applied
dodder-side, so `repos/<name>/` nesting reaches only the dodder
metadata tree. Full per-repo blob isolation needs the madder change.
- **Empty id = legacy user scope**, not the FDR's "nearest cwd repo on
the walk-up, else user `default`". This keeps every existing
single-repo tree resolving unchanged (no fixture regen).
- **Single-dot cwd depth only.** `..name` (depth > 1) parses but is
rejected at resolution by `Id.CheckPrototypeSupported`.
- **`/name` resolves straight to system scope**; the remote-first
lookup is out of scope (no remote transport yet).
- No migration command, no `info-repo repos` listing, no MCP
`repo_id` dimension --- those layer on once the grammar is real.

## Path to the full FDR

1. Land the grammar + `repos/<name>/` layout on madder's
`env_dir.RepoId`; `just go/update-flake-input madder`.
2. Replace `repo_id.Id` with the madder type (or have it delegate),
dropping `CheckPrototypeSupported` once the walk-up resolves
multi-dot depth.
3. Nest the blob-store XDG too (full isolation), add
`migrate-repo-layout`, `info-repo repos`, MCP `repo_id` + resource
URI segment, and completions.

## Tests

- `internal/bravo/repo_id/main_test.go` --- grammar parse/canonical
round-trip, error cases, depth gate.
- `zz-tests_bats/current_version/scoped_repos.bats` --- two
user-scoped named repos isolated, `DODDER_REPO_ID` addressing,
multi-dot rejection.
32 changes: 32 additions & 0 deletions go/internal/bravo/env_dir/construction.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package env_dir

import (
"path/filepath"

"code.linenisgreat.com/dodder/go/internal/0/dodder_env"
mad_env_dir "github.com/amarbel-llc/madder/go/pkgs/env_dir"
"github.com/amarbel-llc/purse-first/libs/dewey/pkgs/debug"
Expand Down Expand Up @@ -86,6 +88,36 @@ func MakeDefaultAndInitialize(
)
}

// NestUnderRepoName returns a copy of env whose own XDG category dirs
// (data / config / state / cache / runtime) are nested under
// repos/<name>/, so several named repos coexist per scope under the
// FDR-0019 layout. The repo's whole metadata tree — config-seed,
// object index, inventory-list log, lock — follows automatically
// because every dodder path is built from these XDG bases.
//
// The blob-store XDG is deliberately NOT nested: madder re-derives it
// via xdg.CloneWithUtilityName, which would discard any suffix applied
// here. In this prototype named repos therefore share the
// content-addressed blob pool while keeping fully independent
// metadata, index, and identity. Full blob isolation waits on the
// madder env_dir.RepoId change.
func NestUnderRepoName(
context errors.Context,
env mad_env_dir.Env,
name string,
do debug.Options,
) mad_env_dir.Env {
x := env.GetXDG()

x.Data.ActualValue = filepath.Join(x.Data.ActualValue, "repos", name)
x.Config.ActualValue = filepath.Join(x.Config.ActualValue, "repos", name)
x.State.ActualValue = filepath.Join(x.State.ActualValue, "repos", name)
x.Cache.ActualValue = filepath.Join(x.Cache.ActualValue, "repos", name)
x.Runtime.ActualValue = filepath.Join(x.Runtime.ActualValue, "repos", name)

return MakeWithXDG(context, do, x)
}

func MakeWithDefaultHome(
context errors.Context,
utilityName string,
Expand Down
222 changes: 222 additions & 0 deletions go/internal/bravo/repo_id/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Package repo_id implements dodder's repo *location* selector
// (FDR-0019, "Scoped Repo Resolution").
//
// It extends the legacy location-only selector (`.`, `/`, empty —
// FDR-0003) with an optional name and a cwd dot-depth, mirroring
// madder's blob_store_id grammar so several named repos can coexist
// per scope under a repos/<name>/ layout.
//
// This is the dodder-side prototype that lands ahead of the eventual
// madder env_dir.RepoId grammar change the FDR calls for. Two
// deliberate prototype limitations:
//
// - Only single-dot cwd depth resolves. `..name` (depth > 0) parses
// but callers reject it at resolution time.
// - `/name` resolves to the system scope; the FDR's remote-first
// lookup (try a defined remote, then fall back to system) is not
// implemented, so `/name` and `//name` behave identically here.
//
// This selector is distinct from ids.RepoId (the repo *object* genre),
// which the FDR leaves untouched.
package repo_id

import (
"strings"

mad_env_dir "github.com/amarbel-llc/madder/go/pkgs/env_dir"
"github.com/amarbel-llc/purse-first/libs/dewey/pkgs/errors"
)

// Scope is the location scope a repo id resolves against.
type Scope int

const (
// ScopeUser is the XDG user data tree (unprefixed name, or empty).
ScopeUser Scope = iota
// ScopeCwd is the nearest ancestor `.dodder/` tree (`.name`).
ScopeCwd
// ScopeSystem is the XDG system data tree (`//name`, or `/name`).
ScopeSystem
)

// Id is dodder's repo location selector. The zero value is the empty
// (auto) id.
type Id struct {
isSet bool
scope Scope
name string
cwdDepth uint
}

// Set parses the repo-id grammar. It satisfies flag.Value via a
// pointer receiver.
func (id *Id) Set(value string) (err error) {
*id = Id{}

switch value {
case "":
// Auto: nearest cwd repo on the walk-up, else user `default`.
// The prototype treats empty as the legacy user scope so all
// existing single-repo trees keep resolving unchanged.
return nil

case ".":
id.isSet = true
id.scope = ScopeCwd
return nil

case "/":
id.isSet = true
id.scope = ScopeSystem
return nil
}

id.isSet = true

switch {
case strings.HasPrefix(value, "//"):
id.scope = ScopeSystem
id.name = value[2:]

case value[0] == '/':
// FDR-0019 reserves `/name` for remote-first selection. The
// remote transport is out of scope, so the prototype resolves
// it straight to the system scope.
id.scope = ScopeSystem
id.name = value[1:]

case value[0] == '.':
dots := 0
for dots < len(value) && value[dots] == '.' {
dots++
}

if dots == len(value) {
err = errors.Errorf("repo_id is all dots, no name: %q", value)
return err
}

id.scope = ScopeCwd
id.cwdDepth = uint(dots - 1)
id.name = value[dots:]

case value[0] == '~':
// `~name` is the parse-only user-scope alias; never emitted.
id.scope = ScopeUser
id.name = value[1:]

default:
id.scope = ScopeUser
id.name = value
}

if err = validateName(id.name); err != nil {
err = errors.Wrapf(err, "repo_id: %q", value)
return err
}

return err
}

// String renders the canonical form: cwd ids collapse to a single dot
// (depth dropped, #145 precedent), system named ids emit `//name`, and
// user ids emit the bare name with no prefix.
func (id Id) String() string {
if !id.isSet {
return ""
}

switch id.scope {
case ScopeCwd:
if id.name == "" {
return "."
}
return "." + id.name

case ScopeSystem:
if id.name == "" {
return "/"
}
return "//" + id.name

default:
return id.name
}
}

// IsEmpty reports whether nothing was selected (the auto id). Legacy
// nameless `.` / `/` are NOT empty — they pin a scope.
func (id Id) IsEmpty() bool {
return !id.isSet
}

func (id Id) IsCwd() bool {
return id.isSet && id.scope == ScopeCwd
}

func (id Id) IsSystem() bool {
return id.isSet && id.scope == ScopeSystem
}

// GetName returns the name portion ("" for the legacy nameless and
// auto forms). A non-empty name triggers the repos/<name>/ nesting.
func (id Id) GetName() string {
return id.name
}

// GetCwdDepth returns the 0-indexed ancestor depth for cwd-scoped ids
// (0 = single dot = nearest).
func (id Id) GetCwdDepth() uint {
return id.cwdDepth
}

// GetMad projects this id onto the legacy madder env_dir.RepoId so the
// existing scope routing (MakeDefaultAndInitialize) keeps working. The
// name is carried separately and applied as repos/<name>/ nesting.
func (id Id) GetMad() mad_env_dir.RepoId {
var mad mad_env_dir.RepoId

switch {
case id.IsCwd():
_ = mad.Set(".")
case id.IsSystem():
_ = mad.Set("/")
}

return mad
}

// CheckPrototypeSupported rejects the grammar this dodder-side
// prototype does not yet resolve (multi-dot cwd depth). It is removed
// once the madder env_dir.RepoId change lands the full walk-up.
func (id Id) CheckPrototypeSupported() (err error) {
if id.cwdDepth > 0 {
err = errors.Errorf(
"repo_id %q: cwd dot-depth > 1 is not yet implemented",
id.String(),
)
return err
}

return err
}

func validateName(name string) (err error) {
for _, r := range name {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '_',
r == '-':
default:
err = errors.Errorf(
"name may contain only [a-zA-Z0-9_-]; got %q",
string(r),
)
return err
}
}

return err
}
Loading
Loading