mod filter;
mod output;
use crate::cli::ScopeArgs;
use crate::git::{self, CommitInfo, Repository, ResolvedRef};
use anyhow::{Context, Result, bail};
use colored::Colorize;
use std::path::Path;
const SCOPE_PACK_MARKER: &str = ".prview-scope-pack.json";
const SCOPE_PACK_SCHEMA: &str = "prview-scope-pack/v1";
pub fn run(args: &ScopeArgs) -> Result<()> {
if args.include.is_empty() && args.exclude.is_empty() {
bail!("At least one of --include or --exclude is required");
}
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let repo_root = crate::config::find_repo_root_from(&cwd)
.context("Not inside a git repository (no .git in the current or any parent directory)")?;
let repo = Repository::open(&repo_root)?;
let scope_filter =
filter::ScopeFilter::new(&args.include, &args.exclude).context("Invalid glob pattern")?;
let target = resolve_target(&repo)?;
let base = resolve_base(&repo, args.base.as_deref())?;
let base = {
let effective_commit = repo
.merge_base(&base.commit_id, &target.commit_id)
.unwrap_or_else(|_| base.commit_id.clone());
ResolvedRef {
name: base.name,
commit_id: effective_commit,
is_remote: base.is_remote,
}
};
println!(
"{} Scope: {} → {}",
"→".cyan(),
git::short_sha(&base.commit_id),
git::short_sha(&target.commit_id),
);
let diff = repo.diff_refs(&base, &target)?;
let all_file_paths: Vec<String> = diff.files.iter().map(|f| f.path.clone()).collect();
let scoped_files = scope_filter.filter_paths(&all_file_paths);
let wip_scoped: Vec<String> = if args.wip {
repo.wip_files_scoped(&[])?
.into_iter()
.filter(|f| scope_filter.matches(f))
.collect()
} else {
Vec::new()
};
if scoped_files.is_empty() && wip_scoped.is_empty() {
println!(
"{} No files match the scope. Nothing to generate.",
"ℹ".blue()
);
return Ok(());
}
let scoped_commits = if scoped_files.is_empty() {
Vec::new()
} else {
filter_commits_by_scope(&repo, &diff.commits, &scoped_files)?
};
println!(
"{} Scope: {} files, {} commits (from {} files, {} commits total)",
"✓".green(),
scoped_files.len(),
scoped_commits.len(),
all_file_paths.len(),
diff.commits.len(),
);
let out_dir = &args.output;
if out_dir.exists() {
guard_output_dir(out_dir, &cwd, &repo_root)?;
std::fs::remove_dir_all(out_dir)
.with_context(|| format!("Failed to clean output dir {}", out_dir.display()))?;
}
std::fs::create_dir_all(out_dir)
.with_context(|| format!("Failed to create output dir {}", out_dir.display()))?;
write_scope_pack_marker(out_dir)?;
let full_patch = generate_scoped_full_patch(&repo, &base, &target, &scoped_files)?;
output::write_full_patch(out_dir, &full_patch)?;
let per_commit = generate_per_commit_patches(&repo, &scoped_commits, &scoped_files)?;
output::write_per_commit_patches(out_dir, &per_commit)?;
if args.per_file {
let per_file = generate_per_file_patches(&repo, &base, &target, &scoped_files)?;
output::write_per_file_patches(out_dir, &per_file)?;
}
if args.wip && !wip_scoped.is_empty() {
let wip_refs: Vec<&str> = wip_scoped.iter().map(|s| s.as_str()).collect();
let wip_patch = repo.wip_diff_scoped(&wip_refs)?;
if !wip_patch.is_empty() {
output::write_wip_patch(out_dir, &wip_patch)?;
println!(" {} WIP changes included", "✓".green());
}
}
let wip_scoped_refs: Vec<&str> = wip_scoped.iter().map(|s| s.as_str()).collect();
output::write_scope_md(&output::ScopeMdParams {
dir: out_dir,
include: &args.include,
exclude: &args.exclude,
wip: args.wip,
base_ref: &base.name,
target_ref: &target.name,
scoped_files: &scoped_files,
total_files: all_file_paths.len(),
wip_scoped_files: &wip_scoped_refs,
scoped_commits: &scoped_commits,
total_commits: diff.commits.len(),
})?;
output::write_commits_log(out_dir, &scoped_commits)?;
println!(
"\n{} Review pack: {}",
"✓".green().bold(),
out_dir.display(),
);
print_pack_summary(out_dir);
Ok(())
}
fn resolve_target(repo: &Repository) -> Result<ResolvedRef> {
let config = minimal_config();
repo.resolve_target(&config)
}
fn resolve_base(repo: &Repository, explicit_base: Option<&str>) -> Result<ResolvedRef> {
let mut config = minimal_config();
if let Some(base_name) = explicit_base {
config.bases = vec![base_name.to_string()];
} else {
config.bases = vec![
"develop".to_string(),
"main".to_string(),
"master".to_string(),
];
}
let bases = repo.resolve_bases(&config)?;
bases
.into_iter()
.next()
.context(if let Some(name) = explicit_base {
format!("Could not resolve base ref '{name}'")
} else {
"Could not find a base branch (tried develop, main, master). Use --base to specify one."
.to_string()
})
}
fn minimal_config() -> crate::config::Config {
use crate::config::{DetectedProfile, ProfileKind};
use crate::policy::PolicyConfig;
let mut config = crate::config::Config::base(
std::env::current_dir().unwrap_or_default(),
DetectedProfile {
kind: ProfileKind::Generic,
has_package_json: false,
has_tsconfig: false,
has_cargo: false,
has_pyproject: false,
has_python_source: false,
has_js_source: false,
cargo_root: None,
rust_dirs: vec![],
is_workspace: false,
},
std::path::PathBuf::from(".prview-policy.yml"),
PolicyConfig::default(),
None,
);
config.quiet = true;
config
}
fn filter_commits_by_scope<'a>(
repo: &Repository,
commits: &'a [CommitInfo],
scoped_files: &[&str],
) -> Result<Vec<&'a CommitInfo>> {
let mut result = Vec::new();
for commit in commits {
if commit.id.starts_with("0000000") {
continue;
}
if repo.is_merge_commit(&commit.id)? {
continue;
}
if repo.commit_touches_paths(&commit.id, scoped_files)? {
result.push(commit);
}
}
Ok(result)
}
fn generate_scoped_full_patch(
repo: &Repository,
base: &ResolvedRef,
target: &ResolvedRef,
scoped_files: &[&str],
) -> Result<String> {
repo.scoped_full_diff(&base.commit_id, &target.commit_id, scoped_files)
}
fn generate_per_commit_patches<'a>(
repo: &Repository,
commits: &[&'a CommitInfo],
scoped_files: &[&str],
) -> Result<Vec<(usize, &'a CommitInfo, String)>> {
let mut patches = Vec::new();
for (idx, commit) in commits.iter().enumerate() {
let patch = repo.commit_patch_scoped(&commit.id, scoped_files)?;
patches.push((idx + 1, *commit, patch));
}
Ok(patches)
}
fn generate_per_file_patches<'a>(
repo: &Repository,
base: &ResolvedRef,
target: &ResolvedRef,
scoped_files: &[&'a str],
) -> Result<Vec<(&'a str, String)>> {
let mut patches = Vec::new();
for file_path in scoped_files {
let patch = repo.file_diff(&base.commit_id, &target.commit_id, file_path)?;
patches.push((*file_path, patch));
}
Ok(patches)
}
fn guard_output_dir(out_dir: &Path, cwd: &Path, repo_root: &Path) -> Result<()> {
let abs = if out_dir.is_absolute() {
out_dir.to_path_buf()
} else {
cwd.join(out_dir)
};
let abs = abs.canonicalize().unwrap_or(abs);
let repo_root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
if abs.parent().is_none() {
bail!(
"Refusing to clean filesystem root as output dir: {}",
abs.display()
);
}
if let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) {
let home = home.canonicalize().unwrap_or(home);
if abs == home {
bail!(
"Refusing to clean home directory as output dir: {}",
abs.display()
);
}
}
if abs == repo_root || repo_root.starts_with(&abs) {
bail!(
"Refusing to clean {} — it is the repo root or an ancestor of it",
abs.display()
);
}
if !abs.starts_with(&repo_root) {
bail!(
"Refusing to clean {} — output dir must be inside the repository ({})",
abs.display(),
repo_root.display()
);
}
let is_empty = std::fs::read_dir(&abs)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if !is_empty && !dir_has_scope_pack_marker(&abs) {
bail!(
"Refusing to clean {} — it is not empty and does not carry a prview scope-pack \
marker ({SCOPE_PACK_MARKER}). Packs created before this marker existed, or any \
unrelated directory, must be removed manually or point -o elsewhere.",
abs.display()
);
}
Ok(())
}
fn write_scope_pack_marker(dir: &Path) -> Result<()> {
let body = format!("{{\"schema\":\"{SCOPE_PACK_SCHEMA}\"}}\n");
std::fs::write(dir.join(SCOPE_PACK_MARKER), body)
.with_context(|| format!("Failed to write scope-pack marker in {}", dir.display()))?;
Ok(())
}
fn dir_has_scope_pack_marker(dir: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(dir.join(SCOPE_PACK_MARKER)) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
value.get("schema").and_then(|s| s.as_str()) == Some(SCOPE_PACK_SCHEMA)
}
fn print_pack_summary(dir: &Path) {
let entries = [
("SCOPE.md", "scope metadata"),
("commits.log", "commit log"),
("full.patch", "unified diff"),
];
for (file, desc) in entries {
if dir.join(file).exists() {
println!(" {} {file} ({desc})", "·".dimmed());
}
}
let per_commit = dir.join("per-commit");
if per_commit.exists()
&& let Ok(rd) = std::fs::read_dir(&per_commit)
{
let count = rd.filter_map(|e| e.ok()).count();
if count > 0 {
println!(" {} per-commit/ ({count} patches)", "·".dimmed());
}
}
let per_file = dir.join("per-file");
if per_file.exists()
&& let Ok(rd) = std::fs::read_dir(&per_file)
{
let count = rd.filter_map(|e| e.ok()).count();
if count > 0 {
println!(" {} per-file/ ({count} patches)", "·".dimmed());
}
}
if dir.join("wip.patch").exists() {
println!(" {} wip.patch (uncommitted changes)", "·".dimmed());
}
}