use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
use clap_complete::ArgValueCandidates;
use color_eyre::eyre::{Result, WrapErr as _, bail, eyre};
use girt::clone::CloneHead;
use girt::push::{ForcePolicy, PushCommand, RejectionOrigin, Status};
use girt::refs::RefName;
use girt::remote::RefSource;
use girt::revision::Resolver;
use girt::serve::{Protocol, ReceivePack, UploadLimits, UploadPack};
use girt::workflow::{self, Environment, FetchOptions, PushOptions};
use girt::{InitKind, ObjectId, Repository};
use crate::common::{display_bytes, resolve, resolve_ref, stdout};
use crate::config::find_remote;
use crate::{Context, complete};
#[derive(Debug, clap::Args)]
pub struct CloneArgs {
url: String,
#[arg(value_name = "DIRECTORY")]
destination: Option<PathBuf>,
#[arg(long)]
bare: bool,
#[arg(short, long)]
no_checkout: bool,
#[command(flatten)]
filter: FilterArgs,
}
#[derive(Debug, Default)]
struct FilterArgs {
filters: Vec<girt::fetch::ObjectFilter>,
no_filter: bool,
}
impl FilterArgs {
fn filter(&self) -> Option<girt::fetch::ObjectFilter> {
use girt::fetch::ObjectFilter;
match self.filters.as_slice() {
[] => None,
[one] => Some(one.clone()),
many => Some(ObjectFilter::Combine(
many.iter()
.flat_map(|filter| match filter {
ObjectFilter::Combine(parts) => parts.clone(),
other => vec![other.clone()],
})
.collect(),
)),
}
}
}
impl clap::FromArgMatches for FilterArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
let mut args = Self::default();
args.update_from_arg_matches(matches)?;
Ok(args)
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
let cleared = (matches.value_source("no_filter")
== Some(clap::parser::ValueSource::CommandLine))
.then(|| matches.indices_of("no_filter").and_then(Iterator::max))
.flatten();
let values = matches
.get_many::<girt::fetch::ObjectFilter>("filter")
.into_iter()
.flatten();
let indices = matches.indices_of("filter").into_iter().flatten();
self.filters = values
.zip(indices)
.filter(|(_, index)| cleared.is_none_or(|cleared| *index > cleared))
.map(|(filter, _)| filter.clone())
.collect();
self.no_filter = cleared.is_some() && self.filters.is_empty();
Ok(())
}
}
impl clap::Args for FilterArgs {
fn augment_args(command: clap::Command) -> clap::Command {
command
.arg(
clap::Arg::new("filter")
.long("filter")
.value_name("SPEC")
.action(clap::ArgAction::Append)
.value_parser(clap::value_parser!(girt::fetch::ObjectFilter))
.help(
"Leave out objects the filter matches, such as `blob:none`, and record \
the remote as a promisor. Repeated filters combine. Only girt's object \
commands fetch the missing objects later; local paths and `file://` \
URLs are read directly and ignore the filter",
),
)
.arg(
clap::Arg::new("no_filter")
.long("no-filter")
.action(clap::ArgAction::Count)
.help(
"Fetch every object, discarding earlier `--filter` values and the \
remote's `partialclonefilter`",
),
)
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
Self::augment_args(command)
}
}
#[derive(Debug, clap::Args)]
pub struct FetchArgs {
#[arg(default_value = "origin", add = ArgValueCandidates::new(complete::remotes))]
remote: String,
#[command(flatten)]
filter: FilterArgs,
}
#[derive(Debug, clap::Args)]
pub struct ReceivePackArgs {
directory: PathBuf,
}
pub fn receive_pack(args: ReceivePackArgs, context: &Context) -> Result<ExitCode> {
let defaults = girt::push::PushLimits::default();
let mut limits = girt::push::PushLimits::trusted()
.with_max_commands(defaults.max_commands)
.with_max_command_bytes(defaults.max_command_bytes);
limits.pack = defaults.pack;
let mut receive =
ReceivePack::new(&args.directory, context.config_inputs()?).with_limits(limits);
let repository = Repository::open_with_config(&args.directory, &context.config_inputs()?)
.wrap_err_with(|| format!("opening {}", args.directory.display()))?;
if let Ok(identity) = girt::Signature::committer(repository.config(), &context.environment) {
receive = receive.with_identity(identity);
}
let cancel = std::sync::atomic::AtomicBool::new(false);
receive
.serve(
&mut std::io::stdin().lock(),
&mut std::io::BufWriter::new(std::io::stdout().lock()),
&cancel,
)
.wrap_err("serving receive-pack")?;
Ok(ExitCode::SUCCESS)
}
#[derive(Debug, clap::Args)]
pub struct UploadPackArgs {
directory: PathBuf,
}
pub fn upload_pack(args: UploadPackArgs, context: &Context) -> Result<ExitCode> {
let repository = Repository::open_with_config(&args.directory, &context.config_inputs()?)
.wrap_err_with(|| format!("opening {}", args.directory.display()))?;
let protocol = context
.environment
.get("GIT_PROTOCOL")
.map_or(Protocol::V0, |value| {
Protocol::from_parameters(value.as_encoded_bytes())
});
let request = UploadLimits::default();
let limits = UploadLimits::trusted()
.with_max_request_bytes(request.max_request_bytes)
.with_max_wants(request.max_wants)
.with_max_haves(request.max_haves);
let upload = UploadPack::new(&repository)?.with_limits(limits);
let cancel = std::sync::atomic::AtomicBool::new(false);
upload
.serve(
protocol,
&mut std::io::stdin().lock(),
&mut std::io::BufWriter::new(std::io::stdout().lock()),
&cancel,
)
.wrap_err("serving upload-pack")?;
Ok(ExitCode::SUCCESS)
}
#[derive(Debug, clap::Args)]
pub struct PushArgs {
#[arg(add = ArgValueCandidates::new(complete::remotes))]
remote: String,
#[arg(required = true)]
refspecs: Vec<String>,
}
pub fn clone(args: CloneArgs, context: &Context) -> Result<ExitCode> {
let directory = match args.destination {
Some(directory) => directory,
None => default_directory(&args.url, args.bare)?,
};
let destination = std::fs::canonicalize(context.directory())
.wrap_err_with(|| format!("reading {}", context.directory().display()))?
.join(directory);
let filter = args.filter.filter();
if filter.is_some() && is_plain_path(&args.url) {
eprintln!("warning: --filter is ignored in local clones; use file:// instead.");
}
let mut options = workflow::CloneOptions::default()
.with_kind(if args.bare {
InitKind::Bare
} else {
InitKind::Worktree
})
.with_checkout(!args.no_checkout)
.with_fetch_limits(girt::fetch::FetchLimits::trusted())
.with_update_limits(girt::fetch::FetchUpdateLimits::trusted());
if let Some(filter) = filter.clone() {
options = options.with_filter(filter);
}
let mut progress = crate::progress::Progress::new(context.progress);
let outcome = workflow::clone_with(
&args.url,
&destination,
&options,
&context.config_inputs()?,
&environment(context)?,
&mut progress,
)
.map_err(|error| match &error {
workflow::CloneError::Checkout { .. }
| workflow::CloneError::Read { .. }
| workflow::CloneError::FetchPromised { .. } =>
eyre!(error).wrap_err(format!(
"clone completed at {}; inspect files and index before recovery with `girt -C {} checkout HEAD`",
destination.display(), destination.display(),
)),
workflow::CloneError::Finish(_) => eyre!(error).wrap_err(format!(
"clone installation failed; inspect {} for partial effects", destination.display()
)),
_ => eyre!(error).wrap_err("cloning"),
})?;
progress.finish();
if filter.is_some() && !outcome.filter_applied && !is_local(&args.url) {
eprintln!("warning: filtering not recognized by server, ignoring");
}
let report = outcome.clone;
let mut out = stdout();
writeln!(out, "cloned into {}", destination.display())?;
match &report.head {
CloneHead::Branch { name, id } => {
writeln!(out, "HEAD -> {} {id}", display_bytes(name.as_bytes()))?;
}
CloneHead::Detached(id) => writeln!(out, "HEAD {id} (detached)")?,
CloneHead::Unborn(name) => {
writeln!(out, "HEAD -> {} (unborn)", display_bytes(name.as_bytes()))?;
}
other => writeln!(out, "HEAD {other:?}")?,
}
out.flush()?;
Ok(ExitCode::SUCCESS)
}
fn is_plain_path(url: &str) -> bool {
!url.contains("://") && !is_scp_like(url)
}
fn is_local(url: &str) -> bool {
url.starts_with("file://") || is_plain_path(url)
}
fn is_scp_like(url: &str) -> bool {
url.find(':')
.is_some_and(|colon| !url[..colon].contains('/') && colon > 1)
}
fn default_directory(url: &str, bare: bool) -> Result<PathBuf> {
let trimmed = url.trim_end_matches('/');
let trimmed = trimmed.strip_suffix("/.git").unwrap_or(trimmed);
let last = trimmed
.rsplit(['/', ':'])
.next()
.filter(|name| !name.is_empty())
.ok_or_else(|| eyre!("cannot derive a directory name from `{url}`"))?;
let stem = last.strip_suffix(".git").unwrap_or(last);
Ok(PathBuf::from(if bare {
format!("{stem}.git")
} else {
stem.to_owned()
}))
}
fn environment(context: &Context) -> Result<Environment> {
let directory = std::fs::canonicalize(context.directory())
.wrap_err_with(|| format!("reading {}", context.directory().display()))?;
Ok(Environment::from_process().with_current_dir(directory))
}
pub fn fetch(args: FetchArgs, context: &Context) -> Result<ExitCode> {
let repository = context.repository()?;
let remote = find_remote(&repository, &args.remote)?;
let refspecs: Vec<Vec<u8>> = remote
.fetch_refspecs()
.specs()
.iter()
.map(|spec| spec.as_bytes().to_vec())
.collect();
if refspecs.is_empty() {
bail!("remote {} has no fetch refspecs", args.remote);
}
let mut options = FetchOptions::new(refspecs);
if args.filter.no_filter {
options = options.without_filter();
} else if let Some(filter) = args.filter.filter() {
options = options.with_filter(filter);
}
let filtered = !args.filter.no_filter
&& (!args.filter.filters.is_empty()
|| repository
.config()
.string("remote", Some(args.remote.as_bytes()), "partialclonefilter")
.is_some_and(|spec| !spec.is_empty()));
let mut progress = crate::progress::Progress::new(context.progress);
let outcome = workflow::fetch(
&repository,
&args.remote,
&options,
&environment(context)?,
&mut progress,
)
.wrap_err_with(|| format!("fetching {}", args.remote))?;
progress.finish();
if filtered && !outcome.filter_applied && !is_local(&remote_url(&remote)) {
eprintln!("warning: filtering not recognized by server, ignoring");
}
let null = ObjectId::null(repository.object_format());
let mut out = stdout();
for change in &outcome.updated {
let old = change.old.unwrap_or(null);
let new = change.new.unwrap_or(null);
write!(out, "{old}..{new} ")?;
out.write_all(change.name.as_bytes())?;
out.write_all(b"\n")?;
}
out.flush()?;
for source in &outcome.missing_sources {
eprintln!("warning: remote has no {}", display_bytes(source));
}
Ok(ExitCode::SUCCESS)
}
fn remote_url(remote: &girt::remote::Remote) -> String {
remote
.fetch_url()
.map(|url| display_bytes(url).into_owned())
.unwrap_or_default()
}
pub fn push(args: PushArgs, context: &Context) -> Result<ExitCode> {
let repository = context.repository()?;
let remote = find_remote(&repository, &args.remote)?;
let mut commands = Vec::new();
for refspec in &args.refspecs {
commands.push(
push_command(&repository, &remote, refspec)
.wrap_err_with(|| format!("planning {refspec}"))?,
);
}
let mut progress = crate::progress::Progress::new(context.progress);
let report = workflow::push(
&repository,
&args.remote,
commands,
&PushOptions::default(),
&environment(context)?,
&mut progress,
)
.wrap_err_with(|| format!("pushing to {}", args.remote))?;
progress.finish();
let mut out = stdout();
for reference in &report.refs {
let name = display_bytes(reference.command.name.as_bytes());
match &reference.status {
Some(Status::Ok) => writeln!(out, "ok\t{name}")?,
Some(Status::Rejected(reason)) => {
let stale = reference.rejection_origin == Some(RejectionOrigin::ExpectedValue);
let hint = if stale { " (stale; fetch first)" } else { "" };
writeln!(out, "rejected\t{name}\t{}{hint}", display_bytes(reason))?;
}
Some(other) => writeln!(out, "{other:?}\t{name}")?,
None => writeln!(out, "not-attempted\t{name}")?,
}
}
out.flush()?;
Ok(if report.all_succeeded() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
fn push_command(
repository: &Repository,
remote: &girt::remote::Remote,
refspec: &str,
) -> Result<PushCommand> {
let (force, spec) = match refspec.strip_prefix('+') {
Some(spec) => (ForcePolicy::Allow, spec),
None => (ForcePolicy::FastForwardOnly, refspec),
};
let (source, destination) = match spec.split_once(':') {
Some((source, destination)) => (source, Some(destination)),
None => (spec, None),
};
let objects = repository.objects()?;
let source_ref = if source.is_empty() {
None
} else {
Resolver::new(repository, &objects)?
.expand_ref(source)?
.map(|(name, _)| name)
};
let new = if source.is_empty() {
ObjectId::null(repository.object_format())
} else {
resolve(repository, source)?
};
let destination = match (destination, &source_ref) {
(Some(name), _) if name.starts_with("refs/") => name.to_owned(),
(Some(name), _) => format!("refs/heads/{name}"),
(None, Some(name)) if name.as_bytes() != b"HEAD" => {
display_bytes(name.as_bytes()).into_owned()
}
_ => bail!("cannot infer a destination for `{refspec}`; use <src>:<dst>"),
};
let name = RefName::new(&destination).wrap_err_with(|| format!("invalid ref {destination}"))?;
let expected = tracking_value(repository, remote, &name)?;
Ok(PushCommand::new(name, expected, new).with_force(force))
}
fn tracking_value(
repository: &Repository,
remote: &girt::remote::Remote,
name: &RefName,
) -> Result<Option<ObjectId>> {
let probe = [RefSource {
name: name.clone(),
id: ObjectId::for_blob(repository.object_format(), b""),
}];
let mappings = remote
.fetch_refspecs()
.map(&probe)
.wrap_err("mapping fetch refspecs")?;
let references = repository.references().wrap_err("opening references")?;
for tracking in mappings
.iter()
.filter_map(|mapping| mapping.destination.as_ref())
{
if let Some(id) = resolve_ref(&references, tracking)? {
return Ok(Some(id));
}
}
Ok(None)
}