use std::path::{Path, PathBuf};
use tracing::{info, warn};
pub mod untracked_sync;
pub const REPOS_ROOT_ENV: &str = "TRUSTY_MPM_REPOS_ROOT";
pub const DEFAULT_REPOS_DIR: &str = "trusty-mpm-projects";
pub fn repos_root_from(env_override: Option<&str>) -> PathBuf {
let home = dirs::home_dir();
if let Some(raw) = env_override {
let raw = raw.trim();
if !raw.is_empty() {
return match &home {
Some(h) => expand_tilde_repos(raw, h),
None => PathBuf::from(raw),
};
}
}
if let Ok(raw) = std::env::var(REPOS_ROOT_ENV) {
let raw = raw.trim();
if !raw.is_empty() {
return match &home {
Some(h) => expand_tilde_repos(raw, h),
None => PathBuf::from(raw),
};
}
}
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
crate::core::trusty_tools_config::workspace_root(&config)
}
pub fn repos_root() -> PathBuf {
repos_root_from(None)
}
fn expand_tilde_repos(template: &str, home: &Path) -> PathBuf {
if let Some(rest) = template.strip_prefix("~/") {
home.join(rest)
} else if template == "~" {
home.to_path_buf()
} else {
PathBuf::from(template)
}
}
pub fn base_clone_path(owner: &str, repo: &str) -> PathBuf {
repos_root().join(owner).join(repo)
}
pub const OLD_LAYOUT_BACKUP_SUFFIX: &str = ".old-layout-backup-";
fn migrate_old_layout_aside(base_path: &Path) -> Result<Option<PathBuf>, String> {
if !base_path.exists() || base_path.join(".git").exists() {
return Ok(None);
}
if !base_path.is_dir() {
return Ok(None);
}
let is_empty = std::fs::read_dir(base_path)
.map_err(|e| {
format!(
"inproject: could not inspect base dir {} for old-layout migration: {e}",
base_path.display()
)
})?
.next()
.is_none();
if is_empty {
return Ok(None);
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let leaf = base_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".to_string());
let backup = base_path.with_file_name(format!("{leaf}{OLD_LAYOUT_BACKUP_SUFFIX}{ts}"));
warn!(
base = %base_path.display(),
backup = %backup.display(),
"inproject: detected pre-#1803 old-layout base dir (non-empty, no top-level \
.git); migrating it aside so the new shared-base clone + .worktrees/ layout \
can engage. The old per-session clones are preserved at the backup path — \
inspect and delete them once you have confirmed nothing is needed."
);
std::fs::rename(base_path, &backup).map_err(|e| {
format!(
"inproject: failed to migrate old-layout dir {} aside to {}: {e}",
base_path.display(),
backup.display()
)
})?;
warn!(
backup = %backup.display(),
"inproject: old-layout base dir migrated aside successfully"
);
Ok(Some(backup))
}
pub fn ensure_base_clone(origin_url: &str, base_path: &Path) -> Result<(), String> {
if base_path.join(".git").exists() {
info!(
path = %base_path.display(),
"inproject: base clone already present, reusing"
);
ensure_worktrees_gitignored(base_path)?;
return Ok(());
}
migrate_old_layout_aside(base_path)?;
crate::core::provisioning_stage::emit(
crate::core::provisioning_stage::ProvisioningStage::CloningRepo,
);
info!(
url = %origin_url,
dest = %base_path.display(),
"inproject: cloning base repo"
);
if let Some(parent) = base_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"inproject: failed to create parent dir {}: {e}",
parent.display()
)
})?;
}
let cwd = base_path.parent().unwrap_or(std::path::Path::new("/"));
let out = std::process::Command::new("git")
.args(["clone", "--no-local", origin_url])
.arg(base_path)
.current_dir(cwd)
.output()
.map_err(|e| format!("inproject: git clone failed to spawn: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"inproject: git clone failed ({}): {stderr}",
out.status
));
}
info!(dest = %base_path.display(), "inproject: base clone complete");
ensure_worktrees_gitignored(base_path)?;
Ok(())
}
pub fn worktree_path_for(base_path: &Path, worktree_name: &str) -> PathBuf {
base_path.join(".worktrees").join(worktree_name)
}
pub use crate::core::worktree_naming::worktree_branch_for;
pub fn worktree_name_collides(base_path: &Path, worktree_name: &str) -> bool {
if worktree_path_for(base_path, worktree_name).exists() {
return true;
}
let branch_ref = format!("refs/heads/{}", worktree_branch_for(worktree_name));
std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["rev-parse", "--verify", "--quiet"])
.arg(&branch_ref)
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
pub fn create_session_worktree(base_path: &Path, worktree_name: &str) -> Result<PathBuf, String> {
let worktree_path = worktree_path_for(base_path, worktree_name);
let branch = worktree_branch_for(worktree_name);
if worktree_path.exists() {
return Err(format!(
"inproject: worktree path already exists: {} — refusing to clobber \
(this should have been caught by the pre-reservation collision check)",
worktree_path.display()
));
}
info!(
base = %base_path.display(),
worktree = %worktree_path.display(),
branch = %branch,
"inproject: creating per-session worktree"
);
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["worktree", "add", "-b"])
.arg(&branch)
.arg(&worktree_path)
.output()
.map_err(|e| format!("inproject: git worktree add failed to spawn: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"inproject: git worktree add failed ({}): {stderr}",
out.status
));
}
configure_session_branch_tracking(base_path, &worktree_path);
let sentinel = worktree_path.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE);
if let Err(e) = std::fs::write(&sentinel, b"") {
warn!(
worktree = %worktree_path.display(),
sentinel = crate::session_manager::decommission::WORKTREE_SENTINEL_FILE,
"inproject: failed to write SM ownership sentinel (non-fatal): {e}"
);
}
info!(worktree = %worktree_path.display(), "inproject: per-session worktree created");
Ok(worktree_path)
}
fn configure_session_branch_tracking(base_path: &Path, worktree_path: &Path) {
let default_branch = super::inproject_hygiene::get_default_branch(base_path)
.unwrap_or_else(|| "main".to_string());
let upstream = format!("origin/{default_branch}");
let set_upstream = std::process::Command::new("git")
.arg("-C")
.arg(worktree_path)
.args(["branch", &format!("--set-upstream-to={upstream}")])
.output();
match set_upstream {
Ok(out) if out.status.success() => {
info!(
worktree = %worktree_path.display(),
upstream = %upstream,
"inproject: session worktree branch upstream set (git pull now works)"
);
}
Ok(out) => {
warn!(
worktree = %worktree_path.display(),
upstream = %upstream,
"inproject: failed to set branch upstream (non-fatal; `git pull` will \
require manual tracking setup) ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(
worktree = %worktree_path.display(),
"inproject: git branch --set-upstream-to failed to spawn (non-fatal): {e}"
);
}
}
let enable_worktree_config = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["config", "extensions.worktreeConfig", "true"])
.output();
match enable_worktree_config {
Ok(out) if out.status.success() => {
info!(base = %base_path.display(), "inproject: extensions.worktreeConfig enabled on base clone");
}
Ok(out) => {
warn!(
base = %base_path.display(),
"inproject: git config extensions.worktreeConfig failed (non-fatal) ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(
base = %base_path.display(),
"inproject: failed to spawn git config extensions.worktreeConfig (non-fatal): {e}"
);
}
}
let set_push_default = std::process::Command::new("git")
.arg("-C")
.arg(worktree_path)
.args(["config", "--worktree", "push.default", "current"])
.output();
match set_push_default {
Ok(out) if out.status.success() => {
info!(
worktree = %worktree_path.display(),
"inproject: worktree-scoped push.default=current set (git push cannot target the default branch)"
);
}
Ok(out) => {
warn!(
worktree = %worktree_path.display(),
"inproject: failed to set worktree-scoped push.default (non-fatal) ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(
worktree = %worktree_path.display(),
"inproject: git config --worktree push.default failed to spawn (non-fatal): {e}"
);
}
}
}
pub fn ensure_worktrees_gitignored(base_path: &Path) -> Result<(), String> {
let info_dir = base_path.join(".git").join("info");
std::fs::create_dir_all(&info_dir).map_err(|e| {
format!(
"inproject: could not create .git/info/ at {}: {e}",
info_dir.display()
)
})?;
let exclude_path = info_dir.join("exclude");
let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default();
if existing.lines().any(|line| line.trim() == ".worktrees/") {
return Ok(());
}
let entry = if existing.is_empty() || existing.ends_with('\n') {
".worktrees/\n".to_string()
} else {
"\n.worktrees/\n".to_string()
};
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&exclude_path)
.map_err(|e| format!("inproject: could not open .git/info/exclude: {e}"))?;
file.write_all(entry.as_bytes())
.map_err(|e| format!("inproject: could not write .git/info/exclude: {e}"))?;
info!(
path = %exclude_path.display(),
"inproject: added .worktrees/ to .git/info/exclude"
);
Ok(())
}
pub fn get_origin_url(path: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
pub fn try_inproject_spawn(path: &Path) -> Result<Option<(PathBuf, String, String)>, String> {
if !path.is_dir() || !path.join(".git").exists() {
return Ok(None);
}
let Some(origin_url) = get_origin_url(path) else {
warn!(
path = %path.display(),
"inproject: no remote.origin.url found; falling through to local-path spawn"
);
return Ok(None);
};
let Some(gh) = trusty_common::github_path::parse_github_path(&origin_url) else {
warn!(
url = %origin_url,
"inproject: cannot parse GitHub owner/repo from remote URL; falling through"
);
return Ok(None);
};
let base = base_clone_path(&gh.owner, &gh.repo);
ensure_base_clone(&origin_url, &base)?;
info!(
owner = %gh.owner,
repo = %gh.repo,
base = %base.display(),
"inproject: base clone ready for per-session worktree"
);
Ok(Some((base, gh.owner, gh.repo)))
}
#[cfg(test)]
mod tests;