use anyhow::Result;
use clap::Parser;
use yansi::Paint;
use crate::{
cmd, constants, display, errors, git, model,
model::{IndexMap, IndexSet, StringSet},
query,
};
type GitConfigMap = IndexMap<String, StringSet>;
#[derive(Parser, Clone, Debug)]
#[command(author, about, long_about)]
pub struct GrowOptions {
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(long, short, default_value = "*")]
trees: String,
#[arg(long, default_value = "*")]
remote: String,
#[arg(required = true)]
queries: Vec<String>,
}
pub fn main(app_context: &model::ApplicationContext, options: &GrowOptions) -> Result<()> {
let quiet = app_context.options.quiet;
let verbose = app_context.options.verbose + options.verbose;
let mut exit_status = errors::EX_OK;
let mut configured_worktrees: StringSet = IndexSet::new();
for query in &options.queries {
let status = grow(
app_context,
&mut configured_worktrees,
quiet,
verbose,
query,
&options.trees,
&options.remote,
)?;
if status != errors::EX_OK {
exit_status = status;
}
}
errors::exit_status_into_result(exit_status)
}
fn grow(
app_context: &model::ApplicationContext,
configured_worktrees: &mut StringSet,
quiet: bool,
verbose: u8,
query: &str,
tree_pattern: &str,
remote_pattern: &str,
) -> Result<i32> {
let config = app_context.get_root_config();
let contexts = query::resolve_and_filter_trees(app_context, config, query, tree_pattern);
let remote_pattern = glob::Pattern::new(remote_pattern).unwrap_or_default();
let mut exit_status = errors::EX_OK;
for tree_context in &contexts {
let eval_context = model::EvalContext::from_app_context(app_context, tree_context);
let status = grow_tree_from_context(
&eval_context,
configured_worktrees,
&remote_pattern,
quiet,
verbose,
)?;
if status != errors::EX_OK {
exit_status = status;
}
}
Ok(exit_status)
}
fn grow_tree_from_context(
eval_context: &model::EvalContext,
configured_worktrees: &mut StringSet,
remote_pattern: &glob::Pattern,
quiet: bool,
verbose: u8,
) -> Result<i32> {
let mut exit_status = errors::EX_OK;
let tree = if let Some(graft_cfg) = eval_context.graft_config {
match graft_cfg.trees.get(&eval_context.tree_context.tree) {
Some(tree) => tree,
None => return Ok(exit_status),
}
} else {
match eval_context
.config
.trees
.get(&eval_context.tree_context.tree)
{
Some(tree) => tree,
None => return Ok(exit_status),
}
};
display::print_tree_details(tree, eval_context.config.tree_branches, verbose, quiet);
let Some(pathbuf) = tree.pathbuf() else {
return Err(errors::GardenError::ConfigurationError(format!(
"invalid path for tree: {tree}",
tree = tree.get_name()
))
.into());
};
let Some(parent) = pathbuf.parent() else {
return Err(errors::GardenError::OSError(format!(
"{pathbuf:?}: unable to get parent directory"
))
.into());
};
std::fs::create_dir_all(parent).map_err(|err| {
errors::GardenError::OSError(format!("unable to create {parent:?}: {err}"))
})?;
let branch = tree.eval_branch(eval_context);
if !is_empty_tree(&pathbuf) {
return update_tree_from_context(
eval_context,
configured_worktrees,
&pathbuf,
&branch,
remote_pattern,
false,
quiet,
verbose,
);
}
if tree.is_symlink {
let status = grow_symlink(eval_context.app_context, eval_context.tree_context)
.unwrap_or(errors::EX_IOERR);
if status != errors::EX_OK {
exit_status = status;
}
return Ok(exit_status);
}
if tree.is_worktree {
return grow_tree_from_context_as_worktree(
eval_context,
configured_worktrees,
remote_pattern,
quiet,
verbose,
);
}
let Some(url) = tree.eval_url(eval_context) else {
return Ok(exit_status);
};
let mut cmd: Vec<&str> = ["git", "clone"].to_vec();
if tree.is_bare_repository {
cmd.push("--bare");
}
if tree.default_remote != constants::ORIGIN {
cmd.push("--origin");
cmd.push(&tree.default_remote);
}
let remote_for_branch = tree.get_remote_for_branch(eval_context, &branch);
let upstream_branch = tree.get_upstream_branch(eval_context, &branch);
let branch_opt;
if !branch.is_empty() && upstream_branch.is_none() && remote_for_branch.is_none() {
branch_opt = format!("--branch={branch}");
cmd.push(&branch_opt);
}
let clone_depth = tree.clone_depth;
let clone_depth_opt;
if clone_depth > 0 {
clone_depth_opt = format!("--depth={clone_depth}");
cmd.push(&clone_depth_opt);
}
let is_single_branch = tree.is_single_branch;
if is_single_branch {
cmd.push("--single-branch");
} else {
cmd.push("--no-single-branch");
}
let path = tree.path_as_ref()?;
cmd.push(&url);
cmd.push(path);
if verbose > 1 {
print_quoted_command(&cmd);
}
let exec = cmd::exec_cmd(&cmd);
let status = cmd::status(exec);
if status != 0 {
exit_status = status;
}
let status = update_tree_from_context(
eval_context,
configured_worktrees,
&pathbuf,
&branch,
remote_pattern,
true,
quiet,
verbose,
)?;
if status != errors::EX_OK {
exit_status = status;
}
Ok(exit_status)
}
fn print_quoted_command(command: &[&str]) {
let quoted_args = command
.iter()
.map(|arg| cmd::shell_quote(arg))
.collect::<Vec<String>>();
print_command_str("ed_args.join(" "));
}
fn print_command_str(cmd: &str) {
println!("{} {}", ":".cyan(), cmd.green())
}
#[allow(clippy::too_many_arguments)]
fn update_tree_from_context(
eval_context: &model::EvalContext,
configured_worktrees: &mut StringSet,
path: &dyn AsRef<std::path::Path>,
branch: &str,
remote_pattern: &glob::Pattern,
checkout: bool,
_quiet: bool,
verbose: u8,
) -> Result<i32> {
let mut exit_status = errors::EX_OK;
let tree = if let Some(graft_cfg) = eval_context.graft_config {
match graft_cfg.trees.get(&eval_context.tree_context.tree) {
Some(tree) => tree,
None => return Ok(exit_status),
}
} else {
match eval_context
.config
.trees
.get(&eval_context.tree_context.tree)
{
Some(tree) => tree,
None => return Ok(exit_status),
}
};
if tree.is_symlink {
return Ok(exit_status);
}
let shared_worktree_path = query::shared_worktree_path(
eval_context.app_context,
eval_context.config,
eval_context.tree_context,
);
if !configured_worktrees.insert(shared_worktree_path) {
return Ok(exit_status);
}
let mut existing_remotes = IndexSet::new();
{
let command = ["git", "remote"];
let exec = cmd::exec_in_dir(&command, path.as_ref());
if let Ok(output) = cmd::stdout_to_string(exec) {
for line in output.lines() {
existing_remotes.insert(String::from(line));
}
}
}
if tree.default_remote != constants::ORIGIN {
set_gitconfig_value(
"checkout.defaultRemoteName",
&tree.default_remote,
path,
verbose,
);
}
let mut fetched_remotes: StringSet = IndexSet::new();
fetched_remotes.insert(tree.default_remote.to_string());
for (remote, var) in &tree.remotes {
if !remote_pattern.matches(remote) {
continue;
}
let url = eval_context.tree_variable(var);
if existing_remotes.contains(remote) {
let remote_key = format!("remote.{remote}.url");
let status = set_gitconfig_value(&remote_key, &url, path, verbose);
if status != errors::EX_OK {
exit_status = status;
}
} else {
let command = ["git", "remote", "add", remote.as_ref(), url.as_ref()];
if verbose > 1 {
print_command_str(&command.join(" "));
}
let status = cmd::run_command(&command, path.as_ref());
if status != errors::EX_OK {
exit_status = status;
}
let key = format!("remote.{remote}.tagopt");
let status = set_gitconfig_value(&key, "--no-tags", path, verbose);
if status != errors::EX_OK {
exit_status = status;
}
let remote_for_branch = tree.get_remote_for_branch(eval_context, branch);
if Some(remote) == remote_for_branch.as_ref() {
let command = ["git", "fetch", remote];
if verbose > 1 {
print_command_str(&command.join(" "));
}
fetched_remotes.insert(remote.to_string());
let status = cmd::run_command(&command, path.as_ref());
if status != errors::EX_OK {
exit_status = status;
}
}
}
}
let mut gitconfig_cache: GitConfigMap = GitConfigMap::new();
for (var_name, variables) in &tree.gitconfig {
let name = eval_context.tree_value(var_name);
for var in variables {
let value = match var.get_value() {
Some(precomputed_value) => precomputed_value.to_string(),
None => eval_context.tree_variable(var),
};
let status = if variables.len() > 1 {
append_gitconfig_value(&name, &value, path, &mut gitconfig_cache)
} else {
set_gitconfig_value(&name, &value, path, verbose)
};
if status != errors::EX_OK {
exit_status = status;
}
}
}
if !tree.branches.is_empty() {
let branches = git::branches(path.as_ref());
for (branch, expr) in &tree.branches {
if !branches.contains(branch) {
let remote_branch = eval_context.tree_variable(expr);
if remote_branch.is_empty() {
continue;
}
if let Some(remote_for_branch) = tree.get_remote_for_branch(eval_context, branch) {
if !fetched_remotes.contains(&remote_for_branch) {
fetched_remotes.insert(remote_for_branch.to_string());
let command = ["git", "fetch", remote_for_branch.as_str()];
if verbose > 1 {
print_command_str(&command.join(" "));
}
let status = cmd::run_command(&command, path.as_ref());
if status != errors::EX_OK {
exit_status = status;
}
}
}
let command = ["git", "branch", "--track", branch, remote_branch.as_str()];
if verbose > 1 {
print_command_str(&command.join(" "));
}
let status = cmd::run_command(&command, path.as_ref());
if status != errors::EX_OK {
exit_status = status;
}
}
}
}
if checkout && !branch.is_empty() && tree.branches.contains_key(branch) {
let command = ["git", "checkout", branch, "--"];
let status = cmd::run_command(&command, path.as_ref());
if status != errors::EX_OK {
exit_status = status;
}
}
Ok(exit_status)
}
fn append_gitconfig_value(
name: &str,
value: &str,
path: &dyn AsRef<std::path::Path>,
config_map: &mut GitConfigMap,
) -> i32 {
let needs_cache = !config_map.contains_key(name);
if needs_cache {
let cmd = ["git", "config", "--get-all", name];
let exec = cmd::exec_in_dir(&cmd, path.as_ref());
if let Ok(output) = cmd::stdout_to_string(exec) {
let mut values = IndexSet::new();
for value in output.lines() {
values.insert(value.to_string());
}
config_map.insert(name.to_string(), values);
} else {
config_map.insert(name.to_string(), IndexSet::new());
}
}
let mut status = errors::EX_OK;
if let Some(values) = config_map.get_mut(name) {
if !values.contains(value) {
values.insert(value.to_string());
let command = ["git", "config", "--add", name, value];
status = cmd::run_command(&command, path.as_ref());
}
}
status
}
fn set_gitconfig_value(
name: &str,
value: &str,
path: &dyn AsRef<std::path::Path>,
verbose: u8,
) -> i32 {
let command = ["git", "config", name, value];
if verbose > 1 {
print_command_str(&command.join(" "));
}
cmd::run_command(&command, path.as_ref())
}
fn grow_tree_from_context_as_worktree(
eval_context: &model::EvalContext,
configured_worktrees: &mut StringSet,
remote_pattern: &glob::Pattern,
quiet: bool,
verbose: u8,
) -> Result<i32> {
let mut exit_status = errors::EX_OK;
let tree = if let Some(graft_cfg) = eval_context.graft_config {
match graft_cfg.trees.get(&eval_context.tree_context.tree) {
Some(tree) => tree,
None => return Ok(exit_status),
}
} else {
match eval_context
.config
.trees
.get(&eval_context.tree_context.tree)
{
Some(tree) => tree,
None => return Ok(exit_status),
}
};
let worktree = tree.eval_worktree(eval_context);
let branch = tree.eval_branch(eval_context);
let parent_tree_context = query::tree_from_name(
eval_context.config,
&worktree,
eval_context.tree_context.garden.as_ref(),
eval_context.tree_context.group.as_ref(),
)
.ok_or_else(|| errors::GardenError::WorktreeNotFound {
tree: tree.get_name().to_string(),
worktree: worktree.clone(),
})?;
let parent_eval_context =
model::EvalContext::from_app_context(eval_context.app_context, &parent_tree_context);
exit_status = grow_tree_from_context(
&parent_eval_context,
configured_worktrees,
remote_pattern,
quiet,
verbose,
)?;
if exit_status != 0 {
return Err(errors::GardenError::WorktreeParentCreationError {
tree: tree.get_name().into(),
worktree,
}
.into());
}
let tree_path = tree.path_as_ref()?;
let parent_tree = match eval_context.config.trees.get(&parent_tree_context.tree) {
Some(parent_tree) => parent_tree,
None => {
return Err(errors::GardenError::WorktreeNotFound {
tree: tree.get_name().to_string(),
worktree,
}
.into())
}
};
let parent_path = parent_tree.path_as_ref()?;
let mut cmd: Vec<&str> = ["git", "worktree", "add"].to_vec();
if !branch.is_empty() {
cmd.push("--track");
cmd.push("-b");
cmd.push(&branch);
}
let relative_path_str;
if let Some(relative_path) = pathdiff::diff_paths(tree_path, parent_path) {
relative_path_str = relative_path.to_string_lossy().to_string();
cmd.push(&relative_path_str);
} else {
cmd.push(tree_path);
}
let remote_branch;
if !branch.is_empty() {
if let Some(expr) = tree.branches.get(&branch) {
remote_branch = eval_context.tree_variable(expr);
} else {
let default_remote = tree.default_remote.to_string();
remote_branch = format!("{default_remote}/{branch}");
}
if !remote_branch.is_empty() {
cmd.push(&remote_branch);
}
}
if verbose > 1 {
print_quoted_command(&cmd);
}
exit_status = cmd::run_command(&cmd, &parent_path);
if exit_status != 0 {
return Err(errors::GardenError::WorktreeGitCheckoutError {
tree: tree.get_name().clone(),
status: exit_status,
}
.into());
}
Ok(exit_status)
}
fn grow_symlink(
app_context: &model::ApplicationContext,
tree_context: &model::TreeContext,
) -> Result<i32> {
let config = match tree_context.config {
Some(config_id) => app_context.get_config(config_id),
None => app_context.get_root_config(),
};
let tree = match config.trees.get(&tree_context.tree) {
Some(tree) => tree,
None => return Ok(errors::EX_OK),
};
if !tree.is_symlink || tree.path_as_ref()?.is_empty() || tree.symlink_as_ref()?.is_empty() {
return Err(errors::GardenError::ConfigurationError(format!(
"invalid symlink: {}",
tree.get_name()
))
.into());
}
let path_str = tree.path_as_ref()?;
let path = std::path::PathBuf::from(&path_str);
if std::fs::read_link(&path).is_ok() || path.exists() {
return Ok(errors::EX_OK);
}
let symlink_str = tree.symlink_as_ref()?;
let symlink = std::path::PathBuf::from(&symlink_str);
let parent = path
.parent()
.as_ref()
.ok_or_else(|| errors::GardenError::AssertionError(format!("parent() failed: {path:?}")))?
.to_path_buf();
let target = if symlink.starts_with(&parent) && symlink.strip_prefix(&parent).is_ok() {
symlink.strip_prefix(&parent)?.to_string_lossy()
} else {
symlink.to_string_lossy()
}
.to_string();
let target_path = std::path::PathBuf::from(&target);
#[cfg(unix)]
{
std::os::unix::fs::symlink(target_path, &path)?;
}
#[cfg(windows)]
{
println!(
"warning: symlink trees are not supported on Windows: {} -> {}",
path.to_string_lossy(),
target_path.to_string_lossy()
);
}
#[cfg(target_family = "wasm")]
{
println!(
"warning: symlink trees are not available on wasm: {} -> {}",
path.to_string_lossy(),
target_path.to_string_lossy()
);
}
Ok(errors::EX_OK)
}
fn is_empty_tree(path: &dyn AsRef<std::path::Path>) -> bool {
let path = path.as_ref();
if !path.exists() {
return true;
}
let Ok(read_dir) = path.read_dir() else {
return true;
};
let num_entries = read_dir.count();
if num_entries == 0 {
return true;
}
false
}