diff --git a/crates/mt-core/src/claims.rs b/crates/mt-core/src/claims.rs index bd2d748..c52c6e2 100644 --- a/crates/mt-core/src/claims.rs +++ b/crates/mt-core/src/claims.rs @@ -43,12 +43,26 @@ pub fn node_hash(tasks_root: &str, node_path: &str) -> String { pub const RUN_REF_PREFIX: &str = "refs/mt/runs"; /// Git top-level, що містить `tasks_dir` (`git rev-parse --show-toplevel`). +/// +/// Якщо `tasks_dir` лежить усередині **linked** worktree — повертає корінь +/// цього worktree, не головного репозиторію (пор. [`discover_main_worktree_root`]). pub fn discover_repo_root(tasks_dir: &Path) -> Result { GitRepository::open(tasks_dir) .and_then(|repository| repository.repo_root()) .map_err(|error| error.to_string()) } +/// Корінь **головного** worktree репозиторію від будь-якого шляху всередині +/// нього — той самий, незалежно від того, викликано з головного checkout чи +/// з linked dev-worktree (`mt worktree create `'s власного checkout). +/// Для команд, чий результат має бути детермінований відносно проєкту в +/// цілому, а не поточного worktree — `mt worktree create|remove|list|prune`. +pub fn discover_main_worktree_root(start: &Path) -> Result { + GitRepository::open(start) + .and_then(|repository| repository.main_worktree_root()) + .map_err(|error| error.to_string()) +} + /// Канонічний шлях `tasks_dir` відносно `repo_root`, POSIX-нормалізований /// (`\` → `/`) — вхід для [`node_hash`] (спека: `\0`). pub fn tasks_root_relative(repo_root: &Path, tasks_dir: &Path) -> Result { diff --git a/crates/mt-core/src/git/mod.rs b/crates/mt-core/src/git/mod.rs index e680550..7485285 100644 --- a/crates/mt-core/src/git/mod.rs +++ b/crates/mt-core/src/git/mod.rs @@ -74,6 +74,13 @@ impl GitRepository { } /// Повертає абсолютний шлях робочого дерева репозиторію. + /// + /// Якщо `self` відкритий усередині **linked** worktree (`gix::discover` + /// зупиняється на першому `.git`, яким тут є `.git`-файл лінкованого + /// worktree) — повертає корінь **цього** worktree, а не головного + /// репозиторію. Для операцій, для яких важливий саме спільний + /// (`common`) корінь незалежно від того, звідки викликано команду, + /// використовуй [`Self::main_worktree_root`]. pub fn repo_root(&self) -> Result { self.repo .workdir() @@ -81,6 +88,22 @@ impl GitRepository { .ok_or_else(|| GitError::from_error("bare repositories have no worktree")) } + /// Корінь **головного** worktree репозиторію — той самий незалежно від + /// того, з якого linked worktree викликано команду (на відміну від + /// [`Self::repo_root`], який повертає корінь *поточного* worktree). + /// + /// Резолвиться через спільний (`common`) git-dir (`gix` + /// `Repository::main_repo`), який лінковані worktree завжди ділять із + /// головним — так само, як `git rev-parse --path-format=absolute + /// --git-common-dir` резолвить один і той самий шлях незалежно від cwd. + pub fn main_worktree_root(&self) -> Result { + let main_repo = self.repo.main_repo().map_err(GitError::from_error)?; + main_repo + .workdir() + .map(Path::to_path_buf) + .ok_or_else(|| GitError::from_error("головний репозиторій — bare, без worktree")) + } + /// Повертає fetch URL віддаленого `origin`, якщо він налаштований. pub fn origin_url(&self) -> Result, GitError> { let remote = match self.repo.try_find_remote("origin") { diff --git a/crates/mt-core/src/runner.rs b/crates/mt-core/src/runner.rs index 27687fb..3241796 100644 --- a/crates/mt-core/src/runner.rs +++ b/crates/mt-core/src/runner.rs @@ -28,7 +28,8 @@ use std::time::{Duration, Instant, SystemTime}; use serde::{Deserialize, Serialize}; use crate::claims::{ - acquire_claim, discover_repo_root, node_hash, tasks_root_relative, ClaimFields, + acquire_claim, discover_main_worktree_root, discover_repo_root, node_hash, tasks_root_relative, + ClaimFields, }; use crate::config::{ agent_cli_env_from_process, merge_config, normalize_model_tier, resolve_model_for_cli, @@ -600,6 +601,13 @@ pub fn run_node_env( let repo_root = discover_repo_root(Path::new(tasks_dir))?; let tasks_root_rel = tasks_root_relative(&repo_root, Path::new(tasks_dir))?; let hash = node_hash(&tasks_root_rel, node_path); + // Ephemeral run-worktree — repo-wide shared placement (те саме адмін-дерево + // `.git/worktrees/`, що й у `mt worktree create`): якщо `mt run` викликано + // зсередини linked dev-worktree, `repo_root` вище — корінь *цього* + // checkout-у (правильно для `tasks_root_rel`/claims), а не головного репо — + // тож для фізичного розташування нового worktree потрібен окремий, + // main-scoped корінь, інакше він вкладеться під `.worktrees/<цей-worktree>/`. + let main_root = discover_main_worktree_root(&repo_root)?; let raw_config = fs::read_to_string(repo_root.join(".mt.json")).ok(); let config = merge_config(raw_config.as_deref()); @@ -637,8 +645,8 @@ pub fn run_node_env( return Err("claim-lost: інший runner уже володіє цим вузлом".to_string()); } - let worktrees_dir = worktrees_dir_path(&repo_root, &config); - let worktree = create_run_worktree(&repo_root, &worktrees_dir, &hash, &token, &base_sha)?; + let worktrees_dir = worktrees_dir_path(&main_root, &config); + let worktree = create_run_worktree(&main_root, &worktrees_dir, &hash, &token, &base_sha)?; push_run_ref(&worktree, &hash, &token)?; let wt_tasks_dir = worktree.join(&tasks_root_rel); @@ -1020,6 +1028,56 @@ mod tests { assert!(run.contains("agent_cli: claude")); } + #[test] + fn run_from_inside_a_linked_worktree_places_ephemeral_worktree_under_main_root() { + // `mt run`, викликаний з cwd усередині dev-worktree (а не головного + // checkout-у), не повинен вкладати ephemeral run-worktree під + // `.worktrees/` *цього* worktree — фізичне розташування має бути + // repo-wide, під головним коренем (той самий нюанс, що й `mt + // worktree create`, див. `discover_main_worktree_root`). + use crate::worktree::create_dev_worktree; + + let repo = TestRepo::new(); + let root = repo.work.path().join("mt"); + node(&root, "solo"); + + let worktrees_dir = tempfile::tempdir().unwrap(); + let devwork = + create_dev_worktree(repo.work.path(), worktrees_dir.path(), "devwork", "main").unwrap(); + let devwork_tasks_dir = devwork.path.join("mt"); + assert!(devwork_tasks_dir.join("solo/task.md").is_file()); + + let marker = tempfile::NamedTempFile::new().unwrap(); + let marker_path = marker.path().to_path_buf(); + std::env::set_var("MT_TEST_RUN_WORKTREE_PWD_MARKER", &marker_path); + + let r = devwork_tasks_dir.to_string_lossy().into_owned(); + with_path_shims( + &[( + "claude", + r#"pwd > "$MT_TEST_RUN_WORKTREE_PWD_MARKER" +printf -- '---\nschema_version: 1\n---\n\n## Summary\n\nok\n' > "fact_${MT_RUN_NNN}.md""#, + )], + || { + let out = run_node_env(&r, "solo", &env_default()).unwrap(); + assert_eq!(out.result, "success"); + }, + ); + std::env::remove_var("MT_TEST_RUN_WORKTREE_PWD_MARKER"); + + let captured_cwd = fs::read_to_string(&marker_path).unwrap(); + let captured_cwd = captured_cwd.trim(); + assert!( + !captured_cwd.contains("/.worktrees/devwork/.worktrees/"), + "ephemeral run-worktree nested under the linked worktree instead of the main root: {captured_cwd}" + ); + + // Головний репо-корінь отримав `.worktrees/-` (успішний + // run прибирає worktree по завершенню — перевіряємо, що nested-шлях + // під devwork так і не з'явився). + assert!(!devwork.path.join(".worktrees").exists()); + } + #[test] fn hard_budget_kills_and_publishes_failure_run() { let repo = TestRepo::new(); diff --git a/crates/mt-core/src/worktree.rs b/crates/mt-core/src/worktree.rs index e9f92ad..a0ff796 100644 --- a/crates/mt-core/src/worktree.rs +++ b/crates/mt-core/src/worktree.rs @@ -101,6 +101,16 @@ pub fn remove_worktree(repo_root: &Path, path: &Path, force: bool) -> Result<(), Ok(()) } +/// Стабільний JSON contract `mt worktree create`: `name`/`branch`/`path` +/// достатні, щоб зовнішній оркестратор не перевідкривав git-стан повторним +/// пошуком — усе, що потрібно для подальшого `remove`, уже тут. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreatedWorktree { + pub name: String, + pub branch: String, + pub path: PathBuf, +} + /// `mt worktree create `: гілковий (не detached) worktree для ручної /// dev-роботи над задачею — `git worktree add -b `. pub fn create_dev_worktree( @@ -108,7 +118,7 @@ pub fn create_dev_worktree( worktrees_dir: &Path, name: &str, base: &str, -) -> Result { +) -> Result { let branch = dev_branch(name); let path = worktrees_dir.join(name); if let Some(parent) = path.parent() { @@ -119,7 +129,11 @@ pub fn create_dev_worktree( &["add", "-b", &branch, &path.to_string_lossy(), base], ) .map_err(|error| error.to_string())?; - Ok(path) + Ok(CreatedWorktree { + name: name.to_string(), + branch, + path, + }) } /// Один запис `git worktree list --porcelain` (`mt worktree list`/`inventory`). @@ -426,21 +440,52 @@ mod tests { fn create_dev_worktree_adds_branch_and_worktree() { let repo = TestRepo::new(); let worktrees_dir = tempfile::tempdir().unwrap(); - let path = + let created = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "my-task", "main").unwrap(); - assert!(path.join("README.md").is_file()); + assert_eq!(created.branch, "mt/my-task"); + assert!(created.path.join("README.md").is_file()); assert_eq!( - crate::test_support::head_branch(&path).as_deref(), + crate::test_support::head_branch(&created.path).as_deref(), Some("refs/heads/mt/my-task") ); } + #[test] + fn created_worktree_outcome_is_enough_to_roll_back_without_git_search() { + // Контракт `mt --json worktree create`: `name`/`branch`/`path` з + // [`CreatedWorktree`] мають бути достатні, щоб побудувати + // [`WorktreeEntry`] і прибрати щойно створений worktree/branch без + // повторного `git worktree list` (транзакційний rollback CLI-шару + // при невдачі наступного metadata-кроку). + let repo = TestRepo::new(); + let worktrees_dir = tempfile::tempdir().unwrap(); + let created = + create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main").unwrap(); + + let entry = WorktreeEntry { + path: created.path.to_string_lossy().into_owned(), + name: created.name.clone(), + head: String::new(), + branch: Some(format!("refs/heads/{}", created.branch)), + locked: false, + prunable: false, + }; + remove_dev_worktree(repo.work.path(), &entry, "demo", true).unwrap(); + + assert!(!created.path.exists()); + assert!(crate::git::GitRepository::open(repo.work.path()) + .unwrap() + .resolve_ref("refs/heads/mt/demo") + .is_err()); + } + #[test] fn remove_worktree_without_force_fails_on_dirty_tree() { let repo = TestRepo::new(); let worktrees_dir = tempfile::tempdir().unwrap(); - let path = - create_dev_worktree(repo.work.path(), worktrees_dir.path(), "my-task", "main").unwrap(); + let path = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "my-task", "main") + .unwrap() + .path; std::fs::write(path.join("uncommitted.txt"), "x").unwrap(); assert!(remove_worktree(repo.work.path(), &path, false).is_err()); assert!(path.exists()); @@ -452,7 +497,7 @@ mod tests { fn inventory_includes_optional_branch_description() { let repo = TestRepo::new(); let worktrees_dir = tempfile::tempdir().unwrap(); - let _path = + let _created = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main").unwrap(); set_branch_description(repo.work.path(), "mt/demo", "Ізольований lint").unwrap(); @@ -468,8 +513,9 @@ mod tests { fn remove_dev_worktree_deletes_owned_branch_and_description() { let repo = TestRepo::new(); let worktrees_dir = tempfile::tempdir().unwrap(); - let path = - create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main").unwrap(); + let path = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main") + .unwrap() + .path; set_branch_description(repo.work.path(), "mt/demo", "Ізольований lint").unwrap(); let entry = list_worktrees(repo.work.path()) .unwrap() @@ -493,8 +539,9 @@ mod tests { fn remove_dev_worktree_deletes_owned_unmerged_branch_without_force() { let repo = TestRepo::new(); let worktrees_dir = tempfile::tempdir().unwrap(); - let path = - create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main").unwrap(); + let path = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main") + .unwrap() + .path; std::fs::write(path.join("feature.txt"), "worktree-only change").unwrap(); crate::test_support::commit_all(&path, "feature work"); let entry = list_worktrees(repo.work.path()) @@ -516,8 +563,9 @@ mod tests { fn remove_dev_worktree_rejects_foreign_branch_before_removal() { let repo = TestRepo::new(); let worktrees_dir = tempfile::tempdir().unwrap(); - let path = - create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main").unwrap(); + let path = create_dev_worktree(repo.work.path(), worktrees_dir.path(), "demo", "main") + .unwrap() + .path; let mut entry = list_worktrees(repo.work.path()) .unwrap() .into_iter() diff --git a/crates/mt-napi/src/context.rs b/crates/mt-napi/src/context.rs index 5b0a2a0..c59c538 100644 --- a/crates/mt-napi/src/context.rs +++ b/crates/mt-napi/src/context.rs @@ -17,8 +17,14 @@ pub fn resolve_tasks_dir(root: Option<&str>) -> Result { mt_core::find_tasks_dir().map_err(Error::from_reason) } -pub fn repo_root(tasks_dir: &str) -> Result { - mt_core::claims::discover_repo_root(Path::new(tasks_dir)).map_err(Error::from_reason) +/// Корінь **головного** worktree репозиторію, незалежно від того, чи +/// `tasks_dir` лежить у linked dev-worktree (той самий нюанс, що й CLI +/// `crate::context::git_root` — див. `crates/mt/src/context.rs`). Для +/// worktree lifecycle-операцій (`create/remove/status`), де фізичне +/// розташування worktree має бути repo-wide, а не прив'язане до checkout-у, +/// звідки викликано binding. +pub fn main_worktree_root(tasks_dir: &str) -> Result { + mt_core::claims::discover_main_worktree_root(Path::new(tasks_dir)).map_err(Error::from_reason) } pub fn project_config(tasks_dir: &str) -> serde_json::Value { diff --git a/crates/mt-napi/src/worktree.rs b/crates/mt-napi/src/worktree.rs index 60f0ba8..00e5c24 100644 --- a/crates/mt-napi/src/worktree.rs +++ b/crates/mt-napi/src/worktree.rs @@ -6,10 +6,12 @@ use std::path::Path; use napi::bindgen_prelude::*; use napi_derive::napi; -use crate::context::{project_config, repo_root, resolve_tasks_dir}; +use crate::context::{main_worktree_root, project_config, resolve_tasks_dir}; #[napi(object)] pub struct WorktreeCreateResult { + pub name: String, + pub branch: String, pub path: String, } @@ -22,7 +24,7 @@ pub fn worktree_create( description: Option, ) -> Result { let tasks_dir = resolve_tasks_dir(root.as_deref())?; - let repo = repo_root(&tasks_dir)?; + let repo = main_worktree_root(&tasks_dir)?; let config = project_config(&tasks_dir); let worktrees_dir = repo.join( config["worktrees_dir"] @@ -31,14 +33,16 @@ pub fn worktree_create( .trim_start_matches("./"), ); let base = base.unwrap_or_else(|| "main".to_string()); - let path = mt_core::worktree::create_dev_worktree(&repo, &worktrees_dir, &name, &base) + let created = mt_core::worktree::create_dev_worktree(&repo, &worktrees_dir, &name, &base) .map_err(Error::from_reason)?; if let Some(description) = description { - mt_core::worktree::set_branch_description(&repo, &format!("mt/{name}"), &description) + mt_core::worktree::set_branch_description(&repo, &created.branch, &description) .map_err(Error::from_reason)?; } Ok(WorktreeCreateResult { - path: path.to_string_lossy().into_owned(), + name: created.name, + branch: created.branch, + path: created.path.to_string_lossy().into_owned(), }) } @@ -55,7 +59,7 @@ pub fn worktree_remove( root: Option, ) -> Result { let tasks_dir = resolve_tasks_dir(root.as_deref())?; - let repo = repo_root(&tasks_dir)?; + let repo = main_worktree_root(&tasks_dir)?; let entries = mt_core::worktree::list_worktrees(&repo).map_err(Error::from_reason)?; let entry = entries .iter() @@ -110,7 +114,7 @@ fn flatten<'a>(nodes: &'a [mt_core::TaskNode], out: &mut Vec<&'a mt_core::TaskNo #[napi] pub fn worktree_status(root: Option) -> Result> { let tasks_dir = resolve_tasks_dir(root.as_deref())?; - let repo = repo_root(&tasks_dir)?; + let repo = main_worktree_root(&tasks_dir)?; let config = project_config(&tasks_dir); let worktrees = mt_core::discover_worktrees(Path::new(&tasks_dir)); let tree = mt_core::scan_tasks(tasks_dir.clone(), worktrees).map_err(Error::from_reason)?; diff --git a/crates/mt/src/commands/check.rs b/crates/mt/src/commands/check.rs index 165fe09..9187543 100644 --- a/crates/mt/src/commands/check.rs +++ b/crates/mt/src/commands/check.rs @@ -6,7 +6,7 @@ use clap::Args; use mt_core::worktree::worktree_inventory; use mt_core::{discover_worktrees, scan_tasks, TaskNode, TaskState}; -use crate::context::{project_config, repo_root, resolve_tasks_dir}; +use crate::context::{git_root, project_config, resolve_tasks_dir}; use crate::output::json; fn flatten<'a>(nodes: &'a [TaskNode], out: &mut Vec<&'a TaskNode>) { @@ -45,7 +45,7 @@ pub fn run(_args: CheckArgs, as_json: bool) -> Result<(), String> { let config = project_config(&tasks_dir); let stale_min = config["stale_worktree_min"].as_u64().unwrap_or(30); let task_paths: Vec = all.iter().map(|n| n.path.clone()).collect(); - let stale_worktrees: Vec = match repo_root(&tasks_dir) { + let stale_worktrees: Vec = match git_root() { Ok(root) => worktree_inventory(&root, &task_paths, stale_min) .unwrap_or_default() .into_iter() diff --git a/crates/mt/src/commands/worktree.rs b/crates/mt/src/commands/worktree.rs index b082fc7..6163a5e 100644 --- a/crates/mt/src/commands/worktree.rs +++ b/crates/mt/src/commands/worktree.rs @@ -5,11 +5,11 @@ use std::path::Path; use clap::{Args, Subcommand}; use mt_core::worktree::{ create_dev_worktree, list_worktrees, prune_worktrees, remove_dev_worktree, - set_branch_description, worktree_inventory, + set_branch_description, worktree_inventory, WorktreeEntry, }; use mt_core::{discover_worktrees, scan_tasks, TaskNode}; -use crate::context::{project_config, repo_root, resolve_tasks_dir}; +use crate::context::{git_root, project_config_at, resolve_tasks_dir}; use crate::output::emit; #[derive(Args)] @@ -50,9 +50,11 @@ fn flatten<'a>(nodes: &'a [TaskNode], out: &mut Vec<&'a TaskNode>) { } pub fn run(args: WorktreeArgs, json: bool) -> Result<(), String> { - let tasks_dir = resolve_tasks_dir(false)?; - let root = repo_root(&tasks_dir)?; - let config = project_config(&tasks_dir); + // `create|remove|list|prune` потребують лише Git root — MT task graph + // (`.mt.json`/`mt/`) не є передумовою (лише `inventory` збагачує його + // task-асоціацією, і то опційно, якщо граф присутній). + let root = git_root()?; + let config = project_config_at(&root); match args.action { WorktreeAction::Create { @@ -66,13 +68,34 @@ pub fn run(args: WorktreeArgs, json: bool) -> Result<(), String> { .unwrap_or("./.worktrees") .trim_start_matches("./"), ); - let path = create_dev_worktree(&root, &worktrees_dir, &name, &base)?; + let created = create_dev_worktree(&root, &worktrees_dir, &name, &base)?; if let Some(description) = description { - set_branch_description(&root, &format!("mt/{name}"), &description)?; + if let Err(err) = set_branch_description(&root, &created.branch, &description) { + // Транзакційний rollback: щойно створений clean worktree/branch + // не повинен лишатись напівготовим після невдалого metadata-кроку. + let entry = WorktreeEntry { + path: created.path.to_string_lossy().into_owned(), + name: created.name.clone(), + head: String::new(), + branch: Some(format!("refs/heads/{}", created.branch)), + locked: false, + prunable: false, + }; + let _ = remove_dev_worktree(&root, &entry, &name, true); + return Err(err); + } } - emit(json, &serde_json::json!({ "path": path }), |_| { - println!("worktree: {}", path.display()); - }); + emit( + json, + &serde_json::json!({ + "name": created.name, + "branch": created.branch, + "path": created.path, + }), + |_| { + println!("worktree: {} ({})", created.path.display(), created.branch); + }, + ); } WorktreeAction::Remove { name, force } => { let entries = list_worktrees(&root)?; @@ -109,11 +132,18 @@ pub fn run(args: WorktreeArgs, json: bool) -> Result<(), String> { }); } WorktreeAction::Inventory {} => { - let worktrees = discover_worktrees(Path::new(&tasks_dir)); - let tree = scan_tasks(tasks_dir.clone(), worktrees)?; - let mut all = Vec::new(); - flatten(&tree, &mut all); - let task_paths: Vec = all.iter().map(|n| n.path.clone()).collect(); + // Task-асоціація — опційне збагачення: без графа задач інвентар + // усе одно повертає worktree-список (просто без `task_path`). + let task_paths: Vec = match resolve_tasks_dir(false) { + Ok(tasks_dir) => { + let worktrees = discover_worktrees(Path::new(&tasks_dir)); + let tree = scan_tasks(tasks_dir.clone(), worktrees)?; + let mut all = Vec::new(); + flatten(&tree, &mut all); + all.iter().map(|n| n.path.clone()).collect() + } + Err(_) => Vec::new(), + }; let stale_min = config["stale_worktree_min"].as_u64().unwrap_or(30); let inventory = worktree_inventory(&root, &task_paths, stale_min)?; emit(json, &inventory, |items| { diff --git a/crates/mt/src/context.rs b/crates/mt/src/context.rs index 9139f11..14a4fcb 100644 --- a/crates/mt/src/context.rs +++ b/crates/mt/src/context.rs @@ -57,9 +57,24 @@ fn node_path_from_cwd(cwd: &Path, tasks_dir: &str) -> Result { Ok(node_path) } -/// Корінь git-репо (для worktree/claim-операцій), від `tasks_dir`. -pub fn repo_root(tasks_dir: &str) -> Result { - mt_core::claims::discover_repo_root(Path::new(tasks_dir)) +/// Корінь **головного** worktree репозиторію від поточного `cwd` (`--root`, +/// якщо задано, уже застосований через [`apply_root`] до виклику команди) — +/// незалежно від наявності MT task graph (`.mt.json`/`mt/`) і незалежно від +/// того, чи cwd сам є linked dev-worktree (`mt worktree create`'s власний +/// checkout): без цього `mt worktree create`, викликаний зсередини іншого +/// worktree, вкладав би новий worktree під `.worktrees/` *того* worktree +/// замість спільного `.worktrees/` головного репо. Для `worktree +/// create|remove|list|prune`, яким граф задач не потрібен. +pub fn git_root() -> Result { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + mt_core::claims::discover_main_worktree_root(&cwd) +} + +/// Ефективний `.mt.json`, читаний напряму з `root` — без прив'язки до +/// tasks_dir (яка може не існувати). `.mt.json` відсутній → чисті дефолти. +pub fn project_config_at(root: &Path) -> serde_json::Value { + let raw = std::fs::read_to_string(root.join(".mt.json")).ok(); + mt_core::config::merge_config(raw.as_deref()) } /// Ефективний `.mt.json` проєкту (без per-node override-шарів — для diff --git a/crates/mt/tests/cli.rs b/crates/mt/tests/cli.rs index 8577540..04c3552 100644 --- a/crates/mt/tests/cli.rs +++ b/crates/mt/tests/cli.rs @@ -183,6 +183,130 @@ fn worktree_description_is_reported_and_owned_branch_is_removed() { .is_err()); } +#[test] +fn worktree_create_list_remove_work_without_task_graph() { + // Жодного `mt init` — репо не має `.mt.json`/`mt/`. `worktree` не повинен + // вимагати MT task graph: лише Git root. + let repo = TestRepo::new(); + assert!(!repo.work.path().join(".mt.json").exists()); + assert!(!repo.work.path().join("mt").exists()); + + let create = run( + repo.work.path(), + &[ + "--json", + "worktree", + "create", + "smoke", + "--base", + "main", + "--description", + "smoke", + ], + ); + assert!(create.status.success(), "{}", stdout(&create)); + let created: serde_json::Value = serde_json::from_str(&stdout(&create)).unwrap(); + assert_eq!(created["name"], "smoke"); + assert_eq!(created["branch"], "mt/smoke"); + let path = created["path"].as_str().unwrap(); + assert!(std::path::Path::new(path).is_dir()); + + let list = run(repo.work.path(), &["--json", "worktree", "list"]); + assert!(list.status.success(), "{}", stdout(&list)); + let entries: serde_json::Value = serde_json::from_str(&stdout(&list)).unwrap(); + assert!(entries + .as_array() + .unwrap() + .iter() + .any(|e| e["name"] == "smoke" && e["branch"] == "refs/heads/mt/smoke")); + + let prune = run(repo.work.path(), &["--json", "worktree", "prune"]); + assert!(prune.status.success(), "{}", stdout(&prune)); + + // Inventory без task graph — не падає, task-асоціація просто відсутня. + let inventory = run(repo.work.path(), &["--json", "worktree", "inventory"]); + assert!(inventory.status.success(), "{}", stdout(&inventory)); + let items: serde_json::Value = serde_json::from_str(&stdout(&inventory)).unwrap(); + assert!(items + .as_array() + .unwrap() + .iter() + .any(|i| i["name"] == "smoke" && i["task_path"].is_null())); + + let remove = run( + repo.work.path(), + &["--json", "worktree", "remove", "smoke", "--force"], + ); + assert!(remove.status.success(), "{}", stdout(&remove)); + assert!(!std::path::Path::new(path).exists()); + assert!(mt_core::git::GitRepository::open(repo.work.path()) + .unwrap() + .resolve_ref("refs/heads/mt/smoke") + .is_err()); + + let list3 = run(repo.work.path(), &["--json", "worktree", "list"]); + let entries3: serde_json::Value = serde_json::from_str(&stdout(&list3)).unwrap(); + assert!(!entries3 + .as_array() + .unwrap() + .iter() + .any(|e| e["name"] == "smoke")); +} + +#[test] +fn worktree_create_from_inside_a_linked_worktree_targets_the_main_root() { + // `mt worktree create`, викликаний зсередини вже наявного dev-worktree + // (а не з головного checkout), має класти новий worktree під спільний + // `.worktrees/` головного репо — не вкладено під `.worktrees/` + // *поточного* worktree. Інакше `gix::discover(cwd).workdir()` мовчки + // резолвить корінь поточного checkout, і worktree-дерево розповзається + // рекурсивно замість плаского списку поруч. + let repo = TestRepo::new(); + let create_first = run( + repo.work.path(), + &["--json", "worktree", "create", "devwork"], + ); + assert!(create_first.status.success(), "{}", stdout(&create_first)); + let devwork_dir = repo.work.path().join(".worktrees/devwork"); + assert!(devwork_dir.is_dir()); + + let create_nested = run( + &devwork_dir, + &["--json", "worktree", "create", "nested-child"], + ); + assert!(create_nested.status.success(), "{}", stdout(&create_nested)); + let created: serde_json::Value = serde_json::from_str(&stdout(&create_nested)).unwrap(); + let nested_path = std::path::PathBuf::from(created["path"].as_str().unwrap()); + + let expected = repo.work.path().join(".worktrees/nested-child"); + assert_eq!( + nested_path.canonicalize().unwrap(), + expected.canonicalize().unwrap(), + "worktree must be created next to devwork under the main repo root, not nested inside it" + ); + assert!(!devwork_dir.join(".worktrees").exists()); + + let list = run(repo.work.path(), &["--json", "worktree", "list"]); + let entries: serde_json::Value = serde_json::from_str(&stdout(&list)).unwrap(); + let names: Vec<&str> = entries + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"devwork")); + assert!(names.contains(&"nested-child")); + + // remove/prune, викликані з linked worktree, теж мають бачити повний + // репо-глобальний список, а не лише свій піддерево. + let remove = run( + &devwork_dir, + &["--json", "worktree", "remove", "nested-child", "--force"], + ); + assert!(remove.status.success(), "{}", stdout(&remove)); + assert!(!nested_path.exists()); +} + #[test] fn spawn_approve_materializes_children_from_plan() { let repo = TestRepo::new();