use std::ffi::OsString;
use std::io::Write as _;
use std::path::Path;
use std::process::ExitCode;
use color_eyre::eyre::{Result, WrapErr as _, bail, eyre};
use girt::archive::{ExtraFile, Format, Options};
use girt::pathspec::{self, Pathspec, Prune};
use girt::{EntryMode, ObjectId, ObjectKind, Objects, Repository};
use crate::{Context, common};
#[derive(Debug, clap::Args)]
#[command(disable_help_flag = true)]
pub struct Args {
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0..)]
args: Vec<OsString>,
#[arg(skip)]
raw: Option<Vec<OsString>>,
}
impl Args {
pub fn set_raw(&mut self, raw: Vec<OsString>) {
self.raw = Some(raw);
}
}
pub fn run(args: Args, context: &Context) -> Result<ExitCode> {
let raw = args.raw.unwrap_or(args.args);
let mut format = None;
let mut output: Option<OsString> = None;
let mut prefix = Vec::new();
let mut options = Options::new();
let mut positional = Vec::new();
let mut args = raw.into_iter();
while let Some(arg) = args.next() {
let Some(text) = arg.to_str().map(str::to_owned) else {
positional.push(arg);
continue;
};
if text == "--" {
positional.extend(args.by_ref());
break;
}
let (name, inline) = match text.split_once('=') {
Some((name, value)) if name.starts_with("--") => {
(name.to_owned(), Some(value.to_owned()))
}
_ => (text.clone(), None),
};
let mut value = |option: &str| -> Result<String> {
match inline.clone() {
Some(value) => Ok(value),
None => args
.next()
.and_then(|value| value.into_string().ok())
.ok_or_else(|| eyre!("option `{option}` requires a value")),
}
};
match name.as_str() {
"--format" => format = Some(parse_format(&value("--format")?)?),
"-o" | "--output" => output = Some(OsString::from(value("--output")?)),
"--prefix" => prefix = value("--prefix")?.into_bytes(),
"--add-file" => {
let file = value("--add-file")?;
options = options.with_extra_file(read_extra(&file, &prefix)?);
}
"--add-virtual-file" => {
let spec = value("--add-virtual-file")?;
let (path, content) = spec
.split_once(':')
.ok_or_else(|| eyre!("--add-virtual-file expects <path>:<content>"))?;
options = options.with_extra_file(ExtraFile::new(path, content));
}
"--mtime" => options = options.with_mtime(Some(parse_mtime(&value("--mtime")?)?)),
"--worktree-attributes" => options = options.with_worktree_attributes(true),
"-v" | "--verbose" => {}
"-l" | "--list" => {
let mut out = common::stdout();
for name in ["tar", "tgz", "tar.gz", "zip"] {
writeln!(out, "{name}")?;
}
out.flush()?;
return Ok(ExitCode::SUCCESS);
}
"--remote" | "--exec" => bail!("{name} is unsupported"),
_ if name.len() == 2
&& name.starts_with('-')
&& name.as_bytes().get(1).is_some_and(u8::is_ascii_digit) =>
{
let level = name
.as_bytes()
.get(1)
.map_or(6, |digit| u32::from(digit - b'0'));
options = options.with_level(Some(level));
}
_ if name.starts_with('-') && name != "-" => bail!("unknown option `{name}`"),
_ => positional.push(arg),
}
}
let mut positional = positional.into_iter();
let revision = positional
.next()
.ok_or_else(|| eyre!("a tree-ish is required"))?;
let revision = revision
.to_str()
.ok_or_else(|| eyre!("tree-ish is not UTF-8"))?
.to_owned();
let specs: Vec<OsString> = positional.collect();
let format = match (format, &output) {
(Some(format), _) => format,
(None, Some(output)) => format_from_name(output),
(None, None) => Format::Tar,
};
let repository = context.repository()?;
let objects = repository.objects()?;
let directory = current_prefix(&repository, context);
let id = common::resolve(&repository, &revision)?;
let pathspec = Pathspec::parse(
specs.iter().map(|spec| spec.as_encoded_bytes()),
&pathspec::Context::from_environment(&context.environment)?,
)?;
for spec in &specs {
let single = Pathspec::parse([spec.as_encoded_bytes()], &pathspec::Context::new())?;
let tree = subtree(&objects, tree_of(&objects, id)?, &directory)?;
if !tree_matches(&objects, tree, &mut Vec::new(), &single)? {
bail!(
"pathspec '{}' did not match any files",
Path::new(spec).display()
);
}
}
let options = options
.with_format(format)
.with_prefix(prefix)
.with_subdirectory(directory)
.with_commands(girt::filter::Commands::Run)
.with_environment(context.environment.clone());
match output {
Some(output) => {
let file = std::fs::File::create(&output)
.wrap_err_with(|| format!("creating {}", Path::new(&output).display()))?;
let mut file = std::io::BufWriter::new(file);
repository.archive(id, &pathspec, &options, &mut file)?;
file.flush()?;
}
None => {
let mut out = common::stdout();
repository.archive(id, &pathspec, &options, &mut out)?;
out.flush()?;
}
}
Ok(ExitCode::SUCCESS)
}
fn parse_format(name: &str) -> Result<Format> {
Ok(match name {
"tar" => Format::Tar,
"tgz" | "tar.gz" => Format::TarGz,
"zip" => Format::Zip,
_ => bail!("unknown archive format '{name}'"),
})
}
fn format_from_name(name: &OsString) -> Format {
let name = name.to_string_lossy();
if name.ends_with(".zip") {
Format::Zip
} else if name.ends_with(".tgz") || name.ends_with(".tar.gz") {
Format::TarGz
} else {
Format::Tar
}
}
fn parse_mtime(value: &str) -> Result<i64> {
value
.strip_prefix('@')
.and_then(|seconds| seconds.parse().ok())
.ok_or_else(|| eyre!("unsupported --mtime '{value}'; use @<seconds>"))
}
fn read_extra(file: &str, prefix: &[u8]) -> Result<ExtraFile> {
let path = Path::new(file);
let content = std::fs::read(path).wrap_err_with(|| format!("reading {file}"))?;
let name = path
.file_name()
.ok_or_else(|| eyre!("--add-file needs a file name: {file}"))?;
let mut archived = prefix.to_vec();
archived.extend_from_slice(name.as_encoded_bytes());
#[cfg(unix)]
let executable = {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path)?.permissions().mode() & 0o111 != 0
};
#[cfg(not(unix))]
let executable = false;
Ok(ExtraFile::new(archived, content).with_executable(executable))
}
fn current_prefix(repository: &Repository, context: &Context) -> String {
if let Some(worktree) = repository.worktree()
&& let Ok(worktree) = worktree.canonicalize()
&& let Ok(current) = context.directory().canonicalize()
&& let Ok(prefix) = current.strip_prefix(&worktree)
{
return prefix
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
}
String::new()
}
fn tree_of(objects: &Objects, mut id: ObjectId) -> Result<ObjectId> {
loop {
let header = objects
.header(id, girt::ReadLimits::default())?
.ok_or_else(|| eyre!("object {id} is missing"))?;
match header.kind() {
ObjectKind::Tree => return Ok(id),
ObjectKind::Commit => return Ok(objects.read_commit(id)?.tree()),
ObjectKind::Tag => id = objects.read_tag(id)?.target(),
ObjectKind::Blob => bail!("{id} is not a tree-ish"),
}
}
}
fn subtree(objects: &Objects, mut tree: ObjectId, path: &str) -> Result<ObjectId> {
for name in path.split('/').filter(|name| !name.is_empty()) {
tree = objects
.read_tree(tree)?
.entries()
.iter()
.find(|entry| entry.name == name.as_bytes() && entry.mode == EntryMode::Tree)
.map(|entry| entry.id)
.ok_or_else(|| eyre!("the current directory is not in the archived tree"))?;
}
Ok(tree)
}
fn tree_matches(
objects: &Objects,
tree: ObjectId,
directory: &mut Vec<u8>,
pathspec: &Pathspec,
) -> Result<bool> {
for entry in objects.read_tree(tree)?.entries() {
let length = directory.len();
if !directory.is_empty() {
directory.push(b'/');
}
directory.extend_from_slice(&entry.name);
let found = if entry.mode == EntryMode::Tree {
match pathspec.directory(directory) {
Prune::Skip => false,
Prune::All => true,
Prune::Descend => tree_matches(objects, entry.id, directory, pathspec)?,
}
} else {
pathspec.matches(directory).unwrap_or(true)
};
directory.truncate(length);
if found {
return Ok(true);
}
}
Ok(false)
}