mod audio;
mod ignore;
mod mirror;
mod model;
mod names;
mod remote;
mod repo;
mod scan;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
use rayon::prelude::*;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use model::{Commit, Entry, Manifest};
use repo::Repo;
const REMOTES_NOTE: &str = "Every remote is one of two shapes:
mirror real, playable folders - a drive or phone you can browse & play
backup deduped content-addressed blobs - S3, or a space-saving archive
Local remotes default to mirror, s3:// to backup; set it with `remote add --format`.
A `.stoweignore` at the repo root keeps paths out of every scan, local and
remote: bare names match anywhere, a trailing `/` means directories only, and a
pattern with a `/` is anchored at the root.
Run `stowe <command> --help` for the full detail of any command.";
#[derive(Parser)]
#[command(
name = "stowe",
version,
about = "git for the files git chokes on: versioned, deduped, playable backups on any remote",
after_help = REMOTES_NOTE,
arg_required_else_help = true
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Init,
Status,
#[command(verbatim_doc_comment)]
Add {
paths: Vec<std::path::PathBuf>,
#[arg(short = 'A', long)]
all: bool,
},
Unstage,
#[command(verbatim_doc_comment)]
Commit {
#[arg(short = 'm', long)]
message: String,
},
Log,
#[command(verbatim_doc_comment)]
Remote {
#[arg(short, long)]
verbose: bool,
#[command(subcommand)]
cmd: Option<RemoteCmd>,
},
#[command(verbatim_doc_comment)]
Push {
remotes: Vec<String>,
#[arg(long)]
force: bool,
},
Pull {
#[arg(default_value = "origin")]
remote: String,
},
Adapt {
#[arg(default_value = "origin")]
remote: String,
},
#[command(verbatim_doc_comment)]
Restore {
paths: Vec<std::path::PathBuf>,
#[arg(short = 'A', long)]
all: bool,
#[arg(long)]
from: Option<String>,
#[arg(long, default_value = "origin")]
remote: String,
},
#[command(verbatim_doc_comment)]
Convert {
#[arg(default_value = "origin")]
remote: String,
#[arg(long, value_parser = ["mirror", "backup"])]
to: Option<String>,
},
#[command(verbatim_doc_comment)]
Update {
#[arg(short, long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum RemoteCmd {
Add {
name: String,
url: String,
#[arg(long, value_parser = ["mirror", "backup"])]
format: Option<String>,
#[arg(long)]
mount: Option<String>,
},
List,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Init => cmd_init(),
Cmd::Status => cmd_status(),
Cmd::Add { paths, all } => cmd_add(paths, all),
Cmd::Unstage => cmd_unstage(),
Cmd::Commit { message } => cmd_commit(&message),
Cmd::Log => cmd_log(),
Cmd::Remote { cmd, .. } => cmd_remote(cmd),
Cmd::Push { remotes, force } => cmd_push(&remotes, force),
Cmd::Pull { remote } => cmd_pull(&remote),
Cmd::Restore { paths, all, from, remote } => cmd_restore(paths, all, from.as_deref(), &remote),
Cmd::Adapt { remote } => cmd_adapt(&remote),
Cmd::Convert { remote, to } => cmd_convert(&remote, to.as_deref()),
Cmd::Update { yes } => cmd_update(yes),
}
}
fn cmd_init() -> Result<()> {
let cwd = std::env::current_dir()?;
Repo::init(&cwd)?;
println!("initialized empty stowe repo in {}/.stowe", cwd.display());
Ok(())
}
fn cmd_status() -> Result<()> {
let repo = Repo::find()?;
let head = repo.head_manifest()?;
let working = scan::scan(&repo, &head, false)?;
let base = repo.read_index()?.unwrap_or_else(|| head.clone());
let staged = scan::diff(&head, &base); let unstaged = scan::diff(&base, &working); let summary = scan::diff(&head, &working); scan::print_status(&staged, &unstaged, &summary);
Ok(())
}
fn cmd_add(paths: Vec<PathBuf>, all: bool) -> Result<()> {
let repo = Repo::find()?;
let head = repo.head_manifest()?;
if all {
let current = scan::scan(&repo, &head, true)?;
let d = scan::diff(&head, ¤t);
if d.is_empty() {
println!("nothing to stage; working tree matches the last commit.");
return Ok(());
}
repo.write_index(¤t)?;
println!("staged snapshot of {} files.", current.len());
scan::print_diff(&d);
warn_unportable(&d);
return Ok(());
}
if paths.is_empty() {
bail!("specify files/directories to stage, or `-A` to stage everything");
}
let mut index: BTreeMap<String, Entry> = repo
.read_index()?
.unwrap_or_else(|| head.clone())
.into_iter()
.map(|e| (e.path.clone(), e))
.collect();
let root = repo.root.canonicalize()?;
let cwd = std::env::current_dir()?;
let mut staged = 0usize;
let mut removed = 0usize;
for arg in &paths {
let lexical = if arg.is_absolute() { arg.clone() } else { cwd.join(arg) };
let abs = match lexical.canonicalize() {
Ok(c) => c,
Err(_) => {
let parent = lexical.parent().unwrap_or_else(|| Path::new("."));
let name = lexical
.file_name()
.ok_or_else(|| anyhow!("bad path: {}", arg.display()))?;
parent
.canonicalize()
.with_context(|| format!("no such path: {}", arg.display()))?
.join(name)
}
};
let rel = scan::rel_path(&root, &abs);
if abs.strip_prefix(&root).is_err() {
bail!("{} is outside the repo", arg.display());
}
if abs.is_dir() {
let entries: Vec<Entry> = scan::files_under(&root, &abs, &ignore::Ignore::load(&root))?
.par_iter()
.map(|f| scan::entry_for(&root, f, true))
.collect::<Result<_>>()?;
let present: HashSet<String> = entries.iter().map(|e| e.path.clone()).collect();
for e in entries {
index.insert(e.path.clone(), e);
staged += 1;
}
let gone: Vec<String> = index
.keys()
.filter(|p| under_prefix(p, &rel) && !present.contains(*p))
.cloned()
.collect();
for p in gone {
index.remove(&p);
removed += 1;
}
} else if abs.is_file() {
let e = scan::entry_for(&root, &abs, true)?;
index.insert(rel, e);
staged += 1;
} else if index.remove(&rel).is_some() {
removed += 1;
} else {
bail!("no such path, and nothing staged to remove: {}", arg.display());
}
}
let manifest: Manifest = index.into_values().collect();
repo.write_index(&manifest)?;
let mut summary = format!("staged {staged} file(s)");
if removed > 0 {
summary += &format!(", {removed} removal(s)");
}
println!("{summary}");
let d = scan::diff(&head, &manifest);
scan::print_diff(&d);
warn_unportable(&d);
Ok(())
}
fn warn_unportable(d: &scan::Diff) {
use colored::Colorize;
let mut bad: Vec<&String> = d
.added
.iter()
.filter(|p| names::unportable(p, true))
.collect();
bad.extend(d.moved.iter().map(|(_, to)| to).filter(|p| names::unportable(p, true)));
if bad.is_empty() {
return;
}
eprintln!(
"\n{} {} name(s) may not be storable on external drives (FAT/exFAT/NTFS):",
"warning:".yellow().bold(),
bad.len()
);
for p in bad {
eprintln!(" {}", names::display(p).yellow());
}
eprintln!("{}", "(a push to such a drive will offer to rename them)".dimmed());
}
fn under_prefix(path: &str, prefix: &str) -> bool {
prefix.is_empty() || path == prefix || path.starts_with(&format!("{prefix}/"))
}
fn cmd_unstage() -> Result<()> {
let repo = Repo::find()?;
match repo.read_index()? {
None => println!("nothing staged."),
Some(staged) => {
repo.clear_index()?;
println!("unstaged {} file(s); working tree left as-is.", staged.len());
}
}
Ok(())
}
fn cmd_commit(message: &str) -> Result<()> {
let repo = Repo::find()?;
let staged = repo
.read_index()?
.ok_or_else(|| anyhow!("nothing staged - run `stowe add -A` first"))?;
let head = repo.head_manifest()?;
let d = scan::diff(&head, &staged);
if d.is_empty() {
bail!("staged snapshot is identical to the last commit; nothing to commit");
}
let commit = Commit {
parent: repo.head()?,
message: message.to_string(),
time: now(),
files: staged,
};
let hash = repo.write_commit(&commit)?;
repo.set_head(&hash)?;
repo.clear_index()?;
println!("committed {} \"{message}\"", short(&hash));
scan::print_diff(&d);
println!("\n(remember: `stowe push` to back the file contents up to a remote)");
Ok(())
}
fn cmd_log() -> Result<()> {
let repo = Repo::find()?;
let history = repo.history()?;
if history.is_empty() {
println!("no commits yet.");
return Ok(());
}
for (hash, c) in history {
println!("commit {hash}");
println!("Date: {}", format_time(c.time));
println!("Files: {}", c.files.len());
println!("\n {}\n", c.message);
}
Ok(())
}
fn cmd_remote(cmd: Option<RemoteCmd>) -> Result<()> {
let repo = Repo::find()?;
match cmd {
Some(RemoteCmd::Add { name, url, format, mount }) => {
let mut cfg = repo.config()?;
match &format {
Some(fmt) => {
if fmt == "mirror" && mirror::local_root(&url).is_none() {
bail!("`mirror` format needs a local path - {url} can't be a mirror");
}
cfg.formats.insert(name.clone(), fmt.clone());
}
None => {
cfg.formats.remove(&name);
}
}
match &mount {
Some(cmd) => {
if mirror::local_root(&url).is_none() {
bail!("--mount only applies to local remotes; {url} has nothing to mount");
}
cfg.mounts.insert(name.clone(), cmd.clone());
}
None => {
cfg.mounts.remove(&name);
}
}
if !remote_reachable(&url) && mount.is_none() {
let shown = mirror::local_root(&url)
.map(|r| r.display().to_string())
.unwrap_or_else(|| url.clone());
if !confirm_default_yes(&format!(
"remote `{name}` ({shown}) isn't reachable right now. Add it anyway?"
)) {
println!("aborted - remote not added.");
return Ok(());
}
}
cfg.remotes.insert(name.clone(), url.clone());
repo.save_config(&cfg)?;
println!("remote `{name}` -> {url} ({} format)", remote_format(&cfg, &name, &url).name());
if let Some(cmd) = &mount {
println!(" mount: {cmd}");
}
}
None | Some(RemoteCmd::List) => {
let cfg = repo.config()?;
if cfg.remotes.is_empty() {
println!("no remotes. add one, e.g.:");
println!(" stowe remote add origin local:/path/to/backup");
}
for (name, url) in &cfg.remotes {
println!("{name}\t{url}\t[{}]", remote_format(&cfg, name, url).name());
if let Some(cmd) = cfg.mounts.get(name) {
println!(" mount: {cmd}");
}
}
}
}
Ok(())
}
fn remote_format(cfg: &model::Config, name: &str, url: &str) -> mirror::Format {
match cfg.formats.get(name).map(String::as_str) {
Some("backup") => mirror::Format::Backup,
Some("mirror") => mirror::Format::Mirror,
_ if mirror::local_root(url).is_some() => mirror::Format::Mirror,
_ => mirror::Format::Backup,
}
}
fn cmd_push(names: &[String], force: bool) -> Result<()> {
let repo = Repo::find()?;
if repo.head()?.is_none() {
bail!("nothing committed yet - `stowe commit` first");
}
let targets = if names.is_empty() {
vec!["origin".to_string()]
} else {
names.to_vec()
};
let cfg = repo.config()?;
let mut resolved = Vec::new();
for name in &targets {
resolved.push((name.clone(), remote_url(&repo, name)?));
}
for (name, url) in &resolved {
ensure_reachable(&repo, &cfg, name, url)?;
}
for (name, url) in &resolved {
match remote_format(&cfg, name, url) {
mirror::Format::Mirror => {
let root = mirror::local_root(url).ok_or_else(|| {
anyhow!("remote `{name}` is set to mirror but {url} isn't a local path")
})?;
preflight_names(&repo, name, &root)?;
let r = mirror::sync(&repo, &root, force)?;
let head = repo.head()?.unwrap_or_default();
repo.set_remote_head(name, &head)?;
println!(
"mirrored to `{name}`: +{} new, ~{} changed, ⇄{} moved, -{} removed, \
{} new commits -> {}",
r.added, r.modified, r.moved, r.removed, r.new_commits, short(&head)
);
}
_ => push_objects(&repo, name, url)?,
}
}
Ok(())
}
fn preflight_names(repo: &Repo, name: &str, root: &Path) -> Result<()> {
use colored::Colorize;
let strict = names::probe_restrictive(root);
let manifest = repo.head_manifest()?;
let offenders: Vec<String> = manifest
.iter()
.map(|e| e.path.clone())
.filter(|p| names::unportable(p, strict))
.collect();
if offenders.is_empty() {
return Ok(());
}
eprintln!(
"{} {} committed name(s) can't be stored on `{name}`:",
"note:".yellow().bold(),
offenders.len()
);
for p in &offenders {
eprintln!(" {}", names::display(p).yellow());
}
if !confirm_default_yes("Rename them locally to safe names and continue?") {
bail!("push aborted - fix the names and push again");
}
let mut used: HashSet<String> = manifest.iter().map(|e| e.path.clone()).collect();
let mut map: BTreeMap<String, String> = BTreeMap::new();
for old in &offenders {
let base = names::sanitize(old, strict);
let mut target = base.clone();
let mut n = 1;
while used.contains(&target) {
target = bump_name(&base, n);
n += 1;
}
used.insert(target.clone());
map.insert(old.clone(), target);
}
for (old, new) in &map {
let src = repo.root.join(old);
if !src.exists() {
bail!(
"`{}` is no longer in the working tree - commit your changes, then push again",
names::display(old)
);
}
let dst = repo.root.join(new);
if let Some(p) = dst.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::rename(&src, &dst)?;
let mut dir = src.parent();
while let Some(d) = dir {
if d == repo.root || std::fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
println!("renamed {} -> {new}", names::display(old));
}
let mut files = manifest;
for e in &mut files {
if let Some(new) = map.get(&e.path) {
e.path = new.clone();
}
}
files.sort_by(|a, b| a.path.cmp(&b.path));
let commit = Commit {
parent: repo.head()?,
message: "fix: portable file names".to_string(),
time: now(),
files,
};
let hash = repo.write_commit(&commit)?;
repo.set_head(&hash)?;
println!("committed {} \"fix: portable file names\"", short(&hash));
if let Some(mut idx) = repo.read_index()? {
for e in &mut idx {
if let Some(new) = map.get(&e.path) {
e.path = new.clone();
}
}
idx.sort_by(|a, b| a.path.cmp(&b.path));
repo.write_index(&idx)?;
}
Ok(())
}
fn bump_name(path: &str, n: usize) -> String {
let (dir, file) = match path.rsplit_once('/') {
Some((d, f)) => (Some(d), f),
None => (None, path),
};
let bumped = match file.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => format!("{stem} ({n}).{ext}"),
_ => format!("{file} ({n})"),
};
match dir {
Some(d) => format!("{d}/{bumped}"),
None => bumped,
}
}
fn push_objects(repo: &Repo, name: &str, url: &str) -> Result<()> {
let history = repo.history()?;
let head = history[0].0.clone();
let head_commit = &history[0].1;
let working = scan::scan(repo, &repo.head_manifest()?, false)?;
let mut by_hash: HashMap<&str, &str> = HashMap::new();
for e in &working {
by_hash.entry(&e.hash).or_insert(&e.path);
}
let mut seen = HashSet::new();
let mut to_upload = Vec::new();
for e in &head_commit.files {
if !seen.insert(e.hash.as_str()) {
continue;
}
match by_hash.get(e.hash.as_str()) {
Some(rel) => to_upload.push((remote::object_key(&e.hash), repo.root.join(rel))),
None => bail!(
"content for `{}` is no longer in the working tree (modified or deleted \
since the commit) - restore it or commit the change before pushing",
e.path
),
}
}
let backend = remote::open(url)?;
let new_objects = backend.put_files(to_upload)?;
let mut new_commits = 0;
for (hash, _) in &history {
let key = format!("commits/{hash}.json");
if !backend.exists(&key)? {
let bytes = std::fs::read(repo.dir.join("commits").join(format!("{hash}.json")))?;
backend.put_bytes(&key, &bytes)?;
new_commits += 1;
}
}
backend.put_bytes("refs/main", head.as_bytes())?;
repo.set_remote_head(name, &head)?;
println!(
"pushed to `{name}`: {new_objects} new objects, {new_commits} new commits, refs/main -> {}",
short(&head)
);
Ok(())
}
fn cmd_pull(name: &str) -> Result<()> {
let repo = Repo::find()?;
let url = remote_url(&repo, name)?;
ensure_reachable(&repo, &repo.config()?, name, &url)?;
if remote_format(&repo.config()?, name, &url) == mirror::Format::Mirror {
let root = mirror::local_root(&url)
.ok_or_else(|| anyhow!("remote `{name}` is set to mirror but {url} isn't local"))?;
let r = mirror::pull(&repo, &root)?;
println!(
"pulled from `{name}`: now at {} ({} new commits, {} files written)",
short(&r.head),
r.new_commits,
r.written
);
return Ok(());
}
let backend = remote::open(&url)?;
if !backend.exists("refs/main")? {
bail!("remote `{name}` is empty - nothing to pull");
}
let remote_head = String::from_utf8(backend.get_bytes("refs/main")?)?
.trim()
.to_string();
let mut new_commits = 0;
let mut cur = Some(remote_head.clone());
while let Some(hash) = cur {
let local = repo.dir.join("commits").join(format!("{hash}.json"));
let bytes = if local.exists() {
std::fs::read(&local)?
} else {
let b = backend.get_bytes(&format!("commits/{hash}.json"))?;
std::fs::write(&local, &b)?;
new_commits += 1;
b
};
let commit: Commit = serde_json::from_slice(&bytes)?;
cur = commit.parent;
}
repo.set_head(&remote_head)?;
let commit = repo.read_commit(&remote_head)?;
let mut written = 0;
for e in &commit.files {
let dest = repo.root.join(&e.path);
let need = !dest.exists() || scan::hash_file(&dest)? != e.hash;
if need {
backend.get_file(&remote::object_key(&e.hash), &dest)?;
written += 1;
}
}
repo.clear_index()?;
println!(
"pulled from `{name}`: now at {} ({new_commits} new commits, {written} files written)",
short(&remote_head)
);
Ok(())
}
fn cmd_restore(
paths: Vec<PathBuf>,
all: bool,
from: Option<&str>,
remote_name: &str,
) -> Result<()> {
let repo = Repo::find()?;
let history = repo.history()?;
if history.is_empty() {
bail!("nothing committed yet - nothing to restore");
}
let (chash, commit) = match from {
None => history[0].clone(),
Some(prefix) => {
let mut it = history.iter().filter(|(h, _)| h.starts_with(prefix));
match (it.next(), it.next()) {
(None, _) => bail!("no commit matches `{prefix}` (see `stowe log`)"),
(Some(one), None) => one.clone(),
(Some(_), Some(_)) => bail!("`{prefix}` is ambiguous - give more characters"),
}
}
};
let manifest = commit.files;
let by_path: BTreeMap<&str, &Entry> = manifest.iter().map(|e| (e.path.as_str(), e)).collect();
let targets: Vec<&Entry> = if all {
manifest.iter().collect()
} else {
if paths.is_empty() {
bail!("specify files to restore, or `-A` for the whole snapshot");
}
let root = repo.root.canonicalize()?;
let cwd = std::env::current_dir()?;
let mut out = Vec::new();
for arg in &paths {
let lexical = if arg.is_absolute() { arg.clone() } else { cwd.join(arg) };
let abs = match lexical.canonicalize() {
Ok(c) => c,
Err(_) => {
let parent = lexical.parent().unwrap_or_else(|| Path::new("."));
let name = lexical
.file_name()
.ok_or_else(|| anyhow!("bad path: {}", arg.display()))?;
parent
.canonicalize()
.with_context(|| format!("no such path: {}", arg.display()))?
.join(name)
}
};
let rel = scan::rel_path(&root, &abs);
let e = by_path.get(rel.as_str()).ok_or_else(|| {
anyhow!("`{rel}` isn't in commit {} - nothing to restore", short(&chash))
})?;
out.push(*e);
}
out
};
let url = remote_url(&repo, remote_name)?;
ensure_reachable(&repo, &repo.config()?, remote_name, &url)?;
let mirror_root = match remote_format(&repo.config()?, remote_name, &url) {
mirror::Format::Mirror => Some(
mirror::local_root(&url)
.ok_or_else(|| anyhow!("remote `{remote_name}` is set to mirror but isn't local"))?,
),
_ => None,
};
let backend = match &mirror_root {
Some(_) => None,
None => Some(remote::open(&url)?),
};
let mut restored = 0usize;
let mut skipped = 0usize;
for e in &targets {
let dest = repo.root.join(&e.path);
if dest.exists() && scan::hash_file(&dest)? == e.hash {
skipped += 1;
continue;
}
let got = match &mirror_root {
Some(root) => mirror::fetch(root, &e.hash, &dest)?,
None => {
let backend = backend.as_ref().unwrap();
let key = remote::object_key(&e.hash);
if backend.exists(&key)? {
backend.get_file(&key, &dest)?;
true
} else {
false
}
}
};
if !got {
bail!(
"content for `{}` (commit {}) isn't on remote `{remote_name}` - was it pushed?",
e.path,
short(&chash)
);
}
restored += 1;
println!("restored {}", e.path);
}
println!(
"\n{restored} file(s) restored from {}, {skipped} already current.",
short(&chash)
);
Ok(())
}
fn cmd_adapt(name: &str) -> Result<()> {
let repo = Repo::find()?;
let url = remote_url(&repo, name)?;
ensure_reachable(&repo, &repo.config()?, name, &url)?;
let root = mirror::local_root(&url).ok_or_else(|| {
anyhow!("`stowe adapt` only works on mirror (local:) remotes - `{name}` is {url}")
})?;
if mirror::detect_format(&root) != mirror::Format::Mirror {
bail!("remote `{name}` isn't a mirror - nothing to adapt from");
}
let r = mirror::adapt(&repo, &root)?;
if r.is_empty() {
println!("already in sync with `{name}` - nothing to adapt.");
return Ok(());
}
println!(
"adapted from `{name}`: +{} new, ~{} changed, ⇄{} moved, -{} removed in the working tree.\n\
review with `stowe status`, then `stowe add -A && stowe commit` to record.",
r.added, r.modified, r.moved, r.removed
);
Ok(())
}
fn cmd_convert(name: &str, to: Option<&str>) -> Result<()> {
let repo = Repo::find()?;
let url = remote_url(&repo, name)?;
ensure_reachable(&repo, &repo.config()?, name, &url)?;
let root = mirror::local_root(&url).ok_or_else(|| {
anyhow!("only local remotes can be a playable mirror - `{name}` is {url}")
})?;
let current = mirror::detect_format(&root);
if current == mirror::Format::Empty {
bail!("remote `{name}` is empty - push to it first, then convert");
}
let target = match to {
Some("mirror") => mirror::Format::Mirror,
Some("backup") => mirror::Format::Backup,
_ => match current {
mirror::Format::Mirror => mirror::Format::Backup,
_ => mirror::Format::Mirror,
},
};
if current == target {
println!("remote `{name}` is already a {}.", target.name());
return Ok(());
}
let r = match target {
mirror::Format::Mirror => mirror::backup_to_mirror(&root)?,
mirror::Format::Backup => mirror::mirror_to_backup(&root)?,
mirror::Format::Empty => unreachable!(),
};
let mut cfg = repo.config()?;
cfg.formats.insert(name.to_string(), target.name().to_string());
repo.save_config(&cfg)?;
println!(
"converted `{name}` to {}: {} files, {} preserved version(s).",
target.name(),
r.files,
r.preserved
);
Ok(())
}
fn cmd_update(yes: bool) -> Result<()> {
use colored::Colorize;
use std::io::Write;
if !yes {
print!(
"{} {} ",
"Update stowe to the latest release via cargo?".bold(),
"[y/N]".dimmed()
);
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
println!("{}", "Aborted.".dimmed());
return Ok(());
}
}
println!(
"{} {}\n",
"Updating stowe via".dimmed(),
"cargo install stowe --force".bold()
);
match std::process::Command::new("cargo")
.args(["install", "stowe", "--force"])
.status()
{
Ok(status) if status.success() => {
println!("\n{}", "✓ stowe is up to date.".green());
Ok(())
}
Ok(status) => bail!("update failed (cargo exited {})", status.code().unwrap_or(1)),
Err(e) => {
bail!("could not run cargo: {e} - is it installed and on PATH? (https://rustup.rs)")
}
}
}
fn remote_url(repo: &Repo, name: &str) -> Result<String> {
repo.config()?
.remotes
.get(name)
.cloned()
.ok_or_else(|| anyhow!("no remote named `{name}` - add one: stowe remote add {name} <url>"))
}
fn remote_reachable(url: &str) -> bool {
match mirror::local_root(url) {
Some(root) => root.exists() || root.parent().map(Path::exists).unwrap_or(false),
None => true,
}
}
fn ensure_reachable(repo: &Repo, cfg: &model::Config, name: &str, url: &str) -> Result<()> {
let Some(root) = mirror::local_root(url) else {
return Ok(());
};
if let Some(cmd) = cfg.mounts.get(name) {
run_mount(name, cmd)?;
if on_local_disk(&root) {
bail!(
"`{name}`: the mount command succeeded, but {} is still on your local disk. \
Refusing to write there - the drive would be backed up to the wrong place.",
root.display()
);
}
}
let known = repo.remote_head(name)?.is_some();
if known && mirror::detect_format(&root) == mirror::Format::Empty {
bail!(
"remote `{name}` ({}) has been pushed to before, but isn't there now. \
Is the drive connected? (refusing to recreate it)",
root.display()
);
}
if !remote_reachable(url) {
bail!(
"remote `{name}` ({}) isn't reachable. Is the drive connected?",
root.display()
);
}
Ok(())
}
#[cfg(unix)]
fn on_local_disk(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let device_of = |p: &Path| -> Option<u64> {
let mut cur = Some(p);
while let Some(c) = cur {
if let Ok(md) = std::fs::metadata(c) {
return Some(md.dev());
}
cur = c.parent();
}
None
};
match (device_of(path), device_of(Path::new("/"))) {
(Some(here), Some(root_fs)) => here == root_fs,
_ => false, }
}
#[cfg(not(unix))]
fn on_local_disk(_path: &Path) -> bool {
false
}
fn run_mount(name: &str, cmd: &str) -> Result<()> {
use colored::Colorize;
println!("{} {}", "mounting".dimmed(), format!("`{name}`: {cmd}").dimmed());
#[cfg(windows)]
let status = std::process::Command::new("cmd").args(["/C", cmd]).status();
#[cfg(not(windows))]
let status = std::process::Command::new("sh").arg("-c").arg(cmd).status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => bail!(
"mount command for `{name}` failed (exit {})",
s.code().unwrap_or(1)
),
Err(e) => bail!("could not run the mount command for `{name}`: {e}"),
}
}
fn confirm_default_yes(prompt: &str) -> bool {
use std::io::Write;
print!("{prompt} [Y/n] ");
std::io::stdout().flush().ok();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return true;
}
!matches!(input.trim().to_lowercase().as_str(), "n" | "no")
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn short(hash: &str) -> &str {
&hash[..hash.len().min(10)]
}
fn format_time(secs: i64) -> String {
let days = secs.div_euclid(86400);
let tod = secs.rem_euclid(86400);
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
tod / 3600,
(tod % 3600) / 60,
tod % 60
)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}