use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use super::process::run_git;
use super::shepignore::{pattern_matches, shepignore_patterns};
use crate::error::Error;
mod within;
#[cfg(test)]
mod into_tests;
pub fn ignored_present(checkout: &Path) -> Result<Vec<PathBuf>, Error> {
let stdout = run_git(
checkout,
&["status", "--ignored=matching", "--porcelain", "-z"],
)?;
let mut fields = stdout.split('\0');
let mut found = Vec::new();
while let Some(entry) = fields.next() {
let Some((status, path)) = entry.split_at_checked(3) else {
continue;
};
if status.as_bytes()[..2]
.iter()
.any(|b| matches!(b, b'R' | b'C'))
{
fields.next();
}
if status == "!! " {
found.push(PathBuf::from(path.trim_end_matches('/')));
}
}
Ok(found)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FromCheckout(Vec<PathBuf>);
impl FromCheckout {
#[must_use]
pub fn includes_override(&self) -> bool {
self.0
.iter()
.any(|path| path == Path::new(crate::flockfile::OVERRIDE))
}
#[cfg(test)]
pub fn of(paths: Vec<PathBuf>) -> Self {
Self(paths)
}
}
impl std::ops::Deref for FromCheckout {
type Target = [PathBuf];
fn deref(&self) -> &[PathBuf] {
&self.0
}
}
pub fn to_link(checkout: &Path) -> Result<FromCheckout, Error> {
let ignored = ignored_present(checkout)?;
let patterns = shepignore_patterns(checkout)?;
Ok(FromCheckout(
ignored
.into_iter()
.filter(|path| {
path == Path::new(crate::flockfile::OVERRIDE)
|| !patterns
.iter()
.any(|pattern| pattern_matches(path, pattern))
})
.collect(),
))
}
pub fn link_into(release: &Path, checkout: &Path, paths: &FromCheckout) -> Result<(), Error> {
let checkout = fs::canonicalize(checkout).map_err(Error::at(checkout))?;
for relative in paths.iter() {
let target = checkout.join(relative);
let link = release.join(relative);
let in_place = within::link_within(release, relative, &target).map_err(Error::at(&link))?;
if !in_place {
return Err(Error::Config(format!(
"{} is already present in the release, so {} cannot be linked from the \
checkout. The usual cause is a build output that git ignores and \
`.shepignore` does not: this dog gives each sheep its own build cache and \
links it in first, and the operator's own build artifacts must not be \
shared into a release at all, because the next release's build would write \
through the link and replace what the current one is serving. Add {} to \
`.shepignore` in the checkout.",
link.display(),
relative.display(),
relative.display()
)));
}
}
Ok(())
}
pub fn link_cache(release: &Path, cache_target: &Path) -> Result<(), Error> {
let link = release.join("target");
if link.symlink_metadata().is_ok() {
return Ok(());
}
fs::create_dir_all(cache_target).map_err(Error::at(cache_target))?;
symlink(cache_target, &link).map_err(Error::at(link))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures;
use tempfile::TempDir;
fn fixture_repo(entries: &[(&str, &str)]) -> TempDir {
fixtures::checkout(entries)
}
#[test]
fn enumeration_asks_git_rather_than_parsing_gitignore() {
let repo = fixture_repo(&[
(".gitignore", "config/\n!config/.gitkeep\n"),
("config/local.json", "{}"),
("config/.gitkeep", ""),
("tracked.txt", "x"),
]);
let found = ignored_present(repo.path()).expect("enumerates");
assert!(found.iter().any(|p| p.ends_with("config")));
assert!(!found.iter().any(|p| p.ends_with("tracked.txt")));
}
#[test]
fn ignored_present_excludes_untracked_files_that_are_not_ignored() {
let repo = fixture_repo(&[(".gitignore", "dist/\n"), ("dist/app.js", "//")]);
fs::write(repo.path().join("scratch.txt"), "untracked, not ignored")
.expect("write scratch file");
let found = ignored_present(repo.path()).expect("enumerates");
assert!(found.iter().any(|p| p.ends_with("dist")));
assert!(!found.iter().any(|p| p.ends_with("scratch.txt")));
}
#[test]
fn the_override_is_recognised_only_when_it_was_linked() {
let repo = tempfile::tempdir().expect("tempdir");
fixtures::run_git(repo.path(), &["init", "-q"]);
fs::write(repo.path().join(".gitignore"), "Flockfile.override.toml\n").expect("ignore");
fs::write(
repo.path().join("Flockfile.override.toml"),
"[[app]]\nname = 'web'\nuser = 'app'\n",
)
.expect("override");
let linked = to_link(repo.path()).expect("computes");
assert!(linked.includes_override(), "{linked:?}");
fs::remove_file(repo.path().join("Flockfile.override.toml")).expect("removed");
let linked = to_link(repo.path()).expect("computes");
assert!(!linked.includes_override(), "{linked:?}");
assert!(!FromCheckout::of(vec![PathBuf::from("config/local.json")]).includes_override());
}
#[test]
fn shepignored_paths_are_not_linked() {
let repo = fixture_repo(&[
(".gitignore", "dist/\nconfig/local.json\n"),
(".shepignore", "dist\n"),
("dist/app.js", "//"),
("config/local.json", "{}"),
]);
let linked = to_link(repo.path()).expect("computes");
assert!(linked.iter().any(|p| p.ends_with("config/local.json")));
assert!(!linked.iter().any(|p| p.ends_with("dist")));
}
#[test]
fn a_committed_shepignore_cannot_drop_the_operators_override() {
let repo = fixture_repo(&[
(".gitignore", "Flockfile.override.toml\n"),
(".shepignore", "Flockfile.override.toml\n"),
]);
fs::write(
repo.path().join("Flockfile.override.toml"),
"[[app]]\nname = \"web\"\nuser = \"svc\"\n",
)
.expect("the operator's override");
let linked = to_link(repo.path()).expect("computes");
assert!(
linked
.iter()
.any(|p| p.ends_with("Flockfile.override.toml")),
"the override must survive a repo-committed .shepignore: {linked:?}"
);
}
#[test]
fn no_shepignore_means_share_everything_ignored() {
let repo = fixture_repo(&[(".gitignore", "config/\n"), ("config/local.json", "{}")]);
assert!(!to_link(repo.path()).expect("computes").is_empty());
}
#[test]
fn a_leading_slash_anchors_a_pattern_to_the_checkout_root() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "/dist\n"),
("dist/app.js", "//"),
("packages/app/dist/bundle.js", "//"),
]);
let linked = to_link(repo.path()).expect("computes");
assert!(!linked.contains(&PathBuf::from("dist")), "{linked:?}");
assert!(
linked.contains(&PathBuf::from("packages/app/dist")),
"anchored means only the root one: {linked:?}"
);
}
#[test]
fn a_trailing_slash_is_not_part_of_the_name() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "dist/\n"),
("dist/app.js", "//"),
]);
let linked = to_link(repo.path()).expect("computes");
assert!(!linked.contains(&PathBuf::from("dist")), "{linked:?}");
}
#[test]
fn a_staged_rename_is_one_record_not_two() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
("dist/app.js", "//"),
("old.txt", "x"),
]);
fixtures::run_git(repo.path(), &["mv", "old.txt", "!! x"]);
let found = ignored_present(repo.path()).expect("enumerates");
assert!(found.contains(&PathBuf::from("dist")), "{found:?}");
assert!(
!found.iter().any(|p| p.ends_with("x")),
"the rename's own path must not be read as ignored: {found:?}"
);
}
#[test]
fn shepignore_bare_pattern_matches_at_any_depth() {
let repo = fixture_repo(&[
(".gitignore", "node_modules/\ndist/\n"),
(".shepignore", "node_modules\n"),
("node_modules/a.js", "//"),
("packages/foo/node_modules/b.js", "//"),
("dist/app.js", "//"),
]);
let linked = to_link(repo.path()).expect("computes");
assert!(!linked.iter().any(|p| p.ends_with("node_modules")));
assert!(linked.iter().any(|p| p.ends_with("dist")));
}
#[test]
fn shepignore_pattern_with_slash_is_anchored_to_its_own_subtree() {
let repo = fixture_repo(&[
(".gitignore", "dist/\npackages/dist/\n"),
(".shepignore", "packages/dist\n"),
("dist/app.js", "//"),
("packages/dist/bundle.js", "//"),
]);
let linked = to_link(repo.path()).expect("computes");
assert!(linked.contains(&PathBuf::from("dist")));
assert!(!linked.contains(&PathBuf::from("packages/dist")));
}
#[test]
fn a_release_gets_target_linked_at_the_cache() {
let root = tempfile::tempdir().expect("tempdir");
let release = root.path().join("release");
let cache = root.path().join("cache/target");
fs::create_dir_all(&release).expect("release dir");
link_cache(&release, &cache).expect("links");
let link = release.join("target");
assert_eq!(fs::read_link(&link).expect("a symlink"), cache);
assert!(cache.is_dir(), "the cache itself must be created");
}
#[test]
fn a_release_that_ships_its_own_target_is_left_alone() {
let root = tempfile::tempdir().expect("tempdir");
let release = root.path().join("release");
fs::create_dir_all(release.join("target")).expect("a committed target");
let cache = root.path().join("cache/target");
link_cache(&release, &cache).expect("does nothing, successfully");
assert!(
release.join("target").is_dir(),
"the repository's own directory must survive"
);
assert!(
fs::read_link(release.join("target")).is_err(),
"and must not have been replaced by a link"
);
}
#[test]
fn a_checkout_sharing_target_says_how_to_fix_it() {
let root = tempfile::tempdir().expect("tempdir");
let release = root.path().join("release");
let checkout = root.path().join("checkout");
fs::create_dir_all(&release).expect("release");
fs::create_dir_all(checkout.join("target")).expect("their own target");
link_cache(&release, &root.path().join("cache/target")).expect("links");
let err = link_into(
&release,
&checkout,
&FromCheckout::of(vec![PathBuf::from("target")]),
)
.expect_err("collides");
let shown = err.to_string();
assert!(shown.contains(".shepignore"), "{shown}");
assert!(shown.contains("target"), "{shown}");
}
}