use std::collections::BTreeSet;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{Duration, SystemTime};
use color_eyre::eyre::{Result, WrapErr as _, bail};
use girt::pack::{IndexRebuildLimits, IndexRebuildOutcome, rebuild_index};
use girt::retention::{
MaintenanceIsolation, ReflogExpiryPolicy, RepackLimits, RetentionPlan, RetentionPolicy,
};
use girt::{ObjectFormat, ObjectId, Repository};
use crate::Context;
use crate::common::{not_cancelled, objects, stdout};
const PRUNE_AGE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
const REFLOG_LIVE_AGE: Duration = Duration::from_secs(90 * 24 * 60 * 60);
const REFLOG_DISCONNECTED_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
#[derive(Debug, clap::Subcommand)]
pub enum Command {
Plan,
Repack,
Run(RunArgs),
}
#[derive(Debug, clap::Args)]
pub struct RunArgs {
#[arg(long)]
prune_loose: bool,
#[arg(long)]
expire_reflogs: bool,
#[arg(long)]
retire_packs: bool,
#[arg(long)]
yes: bool,
}
#[derive(Debug, clap::Subcommand)]
pub enum PackCommand {
List,
Verify,
Reindex {
pack: PathBuf,
},
}
pub fn run(command: Command, context: &Context) -> Result<ExitCode> {
let repository = context.repository()?;
match command {
Command::Plan => {
let plan = plan(&repository, &RetentionPolicy::trusted())?;
print_plan(&repository, &plan)?;
}
Command::Repack => {
let published = repository
.repack_retained(
&RetentionPolicy::trusted(),
RepackLimits::trusted(),
not_cancelled(),
)
.wrap_err("repacking retained objects")?;
let written = published.written;
let path = pack_dir(&repository).join(format!("pack-{}.pack", written.checksum));
writeln!(
stdout(),
"{} {} {}",
path.display(),
written.objects,
written.pack_bytes
)?;
}
Command::Run(args) => return run_phases(&repository, &args),
}
Ok(ExitCode::SUCCESS)
}
pub fn pack(command: PackCommand, context: &Context) -> Result<ExitCode> {
match command {
PackCommand::List => {
let repository = context.repository()?;
let format = repository.object_format();
let mut out = stdout();
for pack in pack_paths(&repository)? {
let ids = index_ids(&pack.with_extension("idx"), format)?;
let bytes = std::fs::metadata(&pack)
.wrap_err_with(|| format!("reading {}", pack.display()))?
.len();
writeln!(out, "{} {} {bytes}", pack.display(), ids.len())?;
}
}
PackCommand::Verify => {
let repository = context.repository()?;
match objects(&repository)?.verify_packs(not_cancelled()) {
Ok(()) => writeln!(stdout(), "ok")?,
Err(error) => {
eprintln!("error: {:#}", color_eyre::eyre::Report::new(error));
return Ok(ExitCode::FAILURE);
}
}
}
PackCommand::Reindex { pack } => {
let format = context
.repository()
.map_or(ObjectFormat::Sha1, |repository| repository.object_format());
let rebuilt = rebuild_index(
&pack,
format,
IndexRebuildLimits::default(),
not_cancelled(),
)
.wrap_err_with(|| format!("rebuilding index for {}", pack.display()))?;
let mut out = stdout();
if context.git {
writeln!(out, "{}", rebuilt.checksum)?;
} else {
let outcome = match rebuilt.outcome {
IndexRebuildOutcome::Created => "created",
IndexRebuildOutcome::Replaced => "replaced",
IndexRebuildOutcome::Unchanged => "unchanged",
_ => "rebuilt",
};
writeln!(out, "{outcome} {} {}", rebuilt.checksum, rebuilt.objects)?;
}
}
}
Ok(ExitCode::SUCCESS)
}
struct UserAsserted;
impl MaintenanceIsolation for UserAsserted {
type Guard = ();
fn acquire(&mut self, _: &Repository) -> io::Result<()> {
Ok(())
}
}
fn run_phases(repository: &Repository, args: &RunArgs) -> Result<ExitCode> {
if !(args.prune_loose || args.expire_reflogs || args.retire_packs) {
bail!("select at least one of --prune-loose, --expire-reflogs, --retire-packs");
}
let now = SystemTime::now();
let mut policy = RetentionPolicy::trusted();
if args.prune_loose || args.retire_packs {
policy = policy.with_recent_cutoff(now - PRUNE_AGE);
}
if args.expire_reflogs {
let before = |age: Duration| {
let cutoff = now - age;
cutoff
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |since| i128::from(since.as_secs()))
};
policy = policy.with_reflog_expiry(ReflogExpiryPolicy::ClassifiedCutoffs {
live_before: Some(before(REFLOG_LIVE_AGE)),
disconnected_before: Some(before(REFLOG_DISCONNECTED_AGE)),
});
}
let mut out = stdout();
if !args.yes {
let plan = plan(repository, &policy)?;
print_plan(repository, &plan)?;
let (loose, _) = unreachable(repository, &plan)?;
if args.expire_reflogs {
let records: usize = plan.reflog_expiry_candidates.values().map(Vec::len).sum();
writeln!(out, "would-expire-reflog-records {records}")?;
}
if args.retire_packs {
let packs = pack_paths(repository)?;
let retired = packs
.iter()
.filter(|pack| !plan.protected_packs.contains(*pack))
.count();
writeln!(out, "would-retire-packs {retired}")?;
}
if args.prune_loose {
writeln!(out, "would-prune-loose {}", loose.len())?;
}
writeln!(
out,
"dry run: pass --yes to run (nothing else may use the repository)"
)?;
return Ok(ExitCode::SUCCESS);
}
let cancel = not_cancelled();
let mut isolation = UserAsserted;
if args.expire_reflogs {
let report = repository
.expire_reflogs(&mut isolation, &policy, cancel)
.wrap_err("expiring reflogs")?;
writeln!(out, "expired-reflogs {}", report.changed.len())?;
}
if args.retire_packs {
let report = repository
.retire_old_packs(&mut isolation, &policy, RepackLimits::trusted(), cancel)
.wrap_err("retiring old packs")?;
if let Some(published) = report.published {
writeln!(out, "new-pack pack-{}", published.written.checksum)?;
}
for path in &report.removed {
writeln!(out, "removed {}", path.display())?;
}
}
if args.prune_loose {
let report = repository
.prune_unreachable_loose(&mut isolation, &policy, cancel)
.wrap_err("pruning loose objects")?;
writeln!(out, "pruned-loose {}", report.deleted.len())?;
}
Ok(ExitCode::SUCCESS)
}
fn plan(repository: &Repository, policy: &RetentionPolicy) -> Result<RetentionPlan> {
let plan = repository.plan_retention(policy, not_cancelled());
if let girt::retention::RetentionOutcome::Incomplete(error) = &plan.outcome {
bail!("retention scan incomplete: {error}");
}
Ok(plan)
}
fn print_plan(repository: &Repository, plan: &RetentionPlan) -> Result<()> {
let (loose, packed) = unreachable(repository, plan)?;
let mut out = stdout();
writeln!(out, "roots {}", plan.roots.len())?;
writeln!(out, "retained {}", plan.required.len())?;
writeln!(out, "reachable {}", plan.reachable.len())?;
writeln!(out, "unreachable-loose {}", loose.len())?;
writeln!(out, "unreachable-packed {}", packed.len())?;
writeln!(out, "protected-packs {}", plan.protected_packs.len())?;
writeln!(out, "alternates {}", plan.alternate_stores.len())?;
Ok(())
}
fn unreachable(
repository: &Repository,
plan: &RetentionPlan,
) -> Result<(BTreeSet<ObjectId>, BTreeSet<ObjectId>)> {
let format = repository.object_format();
let keep = |id: &ObjectId| !plan.reachable.contains(id) && !plan.required.contains(id);
let mut loose: BTreeSet<_> = loose_ids(&objects_dir(repository), format)?;
loose.retain(keep);
let mut packed = BTreeSet::new();
for pack in pack_paths(repository)? {
packed.extend(index_ids(&pack.with_extension("idx"), format)?);
}
packed.retain(keep);
Ok((loose, packed))
}
fn objects_dir(repository: &Repository) -> PathBuf {
repository.common_dir().join("objects")
}
fn pack_dir(repository: &Repository) -> PathBuf {
objects_dir(repository).join("pack")
}
fn pack_paths(repository: &Repository) -> Result<Vec<PathBuf>> {
let dir = pack_dir(repository);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error).wrap_err_with(|| format!("reading {}", dir.display())),
};
let mut packs = Vec::new();
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "pack") && path.with_extension("idx").exists()
{
packs.push(path);
}
}
packs.sort();
Ok(packs)
}
fn loose_ids(dir: &Path, format: ObjectFormat) -> Result<BTreeSet<ObjectId>> {
let mut ids = BTreeSet::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(ids);
};
for entry in entries {
let entry = entry?;
let prefix = entry.file_name();
let Some(prefix) = prefix.to_str().filter(|name| name.len() == 2) else {
continue;
};
if !entry.file_type()?.is_dir() {
continue;
}
for file in std::fs::read_dir(entry.path())? {
let name = file?.file_name();
let hex = format!("{prefix}{}", name.to_string_lossy());
if let Ok(id) = hex.parse::<ObjectId>()
&& id.format() == format
{
ids.insert(id);
}
}
}
Ok(ids)
}
fn index_ids(path: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
let bytes = std::fs::read(path).wrap_err_with(|| format!("reading {}", path.display()))?;
let width = format.digest_len();
let (fanout, names, stride) = if bytes.starts_with(b"\xfftOc\0\0\0\x02") {
(8, 8 + 1024, width)
} else {
(0, 1024, width + 4)
};
let corrupt = || color_eyre::eyre::eyre!("corrupt pack index {}", path.display());
let count = bytes
.get(fanout + 1020..fanout + 1024)
.map(|word| u32::from_be_bytes([word[0], word[1], word[2], word[3]]) as usize)
.ok_or_else(corrupt)?;
let skip = if fanout == 0 { 4 } else { 0 };
(0..count)
.map(|position| {
let start = names + position * stride + skip;
let raw = bytes.get(start..start + width).ok_or_else(corrupt)?;
ObjectId::from_bytes(format, raw).map_err(|_| corrupt())
})
.collect()
}