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::manifest_paths;
use crate::core::repo::{filter_repos, get_manifest_repo_info, RepoInfo};
use crate::git::branch::{
branch_exists, checkout_branch, delete_local_branch, remote_branch_exists,
};
use crate::git::remote::{delete_remote_branch, get_upstream_branch, set_branch_upstream_ref};
use crate::git::status::get_cached_status;
use crate::git::{get_current_branch, open_repo, path_exists};
use crate::util::log_cmd;
use chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
#[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: &Path,
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()
};
let stale_child_marker = workspace_root.join(".gitgrip").join("griptree.json");
if stale_child_marker.exists() {
eprintln!(
"Cleaning up stale .gitgrip/griptree.json (this workspace is a root, not a child)"
);
let _ = std::fs::remove_file(&stale_child_marker);
}
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,
&manifest.settings,
manifest.remotes.as_ref(),
)
})
.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.revision) {
Ok(_) => None,
Err(e) => Some(format!("sync skipped: {}", e)),
}
} else {
None
};
match create_worktree(
&repo.absolute_path,
&worktree_path,
branch,
Some(&repo.revision),
) {
Ok(_) => {
let expected_upstream = format!("origin/{}", repo.revision);
let upstream_warning = match open_repo(&worktree_path) {
Ok(repo_handle) => {
match set_branch_upstream_ref(&repo_handle, branch, &expected_upstream) {
Ok(()) => None,
Err(e) => Some(format!("upstream not set ({})", e)),
}
}
Err(e) => Some(format!("upstream not set ({})", e)),
};
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 mut 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.revision
)
};
if let Some(warning) = upstream_warning {
status_msg.push_str(&format!(" ({})", warning));
}
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 = manifest_paths::resolve_manifest_repo_dir(workspace_root);
let (manifest_branch_option, manifest_worktree_name): (Option<String>, Option<String>) =
if let Some(main_manifests_dir) = main_manifests_dir {
let main_manifest_git_dir = main_manifests_dir.join(".git");
if main_manifest_git_dir.exists() {
let tree_manifests_dir = tree_gitgrip.join("spaces").join("main");
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.revision),
},
Err(_) => format!("origin/{}", repo.revision),
};
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
));
}
if let Some(tree_manifest_path) = manifest_paths::resolve_gripspace_manifest_path(&tree_path) {
println!();
if let Ok(tree_manifest) = Manifest::load(&tree_manifest_path) {
if let Err(e) = run_link(&tree_path, &tree_manifest, false, true, false) {
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: &Path) -> anyhow::Result<()> {
Output::header("Griptrees");
println!();
let griptrees_root = resolve_griptrees_workspace_root(workspace_root);
let config_path = griptrees_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()
};
let stale_child_marker = griptrees_root.join(".gitgrip").join("griptree.json");
if config_path.exists() && stale_child_marker.exists() {
eprintln!("Repaired: removed stale .gitgrip/griptree.json from root workspace");
let _ = std::fs::remove_file(&stale_child_marker);
}
let current_branch = std::env::current_dir().ok().and_then(|cwd| {
crate::core::griptree::GriptreePointer::find_in_ancestors(&cwd)
.map(|(_, pointer)| pointer.branch)
});
if griptrees.griptrees.is_empty() {
println!("No griptrees configured.");
} else {
for (branch, entry) in &griptrees.griptrees {
let exists = PathBuf::from(&entry.path).exists();
let is_current = current_branch.as_deref() == Some(branch.as_str());
let mut markers = Vec::new();
if is_current {
markers.push("current");
}
if !exists {
markers.push("missing");
}
if entry.locked {
markers.push("locked");
}
let suffix = if markers.is_empty() {
String::new()
} else {
format!(" ({})", markers.join(", "))
};
let prefix = if is_current { "* " } else { " " };
println!("{}{} -> {}{}", prefix, branch, entry.path, suffix);
if let Some(ref reason) = entry.lock_reason {
println!(" Lock reason: {}", reason);
}
}
}
let discovered = discover_legacy_griptrees(&griptrees_root, &griptrees)?;
if !discovered.is_empty() {
println!();
Output::warning("Found unregistered griptrees:");
for (path, branch) in &discovered {
let is_current = current_branch.as_deref() == Some(branch.as_str());
let prefix = if is_current { "* " } else { " " };
let suffix = if is_current {
" (current, unregistered)"
} else {
" (unregistered)"
};
println!("{}{} -> {}{}", prefix, branch, path.display(), suffix);
}
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(())
}
pub struct TreeReturnOptions<'a> {
pub base_override: Option<&'a str>,
pub no_sync: bool,
pub autostash: bool,
pub prune_branch: Option<&'a str>,
pub prune_current: bool,
pub prune_remote: bool,
pub force: bool,
}
pub async fn run_tree_return(
workspace_root: &Path,
manifest: &Manifest,
opts: &TreeReturnOptions<'_>,
) -> anyhow::Result<()> {
let griptree_config = GriptreeConfig::load_from_workspace(workspace_root)?;
let base_branch = match (opts.base_override, griptree_config.as_ref()) {
(Some(base), _) => base.to_string(),
(None, Some(cfg)) => cfg.branch.clone(),
(None, None) => {
anyhow::bail!(
"No griptree config found. Use --base <branch> to specify the base branch."
);
}
};
let mut repos: Vec<RepoInfo> = filter_repos(
manifest,
workspace_root,
None,
None,
false,
);
if let Some(manifest_repo) = get_manifest_repo_info(manifest, workspace_root) {
repos.push(manifest_repo);
}
Output::header(&format!(
"Returning to {} and syncing upstreams...",
Output::branch_name(&base_branch)
));
println!();
let mut dirty_repos: Vec<String> = Vec::new();
let mut current_branches: HashMap<String, String> = HashMap::new();
for repo in &repos {
if !repo.exists() {
continue;
}
let status = get_cached_status(&repo.absolute_path)?;
current_branches.insert(repo.name.clone(), status.current_branch.clone());
if !status.is_clean {
dirty_repos.push(repo.name.clone());
}
}
if !dirty_repos.is_empty() && !opts.autostash {
anyhow::bail!(
"Uncommitted changes in: {}. Use --autostash to proceed.",
dirty_repos.join(", ")
);
}
let mut stashed_repos: Vec<PathBuf> = Vec::new();
if opts.autostash {
for repo in &repos {
if !dirty_repos.contains(&repo.name) || !repo.exists() {
continue;
}
match stash_repo(&repo.absolute_path, "gr tree return") {
Ok(true) => stashed_repos.push(repo.absolute_path.clone()),
Ok(false) => {}
Err(e) => Output::error(&format!("{}: stash failed - {}", repo.name, e)),
}
}
}
let mut checkout_failures = 0;
for repo in &repos {
if !repo.exists() {
Output::warning(&format!("{}: not cloned, skipping", repo.name));
continue;
}
let git_repo = open_repo(&repo.absolute_path)?;
if let Ok(current) = get_current_branch(&git_repo) {
if current == base_branch {
Output::success(&format!("{}: already on {}", repo.name, base_branch));
continue;
}
}
if !branch_exists(&git_repo, &base_branch) {
Output::warning(&format!(
"{}: branch '{}' does not exist, skipping",
repo.name, base_branch
));
checkout_failures += 1;
continue;
}
match checkout_branch(&git_repo, &base_branch) {
Ok(()) => Output::success(&format!("{}: checked out {}", repo.name, base_branch)),
Err(e) => {
Output::error(&format!("{}: {}", repo.name, e));
checkout_failures += 1;
}
}
}
if !opts.no_sync {
println!();
let _ = crate::cli::commands::sync::run_sync(
workspace_root,
manifest,
false,
false,
None,
None,
false,
false,
false,
false,
)
.await;
}
if opts.prune_branch.is_some() || opts.prune_current {
println!();
let prune_target = opts.prune_branch.map(|b| b.to_string());
for repo in &repos {
if !repo.exists() {
continue;
}
let git_repo = open_repo(&repo.absolute_path)?;
let target_branch = match &prune_target {
Some(branch) => branch.clone(),
None => current_branches
.get(&repo.name)
.cloned()
.unwrap_or_default(),
};
if target_branch.is_empty() || target_branch == base_branch {
continue;
}
if !branch_exists(&git_repo, &target_branch) {
Output::info(&format!(
"{}: branch '{}' not found, skipping",
repo.name, target_branch
));
continue;
}
if let Err(e) = delete_local_branch(&git_repo, &target_branch, opts.force) {
Output::warning(&format!(
"{}: failed to delete '{}' - {}",
repo.name, target_branch, e
));
continue;
}
Output::success(&format!(
"{}: deleted local branch '{}'",
repo.name, target_branch
));
if opts.prune_remote {
let remote = "origin";
if remote_branch_exists(&git_repo, &target_branch, remote) {
match delete_remote_branch(&git_repo, &target_branch, remote) {
Ok(()) => Output::success(&format!(
"{}: deleted remote branch '{}/{}'",
repo.name, remote, target_branch
)),
Err(e) => Output::warning(&format!(
"{}: failed to delete remote '{}/{}' - {}",
repo.name, remote, target_branch, e
)),
}
} else {
Output::info(&format!(
"{}: remote branch '{}/{}' not found, skipping",
repo.name, remote, target_branch
));
}
}
}
}
if opts.autostash && !stashed_repos.is_empty() {
println!();
for repo_path in &stashed_repos {
if let Err(e) = stash_pop_repo(repo_path) {
Output::warning(&format!(
"{}: stash pop failed - {}",
repo_path.display(),
e
));
}
}
}
if checkout_failures > 0 {
Output::warning(&format!(
"Return completed with {} checkout error(s)",
checkout_failures
));
} else {
Output::success("Return completed");
}
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)
}
fn resolve_griptrees_workspace_root(workspace_root: &Path) -> PathBuf {
let local_registry = workspace_root.join(".gitgrip").join("griptrees.json");
if local_registry.exists() {
return workspace_root.to_path_buf();
}
let pointer_path = workspace_root.join(".griptree");
if pointer_path.exists() {
if let Ok(pointer) = GriptreePointer::load(&pointer_path) {
let main_workspace = PathBuf::from(pointer.main_workspace);
let main_registry = main_workspace.join(".gitgrip").join("griptrees.json");
if main_registry.exists() {
return main_workspace;
}
}
}
workspace_root.to_path_buf()
}
pub fn run_tree_remove(workspace_root: &Path, branch: &str, force: bool) -> anyhow::Result<()> {
Output::header(&format!("Removing griptree for '{}'", branch));
println!();
let griptrees_root = resolve_griptrees_workspace_root(workspace_root);
let config_path = griptrees_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_workspace = PathBuf::from(&ptr.main_workspace);
if let Some(main_manifest_path) =
manifest_paths::resolve_manifest_repo_dir(&main_workspace)
{
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: &Path,
branch: &str,
reason: Option<&str>,
) -> anyhow::Result<()> {
let griptrees_root = resolve_griptrees_workspace_root(workspace_root);
let config_path = griptrees_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: &Path, branch: &str) -> anyhow::Result<()> {
let griptrees_root = resolve_griptrees_workspace_root(workspace_root);
let config_path = griptrees_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: &Path,
tree_manifests_dir: &Path,
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)?;
if manifest_paths::resolve_manifest_file_in_dir(tree_manifests_dir).is_none() {
if let Some(main_manifest) =
manifest_paths::resolve_manifest_file_in_dir(main_manifests_dir)
{
let target_manifest = tree_manifests_dir.join(manifest_paths::PRIMARY_FILE_NAME);
std::fs::copy(main_manifest, target_manifest)?;
}
}
Ok(worktree_name)
}
fn create_worktree(
repo_path: &Path,
worktree_path: &Path,
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: &Path, revision: &str) -> anyhow::Result<()> {
let repo = open_repo(repo_path)?;
let mut remote = repo.find_remote("origin")?;
remote.fetch(&[revision], None, None)?;
let upstream_ref = format!("refs/remotes/origin/{}", revision);
let upstream_commit = repo.revparse_single(&upstream_ref)?.peel_to_commit()?;
repo.reset(upstream_commit.as_object(), git2::ResetType::Hard, None)?;
Ok(())
}
fn stash_repo(repo_path: &Path, message: &str) -> anyhow::Result<bool> {
let mut cmd = Command::new("git");
cmd.args(["stash", "push", "-u", "-m", message])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(anyhow::anyhow!("git stash failed: {}", stderr.trim()));
}
let combined = format!("{}{}", stdout, stderr);
if combined.contains("No local changes to save") {
return Ok(false);
}
Ok(true)
}
fn stash_pop_repo(repo_path: &Path) -> anyhow::Result<()> {
let mut cmd = Command::new("git");
cmd.args(["stash", "pop"]).current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("git stash pop failed: {}", stderr.trim()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_stale_child_marker_cleaned_on_tree_list() {
let tmp = TempDir::new().unwrap();
let gitgrip_dir = tmp.path().join(".gitgrip");
std::fs::create_dir_all(&gitgrip_dir).unwrap();
let griptrees_path = gitgrip_dir.join("griptrees.json");
std::fs::write(&griptrees_path, r#"{"griptrees":{}}"#).unwrap();
let griptree_path = gitgrip_dir.join("griptree.json");
std::fs::write(&griptree_path, r#"{"branch":"old","path":"."}"#).unwrap();
assert!(
griptree_path.exists(),
"child marker should exist before repair"
);
if griptrees_path.exists() && griptree_path.exists() {
let _ = std::fs::remove_file(&griptree_path);
}
assert!(!griptree_path.exists(), "child marker should be removed");
assert!(griptrees_path.exists(), "root marker should be preserved");
}
}