use std::fs;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::Error;
pub(crate) fn run_git(dir: &Path, args: &[&str]) -> Result<String, Error> {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(|source| Error::Io {
path: dir.to_owned(),
source,
})?;
if !output.status.success() {
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
String::from_utf8(output.stdout).map_err(|err| Error::Io {
path: dir.to_owned(),
source: io::Error::other(err),
})
}
pub fn ignored_present(checkout: &Path) -> Result<Vec<PathBuf>, Error> {
let stdout = run_git(
checkout,
&[
"-c",
"core.quotePath=false",
"status",
"--ignored=matching",
"--porcelain",
],
)?;
Ok(stdout
.lines()
.filter_map(|line| line.strip_prefix("!! "))
.map(|path| PathBuf::from(path.trim_end_matches('/')))
.collect())
}
pub fn shepignore_patterns(checkout: &Path) -> Result<Vec<String>, Error> {
let path = checkout.join(".shepignore");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => return Err(Error::Io { path, source }),
};
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|pattern| {
if pattern.contains(['*', '?', '[']) {
Err(Error::Config(format!(
".shepignore pattern {pattern:?} uses glob syntax (`*`, `?`, `[`), which \
is not supported - a .shepignore pattern is a bare name (matches at any \
depth) or a path containing `/` (anchored to the checkout root), nothing \
else"
)))
} else {
Ok(pattern.to_owned())
}
})
.collect()
}
fn pattern_matches(path: &Path, pattern: &str) -> bool {
let pattern = Path::new(pattern);
if pattern.components().count() == 1 {
path.components()
.any(|component| component.as_os_str() == pattern.as_os_str())
} else {
path.starts_with(pattern)
}
}
pub fn to_link(checkout: &Path) -> Result<Vec<PathBuf>, Error> {
let ignored = ignored_present(checkout)?;
let patterns = shepignore_patterns(checkout)?;
Ok(ignored
.into_iter()
.filter(|path| {
!patterns
.iter()
.any(|pattern| pattern_matches(path, pattern))
})
.collect())
}
pub fn link_into(release: &Path, checkout: &Path, paths: &[PathBuf]) -> Result<(), Error> {
let checkout = fs::canonicalize(checkout).map_err(|source| Error::Io {
path: checkout.to_owned(),
source,
})?;
for relative in paths {
let target = checkout.join(relative);
let link = release.join(relative);
if let Some(parent) = link.parent() {
fs::create_dir_all(parent).map_err(|source| Error::Io {
path: parent.to_owned(),
source,
})?;
}
symlink(&target, &link).map_err(|source| {
if source.kind() == io::ErrorKind::AlreadyExists {
return 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()
));
}
Error::Io {
path: link.clone(),
source,
}
})?;
}
Ok(())
}
pub fn link_cache(release: &Path, cache_target: &Path) -> Result<(), Error> {
let link = release.join("target");
if link.exists() || link.symlink_metadata().is_ok() {
return Ok(());
}
fs::create_dir_all(cache_target).map_err(|source| Error::Io {
path: cache_target.to_owned(),
source,
})?;
symlink(cache_target, &link).map_err(|source| Error::Io { path: link, source })
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tempfile::TempDir;
fn fixture_repo(entries: &[(&str, &str)]) -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
run(dir.path(), &["init", "-q"]);
run(dir.path(), &["config", "user.email", "test@example.com"]);
run(dir.path(), &["config", "user.name", "test"]);
for (path, contents) in entries {
let full = dir.path().join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("mkdir fixture parent");
}
fs::write(&full, contents).expect("write fixture file");
}
run(dir.path(), &["add", "."]);
run(dir.path(), &["commit", "-q", "-m", "seed"]);
dir
}
fn run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
static CWD_GUARD: Mutex<()> = Mutex::new(());
#[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 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 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 shepignore_patterns_skips_blank_lines_and_comments() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(
".shepignore",
"# build output, never share this\n\ndist\n\n",
),
("dist/app.js", "//"),
]);
let patterns = shepignore_patterns(repo.path()).expect("reads");
assert_eq!(patterns, vec!["dist".to_string()]);
}
#[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 link_into_creates_symlinks_that_resolve_into_the_checkout() {
let repo = fixture_repo(&[
(".gitignore", "config/local.json\n"),
("config/local.json", r#"{"real":true}"#),
]);
let release = tempfile::tempdir().expect("release tempdir");
let paths = to_link(repo.path()).expect("computes");
link_into(release.path(), repo.path(), &paths).expect("links");
let linked_path = release.path().join("config").join("local.json");
assert!(linked_path.is_symlink());
let contents = fs::read_to_string(&linked_path).expect("read through symlink");
assert_eq!(contents, r#"{"real":true}"#);
}
#[test]
fn link_into_resolves_even_when_checkout_is_relative() {
let repo = fixture_repo(&[
(".gitignore", "config/local.json\n"),
("config/local.json", r#"{"real":true}"#),
]);
let release = tempfile::tempdir().expect("release tempdir");
let paths = vec![PathBuf::from("config/local.json")];
let _guard = CWD_GUARD.lock().expect("cwd guard poisoned");
let original_cwd = std::env::current_dir().expect("read cwd");
std::env::set_current_dir(repo.path().parent().expect("repo has a parent"))
.expect("chdir into repo's parent");
let relative_checkout = PathBuf::from(repo.path().file_name().expect("repo has a name"));
let result = link_into(release.path(), &relative_checkout, &paths);
std::env::set_current_dir(&original_cwd).expect("restore cwd");
result.expect("links despite a relative checkout");
let linked_path = release.path().join("config").join("local.json");
let contents = fs::read_to_string(&linked_path).expect("read through symlink");
assert_eq!(contents, r#"{"real":true}"#);
}
#[test]
fn link_into_fails_loudly_when_checkout_does_not_exist() {
let release = tempfile::tempdir().expect("release tempdir");
let missing_checkout = release.path().join("no-such-checkout");
let paths = vec![PathBuf::from("config/local.json")];
let err = link_into(release.path(), &missing_checkout, &paths)
.expect_err("a checkout that does not exist cannot be canonicalised");
assert!(matches!(err, Error::Io { .. }));
}
#[test]
fn shepignore_refuses_a_pattern_with_an_asterisk() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "*.log\n"),
("dist/app.js", "//"),
]);
let err = shepignore_patterns(repo.path()).expect_err("must refuse a glob pattern");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("*.log"));
}
#[test]
fn shepignore_refuses_a_pattern_with_a_question_mark() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "cache?.tmp\n"),
("dist/app.js", "//"),
]);
let err = shepignore_patterns(repo.path()).expect_err("must refuse a glob pattern");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("cache?.tmp"));
}
#[test]
fn shepignore_refuses_a_pattern_with_a_bracket_class() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "cache[0-9].tmp\n"),
("dist/app.js", "//"),
]);
let err = shepignore_patterns(repo.path()).expect_err("must refuse a glob pattern");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("cache[0-9].tmp"));
}
#[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, &[PathBuf::from("target")]).expect_err("collides");
let shown = err.to_string();
assert!(shown.contains(".shepignore"), "{shown}");
assert!(shown.contains("target"), "{shown}");
}
}