Skip to content

Commit 49dbe7a

Browse files
njbrakeclaude
andcommitted
fix(multi-repo): address review feedback on PR #974
Bundles six review fixes: - TUI strict-mode: add 'p' to the bare-lowercase blocklist so the new Projects dialog does not bypass the strict-mode contract. In strict mode, reach Projects via the command palette. - TUI discoverability: add ("p", "Projects") to the help overlay (non-strict) and a "projects" entry to the command palette so the feature is reachable from `?` and Ctrl+K. Bumped help DIALOG_HEIGHT 43 -> 44 to fit the extra row. - Web API: introduce typed RegistryError (Conflict / NotFound / Other) in projects::add and projects::remove. Server now returns 409 only on conflicts and 500 on I/O errors; delete returns 404 only on missing entries. CLI and TUI callers compile unchanged via Into<anyhow::Error>. - Refactor: extract apply_picked_project() and open_projects_picker() out of handle_worktree_config_key, drop the #[allow(clippy::too_many_lines)]. - Docs: add docs/guides/multi-repo-workspaces.md with the full user story (registry, scopes, CLI/TUI/web flows, limitations). Wire it into website sync-docs PAGES, URL_MAP, and docsNav. - E2E: add tests/e2e/project_registry.rs with six tests covering add/list/remove round-trip, JSON output, non-git rejection, duplicate-within-scope, cross-scope override, --project requires --worktree, and unknown --project name fast-fail. Verified: cargo fmt, cargo clippy --all-targets --features serve, cargo test --features serve --lib (1604 pass), cargo test --test e2e project_registry (6 pass), web/tsc --noEmit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b7a35fa commit 49dbe7a

11 files changed

