use std::path::{Path, PathBuf};
use anyhow::Context;
use ignore::gitignore::GitignoreBuilder;
use worktrunk::config::CopyIgnoredConfig;
use worktrunk::git::Repository;
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{format_bash_with_gutter, format_heading, format_with_gutter};
use super::super::commit::CommitGenerator;
pub(super) fn print_dry_run(
prompt: &str,
commit_config: &worktrunk::config::CommitGenerationConfig,
message: &str,
) -> anyhow::Result<()> {
let command_block = match commit_config
.command
.as_deref()
.filter(|s| !s.trim().is_empty())
{
Some(cmd) => format_bash_with_gutter(&crate::llm::render_llm_invocation(cmd)?),
None => format_with_gutter("(LLM not configured — using built-in fallback)", None),
};
let formatted = CommitGenerator::new(commit_config).format_message_for_display(message);
let out = format!(
"{prompt_heading}\n{prompt}\n\n{command_heading}\n{command_block}\n\n{message_heading}\n{message_block}\n",
prompt_heading = format_heading("PROMPT", None),
command_heading = format_heading("COMMAND", None),
message_heading = format_heading("MESSAGE", None),
message_block = format_with_gutter(&formatted, None),
);
crate::help_pager::show_help_in_pager(&out, true);
Ok(())
}
pub(super) const BUILTIN_COPY_IGNORED_EXCLUDES: &[&str] = &[
".bzr/",
".conductor/",
".entire/",
".hg/",
".jj/",
".pijul/",
".sl/",
".svn/",
".worktrees/",
];
fn default_copy_ignored_excludes() -> Vec<String> {
BUILTIN_COPY_IGNORED_EXCLUDES
.iter()
.map(|s| (*s).to_string())
.collect()
}
pub(super) fn resolve_copy_ignored_config(repo: &Repository) -> anyhow::Result<CopyIgnoredConfig> {
let mut config = CopyIgnoredConfig {
exclude: default_copy_ignored_excludes(),
};
let user_config = repo.user_config();
let project_config = repo
.project_config()
.context("Failed to load project config")?;
if let Some(project_config) = project_config
&& let Some(project_copy_ignored) = project_config.copy_ignored()
{
config = config.merged_with(project_copy_ignored);
}
let project_id = repo.project_identifier().ok();
config = config.merged_with(&user_config.copy_ignored(project_id.as_deref()));
Ok(config)
}
pub(super) fn list_and_filter_ignored_entries(
worktree_path: &Path,
context: &str,
worktree_paths: &[PathBuf],
exclude_patterns: &[String],
) -> anyhow::Result<Vec<(PathBuf, bool)>> {
let ignored_entries = list_ignored_entries(worktree_path, context)?;
let include_path = worktree_path.join(".worktreeinclude");
let filtered: Vec<_> = if include_path.exists() {
let include_matcher = {
let mut builder = GitignoreBuilder::new(worktree_path);
if let Some(err) = builder.add(&include_path) {
return Err(worktrunk::git::GitError::WorktreeIncludeParseError {
error: err.to_string().replace('\\', "/"),
}
.into());
}
builder.build().context("Failed to build include matcher")?
};
ignored_entries
.into_iter()
.filter(|(path, is_dir)| include_matcher.matched(path, *is_dir).is_ignore())
.collect()
} else {
ignored_entries
};
let exclude_matcher = if exclude_patterns.is_empty() {
None
} else {
let mut builder = GitignoreBuilder::new(worktree_path);
for pattern in exclude_patterns {
builder.add_line(None, pattern).map_err(|error| {
anyhow::anyhow!(
"Invalid [step.copy-ignored].exclude pattern {:?}: {}",
pattern,
error
)
})?;
}
Some(
builder
.build()
.context("Failed to build copy-ignored exclude matcher")?,
)
};
Ok(filtered
.into_iter()
.filter(|(path, is_dir)| {
if let Some(ref matcher) = exclude_matcher {
let relative = path.strip_prefix(worktree_path).unwrap_or(path.as_path());
if matcher.matched(relative, *is_dir).is_ignore() {
return false;
}
}
if *is_dir
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| {
BUILTIN_COPY_IGNORED_EXCLUDES
.iter()
.any(|pat| pat.trim_end_matches('/') == name)
})
{
return false;
}
!worktree_paths
.iter()
.any(|wt_path| wt_path != worktree_path && wt_path.starts_with(path))
})
.collect())
}
fn list_ignored_entries(
worktree_path: &Path,
context: &str,
) -> anyhow::Result<Vec<(std::path::PathBuf, bool)>> {
let output = Cmd::new("git")
.args([
"ls-files",
"--ignored",
"--exclude-standard",
"-o",
"--directory",
])
.current_dir(worktree_path)
.context(context)
.run()
.context("Failed to run git ls-files")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git ls-files failed: {}", stderr.trim());
}
let entries = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| {
let is_dir = line.ends_with('/');
let path = worktree_path.join(line.trim_end_matches('/'));
(path, is_dir)
})
.collect();
Ok(entries)
}