use crate::cli::commands::link::run_link;
use crate::cli::output::Output;
use crate::core::griptree::{GriptreeConfig, GriptreePointer, GriptreeRepoInfo};
use crate::core::manifest::Manifest;
use crate::core::repo::RepoInfo;
use crate::git::remote::get_upstream_branch;
use crate::git::{get_current_branch, open_repo, path_exists};
use chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
struct GriptreesList {
griptrees: HashMap<String, GriptreeEntry>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct GriptreeEntry {
path: String,
branch: String,
locked: bool,
lock_reason: Option<String>,
}
struct GriptreeCreationContext {
created_worktrees: Vec<(PathBuf, String)>,
tree_path: PathBuf,
}
impl GriptreeCreationContext {
fn new(tree_path: PathBuf) -> Self {
Self {
created_worktrees: Vec::new(),
tree_path,
}
}
fn record_worktree(&mut self, main_repo_path: PathBuf, worktree_name: String) {
self.created_worktrees.push((main_repo_path, worktree_name));
}
fn rollback(&self) {
for (repo_path, wt_name) in self.created_worktrees.iter().rev() {
if let Ok(repo) = open_repo(repo_path) {
if let Ok(wt) = repo.find_worktree(wt_name) {
let mut opts = git2::WorktreePruneOptions::new();
opts.valid(true);
let _ = wt.prune(Some(&mut opts));
}
}
}
let _ = std::fs::remove_dir_all(&self.tree_path);
}
}
pub fn run_tree_add(
workspace_root: &PathBuf,
manifest: &Manifest,
branch: &str,
) -> anyhow::Result<()> {
Output::header(&format!("Creating griptree for branch '{}'", branch));
println!();
let config_path = workspace_root.join(".gitgrip").join("griptrees.json");
let mut griptrees: GriptreesList = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content)?
} else {
GriptreesList::default()
};
if griptrees.griptrees.contains_key(branch) {
anyhow::bail!("Griptree for '{}' already exists", branch);
}
let tree_name = branch.replace('/', "-");
let tree_path = workspace_root
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine parent directory"))?
.join(&tree_name);
if tree_path.exists() {
anyhow::bail!("Directory already exists: {:?}", tree_path);
}
std::fs::create_dir_all(&tree_path)?;
let mut ctx = GriptreeCreationContext::new(tree_path.clone());
let repos: Vec<RepoInfo> = manifest
.repos
.iter()
.filter_map(|(name, config)| RepoInfo::from_config(name, config, workspace_root))
.collect();
let mut success_count = 0;
let mut error_count = 0;
let mut repo_branches: Vec<GriptreeRepoInfo> = Vec::new();
for repo in &repos {
if !path_exists(&repo.absolute_path) {
if repo.name == "opencode" {
Output::error(&format!(
"{}: not cloned, skipping - this repo is required",
repo.name
));
} else {
Output::warning(&format!("{}: not cloned, skipping", repo.name));
}
continue;
}
let git_repo = match open_repo(&repo.absolute_path) {
Ok(r) => r,
Err(e) => {
Output::warning(&format!("{}: failed to open - {}", repo.name, e));
continue;
}
};
let current_branch = match get_current_branch(&git_repo) {
Ok(b) => b,
Err(e) => {
Output::warning(&format!("{}: failed to get branch - {}", repo.name, e));
continue;
}
};
let worktree_path = tree_path.join(&repo.path);
let spinner = Output::spinner(&format!("{}...", repo.name));
let sync_warning = if repo.reference {
match sync_repo_with_upstream(&repo.absolute_path, &repo.default_branch) {
Ok(_) => None,
Err(e) => Some(format!("sync skipped: {}", e)),
}
} else {
None
};
match create_worktree(
&repo.absolute_path,
&worktree_path,
branch,
Some(&repo.default_branch),
) {
Ok(_) => {
let worktree_name = branch.replace('/', "-");
ctx.record_worktree(repo.absolute_path.clone(), worktree_name.clone());
repo_branches.push(GriptreeRepoInfo {
name: repo.name.clone(),
original_branch: current_branch.clone(),
is_reference: repo.reference,
worktree_name: Some(worktree_name),
worktree_path: Some(worktree_path.to_string_lossy().to_string()),
main_repo_path: Some(repo.absolute_path.to_string_lossy().to_string()),
});
let status_msg = if repo.reference {
if let Some(ref warning) = sync_warning {
format!("{}: created on {} ({})", repo.name, branch, warning)
} else {
format!("{}: synced & created on {}", repo.name, branch)
}
} else {
format!(
"{}: created on {} (from {})",
repo.name, branch, repo.default_branch
)
};
spinner.finish_with_message(status_msg);
success_count += 1;
}
Err(e) => {
spinner.finish_with_message(format!("{}: failed - {}", repo.name, e));
error_count += 1;
}
}
}
let tree_gitgrip = tree_path.join(".gitgrip");
std::fs::create_dir_all(&tree_gitgrip)?;
let state_path = tree_gitgrip.join("state.json");
std::fs::write(&state_path, "{}")?;
let main_manifests_dir = workspace_root.join(".gitgrip").join("manifests");
let (manifest_branch_option, manifest_worktree_name): (Option<String>, Option<String>) =
if main_manifests_dir.exists() {
let main_manifest_git_dir = main_manifests_dir.join(".git");
if main_manifest_git_dir.exists() {
let tree_manifests_dir = tree_gitgrip.join("manifests");
let manifest_spinner = Output::spinner("manifest");
match create_manifest_worktree(&main_manifests_dir, &tree_manifests_dir, branch) {
Ok(manifest_branch) => {
manifest_spinner.finish_with_message(format!(
"manifest: created on {}",
manifest_branch
));
success_count += 1;
(Some(manifest_branch.clone()), Some(manifest_branch))
}
Err(e) => {
manifest_spinner.finish_with_message(format!("manifest: failed - {}", e));
error_count += 1;
(None, None)
}
}
} else {
(None, None)
}
} else {
(None, None)
};
let mut repo_upstreams: HashMap<String, String> = HashMap::new();
for repo in &repos {
let worktree_path = tree_path.join(&repo.path);
if !worktree_path.exists() {
continue;
}
let upstream = match open_repo(&worktree_path) {
Ok(repo_handle) => match get_upstream_branch(&repo_handle, Some(branch)) {
Ok(Some(name)) => name,
_ => format!("origin/{}", repo.default_branch),
},
Err(_) => format!("origin/{}", repo.default_branch),
};
repo_upstreams.insert(repo.name.clone(), upstream);
}
let mut griptree_config = GriptreeConfig::new(branch, &tree_path.to_string_lossy());
griptree_config.repo_upstreams = repo_upstreams;
let griptree_config_path = tree_gitgrip.join("griptree.json");
griptree_config.save(&griptree_config_path)?;
let pointer = GriptreePointer {
main_workspace: workspace_root.to_string_lossy().to_string(),
branch: branch.to_string(),
locked: false,
created_at: Some(Utc::now()),
repos: repo_branches,
manifest_branch: manifest_branch_option,
manifest_worktree_name,
};
let pointer_path = tree_path.join(".griptree");
let pointer_json = serde_json::to_string_pretty(&pointer)?;
std::fs::write(&pointer_path, pointer_json)?;
if success_count == 0 && error_count > 0 {
Output::error("Griptree creation failed - no worktrees were created successfully");
ctx.rollback();
anyhow::bail!("Griptree creation failed, rolled back");
}
griptrees.griptrees.insert(
branch.to_string(),
GriptreeEntry {
path: tree_path.to_string_lossy().to_string(),
branch: branch.to_string(),
locked: false,
lock_reason: None,
},
);
let config_json = serde_json::to_string_pretty(&griptrees)?;
std::fs::write(&config_path, config_json)?;
println!();
if error_count == 0 {
Output::success(&format!(
"Griptree created at {:?} with {} repo(s)",
tree_path, success_count
));
} else {
Output::warning(&format!(
"Griptree created with {} success, {} errors",
success_count, error_count
));
}
let tree_manifest_path = tree_path
.join(".gitgrip")
.join("manifests")
.join("manifest.yaml");
if tree_manifest_path.exists() {
println!();
if let Ok(tree_manifest) = Manifest::load(&tree_manifest_path) {
if let Err(e) = run_link(&tree_path, &tree_manifest, false, true) {
Output::warning(&format!("Failed to apply links: {}", e));
}
}
}
println!();
println!("To use the griptree:");
println!(" cd {:?}", tree_path);
Ok(())
}
pub fn run_tree_list(workspace_root: &PathBuf) -> anyhow::Result<()> {
Output::header("Griptrees");
println!();
let config_path = workspace_root.join(".gitgrip").join("griptrees.json");
let griptrees: GriptreesList = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&content)?
} else {
GriptreesList::default()
};
if griptrees.griptrees.is_empty() {
println!("No griptrees configured.");
} else {
for (branch, entry) in &griptrees.griptrees {
let exists = PathBuf::from(&entry.path).exists();
let status = if !exists {
" (missing)"
} else if entry.locked {
" (locked)"
} else {
""
};
println!(" {} -> {}{}", branch, entry.path, status);
if let Some(ref reason) = entry.lock_reason {
println!(" Lock reason: {}", reason);
}
}
}
let discovered = discover_legacy_griptrees(workspace_root, &griptrees)?;
if !discovered.is_empty() {
println!();
Output::warning("Found unregistered griptrees:");
for (path, branch) in &discovered {
println!(" {} -> {} (unregistered)", branch, path.display());
}
println!();
println!("These griptrees point to this workspace but are not in griptrees.json.");
println!("You can manually add them to griptrees.json if needed.");
}
Ok(())
}
fn discover_legacy_griptrees(
workspace_root: &Path,
registered: &GriptreesList,
) -> anyhow::Result<Vec<(PathBuf, String)>> {
let mut discovered = Vec::new();
let parent = match workspace_root.parent() {
Some(p) => p,
None => return Ok(discovered),
};
let registered_paths: HashSet<String> = registered
.griptrees
.values()
.map(|e| e.path.clone())
.collect();
let entries = match std::fs::read_dir(parent) {
Ok(e) => e,
Err(_) => return Ok(discovered),
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path == workspace_root {
continue;
}
if registered_paths.contains(&path.to_string_lossy().to_string()) {
continue;
}
let pointer_path = path.join(".griptree");
if pointer_path.exists() {
if let Ok(pointer) = GriptreePointer::load(&pointer_path) {
if pointer.main_workspace == workspace_root.to_string_lossy() {
discovered.push((path, pointer.branch));
}
}
}
}
Ok(discovered)
}
pub fn run_tree_remove(workspace_root: &PathBuf, branch: &str, force: bool) -> anyhow::Result<()> {
Output::header(&format!("Removing griptree for '{}'", branch));
println!();
let config_path = workspace_root.join(".gitgrip").join("griptrees.json");
if !config_path.exists() {
anyhow::bail!("No griptrees configured");
}
let content = std::fs::read_to_string(&config_path)?;
let mut griptrees: GriptreesList = serde_json::from_str(&content)?;
let entry = griptrees
.griptrees
.get(branch)
.ok_or_else(|| anyhow::anyhow!("Griptree '{}' not found", branch))?;
if entry.locked && !force {
anyhow::bail!(
"Griptree '{}' is locked{}. Use --force to remove anyway.",
branch,
entry
.lock_reason
.as_ref()
.map(|r| format!(": {}", r))
.unwrap_or_default()
);
}
let tree_path = PathBuf::from(&entry.path);
let pointer_path = tree_path.join(".griptree");
let pointer = if pointer_path.exists() {
GriptreePointer::load(&pointer_path).ok()
} else {
None
};
if let Some(ref ptr) = pointer {
let cleanup_spinner = Output::spinner("Cleaning up worktrees...");
for repo_info in &ptr.repos {
let main_repo_path = repo_info
.main_repo_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(&ptr.main_workspace).join(&repo_info.name));
if let Ok(repo) = open_repo(&main_repo_path) {
let wt_name = repo_info
.worktree_name
.as_deref()
.unwrap_or(&repo_info.original_branch);
prune_worktree(&repo, wt_name);
}
}
if let Some(ref manifest_wt_name) = ptr.manifest_worktree_name {
let main_manifest_path = PathBuf::from(&ptr.main_workspace)
.join(".gitgrip")
.join("manifests");
if let Ok(repo) = open_repo(&main_manifest_path) {
prune_worktree(&repo, manifest_wt_name);
}
}
cleanup_spinner.finish_with_message("Worktrees cleaned up");
}
if tree_path.exists() {
let spinner = Output::spinner("Removing griptree directory...");
std::fs::remove_dir_all(&tree_path)?;
spinner.finish_with_message("Directory removed");
}
griptrees.griptrees.remove(branch);
let config_json = serde_json::to_string_pretty(&griptrees)?;
std::fs::write(&config_path, config_json)?;
Output::success(&format!("Griptree '{}' removed", branch));
Ok(())
}
fn prune_worktree(repo: &git2::Repository, worktree_name: &str) {
if let Ok(wt) = repo.find_worktree(worktree_name) {
let mut opts = git2::WorktreePruneOptions::new();
opts.valid(true); let _ = wt.prune(Some(&mut opts));
}
}
pub fn run_tree_lock(
workspace_root: &PathBuf,
branch: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let config_path = workspace_root.join(".gitgrip").join("griptrees.json");
if !config_path.exists() {
anyhow::bail!("No griptrees configured");
}
let content = std::fs::read_to_string(&config_path)?;
let mut griptrees: GriptreesList = serde_json::from_str(&content)?;
let entry = griptrees
.griptrees
.get_mut(branch)
.ok_or_else(|| anyhow::anyhow!("Griptree '{}' not found", branch))?;
entry.locked = true;
entry.lock_reason = reason.map(|s| s.to_string());
let entry_path = entry.path.clone();
let config_json = serde_json::to_string_pretty(&griptrees)?;
std::fs::write(&config_path, config_json)?;
let pointer_path = PathBuf::from(&entry_path).join(".griptree");
if pointer_path.exists() {
if let Ok(mut pointer) = GriptreePointer::load(&pointer_path) {
pointer.locked = true;
let pointer_json = serde_json::to_string_pretty(&pointer)?;
std::fs::write(&pointer_path, pointer_json)?;
}
}
Output::success(&format!("Griptree '{}' locked", branch));
Ok(())
}
pub fn run_tree_unlock(workspace_root: &PathBuf, branch: &str) -> anyhow::Result<()> {
let config_path = workspace_root.join(".gitgrip").join("griptrees.json");
if !config_path.exists() {
anyhow::bail!("No griptrees configured");
}
let content = std::fs::read_to_string(&config_path)?;
let mut griptrees: GriptreesList = serde_json::from_str(&content)?;
let entry = griptrees
.griptrees
.get_mut(branch)
.ok_or_else(|| anyhow::anyhow!("Griptree '{}' not found", branch))?;
entry.locked = false;
entry.lock_reason = None;
let entry_path = entry.path.clone();
let config_json = serde_json::to_string_pretty(&griptrees)?;
std::fs::write(&config_path, config_json)?;
let pointer_path = PathBuf::from(&entry_path).join(".griptree");
if pointer_path.exists() {
if let Ok(mut pointer) = GriptreePointer::load(&pointer_path) {
pointer.locked = false;
let pointer_json = serde_json::to_string_pretty(&pointer)?;
std::fs::write(&pointer_path, pointer_json)?;
}
}
Output::success(&format!("Griptree '{}' unlocked", branch));
Ok(())
}
fn create_manifest_worktree(
main_manifests_dir: &PathBuf,
tree_manifests_dir: &PathBuf,
branch: &str,
) -> anyhow::Result<String> {
let repo = open_repo(main_manifests_dir)?;
let _current_branch = get_current_branch(&repo)?;
let worktree_name = format!("griptree-{}", branch.replace('/', "-"));
create_worktree(main_manifests_dir, tree_manifests_dir, &worktree_name, None)?;
let manifest_yaml = tree_manifests_dir.join("manifest.yaml");
if !manifest_yaml.exists() {
let main_manifest = main_manifests_dir.join("manifest.yaml");
if main_manifest.exists() {
std::fs::copy(&main_manifest, &manifest_yaml)?;
}
}
Ok(worktree_name)
}
fn create_worktree(
repo_path: &PathBuf,
worktree_path: &PathBuf,
branch: &str,
base_branch: Option<&str>,
) -> anyhow::Result<()> {
let repo = open_repo(repo_path)?;
if let Some(parent) = worktree_path.parent() {
std::fs::create_dir_all(parent)?;
}
let worktree_name = branch.replace('/', "-");
let branch_exists = repo.find_branch(branch, git2::BranchType::Local).is_ok();
if branch_exists {
repo.worktree(
&worktree_name,
worktree_path,
Some(
git2::WorktreeAddOptions::new().reference(Some(
&repo
.find_branch(branch, git2::BranchType::Local)?
.into_reference(),
)),
),
)?;
} else {
let base_commit = if let Some(base) = base_branch {
if let Ok(local_branch) = repo.find_branch(base, git2::BranchType::Local) {
local_branch.get().peel_to_commit()?
} else {
let remote_ref = format!("refs/remotes/origin/{}", base);
repo.revparse_single(&remote_ref)?.peel_to_commit()?
}
} else {
repo.head()?.peel_to_commit()?
};
repo.branch(branch, &base_commit, false)?;
repo.worktree(
&worktree_name,
worktree_path,
Some(
git2::WorktreeAddOptions::new().reference(Some(
&repo
.find_branch(branch, git2::BranchType::Local)?
.into_reference(),
)),
),
)?;
}
Ok(())
}
fn sync_repo_with_upstream(repo_path: &PathBuf, default_branch: &str) -> anyhow::Result<()> {
let repo = open_repo(repo_path)?;
let mut remote = repo.find_remote("origin")?;
remote.fetch(&[default_branch], None, None)?;
let upstream_ref = format!("refs/remotes/origin/{}", default_branch);
let upstream_commit = repo.revparse_single(&upstream_ref)?.peel_to_commit()?;
repo.reset(upstream_commit.as_object(), git2::ResetType::Hard, None)?;
Ok(())
}