use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use super::registry::ManagedRegistry;
#[derive(Debug, Serialize, Deserialize)]
pub struct ManagedMarker {
pub alias: String,
pub url: String,
pub git_ref: String,
pub claude_config_dir: String,
}
pub fn load_alias(
alias: &str,
managed_root: &Path,
claude_config_dir: &Path,
) -> anyhow::Result<PathBuf> {
let registry = ManagedRegistry::load(managed_root)
.with_context(|| format!("failed to load registry from {}", managed_root.display()))?;
let entry = registry
.get(alias)
.with_context(|| format!("alias '{alias}' is not registered"))?;
let url = entry.url.clone();
let git_ref = entry.git_ref.clone();
let project_dir = managed_root.join("projects").join(alias);
let repo_dir = project_dir.join("repo");
if !repo_dir.exists() {
clone_repo(&url, &project_dir)?;
} else {
pull_ff_only(&repo_dir);
}
run_prepare_session(&repo_dir, Some(&url), managed_root)?;
write_marker(&repo_dir, alias, &url, &git_ref, claude_config_dir)?;
if let Err(err) = super::trust_seed::preseed_managed_trust(claude_config_dir, &repo_dir) {
tracing::warn!("failed to pre-seed managed trust for '{alias}': {err}");
}
Ok(repo_dir)
}
fn clone_repo(url: &str, project_dir: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(project_dir).with_context(|| {
format!(
"failed to create project directory {}",
project_dir.display()
)
})?;
let status = Command::new("git")
.args(["clone", "--depth", "1", url, "repo"])
.current_dir(project_dir)
.status()
.context("failed to spawn git clone")?;
if !status.success() {
anyhow::bail!("git clone failed for '{url}'");
}
Ok(())
}
fn pull_ff_only(repo_dir: &Path) {
let result = Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(repo_dir)
.status();
if let Ok(status) = result
&& !status.success()
{
eprintln!(
"warning: git pull --ff-only failed in {}; skipping refresh",
repo_dir.display()
);
}
}
fn run_prepare_session(
repo_dir: &Path,
repo_url: Option<&str>,
managed_root: &Path,
) -> anyhow::Result<()> {
let fw = crate::core::paths::FrameworkPaths::for_managed_project(managed_root, repo_dir);
let report =
crate::core::session_launch::prepare_session_with_repo_url(&fw, repo_dir, repo_url)
.map_err(|e| anyhow::anyhow!("prepare_session failed: {e}"))?;
crate::core::session_launch::log_prep_findings(
&report.roster_errors,
&report.asset_notices,
crate::core::session_launch::PrepScope {
kind: "load",
session: None,
dir: repo_dir,
},
);
Ok(())
}
fn write_marker(
repo_dir: &Path,
alias: &str,
url: &str,
git_ref: &str,
claude_config_dir: &Path,
) -> anyhow::Result<()> {
let dot_dir = repo_dir.join(".trusty-mpm");
std::fs::create_dir_all(&dot_dir)
.with_context(|| format!("failed to create {}", dot_dir.display()))?;
let marker = ManagedMarker {
alias: alias.to_string(),
url: url.to_string(),
git_ref: git_ref.to_string(),
claude_config_dir: claude_config_dir.to_string_lossy().to_string(),
};
let toml = toml::to_string_pretty(&marker).context("failed to serialize managed.toml")?;
std::fs::write(dot_dir.join("managed.toml"), toml)
.context("failed to write .trusty-mpm/managed.toml")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvClearGuard {
key: &'static str,
prior: Option<String>,
}
impl EnvClearGuard {
fn clear(key: &'static str) -> Self {
let prior = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, prior }
}
}
impl Drop for EnvClearGuard {
fn drop(&mut self) {
unsafe {
match self.prior.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
#[test]
#[serial_test::serial]
fn run_prepare_session_never_writes_real_home_claude_dirs() {
let _palace_guard = EnvClearGuard::clear("TRUSTY_MEMORY_PALACE");
let fake_home = crate::test_support::hermetic_temp_dir();
let _home_guard = {
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", fake_home.path()) };
HomeGuard(prior)
};
let managed_root = fake_home.path().join(".trusty-mpm");
let agents_src = managed_root.join("framework").join("agents");
std::fs::create_dir_all(&agents_src).unwrap();
std::fs::write(
agents_src.join("regression-agent.md"),
"---\nname: regression-agent\nrole: engineer\n---\n\n\
# Regression Agent\n\nAgent content.\n",
)
.unwrap();
let project_root = crate::test_support::hermetic_temp_dir();
let repo = project_root.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
run_prepare_session(&repo, None, &managed_root).expect("run_prepare_session succeeds");
assert!(
!fake_home.path().join(".claude").join("agents").exists(),
"run_prepare_session must NOT write to the real $HOME/.claude/agents (issue #1927)"
);
assert!(
!fake_home.path().join(".claude").join("skills").exists(),
"run_prepare_session must NOT write to the real $HOME/.claude/skills (issue #1927)"
);
let agent_tier =
crate::core::paths::FrameworkPaths::for_managed_project(&managed_root, &repo)
.agent_deploy_dir();
assert!(
agent_tier.join("regression-agent.md").exists(),
"run_prepare_session must deploy agents to the tm-managed config tier ({}) — #4409",
agent_tier.display()
);
assert!(
!repo.join(".claude").join("agents").exists(),
"run_prepare_session must NOT deploy bundled agents into the workspace (#4409)"
);
let deployed_skills_dir = repo.join(".claude").join("skills");
let has_deployed_skill = deployed_skills_dir
.read_dir()
.map(|mut entries| entries.next().is_some())
.unwrap_or(false);
assert!(
has_deployed_skill,
"run_prepare_session must deploy the compiled-in skill bundle to \
repo/.claude/skills (project-local, DOC-24); dir: {}",
deployed_skills_dir.display()
);
}
#[test]
fn test_marker_write_round_trip() {
let tmp = crate::test_support::hermetic_temp_dir();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
let cfg = tmp.path().join("claude-config");
write_marker(
&repo,
"my-alias",
"https://github.com/org/r",
"default",
&cfg,
)
.unwrap();
let toml_path = repo.join(".trusty-mpm").join("managed.toml");
assert!(toml_path.exists());
let text = std::fs::read_to_string(&toml_path).unwrap();
let marker: ManagedMarker = toml::from_str(&text).unwrap();
assert_eq!(marker.alias, "my-alias");
assert_eq!(marker.url, "https://github.com/org/r");
assert_eq!(
marker.git_ref, "default",
"git_ref must be the 'default' sentinel, not a literal branch name"
);
}
}