Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/mt-core/src/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf, String> {
GitRepository::open(tasks_dir)
.and_then(|repository| repository.repo_root())
.map_err(|error| error.to_string())
}

/// Корінь **головного** worktree репозиторію від будь-якого шляху всередині
/// нього — той самий, незалежно від того, викликано з головного checkout чи
/// з linked dev-worktree (`mt worktree create <name>`'s власного checkout).
/// Для команд, чий результат має бути детермінований відносно проєкту в
/// цілому, а не поточного worktree — `mt worktree create|remove|list|prune`.
pub fn discover_main_worktree_root(start: &Path) -> Result<PathBuf, String> {
GitRepository::open(start)
.and_then(|repository| repository.main_worktree_root())
.map_err(|error| error.to_string())
}

/// Канонічний шлях `tasks_dir` відносно `repo_root`, POSIX-нормалізований
/// (`\` → `/`) — вхід для [`node_hash`] (спека: `<tasks-root>\0<node-path>`).
pub fn tasks_root_relative(repo_root: &Path, tasks_dir: &Path) -> Result<String, String> {
Expand Down
23 changes: 23 additions & 0 deletions crates/mt-core/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,36 @@ impl GitRepository {
}

/// Повертає абсолютний шлях робочого дерева репозиторію.
///
/// Якщо `self` відкритий усередині **linked** worktree (`gix::discover`
/// зупиняється на першому `.git`, яким тут є `.git`-файл лінкованого
/// worktree) — повертає корінь **цього** worktree, а не головного
/// репозиторію. Для операцій, для яких важливий саме спільний
/// (`common`) корінь незалежно від того, звідки викликано команду,
/// використовуй [`Self::main_worktree_root`].
pub fn repo_root(&self) -> Result<PathBuf, GitError> {
self.repo
.workdir()
.map(Path::to_path_buf)
.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<PathBuf, GitError> {
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<Option<String>, GitError> {
let remote = match self.repo.try_find_remote("origin") {
Expand Down
64 changes: 61 additions & 3 deletions crates/mt-core/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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/<hash>-<token>` (успішний
// run прибирає worktree по завершенню — перевіряємо, що nested-шлях
// під devwork так і не з'явився).
assert!(!devwork.path.join(".worktrees").exists());
}

#[test]
fn hard_budget_kills_and_publishes_failure_run() {
let repo = TestRepo::new();
Expand Down
76 changes: 62 additions & 14 deletions crates/mt-core/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,24 @@ 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 <name>`: гілковий (не detached) worktree для ручної
/// dev-роботи над задачею — `git worktree add -b <branch> <path> <base>`.
pub fn create_dev_worktree(
repo_root: &Path,
worktrees_dir: &Path,
name: &str,
base: &str,
) -> Result<PathBuf, String> {
) -> Result<CreatedWorktree, String> {
let branch = dev_branch(name);
let path = worktrees_dir.join(name);
if let Some(parent) = path.parent() {
Expand All @@ -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`).
Expand Down Expand Up @@ -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());
Expand All @@ -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();

Expand All @@ -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()
Expand All @@ -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())
Expand All @@ -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()
Expand Down
10 changes: 8 additions & 2 deletions crates/mt-napi/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ pub fn resolve_tasks_dir(root: Option<&str>) -> Result<String> {
mt_core::find_tasks_dir().map_err(Error::from_reason)
}

pub fn repo_root(tasks_dir: &str) -> Result<PathBuf> {
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<PathBuf> {
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 {
Expand Down
18 changes: 11 additions & 7 deletions crates/mt-napi/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -22,7 +24,7 @@ pub fn worktree_create(
description: Option<String>,
) -> Result<WorktreeCreateResult> {
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"]
Expand All @@ -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(),
})
}

Expand All @@ -55,7 +59,7 @@ pub fn worktree_remove(
root: Option<String>,
) -> Result<WorktreeRemoveResult> {
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()
Expand Down Expand Up @@ -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<String>) -> Result<Vec<WorktreeStatusItem>> {
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)?;
Expand Down
4 changes: 2 additions & 2 deletions crates/mt/src/commands/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) {
Expand Down Expand Up @@ -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<String> = all.iter().map(|n| n.path.clone()).collect();
let stale_worktrees: Vec<String> = match repo_root(&tasks_dir) {
let stale_worktrees: Vec<String> = match git_root() {
Ok(root) => worktree_inventory(&root, &task_paths, stale_min)
.unwrap_or_default()
.into_iter()
Expand Down
Loading
Loading