ajhahn.de
← eeco commits

Commit

eeco

fix: resolve project root past eeco's private workspace-history repo

Repo-root detection stopped at the first .git walking up, so running from inside <repo>/<username>/ — the directory the harness launches from to load the emitted cockpit — resolved the private workspace-history repo (<username>/.git) instead of the project root, leaving the engine workspace unresolved. The SessionStart briefer/drift, Stop handover-nudge, and PostToolUse contract-watch hooks then degraded to no-ops there; only the fail-closed PreToolUse git-write guard was unaffected.

Load now resolves the project root via resolveProjectRoot, which walks past a candidate git dir recognized as eeco's private repo (marked by a sibling .eeco/ workspace) and falls back to the nearest git root when no host repo exists above. FindRepoRoot keeps its generic contract.

ajhahnde · Jun 2026 · b2c88ec8ff4d54b6bd10e1caf7461cbdea7371a9 · parent: 2b22a65 · view on GitHub →

modified CHANGELOG.md
@@ -33,6 +33,21 @@ eeco follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
over the surface in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md), under the
pre-stability caveat of the [versioning policy](VERSIONING.md) §2.1.
## [v0.1.1] - 2026-06-06
### Fixed
- **Cockpit machinery no longer silently no-ops when launched from
`<repo>/<username>/`.** Repo-root detection walked up and stopped at the
first `.git`, so from inside the private workspace-history repo
(`<username>/.git`, created by `eeco init`) it resolved that nested repo
instead of the project root, leaving the engine workspace unresolved. The
SessionStart briefer/drift, Stop handover-nudge, and PostToolUse
contract-watch hooks then degraded to no-ops in the documented launch
directory (the PreToolUse git-write guard, which fails closed, was
unaffected). Detection now walks past eeco's own private repo to the host
project root.
## [v0.1.0] - 2026-06-06
First public release of eeco as a **provider-agnostic AI-cockpit
@@ -126,6 +141,7 @@ configures; eeco is its author and mechanic. eeco is pre-stability on the
§2.1); the cockpit surface is documented but not yet frozen
([`docs/COCKPIT.md`](docs/COCKPIT.md)).
[v0.1.1]: https://github.com/ajhahnde/eeco/releases/tag/v0.1.1
[v0.1.0]: https://github.com/ajhahnde/eeco/releases/tag/v0.1.0
---
modified README.md
@@ -9,7 +9,7 @@
<p>
<a href="https://github.com/ajhahnde/eeco/actions/workflows/ci.yml"><img src="https://github.com/ajhahnde/eeco/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://codecov.io/gh/ajhahnde/eeco"><img src="https://codecov.io/gh/ajhahnde/eeco/branch/main/graph/badge.svg" alt="Coverage"></a>
<a href="https://github.com/ajhahnde/eeco/releases/latest"><img src="https://img.shields.io/badge/version-v0.1.0-blue" alt="Version"></a>
<a href="https://github.com/ajhahnde/eeco/releases/latest"><img src="https://img.shields.io/badge/version-v0.1.1-blue" alt="Version"></a>
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
<img src="https://img.shields.io/badge/go-1.24-orange" alt="Go 1.24">
<img src="https://img.shields.io/badge/binary-single--static-lightgrey" alt="single-static binary">
modified internal/config/config.go
@@ -489,13 +489,22 @@ var ErrNotInRepo = errors.New("not inside a git repository")
// a worktree). The start path may be relative; the returned path is
// always absolute and cleaned.
func FindRepoRoot(start string) (string, error) {
return walkUpForGitRoot(start, func(string) bool { return true })
}
// walkUpForGitRoot walks upwards from start and returns the first directory
// that both contains a `.git` entry and satisfies accept. A `.git` directory
// for which accept returns false is walked past rather than returned, letting
// a caller ignore a specific kind of nested repo. The start path may be
// relative; the returned path is always absolute and cleaned.
func walkUpForGitRoot(start string, accept func(dir string) bool) (string, error) {
abs, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("resolve start path: %w", err)
}
dir := abs
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil && accept(dir) {
return dir, nil
}
parent := filepath.Dir(dir)
@@ -506,6 +515,34 @@ func FindRepoRoot(start string) (string, error) {
}
}
// resolveProjectRoot finds the host project's repo root for cwd, walking past
// eeco's own private workspace-history repo (<username>/.git, created by
// `eeco init`) so that running from inside <username>/ — the directory the
// harness launches from to load the emitted cockpit — resolves the project
// root and not the private repo. If no non-private repo is found above (a
// private repo with no host repo, which `eeco init` never produces), it falls
// back to the nearest git root so behavior is never worse than FindRepoRoot.
func resolveProjectRoot(start string) (string, error) {
root, err := walkUpForGitRoot(start, func(dir string) bool {
return !isPrivateWorkspaceRepo(dir)
})
if errors.Is(err, ErrNotInRepo) {
return FindRepoRoot(start)
}
return root, err
}
// isPrivateWorkspaceRepo reports whether dir is eeco's private
// workspace-history repo rather than a host project root. The private repo
// lives at <userDir>/.git with the engine workspace <userDir>/.eeco beside it;
// a host project root never has the workspace directory directly under it (the
// workspace is always nested under <username>/). The check is a single stat so
// root detection stays cheap on the hot path (Load runs on every command).
func isPrivateWorkspaceRepo(dir string) bool {
info, err := os.Stat(filepath.Join(dir, DefaultWorkspace))
return err == nil && info.IsDir()
}
// DetectProfile inspects marker files at the repository root and
// returns the best-matching profile. When several markers are present,
// the first match in the documented order wins (go, zig, rust, node,
@@ -625,7 +662,7 @@ func Load(cwd, workspaceName string) (*Config, error) {
if err := validateWorkspaceName(workspaceName); err != nil {
return nil, err
}
root, err := FindRepoRoot(cwd)
root, err := resolveProjectRoot(cwd)
if err != nil {
return nil, err
}
modified internal/config/config_test.go
@@ -65,6 +65,111 @@ func TestFindRepoRoot_ErrorsOutsideRepo(t *testing.T) {
}
}
// newHostWithPrivateRepo builds a host repo (<root>/.git) containing eeco's
// private workspace-history repo at <root>/tester/.git with the engine
// workspace <root>/tester/.eeco beside it — the on-disk shape `eeco init`
// leaves and the cwd the harness launches from to load the emitted cockpit.
// It returns the host root and the private workspace dir (<root>/tester).
func newHostWithPrivateRepo(t *testing.T) (host, priv string) {
t.Helper()
host = newRepo(t)
priv = filepath.Join(host, "tester")
if err := os.MkdirAll(filepath.Join(priv, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(priv, DefaultWorkspace), 0o755); err != nil {
t.Fatal(err)
}
return host, priv
}
func TestResolveProjectRoot_SkipsPrivateWorkspaceRepo(t *testing.T) {
// FIX-1: from inside <username>/ (and deeper), root detection must walk
// past the private <username>/.git and resolve the host project root.
host, priv := newHostWithPrivateRepo(t)
wantRoot, _ := filepath.EvalSymlinks(host)
for _, start := range []string{priv, filepath.Join(priv, DefaultWorkspace, "memory")} {
if err := os.MkdirAll(start, 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveProjectRoot(start)
if err != nil {
t.Fatalf("resolveProjectRoot(%q) error: %v", start, err)
}
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("resolveProjectRoot(%q) = %q, want host root %q (must skip the private <username>/.git)", start, gotRoot, wantRoot)
}
}
}
func TestResolveProjectRoot_NormalRepoUnchanged(t *testing.T) {
// A repo with no .eeco beside its .git resolves exactly like FindRepoRoot.
root := newRepo(t)
deep := filepath.Join(root, "a", "b")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveProjectRoot(deep)
if err != nil {
t.Fatal(err)
}
wantRoot, _ := filepath.EvalSymlinks(root)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("resolveProjectRoot = %q, want %q", gotRoot, wantRoot)
}
}
func TestResolveProjectRoot_PrivateOnlyFallsBack(t *testing.T) {
// A private workspace repo with no host repo above it (a shape eeco init
// never produces) falls back to the private repo rather than erroring, so
// the fix is never worse than a plain FindRepoRoot.
base := t.TempDir()
priv := filepath.Join(base, "tester")
if err := os.MkdirAll(filepath.Join(priv, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(priv, DefaultWorkspace), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveProjectRoot(priv)
if err != nil {
t.Fatalf("resolveProjectRoot fallback error: %v", err)
}
wantRoot, _ := filepath.EvalSymlinks(priv)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("resolveProjectRoot fallback = %q, want private repo %q", gotRoot, wantRoot)
}
}
func TestLoad_FromInsidePrivateWorkspaceRepo(t *testing.T) {
// The FIX-1 repro end-to-end: launched from <repo>/<username>/, Load must
// resolve the host repo root and the real workspace, not the nested
// private repo (which made <repo>/<username>/<username>/.eeco missing).
host, priv := newHostWithPrivateRepo(t)
write(t, host, "go.mod", "module x\n")
cfg, err := Load(priv, "")
if err != nil {
t.Fatal(err)
}
wantRoot, _ := filepath.EvalSymlinks(host)
if gotRoot, _ := filepath.EvalSymlinks(cfg.RepoRoot); gotRoot != wantRoot {
t.Fatalf("RepoRoot = %q, want host root %q", gotRoot, wantRoot)
}
wantUserDir, _ := filepath.EvalSymlinks(priv)
if gotUserDir, _ := filepath.EvalSymlinks(cfg.UserDir); gotUserDir != wantUserDir {
t.Fatalf("UserDir = %q, want %q", gotUserDir, wantUserDir)
}
wantWS, _ := filepath.EvalSymlinks(filepath.Join(host, "tester", DefaultWorkspace))
if gotWS, _ := filepath.EvalSymlinks(cfg.Workspace); gotWS != wantWS {
t.Fatalf("Workspace = %q, want %q", gotWS, wantWS)
}
}
func TestDetectProfile(t *testing.T) {
cases := []struct {
name string