Lines changed: 528 additions & 60 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Multi-Repo Workspaces
2+
3+
Run a single AoE session across several git repositories at once. Each repo gets its own worktree on a shared branch name, all rooted under one workspace directory, attached to one tmux session.
4+
5+
Use this when a unit of work, a feature, a bug fix, an investigation, touches more than one repo and you want one agent driving all of them, not N agents you have to mentally reconcile.
6+
7+
## When to Use
8+
9+
| Scenario | Multi-repo? |
10+
|---|---|
11+
| Bug spans backend and frontend repos | Yes |
12+
| Refactor across an OSS core and a private wrapper | Yes |
13+
| Feature limited to a single repo | No, regular session |
14+
| Investigating logs that touch many repos | Yes, agent picks the relevant ones |
15+
| OSS core is pinned and rarely changes | Use [`on_create` hooks](repo-config.md) instead |
16+
17+
## Quick Start
18+
19+
### 1. Register your repos once
20+
21+
```bash
22+
aoe project add /path/to/backend
23+
aoe project add /path/to/frontend
24+
aoe project add /path/to/shared-lib
25+
```
26+
27+
`aoe project list` shows what is registered.
28+
29+
### 2. Start a multi-repo session
30+
31+
CLI:
32+
33+
```bash
34+
aoe add /path/to/backend \
35+
--project frontend \
36+
--project shared-lib \
37+
-w feat/auth-rewrite -b
38+
```
39+
40+
TUI: open the new-session dialog (`n`), enter the worktree branch, focus the **Extra Repos** field, press `Ctrl+R`, and pick the registered projects you want to include.
41+
42+
Web: `+ New session`, pick a primary repo, then click registered projects in the **Extra repos** picker (or paste a path with the free-text input).
43+
44+
### 3. The agent sees one workspace
45+
46+
The session starts in the workspace root with all the worktrees as siblings:
47+
48+
```
49+
~/aoe-workspaces/feat-auth-rewrite/
50+
├── backend/ ← branch feat/auth-rewrite
51+
├── frontend/ ← branch feat/auth-rewrite
52+
└── shared-lib/ ← branch feat/auth-rewrite
53+
```
54+
55+
The agent navigates between them like any normal multi-repo working tree. Use `cd` and standard git commands; AoE does not impose any cross-repo orchestration.
56+
57+
## The Project Registry
58+
59+
Saved repo paths the multi-repo pickers draw from. Two scopes:
60+
61+
| Scope | File | Visibility |
62+
|---|---|---|
63+
| Global | `<app_dir>/projects.json` | Every profile |
64+
| Profile | `<app_dir>/profiles/{profile}/projects.json` | Only that profile |
65+
66+
`<app_dir>` is `$XDG_CONFIG_HOME/agent-of-empires/` on Linux, `~/.agent-of-empires/` on macOS.
67+
68+
When both scopes hold the same canonical path, the **profile entry wins** in merged views (this is how `--allow-override` is meant to be used: stage a profile-specific name on top of a global default).
69+
70+
### Default scope
71+
72+
| Invocation | Default scope |
73+
|---|---|
74+
| `aoe project add <path>` | Global |
75+
| `aoe -p <profile> project add <path>` | Profile |
76+
77+
Pass `--scope global` or `--scope profile` to override.
78+
79+
### Cross-scope collisions
80+
81+
```bash
82+
aoe project add /repo/foo # global
83+
aoe -p other project add /repo/foo # ERROR: same path in global scope
84+
aoe -p other project add /repo/foo --allow-override # OK, profile shadows global
85+
```
86+
87+
## CLI Reference
88+
89+
```bash
90+
# List
91+
aoe project list # merged (global + active profile)
92+
aoe project list --scope global # globals only
93+
aoe project list --scope profile # active profile only
94+
aoe project list --json # machine-readable
95+
96+
# Add
97+
aoe project add /path/to/repo # global, name = basename
98+
aoe project add /path/to/repo --name shortname # custom display name
99+
aoe project add /path/to/repo --scope profile # profile-only
100+
aoe project add /path/to/repo --allow-override # shadow other-scope entry
101+
102+
# Remove
103+
aoe project remove backend # by name (case-insensitive)
104+
aoe project remove /path/to/repo # by canonical path
105+
aoe project remove backend --scope profile
106+
107+
# Use in a session
108+
aoe add /path/to/primary --project name1 --project name2 -w branch -b
109+
aoe add /path/to/primary --repo /literal/path --project registered -w branch -b
110+
```
111+
112+
`--repo` and `--project` may be mixed; the union is passed to the workspace builder. The builder rejects duplicate repo names, so the same repo via two paths is a hard error.
113+
114+
`aoe list --json` includes a `workspace_repos` array for each session; the array is empty for single-repo sessions.
115+
116+
## Web Dashboard
117+
118+
The Projects page (folder icon in the sidebar footer) is full CRUD over the registry: add, remove, switch scope, opt into `allow_override`. Read-only servers (`aoe serve --read-only`) hide the destructive controls.
119+
120+
The new-session wizard surfaces the registry as toggleable chips on the Project step. The free-text input still works for paths that aren't registered.
121+
122+
Multi-repo sessions are bucketed into a single **Multi-repo** group at the bottom of the sidebar, regardless of which repo was chosen as the primary. Each session row shows a chip per repo under the title.
123+
124+
## Limitations
125+
126+
These are out of scope for the current release; tracked separately:
127+
128+
- **One branch name per workspace**: every repo gets the same `-w <branch>` value. Per-repo branch names is a future feature.
129+
- **No agent-driven repo pull-in mid-session**: if the agent realizes it needs another repo, you have to start a new session. Tracked alongside the orchestrator work.
130+
- **No saved workspace templates** ("named bundles of repos"): each session picks the set fresh. If your bundle is fixed, register the repos and select them all from the picker.
131+
- **No per-repo PR tracking**: AoE does not track PRs today. Coordinated PR workflow happens outside AoE.
132+
133+
## Related
134+
135+
- [Worktrees Reference](worktrees.md) — how the per-repo worktrees are created.
136+
- [Repository Configuration & Hooks](repo-config.md)`on_create` hooks for fixed sibling repos that don't need a registry entry.
137+
- [CLI Reference](../cli/reference.md) — full `aoe project` and `aoe add --project` flag listing.

