mirror of
https://github.com/martinvonz/jj.git
synced 2025-02-01 00:50:57 +00:00
a67aa08995
This partially reverts changes in a9f489ccdf
"Switch to ignore crate for
gitignore handling." Since child ignore object no longer needs to access the
root to resolve the prefix path, it's simpler to store a matcher per node.
418 lines
14 KiB
Rust
418 lines
14 KiB
Rust
// Copyright 2021 The Jujutsu Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
#![allow(missing_docs)]
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::{fs, io, iter};
|
|
|
|
use ignore::gitignore;
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum GitIgnoreError {
|
|
#[error("Failed to read ignore patterns from file {path}")]
|
|
ReadFile { path: PathBuf, source: io::Error },
|
|
#[error("invalid UTF-8 for ignore pattern in {path} on line #{line_num_for_display}: {line}")]
|
|
InvalidUtf8 {
|
|
path: PathBuf,
|
|
line_num_for_display: usize,
|
|
line: String,
|
|
source: std::str::Utf8Error,
|
|
},
|
|
#[error(transparent)]
|
|
Underlying(#[from] ignore::Error),
|
|
}
|
|
|
|
/// Models the effective contents of multiple .gitignore files.
|
|
#[derive(Debug)]
|
|
pub struct GitIgnoreFile {
|
|
parent: Option<Arc<GitIgnoreFile>>,
|
|
matcher: gitignore::Gitignore,
|
|
}
|
|
|
|
impl GitIgnoreFile {
|
|
pub fn empty() -> Arc<GitIgnoreFile> {
|
|
Arc::new(GitIgnoreFile {
|
|
parent: None,
|
|
matcher: gitignore::Gitignore::empty(),
|
|
})
|
|
}
|
|
|
|
/// Concatenates new `.gitignore` content at the `prefix` directory.
|
|
///
|
|
/// The `prefix` should be a slash-separated path relative to the workspace
|
|
/// root.
|
|
pub fn chain(
|
|
self: &Arc<GitIgnoreFile>,
|
|
prefix: &str,
|
|
input: &[u8],
|
|
) -> Result<Arc<GitIgnoreFile>, GitIgnoreError> {
|
|
let mut builder = gitignore::GitignoreBuilder::new(prefix);
|
|
for (i, input_line) in input.split(|b| *b == b'\n').enumerate() {
|
|
let line =
|
|
std::str::from_utf8(input_line).map_err(|err| GitIgnoreError::InvalidUtf8 {
|
|
path: PathBuf::from(prefix),
|
|
line_num_for_display: i + 1,
|
|
line: String::from_utf8_lossy(input_line).to_string(),
|
|
source: err,
|
|
})?;
|
|
// FIXME: do we need to provide the `from` argument? Is it for providing
|
|
// diagnostics or correctness?
|
|
builder.add_line(None, line)?;
|
|
}
|
|
let matcher = builder.build()?;
|
|
let parent = if self.matcher.is_empty() {
|
|
self.parent.clone() // omit the empty root
|
|
} else {
|
|
Some(self.clone())
|
|
};
|
|
Ok(Arc::new(GitIgnoreFile { parent, matcher }))
|
|
}
|
|
|
|
/// Concatenates new `.gitignore` file at the `prefix` directory.
|
|
///
|
|
/// The `prefix` should be a slash-separated path relative to the workspace
|
|
/// root.
|
|
pub fn chain_with_file(
|
|
self: &Arc<GitIgnoreFile>,
|
|
prefix: &str,
|
|
file: PathBuf,
|
|
) -> Result<Arc<GitIgnoreFile>, GitIgnoreError> {
|
|
if file.is_file() {
|
|
let buf = fs::read(&file).map_err(|err| GitIgnoreError::ReadFile {
|
|
path: file.clone(),
|
|
source: err,
|
|
})?;
|
|
self.chain(prefix, &buf)
|
|
} else {
|
|
Ok(self.clone())
|
|
}
|
|
}
|
|
|
|
fn matches_helper(&self, path: &str, is_dir: bool) -> bool {
|
|
iter::successors(Some(self), |file| file.parent.as_deref())
|
|
.find_map(|file| {
|
|
// TODO: the documentation warns that
|
|
// `matched_path_or_any_parents` is slower than `matched`;
|
|
// ideally, we would switch to that.
|
|
match file.matcher.matched_path_or_any_parents(path, is_dir) {
|
|
ignore::Match::None => None,
|
|
ignore::Match::Ignore(_) => Some(true),
|
|
ignore::Match::Whitelist(_) => Some(false),
|
|
}
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Returns whether specified path (not just file!) should be ignored. This
|
|
/// method does not directly define which files should not be tracked in
|
|
/// the repository. Instead, it performs a simple matching against the
|
|
/// last applicable .gitignore line. The effective set of paths
|
|
/// ignored in the repository should take into account that all (untracked)
|
|
/// files within a ignored directory should be ignored unconditionally.
|
|
/// The code in this file does not take that into account.
|
|
pub fn matches(&self, path: &str) -> bool {
|
|
//If path ends with slash, consider it as a directory.
|
|
let (path, is_dir) = match path.strip_suffix('/') {
|
|
Some(path) => (path, true),
|
|
None => (path, false),
|
|
};
|
|
self.matches_helper(path, is_dir)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
use super::*;
|
|
|
|
fn matches(input: &[u8], path: &str) -> bool {
|
|
let file = GitIgnoreFile::empty().chain("", input).unwrap();
|
|
file.matches(path)
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_empty_file() {
|
|
let file = GitIgnoreFile::empty();
|
|
assert!(!file.matches("foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_empty_file_with_prefix() {
|
|
let file = GitIgnoreFile::empty().chain("dir/", b"").unwrap();
|
|
assert!(!file.matches("dir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_literal() {
|
|
let file = GitIgnoreFile::empty().chain("", b"foo\n").unwrap();
|
|
assert!(file.matches("foo"));
|
|
assert!(file.matches("dir/foo"));
|
|
assert!(file.matches("dir/subdir/foo"));
|
|
assert!(!file.matches("food"));
|
|
assert!(!file.matches("dir/food"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_literal_with_prefix() {
|
|
let file = GitIgnoreFile::empty().chain("./dir/", b"foo\n").unwrap();
|
|
assert!(file.matches("dir/foo"));
|
|
assert!(file.matches("dir/subdir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_pattern_same_as_prefix() {
|
|
let file = GitIgnoreFile::empty().chain("dir/", b"dir\n").unwrap();
|
|
assert!(file.matches("dir/dir"));
|
|
// We don't want the "dir" pattern to apply to the parent directory
|
|
assert!(!file.matches("dir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_rooted_literal() {
|
|
let file = GitIgnoreFile::empty().chain("", b"/foo\n").unwrap();
|
|
assert!(file.matches("foo"));
|
|
assert!(!file.matches("dir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_rooted_literal_with_prefix() {
|
|
let file = GitIgnoreFile::empty().chain("dir/", b"/foo\n").unwrap();
|
|
assert!(file.matches("dir/foo"));
|
|
assert!(!file.matches("dir/subdir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_deep_dir() {
|
|
let file = GitIgnoreFile::empty()
|
|
.chain("", b"/dir1/dir2/dir3\n")
|
|
.unwrap();
|
|
assert!(!file.matches("foo"));
|
|
assert!(!file.matches("dir1/foo"));
|
|
assert!(!file.matches("dir1/dir2/foo"));
|
|
assert!(file.matches("dir1/dir2/dir3/foo"));
|
|
assert!(file.matches("dir1/dir2/dir3/dir4/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_deep_dir_chained() {
|
|
// Prefix is relative to root, not to parent file
|
|
let file = GitIgnoreFile::empty()
|
|
.chain("", b"/dummy\n")
|
|
.unwrap()
|
|
.chain("dir1/", b"/dummy\n")
|
|
.unwrap()
|
|
.chain("dir1/dir2/", b"/dir3\n")
|
|
.unwrap();
|
|
assert!(!file.matches("foo"));
|
|
assert!(!file.matches("dir1/foo"));
|
|
assert!(!file.matches("dir1/dir2/foo"));
|
|
assert!(file.matches("dir1/dir2/dir3/foo"));
|
|
assert!(file.matches("dir1/dir2/dir3/dir4/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_match_only_dir() {
|
|
let file = GitIgnoreFile::empty().chain("", b"/dir/\n").unwrap();
|
|
assert!(!file.matches("dir"));
|
|
assert!(file.matches("dir/foo"));
|
|
assert!(file.matches("dir/subdir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_unusual_symbols() {
|
|
assert!(matches(b"\\*\n", "*"));
|
|
assert!(!matches(b"\\*\n", "foo"));
|
|
assert!(matches(b"\\!\n", "!"));
|
|
assert!(matches(b"\\?\n", "?"));
|
|
assert!(!matches(b"\\?\n", "x"));
|
|
assert!(matches(b"\\w\n", "w"));
|
|
assert!(GitIgnoreFile::empty().chain("", b"\\\n").is_err());
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(not(target_os = "windows"))]
|
|
fn teest_gitignore_backslash_path() {
|
|
assert!(!matches(b"/foo/bar", "/foo\\bar"));
|
|
assert!(!matches(b"/foo/bar", "/foo/bar\\"));
|
|
|
|
assert!(!matches(b"/foo/bar/", "/foo\\bar/"));
|
|
assert!(!matches(b"/foo/bar/", "/foo\\bar\\/"));
|
|
|
|
// Invalid escapes are treated like literal backslashes
|
|
assert!(!matches(b"\\w\n", "\\w"));
|
|
assert!(matches(b"\\\\ \n", "\\ "));
|
|
assert!(matches(b"\\\\\\ \n", "\\ "));
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(target_os = "windows")]
|
|
/// ignore crate consider backslashes as a directory divider only on
|
|
/// Windows.
|
|
fn teest_gitignore_backslash_path() {
|
|
assert!(matches(b"/foo/bar", "/foo\\bar"));
|
|
assert!(matches(b"/foo/bar", "/foo/bar\\"));
|
|
|
|
assert!(matches(b"/foo/bar/", "/foo\\bar/"));
|
|
assert!(matches(b"/foo/bar/", "/foo\\bar\\/"));
|
|
|
|
assert!(matches(b"\\w\n", "\\w"));
|
|
assert!(!matches(b"\\\\ \n", "\\ "));
|
|
assert!(!matches(b"\\\\\\ \n", "\\ "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_whitespace() {
|
|
assert!(!matches(b" \n", " "));
|
|
assert!(matches(b"\\ \n", " "));
|
|
assert!(!matches(b"\\\\ \n", " "));
|
|
assert!(matches(b" a\n", " a"));
|
|
assert!(matches(b"a b\n", "a b"));
|
|
assert!(matches(b"a b \n", "a b"));
|
|
assert!(!matches(b"a b \n", "a b "));
|
|
assert!(matches(b"a b\\ \\ \n", "a b "));
|
|
// Trail CRs at EOL is ignored
|
|
assert!(matches(b"a\r\n", "a"));
|
|
assert!(!matches(b"a\r\n", "a\r"));
|
|
assert!(!matches(b"a\r\r\n", "a\r"));
|
|
assert!(matches(b"a\r\r\n", "a"));
|
|
assert!(!matches(b"a\r\r\n", "a\r\r"));
|
|
assert!(matches(b"a\r\r\n", "a"));
|
|
assert!(matches(b"\ra\n", "\ra"));
|
|
assert!(!matches(b"\ra\n", "a"));
|
|
assert!(GitIgnoreFile::empty().chain("", b"a b \\ \n").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_glob() {
|
|
assert!(!matches(b"*.o\n", "foo"));
|
|
assert!(matches(b"*.o\n", "foo.o"));
|
|
assert!(!matches(b"foo.?\n", "foo"));
|
|
assert!(!matches(b"foo.?\n", "foo."));
|
|
assert!(matches(b"foo.?\n", "foo.o"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_range() {
|
|
assert!(!matches(b"foo.[az]\n", "foo"));
|
|
assert!(matches(b"foo.[az]\n", "foo.a"));
|
|
assert!(!matches(b"foo.[az]\n", "foo.g"));
|
|
assert!(matches(b"foo.[az]\n", "foo.z"));
|
|
assert!(!matches(b"foo.[a-z]\n", "foo"));
|
|
assert!(matches(b"foo.[a-z]\n", "foo.a"));
|
|
assert!(matches(b"foo.[a-z]\n", "foo.g"));
|
|
assert!(matches(b"foo.[a-z]\n", "foo.z"));
|
|
assert!(matches(b"foo.[0-9a-fA-F]\n", "foo.5"));
|
|
assert!(matches(b"foo.[0-9a-fA-F]\n", "foo.c"));
|
|
assert!(matches(b"foo.[0-9a-fA-F]\n", "foo.E"));
|
|
assert!(!matches(b"foo.[0-9a-fA-F]\n", "foo._"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_leading_dir_glob() {
|
|
assert!(matches(b"**/foo\n", "foo"));
|
|
assert!(matches(b"**/foo\n", "dir1/dir2/foo"));
|
|
assert!(matches(b"**/foo\n", "foo/file"));
|
|
assert!(matches(b"**/dir/foo\n", "dir/foo"));
|
|
assert!(matches(b"**/dir/foo\n", "dir1/dir2/dir/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_leading_dir_glob_with_prefix() {
|
|
let file = GitIgnoreFile::empty()
|
|
.chain("dir1/dir2/", b"**/foo\n")
|
|
.unwrap();
|
|
assert!(file.matches("dir1/dir2/foo"));
|
|
assert!(!file.matches("dir1/dir2/bar"));
|
|
assert!(file.matches("dir1/dir2/sub1/sub2/foo"));
|
|
assert!(!file.matches("dir1/dir2/sub1/sub2/bar"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_trailing_dir_glob() {
|
|
assert!(!matches(b"abc/**\n", "abc"));
|
|
assert!(matches(b"abc/**\n", "abc/file"));
|
|
assert!(matches(b"abc/**\n", "abc/dir/file"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_internal_dir_glob() {
|
|
assert!(matches(b"a/**/b\n", "a/b"));
|
|
assert!(matches(b"a/**/b\n", "a/x/b"));
|
|
assert!(matches(b"a/**/b\n", "a/x/y/b"));
|
|
assert!(!matches(b"a/**/b\n", "ax/y/b"));
|
|
assert!(!matches(b"a/**/b\n", "a/x/yb"));
|
|
assert!(!matches(b"a/**/b\n", "ab"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_internal_dir_glob_not_really() {
|
|
assert!(!matches(b"a/x**y/b\n", "a/b"));
|
|
assert!(matches(b"a/x**y/b\n", "a/xy/b"));
|
|
assert!(matches(b"a/x**y/b\n", "a/xzzzy/b"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_line_ordering() {
|
|
assert!(matches(b"foo\n!foo/bar\n", "foo"));
|
|
assert!(!matches(b"foo\n!foo/bar\n", "foo/bar"));
|
|
assert!(matches(b"foo\n!foo/bar\n", "foo/baz"));
|
|
assert!(matches(b"foo\n!foo/bar\nfoo/bar/baz", "foo"));
|
|
assert!(!matches(b"foo\n!foo/bar\nfoo/bar/baz", "foo/bar"));
|
|
assert!(matches(b"foo\n!foo/bar\nfoo/bar/baz", "foo/bar/baz"));
|
|
assert!(!matches(b"foo\n!foo/bar\nfoo/bar/baz", "foo/bar/quux"));
|
|
assert!(!matches(b"foo/*\n!foo/bar", "foo/bar"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_file_ordering() {
|
|
let file1 = GitIgnoreFile::empty().chain("", b"/foo\n").unwrap();
|
|
let file2 = file1.chain("foo/", b"!/bar").unwrap();
|
|
let file3 = file2.chain("foo/bar/", b"/baz").unwrap();
|
|
assert!(file1.matches("foo"));
|
|
assert!(file1.matches("foo/bar"));
|
|
assert!(!file2.matches("foo/bar"));
|
|
assert!(!file2.matches("foo/bar/baz"));
|
|
assert!(file2.matches("foo/baz"));
|
|
assert!(file3.matches("foo/bar/baz"));
|
|
assert!(!file3.matches("foo/bar/qux"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_negative_parent_directory() {
|
|
// The following script shows that Git ignores the file:
|
|
//
|
|
// ```bash
|
|
// $ rm -rf test-repo && \
|
|
// git init test-repo &>/dev/null && \
|
|
// cd test-repo && \
|
|
// printf 'A/B.*\n!/A/\n' >.gitignore && \
|
|
// mkdir A && \
|
|
// touch A/B.ext && \
|
|
// git check-ignore A/B.ext
|
|
// A/B.ext
|
|
// ```
|
|
let ignore = GitIgnoreFile::empty()
|
|
.chain("", b"foo/bar.*\n!/foo/\n")
|
|
.unwrap();
|
|
assert!(ignore.matches("foo/bar.ext"));
|
|
|
|
let ignore = GitIgnoreFile::empty()
|
|
.chain("", b"!/foo/\nfoo/bar.*\n")
|
|
.unwrap();
|
|
assert!(ignore.matches("foo/bar.ext"));
|
|
}
|
|
}
|