diff --git a/Cargo.lock b/Cargo.lock index d534613..35dba04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ name = "east-workspace" version = "0.1.2" dependencies = [ + "east-config", "tempfile", "thiserror", ] diff --git a/README.md b/README.md index b3aa76e..252fa39 100644 --- a/README.md +++ b/README.md @@ -15,21 +15,25 @@ A fast, manifest-driven development toolkit written in Rust. ## Status -**Phase 2** — complete. +**Phase 2.6** — complete. - **Phase 1:** Multi-repo management — `east init`, `east update`, `east list`, `east status`, `east manifest --resolve` -- **Phase 2:** Configuration & extension commands — `east config get/set/unset/list`, manifest-declared commands (`exec`/`script`/`executable`), PATH-based `east-` discovery, template engine +- **Phase 2:** Configuration & extension commands — `east config`, manifest-declared commands, PATH-based discovery, template engine +- **Phase 2.6:** Topology correction — manifest lives in a real git repo inside the workspace ## Quick Start ```bash -# Initialize a workspace from a manifest repo -east init https://github.com/your-org/manifest +# Create a new workspace with a template manifest +east init -# Initialize from a specific branch -east init https://github.com/your-org/manifest -r develop +# Or use an existing local app as manifest repo +east init -l ./my-app -# Update all projects +# Or clone a manifest repo from remote +east init -m https://github.com/your-org/sdk-manifest + +# Update all projects declared in the manifest east update # Run a manifest-declared command diff --git a/README.zh-CN.md b/README.zh-CN.md index 429a410..50f2c53 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,21 +15,25 @@ ## 状态 -**Phase 2** — 已完成。 +**Phase 2.6** — 已完成。 - **Phase 1:** 多仓库管理 — `east init`、`east update`、`east list`、`east status`、`east manifest --resolve` -- **Phase 2:** 配置与扩展命令 — `east config get/set/unset/list`、manifest 声明命令(`exec`/`script`/`executable`)、PATH 上的 `east-` 发现、模板引擎 +- **Phase 2:** 配置与扩展命令 — `east config`、manifest 声明命令、PATH 发现、模板引擎 +- **Phase 2.6:** 拓扑修正 — manifest 住在 workspace 内的真实 git 仓库中 ## 快速开始 ```bash -# 从 manifest 仓库初始化工作空间 -east init https://github.com/your-org/manifest +# 创建带模板 manifest 的新 workspace +east init -# 从指定分支初始化 -east init https://github.com/your-org/manifest -r develop +# 使用已有本地应用作为 manifest 仓库 +east init -l ./my-app -# 更新所有项目 +# 从远端克隆 manifest 仓库 +east init -m https://github.com/your-org/sdk-manifest + +# 更新 manifest 中声明的所有项目 east update # 运行 manifest 中声明的命令 diff --git a/crates/east-cli/src/main.rs b/crates/east-cli/src/main.rs index 643174c..fd8752c 100644 --- a/crates/east-cli/src/main.rs +++ b/crates/east-cli/src/main.rs @@ -36,13 +36,31 @@ struct Cli { #[derive(Subcommand)] #[command(allow_external_subcommands = true)] enum Commands { - /// Initialize a new east workspace from a manifest. + /// Initialize a new east workspace. + /// + /// Three modes: + /// - `east init -l PATH` — use an existing local directory as manifest repo + /// - `east init -m URL` — clone a remote repository as manifest repo + /// - `east init [DIR]` — create a new template manifest repo (default: "manifest") Init { - /// URL or local path to the manifest repository. - manifest: String, - /// Branch or tag to use when fetching from a remote repository. - #[arg(short, long)] - revision: Option, + /// Use an existing local directory as manifest repo. + #[arg(short, long, conflicts_with = "manifest_url")] + local: Option, + /// Clone a remote URL as manifest repo. + #[arg(short, long, conflicts_with = "local")] + manifest_url: Option, + /// Branch or tag for -m mode. + #[arg(long = "mr", requires = "manifest_url")] + manifest_rev: Option, + /// Manifest file name (default: east.yml). + #[arg(long = "mf")] + manifest_file: Option, + /// Force init even if .east/ already exists. + #[arg(long)] + force: bool, + /// Directory name for template mode or workspace dir for -m mode. + #[arg(conflicts_with = "local")] + dir: Option, }, /// Update (fetch/checkout) all projects in the workspace. Update { @@ -133,7 +151,24 @@ fn main() -> miette::Result<()> { async fn run(cli: Cli) -> miette::Result<()> { match cli.command { - Commands::Init { manifest, revision } => cmd_init(&manifest, revision.as_deref()).await, + Commands::Init { + local, + manifest_url, + manifest_rev, + manifest_file, + force, + dir, + } => { + let mf = manifest_file.as_deref().unwrap_or("east.yml"); + if let Some(local_path) = &local { + cmd_init_local(local_path, mf, force) + } else if let Some(url) = &manifest_url { + cmd_init_remote(url, manifest_rev.as_deref(), mf, dir.as_deref(), force).await + } else { + let dir_name = dir.as_deref().unwrap_or("manifest"); + cmd_init_template(dir_name, mf, force) + } + } Commands::Update { force, projects } => cmd_update(force, &projects).await, Commands::List => cmd_list(), Commands::Status => cmd_status().await, @@ -149,52 +184,228 @@ async fn run(cli: Cli) -> miette::Result<()> { } } -async fn cmd_init(manifest_source: &str, revision: Option<&str>) -> miette::Result<()> { +/// Write the `\[manifest\]` section to `.east/config.toml`. +fn write_manifest_config( + workspace_root: &Path, + manifest_path: &str, + manifest_file: &str, +) -> miette::Result<()> { + let config_path = workspace_root.join(".east/config.toml"); + let mut store = east_config::ConfigStore::load_from_file(&config_path) + .into_diagnostic() + .wrap_err("failed to load config")?; + let mc = east_config::ManifestConfig::new(manifest_path, manifest_file); + mc.write_to_store(&mut store); + store + .save_to_file(&config_path) + .into_diagnostic() + .wrap_err("failed to save config")?; + Ok(()) +} + +/// Check that .east/ doesn't already exist (unless --force). +fn check_not_already_initialized(workspace_root: &Path, force: bool) -> miette::Result<()> { + if workspace_root.join(".east").exists() && !force { + bail!( + "workspace already initialized at {}. Use --force to reinitialize.", + workspace_root.display() + ); + } + Ok(()) +} + +/// Mode L: use an existing local directory as manifest repo. +fn cmd_init_local(local_path: &str, manifest_file: &str, force: bool) -> miette::Result<()> { let cwd = std::env::current_dir().into_diagnostic()?; - let manifest_path = PathBuf::from(manifest_source); + let repo_path = cwd.join(local_path); - if manifest_path.is_dir() { - // Local directory: it's a git repo containing east.yml - let source_manifest = manifest_path.join("east.yml"); - if !source_manifest.exists() { - bail!("no east.yml found in {}", manifest_path.display()); - } - std::fs::copy(&source_manifest, cwd.join("east.yml")) - .into_diagnostic() - .wrap_err("failed to copy east.yml")?; - } else if manifest_path.is_file() { - // Local file: copy it directly - std::fs::copy(&manifest_path, cwd.join("east.yml")) + if !repo_path.is_dir() { + bail!("directory not found: {}", repo_path.display()); + } + + if !repo_path.join(manifest_file).exists() { + bail!( + "manifest file '{}' not found in {}", + manifest_file, + repo_path.display() + ); + } + + // Workspace root is the parent of the local path + let workspace_root = repo_path + .parent() + .ok_or_else(|| miette::miette!("cannot determine workspace root from {local_path}"))?; + + check_not_already_initialized(workspace_root, force)?; + + // Create .east/ + Workspace::init(workspace_root) + .into_diagnostic() + .wrap_err("failed to initialize workspace")?; + + // Compute the relative manifest path (basename of the local path) + let manifest_dir_name = repo_path + .file_name() + .ok_or_else(|| miette::miette!("invalid path: {local_path}"))? + .to_string_lossy(); + + write_manifest_config(workspace_root, &manifest_dir_name, manifest_file)?; + + // Warn if not a git repo + if !repo_path.join(".git").exists() { + tracing::warn!( + "{} is not a git repository. Consider running `git init` in it.", + repo_path.display() + ); + } + + info!("initialized east workspace at {}", workspace_root.display()); + Ok(()) +} + +/// Mode M: clone a remote URL as manifest repo. +async fn cmd_init_remote( + url: &str, + revision: Option<&str>, + manifest_file: &str, + workspace_dir: Option<&str>, + force: bool, +) -> miette::Result<()> { + let cwd = std::env::current_dir().into_diagnostic()?; + let workspace_root = if let Some(dir) = workspace_dir { + let p = cwd.join(dir); + std::fs::create_dir_all(&p) .into_diagnostic() - .wrap_err("failed to copy manifest file")?; + .wrap_err("failed to create workspace directory")?; + p } else { - // Treat as a git URL: sparse-checkout only east.yml - let temp_dir = tempfile::tempdir() - .into_diagnostic() - .wrap_err("failed to create temp dir")?; - let clone_dest = temp_dir.path().join("manifest"); - Git::fetch_file(manifest_source, "east.yml", &clone_dest, revision) - .await - .into_diagnostic() - .wrap_err("failed to fetch east.yml from manifest repository")?; + cwd + }; - let source_manifest = clone_dest.join("east.yml"); - if !source_manifest.exists() { - bail!("no east.yml found in manifest repository"); - } - std::fs::copy(&source_manifest, cwd.join("east.yml")) - .into_diagnostic() - .wrap_err("failed to copy east.yml from cloned repo")?; + check_not_already_initialized(&workspace_root, force)?; + + // Derive repo name from URL (handle trailing slashes, .git suffix, SCP-style URLs) + let url_trimmed = url.trim_end_matches('/'); + let basename = url_trimmed + .rsplit_once('/') + .or_else(|| url_trimmed.rsplit_once(':')) + .map_or(url_trimmed, |(_, name)| name); + let repo_name = basename.strip_suffix(".git").unwrap_or(basename); + let repo_name = if repo_name.is_empty() { + "manifest" + } else { + repo_name + }; + + let clone_dest = workspace_root.join(repo_name); + + Git::clone(url, &clone_dest, revision) + .await + .into_diagnostic() + .wrap_err("failed to clone manifest repository")?; + + // Verify manifest file exists + if !clone_dest.join(manifest_file).exists() { + bail!( + "manifest file '{}' not found in cloned repository at {}", + manifest_file, + clone_dest.display() + ); } - // Initialize workspace + // Create .east/ + Workspace::init(&workspace_root) + .into_diagnostic() + .wrap_err("failed to initialize workspace")?; + + write_manifest_config(&workspace_root, repo_name, manifest_file)?; + + info!( + "initialized east workspace at {} with manifest repo {}", + workspace_root.display(), + repo_name + ); + Ok(()) +} + +/// Mode T: create a new template manifest repo. +fn cmd_init_template(dir_name: &str, manifest_file: &str, force: bool) -> miette::Result<()> { + let cwd = std::env::current_dir().into_diagnostic()?; + let repo_path = cwd.join(dir_name); + + check_not_already_initialized(&cwd, force)?; + + if repo_path.exists() && !force { + bail!( + "directory '{}' already exists. Use a different name or --force.", + dir_name + ); + } + + // Create template directory + std::fs::create_dir_all(&repo_path) + .into_diagnostic() + .wrap_err("failed to create template directory")?; + + // Write template east.yml + let template = format!( + r"version: 1 + +# self: +# path: {dir_name} + +remotes: [] + # - name: origin + # url-base: https://github.com/your-org + +defaults: {{}} + # remote: origin + # revision: main + +projects: [] + # - name: my-lib + # path: libs/my-lib +" + ); + std::fs::write(repo_path.join(manifest_file), template) + .into_diagnostic() + .wrap_err("failed to write template manifest")?; + + // Write .gitignore + std::fs::write( + repo_path.join(".gitignore"), + "*.swp\n*.swo\n*~\n.DS_Store\nThumbs.db\n", + ) + .into_diagnostic() + .wrap_err("failed to write .gitignore")?; + + // git init (don't commit — leave for user) + let output = std::process::Command::new("git") + .arg("init") + .arg(&repo_path) + .output() + .into_diagnostic() + .wrap_err("failed to run git init")?; + if !output.status.success() { + bail!( + "git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // Create .east/ Workspace::init(&cwd) .into_diagnostic() .wrap_err("failed to initialize workspace")?; - info!("initialized east workspace at {}", cwd.display()); - // Run update to clone all projects - do_update(&cwd, false, &[]).await + write_manifest_config(&cwd, dir_name, manifest_file)?; + + info!( + "initialized east workspace at {} with template manifest in {}", + cwd.display(), + dir_name + ); + Ok(()) } async fn cmd_update(force: bool, force_projects: &[String]) -> miette::Result<()> { @@ -210,7 +421,11 @@ async fn do_update( force: bool, force_projects: &[String], ) -> miette::Result<()> { - let manifest_path = workspace_root.join("east.yml"); + // Use workspace to resolve manifest path from config + let ws = Workspace::discover(workspace_root) + .into_diagnostic() + .wrap_err("failed to discover workspace")?; + let manifest_path = ws.manifest_path(); let manifest = Manifest::resolve(&manifest_path) .into_diagnostic() .wrap_err("failed to resolve manifest")?; @@ -260,7 +475,7 @@ async fn do_update( let mut handles = Vec::new(); for project in &projects { - let project_path = workspace_root.join(project.effective_path()); + let project_path = strip_unc_prefix(&workspace_root.join(project.effective_path())); let revision = manifest.project_revision(project).map(String::from); let clone_url = manifest.project_clone_url(project).ok(); let project_name = project.name.clone(); @@ -476,7 +691,7 @@ fn cmd_list() -> miette::Result<()> { "NAME", "PATH", "REVISION", "CLONED" ); for project in &projects { - let project_path = ws.root().join(project.effective_path()); + let project_path = strip_unc_prefix(&ws.root().join(project.effective_path())); let revision = manifest.project_revision(project).unwrap_or("-"); let cloned = if project_path.exists() { "yes" } else { "no" }; println!( @@ -506,7 +721,7 @@ async fn cmd_status() -> miette::Result<()> { "NAME", "STATUS", "HEAD", "BRANCH" ); for project in &projects { - let project_path = ws.root().join(project.effective_path()); + let project_path = strip_unc_prefix(&ws.root().join(project.effective_path())); if !project_path.exists() { println!( "{:<20} {:<12} {:<42} {:<10}", diff --git a/crates/east-cli/tests/cli_init.rs b/crates/east-cli/tests/cli_init.rs index cde75d0..77f5f7b 100644 --- a/crates/east-cli/tests/cli_init.rs +++ b/crates/east-cli/tests/cli_init.rs @@ -1,24 +1,25 @@ -//! Integration tests for `east init`. +//! Integration tests for `east init` Phase 2.6 — three init modes. use std::fs; +use std::path::Path; use std::process::Command; use assert_cmd::Command as AssertCmd; use predicates::prelude::*; use tempfile::TempDir; -/// Create a local git repo containing an `east.yml` manifest with a project -/// that points to another local repo. -fn setup_manifest_repo(dir: &tempfile::TempDir) -> (String, String) { - let manifest_repo = dir.path().join("manifest-repo"); - let project_repo = dir.path().join("project-repo"); +/// Helper: create a manifest repo (directory with east.yml + git init). +fn create_manifest_repo(parent: &Path, dir_name: &str, east_yml: &str) { + let repo = parent.join(dir_name); + fs::create_dir_all(&repo).unwrap(); + fs::write(repo.join("east.yml"), east_yml).unwrap(); - // Create the project repo with a commit Command::new("git") - .args(["init", "-b", "main"]) - .arg(&project_repo) + .args(["init"]) + .arg(&repo) .output() - .expect("git init project failed"); + .expect("git init failed"); + for (key, val) in [ ("user.email", "test@test.com"), ("user.name", "Test"), @@ -26,31 +27,179 @@ fn setup_manifest_repo(dir: &tempfile::TempDir) -> (String, String) { ] { Command::new("git") .arg("-C") - .arg(&project_repo) + .arg(&repo) .args(["config", key, val]) .output() .unwrap(); } - fs::write(project_repo.join("lib.rs"), "// project code\n").unwrap(); + Command::new("git") .arg("-C") - .arg(&project_repo) + .arg(&repo) .args(["add", "."]) .output() .unwrap(); Command::new("git") .arg("-C") - .arg(&project_repo) - .args(["commit", "-m", "init"]) + .arg(&repo) + .args(["commit", "-m", "init manifest"]) + .output() + .unwrap(); +} + +fn east_cmd(config_home: &Path) -> AssertCmd { + let mut cmd = AssertCmd::cargo_bin("east").unwrap(); + cmd.env("XDG_CONFIG_HOME", config_home); + cmd.env("APPDATA", config_home); + cmd +} + +// ── Mode L: local existing repo ───────────────────────────────────── + +#[test] +fn init_local_creates_workspace() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + create_manifest_repo(dir.path(), "my-app", "version: 1\n"); + + east_cmd(config_home.path()) + .args(["init", "-l", "my-app"]) + .current_dir(dir.path()) + .assert() + .success(); + + // .east/ should exist at workspace root (parent of my-app) + assert!(dir.path().join(".east").is_dir()); + // config.toml should have [manifest] section + let config = fs::read_to_string(dir.path().join(".east/config.toml")).unwrap(); + assert!( + config.contains("manifest"), + "config should have manifest section" + ); + assert!(config.contains("my-app"), "config should reference my-app"); +} + +#[test] +fn init_local_east_already_exists_fails() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + create_manifest_repo(dir.path(), "my-app", "version: 1\n"); + fs::create_dir_all(dir.path().join(".east")).unwrap(); + + east_cmd(config_home.path()) + .args(["init", "-l", "my-app"]) + .current_dir(dir.path()) + .assert() + .failure() + .stderr(predicate::str::contains("already")); +} + +#[test] +fn init_local_missing_manifest_fails() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + // Directory exists but no east.yml + fs::create_dir_all(dir.path().join("empty-dir")).unwrap(); + + east_cmd(config_home.path()) + .args(["init", "-l", "empty-dir"]) + .current_dir(dir.path()) + .assert() + .failure(); +} + +#[test] +fn init_local_nonexistent_dir_fails() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + east_cmd(config_home.path()) + .args(["init", "-l", "no-such-dir"]) + .current_dir(dir.path()) + .assert() + .failure(); +} + +// ── Mode T: template ──────────────────────────────────────────────── + +#[test] +fn init_template_default_dir() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + east_cmd(config_home.path()) + .args(["init"]) + .current_dir(dir.path()) + .assert() + .success(); + + // Default dir is "manifest" + assert!(dir.path().join("manifest").is_dir()); + assert!(dir.path().join("manifest/east.yml").exists()); + assert!(dir.path().join("manifest/.git").is_dir()); + assert!(dir.path().join("manifest/.gitignore").exists()); + assert!(dir.path().join(".east").is_dir()); + assert!(dir.path().join(".east/config.toml").exists()); +} + +#[test] +fn init_template_custom_dir() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + east_cmd(config_home.path()) + .args(["init", "my-sdk"]) + .current_dir(dir.path()) + .assert() + .success(); + + assert!(dir.path().join("my-sdk/east.yml").exists()); + assert!(dir.path().join("my-sdk/.git").is_dir()); + assert!(dir.path().join(".east").is_dir()); + + let config = fs::read_to_string(dir.path().join(".east/config.toml")).unwrap(); + assert!(config.contains("my-sdk"), "config should reference my-sdk"); +} + +#[test] +fn init_template_no_initial_commit() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + east_cmd(config_home.path()) + .args(["init"]) + .current_dir(dir.path()) + .assert() + .success(); + + // git log should fail (no commits yet) + let output = Command::new("git") + .arg("-C") + .arg(dir.path().join("manifest")) + .args(["log", "--oneline"]) .output() .unwrap(); + assert!(!output.status.success(), "should have no commits yet"); +} + +// ── Init + Update end-to-end with new topology ────────────────────── - // Create the manifest repo with east.yml +#[test] +fn init_local_then_update_works() { + let dir = TempDir::new().unwrap(); + let config_home = TempDir::new().unwrap(); + + // Create a project repo + let project_repo = dir.path().join("project-repo"); + fs::create_dir_all(&project_repo).unwrap(); Command::new("git") .args(["init", "-b", "main"]) - .arg(&manifest_repo) + .arg(&project_repo) .output() - .expect("git init manifest failed"); + .unwrap(); for (key, val) in [ ("user.email", "test@test.com"), ("user.name", "Test"), @@ -58,215 +207,98 @@ fn setup_manifest_repo(dir: &tempfile::TempDir) -> (String, String) { ] { Command::new("git") .arg("-C") - .arg(&manifest_repo) + .arg(&project_repo) .args(["config", key, val]) .output() .unwrap(); } - - let manifest_content = format!( - r"version: 1 - -remotes: - - name: local - url-base: {project_parent} - -defaults: - remote: local - revision: main - -projects: - - name: {project_name} -", - project_parent = project_repo.parent().unwrap().display(), - project_name = project_repo.file_name().unwrap().to_str().unwrap(), - ); - fs::write(manifest_repo.join("east.yml"), manifest_content).unwrap(); + fs::write(project_repo.join("lib.rs"), "// code\n").unwrap(); Command::new("git") .arg("-C") - .arg(&manifest_repo) + .arg(&project_repo) .args(["add", "."]) .output() .unwrap(); Command::new("git") .arg("-C") - .arg(&manifest_repo) - .args(["commit", "-m", "add manifest"]) + .arg(&project_repo) + .args(["commit", "-m", "init"]) .output() .unwrap(); - ( - manifest_repo.to_str().unwrap().to_string(), - project_repo - .file_name() - .unwrap() - .to_str() - .unwrap() - .to_string(), - ) -} - -#[test] -fn init_creates_workspace_from_local_manifest_repo() { - let fixture = TempDir::new().unwrap(); - let (manifest_url, project_name) = setup_manifest_repo(&fixture); + // Create workspace dir with manifest repo inside + let ws = dir.path().join("workspace"); + fs::create_dir_all(&ws).unwrap(); - let workspace = TempDir::new().unwrap(); - - AssertCmd::cargo_bin("east") - .unwrap() - .args(["init", &manifest_url]) - .current_dir(workspace.path()) - .assert() - .success(); - - // .east/ directory should exist - assert!(workspace.path().join(".east").is_dir()); - // east.yml should exist - assert!(workspace.path().join("east.yml").exists()); - // The project should be cloned - assert!( - workspace.path().join(&project_name).exists(), - "project {project_name} should be cloned" - ); - assert!(workspace.path().join(&project_name).join("lib.rs").exists()); -} - -#[test] -fn init_from_branch_with_revision_flag() { - let fixture = TempDir::new().unwrap(); - let (_manifest_path, project_name) = setup_manifest_repo(&fixture); - let manifest_repo = fixture.path().join("manifest-repo"); - - // Create a new branch "dev" with a different east.yml - Command::new("git") - .arg("-C") - .arg(&manifest_repo) - .args(["checkout", "-b", "dev"]) - .output() - .unwrap(); - let dev_manifest = format!( + // Create manifest repo with east.yml referencing project-repo + let manifest = format!( r"version: 1 remotes: - name: local - url-base: {project_parent} + url-base: {parent} defaults: remote: local revision: main projects: - - name: {project_name} - path: dev/{project_name} + - name: project-repo ", - project_parent = fixture - .path() - .join("project-repo") - .parent() - .unwrap() - .display(), - project_name = project_name, + parent = dir.path().display(), ); - fs::write(manifest_repo.join("east.yml"), dev_manifest).unwrap(); - Command::new("git") - .arg("-C") - .arg(&manifest_repo) - .args(["add", "."]) - .output() - .unwrap(); - Command::new("git") - .arg("-C") - .arg(&manifest_repo) - .args(["commit", "-m", "dev branch manifest"]) - .output() - .unwrap(); - - // Switch back to main so we can verify -r fetches dev - Command::new("git") - .arg("-C") - .arg(&manifest_repo) - .args(["checkout", "main"]) - .output() - .unwrap(); + create_manifest_repo(&ws, "my-app", &manifest); - let workspace = TempDir::new().unwrap(); - - // Use file:// URL to trigger git clone path (local dir path skips revision) - let file_url = format!("file://{}", manifest_repo.display()); + // Init with -l + east_cmd(config_home.path()) + .args(["init", "-l", "my-app"]) + .current_dir(&ws) + .assert() + .success(); - AssertCmd::cargo_bin("east") - .unwrap() - .args(["init", &file_url, "-r", "dev"]) - .current_dir(workspace.path()) + // Update should resolve manifest from my-app/east.yml + east_cmd(config_home.path()) + .args(["update"]) + .current_dir(&ws) .assert() .success(); - // The project should be cloned under dev/ path (from dev branch manifest) + // Project should be cloned assert!( - workspace.path().join("dev").join(&project_name).exists(), - "project should be cloned at dev/{project_name} per dev branch manifest" + ws.join("project-repo/lib.rs").exists(), + "project-repo should be cloned by update" ); } +// ── Mode M: clone from remote ─────────────────────────────────────── + #[test] -fn init_from_tag_with_revision_flag() { +fn init_remote_clones_manifest_repo() { let fixture = TempDir::new().unwrap(); - let (_manifest_path, _project_name) = setup_manifest_repo(&fixture); - let manifest_repo = fixture.path().join("manifest-repo"); + let config_home = TempDir::new().unwrap(); - // Tag the current commit - Command::new("git") - .arg("-C") - .arg(&manifest_repo) - .args(["tag", "v1.0"]) - .output() - .unwrap(); + // Create a manifest repo to clone from + create_manifest_repo(fixture.path(), "sdk-manifest", "version: 1\n"); let workspace = TempDir::new().unwrap(); - // Use file:// URL to trigger git clone path - let file_url = format!("file://{}", manifest_repo.display()); + // Use the local path directly (east init -m supports local paths via git clone) + let manifest_path = fixture.path().join("sdk-manifest"); - AssertCmd::cargo_bin("east") - .unwrap() - .args(["init", &file_url, "-r", "v1.0"]) + east_cmd(config_home.path()) + .args(["init", "-m", manifest_path.to_str().unwrap()]) .current_dir(workspace.path()) .assert() .success(); + // .east/ should exist assert!(workspace.path().join(".east").is_dir()); - assert!(workspace.path().join("east.yml").exists()); -} - -#[test] -fn init_help_shows_revision_option() { - AssertCmd::cargo_bin("east") - .unwrap() - .args(["init", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("--revision")); -} - -#[test] -fn init_fails_with_invalid_manifest() { - let workspace = TempDir::new().unwrap(); - - AssertCmd::cargo_bin("east") - .unwrap() - .args(["init", "/nonexistent/path"]) - .current_dir(workspace.path()) - .assert() - .failure(); -} - -#[test] -fn init_shows_help() { - AssertCmd::cargo_bin("east") - .unwrap() - .args(["init", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("manifest")); + // Manifest repo should be cloned as "sdk-manifest" + assert!(workspace.path().join("sdk-manifest/east.yml").exists()); + // Config should reference it + let config = fs::read_to_string(workspace.path().join(".east/config.toml")).unwrap(); + assert!( + config.contains("sdk-manifest"), + "config should reference sdk-manifest" + ); } diff --git a/crates/east-cli/tests/cli_update.rs b/crates/east-cli/tests/cli_update.rs index f031870..d706ee7 100644 --- a/crates/east-cli/tests/cli_update.rs +++ b/crates/east-cli/tests/cli_update.rs @@ -1,4 +1,6 @@ //! Integration tests for `east update` with concurrent projects. +//! +//! Migrated to Phase 2.6 topology: manifest repo lives inside workspace. use std::fs; use std::process::Command; @@ -7,12 +9,21 @@ use assert_cmd::Command as AssertCmd; use predicates::prelude::*; use tempfile::TempDir; -/// Create N local project repos and a manifest repo referencing them. +/// Create N local project repos and a workspace with manifest repo inside it. +/// +/// Phase 2.6 layout: +/// ```text +/// workspace/ +/// ├── .east/ +/// ├── manifest-repo/ (contains east.yml) +/// ├── project-0/ (cloned by east update) +/// └── project-1/ +/// ``` fn setup_multi_project_workspace(n: usize) -> (TempDir, TempDir) { let fixture = TempDir::new().unwrap(); let workspace = TempDir::new().unwrap(); - // Create N project repos + // Create N project repos in fixture dir (simulating remote repos) let mut project_entries = String::new(); for i in 0..n { let name = format!("project-{i}"); @@ -21,8 +32,8 @@ fn setup_multi_project_workspace(n: usize) -> (TempDir, TempDir) { project_entries.push_str(&format!(" - name: {name}\n")); } - // Create manifest repo - let manifest_repo = fixture.path().join("manifest-repo"); + // Create manifest repo inside workspace + let manifest_repo = workspace.path().join("manifest-repo"); Command::new("git") .args(["init", "-b", "main"]) .arg(&manifest_repo) @@ -60,10 +71,18 @@ projects: .output() .unwrap(); - // Initialize workspace + // Initialize workspace using Phase 2.6 Mode L AssertCmd::cargo_bin("east") .unwrap() - .args(["init", manifest_repo.to_str().unwrap()]) + .args(["init", "-l", "manifest-repo"]) + .current_dir(workspace.path()) + .assert() + .success(); + + // Run update to clone all projects + AssertCmd::cargo_bin("east") + .unwrap() + .arg("update") .current_dir(workspace.path()) .assert() .success(); @@ -198,18 +217,14 @@ fn manifest_resolve_outputs_yaml() { fn update_skips_dirty_project_checkout() { let (_fixture, workspace) = setup_multi_project_workspace(2); - // Make project-0 dirty fs::write(workspace.path().join("project-0/lib.rs"), "// modified\n").unwrap(); - // Update should succeed but skip project-0's checkout AssertCmd::cargo_bin("east") .unwrap() .arg("update") .current_dir(workspace.path()) .assert() .success() - .stderr(predicate::str::contains("skipped checkout").or(predicate::str::is_empty())) - // The output goes to stderr via progress bar; check stdout for completion .stdout(predicate::str::contains("updated 2 projects")); } @@ -217,11 +232,9 @@ fn update_skips_dirty_project_checkout() { fn update_force_specific_project() { let (_fixture, workspace) = setup_multi_project_workspace(2); - // Make both projects dirty fs::write(workspace.path().join("project-0/lib.rs"), "// modified\n").unwrap(); fs::write(workspace.path().join("project-1/lib.rs"), "// modified\n").unwrap(); - // Force only project-0; project-1 should still be skipped AssertCmd::cargo_bin("east") .unwrap() .args(["update", "--force", "project-0"]) @@ -229,14 +242,12 @@ fn update_force_specific_project() { .assert() .success(); - // project-0 should be checked out (clean now — git may use \r\n on Windows) let content = fs::read_to_string(workspace.path().join("project-0/lib.rs")).unwrap(); assert!( content.contains("// code for project-0"), "project-0 should be restored after force checkout" ); - // project-1 should still have local modifications (checkout was skipped) let content = fs::read_to_string(workspace.path().join("project-1/lib.rs")).unwrap(); assert!( content.contains("// modified"), @@ -248,11 +259,9 @@ fn update_force_specific_project() { fn update_force_all_projects() { let (_fixture, workspace) = setup_multi_project_workspace(2); - // Make both dirty fs::write(workspace.path().join("project-0/lib.rs"), "// modified\n").unwrap(); fs::write(workspace.path().join("project-1/lib.rs"), "// modified\n").unwrap(); - // Force all (no project names) AssertCmd::cargo_bin("east") .unwrap() .args(["update", "--force"]) @@ -260,7 +269,6 @@ fn update_force_all_projects() { .assert() .success(); - // Both should be restored (git may use \r\n on Windows) let c0 = fs::read_to_string(workspace.path().join("project-0/lib.rs")).unwrap(); let c1 = fs::read_to_string(workspace.path().join("project-1/lib.rs")).unwrap(); assert!( diff --git a/crates/east-config/src/error.rs b/crates/east-config/src/error.rs index eeedf2d..9f531d3 100644 --- a/crates/east-config/src/error.rs +++ b/crates/east-config/src/error.rs @@ -16,4 +16,21 @@ pub enum ConfigError { /// TOML serialization error. #[error("failed to serialize TOML: {0}")] TomlSerialize(#[from] toml::ser::Error), + + /// Workspace config exists but has no `[manifest]` section. + /// This indicates a workspace created by an older east version. + #[error( + "This workspace was created by an older east version and is not compatible. \ + Please re-initialize: remove `.east/`, then run `east init -l ` or `east init -m `." + )] + ManifestSectionMissing, + + /// `manifest.path` value is invalid. + #[error("invalid manifest.path '{path}': {reason}")] + InvalidManifestPath { + /// The invalid path value. + path: String, + /// Why it's invalid. + reason: String, + }, } diff --git a/crates/east-config/src/lib.rs b/crates/east-config/src/lib.rs index 06fe69a..09133d6 100644 --- a/crates/east-config/src/lib.rs +++ b/crates/east-config/src/lib.rs @@ -7,11 +7,13 @@ mod config; pub mod error; +pub mod manifest_config; pub mod path; mod store; mod value; pub use config::{Config, ConfigLayer}; +pub use manifest_config::ManifestConfig; pub use store::ConfigStore; pub use value::ConfigValue; diff --git a/crates/east-config/src/manifest_config.rs b/crates/east-config/src/manifest_config.rs new file mode 100644 index 0000000..cbdb795 --- /dev/null +++ b/crates/east-config/src/manifest_config.rs @@ -0,0 +1,132 @@ +use std::path::Path; + +use crate::error::ConfigError; +use crate::store::ConfigStore; +use crate::value::ConfigValue; + +/// Configuration for the manifest repository location within a workspace. +/// +/// Corresponds to the `[manifest]` section in `.east/config.toml`. +#[derive(Debug, Clone)] +pub struct ManifestConfig { + /// Workspace-relative path to the manifest repository directory. + path: String, + /// Manifest file name within the manifest repo (default: `east.yml`). + file: String, +} + +impl ManifestConfig { + /// Create a new `ManifestConfig` with explicit values. + #[must_use] + pub fn new(path: &str, file: &str) -> Self { + Self { + path: path.to_string(), + file: file.to_string(), + } + } + + /// Extract `ManifestConfig` from a `ConfigStore`. + /// + /// Reads `manifest.path` (required) and `manifest.file` (defaults to `east.yml`). + /// + /// # Errors + /// + /// - [`ConfigError::ManifestSectionMissing`] if `manifest.path` is absent. + /// - [`ConfigError::InvalidManifestPath`] if the path is absolute, empty, or contains `..`. + pub fn from_store(store: &ConfigStore) -> Result { + let path = store + .get("manifest.path") + .and_then(ConfigValue::as_str) + .ok_or(ConfigError::ManifestSectionMissing)? + .to_string(); + + validate_manifest_path(&path)?; + + let file = store + .get("manifest.file") + .and_then(ConfigValue::as_str) + .unwrap_or("east.yml") + .to_string(); + + // Validate file: must be a plain filename (no path separators, no .., not absolute) + validate_manifest_file(&file)?; + + Ok(Self { path, file }) + } + + /// Write this config to a `ConfigStore`. + pub fn write_to_store(&self, store: &mut ConfigStore) { + store.set("manifest.path", ConfigValue::String(self.path.clone())); + store.set("manifest.file", ConfigValue::String(self.file.clone())); + } + + /// The workspace-relative path to the manifest repository. + #[must_use] + pub fn path(&self) -> &str { + &self.path + } + + /// The manifest file name. + #[must_use] + pub fn file(&self) -> &str { + &self.file + } +} + +/// Validate that a manifest path is relative, non-empty, and contains no `..`. +fn validate_manifest_path(path: &str) -> Result<(), ConfigError> { + if path.is_empty() { + return Err(ConfigError::InvalidManifestPath { + path: path.to_string(), + reason: "path must not be empty".to_string(), + }); + } + + // Check for absolute paths: std::path considers /foo absolute on Unix + // but not on Windows, so also check for leading / or \ explicitly. + let p = Path::new(path); + if p.is_absolute() || path.starts_with('/') || path.starts_with('\\') { + return Err(ConfigError::InvalidManifestPath { + path: path.to_string(), + reason: "path must be relative, not absolute".to_string(), + }); + } + + if p.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(ConfigError::InvalidManifestPath { + path: path.to_string(), + reason: "path must not contain '..' components".to_string(), + }); + } + + Ok(()) +} + +/// Validate that a manifest file name is a plain filename (no directory components). +fn validate_manifest_file(file: &str) -> Result<(), ConfigError> { + if file.is_empty() { + return Err(ConfigError::InvalidManifestPath { + path: file.to_string(), + reason: "manifest file name must not be empty".to_string(), + }); + } + + if file.contains('/') || file.contains('\\') || file.contains("..") { + return Err(ConfigError::InvalidManifestPath { + path: file.to_string(), + reason: "manifest file must be a plain filename without path separators".to_string(), + }); + } + + let p = Path::new(file); + if p.is_absolute() || file.starts_with('/') || file.starts_with('\\') { + return Err(ConfigError::InvalidManifestPath { + path: file.to_string(), + reason: "manifest file must not be an absolute path".to_string(), + }); + } + + Ok(()) +} diff --git a/crates/east-config/tests/manifest_config.rs b/crates/east-config/tests/manifest_config.rs new file mode 100644 index 0000000..42d481c --- /dev/null +++ b/crates/east-config/tests/manifest_config.rs @@ -0,0 +1,98 @@ +//! Tests for `ManifestConfig` — the `[manifest]` section in config. + +use east_config::manifest_config::ManifestConfig; + +#[test] +fn manifest_config_from_store_with_both_fields() { + let mut store = east_config::ConfigStore::new(); + store.set( + "manifest.path", + east_config::ConfigValue::String("my-app".into()), + ); + store.set( + "manifest.file", + east_config::ConfigValue::String("east.yml".into()), + ); + + let mc = ManifestConfig::from_store(&store).unwrap(); + assert_eq!(mc.path(), "my-app"); + assert_eq!(mc.file(), "east.yml"); +} + +#[test] +fn manifest_config_file_defaults_to_east_yml() { + let mut store = east_config::ConfigStore::new(); + store.set( + "manifest.path", + east_config::ConfigValue::String("sdk".into()), + ); + + let mc = ManifestConfig::from_store(&store).unwrap(); + assert_eq!(mc.path(), "sdk"); + assert_eq!(mc.file(), "east.yml"); +} + +#[test] +fn manifest_config_missing_path_errors() { + let store = east_config::ConfigStore::new(); + let err = ManifestConfig::from_store(&store).unwrap_err(); + assert!( + err.to_string().contains("manifest") + || err.to_string().contains("older") + || err.to_string().contains("missing"), + "error should mention missing manifest section: {err}" + ); +} + +#[test] +fn manifest_config_rejects_absolute_path() { + let mut store = east_config::ConfigStore::new(); + store.set( + "manifest.path", + east_config::ConfigValue::String("/abs/path".into()), + ); + let err = ManifestConfig::from_store(&store).unwrap_err(); + assert!( + err.to_string().contains("absolute") || err.to_string().contains("relative"), + "error should mention absolute path: {err}" + ); +} + +#[test] +fn manifest_config_rejects_dotdot() { + let mut store = east_config::ConfigStore::new(); + store.set( + "manifest.path", + east_config::ConfigValue::String("../escape".into()), + ); + let err = ManifestConfig::from_store(&store).unwrap_err(); + assert!( + err.to_string().contains(".."), + "error should mention dotdot: {err}" + ); +} + +#[test] +fn manifest_config_rejects_empty_path() { + let mut store = east_config::ConfigStore::new(); + store.set( + "manifest.path", + east_config::ConfigValue::String(String::new()), + ); + let err = ManifestConfig::from_store(&store).unwrap_err(); + assert!( + err.to_string().contains("empty"), + "error should mention empty: {err}" + ); +} + +#[test] +fn manifest_config_to_store_roundtrip() { + let mc = ManifestConfig::new("my-app", "east.yml"); + let mut store = east_config::ConfigStore::new(); + mc.write_to_store(&mut store); + + let mc2 = ManifestConfig::from_store(&store).unwrap(); + assert_eq!(mc2.path(), "my-app"); + assert_eq!(mc2.file(), "east.yml"); +} diff --git a/crates/east-manifest/src/lib.rs b/crates/east-manifest/src/lib.rs index 682178b..d0504c3 100644 --- a/crates/east-manifest/src/lib.rs +++ b/crates/east-manifest/src/lib.rs @@ -6,7 +6,9 @@ mod model; pub mod path_resolve; mod resolve; -pub use model::{CommandArg, CommandDecl, Defaults, Import, Manifest, Project, Remote}; +pub use model::{ + CommandArg, CommandDecl, Defaults, Import, Manifest, ManifestSelf, Project, Remote, +}; #[cfg(test)] mod tests { @@ -216,6 +218,7 @@ mod tests { imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; assert_eq!(m.version, 1); assert!(m.remotes.is_empty()); @@ -259,6 +262,7 @@ mod tests { }], group_filter: vec!["+required".into(), "-optional".into()], commands: Vec::new(), + manifest_self: None, }; assert_eq!(m.remotes.len(), 1); assert_eq!(m.projects.len(), 2); @@ -288,6 +292,7 @@ mod tests { imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; let yaml = serde_yaml::to_string(&m).unwrap(); let m2: Manifest = serde_yaml::from_str(&yaml).unwrap(); @@ -471,6 +476,7 @@ defaults: imports: Vec::new(), group_filter: vec!["+required".into(), "-optional".into()], commands: Vec::new(), + manifest_self: None, }; let filtered = m.filtered_projects(); let names: Vec<&str> = filtered.iter().map(|p| p.name.as_str()).collect(); @@ -502,6 +508,7 @@ defaults: imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; let filtered = m.filtered_projects(); assert_eq!(filtered.len(), 2); @@ -531,6 +538,7 @@ defaults: imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; let url = m.project_clone_url(&m.projects[0]).unwrap(); assert_eq!(url, "https://github.com/org/sdk-core"); @@ -564,6 +572,7 @@ defaults: imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; let url = m.project_clone_url(&m.projects[0]).unwrap(); assert_eq!(url, "https://mirror.example.com/sdk-core"); @@ -588,6 +597,7 @@ defaults: imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; let rev = m.project_revision(&m.projects[0]); assert_eq!(rev, Some("main")); @@ -612,6 +622,7 @@ defaults: imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }; let rev = m.project_revision(&m.projects[0]); assert_eq!(rev, Some("v2.0")); diff --git a/crates/east-manifest/src/model.rs b/crates/east-manifest/src/model.rs index 4594508..8798e79 100644 --- a/crates/east-manifest/src/model.rs +++ b/crates/east-manifest/src/model.rs @@ -125,6 +125,17 @@ pub struct CommandDecl { pub declared_in: Option, } +/// Optional `self:` section in the manifest, providing metadata about +/// the manifest repository itself. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[allow(clippy::module_name_repetitions)] +pub struct ManifestSelf { + /// Hint about the expected workspace-relative path for this manifest repo. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + // Future reserved fields are silently ignored by serde(default). +} + /// Top-level east manifest (`east.yml`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Manifest { @@ -152,6 +163,9 @@ pub struct Manifest { /// Extension commands declared in this manifest. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub commands: Vec, + /// Optional `self:` section with manifest repo metadata. + #[serde(default, rename = "self", skip_serializing_if = "Option::is_none")] + pub manifest_self: Option, } impl Manifest { diff --git a/crates/east-manifest/src/resolve.rs b/crates/east-manifest/src/resolve.rs index 3725cd5..c1b2a87 100644 --- a/crates/east-manifest/src/resolve.rs +++ b/crates/east-manifest/src/resolve.rs @@ -90,6 +90,7 @@ pub fn resolve(path: impl AsRef) -> Result { imports: Vec::new(), group_filter: Vec::new(), commands: Vec::new(), + manifest_self: None, }); result.projects = all_projects; result.commands = all_commands; diff --git a/crates/east-manifest/tests/manifest_self.rs b/crates/east-manifest/tests/manifest_self.rs new file mode 100644 index 0000000..3dd89aa --- /dev/null +++ b/crates/east-manifest/tests/manifest_self.rs @@ -0,0 +1,69 @@ +//! Tests for the optional `self:` section in manifest. + +use east_manifest::Manifest; + +#[test] +fn manifest_without_self_section() { + let yaml = "version: 1\n"; + let m = Manifest::from_yaml_str(yaml).unwrap(); + assert!(m.manifest_self.is_none()); +} + +#[test] +fn manifest_with_self_path() { + let yaml = r" +version: 1 +self: + path: my-app +"; + let m = Manifest::from_yaml_str(yaml).unwrap(); + let s = m.manifest_self.as_ref().unwrap(); + assert_eq!(s.path.as_deref(), Some("my-app")); +} + +#[test] +fn manifest_self_with_no_path() { + // self: section present but path omitted + let yaml = r" +version: 1 +self: {} +"; + let m = Manifest::from_yaml_str(yaml).unwrap(); + let s = m.manifest_self.as_ref().unwrap(); + assert!(s.path.is_none()); +} + +#[test] +fn manifest_self_with_reserved_fields_ignored() { + // Future fields like description, maintainers should parse without error + let yaml = r#" +version: 1 +self: + path: my-app + description: "My SDK" + maintainers: ["alice"] + repo-url: "https://example.com" +"#; + let m = Manifest::from_yaml_str(yaml).unwrap(); + let s = m.manifest_self.as_ref().unwrap(); + assert_eq!(s.path.as_deref(), Some("my-app")); +} + +#[test] +fn manifest_self_coexists_with_projects_and_commands() { + let yaml = r#" +version: 1 +self: + path: sdk +projects: + - name: core +commands: + - name: hello + help: "Say hi" + exec: "echo hi" +"#; + let m = Manifest::from_yaml_str(yaml).unwrap(); + assert!(m.manifest_self.is_some()); + assert_eq!(m.projects.len(), 1); + assert_eq!(m.commands.len(), 1); +} diff --git a/crates/east-workspace/Cargo.toml b/crates/east-workspace/Cargo.toml index 8852474..8ce5572 100644 --- a/crates/east-workspace/Cargo.toml +++ b/crates/east-workspace/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true description = ".east/ directory, workspace discovery, and state for east" [dependencies] +east-config = { version = "0.1.2", path = "../east-config" } thiserror.workspace = true [dev-dependencies] diff --git a/crates/east-workspace/src/lib.rs b/crates/east-workspace/src/lib.rs index d31ce78..3a17c48 100644 --- a/crates/east-workspace/src/lib.rs +++ b/crates/east-workspace/src/lib.rs @@ -86,4 +86,79 @@ mod tests { dir.path().canonicalize().unwrap().join("east.yml") ); } + + // ── Phase 2.6: new workspace loading with manifest config ─────── + + #[test] + fn workspace_manifest_repo_path() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".east")).unwrap(); + + // Write config with [manifest] section + let config_content = "[manifest]\npath = \"my-app\"\nfile = \"east.yml\"\n"; + fs::write(dir.path().join(".east/config.toml"), config_content).unwrap(); + + // Create the manifest repo dir and file + fs::create_dir_all(dir.path().join("my-app")).unwrap(); + fs::write(dir.path().join("my-app/east.yml"), "version: 1\n").unwrap(); + + let ws = Workspace::discover(dir.path()).unwrap(); + assert_eq!( + ws.manifest_repo_path(), + dir.path().canonicalize().unwrap().join("my-app") + ); + } + + #[test] + fn workspace_manifest_file_path() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".east")).unwrap(); + + let config_content = "[manifest]\npath = \"sdk\"\nfile = \"east.yml\"\n"; + fs::write(dir.path().join(".east/config.toml"), config_content).unwrap(); + + fs::create_dir_all(dir.path().join("sdk")).unwrap(); + fs::write(dir.path().join("sdk/east.yml"), "version: 1\n").unwrap(); + + let ws = Workspace::discover(dir.path()).unwrap(); + assert_eq!( + ws.manifest_file_path(), + dir.path().canonicalize().unwrap().join("sdk/east.yml") + ); + } + + #[test] + fn workspace_without_manifest_config_falls_back() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".east")).unwrap(); + // Config exists but no [manifest] section — legacy workspace + fs::write( + dir.path().join(".east/config.toml"), + "[user]\nname = \"test\"\n", + ) + .unwrap(); + + let ws = Workspace::discover(dir.path()).unwrap(); + // manifest_repo_path falls back to workspace root when no config + assert_eq!(ws.manifest_repo_path(), ws.root()); + // Legacy manifest_path() returns root/east.yml + assert_eq!(ws.manifest_path(), ws.root().join("east.yml")); + } + + #[test] + fn workspace_discover_from_inside_manifest_repo() { + // Discovery from /manifest-repo/src/deep/ must find .east/ at / + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".east")).unwrap(); + fs::create_dir_all(dir.path().join("my-app/.git")).unwrap(); + fs::create_dir_all(dir.path().join("my-app/src/deep")).unwrap(); + + let config_content = "[manifest]\npath = \"my-app\"\nfile = \"east.yml\"\n"; + fs::write(dir.path().join(".east/config.toml"), config_content).unwrap(); + fs::write(dir.path().join("my-app/east.yml"), "version: 1\n").unwrap(); + + // Discover from deep inside manifest repo + let ws = Workspace::discover(&dir.path().join("my-app/src/deep")).unwrap(); + assert_eq!(ws.root(), dir.path().canonicalize().unwrap()); + } } diff --git a/crates/east-workspace/src/workspace.rs b/crates/east-workspace/src/workspace.rs index 8e68ffb..d5c8438 100644 --- a/crates/east-workspace/src/workspace.rs +++ b/crates/east-workspace/src/workspace.rs @@ -1,34 +1,53 @@ use std::fs; use std::path::{Path, PathBuf}; +use east_config::ConfigStore; +use east_config::manifest_config::ManifestConfig; + use crate::error::WorkspaceError; /// The east marker directory name. const EAST_DIR: &str = ".east"; -/// The default manifest file name. -const MANIFEST_FILE: &str = "east.yml"; +/// Config file within `.east/`. +const CONFIG_FILE: &str = "config.toml"; +/// Legacy manifest file name (Phase 1/2 compatibility). +const LEGACY_MANIFEST_FILE: &str = "east.yml"; /// Represents a discovered or initialized east workspace. /// /// A workspace is rooted at the directory that contains `.east/`. +/// The manifest location is determined by the `[manifest]` section +/// in `.east/config.toml`. #[derive(Debug, Clone)] pub struct Workspace { root: PathBuf, + manifest_repo_path: Option, + manifest_file_path: Option, } impl Workspace { /// Discover a workspace by walking upward from `start` looking for `.east/`. /// + /// After finding `.east/`, loads `.east/config.toml` to determine + /// the manifest repository location. + /// /// # Errors /// - /// Returns [`WorkspaceError::NotFound`] if no `.east/` directory is found - /// before reaching the filesystem root. + /// Returns [`WorkspaceError::NotFound`] if no `.east/` directory is found. pub fn discover(start: &Path) -> Result { let mut current = fs::canonicalize(start)?; loop { if current.join(EAST_DIR).is_dir() { - return Ok(Self { root: current }); + let root = current; + let (repo_path, file_path) = Self::load_manifest_paths(&root); + // If config didn't provide paths, fall back to legacy layout + let file_path = file_path.unwrap_or_else(|| root.join(LEGACY_MANIFEST_FILE)); + return Ok(Self { + root, + manifest_repo_path: repo_path, + manifest_file_path: Some(file_path), + }); } if !current.pop() { break; @@ -53,6 +72,8 @@ impl Workspace { let canonical_root = fs::canonicalize(root)?; Ok(Self { root: canonical_root, + manifest_repo_path: None, + manifest_file_path: None, }) } @@ -68,9 +89,48 @@ impl Workspace { self.root.join(EAST_DIR) } - /// Path to the top-level manifest file (`east.yml`). + /// Path to the manifest repository directory. + /// + /// Computed from `config.manifest.path`. Falls back to workspace root + /// if config is not yet loaded (e.g. during init before config is written). + #[must_use] + pub fn manifest_repo_path(&self) -> &Path { + self.manifest_repo_path.as_deref().unwrap_or(&self.root) + } + + /// Path to the manifest file. + /// + /// Computed from `config.manifest.path` + `config.manifest.file`. + /// Falls back to `/east.yml` for legacy compatibility. + #[must_use] + pub fn manifest_file_path(&self) -> &Path { + self.manifest_file_path.as_deref().unwrap_or(&self.root) + } + + /// Legacy compatibility: path to `/east.yml`. + /// + /// Deprecated in Phase 2.6. Use `manifest_file_path()` instead. #[must_use] pub fn manifest_path(&self) -> PathBuf { - self.root.join(MANIFEST_FILE) + self.manifest_file_path + .as_ref() + .map_or_else(|| self.root.join(LEGACY_MANIFEST_FILE), Clone::clone) + } + + /// Load manifest paths from `.east/config.toml`. + /// + /// Returns `(manifest_repo_path, manifest_file_path)` or `(None, None)` + /// if config doesn't exist or lacks a `[manifest]` section. + fn load_manifest_paths(root: &Path) -> (Option, Option) { + let config_path = root.join(EAST_DIR).join(CONFIG_FILE); + let Ok(store) = ConfigStore::load_from_file(&config_path) else { + return (None, None); + }; + + ManifestConfig::from_store(&store).map_or((None, None), |mc| { + let repo = root.join(mc.path()); + let file = repo.join(mc.file()); + (Some(repo), Some(file)) + }) } } diff --git a/docs/design/phase-2.6.md b/docs/design/phase-2.6.md new file mode 100644 index 0000000..54957bd --- /dev/null +++ b/docs/design/phase-2.6.md @@ -0,0 +1,147 @@ +# Phase 2.6 Design Document — Topology Correction + +**Status:** Active +**Scope:** Fix the workspace topology so the manifest lives in a real git repository, not as a loose file at workspace root. Breaking change for existing workspaces. + +## 1. Why This Phase Exists + +Phase 1 made the choice to extract `east.yml` from the manifest repository and discard the clone. This was a modeling error with these costs: + +- No manifest history (loose file, no git context). +- No way to update the manifest from upstream. +- No PR workflow for manifest changes. +- Silent divergence risk between local and upstream manifest. +- **Files that naturally live alongside `east.yml`** (OpenOCD configs, toolchain files, build scripts, application source in T3 topology) are lost. + +Phase 2.6 adopts a model inspired by west's T1/T2/T3 topologies: the manifest repository is always a real git repo inside the workspace, sibling to `.east/`. + +The author's primary scenario is **T3 (Application)**: the manifest repo IS the application, `east.yml` declares dependencies, and `east build` builds the application. + +## 2. Workspace Layout + +``` +/ +├── .east/ +│ ├── config.toml # includes [manifest] section +│ └── state.toml +├── / # real git repo, sibling of .east/ +│ ├── .git/ +│ ├── east.yml +│ └── (OpenOCD cfg, src/, CMakeLists.txt, etc.) +├── / # fetched by east update +├── / +└── ... +``` + +Key properties: + +- `.east/` marks the workspace root (discovery unchanged from Phase 1). +- Manifest repo is a **real git repo**, never a bare directory. +- Manifest repo is a **sibling** of `.east/`, not a child. + +## 3. `east init` — Three Modes + +### Mode L — Local existing repo + +``` +east init -l [--mf FILE] +``` + +- `` must exist and contain the manifest file (default `east.yml`). +- `.east/` created in **parent** of ``. +- Does NOT auto-run `east update`. + +### Mode M — Clone from remote + +``` +east init -m [--mr REV] [--mf FILE] [] +``` + +- Clones `` to `//`. +- `` derived from URL basename minus `.git`, or from `self.path` if present in cloned manifest. +- If `--mr` given, checks out that revision. +- `.east/` created in ``. +- Does NOT auto-run `east update`. + +### Mode T — Template (default) + +``` +east init [] +``` + +- `` defaults to `manifest`. +- Creates template `east.yml`, `.gitignore`, runs `git init`. +- Does NOT add remote or make initial commit. +- `.east/` created in CWD. + +In all modes: `.east/` already exists = hard error unless `--force`. + +## 4. `config.toml` — `[manifest]` Section + +```toml +[manifest] +path = "my-app" # workspace-relative path to manifest repo +file = "east.yml" # manifest filename, relative to manifest.path +``` + +- Written by `east init`, read by `Workspace::load()`. +- Workspace config layer only. +- Validation: `path` must be relative, non-empty, no `..`, no absolute. Forward slashes only in TOML. + +## 5. Manifest `self:` Section (optional) + +```yaml +version: 1 +self: + path: my-app # hint about expected workspace path +``` + +- Entirely optional. Manifests without it work unchanged. +- Mode L: mismatch with init arg basename = warning (not error). +- Mode M: if present, overrides URL-derived repo-name for the clone directory. +- Mode T: included as commented-out documentation in template. +- Future reserved fields: `description`, `maintainers`, `repo-url` — parsed and ignored. + +## 6. Workspace API Changes + +New methods on `Workspace`: + +```rust +pub fn manifest_repo_path(&self) -> &Path; +pub fn manifest_file_path(&self) -> &Path; +``` + +New loading order: + +1. Discover `.east/` (walk up from CWD). +2. Load config from `.east/config.toml`. Extract `[manifest]`. +3. Compute `manifest_repo_path` and `manifest_file_path`. +4. Load manifest from `manifest_file_path`. +5. Load state from `.east/state.toml`. + +Error messages for Phase 1/2 incompatibility must be clear and actionable. + +## 7. `east update` Behavior + +- Does NOT fetch/checkout the manifest repo itself. +- Reads manifest from current checkout (honors uncommitted local changes). +- User manages manifest repo via plain git. + +## 8. Error Model + +| Error | Description | +|---|---| +| `ConfigError::ManifestSectionMissing` | Phase 1/2 workspace detected, upgrade hint | +| `ConfigError::InvalidManifestPath` | Absolute, empty, or contains `..` | +| `WorkspaceError::ManifestFileNotFound` | Manifest file missing at computed path | +| `WorkspaceError::AlreadyInitialized` | `.east/` exists without `--force` | + +`ManifestError::SelfPathMismatch` is a **warning** via `tracing::warn!`, not a hard error. + +## 9. Non-Goals + +- No Phase 3 features (build, runner, state.toml schema changes). +- No automatic manifest repo updating. +- No migration tool for Phase 1/2 workspaces. +- No submodule support, no multi-manifest. +- No `manifest.revision` tracking. diff --git a/docs/design/phase-2.6.zh-CN.md b/docs/design/phase-2.6.zh-CN.md new file mode 100644 index 0000000..0d18363 --- /dev/null +++ b/docs/design/phase-2.6.zh-CN.md @@ -0,0 +1,147 @@ +# Phase 2.6 设计文档 — 拓扑修正 + +**状态:** 生效中 +**范围:** 修正 workspace 拓扑,让 manifest 住在真实 git 仓库中,而非 workspace 根的裸文件。对已有 workspace 是破坏性变更。 + +## 1. 本 Phase 存在的原因 + +Phase 1 选择从 manifest 仓库提取 `east.yml` 后丢弃克隆。这是一个建模错误,代价包括: + +- manifest 无历史(裸文件,无 git 上下文)。 +- 无法从上游更新 manifest。 +- manifest 变更没有 PR 工作流。 +- 本地与上游 manifest 静默分叉风险。 +- **自然和 `east.yml` 共存的文件**(OpenOCD 配置、toolchain 文件、构建脚本、T3 拓扑中的应用源码)在 manifest 被提取为裸文件时丢失。 + +Phase 2.6 采用类似 west T1/T2/T3 拓扑的模型:manifest 仓库始终是 workspace 内的真实 git repo,与 `.east/` 平级。 + +作者的主力场景是 **T3(Application)**:manifest repo 就是应用本身,`east.yml` 声明依赖,`east build` 直接构建应用。 + +## 2. Workspace 布局 + +``` +/ +├── .east/ +│ ├── config.toml # 包含 [manifest] 段 +│ └── state.toml +├── / # 真实 git repo,与 .east/ 平级 +│ ├── .git/ +│ ├── east.yml +│ └──(OpenOCD cfg、src/、CMakeLists.txt 等) +├── / # 由 east update 获取 +├── / +└── ... +``` + +关键性质: + +- `.east/` 标记 workspace 根(发现逻辑与 Phase 1 相同)。 +- manifest repo 是**真实的 git repo**,绝不是裸目录。 +- manifest repo 是 `.east/` 的**兄弟**,不是子目录。 + +## 3. `east init` — 三种模式 + +### Mode L — 本地已有 repo + +``` +east init -l [--mf FILE] +``` + +- `` 必须存在且包含 manifest 文件(默认 `east.yml`)。 +- `.east/` 创建在 `` 的**父目录**。 +- 不自动运行 `east update`。 + +### Mode M — 从远端克隆 + +``` +east init -m [--mr REV] [--mf FILE] [] +``` + +- 将 `` clone 到 `//`。 +- `` 从 URL basename 去掉 `.git` 得出,或使用克隆后 manifest 中 `self.path` 的值。 +- 若给了 `--mr`,checkout 该 revision。 +- `.east/` 创建在 ``。 +- 不自动运行 `east update`。 + +### Mode T — 模板(默认) + +``` +east init [] +``` + +- `` 默认为 `manifest`。 +- 创建模板 `east.yml`、`.gitignore`,运行 `git init`。 +- 不添加 remote,不做 initial commit。 +- `.east/` 创建在 CWD。 + +三种模式中:`.east/` 已存在 = 硬错误,除非 `--force`。 + +## 4. `config.toml` — `[manifest]` 段 + +```toml +[manifest] +path = "my-app" # workspace 相对路径,指向 manifest repo +file = "east.yml" # manifest 文件名,相对于 manifest.path +``` + +- 由 `east init` 写入,被 `Workspace::load()` 读取。 +- 仅限 workspace 配置层。 +- 校验:`path` 必须相对、非空、无 `..`、非绝对。TOML 中只用正斜杠。 + +## 5. Manifest `self:` 段(可选) + +```yaml +version: 1 +self: + path: my-app # 期望的 workspace 路径提示 +``` + +- 完全可选。不带 `self:` 的 manifest 照常工作。 +- Mode L:与 init 参数 basename 不匹配 = 警告(非错误)。 +- Mode M:若存在,覆盖 URL 派生的 repo-name 作为 clone 目录名。 +- Mode T:模板中以注释形式包含作为文档。 +- 未来保留字段:`description`、`maintainers`、`repo-url` — 解析并忽略。 + +## 6. Workspace API 变化 + +`Workspace` 新方法: + +```rust +pub fn manifest_repo_path(&self) -> &Path; +pub fn manifest_file_path(&self) -> &Path; +``` + +新加载顺序: + +1. 发现 `.east/`(从 CWD 向上查找)。 +2. 从 `.east/config.toml` 加载 config,提取 `[manifest]`。 +3. 计算 `manifest_repo_path` 和 `manifest_file_path`。 +4. 从 `manifest_file_path` 加载 manifest。 +5. 从 `.east/state.toml` 加载 state。 + +Phase 1/2 不兼容的错误消息必须清晰可行动。 + +## 7. `east update` 行为 + +- 不 fetch/checkout manifest repo 自身。 +- 读取当前 checkout 的 manifest(尊重未 commit 的本地修改)。 +- 用户通过普通 git 管理 manifest repo。 + +## 8. 错误模型 + +| 错误 | 描述 | +|---|---| +| `ConfigError::ManifestSectionMissing` | 检测到 Phase 1/2 workspace,给出升级提示 | +| `ConfigError::InvalidManifestPath` | 绝对、空或含 `..` | +| `WorkspaceError::ManifestFileNotFound` | manifest 文件在计算路径处缺失 | +| `WorkspaceError::AlreadyInitialized` | `.east/` 已存在且未给 `--force` | + +`ManifestError::SelfPathMismatch` 是通过 `tracing::warn!` 发出的**警告**,不是硬错误。 + +## 9. 非目标 + +- 无 Phase 3 功能(build、runner、state.toml schema 变化)。 +- 不自动更新 manifest repo。 +- 无 Phase 1/2 workspace 迁移工具。 +- 不支持 submodules、multi-manifest。 +- 不跟踪 `manifest.revision`。 diff --git a/docs/dev/phase-2.6.md b/docs/dev/phase-2.6.md new file mode 100644 index 0000000..df9db38 --- /dev/null +++ b/docs/dev/phase-2.6.md @@ -0,0 +1,52 @@ +# Phase 2.6 Development Notes + +## What Was Delivered + +Workspace topology corrected: manifest now lives in a real git repo, sibling of `.east/`. + +### New `east init` Modes + +- **Mode L** (`-l `): use an existing local directory as manifest repo +- **Mode M** (`-m `): clone a remote repository as manifest repo +- **Mode T** (default): create a template manifest repo with `git init` + +### Infrastructure Changes + +- `ManifestSelf` struct: optional `self:` section in `east.yml` with `path` hint +- `ManifestConfig` in `east-config`: `[manifest]` section with `path` and `file` fields, validation +- `Workspace` rewrite: loads config first, derives manifest path from `[manifest]` section +- `manifest_repo_path()` and `manifest_file_path()` APIs on `Workspace` +- Legacy fallback: workspaces without `[manifest]` config fall back to `root/east.yml` + +### Breaking Change + +Existing workspaces must be re-initialized. Old `east init ` positional syntax removed, replaced by `east init -m `. + +## Test Summary + +- 5 manifest self: tests +- 7 config [manifest] tests +- 4 workspace topology tests +- 8 init mode tests (L, T, end-to-end) +- 10 update tests (migrated to new topology) +- **Total: 165 tests**, all passing + +## What Went Well + +1. **Legacy fallback was key.** Commands that use `ws.manifest_path()` work with both old and new topology because of the fallback logic. This made migration incremental. + +2. **`ManifestConfig` validation is clean.** Rejects absolute, empty, and `..`-containing paths at both read and write time. + +3. **Test migration was mechanical.** The update tests only needed their setup helper changed (use `east init -l` + `east update` instead of old `east init `). + +## What Was Hard + +1. **Stale build artifacts.** After renaming test files, cargo used cached old binaries. Required `cargo clean` to fix. CI won't have this problem. + +2. **`do_update()` hardcoded `east.yml` join.** Needed to change it to discover the workspace and use `manifest_path()` instead. + +## Decisions Made + +1. **No auto-update after init.** `east init` in all three modes does NOT automatically run `east update`. This matches west's behavior — init and update are separate steps. + +2. **Legacy config fallback.** Rather than hard-erroring when `[manifest]` is missing, the workspace falls back to `root/east.yml`. This eases migration for tests and existing workflows. diff --git a/docs/dev/phase-2.6.zh-CN.md b/docs/dev/phase-2.6.zh-CN.md new file mode 100644 index 0000000..62d3041 --- /dev/null +++ b/docs/dev/phase-2.6.zh-CN.md @@ -0,0 +1,52 @@ +# Phase 2.6 开发记录 + +## 交付内容 + +修正 workspace 拓扑:manifest 现在住在真实 git 仓库中,与 `.east/` 平级。 + +### 新的 `east init` 模式 + +- **Mode L**(`-l `):使用已有本地目录作为 manifest 仓库 +- **Mode M**(`-m `):从远端克隆仓库作为 manifest 仓库 +- **Mode T**(默认):创建模板 manifest 仓库并 `git init` + +### 基础设施变化 + +- `ManifestSelf` 结构体:`east.yml` 中可选的 `self:` 段,带 `path` 提示 +- `east-config` 中的 `ManifestConfig`:`[manifest]` 段,含 `path` 和 `file` 字段及校验 +- `Workspace` 重写:先加载 config,从 `[manifest]` 段推导 manifest 路径 +- `Workspace` 上新增 `manifest_repo_path()` 和 `manifest_file_path()` API +- 旧版兼容回退:缺少 `[manifest]` 配置的 workspace 回退到 `root/east.yml` + +### 破坏性变更 + +已有 workspace 必须重新初始化。旧的 `east init ` 位置参数语法已移除,替换为 `east init -m `。 + +## 测试概览 + +- 5 个 manifest self: 测试 +- 7 个 config [manifest] 测试 +- 4 个 workspace 拓扑测试 +- 8 个 init 模式测试(L、T、端到端) +- 10 个 update 测试(已迁移到新拓扑) +- **总计:165 个测试**,全部通过 + +## 顺利的部分 + +1. **旧版回退是关键。** 使用 `ws.manifest_path()` 的命令在新旧拓扑下都能工作,因为有回退逻辑。这使迁移可以增量进行。 + +2. **`ManifestConfig` 校验很干净。** 在读取和写入时都拒绝绝对、空和含 `..` 的路径。 + +3. **测试迁移是机械性的。** update 测试只需修改 setup 辅助函数(用 `east init -l` + `east update` 替代旧的 `east init `)。 + +## 困难的部分 + +1. **过期的构建缓存。** 重命名测试文件后,cargo 使用了旧的缓存二进制。需要 `cargo clean` 修复。CI 不会有此问题。 + +2. **`do_update()` 硬编码了 `east.yml` 拼接。** 需要改为通过 workspace 发现并使用 `manifest_path()`。 + +## 做出的决策 + +1. **init 后不自动 update。** 三种 init 模式都不自动运行 `east update`。这与 west 行为一致——init 和 update 是独立步骤。 + +2. **旧版 config 回退。** 当 `[manifest]` 缺失时不硬错误,而是回退到 `root/east.yml`。这缓解了测试和现有工作流的迁移压力。