src/server/api/projects.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use axum::{
1212
use serde::{Deserialize, Serialize};
1313

1414
use crate::git::GitWorktree;
15-
use crate::session::projects;
15+
use crate::session::projects::{self, RegistryError};
1616
use crate::session::{Project, ProjectScope};
1717

1818
use super::AppState;
@@ -143,8 +143,18 @@ pub async fn create_project(
143143
let project = Project::new(name, canonical.to_string_lossy(), scope);
144144
match projects::add(&state.profile, scope, project, body.allow_override) {
145145
Ok(saved) => (StatusCode::CREATED, Json(ProjectResponse::from(saved))).into_response(),
146-
Err(e) => (
146+
Err(RegistryError::Conflict(msg)) => (
147147
StatusCode::CONFLICT,
148+
Json(serde_json::json!({"error": "conflict", "message": msg})),
149+
)
150+
.into_response(),
151+
Err(RegistryError::NotFound(msg)) => (
152+
StatusCode::NOT_FOUND,
153+
Json(serde_json::json!({"error": "not_found", "message": msg})),
154+
)
155+
.into_response(),
156+
Err(RegistryError::Other(e)) => (
157+
StatusCode::INTERNAL_SERVER_ERROR,
148158
Json(serde_json::json!({"error": "add_failed", "message": e.to_string()})),
149159
)
150160
.into_response(),
@@ -190,9 +200,19 @@ pub async fn delete_project(
190200

191201
match projects::remove(&state.profile, scope, &name) {
192202
Ok(removed) => (StatusCode::OK, Json(ProjectResponse::from(removed))).into_response(),
193-
Err(e) => (
203+
Err(RegistryError::NotFound(msg)) => (
194204
StatusCode::NOT_FOUND,
195-
Json(serde_json::json!({"error": "not_found", "message": e.to_string()})),
205+
Json(serde_json::json!({"error": "not_found", "message": msg})),
206+
)
207+
.into_response(),
208+
Err(RegistryError::Conflict(msg)) => (
209+
StatusCode::CONFLICT,
210+
Json(serde_json::json!({"error": "conflict", "message": msg})),
211+
)
212+
.into_response(),
213+
Err(RegistryError::Other(e)) => (
214+
StatusCode::INTERNAL_SERVER_ERROR,
215+
Json(serde_json::json!({"error": "remove_failed", "message": e.to_string()})),
196216
)
197217
.into_response(),
198218
}

src/session/projects.rs

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,47 @@
33
//! - Global: `<app_dir>/projects.json`, visible from every profile.
44
//! - Profile: `<app_dir>/profiles/{profile}/projects.json`, visible only inside that profile.
55
6-
use anyhow::{bail, Result};
6+
use anyhow::Result;
77
use serde::{Deserialize, Serialize};
88
use std::fs;
99
use std::path::{Path, PathBuf};
10+
use thiserror::Error;
1011
use tracing::warn;
1112

1213
use super::{get_app_dir, get_profile_dir};
1314

15+
/// Distinct failure modes for registry mutations. The web layer maps these to
16+
/// HTTP status codes (Conflict → 409, NotFound → 404, Other → 500); CLI/TUI
17+
/// callers convert via `Into<anyhow::Error>` and surface the message verbatim.
18+
#[derive(Debug, Error)]
19+
pub enum RegistryError {
20+
/// A project with the same name or canonical path already exists in the
21+
/// target scope, or in the other scope when `allow_override` is false.
22+
#[error("{0}")]
23+
Conflict(String),
24+
25+
/// `remove` could not find a project matching the given name or path in
26+
/// the requested scope.
27+
#[error("{0}")]
28+
NotFound(String),
29+
30+
/// Any other failure (I/O, JSON parse, missing app dir).
31+
#[error(transparent)]
32+
Other(#[from] anyhow::Error),
33+
}
34+
35+
impl From<std::io::Error> for RegistryError {
36+
fn from(e: std::io::Error) -> Self {
37+
RegistryError::Other(e.into())
38+
}
39+
}
40+
41+
impl From<serde_json::Error> for RegistryError {
42+
fn from(e: serde_json::Error) -> Self {
43+
RegistryError::Other(e.into())
44+
}
45+
}
46+
1447
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1548
#[serde(rename_all = "lowercase")]
1649
pub enum ProjectScope {
@@ -154,33 +187,33 @@ pub fn add(
154187
scope: ProjectScope,
155188
mut project: Project,
156189
allow_override: bool,
157-
) -> Result<Project> {
190+
) -> std::result::Result<Project, RegistryError> {
158191
project.scope = scope;
159192
let path_buf = PathBuf::from(&project.path);
160193
let canonical = path_buf.canonicalize().unwrap_or_else(|_| path_buf.clone());
161194
project.path = canonical.to_string_lossy().to_string();
162195

163196
let mut existing = match scope {
164-
ProjectScope::Global => load_global()?,
165-
ProjectScope::Profile => load_profile(profile)?,
197+
ProjectScope::Global => load_global().map_err(RegistryError::Other)?,
198+
ProjectScope::Profile => load_profile(profile).map_err(RegistryError::Other)?,
166199
};
167200

168201
for p in &existing {
169202
if p.name.eq_ignore_ascii_case(&project.name) {
170-
bail!(
203+
return Err(RegistryError::Conflict(format!(
171204
"Project '{}' already registered in {} scope (as '{}')",
172205
project.name,
173206
scope.as_str(),
174207
p.name,
175-
);
208+
)));
176209
}
177210
if canonical_key(&p.path) == canonical_key(&project.path) {
178-
bail!(
211+
return Err(RegistryError::Conflict(format!(
179212
"Path '{}' already registered as '{}' in {} scope",
180213
project.path,
181214
p.name,
182215
scope.as_str()
183-
);
216+
)));
184217
}
185218
}
186219

@@ -195,7 +228,7 @@ pub fn add(
195228
};
196229
for p in &other {
197230
if canonical_key(&p.path) == canonical_key(&project.path) {
198-
bail!(
231+
return Err(RegistryError::Conflict(format!(
199232
"Path '{}' is already registered as '{}' in {} scope.\n\
200233
Tip: remove it first with `aoe project remove {} --scope {}`,\n\
201234
or pass `--allow-override` to keep both entries (the profile entry shadows the global entry in merged views).",
@@ -204,22 +237,26 @@ pub fn add(
204237
other_scope.as_str(),
205238
p.name,
206239
other_scope.as_str(),
207-
);
240+
)));
208241
}
209242
}
210243
}
211244

212245
existing.push(project.clone());
213-
save_scope(profile, scope, &existing)?;
246+
save_scope(profile, scope, &existing).map_err(RegistryError::Other)?;
214247
Ok(project)
215248
}
216249

217250
/// Remove the entry matching `name_or_path` from the given scope. Returns the
218251
/// removed project, or errors if no match was found.
219-
pub fn remove(profile: &str, scope: ProjectScope, name_or_path: &str) -> Result<Project> {
252+
pub fn remove(
253+
profile: &str,
254+
scope: ProjectScope,
255+
name_or_path: &str,
256+
) -> std::result::Result<Project, RegistryError> {
220257
let mut existing = match scope {
221-
ProjectScope::Global => load_global()?,
222-
ProjectScope::Profile => load_profile(profile)?,
258+
ProjectScope::Global => load_global().map_err(RegistryError::Other)?,
259+
ProjectScope::Profile => load_profile(profile).map_err(RegistryError::Other)?,
223260
};
224261

225262
let canonical_target = canonical_key(name_or_path);
@@ -229,10 +266,14 @@ pub fn remove(profile: &str, scope: ProjectScope, name_or_path: &str) -> Result<
229266
p.name.eq_ignore_ascii_case(name_or_path) || canonical_key(&p.path) == canonical_target
230267
})
231268
.ok_or_else(|| {
232-
anyhow::anyhow!("No project '{}' in {} scope", name_or_path, scope.as_str())
269+
RegistryError::NotFound(format!(
270+
"No project '{}' in {} scope",
271+
name_or_path,
272+
scope.as_str()
273+
))
233274
})?;
234275
let removed = existing.remove(idx);
235-
save_scope(profile, scope, &existing)?;
276+
save_scope(profile, scope, &existing).map_err(RegistryError::Other)?;
236277
Ok(removed)
237278
}
238279

src/tui/components/help.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::session::config::SortOrder;
77
use crate::tui::styles::Theme;
88

99
const DIALOG_WIDTH: u16 = 50;
10-
const DIALOG_HEIGHT: u16 = 43;
10+
const DIALOG_HEIGHT: u16 = 44;
1111
#[cfg(test)]
1212
const BORDER_HEIGHT: u16 = 2;
1313
#[cfg(test)]
@@ -118,6 +118,7 @@ fn shortcuts(strict: bool) -> Vec<(&'static str, Vec<(&'static str, &'static str
118118
("n/N", "Next/prev match"),
119119
("s", "Settings"),
120120
("P", "Profiles"),
121+
("p", "Projects"),
121122
("R", "Serve (LAN / Tunnel)"),
122123
("u", "Update aoe (when available)"),
123124
("Ctrl+x", "Dismiss update bar (this session)"),

src/tui/dialogs/command_palette.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ pub fn builtin_commands(serve_enabled: bool, strict_hotkeys: bool) -> Vec<Palett
207207
hotkey: "P",
208208
payload: PaletteAction::Key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT)),
209209
},
210+
PaletteCommand {
211+
id: "projects",
212+
title: "Manage projects".to_string(),
213+
group: PaletteGroup::Settings,
214+
keywords: vec!["registry", "repos", "multi-repo", "workspace"],
215+
hotkey: "p",
216+
payload: PaletteAction::Key(key('p')),
217+
},
210218
PaletteCommand {
211219
id: "help",
212220
title: "Show keyboard shortcuts".to_string(),

0 commit comments

Comments
 (0)