refactor: name the modules after what they hold
This commit is contained in:
@@ -3,10 +3,10 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::cmd::{Bash, Cmd, Manage, Words};
|
||||
use crate::compose::Compose;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::fsops::{create_dir, touch, write_new};
|
||||
use crate::output::note;
|
||||
use crate::project::Project;
|
||||
|
||||
const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand
|
||||
|
||||
@@ -20,8 +20,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
let app_name = app.to_string_lossy();
|
||||
let app_dir = Path::new(&app);
|
||||
|
||||
let not_app_exists = !app_dir.is_dir();
|
||||
if not_app_exists {
|
||||
if !app_dir.is_dir() {
|
||||
return Err(anyhow!("directory {app_name} does not exist"));
|
||||
}
|
||||
|
||||
@@ -29,8 +28,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
|
||||
let management_dir = app_dir.join("management");
|
||||
|
||||
let not_management_exists = !management_dir.exists();
|
||||
if not_management_exists {
|
||||
if !management_dir.exists() {
|
||||
create_dir(ctx, &management_dir)?;
|
||||
touch(ctx, &management_dir.join("__init__.py"))?;
|
||||
|
||||
@@ -39,8 +37,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
|
||||
let commands_dir = management_dir.join("commands");
|
||||
|
||||
let not_commands_exists = !commands_dir.exists();
|
||||
if not_commands_exists {
|
||||
if !commands_dir.exists() {
|
||||
create_dir(ctx, &commands_dir)?;
|
||||
touch(ctx, &commands_dir.join("__init__.py"))?;
|
||||
|
||||
@@ -90,5 +87,5 @@ pub fn test(ctx: &Ctx) -> Result<()> {
|
||||
}
|
||||
|
||||
fn service(ctx: &Ctx) -> Result<String> {
|
||||
Compose::resolve(ctx)?.django()
|
||||
Project::resolve(ctx)?.django()
|
||||
}
|
||||
284
src/commands/link.rs
Normal file
284
src/commands/link.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
mod check;
|
||||
mod store;
|
||||
|
||||
pub use check::check;
|
||||
|
||||
use fs_err::{read_dir, read_link};
|
||||
use std::cell::Cell;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
|
||||
use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed};
|
||||
use crate::output::{note, warning};
|
||||
|
||||
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
||||
|
||||
// move untracked paths out of the repo and symlink them back
|
||||
pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let report = Report::new(&repo);
|
||||
|
||||
if let [path] = paths {
|
||||
return link_one(ctx, &repo, path, force, &report);
|
||||
}
|
||||
|
||||
let mut failed = 0;
|
||||
for path in paths {
|
||||
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
|
||||
note!("error: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// move paths in the store back into the repo, the inverse of add
|
||||
pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let report = Report::new(&repo);
|
||||
|
||||
let paths = match (all, paths) {
|
||||
(true, []) => linked_paths(&repo, &repo.store)?,
|
||||
(true, _) => bail!("--all restores everything, so it takes no paths"),
|
||||
(false, []) => bail!("name a path to restore, or pass --all"),
|
||||
(false, paths) => paths.to_vec(),
|
||||
};
|
||||
|
||||
if paths.is_empty() {
|
||||
note!("nothing in the store for this repository");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let [path] = paths.as_slice() {
|
||||
return restore_one(ctx, &repo, path, &report);
|
||||
}
|
||||
|
||||
let mut failed = 0;
|
||||
for path in &paths {
|
||||
if let Err(e) = restore_one(ctx, &repo, path, &report) {
|
||||
note!("error: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
|
||||
let src = resolve(path)?;
|
||||
let rel = repo.relative(&src)?;
|
||||
let stored = repo.store.join(&rel);
|
||||
|
||||
let Some(meta) = symlink_metadata_opt(&src)? else {
|
||||
bail!("{} does not exist", rel.display());
|
||||
};
|
||||
if !meta.is_symlink() {
|
||||
bail!(
|
||||
"{} is not a symlink, so it is not in the store",
|
||||
rel.display()
|
||||
);
|
||||
}
|
||||
|
||||
let dest = read_link(&src)?;
|
||||
if dest != stored {
|
||||
bail!(
|
||||
"{} points at {}, which is not where the store keeps it",
|
||||
rel.display(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
if symlink_metadata_opt(&stored)?.is_none() {
|
||||
bail!("{} is missing from the store", rel.display());
|
||||
}
|
||||
|
||||
remove_file(ctx, &src)?;
|
||||
move_path(ctx, &stored, &src)?;
|
||||
prune_empty(ctx, stored.parent(), &repo.base);
|
||||
|
||||
report.line("restored", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// the store mirrors the repository layout, so walking it finds every path this
|
||||
// repository has linked without asking git anything
|
||||
fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut found = Vec::new();
|
||||
|
||||
let entries = match read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let stored = entry?.path();
|
||||
let rel = stored
|
||||
.strip_prefix(&repo.store)
|
||||
.expect("walked out of the store");
|
||||
let src = repo.root.join(rel);
|
||||
|
||||
let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink())
|
||||
&& read_link(&src).is_ok_and(|dest| dest == stored);
|
||||
|
||||
if linked {
|
||||
found.push(src);
|
||||
} else if stored.is_dir() {
|
||||
found.extend(linked_paths(repo, &stored)?);
|
||||
}
|
||||
}
|
||||
|
||||
found.sort();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
struct Report {
|
||||
store: PathBuf,
|
||||
named: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn new(repo: &Repo) -> Self {
|
||||
Self {
|
||||
store: repo.store.clone(),
|
||||
named: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&self, verb: &str, path: &Path) {
|
||||
// worth naming once per run
|
||||
if !self.named.replace(true) {
|
||||
println!("store: {}", self.store.display());
|
||||
}
|
||||
|
||||
println!("\t{:<11}{}", format!("{verb}:"), path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// list untracked paths not in the store, i.e. what a sandbox can still read
|
||||
fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
|
||||
let src = resolve(path)?;
|
||||
let rel = repo.relative(&src)?;
|
||||
let target = repo.store.join(&rel);
|
||||
|
||||
// a target inside the repo would be readable from the sandbox anyway
|
||||
if target.starts_with(&repo.root) {
|
||||
return Err(anyhow!(
|
||||
"target {} is inside the repository; point AHAB_LINK_ROOT elsewhere",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
|
||||
if tracked(ctx, repo, &rel)? {
|
||||
return Err(anyhow!(
|
||||
"{} is tracked by git; only untracked or ignored paths can be externalized",
|
||||
rel.display()
|
||||
));
|
||||
}
|
||||
if !ignored(ctx, repo, &rel) {
|
||||
warning!("{} is not gitignored", rel.display());
|
||||
}
|
||||
|
||||
let src_meta = symlink_metadata_opt(&src)?;
|
||||
let target_taken = symlink_metadata_opt(&target)?.is_some();
|
||||
|
||||
match src_meta {
|
||||
Some(meta) if meta.is_symlink() => {
|
||||
let dest = read_link(&src)?;
|
||||
|
||||
if dest == target {
|
||||
if !target_taken {
|
||||
return Err(anyhow!(
|
||||
"{} already points at {}, but nothing is there",
|
||||
rel.display(),
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
report.line("unchanged", &rel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// nothing in the store to adopt, so the symlink itself moves out
|
||||
if !target_taken {
|
||||
if !src.exists() {
|
||||
warning!(
|
||||
"{} is a broken symlink to {}",
|
||||
rel.display(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
move_path(ctx, &src, &target)?;
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("moved", &rel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !force {
|
||||
return Err(needs_force(&target));
|
||||
}
|
||||
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("repointed", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some(_) if target_taken => {
|
||||
if !force {
|
||||
return Err(needs_force(&target));
|
||||
}
|
||||
|
||||
let backup = suffixed(&src, BACKUP_SUFFIX);
|
||||
if symlink_metadata_opt(&backup)?.is_some() {
|
||||
return Err(anyhow!(
|
||||
"{} already exists; remove it before re-linking",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
|
||||
rename(ctx, &src, &backup)?;
|
||||
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
|
||||
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("linked", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some(_) => {
|
||||
move_path(ctx, &src, &target)?;
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("moved", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
None if target_taken => {
|
||||
if !force {
|
||||
return Err(needs_force(&target));
|
||||
}
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("linked", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
None => Err(anyhow!(
|
||||
"{} does not exist and the store has no {}",
|
||||
rel.display(),
|
||||
target.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_force(target: &Path) -> anyhow::Error {
|
||||
anyhow!(
|
||||
"{} already exists; pass --force to link to it",
|
||||
target.display()
|
||||
)
|
||||
}
|
||||
243
src/commands/link/check.rs
Normal file
243
src/commands/link/check.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use fs_err::{read_dir, read_link};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use super::store::{Repo, resolve, symlink_metadata_opt};
|
||||
use crate::cmd::{Cmd, LsFiles};
|
||||
use crate::ctx::Ctx;
|
||||
|
||||
pub fn check(
|
||||
ctx: &Ctx,
|
||||
paths: &[PathBuf],
|
||||
porcelain: bool,
|
||||
null: bool,
|
||||
exit_code: bool,
|
||||
store: Option<&Path>,
|
||||
) -> Result<ExitCode> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let pathspecs = relative_pathspecs(&repo, paths)?;
|
||||
|
||||
let mut exposed = Vec::new();
|
||||
// git lists untracked and ignored separately
|
||||
for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] {
|
||||
for entry in list_others(ctx, &repo, ignored, &pathspecs)? {
|
||||
let rel = PathBuf::from(entry.trim_end_matches('/'));
|
||||
|
||||
// --directory collapses a wholly untracked dir into `dir/`
|
||||
if entry.ends_with('/') {
|
||||
exposed.extend(walk(&repo, &rel, mark)?.1);
|
||||
} else {
|
||||
exposed.extend(classify(&repo, &rel, mark)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
if porcelain || null {
|
||||
print_porcelain(&exposed, null);
|
||||
} else {
|
||||
print_listing(&repo, &exposed);
|
||||
}
|
||||
|
||||
// git's --exit-code convention: nothing to report is 0, anything is 1
|
||||
if exit_code && !exposed.is_empty() {
|
||||
return Ok(ExitCode::FAILURE);
|
||||
}
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn print_porcelain(exposed: &[Exposed], null: bool) {
|
||||
let end = if null { '\0' } else { '\n' };
|
||||
|
||||
for item in exposed {
|
||||
match &item.dest {
|
||||
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()),
|
||||
None => print!("{} {}{end}", item.code(), item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
||||
println!("store: {}", repo.store.display());
|
||||
|
||||
if exposed.is_empty() {
|
||||
println!("nothing outside the store, a sandbox would see tracked files only");
|
||||
return;
|
||||
}
|
||||
|
||||
let sections = [
|
||||
(
|
||||
"Untracked paths a sandbox can read:",
|
||||
" (use \"ahab link add <path>...\" to move them into the store)",
|
||||
Section::Content(UNTRACKED),
|
||||
),
|
||||
(
|
||||
"Ignored paths a sandbox can read:",
|
||||
" (use \"ahab link add <path>...\" to move them into the store)",
|
||||
Section::Content(IGNORED),
|
||||
),
|
||||
(
|
||||
"Symlinks leading outside the store:",
|
||||
" (their contents are not in the repository either way)",
|
||||
Section::Elsewhere,
|
||||
),
|
||||
];
|
||||
|
||||
for (heading, hint, section) in sections {
|
||||
let mut items = exposed.iter().filter(|item| section.holds(item)).peekable();
|
||||
if items.peek().is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("\n{heading}\n{hint}");
|
||||
for item in items {
|
||||
match &item.dest {
|
||||
Some(dest) => println!("\t{} -> {}", item.name, dest.display()),
|
||||
None => println!("\t{}", item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Section {
|
||||
Content(char),
|
||||
Elsewhere,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
fn holds(&self, item: &Exposed) -> bool {
|
||||
match self {
|
||||
Self::Content(mark) => item.dest.is_none() && item.mark == *mark,
|
||||
Self::Elsewhere => item.dest.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status codes as `git status --porcelain` spells them
|
||||
const UNTRACKED: char = '?';
|
||||
const IGNORED: char = '!';
|
||||
const ELSEWHERE: char = '>';
|
||||
|
||||
struct Exposed {
|
||||
mark: char,
|
||||
name: String,
|
||||
dest: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Exposed {
|
||||
fn content(mark: char, name: String) -> Self {
|
||||
Self {
|
||||
mark,
|
||||
name,
|
||||
dest: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> String {
|
||||
let second = if self.dest.is_some() {
|
||||
ELSEWHERE
|
||||
} else {
|
||||
self.mark
|
||||
};
|
||||
format!("{}{second}", self.mark)
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
|
||||
let src = repo.root.join(rel);
|
||||
let name = rel.display().to_string();
|
||||
|
||||
let Some(meta) = symlink_metadata_opt(&src)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !meta.is_symlink() {
|
||||
return Ok(Some(Exposed::content(mark, name)));
|
||||
}
|
||||
|
||||
let dest = read_link(&src)?;
|
||||
if dest == repo.store.join(rel) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Exposed {
|
||||
mark,
|
||||
name,
|
||||
dest: Some(dest),
|
||||
}))
|
||||
}
|
||||
|
||||
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
|
||||
let dir = repo.root.join(rel);
|
||||
let mut handled = 0;
|
||||
let mut exposed = Vec::new();
|
||||
|
||||
for entry in read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let child = rel.join(entry.file_name());
|
||||
|
||||
if entry.file_type()?.is_dir() {
|
||||
let (below, inside) = walk(repo, &child, mark)?;
|
||||
handled += below;
|
||||
exposed.extend(inside);
|
||||
continue;
|
||||
}
|
||||
|
||||
match classify(repo, &child, mark)? {
|
||||
Some(item) => exposed.push(item),
|
||||
None => handled += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// nothing below is in the store, so collapse to one line
|
||||
if handled == 0 && !exposed.is_empty() {
|
||||
let name = format!("{}/", rel.display());
|
||||
return Ok((0, vec![Exposed::content(mark, name)]));
|
||||
}
|
||||
|
||||
Ok((handled, exposed))
|
||||
}
|
||||
|
||||
fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
||||
let mut specs = Vec::with_capacity(paths.len());
|
||||
|
||||
for path in paths {
|
||||
let abs = resolve(path)?;
|
||||
let rel = abs.strip_prefix(&repo.root).map_err(|_| {
|
||||
anyhow!(
|
||||
"{} is outside the repository {}",
|
||||
abs.display(),
|
||||
repo.root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if rel.as_os_str().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
specs.push(rel.to_path_buf());
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
fn list_others(
|
||||
ctx: &Ctx,
|
||||
repo: &Repo,
|
||||
ignored: bool,
|
||||
pathspecs: &[PathBuf],
|
||||
) -> Result<Vec<String>> {
|
||||
let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs);
|
||||
if ignored {
|
||||
listing = listing.ignored();
|
||||
}
|
||||
|
||||
Ok(listing
|
||||
.capture(ctx)?
|
||||
.split('\0')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(String::from)
|
||||
.collect())
|
||||
}
|
||||
254
src/commands/link/store.rs
Normal file
254
src/commands/link/store.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use fs_err as fs;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::output::note;
|
||||
|
||||
// a checkout with no remote to name it after
|
||||
const LOCAL_NAMESPACE: &str = "_local";
|
||||
|
||||
pub(super) struct Repo {
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) store: PathBuf,
|
||||
// the configured store root, above the per-repository directories
|
||||
pub(super) base: PathBuf,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result<Self> {
|
||||
let root = git_root(ctx)?;
|
||||
let base = match store {
|
||||
Some(store) => store.to_path_buf(),
|
||||
None => store_root()?,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
store: base.join(repo_components(ctx, &root)?),
|
||||
root,
|
||||
base,
|
||||
})
|
||||
}
|
||||
pub(super) fn relative(&self, src: &Path) -> Result<PathBuf> {
|
||||
let rel = src.strip_prefix(&self.root).map_err(|_| {
|
||||
anyhow!(
|
||||
"{} is outside the repository {}",
|
||||
src.display(),
|
||||
self.root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// empty means the whole repo
|
||||
if rel.as_os_str().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"refusing to externalize the repository root itself"
|
||||
));
|
||||
}
|
||||
if rel.starts_with(".git") {
|
||||
return Err(anyhow!("refusing to externalize anything under .git"));
|
||||
}
|
||||
|
||||
Ok(rel.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve(path: &Path) -> Result<PathBuf> {
|
||||
let abs = std::path::absolute(path)
|
||||
.with_context(|| format!("resolving absolute path of {}", path.display()))?;
|
||||
|
||||
// the parent must exist so symlinked components resolve like git's toplevel
|
||||
let Some(name) = abs.file_name().map(OsString::from) else {
|
||||
return Ok(fs::canonicalize(&abs)?);
|
||||
};
|
||||
let parent = fs::canonicalize(abs.parent().unwrap_or(Path::new("/")))?;
|
||||
Ok(parent.join(name))
|
||||
}
|
||||
|
||||
fn git_root(ctx: &Ctx) -> Result<PathBuf> {
|
||||
let root = RevParse
|
||||
.capture(ctx)
|
||||
.map_err(|_| anyhow!("not inside a git repository"))?;
|
||||
|
||||
let root = root.trim().to_string();
|
||||
if root.is_empty() {
|
||||
return Err(anyhow!("git reported an empty repository root"));
|
||||
}
|
||||
|
||||
Ok(fs::canonicalize(&root)?)
|
||||
}
|
||||
|
||||
fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
|
||||
if let Some(url) = git_origin_url(ctx) {
|
||||
if let Some(components) = components_from_remote(&url) {
|
||||
return Ok(components);
|
||||
}
|
||||
note!("could not parse git remote `{url}`, falling back to the checkout name");
|
||||
}
|
||||
|
||||
let name = root
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?;
|
||||
Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy())))
|
||||
}
|
||||
|
||||
fn git_origin_url(ctx: &Ctx) -> Option<String> {
|
||||
let url = ConfigGet {
|
||||
key: "remote.origin.url",
|
||||
}
|
||||
.capture(ctx)
|
||||
.ok()?;
|
||||
|
||||
let url = url.trim().to_string();
|
||||
(!url.is_empty()).then_some(url)
|
||||
}
|
||||
|
||||
fn components_from_remote(url: &str) -> Option<PathBuf> {
|
||||
let url = url.trim();
|
||||
let url = url.strip_suffix(".git").unwrap_or(url);
|
||||
|
||||
// `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path`
|
||||
let (authority, path) = match url.split_once("://") {
|
||||
Some((_, after)) => after.split_once('/')?,
|
||||
None => url.split_once(':')?,
|
||||
};
|
||||
|
||||
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = host.split_once(':').map_or(host, |(h, _)| h);
|
||||
// a local remote has no host to key on
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut components = PathBuf::from(sanitize(&host.to_lowercase()));
|
||||
let mut depth = 0;
|
||||
for part in path.split('/').filter(|p| !p.is_empty()) {
|
||||
components.push(sanitize(part));
|
||||
depth += 1;
|
||||
}
|
||||
(depth > 0).then_some(components)
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
let out: String = s
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// `.` and `..` are legal characters but not legal components
|
||||
if out.chars().all(|c| c == '.') {
|
||||
return "_".repeat(out.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn store_root() -> Result<PathBuf> {
|
||||
if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
|
||||
return Ok(PathBuf::from(xdg).join("ahab"));
|
||||
}
|
||||
|
||||
let home = non_empty_var("HOME")
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?;
|
||||
|
||||
Ok(PathBuf::from(home).join(".local/share/ahab"))
|
||||
}
|
||||
fn non_empty_var(name: &str) -> Option<OsString> {
|
||||
env::var_os(name).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
|
||||
let listed = LsFiles::tracked(&repo.root)
|
||||
.limited_to(&[rel])
|
||||
.capture(ctx)?;
|
||||
|
||||
Ok(!listed.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool {
|
||||
CheckIgnore::new(&repo.root, rel)
|
||||
.quietly_succeeds(ctx)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn symlink_metadata_opt(path: &Path) -> Result<Option<std::fs::Metadata>> {
|
||||
// symlink_metadata does not follow the link, so a symlink shows as one
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(meta) => Ok(Some(meta)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{components_from_remote, sanitize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parses_every_spelling_of_a_remote() {
|
||||
let cases = [
|
||||
(
|
||||
"git@git.aflabs.org:urnik/afurnik.git",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"https://git.aflabs.org/urnik/afurnik.git",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"https://git.aflabs.org/urnik/afurnik",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"https://git.aflabs.org/urnik/afurnik/",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"ssh://git@git.aflabs.org:22/urnik/afurnik.git",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"git@GIT.Aflabs.org:urnik/AFurnik.git",
|
||||
"git.aflabs.org/urnik/AFurnik",
|
||||
),
|
||||
(
|
||||
"git@git.aflabs.org:urnik/internal/afurnik.git",
|
||||
"git.aflabs.org/urnik/internal/afurnik",
|
||||
),
|
||||
];
|
||||
|
||||
for (url, want) in cases {
|
||||
assert_eq!(
|
||||
components_from_remote(url),
|
||||
Some(PathBuf::from(want)),
|
||||
"url: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_remotes_without_a_host() {
|
||||
assert_eq!(components_from_remote("not-a-url"), None);
|
||||
assert_eq!(components_from_remote("/srv/git/afurnik.git"), None);
|
||||
assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None);
|
||||
assert_eq!(components_from_remote("https://git.aflabs.org/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_never_yields_a_traversal() {
|
||||
assert_eq!(sanitize(".."), "__");
|
||||
assert_eq!(sanitize("."), "_");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize(".env"), ".env");
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,30 @@
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Read, Write},
|
||||
path::Path,
|
||||
process::Stdio,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
mod server;
|
||||
mod shape;
|
||||
|
||||
use fs_err::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use self::server::{Database, wait_until_ready, when_ready};
|
||||
use self::shape::{Dump, HEADER_LEN, Kind};
|
||||
use crate::cli::Format;
|
||||
use crate::cmd::{
|
||||
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgIsReady, PgRestore, Ps,
|
||||
Psql, Rm, Start, Stop, Up,
|
||||
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop,
|
||||
Up,
|
||||
};
|
||||
use crate::compose::Compose;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::fsops::{remove_file, rename, suffixed};
|
||||
use crate::output::note;
|
||||
|
||||
const CUSTOM_MAGIC: &[u8] = b"PGDMP";
|
||||
const TAR_MAGIC: &[u8] = b"toc.dat";
|
||||
const GZIP_MAGIC: &[u8] = b"\x1f\x8b";
|
||||
const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
// wide enough for the cluster marker, which sits a few bytes into the file
|
||||
const HEADER_LEN: usize = 512;
|
||||
|
||||
// unique per run: docker cp will not copy a directory over an existing path, and
|
||||
// quietly leaves whatever was there for pg_restore to read instead
|
||||
fn remote_dump() -> String {
|
||||
format!("/tmp/ahab-dump-{}", std::process::id())
|
||||
}
|
||||
|
||||
struct Database {
|
||||
service: String,
|
||||
container: String,
|
||||
user: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
fn resolve(ctx: &Ctx) -> Result<Self> {
|
||||
let compose = Compose::resolve(ctx)?;
|
||||
let service = compose.postgres()?;
|
||||
let (user, name) = compose.postgres_credentials(&service);
|
||||
|
||||
let container = Ps::id_of(&service).capture(ctx)?.trim().to_string();
|
||||
|
||||
if container.is_empty() {
|
||||
return Err(anyhow!("service {service} has no running container"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
service,
|
||||
container,
|
||||
user,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
// what reads this shape of dump back in
|
||||
fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> {
|
||||
match kind {
|
||||
Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)),
|
||||
Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()),
|
||||
Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Dump {
|
||||
Directory,
|
||||
Gzip,
|
||||
Header(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Dump {
|
||||
fn of(path: &Path) -> Result<Self> {
|
||||
if path.is_dir() {
|
||||
return Ok(Self::Directory);
|
||||
}
|
||||
|
||||
let header = read_header(path)?;
|
||||
if header.starts_with(GZIP_MAGIC) {
|
||||
return Ok(Self::Gzip);
|
||||
}
|
||||
|
||||
Ok(Self::Header(header))
|
||||
}
|
||||
}
|
||||
|
||||
// how the dump has to be fed back in: pg_restore for an archive, psql into the
|
||||
// database for a single database dump, psql into postgres for a whole cluster
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Kind {
|
||||
Archive,
|
||||
Sql,
|
||||
Cluster,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn of(header: &[u8]) -> Self {
|
||||
if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) {
|
||||
return Self::Archive;
|
||||
}
|
||||
|
||||
if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) {
|
||||
return Self::Cluster;
|
||||
}
|
||||
|
||||
Self::Sql
|
||||
}
|
||||
|
||||
fn tool(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Archive => "pg_restore",
|
||||
Self::Sql | Self::Cluster => "psql",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pg_dumpall recreates roles the cluster already has, so this is the expected
|
||||
// shape of a working restore rather than a problem
|
||||
fn is_existing_role_error(line: &str) -> bool {
|
||||
line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists")
|
||||
}
|
||||
@@ -161,54 +62,6 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_header(path: &Path) -> Result<Vec<u8>> {
|
||||
let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
||||
let mut header = vec![0; HEADER_LEN];
|
||||
|
||||
let read = file
|
||||
.read(&mut header)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
header.truncate(read);
|
||||
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
||||
if ctx.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + READY_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let ready = PgIsReady {
|
||||
username: &db.user,
|
||||
dbname: &db.name,
|
||||
}
|
||||
.in_container(&db.container)
|
||||
.quietly_succeeds(ctx)?;
|
||||
|
||||
if ready {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"{} did not accept connections within {} seconds",
|
||||
db.service,
|
||||
READY_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> {
|
||||
wait_until_ready(ctx, db)?;
|
||||
command.run(ctx)
|
||||
}
|
||||
|
||||
pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
let dump = Dump::of(file)?;
|
||||
let db = Database::resolve(ctx)?;
|
||||
@@ -265,7 +118,9 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let tool = kind.tool();
|
||||
let restore = db.restore_with(kind);
|
||||
// the name of what actually runs, so the message cannot drift from it
|
||||
let tool = restore.argv().program().to_string();
|
||||
note!("restoring database with {tool}");
|
||||
|
||||
when_ready(
|
||||
@@ -309,7 +164,6 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
bail!("{tool} failed, the database is left empty");
|
||||
}
|
||||
} else {
|
||||
let restore = db.restore_with(kind);
|
||||
let restore: Box<dyn Cmd> = match dump {
|
||||
Dump::Gzip => Box::new(Gunzip.pipe(&*restore)),
|
||||
_ => restore,
|
||||
@@ -363,9 +217,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
|
||||
let stdout = if ctx.dry_run {
|
||||
Stdio::null()
|
||||
} else {
|
||||
Stdio::from(
|
||||
File::create(&partial).with_context(|| format!("creating {}", partial.display()))?,
|
||||
)
|
||||
Stdio::from(std::fs::File::from(File::create(&partial)?))
|
||||
};
|
||||
|
||||
let dumping = dump_command(&db, format);
|
||||
@@ -424,7 +276,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GZIP_MAGIC, Kind, is_existing_role_error};
|
||||
use super::is_existing_role_error;
|
||||
|
||||
#[test]
|
||||
fn only_the_existing_role_complaint_is_expected() {
|
||||
@@ -441,35 +293,4 @@ mod tests {
|
||||
assert!(!is_existing_role_error(line), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_is_recognised_by_its_magic() {
|
||||
assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive));
|
||||
assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cluster_dump_is_recognised_by_its_header() {
|
||||
let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n";
|
||||
assert!(matches!(Kind::of(header), Kind::Cluster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_else_is_a_single_database_dump() {
|
||||
for header in [
|
||||
&b"--\n-- PostgreSQL database dump\n"[..],
|
||||
&b"BEGIN;"[..],
|
||||
&b""[..],
|
||||
&b"PGD"[..],
|
||||
&b"toc.da"[..],
|
||||
] {
|
||||
assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_is_recognised_by_its_magic() {
|
||||
assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC));
|
||||
assert!(!b"PGDMP".starts_with(GZIP_MAGIC));
|
||||
}
|
||||
}
|
||||
85
src/commands/postgres/server.rs
Normal file
85
src/commands/postgres/server.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
|
||||
use super::shape::Kind;
|
||||
use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::project::Project;
|
||||
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
pub(super) struct Database {
|
||||
pub(super) service: String,
|
||||
pub(super) container: String,
|
||||
pub(super) user: String,
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(super) fn resolve(ctx: &Ctx) -> Result<Self> {
|
||||
let compose = Project::resolve(ctx)?;
|
||||
let service = compose.postgres()?;
|
||||
let (user, name) = compose.postgres_credentials(&service);
|
||||
|
||||
let container = Ps::id_of(&service).capture(ctx)?.trim().to_string();
|
||||
|
||||
if container.is_empty() {
|
||||
return Err(anyhow!("service {service} has no running container"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
service,
|
||||
container,
|
||||
user,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
// what reads this shape of dump back in
|
||||
pub(super) fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> {
|
||||
match kind {
|
||||
Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)),
|
||||
Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()),
|
||||
Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
||||
if ctx.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + READY_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let ready = PgIsReady {
|
||||
username: &db.user,
|
||||
dbname: &db.name,
|
||||
}
|
||||
.in_container(&db.container)
|
||||
.quietly_succeeds(ctx)?;
|
||||
|
||||
if ready {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"{} did not accept connections within {} seconds",
|
||||
db.service,
|
||||
READY_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> {
|
||||
wait_until_ready(ctx, db)?;
|
||||
command.run(ctx)
|
||||
}
|
||||
104
src/commands/postgres/shape.rs
Normal file
104
src/commands/postgres/shape.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use fs_err::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
const CUSTOM_MAGIC: &[u8] = b"PGDMP";
|
||||
const TAR_MAGIC: &[u8] = b"toc.dat";
|
||||
const GZIP_MAGIC: &[u8] = b"\x1f\x8b";
|
||||
const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump";
|
||||
// wide enough for the cluster marker, which sits a few bytes into the file
|
||||
pub(super) const HEADER_LEN: usize = 512;
|
||||
|
||||
pub(super) enum Dump {
|
||||
Directory,
|
||||
Gzip,
|
||||
Header(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Dump {
|
||||
pub(super) fn of(path: &Path) -> Result<Self> {
|
||||
if path.is_dir() {
|
||||
return Ok(Self::Directory);
|
||||
}
|
||||
|
||||
let header = read_header(path)?;
|
||||
if header.starts_with(GZIP_MAGIC) {
|
||||
return Ok(Self::Gzip);
|
||||
}
|
||||
|
||||
Ok(Self::Header(header))
|
||||
}
|
||||
}
|
||||
|
||||
// how the dump has to be fed back in: pg_restore for an archive, psql into the
|
||||
// database for a single database dump, psql into postgres for a whole cluster
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) enum Kind {
|
||||
Archive,
|
||||
Sql,
|
||||
Cluster,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub(super) fn of(header: &[u8]) -> Self {
|
||||
if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) {
|
||||
return Self::Archive;
|
||||
}
|
||||
|
||||
if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) {
|
||||
return Self::Cluster;
|
||||
}
|
||||
|
||||
Self::Sql
|
||||
}
|
||||
}
|
||||
|
||||
// pg_dumpall recreates roles the cluster already has, so this is the expected
|
||||
|
||||
fn read_header(path: &Path) -> Result<Vec<u8>> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut header = vec![0; HEADER_LEN];
|
||||
|
||||
let read = file.read(&mut header)?;
|
||||
header.truncate(read);
|
||||
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GZIP_MAGIC, Kind};
|
||||
|
||||
#[test]
|
||||
fn an_archive_is_recognised_by_its_magic() {
|
||||
assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive));
|
||||
assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cluster_dump_is_recognised_by_its_header() {
|
||||
let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n";
|
||||
assert!(matches!(Kind::of(header), Kind::Cluster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_else_is_a_single_database_dump() {
|
||||
for header in [
|
||||
&b"--\n-- PostgreSQL database dump\n"[..],
|
||||
&b"BEGIN;"[..],
|
||||
&b""[..],
|
||||
&b"PGD"[..],
|
||||
&b"toc.da"[..],
|
||||
] {
|
||||
assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_is_recognised_by_its_magic() {
|
||||
assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC));
|
||||
assert!(!b"PGDMP".starts_with(GZIP_MAGIC));
|
||||
}
|
||||
}
|
||||
32
src/main.rs
32
src/main.rs
@@ -2,11 +2,11 @@ use std::process::ExitCode;
|
||||
|
||||
mod cli;
|
||||
mod cmd;
|
||||
mod compose;
|
||||
mod commands;
|
||||
mod ctx;
|
||||
mod fsops;
|
||||
mod output;
|
||||
mod scripts;
|
||||
mod project;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
@@ -43,25 +43,25 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
match command {
|
||||
cli::Commands::Django { command } => {
|
||||
match command {
|
||||
cli::Django::Bash => scripts::django::bash(ctx),
|
||||
cli::Django::Run { rest } => scripts::django::run(ctx, &rest),
|
||||
cli::Django::Bash => commands::django::bash(ctx),
|
||||
cli::Django::Run { rest } => commands::django::run(ctx, &rest),
|
||||
cli::Django::MakeCommand { app, name } => {
|
||||
scripts::django::make_command(ctx, &app, &name)
|
||||
commands::django::make_command(ctx, &app, &name)
|
||||
}
|
||||
cli::Django::Makemigrations => scripts::django::makemigrations(ctx),
|
||||
cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest),
|
||||
cli::Django::Migrate { rest } => scripts::django::migrate(ctx, &rest),
|
||||
cli::Django::Shell => scripts::django::shell(ctx),
|
||||
cli::Django::Test => scripts::django::test(ctx),
|
||||
cli::Django::Makemigrations => commands::django::makemigrations(ctx),
|
||||
cli::Django::Manage { rest } => commands::django::manage(ctx, &rest),
|
||||
cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest),
|
||||
cli::Django::Shell => commands::django::shell(ctx),
|
||||
cli::Django::Test => commands::django::test(ctx),
|
||||
}?;
|
||||
|
||||
Ok(done)
|
||||
}
|
||||
cli::Commands::Postgres { command } => {
|
||||
match command {
|
||||
cli::Postgres::Import { path } => scripts::postgres::import(ctx, &path),
|
||||
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
|
||||
cli::Postgres::Dump { path, format, gzip } => {
|
||||
scripts::postgres::dump(ctx, &path, format, gzip)
|
||||
commands::postgres::dump(ctx, &path, format, gzip)
|
||||
}
|
||||
}?;
|
||||
|
||||
@@ -72,9 +72,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
paths,
|
||||
force,
|
||||
store,
|
||||
} => scripts::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done),
|
||||
} => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done),
|
||||
cli::Link::Restore { paths, all, store } => {
|
||||
scripts::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done)
|
||||
commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done)
|
||||
}
|
||||
// the one command with something to say through its exit code
|
||||
cli::Link::Check {
|
||||
@@ -83,7 +83,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
null,
|
||||
exit_code,
|
||||
store,
|
||||
} => scripts::link::check(
|
||||
} => commands::link::check(
|
||||
ctx,
|
||||
&paths,
|
||||
porcelain,
|
||||
@@ -93,7 +93,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
),
|
||||
},
|
||||
cli::Commands::Completions { shell } => {
|
||||
scripts::completions::completions(shell)?;
|
||||
commands::completions::completions(shell)?;
|
||||
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ use crate::ctx::Ctx;
|
||||
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
||||
const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE";
|
||||
|
||||
pub struct Compose {
|
||||
pub struct Project {
|
||||
services: Value,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
impl Project {
|
||||
pub fn resolve(ctx: &Ctx) -> Result<Self> {
|
||||
let json = Config.capture(ctx)?;
|
||||
let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?;
|
||||
@@ -125,11 +125,11 @@ fn is_postgres_image(image: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Compose, is_postgres_image};
|
||||
use super::{Project, is_postgres_image};
|
||||
use serde_json::json;
|
||||
|
||||
fn compose(services: serde_json::Value) -> Compose {
|
||||
Compose { services }
|
||||
fn compose(services: serde_json::Value) -> Project {
|
||||
Project { services }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1,763 +0,0 @@
|
||||
use std::cell::Cell;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
|
||||
use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed};
|
||||
use crate::output::{note, warning};
|
||||
|
||||
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
||||
const LOCAL_NAMESPACE: &str = "_local";
|
||||
|
||||
// move untracked paths out of the repo and symlink them back
|
||||
pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let report = Report::new(&repo);
|
||||
|
||||
if let [path] = paths {
|
||||
return link_one(ctx, &repo, path, force, &report);
|
||||
}
|
||||
|
||||
let mut failed = 0;
|
||||
for path in paths {
|
||||
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
|
||||
note!("error: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// move paths in the store back into the repo, the inverse of add
|
||||
pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let report = Report::new(&repo);
|
||||
|
||||
let paths = match (all, paths) {
|
||||
(true, []) => linked_paths(&repo, &repo.store)?,
|
||||
(true, _) => bail!("--all restores everything, so it takes no paths"),
|
||||
(false, []) => bail!("name a path to restore, or pass --all"),
|
||||
(false, paths) => paths.to_vec(),
|
||||
};
|
||||
|
||||
if paths.is_empty() {
|
||||
note!("nothing in the store for this repository");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let [path] = paths.as_slice() {
|
||||
return restore_one(ctx, &repo, path, &report);
|
||||
}
|
||||
|
||||
let mut failed = 0;
|
||||
for path in &paths {
|
||||
if let Err(e) = restore_one(ctx, &repo, path, &report) {
|
||||
note!("error: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
|
||||
let src = resolve(path)?;
|
||||
let rel = repo.relative(&src)?;
|
||||
let stored = repo.store.join(&rel);
|
||||
|
||||
let Some(meta) = symlink_metadata_opt(&src)? else {
|
||||
bail!("{} does not exist", rel.display());
|
||||
};
|
||||
if !meta.is_symlink() {
|
||||
bail!(
|
||||
"{} is not a symlink, so it is not in the store",
|
||||
rel.display()
|
||||
);
|
||||
}
|
||||
|
||||
let dest = fs::read_link(&src).with_context(|| format!("reading {}", src.display()))?;
|
||||
if dest != stored {
|
||||
bail!(
|
||||
"{} points at {}, which is not where the store keeps it",
|
||||
rel.display(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
if symlink_metadata_opt(&stored)?.is_none() {
|
||||
bail!("{} is missing from the store", rel.display());
|
||||
}
|
||||
|
||||
remove_file(ctx, &src)?;
|
||||
move_path(ctx, &stored, &src)?;
|
||||
prune_empty(ctx, stored.parent(), &repo.base);
|
||||
|
||||
report.line("restored", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// the store mirrors the repository layout, so walking it finds every path this
|
||||
// repository has linked without asking git anything
|
||||
fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut found = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
|
||||
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let stored = entry?.path();
|
||||
let rel = stored
|
||||
.strip_prefix(&repo.store)
|
||||
.expect("walked out of the store");
|
||||
let src = repo.root.join(rel);
|
||||
|
||||
let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink())
|
||||
&& fs::read_link(&src).is_ok_and(|dest| dest == stored);
|
||||
|
||||
if linked {
|
||||
found.push(src);
|
||||
} else if stored.is_dir() {
|
||||
found.extend(linked_paths(repo, &stored)?);
|
||||
}
|
||||
}
|
||||
|
||||
found.sort();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
struct Report {
|
||||
store: PathBuf,
|
||||
named: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn new(repo: &Repo) -> Self {
|
||||
Self {
|
||||
store: repo.store.clone(),
|
||||
named: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&self, verb: &str, path: &Path) {
|
||||
// worth naming once per run
|
||||
if !self.named.replace(true) {
|
||||
println!("store: {}", self.store.display());
|
||||
}
|
||||
|
||||
println!("\t{:<11}{}", format!("{verb}:"), path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// list untracked paths not in the store, i.e. what a sandbox can still read
|
||||
pub fn check(
|
||||
ctx: &Ctx,
|
||||
paths: &[PathBuf],
|
||||
porcelain: bool,
|
||||
null: bool,
|
||||
exit_code: bool,
|
||||
store: Option<&Path>,
|
||||
) -> Result<ExitCode> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let pathspecs = relative_pathspecs(&repo, paths)?;
|
||||
|
||||
let mut exposed = Vec::new();
|
||||
// git lists untracked and ignored separately
|
||||
for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] {
|
||||
for entry in list_others(ctx, &repo, ignored, &pathspecs)? {
|
||||
let rel = PathBuf::from(entry.trim_end_matches('/'));
|
||||
|
||||
// --directory collapses a wholly untracked dir into `dir/`
|
||||
if entry.ends_with('/') {
|
||||
exposed.extend(walk(&repo, &rel, mark)?.1);
|
||||
} else {
|
||||
exposed.extend(classify(&repo, &rel, mark)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
if porcelain || null {
|
||||
print_porcelain(&exposed, null);
|
||||
} else {
|
||||
print_listing(&repo, &exposed);
|
||||
}
|
||||
|
||||
// git's --exit-code convention: nothing to report is 0, anything is 1
|
||||
if exit_code && !exposed.is_empty() {
|
||||
return Ok(ExitCode::FAILURE);
|
||||
}
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn print_porcelain(exposed: &[Exposed], null: bool) {
|
||||
let end = if null { '\0' } else { '\n' };
|
||||
|
||||
for item in exposed {
|
||||
match &item.dest {
|
||||
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()),
|
||||
None => print!("{} {}{end}", item.code(), item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
||||
println!("store: {}", repo.store.display());
|
||||
|
||||
if exposed.is_empty() {
|
||||
println!("nothing outside the store, a sandbox would see tracked files only");
|
||||
return;
|
||||
}
|
||||
|
||||
let sections = [
|
||||
(
|
||||
"Untracked paths a sandbox can read:",
|
||||
" (use \"ahab link add <path>...\" to move them into the store)",
|
||||
Section::Content(UNTRACKED),
|
||||
),
|
||||
(
|
||||
"Ignored paths a sandbox can read:",
|
||||
" (use \"ahab link add <path>...\" to move them into the store)",
|
||||
Section::Content(IGNORED),
|
||||
),
|
||||
(
|
||||
"Symlinks leading outside the store:",
|
||||
" (their contents are not in the repository either way)",
|
||||
Section::Elsewhere,
|
||||
),
|
||||
];
|
||||
|
||||
for (heading, hint, section) in sections {
|
||||
let mut items = exposed.iter().filter(|item| section.holds(item)).peekable();
|
||||
if items.peek().is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("\n{heading}\n{hint}");
|
||||
for item in items {
|
||||
match &item.dest {
|
||||
Some(dest) => println!("\t{} -> {}", item.name, dest.display()),
|
||||
None => println!("\t{}", item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Section {
|
||||
Content(char),
|
||||
Elsewhere,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
fn holds(&self, item: &Exposed) -> bool {
|
||||
match self {
|
||||
Self::Content(mark) => item.dest.is_none() && item.mark == *mark,
|
||||
Self::Elsewhere => item.dest.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status codes as `git status --porcelain` spells them
|
||||
const UNTRACKED: char = '?';
|
||||
const IGNORED: char = '!';
|
||||
const ELSEWHERE: char = '>';
|
||||
|
||||
struct Exposed {
|
||||
mark: char,
|
||||
name: String,
|
||||
dest: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Exposed {
|
||||
fn content(mark: char, name: String) -> Self {
|
||||
Self {
|
||||
mark,
|
||||
name,
|
||||
dest: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> String {
|
||||
let second = if self.dest.is_some() {
|
||||
ELSEWHERE
|
||||
} else {
|
||||
self.mark
|
||||
};
|
||||
format!("{}{second}", self.mark)
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
|
||||
let src = repo.root.join(rel);
|
||||
let name = rel.display().to_string();
|
||||
|
||||
let Some(meta) = symlink_metadata_opt(&src)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !meta.is_symlink() {
|
||||
return Ok(Some(Exposed::content(mark, name)));
|
||||
}
|
||||
|
||||
let dest = fs::read_link(&src).with_context(|| format!("reading symlink {}", src.display()))?;
|
||||
if dest == repo.store.join(rel) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Exposed {
|
||||
mark,
|
||||
name,
|
||||
dest: Some(dest),
|
||||
}))
|
||||
}
|
||||
|
||||
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
|
||||
let dir = repo.root.join(rel);
|
||||
let mut handled = 0;
|
||||
let mut exposed = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
let child = rel.join(entry.file_name());
|
||||
|
||||
if entry.file_type()?.is_dir() {
|
||||
let (below, inside) = walk(repo, &child, mark)?;
|
||||
handled += below;
|
||||
exposed.extend(inside);
|
||||
continue;
|
||||
}
|
||||
|
||||
match classify(repo, &child, mark)? {
|
||||
Some(item) => exposed.push(item),
|
||||
None => handled += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// nothing below is in the store, so collapse to one line
|
||||
if handled == 0 && !exposed.is_empty() {
|
||||
let name = format!("{}/", rel.display());
|
||||
return Ok((0, vec![Exposed::content(mark, name)]));
|
||||
}
|
||||
|
||||
Ok((handled, exposed))
|
||||
}
|
||||
|
||||
fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
||||
let mut specs = Vec::with_capacity(paths.len());
|
||||
|
||||
for path in paths {
|
||||
let abs = resolve(path)?;
|
||||
let rel = abs.strip_prefix(&repo.root).map_err(|_| {
|
||||
anyhow!(
|
||||
"{} is outside the repository {}",
|
||||
abs.display(),
|
||||
repo.root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if rel.as_os_str().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
specs.push(rel.to_path_buf());
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
fn list_others(
|
||||
ctx: &Ctx,
|
||||
repo: &Repo,
|
||||
ignored: bool,
|
||||
pathspecs: &[PathBuf],
|
||||
) -> Result<Vec<String>> {
|
||||
let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs);
|
||||
if ignored {
|
||||
listing = listing.ignored();
|
||||
}
|
||||
|
||||
Ok(listing
|
||||
.capture(ctx)?
|
||||
.split('\0')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(String::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
struct Repo {
|
||||
root: PathBuf,
|
||||
store: PathBuf,
|
||||
// the configured store root, above the per-repository directories
|
||||
base: PathBuf,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
fn discover(ctx: &Ctx, store: Option<&Path>) -> Result<Self> {
|
||||
let root = git_root(ctx)?;
|
||||
let base = match store {
|
||||
Some(store) => store.to_path_buf(),
|
||||
None => store_root()?,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
store: base.join(repo_components(ctx, &root)?),
|
||||
root,
|
||||
base,
|
||||
})
|
||||
}
|
||||
fn relative(&self, src: &Path) -> Result<PathBuf> {
|
||||
let rel = src.strip_prefix(&self.root).map_err(|_| {
|
||||
anyhow!(
|
||||
"{} is outside the repository {}",
|
||||
src.display(),
|
||||
self.root.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// empty means the whole repo
|
||||
if rel.as_os_str().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"refusing to externalize the repository root itself"
|
||||
));
|
||||
}
|
||||
if rel.starts_with(".git") {
|
||||
return Err(anyhow!("refusing to externalize anything under .git"));
|
||||
}
|
||||
|
||||
Ok(rel.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
|
||||
let src = resolve(path)?;
|
||||
let rel = repo.relative(&src)?;
|
||||
let target = repo.store.join(&rel);
|
||||
|
||||
// a target inside the repo would be readable from the sandbox anyway
|
||||
if target.starts_with(&repo.root) {
|
||||
return Err(anyhow!(
|
||||
"target {} is inside the repository; point AHAB_LINK_ROOT elsewhere",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
|
||||
if tracked(ctx, repo, &rel)? {
|
||||
return Err(anyhow!(
|
||||
"{} is tracked by git; only untracked or ignored paths can be externalized",
|
||||
rel.display()
|
||||
));
|
||||
}
|
||||
if !ignored(ctx, repo, &rel) {
|
||||
warning!("{} is not gitignored", rel.display());
|
||||
}
|
||||
|
||||
let src_meta = symlink_metadata_opt(&src)?;
|
||||
let target_taken = symlink_metadata_opt(&target)?.is_some();
|
||||
|
||||
match src_meta {
|
||||
Some(meta) if meta.is_symlink() => {
|
||||
let dest = fs::read_link(&src)
|
||||
.with_context(|| format!("reading symlink {}", src.display()))?;
|
||||
|
||||
if dest == target {
|
||||
if !target_taken {
|
||||
return Err(anyhow!(
|
||||
"{} already points at {}, but nothing is there",
|
||||
rel.display(),
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
report.line("unchanged", &rel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// nothing in the store to adopt, so the symlink itself moves out
|
||||
if !target_taken {
|
||||
if !src.exists() {
|
||||
warning!(
|
||||
"{} is a broken symlink to {}",
|
||||
rel.display(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
move_path(ctx, &src, &target)?;
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("moved", &rel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !force {
|
||||
return Err(needs_force(&target));
|
||||
}
|
||||
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("repointed", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some(_) if target_taken => {
|
||||
if !force {
|
||||
return Err(needs_force(&target));
|
||||
}
|
||||
|
||||
let backup = suffixed(&src, BACKUP_SUFFIX);
|
||||
if symlink_metadata_opt(&backup)?.is_some() {
|
||||
return Err(anyhow!(
|
||||
"{} already exists; remove it before re-linking",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
|
||||
rename(ctx, &src, &backup)?;
|
||||
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
|
||||
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("linked", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some(_) => {
|
||||
move_path(ctx, &src, &target)?;
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("moved", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
None if target_taken => {
|
||||
if !force {
|
||||
return Err(needs_force(&target));
|
||||
}
|
||||
place_link(ctx, &src, &target)?;
|
||||
report.line("linked", &rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
None => Err(anyhow!(
|
||||
"{} does not exist and the store has no {}",
|
||||
rel.display(),
|
||||
target.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_force(target: &Path) -> anyhow::Error {
|
||||
anyhow!(
|
||||
"{} already exists; pass --force to link to it",
|
||||
target.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve(path: &Path) -> Result<PathBuf> {
|
||||
let abs = std::path::absolute(path)
|
||||
.with_context(|| format!("resolving absolute path of {}", path.display()))?;
|
||||
|
||||
// the parent must exist so symlinked components resolve like git's toplevel
|
||||
let Some(name) = abs.file_name().map(OsString::from) else {
|
||||
return fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()));
|
||||
};
|
||||
let parent = abs.parent().unwrap_or(Path::new("/"));
|
||||
|
||||
let parent = fs::canonicalize(parent)
|
||||
.with_context(|| format!("resolving directory {}", parent.display()))?;
|
||||
Ok(parent.join(name))
|
||||
}
|
||||
|
||||
fn git_root(ctx: &Ctx) -> Result<PathBuf> {
|
||||
let root = RevParse
|
||||
.capture(ctx)
|
||||
.map_err(|_| anyhow!("not inside a git repository"))?;
|
||||
|
||||
let root = root.trim().to_string();
|
||||
if root.is_empty() {
|
||||
return Err(anyhow!("git reported an empty repository root"));
|
||||
}
|
||||
|
||||
fs::canonicalize(&root).with_context(|| format!("resolving {root}"))
|
||||
}
|
||||
|
||||
fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
|
||||
if let Some(url) = git_origin_url(ctx) {
|
||||
if let Some(components) = components_from_remote(&url) {
|
||||
return Ok(components);
|
||||
}
|
||||
note!("could not parse git remote `{url}`, falling back to the checkout name");
|
||||
}
|
||||
|
||||
let name = root
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?;
|
||||
Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy())))
|
||||
}
|
||||
|
||||
fn git_origin_url(ctx: &Ctx) -> Option<String> {
|
||||
let url = ConfigGet {
|
||||
key: "remote.origin.url",
|
||||
}
|
||||
.capture(ctx)
|
||||
.ok()?;
|
||||
|
||||
let url = url.trim().to_string();
|
||||
(!url.is_empty()).then_some(url)
|
||||
}
|
||||
|
||||
fn components_from_remote(url: &str) -> Option<PathBuf> {
|
||||
let url = url.trim();
|
||||
let url = url.strip_suffix(".git").unwrap_or(url);
|
||||
|
||||
// `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path`
|
||||
let (authority, path) = match url.split_once("://") {
|
||||
Some((_, after)) => after.split_once('/')?,
|
||||
None => url.split_once(':')?,
|
||||
};
|
||||
|
||||
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = host.split_once(':').map_or(host, |(h, _)| h);
|
||||
// a local remote has no host to key on
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut components = PathBuf::from(sanitize(&host.to_lowercase()));
|
||||
let mut depth = 0;
|
||||
for part in path.split('/').filter(|p| !p.is_empty()) {
|
||||
components.push(sanitize(part));
|
||||
depth += 1;
|
||||
}
|
||||
(depth > 0).then_some(components)
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
let out: String = s
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// `.` and `..` are legal characters but not legal components
|
||||
if out.chars().all(|c| c == '.') {
|
||||
return "_".repeat(out.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn store_root() -> Result<PathBuf> {
|
||||
if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
|
||||
return Ok(PathBuf::from(xdg).join("ahab"));
|
||||
}
|
||||
|
||||
let home = non_empty_var("HOME")
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?;
|
||||
|
||||
Ok(PathBuf::from(home).join(".local/share/ahab"))
|
||||
}
|
||||
fn non_empty_var(name: &str) -> Option<OsString> {
|
||||
env::var_os(name).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
|
||||
let listed = LsFiles::tracked(&repo.root)
|
||||
.limited_to(&[rel])
|
||||
.capture(ctx)?;
|
||||
|
||||
Ok(!listed.is_empty())
|
||||
}
|
||||
|
||||
fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool {
|
||||
CheckIgnore::new(&repo.root, rel)
|
||||
.quietly_succeeds(ctx)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn symlink_metadata_opt(path: &Path) -> Result<Option<fs::Metadata>> {
|
||||
// symlink_metadata does not follow the link, so a symlink shows as one
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(meta) => Ok(Some(meta)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("inspecting {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{components_from_remote, sanitize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parses_every_spelling_of_a_remote() {
|
||||
let cases = [
|
||||
(
|
||||
"git@git.aflabs.org:urnik/afurnik.git",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"https://git.aflabs.org/urnik/afurnik.git",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"https://git.aflabs.org/urnik/afurnik",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"https://git.aflabs.org/urnik/afurnik/",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"ssh://git@git.aflabs.org:22/urnik/afurnik.git",
|
||||
"git.aflabs.org/urnik/afurnik",
|
||||
),
|
||||
(
|
||||
"git@GIT.Aflabs.org:urnik/AFurnik.git",
|
||||
"git.aflabs.org/urnik/AFurnik",
|
||||
),
|
||||
(
|
||||
"git@git.aflabs.org:urnik/internal/afurnik.git",
|
||||
"git.aflabs.org/urnik/internal/afurnik",
|
||||
),
|
||||
];
|
||||
|
||||
for (url, want) in cases {
|
||||
assert_eq!(
|
||||
components_from_remote(url),
|
||||
Some(PathBuf::from(want)),
|
||||
"url: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_remotes_without_a_host() {
|
||||
assert_eq!(components_from_remote("not-a-url"), None);
|
||||
assert_eq!(components_from_remote("/srv/git/afurnik.git"), None);
|
||||
assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None);
|
||||
assert_eq!(components_from_remote("https://git.aflabs.org/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_never_yields_a_traversal() {
|
||||
assert_eq!(sanitize(".."), "__");
|
||||
assert_eq!(sanitize("."), "_");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(sanitize(".env"), ".env");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user