use std::fs;
use std::io;
use std::os::unix::fs::symlink;
use std::os::unix::process::CommandExt;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
use crate::error::Error;
pub(crate) fn run_git_within(dir: &Path, args: &[&str], budget: Duration) -> Result<String, Error> {
let mut child = Command::new("git")
.current_dir(dir)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.map_err(Error::at(dir))?;
let out = drain(child.stdout.take());
let err = drain(child.stderr.take());
let deadline = Instant::now() + budget;
let status = loop {
match child.try_wait() {
Err(source) => {
let _ = child.kill();
return Err(Error::Io {
path: dir.to_owned(),
source,
});
}
Ok(Some(status)) => break Some(status),
Ok(None) => {}
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
abandon(&mut child);
break None;
}
thread::sleep(POLL.min(left));
};
let Some(status) = status else {
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: None,
stderr: format!(
"no answer within {budget:?}; abandoned so the other targets keep deploying"
),
});
};
let remaining = || deadline.saturating_duration_since(Instant::now());
let (Ok(stdout), Ok(stderr)) = (out.recv_timeout(remaining()), err.recv_timeout(remaining()))
else {
drop(out);
drop(err);
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: None,
stderr: format!(
"exited, but something it started still held its output after \
{budget:?}; abandoned so the other targets keep deploying"
),
});
};
decode(dir, args, status, stdout, stderr)
}
pub(crate) fn kill_group(pid: u32) {
if pid == 0 {
return;
}
if let Ok(pid) = i32::try_from(pid) {
let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
}
}
pub(crate) fn printable(text: impl std::fmt::Display) -> String {
text.to_string()
.chars()
.map(|c| if forges_a_line(c) { '?' } else { c })
.collect()
}
pub(crate) fn forges_a_line(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{00ad}'
| '\u{061c}'
| '\u{180e}'
| '\u{200b}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2069}'
| '\u{feff}'
| '\u{e0000}'..='\u{e007f}'
)
}
fn abandon(child: &mut std::process::Child) {
kill_group(child.id());
let _ = child.kill();
let _ = child.wait();
}
fn drain<R: io::Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = pipe {
let _ = pipe.read_to_end(&mut buf);
}
let _ = tx.send(buf);
});
rx
}
pub(crate) async fn off_thread<T, F>(work: F) -> Result<T, Error>
where
T: Send + 'static,
F: FnOnce() -> Result<T, Error> + Send + 'static,
{
match tokio::task::spawn_blocking(work).await {
Ok(result) => result,
Err(join) => match join.try_into_panic() {
Ok(payload) => std::panic::resume_unwind(payload),
Err(join) => panic!("a blocking task was cancelled: {join}"),
},
}
}
pub(crate) const fn o_nofollow() -> i32 {
#[cfg(target_os = "linux")]
{
0o400_000
}
#[cfg(target_vendor = "apple")]
{
0x0100
}
#[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
{
compile_error!("O_NOFOLLOW's value is not known for this target")
}
}
pub(crate) fn is_eloop(err: &io::Error) -> bool {
err.raw_os_error() == Some(nix::errno::Errno::ELOOP as i32)
}
const POLL: Duration = Duration::from_millis(50);
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(Error::at(dir))?;
decode(dir, args, output.status, output.stdout, output.stderr)
}
fn decode(
dir: &Path,
args: &[&str],
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
) -> Result<String, Error> {
if !status.success() {
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: status.code(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
});
}
String::from_utf8(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,
&["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 enum Pattern {
Anywhere(String),
Anchored(PathBuf),
}
pub fn shepignore_patterns(checkout: &Path) -> Result<Vec<Pattern>, 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(parse_pattern)
.collect()
}
fn parse_pattern(line: &str) -> Result<Pattern, Error> {
let refuse = |why: &str| {
Error::Config(format!(
".shepignore pattern {line:?} {why} - a .shepignore pattern is a bare name \
(matches at any depth) or a path containing `/` (anchored to the checkout root), \
nothing else"
))
};
if line.contains(['*', '?', '[']) {
return Err(refuse(
"uses glob syntax (`*`, `?`, `[`), which is not supported",
));
}
if line.starts_with('!') {
return Err(refuse(
"is a negation, which is not supported: everything is shared unless named here. A \
name that really begins with `!` is written `\\!name`, as in .gitignore",
));
}
let unescaped;
let line = match line.strip_prefix("\\!") {
Some(rest) => {
unescaped = format!("!{rest}");
unescaped.as_str()
}
None => line,
};
if line.contains('\\') {
return Err(refuse(
"carries a backslash; the only escape this file reads is a leading `\\!`",
));
}
let anchored_by_slash = line.starts_with('/');
let mut body = line;
loop {
let stripped = body.trim_start_matches('/').trim_start_matches("./");
if stripped == body {
break;
}
body = stripped;
}
let body = body.trim_end_matches('/');
if body.is_empty()
|| Path::new(body)
.components()
.any(|component| component == Component::CurDir)
{
return Err(refuse("names nothing"));
}
if Path::new(body)
.components()
.any(|component| component == Component::ParentDir)
{
return Err(refuse(
"climbs out of the checkout with `..`, so it cannot name anything in it",
));
}
if anchored_by_slash || body.contains('/') {
Ok(Pattern::Anchored(PathBuf::from(body)))
} else {
Ok(Pattern::Anywhere(body.to_owned()))
}
}
fn pattern_matches(path: &Path, pattern: &Pattern) -> bool {
match pattern {
Pattern::Anywhere(name) => path
.components()
.any(|component| component.as_os_str() == name.as_str()),
Pattern::Anchored(prefix) => path.starts_with(prefix),
}
}
#[must_use]
pub fn resolved(path: &Path) -> PathBuf {
if let Ok(real) = fs::canonicalize(path) {
return real;
}
if let (Some(parent), Some(name)) = (path.parent(), path.file_name())
&& let Ok(real_parent) = fs::canonicalize(parent)
{
return real_parent.join(name);
}
path.to_path_buf()
}
#[must_use]
pub fn same_path(a: &Path, b: &Path) -> bool {
resolved(a) == resolved(b)
}
#[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);
if let Some(parent) = link.parent() {
fs::create_dir_all(parent).map_err(Error::at(parent))?;
}
symlink(&target, &link).or_else(|source| {
if source.kind() == io::ErrorKind::AlreadyExists {
if fs::read_link(&link).is_ok_and(|existing| existing == target) {
return Ok(());
}
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()
)));
}
Err(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.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 link_into_tests;
#[cfg(test)]
mod tests {
use crate::fixtures;
#[tokio::test]
async fn off_thread_answers_with_the_result_and_re_raises_a_panic() {
let answered = off_thread(|| Ok::<_, Error>(41 + 1))
.await
.expect("passes through");
assert_eq!(answered, 42);
let refused = off_thread(|| Err::<(), _>(Error::Config("no".to_owned())))
.await
.expect_err("passes the error through");
assert!(matches!(refused, Error::Config(_)));
let caught = std::panic::AssertUnwindSafe(off_thread(|| -> Result<(), Error> {
panic!("inside the pool");
}));
let outcome = futures_catch(caught).await;
assert!(outcome.is_err(), "the panic must come out as a panic");
}
async fn futures_catch<F: std::future::Future>(
fut: std::panic::AssertUnwindSafe<F>,
) -> Result<F::Output, Box<dyn std::any::Any + Send>> {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Catch<F>(Pin<Box<F>>);
impl<F: Future> Future for Catch<F> {
type Output = Result<F::Output, Box<dyn std::any::Any + Send>>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let inner = &mut self.get_mut().0;
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
inner.as_mut().poll(cx)
})) {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(v)) => Poll::Ready(Ok(v)),
Err(payload) => Poll::Ready(Err(payload)),
}
}
}
Catch(Box::pin(fut.0)).await
}
#[test]
fn a_chatty_subprocess_is_not_mistaken_for_a_hung_one() {
let dir = tempfile::tempdir().expect("tempdir");
run_git(dir.path(), &["init", "-q", "-b", "main"]).expect("init");
run_git(dir.path(), &["config", "user.email", "t@example.invalid"]).expect("email");
run_git(dir.path(), &["config", "user.name", "t"]).expect("name");
std::fs::write(dir.path().join("big.txt"), "x".repeat(400_000)).expect("big file");
run_git(dir.path(), &["add", "big.txt"]).expect("add");
run_git(dir.path(), &["commit", "-q", "-m", "big"]).expect("commit");
let out = run_git_within(
dir.path(),
&["show", "HEAD:big.txt"],
Duration::from_secs(20),
)
.expect("a chatty command must not be mistaken for a hung one");
assert!(
out.len() > 100_000,
"the fixture must exceed a pipe buffer or this proves nothing, got {} bytes",
out.len()
);
}
#[test]
fn output_is_collected_within_the_budget_even_when_git_succeeds() {
let dir = tempfile::tempdir().expect("tempdir");
let started = Instant::now();
let _ = run_git_within(
dir.path(),
&["-c", "alias.fork=!sh -c 'sleep 8 &'", "fork"],
Duration::from_millis(600),
);
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(4),
"git exited at once; collecting its output must not wait out what it \
forked. Took {elapsed:?} against a 600ms budget"
);
}
#[test]
fn a_grandchild_cannot_extend_the_budget() {
let dir = tempfile::tempdir().expect("tempdir");
let started = Instant::now();
let err = run_git_within(
dir.path(),
&["-c", "alias.hang=!sh -c 'sleep 30 &' && sleep 30", "hang"],
Duration::from_millis(600),
)
.expect_err("must not succeed");
let elapsed = started.elapsed();
assert!(
format!("{err}").contains("no answer within"),
"must fail on the budget: {err}"
);
assert!(
elapsed < Duration::from_secs(5),
"must return near the budget, not wait out the grandchild; took {elapsed:?}"
);
}
#[test]
fn a_git_subprocess_that_never_answers_is_abandoned() {
let dir = tempfile::tempdir().expect("tempdir");
run_git(dir.path(), &["init", "-q"]).expect("init");
let err = run_git_within(
dir.path(),
&[
"fetch",
"git://10.255.255.1/x",
"+refs/heads/*:refs/heads/*",
],
Duration::from_millis(400),
)
.expect_err("an unanswered fetch must not hang");
let said = format!("{err}");
assert!(
said.contains("no answer within"),
"must fail on the budget, not on something else: {said}"
);
}
use super::*;
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 two_spellings_of_one_path_resolve_alike_even_when_the_target_is_gone() {
let root = tempfile::tempdir().expect("tempdir");
let home = root.path().join("home");
fs::create_dir_all(home.join("deploy/web")).expect("tree");
let link = root.path().join("link");
std::os::unix::fs::symlink(&home, &link).expect("a second spelling");
let current = home.join("deploy/web/current");
let through_link = link.join("deploy/web/current");
std::os::unix::fs::symlink(home.join("deploy/web/releases/gone"), ¤t)
.expect("current");
assert!(
same_path(¤t, &through_link),
"parent resolves, name kept"
);
fs::remove_file(¤t).expect("unlink");
assert!(
same_path(¤t, &through_link),
"nothing exists but the parent"
);
assert!(!same_path(¤t, &home.join("deploy/web/previous")));
assert_eq!(
resolved(Path::new("/nonexistent/anywhere/x")),
PathBuf::from("/nonexistent/anywhere/x"),
"unresolvable stays as spelled"
);
}
#[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 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![Pattern::Anywhere("dist".to_owned())]);
}
#[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 an_escaped_bang_names_the_file_and_a_lone_dot_is_refused() {
assert_eq!(
parse_pattern("\\!weird").expect("the escape is honoured"),
Pattern::Anywhere("!weird".to_owned())
);
for pattern in [".", "./.", "./", ".//", "/./"] {
let err = parse_pattern(pattern).expect_err(pattern);
assert!(
err.to_string().contains("names nothing"),
"{pattern}: {err}"
);
}
assert_eq!(
parse_pattern("./dist").expect("a leading ./ is dropped"),
Pattern::Anywhere("dist".to_owned())
);
assert_eq!(
parse_pattern("/./x").expect("a leading /./ is an anchor"),
Pattern::Anchored(PathBuf::from("x"))
);
assert_eq!(
parse_pattern(".//x").expect("stripped to a fixed point"),
Pattern::Anywhere("x".to_owned())
);
let err = parse_pattern("\\#name").expect_err("an escape this file does not read");
assert!(err.to_string().contains("backslash"), "{err}");
}
#[test]
fn printable_replaces_everything_that_can_forge_a_line() {
let shown = printable("a\u{1b}[2Jb\u{202e}c\u{200b}d\ne\u{061c}f\u{e0041}g");
assert_eq!(shown, "a?[2Jb?c?d?e?f?g");
assert_eq!(printable("plain/name.txt"), "plain/name.txt");
}
#[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 a_negation_a_parent_component_and_a_bare_slash_are_refused_by_name() {
for pattern in ["!dist", "../sibling", "/", "./"] {
let repo = fixture_repo(&[(".gitignore", "dist/\n"), (".shepignore", pattern)]);
let err = shepignore_patterns(repo.path())
.expect_err(&format!("{pattern:?} must be refused"));
assert!(matches!(err, Error::Config(_)), "{pattern:?}: {err}");
assert!(
err.to_string().contains(pattern),
"the refusal must name the pattern: {err}"
);
}
}
#[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 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,
&FromCheckout::of(vec![PathBuf::from("target")]),
)
.expect_err("collides");
let shown = err.to_string();
assert!(shown.contains(".shepignore"), "{shown}");
assert!(shown.contains("target"), "{shown}");
}
}